[
  {
    "path": ".cfnlintrc.yaml",
    "content": "# The W2001 check is used to ignore the featurebranch.yaml DataLoadStackName parameter in the nested\n# stack fargate-featurebranch.yaml used to signal when the DataLoadHost UserData commands are complete.\n# Check if Parameters are Used\nignore_checks:\n    - W2001\n"
  },
  {
    "path": ".dockerignore",
    "content": "node_modules\nstatic-files\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: Bug report\nabout: Create a report to help us improve\n---\n\n**What behavior did you observe? Please describe the bug**\nA clear and concise description of what you experienced.\n\n**How can we reproduce the bug?**\nSteps to reproduce the behavior:\n\n1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error\n\n**What is the expected behavior?**\nA clear and concise description of what you expected to happen.\n\n**Got screenshots? This helps us identify the issue**\nAdd screenshots to help explain your problem.\n\n**Desktop (please complete the following information):**\n\n-   OS: [e.g. iOS]\n-   Browser [e.g. chrome, safari]\n\n**Additional context**\nAdd any other context about the problem here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: Feature request\nabout: Suggest an idea for this project\n---\n\n**User story/persona**\nAs {a user}, I want to {action} so that I can {goal}\n\n**Is your feature request related to a problem? Please describe.**\nA clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n\n**Additional context**\nAdd any other context or screenshots about the feature request here.\n\n**Acceptance Criteria**\nAdd a list items this new feature needs to meet. Ex: The user would not be able to submit a form if all the mandatory fields are not entered.\n\n**Acceptance Test:**\nAdd a list of steps to test for a user to check if functionality satisfies the acceptance criteria.\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file\n\nversion: 2\nupdates:\n    - package-ecosystem: 'github-actions' # See documentation for possible values\n      directory: '/workflows' # Location of package manifests\n      schedule:\n          interval: 'weekly'\n    - package-ecosystem: 'npm' # See documentation for possible values\n      directory: '/' # Location of package manifests\n      schedule:\n          interval: 'daily'\n    - package-ecosystem: 'pip' # See documentation for possible values\n      directory: '/' # Location of package manifests\n      schedule:\n          interval: 'daily'\n"
  },
  {
    "path": ".github/workflows/black.yml",
    "content": "name: Lint\n\non:\n    workflow_dispatch:\n    pull_request:\n        branches: [main, 'feature-*', release]\n        paths-ignore:\n            - docs/**\n            - README.md\n            - .github/**\n            - cloudformation/**\n            - db_scripts/**\n            - jenkins/**\n            - search-proxy/**\n            - postgresql/**\n\njobs:\n    lint:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v6\n            - uses: actions/setup-python@v6\n              with:\n                  python-version: '3.12'\n            - uses: psf/black@stable\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: 'Build'\n\non:\n    workflow_dispatch:\n    pull_request:\n        branches: [main, 'feature-*', release]\n        paths-ignore:\n            - docs/**\n            - README.md\n            - .github/**\n            - cloudformation/**\n            - db_scripts/**\n            - jenkins/**\n            - search-proxy/**\n            - postgresql/**\n\njobs:\n    build:\n        name: Build\n        runs-on: ubuntu-latest\n\n        steps:\n            - name: Install system packages\n              run: |\n                  sudo apt-get update -qy && sudo apt-get dist-upgrade -qy && sudo apt-get install -qy \\\n                  libmemcached-dev libz-dev libfreetype6-dev libtiff-dev \\\n                  libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev libpq-dev\n\n            - name: Install node and npm\n              uses: actions/setup-node@v6\n              with:\n                  node-version: '20'\n\n            - name: Checkout repository\n              uses: actions/checkout@v6\n\n            - name: Set up Python 3.12\n              uses: actions/setup-python@v6\n              with:\n                  # Semantic version range syntax or exact version of a Python version\n                  python-version: '3.12'\n                  # Optional - x64 or x86 architecture, defaults to x64\n                  architecture: 'x64'\n\n            - name: Display Python version\n              run: python -c \"import sys; print(sys.version)\"\n\n            - name: build containers\n              run: |\n                  docker build -t concordia .\n                  docker build -t concordia/importer --file importer/Dockerfile .\n                  docker build -t concordia/celerybeat --file celerybeat/Dockerfile .\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "name: 'CodeQL Advanced'\n\non:\n    workflow_dispatch:\n    push:\n        branches: [main, 'feature-*']\n    pull_request:\n        branches: [main, 'feature-*', release]\n        paths-ignore:\n            - docs/**\n            - README.md\n            - cloudformation/**\n            - db_scripts/**\n            - jenkins/**\n            - search-proxy/**\n            - postgresql/**\n    schedule:\n        - cron: '20 23 * * 2'\n\njobs:\n    analyze:\n        name: Analyze (${{ matrix.language }})\n        runs-on: ubuntu-latest\n\n        permissions:\n            actions: read\n            contents: read\n            security-events: write\n            packages: read\n\n        strategy:\n            fail-fast: false\n            matrix:\n                include:\n                    - language: javascript-typescript\n                      build-mode: none\n                    - language: python\n                      build-mode: none\n\n        steps:\n            - name: Install system packages\n              run: |\n                  sudo apt-get update -qy && sudo apt-get dist-upgrade -qy && sudo apt-get install -qy \\\n                  libmemcached-dev libz-dev libfreetype6-dev libtiff-dev \\\n                  libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev libpq-dev\n\n            - name: Checkout repository\n              uses: actions/checkout@v6\n\n            - if: matrix.language == 'python'\n              name: Setup python\n              uses: actions/setup-python@v6\n              with:\n                  python-version: '3.12'\n\n            # Initializes the CodeQL tools for scanning.\n            - name: Initialize CodeQL\n              uses: github/codeql-action/init@v4\n              with:\n                  languages: ${{ matrix.language }}\n                  build-mode: ${{ matrix.build-mode }}\n\n            - if: matrix.language == 'python'\n              run: |\n                  pip install -U packaging\n                  pip install -U setuptools\n                  pip install pipenv\n                  pipenv install --dev --deploy\n\n            - name: Perform CodeQL Analysis\n              uses: github/codeql-action/analyze@v4\n              with:\n                  category: '/language:${{matrix.language}}'\n"
  },
  {
    "path": ".github/workflows/db_ops.yml",
    "content": "name: DB Operations Multi-Repo Pipeline\n\non:\n    workflow_dispatch:\n        inputs:\n            action_type:\n                description: 'Action'\n                required: true\n                default: 'build_test'\n                type: choice\n                options:\n                    - build_test\n                    - promote_to_latest\n            operation:\n                description: 'Operation'\n                required: true\n                default: 'dump'\n                type: choice\n                options:\n                    - dump\n                    - restore\n\nenv:\n    AWS_REGION: us-east-1\n    # Mapping the operation to the specific ECR Repo Name\n    DUMP_REPO: crowd-db-dump\n    RESTORE_REPO: crowd-db-restore\n\njobs:\n    process:\n        runs-on: ubuntu-latest\n        steps:\n            - name: Checkout Code\n              uses: actions/checkout@v6\n\n            - name: Configure AWS Credentials\n              uses: aws-actions/configure-aws-credentials@v6\n              with:\n                  aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n                  aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n                  aws-region: ${{ env.AWS_REGION }}\n                  role-session-name: github_to_aws_deploy\n\n            - name: Login to Amazon ECR\n              id: login-ecr\n              uses: aws-actions/amazon-ecr-login@v2\n\n            # LOGIC: Determine Repo Name and Docker Stage Target\n            - name: Set Variables\n              id: vars\n              run: |\n                  if [[ \"${{ github.event.inputs.operation }}\" == \"dump\" ]]; then\n                    echo \"REPO_NAME=${{ env.DUMP_REPO }}\" >> $GITHUB_OUTPUT\n                    echo \"STAGE_TARGET=dump\" >> $GITHUB_OUTPUT\n                  else\n                    echo \"REPO_NAME=${{ env.RESTORE_REPO }}\" >> $GITHUB_OUTPUT\n                    echo \"STAGE_TARGET=restore\" >> $GITHUB_OUTPUT\n                  fi\n\n            # ACTION 1: BUILD AND PUSH 'test'\n            - name: Build and Push Test\n              if: ${{ github.event.inputs.action_type == 'build_test' }}\n              uses: docker/build-push-action@v7\n              with:\n                  # context: defines where the 'COPY' commands look for files\n                  context: ./db_scripts\n                  # file: path to the actual Dockerfile relative to repo root\n                  file: ./db_scripts/Dockerfile\n                  # target: tells Docker to stop at the 'dump' or 'restore' stage\n                  target: ${{ steps.vars.outputs.STAGE_TARGET }}\n                  push: true\n                  tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.vars.outputs.REPO_NAME }}:test\n\n            # ACTION 2: PROMOTE 'test' to 'latest'\n            - name: Promote Test to Latest\n              if: ${{ github.event.inputs.action_type == 'promote_to_latest' }}\n              run: |\n                  REPO=${{ steps.vars.outputs.REPO_NAME }}\n\n                  MANIFEST=$(aws ecr batch-get-image \\\n                    --repository-name $REPO \\\n                    --image-ids imageTag=test \\\n                    --query 'images[0].imageManifest' \\\n                    --output text)\n\n                  aws ecr put-image \\\n                    --repository-name $REPO \\\n                    --image-tag latest \\\n                    --image-manifest \"$MANIFEST\"\n"
  },
  {
    "path": ".github/workflows/dev-main-deploy.yml",
    "content": "name: 'Deploy to dev'\n\non:\n    workflow_dispatch:\n    push:\n        branches: [main]\n        paths-ignore:\n            - docs/**\n            - README.md\n            - .github/**\n            - cloudformation/**\n            - db_scripts/**\n            - jenkins/**\n            - search-proxy/**\n            - postgresql/**\n            - cloudformation/tests/**\n            - concordia/tests/**\n            - exporter/tests/**\n            - importer/tests/**\n\nenv:\n    AWS_REGION: us-east-1\n\npermissions:\n    id-token: write\n    contents: read\n\njobs:\n    deploy:\n        name: Deploy to Dev\n        runs-on: ubuntu-latest\n        environment:\n            name: development\n\n        steps:\n            - name: Install system packages\n              run: |\n                  sudo apt-get update -qy && sudo apt-get dist-upgrade -qy && sudo apt-get install -qy \\\n                  libmemcached-dev libz-dev libfreetype6-dev libtiff-dev \\\n                  libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev libpq-dev\n\n            - name: Install node and npm\n              uses: actions/setup-node@v6\n              with:\n                  node-version: '20'\n\n            - name: Checkout repository\n              uses: actions/checkout@v6\n              with:\n                  fetch-depth: 0\n                  fetch-tags: 'true'\n\n            - name: Set up Python 3.12\n              uses: actions/setup-python@v6\n              with:\n                  # Semantic version range syntax or exact version of a Python version\n                  python-version: '3.12'\n                  # Optional - x64 or x86 architecture, defaults to x64\n                  architecture: 'x64'\n\n            - name: Install Python Dependencies and Retrieve Version Number\n              id: python-build\n              run: |\n                  python3 -m pip install --upgrade pip\n                  pip3 install -U setuptools\n                  pip3 install -U setuptools-scm\n\n                  FULL_VERSION_NUMBER=\"$(python3 -m setuptools_scm)\"\n                  echo \"version_number=$(echo \"${FULL_VERSION_NUMBER}\" | cut -d '+' -f 1)\" >> $GITHUB_ENV\n\n            - name: configure aws credentials\n              uses: aws-actions/configure-aws-credentials@v6\n              with:\n                  aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n                  aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n                  aws-region: ${{ env.AWS_REGION }}\n                  role-session-name: github_to_aws_deploy\n\n            - name: Login to Amazon ECR\n              id: login-ecr\n              uses: aws-actions/amazon-ecr-login@v2\n\n            - name: Build, tag and push docker images ECR\n              env:\n                  REGISTRY: ${{ steps.login-ecr.outputs.registry }}\n                  IMAGE_TAG: ${{ secrets.IMAGE_TAG }}\n                  CLUSTER: ${{ secrets.CLUSTER }}\n                  TARGET_SERVICE: ${{ secrets.TARGET_SERVICE }}\n              run: |\n                  docker build -t concordia .\n                  docker build -t concordia/importer --file importer/Dockerfile .\n                  docker build -t concordia/celerybeat --file celerybeat/Dockerfile .\n\n                  docker tag concordia:latest $REGISTRY/concordia:$version_number\n                  docker tag concordia:latest $REGISTRY/concordia:$IMAGE_TAG\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$version_number\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$version_number\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  docker push $REGISTRY/concordia:$version_number\n                  docker push $REGISTRY/concordia:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/importer:$version_number\n                  docker push $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/celerybeat:$version_number\n                  docker push $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE\n"
  },
  {
    "path": ".github/workflows/feature-branch-deploy.yml",
    "content": "name: 'Deploy feature branch to test'\n\non:\n    workflow_dispatch:\n    push:\n        branches: ['feature-*']\n        paths-ignore:\n            - docs/**\n            - README.md\n            - .github/**\n            - cloudformation/**\n            - db_scripts/**\n            - jenkins/**\n            - search-proxy/**\n            - postgresql/**\n\nenv:\n    AWS_REGION: us-east-1\n\npermissions:\n    id-token: write\n    contents: read\n\njobs:\n    deploy:\n        name: Deploy Feature Branch to Test\n        runs-on: ubuntu-latest\n        environment:\n            name: feature\n        steps:\n            - name: Install system packages\n              run: |\n                  sudo apt-get update -qy && sudo apt-get dist-upgrade -qy && sudo apt-get install -qy \\\n                  libmemcached-dev libz-dev libfreetype6-dev libtiff-dev \\\n                  libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev libpq-dev\n\n            - name: Install node and npm\n              uses: actions/setup-node@v6\n              with:\n                  node-version: '20'\n\n            - name: Checkout repository\n              uses: actions/checkout@v6\n              with:\n                  ref: ${{ vars.FEATURE_BRANCH }}\n                  fetch-depth: 0\n                  fetch-tags: 'true'\n\n            - name: Set up Python 3.12\n              uses: actions/setup-python@v6\n              with:\n                  # Semantic version range syntax or exact version of a Python version\n                  python-version: '3.12'\n                  # Optional - x64 or x86 architecture, defaults to x64\n                  architecture: 'x64'\n\n            - name: Install Python Dependencies and Retrieve Version Number\n              id: python-build\n              run: |\n                  python3 -m pip install --upgrade pip\n                  pip3 install -U setuptools\n                  pip3 install -U setuptools-scm\n\n                  FULL_VERSION_NUMBER=\"$(python3 -m setuptools_scm)\"\n                  echo \"version_number=$(echo \"${FULL_VERSION_NUMBER}\" | cut -d '+' -f 1)\" >> $GITHUB_ENV\n\n            - name: configure aws credentials\n              uses: aws-actions/configure-aws-credentials@v6\n              with:\n                  aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n                  aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n                  aws-region: ${{ env.AWS_REGION }}\n                  role-session-name: github_to_aws_deploy\n\n            - name: Login to Amazon ECR\n              id: login-ecr\n              uses: aws-actions/amazon-ecr-login@v2\n\n            - name: Build, tag and push docker images ECR\n              env:\n                  REGISTRY: ${{ steps.login-ecr.outputs.registry }}\n                  IMAGE_TAG: ${{ secrets.IMAGE_TAG }}\n                  CLUSTER: ${{ secrets.CLUSTER }}\n                  TARGET_SERVICE: ${{ secrets.TARGET_SERVICE }}\n              run: |\n                  docker build -t concordia .\n                  docker build -t concordia/importer --file importer/Dockerfile .\n                  docker build -t concordia/celerybeat --file celerybeat/Dockerfile .\n\n                  docker tag concordia:latest $REGISTRY/concordia:$version_number\n                  docker tag concordia:latest $REGISTRY/concordia:$IMAGE_TAG\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$version_number\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$version_number\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  docker push $REGISTRY/concordia:$version_number\n                  docker push $REGISTRY/concordia:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/importer:$version_number\n                  docker push $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/celerybeat:$version_number\n                  docker push $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE\n"
  },
  {
    "path": ".github/workflows/pip-audit.yml",
    "content": "name: pip-audit\n\non:\n    workflow_dispatch:\n    pull_request:\n        branches: [main, release]\n        paths-ignore:\n            - docs/**\n            - README.md\n            - .github/**\n            - cloudformation/**\n            - db_scripts/**\n            - jenkins/**\n            - search-proxy/**\n            - postgresql/**\n\njobs:\n    pip-audit:\n        runs-on: ubuntu-latest\n        steps:\n            - uses: actions/checkout@v6\n            - uses: actions/setup-python@v6\n              with:\n                  python-version: '3.12'\n\n            - name: 'Generate requirements.txt'\n              run: |\n                  pipx run pipfile-requirements Pipfile.lock > requirements.txt\n\n            - uses: pypa/gh-action-pip-audit@v1.1.0\n              with:\n                  inputs: requirements.txt\n                  ignore-vulns: |\n                      PYSEC-2023-312\n"
  },
  {
    "path": ".github/workflows/prod-deploy.yml",
    "content": "name: 'Deploy to production'\n\non:\n    workflow_dispatch:\n\nenv:\n    AWS_REGION: us-east-1\n\npermissions:\n    id-token: write\n    contents: read\n\njobs:\n    deploy:\n        name: Deploy to Production\n        runs-on: ubuntu-latest\n        environment:\n            name: production\n\n        steps:\n            - name: configure aws credentials\n              uses: aws-actions/configure-aws-credentials@v6\n              with:\n                  aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n                  aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n                  aws-region: ${{ env.AWS_REGION }}\n                  role-session-name: github_to_aws_deploy\n\n            - name: Login to Amazon ECR\n              id: login-ecr\n              uses: aws-actions/amazon-ecr-login@v2\n\n            - name: Pull, tag and push docker images ECR\n              env:\n                  REGISTRY: ${{ steps.login-ecr.outputs.registry }}\n                  IMAGE_TAG_PULL: ${{ secrets.IMAGE_TAG_PULL }}\n                  IMAGE_TAG: ${{ secrets.IMAGE_TAG }}\n                  CLUSTER: ${{ secrets.CLUSTER }}\n                  TARGET_SERVICE_A: ${{ secrets.TARGET_SERVICE_A }}\n                  TARGET_SERVICE: ${{ secrets.TARGET_SERVICE }}\n              run: |\n                  docker pull $REGISTRY/concordia:$IMAGE_TAG_PULL\n                  docker pull $REGISTRY/concordia/importer:$IMAGE_TAG_PULL\n                  docker pull $REGISTRY/concordia/celerybeat:$IMAGE_TAG_PULL\n\n                  docker tag $REGISTRY/concordia:$IMAGE_TAG_PULL $REGISTRY/concordia:$IMAGE_TAG\n                  docker tag $REGISTRY/concordia/importer:$IMAGE_TAG_PULL $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker tag $REGISTRY/concordia/celerybeat:$IMAGE_TAG_PULL $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  docker push $REGISTRY/concordia:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE_A\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE\n"
  },
  {
    "path": ".github/workflows/renew_coverage.yml",
    "content": "name: Renew Coverage Cache\n\non:\n    schedule:\n        - cron: '0 0 */5 * *' # Runs every 5 days at midnight UTC\n    workflow_dispatch:\n\n        # The renew_coverage.yml action is used to keep the cached release coverage value by\n        #  accessing it every five days. Normally, cached values are discarded after they're\n        #  not accessed for seven days. To avoid that, the task simply accessing the value so\n        #  it's not lost in case we have a seven-day period with no pull requests.\n\njobs:\n    renew-cache:\n        runs-on: ubuntu-latest\n        steps:\n            - name: Access Coverage Cache to Renew Expiration\n              uses: actions/cache@v5\n              with:\n                  path: coverage.txt\n                  key: release-coverage\n                  restore-keys: |\n                      release-coverage\n"
  },
  {
    "path": ".github/workflows/stage-hotfix-rel-deploy.yml",
    "content": "name: 'Deploy hotfix to stage'\n\non:\n    workflow_dispatch:\n\nenv:\n    AWS_REGION: us-east-1\n\npermissions:\n    id-token: write\n    contents: read\n\njobs:\n    deploy:\n        name: Deploy Release to Stage\n        runs-on: ubuntu-latest\n        environment:\n            name: stage\n\n        steps:\n            - name: Install system packages\n              run: |\n                  sudo apt-get update -qy && sudo apt-get dist-upgrade -qy && sudo apt-get install -qy \\\n                  libmemcached-dev libz-dev libfreetype6-dev libtiff-dev \\\n                  libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev libpq-dev\n\n            - name: Install node and npm\n              uses: actions/setup-node@v6\n              with:\n                  node-version: '20'\n\n            - name: Checkout repository\n              uses: actions/checkout@v6\n              with:\n                  ref: release\n                  fetch-depth: 0\n                  fetch-tags: 'true'\n\n            - name: Set up Python 3.12\n              uses: actions/setup-python@v6\n              with:\n                  # Semantic version range syntax or exact version of a Python version\n                  python-version: '3.12'\n                  # Optional - x64 or x86 architecture, defaults to x64\n                  architecture: 'x64'\n\n            - name: Get version from Git\n              run: |\n                  # Get latest version tag number (e.g. release was tagged in GitHub for this hot fix)\n                  HOTFIX_VERSION_NUMBER=\"$(git describe --tags)\"\n                  echo \"version_number=$(echo \"${HOTFIX_VERSION_NUMBER}\" | cut -d '-' -f 1 | cut -c 2- )\" >> $GITHUB_ENV\n\n            - name: configure aws credentials\n              uses: aws-actions/configure-aws-credentials@v6\n              with:\n                  aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n                  aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n                  aws-region: ${{ env.AWS_REGION }}\n                  role-session-name: github_to_aws_deploy\n\n            - name: Login to Amazon ECR\n              id: login-ecr\n              uses: aws-actions/amazon-ecr-login@v2\n\n            - name: Build, tag and push docker images ECR\n              env:\n                  REGISTRY: ${{ steps.login-ecr.outputs.registry }}\n                  IMAGE_TAG: ${{ secrets.IMAGE_TAG }}\n                  CLUSTER: ${{ secrets.CLUSTER }}\n                  TARGET_SERVICE: ${{ secrets.TARGET_SERVICE }}\n              run: |\n                  echo \"version number: $version_number\"\n\n                  docker build -t concordia .\n                  docker build -t concordia/importer --file importer/Dockerfile .\n                  docker build -t concordia/celerybeat --file celerybeat/Dockerfile .\n\n                  docker tag concordia:latest $REGISTRY/concordia:$version_number\n                  docker tag concordia:latest $REGISTRY/concordia:$IMAGE_TAG\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$version_number\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$version_number\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  docker push $REGISTRY/concordia:$version_number\n                  docker push $REGISTRY/concordia:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/importer:$version_number\n                  docker push $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/celerybeat:$version_number\n                  docker push $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE\n"
  },
  {
    "path": ".github/workflows/stage-image-refresh.yml",
    "content": "name: 'Deploy image refresh to stage'\n\non:\n    workflow_dispatch:\n\nenv:\n    AWS_REGION: us-east-1\n\npermissions:\n    id-token: write\n    contents: read\n\njobs:\n    deploy:\n        name: Deploy Container Environment Update\n        runs-on: ubuntu-latest\n        environment:\n            name: stage\n\n        steps:\n            - name: Install system packages\n              run: |\n                  sudo apt-get update -qy && sudo apt-get dist-upgrade -qy && sudo apt-get install -qy \\\n                  libmemcached-dev libz-dev libfreetype6-dev libtiff-dev \\\n                  libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev libpq-dev\n\n            - name: Install node and npm\n              uses: actions/setup-node@v6\n              with:\n                  node-version: '20'\n\n            - name: Checkout repository\n              uses: actions/checkout@v6\n              with:\n                  ref: release\n                  fetch-depth: 0\n                  fetch-tags: 'true'\n\n            - name: Set up Python 3.12\n              uses: actions/setup-python@v6\n              with:\n                  # Semantic version range syntax or exact version of a Python version\n                  python-version: '3.12'\n                  # Optional - x64 or x86 architecture, defaults to x64\n                  architecture: 'x64'\n\n            - name: Create image tags\n              run: |\n                  # Get latest version tag number (e.g. main was tagged in GitHub for this Release)\n                  FULL_VERSION_NUMBER=\"$(git describe --tags `git rev-list --tags --max-count=1`)\"\n                  echo \"version_number=$(echo \"${FULL_VERSION_NUMBER}\" | cut -c2- )\" >> $GITHUB_ENV\n\n                  # Create image tag for image being being replaced/refreshed/updated\n                  echo \"tag_stale_image=$(echo \"${FULL_VERSION_NUMBER}\" | cut -c2- )_$(date +%Y%m%dT%H%M%S)\" >> $GITHUB_ENV\n\n            - name: configure aws credentials\n              uses: aws-actions/configure-aws-credentials@v6\n              with:\n                  aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n                  aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n                  aws-region: ${{ env.AWS_REGION }}\n                  role-session-name: github_to_aws_deploy\n\n            - name: Login to Amazon ECR\n              id: login-ecr\n              uses: aws-actions/amazon-ecr-login@v2\n\n            - name: Build, tag and push docker images ECR\n              env:\n                  REGISTRY: ${{ steps.login-ecr.outputs.registry }}\n                  IMAGE_TAG: ${{ secrets.IMAGE_TAG }}\n                  CLUSTER: ${{ secrets.CLUSTER }}\n                  TARGET_SERVICE: ${{ secrets.TARGET_SERVICE }}\n              run: |\n                  docker build -t concordia .\n                  docker build -t concordia/importer --file importer/Dockerfile .\n                  docker build -t concordia/celerybeat --file celerybeat/Dockerfile .\n\n                  docker tag concordia:latest $REGISTRY/concordia:$version_number\n                  docker tag concordia:latest $REGISTRY/concordia:$IMAGE_TAG\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$version_number\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$version_number\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  docker push $REGISTRY/concordia:$version_number\n                  docker push $REGISTRY/concordia:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/importer:$version_number\n                  docker push $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/celerybeat:$version_number\n                  docker push $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE\n\n            - name: Tag existing images\n              env:\n                  IT_TAG: ${{ secrets.IT_IMAGE_TAG }}\n              run: |\n\n                  # Add a new tag to existing concordia images to preserve the history of images after final deployment\n                  # Tag concordia\n                  APP_MANIFEST=\"$(aws ecr batch-get-image --repository-name concordia --image-ids imageTag=${IT_TAG} --output json | jq --raw-output --join-output '.images[0].imageManifest')\"\n                  aws ecr put-image --repository-name concordia --image-tag $tag_stale_image --image-manifest \"$APP_MANIFEST\"\n\n                  # Tag concordia/celerybeat\n                  BEAT_MANIFEST=\"$(aws ecr batch-get-image --repository-name concordia/celerybeat --image-ids imageTag=${IT_TAG} --output json | jq --raw-output --join-output '.images[0].imageManifest')\"\n                  aws ecr put-image --repository-name concordia/celerybeat --image-tag $tag_stale_image --image-manifest \"$BEAT_MANIFEST\"\n\n                  # Tag concordia/importer\n                  IMPORT_MANIFEST=\"$(aws ecr batch-get-image --repository-name concordia/importer --image-ids imageTag=${IT_TAG} --output json | jq --raw-output --join-output '.images[0].imageManifest')\"\n                  aws ecr put-image --repository-name concordia/importer --image-tag $tag_stale_image --image-manifest \"$IMPORT_MANIFEST\"\n"
  },
  {
    "path": ".github/workflows/stage-release-deploy.yml",
    "content": "name: 'Deploy release to stage'\n\non:\n    workflow_dispatch:\n    push:\n        branches: [release]\n        paths-ignore:\n            - docs/**\n            - README.md\n            - .github/**\n            - cloudformation/**\n            - db_scripts/**\n            - jenkins/**\n            - search-proxy/**\n            - postgresql/**\n            - cloudformation/tests/**\n            - concordia/tests/**\n            - exporter/tests/**\n            - importer/tests/**\n\nenv:\n    AWS_REGION: us-east-1\n\npermissions:\n    id-token: write\n    contents: read\n\njobs:\n    deploy:\n        name: Deploy Release to Stage\n        runs-on: ubuntu-latest\n        environment:\n            name: stage\n\n        steps:\n            - name: Install system packages\n              run: |\n                  sudo apt-get update -qy && sudo apt-get dist-upgrade -qy && sudo apt-get install -qy \\\n                  libmemcached-dev libz-dev libfreetype6-dev libtiff-dev \\\n                  libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev libpq-dev\n\n            - name: Install node and npm\n              uses: actions/setup-node@v6\n              with:\n                  node-version: '20'\n\n            - name: Checkout repository\n              uses: actions/checkout@v6\n              with:\n                  ref: release\n                  fetch-depth: 0\n                  fetch-tags: 'true'\n\n            - name: Set up Python 3.12\n              uses: actions/setup-python@v6\n              with:\n                  # Semantic version range syntax or exact version of a Python version\n                  python-version: '3.12'\n                  # Optional - x64 or x86 architecture, defaults to x64\n                  architecture: 'x64'\n\n            - name: Get version from Git\n              run: |\n                  # Get latest version tag number (e.g. main was tagged in GitHub for this Release)\n                  FULL_VERSION_NUMBER=\"$(git describe --tags `git rev-list --tags --max-count=1`)\"\n                  echo \"version_number=$(echo \"${FULL_VERSION_NUMBER}\" | cut -c2- )\" >> $GITHUB_ENV\n\n            - name: configure aws credentials\n              uses: aws-actions/configure-aws-credentials@v6\n              with:\n                  aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n                  aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n                  aws-region: ${{ env.AWS_REGION }}\n                  role-session-name: github_to_aws_deploy\n\n            - name: Login to Amazon ECR\n              id: login-ecr\n              uses: aws-actions/amazon-ecr-login@v2\n\n            - name: Build, tag and push docker images ECR\n              env:\n                  REGISTRY: ${{ steps.login-ecr.outputs.registry }}\n                  IMAGE_TAG: ${{ secrets.IMAGE_TAG }}\n                  CLUSTER: ${{ secrets.CLUSTER }}\n                  TARGET_SERVICE: ${{ secrets.TARGET_SERVICE }}\n              run: |\n                  docker build -t concordia .\n                  docker build -t concordia/importer --file importer/Dockerfile .\n                  docker build -t concordia/celerybeat --file celerybeat/Dockerfile .\n\n                  docker tag concordia:latest $REGISTRY/concordia:$version_number\n                  docker tag concordia:latest $REGISTRY/concordia:$IMAGE_TAG\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$version_number\n                  docker tag concordia/importer:latest $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$version_number\n                  docker tag concordia/celerybeat:latest $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  docker push $REGISTRY/concordia:$version_number\n                  docker push $REGISTRY/concordia:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/importer:$version_number\n                  docker push $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/celerybeat:$version_number\n                  docker push $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE\n"
  },
  {
    "path": ".github/workflows/test-main-deploy.yml",
    "content": "name: 'Deploy to test'\n\non:\n    workflow_dispatch:\n\nenv:\n    AWS_REGION: us-east-1\n\npermissions:\n    id-token: write\n    contents: read\n\njobs:\n    deploy:\n        name: Deploy to Test\n        runs-on: ubuntu-latest\n        environment:\n            name: test\n\n        steps:\n            - name: configure aws credentials\n              uses: aws-actions/configure-aws-credentials@v6\n              with:\n                  aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}\n                  aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}\n                  aws-region: ${{ env.AWS_REGION }}\n                  role-session-name: github_to_aws_deploy\n\n            - name: Login to Amazon ECR\n              id: login-ecr\n              uses: aws-actions/amazon-ecr-login@v2\n\n            - name: Pull, tag and push docker images ECR\n              env:\n                  REGISTRY: ${{ steps.login-ecr.outputs.registry }}\n                  IMAGE_TAG_PULL: ${{ secrets.IMAGE_TAG_PULL }}\n                  IMAGE_TAG: ${{ secrets.IMAGE_TAG }}\n                  CLUSTER: ${{ secrets.CLUSTER }}\n                  TARGET_SERVICE: ${{ secrets.TARGET_SERVICE }}\n                  TARGET_SERVICE_B: ${{ secrets.TARGET_SERVICE_B }}\n              run: |\n                  docker pull $REGISTRY/concordia:$IMAGE_TAG_PULL\n                  docker pull $REGISTRY/concordia/importer:$IMAGE_TAG_PULL\n                  docker pull $REGISTRY/concordia/celerybeat:$IMAGE_TAG_PULL\n\n                  docker tag $REGISTRY/concordia:$IMAGE_TAG_PULL $REGISTRY/concordia:$IMAGE_TAG\n                  docker tag $REGISTRY/concordia/importer:$IMAGE_TAG_PULL $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker tag $REGISTRY/concordia/celerybeat:$IMAGE_TAG_PULL $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  docker push $REGISTRY/concordia:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/importer:$IMAGE_TAG\n                  docker push $REGISTRY/concordia/celerybeat:$IMAGE_TAG\n\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE\n                  aws ecs update-service --region ${{ env.AWS_REGION }} --force-new-deployment --cluster $CLUSTER --service $TARGET_SERVICE_B\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: Test\n\non:\n    workflow_dispatch:\n    pull_request:\n        branches: [main, 'feature-*', release]\n        paths-ignore:\n            - docs/**\n            - README.md\n            - .github/**\n            - cloudformation/**\n            - db_scripts/**\n            - jenkins/**\n            - search-proxy/**\n            - postgresql/**\n\nenv:\n    PIPENV_IGNORE_VIRTUALENVS: 1\n    DJANGO_SETTINGS_MODULE: concordia.settings_test\n\njobs:\n    test:\n        runs-on: ubuntu-latest\n\n        services:\n            # Label used to access the service container\n            postgres:\n                # Docker Hub image\n                image: postgres\n                # Provide the password for postgres\n                env:\n                    POSTGRES_DB: concordia\n                    POSTGRES_PASSWORD: postgres\n                # Set health checks to wait until postgres has started\n                options: >-\n                    --health-cmd pg_isready\n                    --health-interval 10s\n                    --health-timeout 5s\n                    --health-retries 5\n                ports:\n                    # Maps tcp port 5432 on service container to the host\n                    - 5432:5432\n\n        steps:\n            - name: Remove Firefox\n              run: sudo apt-get purge firefox\n\n            - name: Install system packages\n              run: |\n                  sudo apt-get update -qy && sudo apt-get dist-upgrade -qy && sudo apt-get install -qy \\\n                  libmemcached-dev libz-dev libfreetype6-dev libtiff-dev \\\n                  libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev libpq-dev \\\n                  tesseract-ocr tesseract-ocr-all\n\n            - name: Install node and npm\n              uses: actions/setup-node@v6\n              with:\n                  node-version: '20'\n\n            - name: Checkout repository\n              uses: actions/checkout@v6\n\n            - name: Set up Python 3.12\n              uses: actions/setup-python@v6\n              with:\n                  python-version: '3.12'\n                  architecture: 'x64'\n                  cache: 'pipenv'\n\n            - name: Display Python version\n              run: python -c \"import sys; print(sys.version)\"\n\n            - name: Install Python Dependencies\n              run: |\n                  python3 -m pip install --upgrade pip\n                  pip3 install -U packaging\n                  pip3 install -U setuptools\n                  pip3 install -U pipenv\n                  pipenv install --dev --deploy\n                  pipenv install tblib # For parallel test debugging\n\n            - name: Install Node Dependencies #and Add .bin to Path\n              run:\n                  npm install\n                  # echo \"PATH=$PWD/node_modules/.bin:$PATH\" >> $GITHUB_ENV\n\n            - name: Configure Logs\n              run: |\n                  mkdir logs\n                  touch ./logs/concordia-celery.log\n\n            - name: Bundle, Build (Vite) and Collect Static Files\n              run: |\n                  npm run build\n                  pipenv run ./manage.py collectstatic --no-input --no-post-process\n\n            # - name: Install Chrome for Testing and Set Path\n            #   run: |\n            #       chromepath=$(npx @puppeteer/browsers install chrome@latest)\n            #       chromepath=${chromepath#* }\n            #       echo \"Chrome installed at: $chromepath\"\n            #       $chromepath --version\n            #       chromepath=${chromepath%/chrome} # Remove the binary so we can add it to the PATH\n            #       # Update PATH for subsequent steps\n            #       echo \"PATH=$chromepath:$PATH\" >> $GITHUB_ENV\n\n            - name: Run Tests with Coverage\n              run: |\n                  mkdir -p coverage_report\n                  pipenv run coverage run --parallel-mode ./manage.py test --parallel auto\n                  pipenv run coverage combine  # Merge results from parallel test workers\n                  # Save full report to coverage_report/coverage.txt and just the total coverage percent to pr_coverage.txt\n                  pipenv run coverage report | tee coverage_report/coverage.txt | grep 'TOTAL' | awk '{print $6}' > pr_coverage.txt\n                  echo \"Stored PR coverage:\"\n                  cat pr_coverage.txt  # Debugging output to verify correct storage\n                  pipenv run coverage html\n                  mv htmlcov coverage_report/html  # Move HTML report into a separate directory\n              env:\n                  PGPASSWORD: postgres\n                  # The hostname used to communicate with the PostgreSQL service container\n                  POSTGRES_HOST: localhost\n                  # The default PostgreSQL port\n                  POSTGRES_PORT: 5432\n                  # COMMIT_RANGE: ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}\n\n            # Store coverage results if running on the release branch\n            - name: Store Release Coverage (if running on release branch)\n              if: github.ref == 'refs/heads/release'\n              run: cp pr_coverage.txt coverage.txt\n\n            # Cache coverage results if running on the release branch\n            - name: Cache Release Coverage (if running on release branch)\n              if: github.ref == 'refs/heads/release'\n              uses: actions/cache@v5\n              with:\n                  path: coverage.txt\n                  key: release-coverage\n\n            # Upload full coverage report as an artifact\n            - name: Upload Full Coverage Report\n              uses: actions/upload-artifact@v7\n              with:\n                  name: coverage-report\n                  path: coverage_report\n\n            # Download the stored release branch coverage for PR comparison, if it exists\n            - name: Restore Release Coverage (if running on PR)\n              if: github.event_name == 'pull_request'\n              uses: actions/cache@v5\n              with:\n                  path: coverage.txt\n                  key: release-coverage\n                  restore-keys: |\n                      release-coverage\n\n            # Compare PR coverage against stored release coverage\n            - name: Compare Coverage (if running on PR)\n              if: github.event_name == 'pull_request'\n              run: |\n                  echo \"Reading PR coverage from pr_coverage.txt...\"\n                  cat pr_coverage.txt || echo \"⚠️ ERROR: pr_coverage.txt not found or empty\"\n                  PR_COVERAGE=$(cat pr_coverage.txt)\n                  if [ -z \"$PR_COVERAGE\" ]; then\n                      echo \"⚠️ ERROR: PR_COVERAGE is empty!\"\n                      PR_COVERAGE=\"N/A\"\n                  fi\n\n                  echo \"PR Coverage: $PR_COVERAGE\"\n                  if [ -f \"coverage.txt\" ]; then\n                      RELEASE_COVERAGE=$(cat coverage.txt)\n                      COMPARISON_AVAILABLE=true\n                  else\n                      COMPARISON_AVAILABLE=false\n                      RELEASE_COVERAGE=\"N/A\"\n                  fi\n\n                  if [ \"$COMPARISON_AVAILABLE\" = true ]; then\n                      # Strip '%' from PR_COVERAGE and RELEASE_COVERAGE for numerical comparison\n                      PR_COVERAGE_NUM=${PR_COVERAGE%\\%}\n                      RELEASE_COVERAGE_NUM=${RELEASE_COVERAGE%\\%}\n                      if (( $(echo \"$PR_COVERAGE_NUM > $RELEASE_COVERAGE_NUM\" | bc -l) )); then\n                          CHANGE=\"🔼 Coverage increased (+$(echo \"$PR_COVERAGE_NUM - $RELEASE_COVERAGE_NUM\" | bc -l)%)!\"\n                      elif (( $(echo \"$PR_COVERAGE_NUM < $RELEASE_COVERAGE_NUM\" | bc -l) )); then\n                          CHANGE=\"🔽 Coverage decreased (-$(echo \"$RELEASE_COVERAGE_NUM - $PR_COVERAGE_NUM\" | bc -l)%)!\"\n                      else\n                          CHANGE=\"✅ Coverage remained the same.\"\n                      fi\n                  else\n                      CHANGE=\"⚠️ No baseline coverage available from 'release' branch.\"\n                  fi\n\n                  echo \"COVERAGE_CHANGE=$CHANGE\" >> $GITHUB_ENV\n                  printf \"RELEASE_COVERAGE=%s\\n\" \"$RELEASE_COVERAGE\" >> $GITHUB_ENV\n                  printf \"PR_COVERAGE=%s\\n\" \"$PR_COVERAGE\" >> $GITHUB_ENV\n\n            # Generate and store command for display on the Action UI and PR (if any)\n            - name: Generate Coverage Report Comment\n              run: |\n                  echo \"**🛡 Test Coverage Report 🛡**\" > coverage_comment.txt\n                  echo \"- **Current PR Coverage:** ${{ env.PR_COVERAGE }}\" >> coverage_comment.txt\n                  echo \"- **Release Branch Coverage:** ${{ env.RELEASE_COVERAGE }}\" >> coverage_comment.txt\n                  echo \"- **${{ env.COVERAGE_CHANGE }}**\" >> coverage_comment.txt\n                  echo \"- 📊 **[Download Full Coverage Report (Under \"Artifacts\")](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}#artifacts)**\" >> coverage_comment.txt\n                  echo \"\" >> coverage_comment.txt\n                  echo \"<details>\" >> coverage_comment.txt\n                  echo \"<summary>📜 Click to view full text coverage report</summary>\" >> coverage_comment.txt\n                  echo \"\" >> coverage_comment.txt\n                  echo '```text' >> coverage_comment.txt\n                  cat coverage_report/coverage.txt >> coverage_comment.txt\n                  echo '```' >> coverage_comment.txt\n                  echo \"</details>\" >> coverage_comment.txt\n\n            # Display the coverage summary in the GitHub Actions UI\n            - name: Post Coverage Summary\n              run: cat coverage_comment.txt >> $GITHUB_STEP_SUMMARY\n\n            # Post a comment on the PR with the coverage results\n            - name: Comment Coverage Change on PR\n              if: github.event_name == 'pull_request'\n              uses: mshick/add-pr-comment@v3\n              with:\n                  message-path: coverage_comment.txt\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\nbin/\ntarget/\nlocal/\nbuild/\n.project\n.classpath\n.settings/\n*.pyc\nbuildstatus.log\ndeploystatus.log\n.metadata/\nartifacts/\n/.*\n!.gitignore\n!.cfnlintrc.yaml\n!.github\n!.dockerignore\n.DS_Store\ndocs/build\nenv.ini\n.venv\n*.sqlite3\n*.egg-info/\n/temp/\n/emails/\n/logs/*\nenv-dev.ini\ndocs/_build\ndocs/modules\ndist/\nprofile_pics/\nmss*\n*.swp\nconfig-optional-override.json\nenv/\nconcordia/settings_dev_*.py\nconcordia/settings_test_*.py\nconcordia/settings_loadtest_*.py\nversion.txt\nstatic-files\n"
  },
  {
    "path": "Dockerfile",
    "content": "# Base runtime: Debian 12 (bookworm) slim + Python 3.12.\nFROM python:3.12-slim-bookworm\n\n# Major Node.js version to install (e.g., 20, 22). This is used to select the\n# NodeSource APT repository \"node_<major>.x\".\nARG NODE_MAJOR=20\n\n# Include a small \"wait for dependencies\" helper used by the container command.\n# This is downloaded at build time and placed at /wait.\n## Add the wait script to the image\nADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.2.1/wait /wait\nRUN chmod +x /wait\n\n# Prevent interactive prompts during apt operations.\nENV DEBIAN_FRONTEND=\"noninteractive\"\n\n# Bootstrap minimal tooling needed later in the build:\n# - curl: download files/keys\n# - ca-certificates: validate HTTPS endpoints\n# - gnupg: import and dearmor APT repository signing keys\nRUN apt-get update -qy && apt-get install -qy curl ca-certificates gnupg\n\n# Trust the Library's certificate authority so the HTTPS tampering proxy does\n# not break TLS validation for clients inside the container.\n#\n# This downloads the CA certificate, converts it to PEM, and refreshes the\n# OpenSSL certificate hashes so it is recognized by OpenSSL-based clients.\n# Ensure that the Library's certificate authority is trusted so the tampering\n# proxy will not break TLS validation. See\n# https://staff.loc.gov/wikis/display/SE/Configuring+HTTPS+clients+for+the+HTTPS+tampering+proxy.\nRUN curl -fso /etc/ssl/certs/LOC-ROOT-CA-1.crt http://crl.loc.gov/LOC-ROOT-CA-1.crt && openssl x509 -inform der -in /etc/ssl/certs/LOC-ROOT-CA-1.crt -outform pem -out /etc/ssl/certs/LOC-ROOT-CA-1.pem && c_rehash\n\n# Install Node.js via the NodeSource APT repository (manual setup; no setup\n# script). Debian bookworm ships Node 18; adding this repo allows installing a\n# newer major version (e.g., Node 20) via apt.\n#\n# This step:\n# - creates a dedicated keyring directory under /etc/apt/keyrings\n# - downloads and installs the NodeSource signing key into a keyring file\n# - registers the NodeSource repository for the selected Node.js major line\n#\n# Note: When installing Node.js from NodeSource, the `nodejs` package includes\n# npm (and npm comes with node-gyp), so there is no separate `npm` or\n# `node-gyp` APT package to install here.\n#\n# References: NodeSource \"Repository Manual Installation\" guide. https://github.com/nodesource/distributions/wiki/Repository-Manual-Installation\nRUN \\\n    # Create a dedicated directory for third-party APT keyrings.\n    mkdir -p /etc/apt/keyrings && \\\n    # Download the NodeSource repository signing key and store it as a keyring\n    # file that apt can use to verify NodeSource packages.\n    curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \\\n        | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \\\n    # Register the NodeSource repository for the selected Node.js major version.\n    # The \"signed-by=\" option scopes trust to just this repository entry.\n    echo \"deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main\" \\\n        > /etc/apt/sources.list.d/nodesource.list\n\n# Bring the base OS packages fully up to date, then install system dependencies\n# needed to build and run the application.\n#\n# Notes:\n# - dist-upgrade pulls in security and point-release updates for the base image.\n# - --force-confnew ensures updated config files are accepted when prompted.\n# - autoremove/autoclean reduce image size after installing packages.\nRUN apt-get update -qy && apt-get dist-upgrade -qy && apt-get install -o Dpkg::Options::='--force-confnew' -qy \\\n    build-essential \\\n    git \\\n    libmemcached-dev \\\n    # Pillow/Imaging: https://pillow.readthedocs.io/en/latest/installation.html#external-libraries\n    libz-dev libfreetype6-dev \\\n    libtiff-dev libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev \\\n    # Postgres client library to build psycopg\n    libpq-dev \\\n    locales \\\n    # Weasyprint requirements\n    libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 \\\n    # Tesseract\n    tesseract-ocr tesseract-ocr-all \\\n    # Node.js runtime (from NodeSource) and build tooling for native addons.\n    nodejs && apt-get -qy autoremove && apt-get -qy autoclean\n\n# Generate and configure a UTF-8 locale for consistent string handling.\nRUN locale-gen en_US.UTF-8\nENV LC_ALL=en_US.UTF-8\nENV LANG=en_US.UTF-8\nENV LANGUAGE=en_US.UTF-8\n\n# Python runtime settings:\n# - unbuffered output for log visibility in containers\n# - add /app to PYTHONPATH for module resolution\nENV PYTHONUNBUFFERED=1 \\\n    PYTHONPATH=/app\n\n# Default Django settings module for container runtime (can be overridden).\nENV DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-concordia.settings_docker}\n\n# Ensure an up-to-date pip and install pipenv for dependency management.\nRUN pip install --upgrade pip\nRUN pip install --no-cache-dir pipenv\n\n# Copy application code into the image.\nWORKDIR /app\nCOPY . /app\n\n# Front-end build and asset pipeline:\n# - update npm to a known major version\n# - Install all JS dependencies (including devDependencies for plugins)\nRUN npm install --silent --global npm@10 && npm install --silent\n\n# Additional JS build step for Vite.\n# - Build assets (Vite) complile scss, bundle, hash and compress js\n# - This populates concordia/static/dist with hashed and compressed files.\nRUN npm run build\n\n# Create Log Directory\n# - Required for Django logging initialization when running collecstatic.\nRUN mkdir -p /app/logs\n\n# Install Python dependencies into the system environment using Pipenv and\n# - Bake static files into the image (Fast, no post-processing)\n# - remove Pipenv cache to reduce image size.\nRUN pipenv install --system --dev --deploy && \\\n    python manage.py collectstatic --no-input --no-post-process && \\\n    rm -rf ~/.cache/\n\n# - Clean up node artifacts to reduce image size\nRUN rm -rf node_modules && rm -rf ~/.cache/\n\n# Container listens on port 80.\nEXPOSE 80\n\n# Wait for dependencies (via /wait) and then run the application entrypoint.\nCMD /wait && /bin/bash entrypoint.sh\n"
  },
  {
    "path": "LICENSE.md",
    "content": "As a work of the United States Government, this project is in the\npublic domain within the United States.\n\nAdditionally, we waive copyright and related rights in the work\nworldwide through the CC0 1.0 Universal public domain dedication.\n\n## CC0 1.0 Universal Summary\n\nThis is a human-readable summary of the\n[Legal Code (read the full text)](https://creativecommons.org/publicdomain/zero/1.0/legalcode).\n\n### No Copyright\n\nThe person who associated a work with this deed has dedicated the work to\nthe public domain by waiving all of his or her rights to the work worldwide\nunder copyright law, including all related and neighboring rights, to the\nextent allowed by law.\n\nYou can copy, modify, distribute and perform the work, even for commercial\npurposes, all without asking permission.\n\n### Other Information\n\nIn no way are the patent or trademark rights of any person affected by CC0,\nnor are the rights that other persons may have in the work or in how the\nwork is used, such as publicity or privacy rights.\n\nUnless expressly stated otherwise, the person who associated a work with\nthis deed makes no warranties about the work, and disclaims liability for\nall uses of the work, to the fullest extent permitted by applicable law.\nWhen using or citing the work, you should not imply endorsement by the\nauthor or the affirmer.\n"
  },
  {
    "path": "Loadtesting.md",
    "content": "# Load Testing Mode\n\nThis document describes the current (incomplete but runnable) \"load testing mode\"\nimplementation and how to run it end-to-end manually.\n\nLoad testing mode consists of:\n\n-   A fixture generator that builds a single JSON fixture from an existing DB\n-   A DB preparation command that creates a fresh load test DB, migrates it, and\n    loads the fixture while suppressing all Django signals\n-   A load test settings file that points the app at the load test DB and disables\n    Turnstile blocking\n-   A Locust script (`locustfile.py`) plus a wrapper shell script (`load_test.sh`)\n    to run a headless load test\n\nThe intended lifecycle is:\n\n1. Generate a fixture from a DB with real-ish data\n2. Create and populate a fresh `concordia_lt` database from that fixture\n3. Run the web app against `concordia_lt` using load test settings\n4. Run Locust against that host\n\nThe load test database is intended to be single-use.\n\n## Files\n\n-   `concordia/management/commands/create_load_test_fixtures.py`\n-   `concordia/management/commands/prepare_load_test_db.py`\n-   `concordia/settings_loadtest.py` (or your own `concordia/settings_loadtest_<name>.py`)\n-   `locustfile.py` (repo root)\n-   `load_test.sh` (repo root)\n\n## Safety notes\n\n-   `create_load_test_fixtures` is read-only against the source DB and only\n    writes a JSON file. It is safe to run against production, though it is\n    normally run against a refreshed copy of production.\n-   `prepare_load_test_db` creates and optionally drops a separate database\n    (`concordia_lt`), runs migrations, and loads fixtures into it.\n    -   It requires PostgreSQL credentials with `CREATE DATABASE` privileges.\n    -   If recreating or dropping, it terminates active connections to the target DB.\n-   During fixture load, all Django signals are suppressed to avoid side effects\n    (Celery tasks, storage writes, cache updates, derived fields, etc).\n-   Storage in load test mode is configured to use dev/staging buckets for safety.\n    The workflow is designed to avoid writes to external systems.\n-   Locust defaults to a non-production host to reduce risk.\n\n## Prerequisites\n\n-   VPN access to the target environment\n-   PostgreSQL credentials available via environment variables\n    -   The DB user must be able to connect to `dbname=postgres` and create databases\n-   Python environment with the normal dev dependencies installed (Locust is a dev\n    dependency)\n-   Ability to restart the web app with a different settings module\n-   A reachable host running the app in load test mode\n\n## Fixture contents\n\nThe fixture generated by `create_load_test_fixtures` contains:\n\n-   Up to 2 published Topics, chosen by ascending `ordering`\n-   Up to 5 published Campaigns, preferring Topic-linked Campaigns and filling with\n    additional published Campaigns by ascending `ordering`\n-   Up to `--assets-limit` Assets (default 10,000), collected by walking:\n    -   Topic-linked Projects first, then\n    -   Campaign-linked Projects if needed\n-   Closure of referenced Items, Projects, Campaigns and Topics for the chosen Assets\n-   All Transcriptions for selected Assets\n-   Anonymized fixtures for any Users referenced by those Transcriptions\n    (`user` and `reviewed_by`)\n-   A synthetic pool of test users:\n    -   Default: 10,000 users named `locusttest00001`..`locusttest10000`\n    -   All share the same password: `locustpass123`\n    -   Email: `<username>@example.test`\n    -   Users are created with explicit PKs beyond the existing fixture user PKs to\n        avoid collisions\n-   ProjectTopic rows for selected Topic+Project links (preserves the M2M)\n\nNotes:\n\n-   Selection is best-effort. If there are fewer than `--assets-limit` Assets, the\n    fixture is still written.\n-   The output is a single JSON file (default `loadtest_fixture.json`).\n-   By default, the command validates the fixture by calling `prepare_load_test_db`\n    unless `--no-validate` is provided.\n\n## Commands\n\n### 1) Create the fixture\n\nRun against a DB with real data (usually a refreshed prod copy):\n\n```bash\npython manage.py create_load_test_fixtures\n```\n\nCommon options:\n\n```bash\npython manage.py create_load_test_fixtures \\\n  --assets-limit 10000 \\\n  --test-users 10000 \\\n  --test-user-prefix locusttest \\\n  --test-user-password locustpass123 \\\n  --output loadtest_fixture.json\n```\n\nValidation options:\n\n-   `--no-validate` to skip validation\n-   `--validate-db-name NAME` to override the validation DB name\n-   `--validate-recreate` to recreate the validation DB if it exists\n-   `--validate-drop` to drop the validation DB after loading\n\n### 2) Create and populate the load test DB\n\nStandard DB name: `concordia_lt`\n\n```bash\npython manage.py prepare_load_test_db \\\n  --db-name concordia_lt \\\n  --recreate \\\n  --fixtures loadtest_fixture.json\n```\n\nBehavior:\n\n-   Creates or recreates `concordia_lt`\n-   Runs migrations\n-   Loads fixtures with all signals suppressed by default\n\n## Running the app in load test mode\n\n### Settings file\n\n`concordia/settings_loadtest.py` is an override layer on top of\n`settings_template.py`. It:\n\n-   Points the DB at `concordia_lt`\n-   Disables rate limiting\n-   Forces Turnstile to always-pass test keys by default\n-   Uses console email backend\n-   Uses dev buckets for safety\n-   Adjusts logging to be visible in common run contexts\n\nIf you need a different DB name, do not edit `settings_loadtest.py` directly.\nCreate a personal settings file, following the local dev convention:\n\n-   `concordia/settings_loadtest_<username>.py`\n-   Override `DATABASES[\"default\"][\"NAME\"]` (and any other local overrides)\n\n### Selecting settings at runtime\n\nLocal example:\n\n```bash\nDJANGO_SETTINGS_MODULE=concordia.settings_loadtest \\\n  python manage.py runserver 0.0.0.0:8000\n```\n\nServer/container example:\n\n-   Set `DJANGO_SETTINGS_MODULE=concordia.settings_loadtest`\n-   Restart/redeploy the web process so it actually uses the load test settings\n\nImportant:\n\n-   Creating `concordia_lt` does not affect any running web process.\n    You must restart the app with the load test settings selected.\n\n## Locust\n\n### Overview\n\nThe load test simulates three flows:\n\n-   Anonymous browsing/transcription page interactions\n-   Authenticated users who transcribe\n-   Authenticated users who review\n\nThe script uses these endpoints:\n\n-   `/` (homepage)\n-   `/next-transcribable-asset/` (redirect to next asset)\n-   `/next-reviewable-asset/` (redirect to next reviewable asset)\n-   `/account/login/` (login)\n-   `/account/ajax-status/` and `/account/ajax-messages/` (simulates normal page load)\n\nThe script parses asset pages to find:\n\n-   The transcription form action (`<form id=\"transcription-editor\" ...>`)\n-   Reservation endpoint (`<script id=\"asset-reservation-data\" data-reserve-asset-url=\"...\">`)\n-   Review endpoints (`data-review-url`, `data-submit-url`)\n\nIf parsing fails, it is treated as a fundamental mismatch between the Locust\nscript and the UI.\n\n### \"No work\" abort behavior\n\nThe Locust run aborts the entire test if it determines there is no work\navailable. \"No work\" is defined as either:\n\n-   A `/next-*` redirect eventually landing on `/` (homepage), or\n-   An asset page not containing the transcription form\n\nThis is controlled by:\n\n-   `ABORT_WHEN_NO_WORK = True` (default)\n-   `NO_WORK_DUMP_HTML = False` (set True to dump a debug HTML file on abort)\n\nThe abort is coordinated across master/workers in distributed mode via a custom\nmessage (`global-abort`). Locust is forced to exit with a non-zero exit code.\n\n### load_test.sh\n\n`load_test.sh` runs Locust in headless mode with defaults that can be overridden\nvia environment variables.\n\nDefaults:\n\n-   Users: 100\n-   Spawn rate: 2\n-   Run time: 1m30s\n-   Host: `https://crowd-dev.loc.gov`\n\nOverride example:\n\n```bash\nLOCUST_USERS=500 \\\nLOCUST_SPAWN_RATE=10 \\\nLOCUST_RUN_TIME=10m \\\nLOCUST_HOST=https://your-loadtest-host.example \\\n./load_test.sh\n```\n\n## End-to-end manual runbook\n\nThis is the current manual process. Nothing here is automated end-to-end yet.\n\n1. Choose the environment to test\n\n-   Typically your personal environment, dev or staging prepared from a refreshed DB copy of production\n\n2. Generate a fixture\n\n```bash\npython manage.py create_load_test_fixtures \\\n  --output loadtest_fixture.json\n```\n\nIf you want a smaller dataset for quicker iteration, lower `--assets-limit`\nand/or `--test-users`.\n\n3. Create and populate the load test DB\n\n```bash\npython manage.py prepare_load_test_db \\\n  --db-name concordia_lt \\\n  --recreate \\\n  --fixtures loadtest_fixture.json\n```\n\n4. Switch the web app to load test settings and restart it\n\n-   Set `DJANGO_SETTINGS_MODULE=concordia.settings_loadtest`\n-   Restart/redeploy the web process so it uses:\n    -   `DATABASES[\"default\"][\"NAME\"] = \"concordia_lt\"`\n    -   Turnstile always-pass test keys\n\nSanity checks:\n\n-   Visit the site and confirm pages load without Turnstile blocking.\n-   Attempt login with a known test user:\n    -   Username: `locusttest00001`\n    -   Password: `locustpass123`\n\n5. Run Locust\n\n```bash\n./load_test.sh\n```\n\nTune parameters if needed:\n\n```bash\nLOCUST_USERS=200 LOCUST_SPAWN_RATE=5 LOCUST_RUN_TIME=5m ./load_test.sh\n```\n\n6. Common failure modes\n\n-   Immediate login failures:\n    -   App not pointing at `concordia_lt`\n    -   Fixture not loaded or test users missing\n    -   Turnstile not disabled for load test mode\n-   Global abort \"no work\":\n    -   `next-*` redirects to `/` because there is no eligible work\n    -   This is likely due to running the script multiple times without refreshing DB\n-   Lots of 403s:\n    -   Turnstile still active\n    -   CSRF issues (the script attempts to seed and use CSRF correctly)\n\n7. Cleanup\n\nThere is no automated cleanup step. The DB is intended to be thrown away or\nrecreated for each run.\n\nTo recreate on the next run, rerun step (3) with `--recreate`.\n\n## Known gaps / Next development priorities\n\n-   No single \"one command\" workflow; all steps are manual.\n-   No automated mechanism to build and deploy a load-test-mode container in AWS.\n-   No automated environment switching between normal and load test settings.\n-   No automated teardown of the load test DB after a run.\n"
  },
  {
    "path": "MANIFEST.in",
    "content": "include README.md\ninclude MANIFEST.in\nrecursive-include concordia *\nrecursive-include tests *.py\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: allup firstup adminuser devup down clean\n\nfirstup:\n\tdocker-compose -f docker-compose.yml up -d\n\tadminuser\n\nadminuser:\n\tdocker-compose -f docker-compose.yml run --rm app ./manage.py shell -c \"from django.contrib.auth.models import User; User.objects.create_superuser('admin', 'crowd@loc.gov', '${CONCORDIA_ADMIN_PW}')\"\n\nallup:\n\tdocker-compose -f docker-compose.yml up -d\n\ndevup:\n\tdocker-compose -f docker-compose.yml up -d\n\ndown:\n\tdocker-compose -f docker-compose.yml down\n\nclean:\tdown\n\tdocker-compose -f docker-compose.yml down -v --remove-orphans\n\trm -rf postgresql-data/\n"
  },
  {
    "path": "Pipfile",
    "content": "[[source]]\nurl = \"https://pypi.org/simple\"\nverify_ssl = true\nname = \"pypi\"\n\n[packages]\ngunicorn = \"==23.0.0\"\ncelery = { extras = [\"redis\"], version = \"==5.5.3\" }\ndjango-tinymce = \"==4.1.0\"\nwhitenoise = \"==6.9.0\"\nopenpyxl = \"==3.1.5\"\nmarkdown = \"==3.10\"\ndjango-bootstrap5 = \"==25.2\"\ndjango-robots = \"==6.1\"\nsetuptools-scm = \"==9.2.2\"\ndjango-ratelimit = \"==4.1.0\"\npylibmc = \"==1.6.3\"\nkombu = \"==5.5.4\"\ndjango-flags = \"==5.0.14\"\nsentry-sdk = \"==2.57.0\"\nchannels = { extras = [\"daphne\"], version = \"==4.2.2\" }\nchannels-redis = \"==4.3.0\"\nmore-itertools = \"==10.7.0\"\nnh3 = \"==0.3.4\"\ndjango-admin-multiple-choice-list-filter = \"==0.1.1\"\ndjango-npm = \"==1.0.1\"\npymemcache = \"==4.0.0\"\nweasyprint = \"==68.1\"\ntesseract = \"==0.1.3\"\npytesseract = \"==0.3.13\"\ndjango-redis = \"==6.0.0\"\ntwisted = { extras = [\"http2\", \"tls\"], version = \"==25.5.0\" }\npyleniumio = \"==1.21.0\"\ndjango-maintenance-mode = \"==0.22.0\"\nxlsxwriter = \"==3.2.5\"\npsycopg2 = \"==2.9.11\"\ndjango-storages = { extras = [\"s3\"], version = \"==1.14.6\" }\ndjango-structlog = {extras = [\"celery\"], version = \"==10.0.0\"}\ndefusedxml = \"==0.7.1\"\ndjango-ninja = \"==1.4.3\"\nurllib3 = \"==2.6.3\"\nbagit = \"==1.9.0\"\ndjango-registration = \"==3.4\"\nboto3 = \"==1.39.17\"\nbotocore = \"==1.39.17\"\ncertifi = \"==2025.7.14\"\nwebsocket-client = \"<1.8.0\"\nblack = \"*\"\ndjango-vite = \"~=3.1.0\"\npyasn1 = \"~=0.6.3\"\nrequests = \"~=2.33.0\"\nhiredis = \"~=3.3.0\"\ndjango-celery-beat = \"~=2.8.1\"\nprometheus-client = \"~=0.25.0\"\naws-xray-sdk = \"~=2.15.0\"\npre-commit = \"~=4.5.1\"\ndjango = \"~=5.2.13\"\ndjango-opensearch-dsl = \"==0.8.0\"\n\n[dev-packages]\ninvoke = \"==2.2.0\"\ndjango-extensions = \"==3.2.3\"\ndjango-debug-toolbar = \"==6.3.0\"\ncoverage = \"==7.9.2\"\nlocust = \"~=2.43\"\ntblib = \"~=3.2.0\"\npre-commit = \"~=4.5.1\"\n\n[requires]\npython_version = \"3.12\"\n"
  },
  {
    "path": "README.md",
    "content": "[![Lint](https://github.com/LibraryOfCongress/concordia/actions/workflows/black.yml/badge.svg)](https://github.com/LibraryOfCongress/concordia/actions/workflows/black.yml)\n[![Test](https://github.com/LibraryOfCongress/concordia/actions/workflows/test.yml/badge.svg)](https://github.com/LibraryOfCongress/concordia/actions/workflows/test.yml)\n[![Build](https://github.com/LibraryOfCongress/concordia/actions/workflows/build.yml/badge.svg)](https://github.com/LibraryOfCongress/concordia/actions/workflows/build.yml)\n[![Coverage Status](https://coveralls.io/repos/github/LibraryOfCongress/concordia/badge.svg?branch=main)](https://coveralls.io/github/LibraryOfCongress/concordia?branch=main)\n\n# Welcome to Concordia\n\nConcordia is a platform developed by the Library of Congress (LOC) for crowdsourcing transcription and tagging of text in digitized images with the dual goals of collection enhancement and public engagement. Concordia is a user-centered project centering the principles of trust and approachability. [Read our full design principles here](https://github.com/LibraryOfCongress/concordia/blob/master/docs/design-principles.md). Learn more about the Concordia development process in [this Code4Lib article](https://journal.code4lib.org/articles/14901).\n\nLOC launched the first iteration of Concordia as [By the People at crowd.loc.gov](https://crowd.loc.gov/) in October 2018.\n\nThe Library of Congress publishes transcriptions created by By the People volunteers on [loc.gov](https://www.loc.gov/) to improve search, readability, and access to handwritten and typed documents. Individual transcriptions are published alongside the transcribed images in digital collections and transcriptions are also published in bulk as [datasets](https://www.loc.gov/search/?fa=contributor:by+the+people+%28program%29). [Learn more about how we publish transcriptions](https://blogs.loc.gov/folklife/2022/05/etl-searching-the-lomax-family-papers-through-the-magic-of-crowdsourcing/).\n\nConcordia code and the By the People transcriptions are released into the public domain. Anyone is free to use or reuse the data. [More info on our licensing page](https://github.com/LibraryOfCongress/concordia/blob/main/LICENSE.md).\n\nAs of May 2022 the Library of Congress Concordia development team has moved issues out of Github to an internal system due to reporting needs. Open github issue tickets may not be active or up-to-date. We continue to publish our code here as it is released. Learn more about [How We Work](https://github.com/LibraryOfCongress/concordia/blob/main/docs/how-we-work.md).\n\n_Concordia and By the People are supported by the National Digital Library Trust Fund._\n\n## What Concordia does\n\nThe application invites volunteers to transcribe and tag digitized images of manuscript and typed materials from the Library’s collections. All transcriptions are made by volunteers and reviewed by volunteers. It takes at least one volunteer to transcribe a page and at least one other volunteer to review and mark it complete. Some complex documents may pass through both transcription and review many times before they are accepted as complete by a volunteer.\n\nConcordia is a containerized Python-Django-Postgres-etc web application. The Library hosts its instance in the cloud.\n\nConcordia leverages the publicly-available [loc.gov API](https://libraryofcongress.github.io/data-exploration/) to call collection metadata and images in JPEG format and save copies for use in Concordia. Completed transcriptions can be exported out of the application as a single CSV or individual TXT files in a BagIt bag.\n\n## Want to use or reuse our code?\n\nFor more on our tech stack and to learn how to set up the Concordia on your computer, check out the [For Developers page](docs/for-developers.md).\n\n## Want to help?\n\nWe're excited that you want to be part of Concordia! Here are two ways to contribute:\n\n**1. Report bugs by submitting an issue.** If you are reporting a bug, please include:\n\n-   Your operating system name and version.\n-   Any details about your local setup that might be helpful in troubleshooting.\n-   Detailed steps to reproduce the bug.\n\n**2. Create an issue to give feedback or suggest a new feature.** The best way to give feedback is to file an issue at https://github.com/LibraryOfCongress/concordia/issues. If you are proposing a feature:\n\n-   Explain in detail how it would work.\n-   Explain how it would serve Concordia via a user story\n-   Keep the scope as narrow as possible, to make it easier to implement.\n\nIf you use or build on our code, we'd love to hear from you! [Contact us here at ask.loc.gov](https://ask.loc.gov/).\n"
  },
  {
    "path": "build_containers.sh",
    "content": "#!/bin/bash\n\nset -eu -o pipefail\n\nBUILD_ALL=${BUILD_ALL:=0}\nBUILD_NUMBER=${BUILD_NUMBER:=1}\nTAG=${TAG:-test}\nPUBLISH_CONTAINERS=${PUBLISH_CONTAINERS:=1}\n\n# Get an unique venv folder to use inside workspace\nVENV=\".venv-${BUILD_NUMBER}\"\n\n# Initialize new venv\npython3 -m venv \"${VENV}\"\nsource \"${VENV}/bin/activate\"\n\n# Update pip\npip3 install -U pip\npip3 install packaging\npip3 install -U setuptools\npip3 install -U pipenv\n\npipenv install --dev --system --deploy\n\nFULL_VERSION_NUMBER=\"$(python3 setup.py --version)\"\nVERSION_NUMBER=$(echo \"${FULL_VERSION_NUMBER}\" | cut -d '+' -f 1)\n\nif [ $PUBLISH_CONTAINERS -eq 1 ]; then\n    AWS_ACCOUNT_ID=\"$(aws sts get-caller-identity  --output=text --query \"Account\")\"\n    eval \"$(aws ecr get-login --no-include-email --region us-east-1)\"\nfi\n\npython3 setup.py build\n\ndocker build -t concordia .\n\nif [ $PUBLISH_CONTAINERS -eq 1 ]; then\n    docker tag concordia:latest \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia:${VERSION_NUMBER}\"\n    docker tag concordia:latest \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia:${TAG}\"\n    docker push \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia:${VERSION_NUMBER}\"\n    docker push \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia:${TAG}\"\nfi\n\nif [ $BUILD_ALL -eq 1 ]; then\n\n    docker build -t concordia/importer --file importer/Dockerfile .\n    docker build -t concordia/celerybeat --file celerybeat/Dockerfile .\n\n    if [ $PUBLISH_CONTAINERS -eq 1 ]; then\n        docker tag concordia/importer:latest \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia/importer:${VERSION_NUMBER}\"\n        docker tag concordia/importer:latest \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia/importer:${TAG}\"\n        docker push \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia/importer:${VERSION_NUMBER}\"\n        docker push \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia/importer:${TAG}\"\n\n        docker tag concordia/celerybeat:latest \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia/celerybeat:${VERSION_NUMBER}\"\n        docker tag concordia/celerybeat:latest \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia/celerybeat:${TAG}\"\n        docker push \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia/celerybeat:${VERSION_NUMBER}\"\n        docker push \"${AWS_ACCOUNT_ID}.dkr.ecr.us-east-1.amazonaws.com/concordia/celerybeat:${TAG}\"\n    fi\nfi\n"
  },
  {
    "path": "celerybeat/Dockerfile",
    "content": "FROM python:3.12-slim-bookworm\n\n## Add the wait script to the image\nADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.2.1/wait /wait\nRUN chmod +x /wait\n\nENV DEBIAN_FRONTEND=\"noninteractive\"\n\nRUN apt-get update -qy && apt-get install -qy curl\n\n# Ensure that the Library's certificate authority is trusted so the tampering\n# proxy will not break TLS validation. See\n# https://staff.loc.gov/wikis/display/SE/Configuring+HTTPS+clients+for+the+HTTPS+tampering+proxy.\n\nRUN curl -fso /etc/ssl/certs/LOC-ROOT-CA-1.crt http://crl.loc.gov/LOC-ROOT-CA-1.crt && openssl x509 -inform der -in /etc/ssl/certs/LOC-ROOT-CA-1.crt -outform pem -out /etc/ssl/certs/LOC-ROOT-CA-1.pem && c_rehash\n\nRUN apt-get update -qy && apt-get dist-upgrade -qy && apt-get install -o Dpkg::Options::='--force-confnew' -qy \\\n    git \\\n    libmemcached-dev \\\n    # Pillow/Imaging: https://pillow.readthedocs.io/en/latest/installation.html#external-libraries\n    libz-dev libfreetype6-dev \\\n    libtiff-dev libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev \\\n    # Postgres client library to build psycopg\n    libpq-dev \\\n    locales \\\n    # Weasyprint requirements\n    libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 \\\n    gcc && apt-get -qy autoremove && apt-get -qy autoclean\n\nRUN locale-gen en_US.UTF-8\nENV LC_ALL=en_US.UTF-8\nENV LANG=en_US.UTF-8\nENV LANGUAGE=en_US.UTF-8\n\nENV PYTHONUNBUFFERED=1 \\\n    PYTHONPATH=/app\n\nENV DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-concordia.settings_docker}\n\nRUN pip install --upgrade pip\nRUN pip install --no-cache-dir pipenv\n\nWORKDIR /app\nCOPY . /app\n\nRUN pipenv install --system --dev --deploy && rm -rf ~/.cache/\n\nCMD /wait && ./celerybeat/entrypoint.sh\n"
  },
  {
    "path": "celerybeat/entrypoint.sh",
    "content": "#!/bin/bash\n\nset -e -u # Exit immediately for unhandled errors or undefined variables\n\nmkdir -p /app/logs\ntouch /app/logs/concordia.log\n\n#  To avoid trace and reporting of errors in the X-Ray SDK\nexport AWS_XRAY_CONTEXT_MISSING=LOG_ERROR\n\necho \"Running celerybeat\"\ncelery -A concordia beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler\n"
  },
  {
    "path": "cloudformation/LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"{}\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2016 Amazon Web Services, Inc.\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "cloudformation/NOTICE",
    "content": "ecs-refarch-cloudformation\nCopyright 2011-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.\n"
  },
  {
    "path": "cloudformation/README.md",
    "content": "# Note Regarding Concordia Usage\n\nThis README, and set of CloudFormation templates, is based on the AWS sample templates at [ecs-refarch-cloudformation](https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml).\n\nThe sample templates have been modified and new templates have been added.\n\nTo use these templates:\n\n1.  Upload this directory to an S3 bucket:\n\n```\ncd cloudformation\n./sync_templates.sh\n```\n\n2.  Read [how to get started with AWS ECR](https://docs.aws.amazon.com/AmazonECR/latest/userguide/ECR_GetStarted.html) and follow the instructions to create an ECR repository for each docker image that will be deployed.\n3.  Set a BUILD_NUMBER in your environment and run `./build_containers.sh`\n4.  Create a KMS key for this project.\n5.  Populate the secrets in `create_secrets.sh` and run that script to create a new set of secrets.\n6.  Upload a certificate for the environment to IAM using the canonical host name.\n7.  If you don't already have the ECS service linked role in your AWS account, run: `aws iam create-service-linked-role --aws-service-name ecs.amazonaws.com`\n8.  Use CloudFormation to create a stack, using the `master.yaml` in the S3 bucket you uploaded in step 1 as the initial template.\n9.  If your environment name is not dev, test, stage or prod: Create a new revision of the task definition, changing the ENV_NAME variable to point to the correct secret storage location. Update the service to use the newest task definition version.\n\n![build-status](https://codebuild.eu-west-1.amazonaws.com/badges?uuid=eyJlbmNyeXB0ZWREYXRhIjoiKzBuNjJCUFk2STRvbDZENXlMUFJOenF2V2EyQ3FMbEtuWDlQeVp6TWlxdXhNMGVOZGo5bG9jdTl1YU16RmZIVVNxa3VqTVg3V3drSnJxOUQwSmhqV2g0PSIsIml2UGFyYW1ldGVyU3BlYyI6IlJJRE4wZGJaS25LL0s0dzkiLCJtYXRlcmlhbFNldFNlcmlhbCI6MX0%3D&branch=master)\n\n# Deploying Microservices with Amazon ECS, AWS CloudFormation, and an Application Load Balancer\n\nThis reference architecture provides a set of YAML templates for deploying microservices to [Amazon EC2 Container Service (Amazon ECS)](http://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html) with [AWS CloudFormation](https://aws.amazon.com/cloudformation/).\n\nYou can launch this CloudFormation stack in your account:\n\n| AWS Region              | Short name |                                                                                                                                                                                                                                                             |\n| ----------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| US East (Ohio)          | us-east-2  | [![cloudformation-launch-button](images/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-east-2#/stacks/new?stackName=Production&templateURL=https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml) |\n| US East (N. Virginia)   | us-east-1  | [![cloudformation-launch-button](images/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/new?stackName=Production&templateURL=https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml) |\n| US West (N. California) | us-west-2  | [![cloudformation-launch-button](images/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/new?stackName=Production&templateURL=https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml) |\n| US West (Oregon)        | us-west-1  | [![cloudformation-launch-button](images/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-west-1#/stacks/new?stackName=Production&templateURL=https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml) |\n\n## Overview\n\n![infrastructure-overview](images/architecture-overview.png)\n\nThe repository consists of a set of nested templates that deploy the following:\n\n-   A tiered [VPC](http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_Introduction.html) with public and private subnets, spanning an AWS region.\n-   A highly available ECS cluster deployed across two [Availability Zones](http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html) in an [Auto Scaling](https://aws.amazon.com/autoscaling/) group and that are AWS SSM enabled.\n-   A pair of [NAT gateways](http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/vpc-nat-gateway.html) (one in each zone) to handle outbound traffic.\n-   Two interconnecting microservices deployed as [ECS services](http://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs_services.html) (website-service and product-service).\n-   An [Application Load Balancer (ALB)](https://aws.amazon.com/elasticloadbalancing/applicationloadbalancer/) to the public subnets to handle inbound traffic.\n-   ALB path-based routes for each ECS service to route the inbound traffic to the correct service.\n-   Centralized container logging with [Amazon CloudWatch Logs](http://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/WhatIsCloudWatchLogs.html).\n-   A [Lambda Function](https://docs.aws.amazon.com/lambda/latest/dg/welcome.html) and [Auto Scaling Lifecycle Hook](https://docs.aws.amazon.com/autoscaling/ec2/userguide/lifecycle-hooks.html) to [drain Tasks from your Container Instances](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/container-instance-draining.html) when an Instance is selected for Termination in your Auto Scaling Group.\n\n## Why use AWS CloudFormation with Amazon ECS?\n\nUsing CloudFormation to deploy and manage services with ECS has a number of nice benefits over more traditional methods ([AWS CLI](https://aws.amazon.com/cli), scripting, etc.).\n\n#### Infrastructure-as-Code\n\nA template can be used repeatedly to create identical copies of the same stack (or to use as a foundation to start a new stack). Templates are simple YAML- or JSON-formatted text files that can be placed under your normal source control mechanisms, stored in private or public locations such as Amazon S3, and exchanged via email. With CloudFormation, you can see exactly which AWS resources make up a stack. You retain full control and have the ability to modify any of the AWS resources created as part of a stack.\n\n#### Self-documenting\n\nFed up with outdated documentation on your infrastructure or environments? Still keep manual documentation of IP ranges, security group rules, etc.?\n\nWith CloudFormation, your template becomes your documentation. Want to see exactly what you have deployed? Just look at your template. If you keep it in source control, then you can also look back at exactly which changes were made and by whom.\n\n#### Intelligent updating & rollback\n\nCloudFormation not only handles the initial deployment of your infrastructure and environments, but it can also manage the whole lifecycle, including future updates. During updates, you have fine-grained control and visibility over how changes are applied, using functionality such as [change sets](https://aws.amazon.com/blogs/aws/new-change-sets-for-aws-cloudformation/), [rolling update policies](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-attribute-updatepolicy.html) and [stack policies](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/protect-stack-resources.html).\n\n## Template details\n\nThe templates below are included in this repository and reference architecture:\n\n| Template                                                                       | Description                                                                                                                                                                                                                                                                                                                                                                          |\n| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |\n| [master.yaml](master.yaml)                                                     | This is the master template - deploy it to CloudFormation and it includes all of the others automatically.                                                                                                                                                                                                                                                                           |\n| [infrastructure/vpc.yaml](infrastructure/vpc.yaml)                             | This template deploys a VPC with a pair of public and private subnets spread across two Availability Zones. It deploys an [Internet gateway](http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_Internet_Gateway.html), with a default route on the public subnets. It deploys a pair of NAT gateways (one in each zone), and default routes for them in the private subnets. |\n| [infrastructure/security-groups.yaml](infrastructure/security-groups.yaml)     | This template contains the [security groups](http://docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_SecurityGroups.html) required by the entire stack. They are created in a separate nested template, so that they can be referenced by all of the other nested templates.                                                                                                       |\n| [infrastructure/load-balancers.yaml](infrastructure/load-balancers.yaml)       | This template deploys an ALB to the public subnets, which exposes the various ECS services. It is created in in a separate nested template, so that it can be referenced by all of the other nested templates and so that the various ECS services can register with it.                                                                                                             |\n| [infrastructure/ecs-cluster.yaml](infrastructure/ecs-cluster.yaml)             | This template deploys an ECS cluster to the private subnets using an Auto Scaling group and installs the AWS SSM agent with related policy requirements.                                                                                                                                                                                                                             |\n| [infrastructure/lifecyclehook.yaml](infrastructure/lifecyclehook.yaml)         | This template deploys a Lambda Function and Auto Scaling Lifecycle Hook to drain Tasks from your Container Instances when an Instance is selected for Termination in your Auto Scaling Group.                                                                                                                                                                                        |\n| [services/product-service/service.yaml](services/product-service/service.yaml) | This is an example of a long-running ECS service that serves a JSON API of products. For the full source for the service, see [services/product-service/src](services/product-service/src).                                                                                                                                                                                          |\n| [services/website-service/service.yaml](services/website-service/service.yaml) | This is an example of a long-running ECS service that needs to connect to another service (product-service) via the load-balanced URL. We use an environment variable to pass the product-service URL to the containers. For the full source for this service, see [services/website-service/src](services/website-service/src).                                                     |\n\nAfter the CloudFormation templates have been deployed, the [stack outputs](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html) contain a link to the load-balanced URLs for each of the deployed microservices.\n\n![stack-outputs](images/stack-outputs.png)\n\nThe ECS instances should also appear in the Managed Instances section of the EC2 console.\n\n## How do I...?\n\n### Get started and deploy this into my AWS account\n\nYou can launch this CloudFormation stack in your account:\n\n| AWS Region              | Short name |                                                                                                                                                                                                                                                             |\n| ----------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| US East (Ohio)          | us-east-2  | [![cloudformation-launch-button](images/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-east-2#/stacks/new?stackName=Production&templateURL=https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml) |\n| US East (N. Virginia)   | us-east-1  | [![cloudformation-launch-button](images/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/new?stackName=Production&templateURL=https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml) |\n| US West (N. California) | us-west-2  | [![cloudformation-launch-button](images/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/new?stackName=Production&templateURL=https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml) |\n| US West (Oregon)        | us-west-1  | [![cloudformation-launch-button](images/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-west-1#/stacks/new?stackName=Production&templateURL=https://s3.amazonaws.com/ecs-refarch-cloudformation/master.yaml) |\n\n### Customize the templates\n\n1. [Fork](https://github.com/awslabs/ecs-refarch-cloudformation#fork-destination-box) this GitHub repository.\n1. Clone the forked GitHub repository to your local machine.\n1. Modify the templates.\n1. Verify your changes locally: `pipenv run cfn-lint path/to/template.yaml`\n1. Upload them to an Amazon S3 bucket of your choice.\n1. Either create a new CloudFormation stack by deploying the master.yaml template, or update your existing stack with your version of the templates.\n\n### Create a new ECS service\n\n1. Push your container to a registry somewhere (e.g., [Amazon ECR](https://aws.amazon.com/ecr/)).\n2. Copy one of the existing service templates in [services/\\*](/services).\n3. Update the `ContainerName` and `Image` parameters to point to your container image instead of the example container.\n4. Increment the `ListenerRule` priority number (no two services can have the same priority number - this is used to order the ALB path based routing rules).\n5. Copy one of the existing service definitions in [master.yaml](master.yaml) and point it at your new service template. Specify the HTTP `Path` at which you want the service exposed.\n6. Deploy the templates as a new stack, or as an update to an existing stack.\n\n### Setup centralized container logging\n\nBy default, the containers in your ECS tasks/services are already configured to send log information to CloudWatch Logs and retain them for 365 days. Within each service's template (in [services/\\*](services/)), a LogGroup is created that is named after the CloudFormation stack. All container logs are sent to that CloudWatch Logs log group.\n\nYou can view the logs by looking in your [CloudWatch Logs console](https://console.aws.amazon.com/cloudwatch/home?#logs:) (make sure you are in the correct AWS region).\n\nECS also supports other logging drivers, including `syslog`, `journald`, `splunk`, `gelf`, `json-file`, and `fluentd`. To configure those instead, adjust the service template to use the alternative `LogDriver`. You can also adjust the log retention period from the default 365 days by tweaking the `RetentionInDays` parameter.\n\nFor more information, see the [LogConfiguration](http://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_LogConfiguration.html) API operation.\n\n### Change the ECS host instance type\n\nThis is specified in the [master.yaml](master.yaml) template.\n\nBy default, [t2.large](https://aws.amazon.com/ec2/instance-types/) instances are used, but you can change this by modifying the following section:\n\n```\nECS:\n  Type: AWS::CloudFormation::Stack\n    Properties:\n      TemplateURL: ...\n      Parameters:\n        ...\n        InstanceType: t2.large\n        InstanceCount: 4\n        ...\n```\n\n### Adjust the Auto Scaling parameters for ECS hosts and services\n\nThe Auto Scaling group scaling policy provided by default launches and maintains a cluster of 4 ECS hosts distributed across two Availability Zones (min: 4, max: 4, desired: 4).\n\nIt is **_not_** set up to scale automatically based on any policies (CPU, network, time of day, etc.).\n\nIf you would like to configure policy or time-based automatic scaling, you can add the [ScalingPolicy](http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-as-policy.html) property to the AutoScalingGroup deployed in [infrastructure/ecs-cluster.yaml](infrastructure/ecs-cluster.yaml#L69).\n\nAs well as configuring Auto Scaling for the ECS hosts (your pool of compute), you can also configure scaling each individual ECS service. This can be useful if you want to run more instances of each container/task depending on the load or time of day (or a custom CloudWatch metric). To do this, you need to create [AWS::ApplicationAutoScaling::ScalingPolicy](http://docs.aws.amazon.com/pt_br/AWSCloudFormation/latest/UserGuide/aws-resource-applicationautoscaling-scalingpolicy.html) within your service template.\n\n### Deploy multiple environments (e.g., dev, test, pre-production)\n\nDeploy another CloudFormation stack from the same set of templates to create a new environment. The stack name provided when deploying the stack is prefixed to all taggable resources (e.g., EC2 instances, VPCs, etc.) so you can distinguish the different environment resources in the AWS Management Console.\n\n### Change the VPC or subnet IP ranges\n\nThis set of templates deploys the following network design:\n\n| Item           | CIDR Range     | Usable IPs | Description                                        |\n| -------------- | -------------- | ---------- | -------------------------------------------------- |\n| VPC            | 10.180.0.0/16  | 65,536     | The whole range used for the VPC and all subnets   |\n| Public Subnet  | 10.180.8.0/21  | 2,041      | The public subnet in the first Availability Zone   |\n| Public Subnet  | 10.180.16.0/21 | 2,041      | The public subnet in the second Availability Zone  |\n| Private Subnet | 10.180.24.0/21 | 2,041      | The private subnet in the first Availability Zone  |\n| Private Subnet | 10.180.32.0/21 | 2,041      | The private subnet in the second Availability Zone |\n\nYou can adjust the CIDR ranges used in this section of the [master.yaml](master.yaml) template:\n\n```\nVPC:\n  Type: AWS::CloudFormation::Stack\n    Properties:\n      TemplateURL: !Sub ${TemplateLocation}/infrastructure/vpc.yaml\n      Parameters:\n        EnvironmentName:    !Ref AWS::StackName\n        VpcCIDR:            10.180.0.0/16\n        PublicSubnet1CIDR:  10.180.8.0/21\n        PublicSubnet2CIDR:  10.180.16.0/21\n        PrivateSubnet1CIDR: 10.180.24.0/21\n        PrivateSubnet2CIDR: 10.180.32.0/21\n```\n\n### Update an ECS service to a new Docker image version\n\nECS has the ability to perform rolling upgrades to your ECS services to minimize downtime during deployments. For more information, see [Updating a Service](http://docs.aws.amazon.com/AmazonECS/latest/developerguide/update-service.html).\n\nTo update one of your services to a new version, adjust the `Image` parameter in the service template (in [services/\\*](services/) to point to the new version of your container image. For example, if `1.0.0` was currently deployed and you wanted to update to `1.1.0`, you could update it as follows:\n\n```\nTaskDefinition:\n  Type: AWS::ECS::TaskDefinition\n  Properties:\n    ContainerDefinitions:\n      - Name: your-container\n        Image: registry.example.com/your-container:1.1.0\n```\n\nAfter you've updated the template, update the deployed CloudFormation stack; CloudFormation and ECS handle the rest.\n\nTo adjust the rollout parameters (min/max number of tasks/containers to keep in service at any time), you need to configure `DeploymentConfiguration` for the ECS service.\n\nFor example:\n\n```\nService:\n  Type: AWS::ECS::Service\n    Properties:\n      ...\n      DesiredCount: 4\n      DeploymentConfiguration:\n        MaximumPercent: 200\n        MinimumHealthyPercent: 50\n```\n\n### Use the SSM Run Command function to see details in the ECS instances\n\nThe AWS SSM Run Command function, in the EC2 console, can be used to execute commands at the shell on the ECS instances. These can be helpful for examining the installed configuration of the instances without requiring direct access to them.\n\n### Spot Instances and the Hibernate Agent.\n\nIn order to use Spot with this template, you will need to enable `SpotPrice` under the `AWS::AutoScaling::LaunchConfiguration` or add in `AWS::EC2::SpotFleet` support. To fully use Hibernation with Spot instances, please review [Spot Instance Interruptions](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/spot-interruptions.html).\n\n### Add a new item to this list\n\nIf you found yourself wishing this set of frequently asked questions had an answer for a particular problem, please [submit a pull request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/). The chances are that others will also benefit from having the answer listed here.\n\n## Contributing\n\nPlease [create a new GitHub issue](https://github.com/awslabs/ecs-refarch-cloudformation/issues/new) for any feature requests, bugs, or documentation improvements.\n\nWhere possible, please also [submit a pull request](https://help.github.com/articles/creating-a-pull-request-from-a-fork/) for the change.\n\n## License\n\nCopyright 2011-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.\n\nLicensed under the Apache License, Version 2.0 (the \"License\"). You may not use this file except in compliance with the License. A copy of the License is located at\n\n[http://aws.amazon.com/apache2.0/](http://aws.amazon.com/apache2.0/)\n\nor in the \"license\" file accompanying this file. This file is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.\n​\n"
  },
  {
    "path": "cloudformation/add_cloudflare_ips_to_sgs.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nEnsure that every security group tagged with “AllowCloudFlareIngress” has\npermissions for every public CloudFlare netblock\n\"\"\"\n\nimport sys\n\nimport boto3\nimport requests\nfrom botocore.exceptions import ClientError\n\nEC2_CLIENT = boto3.client(\"ec2\")\n\nCLOUDFLARE_IPV4 = requests.get(\n    \"https://www.cloudflare.com/ips-v4\", timeout=30\n).text.splitlines()\nCLOUDFLARE_IPV6 = requests.get(\n    \"https://www.cloudflare.com/ips-v6\", timeout=30\n).text.splitlines()\n\n\ndef add_ingess_rules_for_group(sg_id, existing_permissions):\n    permissions = {\"IpProtocol\": \"tcp\", \"FromPort\": 443, \"ToPort\": 443}\n\n    existing_ipv4 = set()\n    existing_ipv6 = set()\n\n    for existing in existing_permissions:\n        if any(\n            permissions[k] != existing[k] for k in (\"IpProtocol\", \"FromPort\", \"ToPort\")\n        ):\n            continue\n\n        existing_ipv4.update(i[\"CidrIp\"] for i in existing[\"IpRanges\"])\n        existing_ipv6.update(i[\"CidrIpv6\"] for i in existing[\"Ipv6Ranges\"])\n\n    ipv4_ranges = [\n        {\"CidrIp\": cidr, \"Description\": \"CloudFlare\"}\n        for cidr in CLOUDFLARE_IPV4\n        if cidr not in existing_ipv4\n    ]\n    ipv6_ranges = [\n        {\"CidrIpv6\": cidr, \"Description\": \"CloudFlare\"}\n        for cidr in CLOUDFLARE_IPV6\n        if cidr not in existing_ipv6\n    ]\n\n    permissions[\"IpRanges\"] = ipv4_ranges\n    permissions[\"Ipv6Ranges\"] = ipv6_ranges\n\n    try:\n        EC2_CLIENT.authorize_security_group_ingress(\n            GroupId=sg_id, IpPermissions=[permissions]\n        )\n    except ClientError as exc:\n        print(f\"Unable to add permssions for {sg_id}: {exc}\", file=sys.stderr)\n\n\ndef get_security_groups():\n    paginator = EC2_CLIENT.get_paginator(\"describe_security_groups\")\n    page_iterator = paginator.paginate(\n        Filters=[{\"Name\": \"tag-key\", \"Values\": [\"AllowCloudFlareIngress\"]}]\n    )\n\n    for page in page_iterator:\n        for sg in page[\"SecurityGroups\"]:\n            yield sg[\"GroupId\"], sg[\"IpPermissions\"]\n\n\nif __name__ == \"__main__\":\n    for security_group_id, existing_permissions in get_security_groups():\n        add_ingess_rules_for_group(security_group_id, existing_permissions)\n"
  },
  {
    "path": "cloudformation/create_secrets.sh",
    "content": "#!/bin/bash\n\nset -eu\n\n# If you create a new set of secrets using a new ENV_NAME here,\n# then add the new ENV_NAME option to the list of allowed options in\n# master.yaml and infrastructure/fargate-cluster.yaml\n\nexport ENV_NAME=cftest2\n\nexport DJANGO_SECRET_KEY=\nexport DB_PASSWORD=\nexport KMS_KEY_ARN=arn:aws:kms:us-east-1:619333082511:key/d300e73d-9170-4001-933a-37af0bcdb956\n\naws secretsmanager create-secret --name \"crowd/${ENV_NAME}/Django/SecretKey\" --kms-key-id \"${KMS_KEY_ARN}\" --secret-string \"{\\\"DjangoSecretKey\\\": \\\"${DJANGO_SECRET_KEY}\\\"}\"\n\naws secretsmanager create-secret --name \"crowd/${ENV_NAME}/DB/MasterUserPassword\" --kms-key-id \"${KMS_KEY_ARN}\" --secret-string \"{\\\"username\\\": \\\"concordia\\\",\\\"engine\\\": \\\"postgres\\\",\\\"port\\\": 5432,\\\"dbname\\\": \\\"concordia\\\",\\\"password\\\": \\\"${DB_PASSWORD}\\\"}\"\n\n# aws secretsmanager create-secret --name \"concordia/SMTP\" --kms-key-id \"${KMS_KEY_ARN}\" --secret-string '{\"Hostname\": \"email-smtp.us-east-1.amazonaws.com\",\"Username\": \"\",\"Password\": \"\"}'\n"
  },
  {
    "path": "cloudformation/featurebranch.yaml",
    "content": "---\nAWSTemplateFormatVersion: '2010-09-09'\nDescription: >\n    Deploy a feature branch to a subdomain of crowd-test.loc.gov\n    using pre-existing infrastructure.\n    Assumes docker images have been published to ECR with\n    tag matching the feature branch name.\n\nParameters:\n    ConcordiaBranch:\n        Description: which branch name to deploy\n        Type: String\n        Default: release\n\n    AbbreviatedName:\n        Description: an abbreviation used for creating short-named cloudformation resources\n        Type: String\n        Default: rel\n\n    Priority:\n        Type: Number\n        Description: Priority of the subdomain listener rule, must be unique in the set of listener rules\n        Default: 100\n\nResources:\n    RDS:\n        Type: AWS::CloudFormation::Stack\n        Properties:\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/rds.yaml'\n            Parameters:\n                DbPassword: '{{resolve:secretsmanager:crowd/test/DB/MasterUserPassword:SecretString:password}}'\n                DbUsername: '{{resolve:secretsmanager:crowd/test/DB/MasterUserPassword:SecretString:username}}'\n                DatabaseSecurityGroup: 'sg-0496910b800de2869'\n                PrivateSubnet1: 'subnet-0aa55b322229b945a'\n                PrivateSubnet2: 'subnet-0f65558b319b2d4dc'\n\n    DataLoadHost:\n        Type: AWS::CloudFormation::Stack\n        Properties:\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/data-load.yaml'\n            Parameters:\n                PostgresqlHost: !GetAtt RDS.Outputs.DatabaseHostName\n                PostgresqlPassword: '{{resolve:secretsmanager:crowd/test/DB/MasterUserPassword:SecretString:password}}'\n                EnvironmentName: 'test'\n\n    ElastiCache:\n        Type: AWS::CloudFormation::Stack\n        Properties:\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/elasticache-feature.yaml'\n            Parameters:\n                EnvironmentName: !Ref AbbreviatedName\n                SecurityGroup: 'sg-028ebfe14211447c4'\n\n    FargateCluster:\n        Type: AWS::CloudFormation::Stack\n        Properties:\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/fargate-featurebranch.yaml'\n            Parameters:\n                EnvName: 'test'\n                FullEnvironmentName: 'test'\n                S3BucketName: 'crowd-test-content'\n                ExportS3BucketName: 'crowd-test-export'\n                ConcordiaVersion: !Ref ConcordiaBranch\n                CanonicalHostName: !Sub '${ConcordiaBranch}.crowd-test.loc.gov'\n                VpcId: 'vpc-018e5a73079d0b350'\n                SecurityGroup: 'sg-04de21574623caca7'\n                RedisAddress: !GetAtt ElastiCache.Outputs.RedisAddress\n                RedisPort: !GetAtt ElastiCache.Outputs.RedisPort\n                DatabaseEndpoint: !GetAtt RDS.Outputs.DatabaseHostName\n                Priority: !Ref Priority\n                DataLoadStackName: !GetAtt DataLoadHost.Outputs.StackName\n"
  },
  {
    "path": "cloudformation/infrastructure/bastion-hosts.yaml",
    "content": "Description: This template deploys a bastion host in each of the public subnets.\n\nParameters:\n    EnvironmentName:\n        Description: An environment name that will be prefixed to resource names\n        Type: String\n        AllowedValues:\n            - dev\n            - test\n            - stage\n            - prod\n\n    KeyPairName:\n        Description: key pair (within this region) for ECS instances access\n        Type: String\n\nMappings:\n    AWSRegionToAMI:\n        us-east-1:\n            AMI: ami-04e5276ebb8451442\n\n    EnvironmentMapping:\n        IamInstanceProfileName:\n            dev: crowd-dev-FargateCluster-WFCY4I0U7JSM-ConcordiaInstanceProfile-RQHLRZADDM9M\n            test: crowd-test-FargateCluster-1R5U1VT4HOYX2-ConcordiaInstanceProfile-1FJXY570ZM2O3\n            stage: crowd-stage-FargateCluster-1TBKSIZQKLJHV-ConcordiaInstanceProfile-1XG3TR3LY42ND\n            prod: crowd-prod-FargateCluster-1X1CI0J3HFJ9F-ConcordiaInstanceProfile-13SHE5FAB7D6Q\n\n        # The ID of the public subnet in the first AZ\n        # Type: AWS::EC2::Subnet::Id\n        PublicSubnet1:\n            dev: subnet-079b5dd4f9acf44e6\n            test: subnet-06f443ea589879e8d\n            stage: subnet-06f40e2fc8d891692\n            prod: subnet-09fdaf1c5c73f588f\n\n        # The ID of the public subnet in the second AZ\n        # Type: AWS::EC2::Subnet::Id\n        PublicSubnet2:\n            dev: subnet-01d6614725c7dabd6\n            test: subnet-05a15c6058ebdf54f\n            stage: subnet-0a022eb0c614b0b00\n            prod: subnet-01580e2a4d6d42b52\n\n        # The security group for bastion hosts\n        # Type: AWS::EC2::SecurityGroup::Id\n        BastionHostsSecurityGroup:\n            dev: sg-062afe8941ace25ad\n            test: sg-0208b0df704b66c3c\n            stage: sg-0a2175a2df32a4332\n            prod: sg-066c68e77787b2a10\n\nResources:\n    Bastion1:\n        Type: AWS::EC2::Instance\n        Properties:\n            ImageId:\n                Fn::FindInMap:\n                    - AWSRegionToAMI\n                    - Ref: 'AWS::Region'\n                    - 'AMI'\n            InstanceType: 't2.medium'\n            IamInstanceProfile:\n                Fn::FindInMap:\n                    - EnvironmentMapping\n                    - IamInstanceProfileName\n                    - Ref: EnvironmentName\n            KeyName:\n                Ref: KeyPairName\n            NetworkInterfaces:\n                - AssociatePublicIpAddress: true\n                  DeviceIndex: '0'\n                  GroupSet:\n                      - Fn::FindInMap:\n                            - EnvironmentMapping\n                            - BastionHostsSecurityGroup\n                            - Ref: EnvironmentName\n                  SubnetId:\n                      Fn::FindInMap:\n                          - EnvironmentMapping\n                          - PublicSubnet1\n                          - Ref: EnvironmentName\n            UserData:\n                Fn::Base64: !Sub |\n                    #!/bin/bash -xe\n                    echo \"Running userdata for ${EnvironmentName}\"\n                    echo \"export ENV_NAME=${EnvironmentName}\" >> /home/ec2-user/.bash_profile\n                    source /home/ec2-user/.bash_profile\n                    # TODO while true is a workaround for AL2023 Consistently Failing to boot\n                    #  · Issue #3741· philips-labs/terraform-aws-github-runner\n                    # https://github.com/amazonlinux/amazon-linux-2023/issues/397\n                    while true; do\n                      dnf -y upgrade --releasever=latest && break\n                    done\n                    while true; do\n                      dnf -y install --assumeyes git && break\n                    done\n                    while true; do\n                      dnf -y install --assumeyes postgresql15.x86_64 && break\n                    done\n                    while true; do\n                      dnf -y install --assumeyes docker.x86_64 && break\n                    done\n                    aws s3 cp s3://crowd-deployment/database-dumps/concordia.latest.dmp concordia.dmp\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName}-BastionHost-1\n\n    Bastion2:\n        Type: AWS::EC2::Instance\n        Properties:\n            ImageId:\n                Fn::FindInMap:\n                    - AWSRegionToAMI\n                    - Ref: 'AWS::Region'\n                    - 'AMI'\n            InstanceType: 't2.medium'\n            IamInstanceProfile:\n                Fn::FindInMap:\n                    - EnvironmentMapping\n                    - IamInstanceProfileName\n                    - Ref: EnvironmentName\n            KeyName:\n                Ref: KeyPairName\n            NetworkInterfaces:\n                - AssociatePublicIpAddress: true\n                  DeviceIndex: '0'\n                  GroupSet:\n                      - Fn::FindInMap:\n                            - EnvironmentMapping\n                            - BastionHostsSecurityGroup\n                            - Ref: EnvironmentName\n                  SubnetId:\n                      Fn::FindInMap:\n                          - EnvironmentMapping\n                          - PublicSubnet2\n                          - Ref: EnvironmentName\n            UserData:\n                Fn::Base64: !Sub |\n                    #!/bin/bash -xe\n                    echo \"Running userdata for ${EnvironmentName}\"\n                    echo \"export ENV_NAME=${EnvironmentName}\" >> /home/ec2-user/.bash_profile\n                    source /home/ec2-user/.bash_profile\n                    while true; do\n                      dnf -y upgrade --releasever=latest && break\n                    done\n                    while true; do\n                      dnf -y install --assumeyes git && break\n                    done\n                    while true; do\n                      dnf -y install --assumeyes postgresql15.x86_64 && break\n                    done\n                    while true; do\n                      dnf -y install --assumeyes docker.x86_64 && break\n                    done\n                    aws s3 cp s3://crowd-deployment/database-dumps/concordia.latest.dmp concordia.dmp\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName}-BastionHost-2\n"
  },
  {
    "path": "cloudformation/infrastructure/data-load.yaml",
    "content": "Description:\n    This template deploys a host in a private subnet and loads the most recent\n    database dump to the specified database server.\n\nParameters:\n    EnvironmentName:\n        Description: An environment name that will be prefixed to resource names\n        Type: String\n        AllowedValues:\n            - dev\n            - test\n            - stage\n            - prod\n\n    PostgresqlHost:\n        Description: the end point of the RDS database host to restore\n        Type: String\n\n    PostgresqlPassword:\n        Description: the password for the RDS endpoint to restore\n        Type: String\n        NoEcho: true\n\nMappings:\n    AWSRegionToAMI:\n        us-east-1:\n            AMI: ami-04e5276ebb8451442\n\n    EnvironmentMapping:\n        IamInstanceProfileName:\n            dev: crowd-dev-FargateCluster-WFCY4I0U7JSM-ConcordiaInstanceProfile-RQHLRZADDM9M\n            test: crowd-test-FargateCluster-1R5U1VT4HOYX2-ConcordiaInstanceProfile-1FJXY570ZM2O3\n            stage: crowd-stage-FargateCluster-1TBKSIZQKLJHV-ConcordiaInstanceProfile-1XG3TR3LY42ND\n            prod: crowd-prod-FargateCluster-1X1CI0J3HFJ9F-ConcordiaInstanceProfile-13SHE5FAB7D6Q\n\n        PrivateSubnet1:\n            dev: subnet-0c95a830ce007fa65\n            test: subnet-0aa55b322229b945a\n            stage: subnet-0f7c7d66b66d6dd90\n            prod: subnet-0da84976b66c32ce4\n\n        # The security group for bastion hosts\n        # Type: AWS::EC2::SecurityGroup::Id\n        BastionHostsSecurityGroup:\n            dev: sg-062afe8941ace25ad\n            test: sg-0208b0df704b66c3c\n            stage: sg-0a2175a2df32a4332\n            prod: sg-066c68e77787b2a10\n\nResources:\n    DataLoadHost:\n        Type: AWS::EC2::Instance\n        CreationPolicy:\n            ResourceSignal:\n                Timeout: PT30M\n        Properties:\n            ImageId:\n                Fn::FindInMap:\n                    - AWSRegionToAMI\n                    - Ref: 'AWS::Region'\n                    - 'AMI'\n            InstanceType: 't2.medium'\n            IamInstanceProfile:\n                Fn::FindInMap:\n                    - EnvironmentMapping\n                    - IamInstanceProfileName\n                    - Ref: EnvironmentName\n            InstanceInitiatedShutdownBehavior: terminate\n            NetworkInterfaces:\n                - AssociatePublicIpAddress: true\n                  DeviceIndex: '0'\n                  GroupSet:\n                      - Fn::FindInMap:\n                            - EnvironmentMapping\n                            - BastionHostsSecurityGroup\n                            - Ref: EnvironmentName\n                  SubnetId:\n                      Fn::FindInMap:\n                          - EnvironmentMapping\n                          - PrivateSubnet1\n                          - Ref: EnvironmentName\n            UserData:\n                Fn::Base64: !Sub |\n                    #!/bin/bash -xe\n                    trap '/opt/aws/bin/cfn-signal --exit-code 1 --resource DataLoadHost --region ${AWS::Region} --stack ${AWS::StackName}' ERR\n                    echo \"Running userdata for ${EnvironmentName}\"\n                    echo \"export ENV_NAME=${EnvironmentName}\" >> /home/ec2-user/.bash_profile\n                    source /home/ec2-user/.bash_profile\n                    # TODO while true is a workaround for AL2023 Consistently Failing to boot\n                    #  · Issue #3741· philips-labs/terraform-aws-github-runner\n                    # https://github.com/amazonlinux/amazon-linux-2023/issues/397\n                    while true; do\n                      dnf -y upgrade --releasever=latest && break\n                    done\n                    while true; do\n                      dnf -y install --assumeyes postgresql15.x86_64 && break\n                    done\n                    aws s3 cp s3://crowd-deployment/database-dumps/concordia.latest.dmp concordia.dmp\n                    echo \"${PostgresqlHost}:5432:*:concordia:${PostgresqlPassword}\" >> /root/.pgpass\n                    chmod 0600 /root/.pgpass\n                    psql -U concordia -h ${PostgresqlHost} -d postgres -c \"select pg_terminate_backend(pid) from pg_stat_activity where datname='concordia';\"\n                    psql -U concordia -h ${PostgresqlHost} -d postgres -c \"drop database concordia;\"\n                    pg_restore --create -Fc -U concordia -h ${PostgresqlHost} --dbname=postgres --no-password --no-owner --no-acl concordia.dmp\n                    # Signal the status from cfn-init\n                    /opt/aws/bin/cfn-signal --exit-code 0 --resource DataLoadHost --region ${AWS::Region} --stack ${AWS::StackName}\n                    shutdown -h now\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName}-DataLoadHost\nOutputs:\n    StackName:\n        Description: 'Stackname for the DataLoadHost'\n        Value: !Ref AWS::StackName\n"
  },
  {
    "path": "cloudformation/infrastructure/elasticache-feature.yaml",
    "content": "Description: >\n    This template deploys an elasticache cluster to the provided VPC and subnets\n\nParameters:\n    EnvironmentName:\n        Description: An environment name that will be prefixed to resource names\n        Type: String\n\n    SecurityGroup:\n        Description: Select the Security Group to use for the ECS cluster hosts\n        Type: AWS::EC2::SecurityGroup::Id\n\n    CacheNodeType:\n        Type: String\n        Default: cache.m5.large\n\nResources:\n    RedisService:\n        Type: AWS::ElastiCache::CacheCluster\n        Properties:\n            VpcSecurityGroupIds:\n                - !Ref 'SecurityGroup'\n            CacheSubnetGroupName: 'crowd-cache-1frtjeewr57u7'\n            CacheNodeType: !Ref 'CacheNodeType'\n            ClusterName: !Sub '${EnvironmentName}-redis'\n            Engine: redis\n            AutoMinorVersionUpgrade: true\n            NumCacheNodes: 1\n            SnapshotRetentionLimit: 1\n\nOutputs:\n    RedisAddress:\n        Description: Redis endpoint address\n        Value: !GetAtt 'RedisService.RedisEndpoint.Address'\n\n    RedisPort:\n        Description: Redis endpoint port\n        Value: !GetAtt 'RedisService.RedisEndpoint.Port'\n"
  },
  {
    "path": "cloudformation/infrastructure/elasticache.yaml",
    "content": "Description: >\n    This template deploys an elasticache cluster to the provided VPC and subnets\n\nParameters:\n    EnvironmentName:\n        Description: An environment name that will be prefixed to resource names\n        Type: String\n\n    PrivateSubnets:\n        Description: Choose which subnets this ECS cluster should be deployed to\n        Type: List<AWS::EC2::Subnet::Id>\n\n    SecurityGroup:\n        Description: Select the Security Group to use for the ECS cluster hosts\n        Type: AWS::EC2::SecurityGroup::Id\n\n    CacheNodeType:\n        Type: String\n        Default: cache.m1.small\n\nResources:\n    CachePrivateSubnetGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::ElastiCache::SubnetGroup\n        DeletionPolicy: Retain\n        Properties:\n            Description: Private subnet group\n            SubnetIds: !Ref PrivateSubnets\n    RedisService:\n        UpdateReplacePolicy: Retain\n        Type: AWS::ElastiCache::CacheCluster\n        DeletionPolicy: Retain\n        Properties:\n            VpcSecurityGroupIds:\n                - !Ref 'SecurityGroup'\n            CacheSubnetGroupName: !Ref 'CachePrivateSubnetGroup'\n            CacheNodeType: !Ref 'CacheNodeType'\n            ClusterName: !Sub '${EnvironmentName}-redis'\n            Engine: redis\n            AutoMinorVersionUpgrade: true\n            NumCacheNodes: 1\n            SnapshotRetentionLimit: 1\n\nOutputs:\n    RedisAddress:\n        Description: Redis endpoint address\n        Value: !GetAtt 'RedisService.RedisEndpoint.Address'\n\n    RedisPort:\n        Description: Redis endpoint port\n        Value: !GetAtt 'RedisService.RedisEndpoint.Port'\n"
  },
  {
    "path": "cloudformation/infrastructure/elasticsearch.yaml",
    "content": "Description: >\n    This template deploys a VPC-based ElasticSearch cluster.\n\nParameters:\n    EnvName:\n        Type: String\n        Description: which environment to target\n        AllowedValues:\n            - 'dev'\n            - 'test'\n            - 'stage'\n            - 'prod'\n        ConstraintDescription: Must match a location for secret storage in secretsmanager\n\n    SecurityGroup:\n        Description: Select the Security Group to use for the ECS cluster hosts\n        Type: AWS::EC2::SecurityGroup::Id\n\n    PrivateSubnet2:\n        Description: The private subnet in AZ2 for the VPC\n        Type: AWS::EC2::Subnet::Id\n\nResources:\n    ESCluster:\n        Type: AWS::Elasticsearch::Domain\n        Properties:\n            ElasticsearchClusterConfig:\n                InstanceCount: 1\n                ZoneAwarenessEnabled: false\n                InstanceType: 'm5.xlarge.elasticsearch'\n            ElasticsearchVersion: '7.10'\n            EBSOptions:\n                EBSEnabled: true\n                Iops: 0\n                VolumeSize: 20\n                VolumeType: 'standard'\n            SnapshotOptions:\n                AutomatedSnapshotStartHour: 0\n            AccessPolicies:\n                Version: '2012-10-17'\n                Statement:\n                    - Effect: 'Allow'\n                      Principal:\n                          AWS: '*'\n                      Action: 'es:*'\n                      Resource: !Sub 'arn:aws:es:us-east-1:619333082511:domain/crowd-${EnvName}-vpc/*'\n            AdvancedOptions:\n                rest.action.multi.allow_explicit_index: 'true'\n            Tags:\n                - Key: Environment\n                  Value: !Ref EnvName\n            VPCOptions:\n                SubnetIds:\n                    - Ref: PrivateSubnet2\n                SecurityGroupIds:\n                    - Ref: SecurityGroup\n"
  },
  {
    "path": "cloudformation/infrastructure/fargate-cluster.yaml",
    "content": "Description: >\n    This template deploys a fargate cluster to the provided VPC and subnets\n\nParameters:\n    EnvironmentName:\n        Description: An environment name that will be prefixed to resource names\n        Type: String\n\n    PublicSubnets:\n        Description: The subnets for the load balancer\n        Type: List<AWS::EC2::Subnet::Id>\n\n    PrivateSubnets:\n        Description: Choose which subnets this ECS cluster should be deployed to\n        Type: List<AWS::EC2::Subnet::Id>\n\n    SecurityGroup:\n        Description: Select the Security Group to use for the ECS cluster hosts\n        Type: AWS::EC2::SecurityGroup::Id\n\n    LoadBalancerSecurityGroup:\n        Description: The SecurityGroup for load balancer\n        Type: AWS::EC2::SecurityGroup::Id\n\n    VpcId:\n        Description: The Id of the VPC for this cluster\n        Type: AWS::EC2::VPC::Id\n\n    ConcordiaVersion:\n        Type: String\n        Description: version of concordia docker images to pull and deploy\n        Default: latest\n\n    DjangoKeyId:\n        Type: String\n        Description: unique ID appended to end of DjangoSecretKey ARN in secrets manager\n        Default: xxxxx\n\n    DbSecretId:\n        Type: String\n        Description: unique ID appended to end of DB password ARN in secrets manager\n        Default: xxxxx\n\n    EnvName:\n        Type: String\n        Description: which environment to target\n        AllowedValues:\n            - 'dev'\n            - 'test'\n            - 'stage'\n            - 'prod'\n            - 'cftest2'\n        ConstraintDescription: Must match a location for secret storage in secretsmanager\n\n    FullEnvironmentName:\n        Type: String\n        Description: Full name of deployment environment\n        AllowedValues:\n            - 'development'\n            - 'test'\n            - 'staging'\n            - 'production'\n\n    RedisAddress:\n        Type: String\n        Description: Redis endpoint address\n\n    RedisPort:\n        Type: String\n        Description: Redis endpoint port\n\n    CanonicalHostName:\n        Type: String\n        Description: canonical host name of the application, e.g. crowd-test.loc.gov\n\n    DatabaseEndpoint:\n        Type: String\n        Description: Host name of the Postgres RDS service\n\n    S3BucketName:\n        Type: String\n        Description: name of the S3 bucket (public) where collection images will be stored\n\n    ExportS3BucketName:\n        Type: String\n        Description: name of the S3 bucket (public) where exported transcriptions will be stored\n\nResources:\n    ConcordiaS3BucketAccessPolicy:\n        UpdateReplacePolicy: Retain\n        Type: AWS::IAM::Policy\n        Metadata:\n            cfn_nag:\n                rules_to_suppress:\n                    - id: W12\n                      reason: 'S3 buckets must be specified with /* after the bucket name'\n        DeletionPolicy: Retain\n        Properties:\n            PolicyName: !Sub ConcordiaServiceS3BucketAccess-${EnvironmentName}\n            Roles:\n                - !Ref 'ConcordiaTaskRole'\n                - !Ref 'ConcordiaEC2Role'\n            PolicyDocument:\n                Version: '2012-10-17'\n                Statement:\n                    - Effect: Allow\n                      Action:\n                          - 's3:PutObject'\n                          - 's3:GetObject'\n                          - 's3:AbortMultipartUpload'\n                          - 's3:ListMultipartUploadParts'\n                          - 's3:ListBucket'\n                          - 's3:ListBucketMultipartUploads'\n                      Resource:\n                          - !Sub 'arn:aws:s3:::crowd-${EnvironmentName}-content/*'\n                          - !Sub 'arn:aws:s3:::crowd-${EnvironmentName}-export/*'\n\n    ConcordiaKMSAccessPolicy:\n        UpdateReplacePolicy: Retain\n        Type: AWS::IAM::Policy\n        DeletionPolicy: Retain\n        Properties:\n            PolicyName: !Sub ConcordiaServiceKMSAccess-${EnvironmentName}\n            Roles:\n                - !Ref 'ConcordiaTaskRole'\n                - !Ref 'ConcordiaEC2Role'\n            PolicyDocument:\n                Version: '2012-10-17'\n                Statement:\n                    - Effect: Allow\n                      Action:\n                          - 'kms:GetParametersForImport'\n                          - 'kms:GetKeyRotationStatus'\n                          - 'kms:GetKeyPolicy'\n                          - 'kms:DescribeKey'\n                          - 'kms:ListResourceTags'\n                          - 'kms:Decrypt'\n                          - 'kms:GenerateDataKey'\n                      Resource:\n                          - 'arn:aws:kms:us-east-1:619333082511:key/d300e73d-9170-4001-933a-37af0bcdb956'\n\n    ConcordiaServiceSecretAccessPolicy:\n        UpdateReplacePolicy: Retain\n        Type: AWS::IAM::Policy\n        DeletionPolicy: Retain\n        Properties:\n            PolicyName: !Sub ConcordiaServiceSecretAccess-${EnvironmentName}\n            Roles:\n                - !Ref 'ConcordiaTaskRole'\n                - !Ref 'ConcordiaEC2Role'\n            PolicyDocument:\n                Version: '2012-10-17'\n                Statement:\n                    - Effect: Allow\n                      Action:\n                          - 'secretsmanager:GetResourcePolicy'\n                          - 'secretsmanager:GetSecretValue'\n                          - 'secretsmanager:DescribeSecret'\n                          - 'secretsmanager:ListSecretVersionIds'\n                      Resource:\n                          - 'arn:aws:secretsmanager:us-east-1:619333082511:secret:concordia/SMTP-GVlolk'\n                          - !Sub 'arn:aws:secretsmanager:us-east-1:619333082511:secret:crowd/${EnvName}/Django/SecretKey-${DjangoKeyId}'\n                          - !Sub 'arn:aws:secretsmanager:us-east-1:619333082511:secret:crowd/${EnvName}/DB/MasterUserPassword-${DbSecretId}'\n\n    ConcordiaEC2Role:\n        UpdateReplacePolicy: Retain\n        Type: AWS::IAM::Role\n        DeletionPolicy: Retain\n        Properties:\n            Path: /\n            AssumeRolePolicyDocument:\n                Version: '2012-10-17'\n                Statement:\n                    - Effect: Allow\n                      Principal:\n                          Service: ec2.amazonaws.com\n                      Action: sts:AssumeRole\n            ManagedPolicyArns:\n                - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly\n                - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy\n\n    ConcordiaInstanceProfile:\n        UpdateReplacePolicy: Retain\n        Type: AWS::IAM::InstanceProfile\n        DeletionPolicy: Retain\n        Properties:\n            Path: /\n            Roles:\n                - !Ref 'ConcordiaEC2Role'\n\n    ConcordiaTaskRole:\n        UpdateReplacePolicy: Retain\n        Type: AWS::IAM::Role\n        DeletionPolicy: Retain\n        Properties:\n            AssumeRolePolicyDocument:\n                Version: '2012-10-17'\n                Statement:\n                    - Effect: Allow\n                      Principal:\n                          Service: ecs-tasks.amazonaws.com\n                      Action:\n                          - sts:AssumeRole\n            ManagedPolicyArns:\n                - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly\n                - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy\n\n    ConcordiaAppLogsGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::Logs::LogGroup\n        DeletionPolicy: Retain\n        Properties:\n            LogGroupName: !Ref AWS::StackName\n            RetentionInDays: 30\n\n    ConcordiaExternalTargetGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::ElasticLoadBalancingV2::TargetGroup\n        DeletionPolicy: Retain\n        Properties:\n            HealthCheckIntervalSeconds: 30\n            HealthCheckPath: /healthz\n            HealthCheckProtocol: HTTP\n            HealthCheckTimeoutSeconds: 5\n            HealthyThresholdCount: 2\n            UnhealthyThresholdCount: 10\n            TargetType: ip\n            Port: 80\n            Protocol: HTTP\n            VpcId: !Ref VpcId\n\n    LoadBalancer:\n        UpdateReplacePolicy: Retain\n        Type: AWS::ElasticLoadBalancingV2::LoadBalancer\n        DeletionPolicy: Retain\n        Properties:\n            Subnets: !Ref PublicSubnets\n            SecurityGroups:\n                - !Ref LoadBalancerSecurityGroup\n\n    ExternalLoadBalancerListener:\n        UpdateReplacePolicy: Retain\n        DeletionPolicy: Retain\n        Properties:\n            DefaultActions:\n                # FIXME: When AWS CF supports it, redirect to https\n                # instead of forward to target group\n                - TargetGroupArn: !Ref ConcordiaExternalTargetGroup\n                  Type: forward\n            LoadBalancerArn: !Ref LoadBalancer\n            Port: 80\n            Protocol: HTTP\n        Type: AWS::ElasticLoadBalancingV2::Listener\n\n    SecureExternalLoadBalancerListener:\n        UpdateReplacePolicy: Retain\n        DeletionPolicy: Retain\n        Properties:\n            Certificates:\n                - CertificateArn: !Sub 'arn:aws:iam::${AWS::AccountId}:server-certificate/${CanonicalHostName}'\n            DefaultActions:\n                - TargetGroupArn: !Ref ConcordiaExternalTargetGroup\n                  Type: forward\n            LoadBalancerArn: !Ref LoadBalancer\n            Port: 443\n            Protocol: HTTPS\n        Type: AWS::ElasticLoadBalancingV2::Listener\n\n    ECSCluster:\n        UpdateReplacePolicy: Retain\n        Type: AWS::ECS::Cluster\n        DeletionPolicy: Retain\n        Properties:\n            ClusterName: !Ref EnvironmentName\n\n    ConcordiaTask:\n        UpdateReplacePolicy: Retain\n        Type: AWS::ECS::TaskDefinition\n        DeletionPolicy: Retain\n        Properties:\n            Family: !Sub crowd-${EnvName}\n            Cpu: '4096'\n            Memory: '16384'\n            NetworkMode: awsvpc\n            RequiresCompatibilities:\n                - FARGATE\n            ExecutionRoleArn: !GetAtt ConcordiaTaskRole.Arn\n            TaskRoleArn: !GetAtt ConcordiaTaskRole.Arn\n            Volumes:\n                - Name: images_volume\n            ContainerDefinitions:\n                - Name: app\n                  Cpu: 2048\n                  Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/concordia:${ConcordiaVersion}'\n                  LogConfiguration:\n                      LogDriver: awslogs\n                      Options:\n                          awslogs-group: !Ref 'ConcordiaAppLogsGroup'\n                          awslogs-region: !Ref 'AWS::Region'\n                          awslogs-stream-prefix: ConcordiaServer\n                  Environment:\n                      - Name: AWS\n                        Value: '1'\n                      - Name: ENV_NAME\n                        Value: !Ref EnvName\n                      - Name: CONCORDIA_ENVIRONMENT\n                        Value: !Ref FullEnvironmentName\n                      - Name: S3_BUCKET_NAME\n                        Value: !Ref S3BucketName\n                      - Name: EXPORT_S3_BUCKET_NAME\n                        Value: !Ref ExportS3BucketName\n                      - Name: CELERY_BROKER_URL\n                        Value: !Sub 'redis://${RedisAddress}:${RedisPort}/0'\n                      - Name: AWS_DEFAULT_REGION\n                        Value: !Ref AWS::Region\n                      - Name: SENTRY_BACKEND_DSN\n                        Value: https://6727341eabcd47e3a48ce300432e840b@errorlogging.loc.gov/5\n                      - Name: SENTRY_FRONTEND_DSN\n                        Value: https://77a13a941ffd485dbf41dbf8e7a0bdd0@errorlogging.loc.gov/4\n                      - Name: REDIS_ADDRESS\n                        Value: !Ref RedisAddress\n                      - Name: REDIS_PORT\n                        Value: !Ref RedisPort\n                      - Name: POSTGRESQL_HOST\n                        Value: !Ref DatabaseEndpoint\n                      - Name: HOST_NAME\n                        Value: !Ref CanonicalHostName\n                      - Name: DJANGO_SETTINGS_MODULE\n                        Value: concordia.settings_ecs\n                  MountPoints:\n                      - SourceVolume: images_volume\n                        ContainerPath: /concordia_images\n                  PortMappings:\n                      - ContainerPort: 80\n                - Name: importer\n                  Cpu: 1024\n                  Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/concordia/importer:${ConcordiaVersion}'\n                  LogConfiguration:\n                      LogDriver: awslogs\n                      Options:\n                          awslogs-group: !Ref 'ConcordiaAppLogsGroup'\n                          awslogs-region: !Ref 'AWS::Region'\n                          awslogs-stream-prefix: ConcordiaWorker\n                  Environment:\n                      - Name: AWS\n                        Value: '1'\n                      - Name: ENV_NAME\n                        Value: !Ref EnvName\n                      - Name: CONCORDIA_ENVIRONMENT\n                        Value: !Ref FullEnvironmentName\n                      - Name: S3_BUCKET_NAME\n                        Value: !Ref S3BucketName\n                      - Name: EXPORT_S3_BUCKET_NAME\n                        Value: !Ref ExportS3BucketName\n                      - Name: CELERY_BROKER_URL\n                        Value: !Sub 'redis://${RedisAddress}:${RedisPort}/0'\n                      - Name: AWS_DEFAULT_REGION\n                        Value: !Ref AWS::Region\n                      - Name: SENTRY_BACKEND_DSN\n                        Value: https://6727341eabcd47e3a48ce300432e840b@errorlogging.loc.gov/5\n                      - Name: SENTRY_FRONTEND_DSN\n                        Value: https://77a13a941ffd485dbf41dbf8e7a0bdd0@errorlogging.loc.gov/4\n                      - Name: REDIS_ADDRESS\n                        Value: !Ref RedisAddress\n                      - Name: REDIS_PORT\n                        Value: !Ref RedisPort\n                      - Name: POSTGRESQL_HOST\n                        Value: !Ref DatabaseEndpoint\n                      - Name: HOST_NAME\n                        Value: !Ref CanonicalHostName\n                      - Name: DJANGO_SETTINGS_MODULE\n                        Value: concordia.settings_ecs\n                  MountPoints:\n                      - SourceVolume: images_volume\n                        ContainerPath: /concordia_images\n                - Name: celerybeat\n                  Cpu: 1024\n                  Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/concordia/celerybeat:${ConcordiaVersion}'\n                  LogConfiguration:\n                      LogDriver: awslogs\n                      Options:\n                          awslogs-group: !Ref 'ConcordiaAppLogsGroup'\n                          awslogs-region: !Ref 'AWS::Region'\n                          awslogs-stream-prefix: ConcordiaWorker\n                  Environment:\n                      - Name: AWS\n                        Value: '1'\n                      - Name: ENV_NAME\n                        Value: !Ref EnvName\n                      - Name: CONCORDIA_ENVIRONMENT\n                        Value: !Ref FullEnvironmentName\n                      - Name: S3_BUCKET_NAME\n                        Value: !Ref S3BucketName\n                      - Name: EXPORT_S3_BUCKET_NAME\n                        Value: !Ref ExportS3BucketName\n                      - Name: CELERY_BROKER_URL\n                        Value: !Sub 'redis://${RedisAddress}:${RedisPort}/0'\n                      - Name: AWS_DEFAULT_REGION\n                        Value: !Ref AWS::Region\n                      - Name: SENTRY_BACKEND_DSN\n                        Value: https://6727341eabcd47e3a48ce300432e840b@errorlogging.loc.gov/5\n                      - Name: SENTRY_FRONTEND_DSN\n                        Value: https://77a13a941ffd485dbf41dbf8e7a0bdd0@errorlogging.loc.gov/4\n                      - Name: REDIS_ADDRESS\n                        Value: !Ref RedisAddress\n                      - Name: REDIS_PORT\n                        Value: !Ref RedisPort\n                      - Name: POSTGRESQL_HOST\n                        Value: !Ref DatabaseEndpoint\n                      - Name: HOST_NAME\n                        Value: !Ref CanonicalHostName\n                      - Name: DJANGO_SETTINGS_MODULE\n                        Value: concordia.settings_ecs\n\n    ConcordiaExternalService:\n        UpdateReplacePolicy: Retain\n        Type: AWS::ECS::Service\n        DependsOn: ExternalLoadBalancerListener\n        DeletionPolicy: Retain\n        Properties:\n            Cluster: !Ref ECSCluster\n            LaunchType: FARGATE\n            DeploymentConfiguration:\n                MaximumPercent: 200\n                MinimumHealthyPercent: 75\n            DesiredCount: 1\n            NetworkConfiguration:\n                AwsvpcConfiguration:\n                    SecurityGroups:\n                        - !Ref SecurityGroup\n                    Subnets: !Ref PrivateSubnets\n            TaskDefinition: !Ref ConcordiaTask\n            LoadBalancers:\n                - ContainerName: 'app'\n                  ContainerPort: 80\n                  TargetGroupArn: !Ref ConcordiaExternalTargetGroup\n\nOutputs:\n    LoadBalancerUrl:\n        Description: The URL of the ALB\n        Value: !GetAtt LoadBalancer.DNSName\n"
  },
  {
    "path": "cloudformation/infrastructure/fargate-featurebranch.yaml",
    "content": "Description: >\n    This template deploys a fargate cluster to the provided VPC and subnets\n\nParameters:\n    SecurityGroup:\n        Description: Select the Security Group to use for the ECS cluster hosts\n        Type: AWS::EC2::SecurityGroup::Id\n\n    VpcId:\n        Description: The Id of the VPC for this cluster\n        Type: AWS::EC2::VPC::Id\n\n    ConcordiaVersion:\n        Type: String\n        Description: docker tag of concordia app image to pull and deploy\n        Default: latest\n\n    EnvName:\n        Type: String\n        Description: which environment to target\n        AllowedValues:\n            - 'dev'\n            - 'test'\n            - 'stage'\n            - 'prod'\n        ConstraintDescription: Must match a location for secret storage in secretsmanager\n\n    FullEnvironmentName:\n        Type: String\n        Description: Full name of deployment environment\n        AllowedValues:\n            - 'development'\n            - 'test'\n            - 'staging'\n            - 'production'\n\n    RedisAddress:\n        Type: String\n        Description: Redis endpoint address\n\n    RedisPort:\n        Type: String\n        Description: Redis endpoint port\n\n    CanonicalHostName:\n        Type: String\n        Description: canonical host name of the application, e.g. crowd-test.loc.gov\n\n    DatabaseEndpoint:\n        Type: String\n        Description: Host name of the Postgres RDS service\n\n    S3BucketName:\n        Type: String\n        Description: name of the S3 bucket (public) where collection images will be stored\n\n    ExportS3BucketName:\n        Type: String\n        Description: name of the S3 bucket (public) where exported transcriptions will be stored\n\n    Priority:\n        Type: Number\n        Description: Priority of the subdomain listener rule, must be unique in the set of listener rules\n        Default: 100\n\n    DataLoadStackName:\n        Type: String\n        Description: Signal that the DataLoadHost UserData has completed\n\nResources:\n    ConcordiaAppLogsGroup:\n        Type: AWS::Logs::LogGroup\n        Properties:\n            LogGroupName: !Ref AWS::StackName\n            RetentionInDays: 30\n\n    ConcordiaExternalTargetGroup:\n        Type: AWS::ElasticLoadBalancingV2::TargetGroup\n        Properties:\n            HealthCheckIntervalSeconds: 30\n            HealthCheckPath: /healthz\n            HealthCheckProtocol: HTTP\n            HealthCheckTimeoutSeconds: 5\n            HealthyThresholdCount: 2\n            UnhealthyThresholdCount: 10\n            TargetType: ip\n            Port: 80\n            Protocol: HTTP\n            VpcId: !Ref VpcId\n\n    SubdomainListenerRule:\n        Type: AWS::ElasticLoadBalancingV2::ListenerRule\n        Properties:\n            Actions:\n                - TargetGroupArn: !Ref ConcordiaExternalTargetGroup\n                  Type: forward\n            Conditions:\n                - Field: host-header\n                  Values:\n                      - !Ref CanonicalHostName\n            ListenerArn: arn:aws:elasticloadbalancing:us-east-1:619333082511:listener/app/crowd-test/81e4820e354ea810/187fd94e534ad833\n            Priority: !Ref Priority\n\n    ConcordiaTask:\n        Type: AWS::ECS::TaskDefinition\n        Properties:\n            Family: !Sub crowd-${ConcordiaVersion}\n            Cpu: '4096'\n            Memory: '30720'\n            NetworkMode: awsvpc\n            RequiresCompatibilities:\n                - FARGATE\n            ExecutionRoleArn: !Sub 'arn:aws:iam::${AWS::AccountId}:role/ConcordiaServerTaskRole-crowd-test'\n            TaskRoleArn: !Sub 'arn:aws:iam::${AWS::AccountId}:role/ConcordiaServerTaskRole-crowd-test'\n            Volumes:\n                - Name: images_volume\n            ContainerDefinitions:\n                - Name: app\n                  Cpu: 2048\n                  Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/concordia:${ConcordiaVersion}'\n                  LogConfiguration:\n                      LogDriver: awslogs\n                      Options:\n                          awslogs-group: !Ref 'ConcordiaAppLogsGroup'\n                          awslogs-region: !Ref 'AWS::Region'\n                          awslogs-stream-prefix: ConcordiaServer\n                  Environment:\n                      - Name: AWS\n                        Value: '1'\n                      - Name: ENV_NAME\n                        Value: !Ref EnvName\n                      - Name: CONCORDIA_ENVIRONMENT\n                        Value: !Ref FullEnvironmentName\n                      - Name: S3_BUCKET_NAME\n                        Value: !Ref S3BucketName\n                      - Name: EXPORT_S3_BUCKET_NAME\n                        Value: !Ref ExportS3BucketName\n                      - Name: CELERY_BROKER_URL\n                        Value: !Sub 'redis://${RedisAddress}:${RedisPort}/0'\n                      - Name: AWS_DEFAULT_REGION\n                        Value: !Ref AWS::Region\n                      - Name: SENTRY_BACKEND_DSN\n                        Value: https://6727341eabcd47e3a48ce300432e840b@errorlogging.loc.gov/5\n                      - Name: SENTRY_FRONTEND_DSN\n                        Value: https://77a13a941ffd485dbf41dbf8e7a0bdd0@errorlogging.loc.gov/4\n                      - Name: REDIS_ADDRESS\n                        Value: !Ref RedisAddress\n                      - Name: REDIS_PORT\n                        Value: !Ref RedisPort\n                      - Name: POSTGRESQL_HOST\n                        Value: !Ref DatabaseEndpoint\n                      - Name: HOST_NAME\n                        Value: !Ref CanonicalHostName\n                      - Name: DJANGO_SETTINGS_MODULE\n                        Value: concordia.settings_ecs\n                  MountPoints:\n                      - SourceVolume: images_volume\n                        ContainerPath: /concordia_images\n                  PortMappings:\n                      - ContainerPort: 80\n                - Name: importer\n                  Cpu: 1024\n                  Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/concordia/importer:${ConcordiaVersion}'\n                  LogConfiguration:\n                      LogDriver: awslogs\n                      Options:\n                          awslogs-group: !Ref 'ConcordiaAppLogsGroup'\n                          awslogs-region: !Ref 'AWS::Region'\n                          awslogs-stream-prefix: ConcordiaWorker\n                  Environment:\n                      - Name: AWS\n                        Value: '1'\n                      - Name: ENV_NAME\n                        Value: !Ref EnvName\n                      - Name: CONCORDIA_ENVIRONMENT\n                        Value: !Ref FullEnvironmentName\n                      - Name: S3_BUCKET_NAME\n                        Value: !Ref S3BucketName\n                      - Name: EXPORT_S3_BUCKET_NAME\n                        Value: !Ref ExportS3BucketName\n                      - Name: CELERY_BROKER_URL\n                        Value: !Sub 'redis://${RedisAddress}:${RedisPort}/0'\n                      - Name: AWS_DEFAULT_REGION\n                        Value: !Ref AWS::Region\n                      - Name: SENTRY_BACKEND_DSN\n                        Value: https://6727341eabcd47e3a48ce300432e840b@errorlogging.loc.gov/5\n                      - Name: SENTRY_FRONTEND_DSN\n                        Value: https://77a13a941ffd485dbf41dbf8e7a0bdd0@errorlogging.loc.gov/4\n                      - Name: REDIS_ADDRESS\n                        Value: !Ref RedisAddress\n                      - Name: REDIS_PORT\n                        Value: !Ref RedisPort\n                      - Name: POSTGRESQL_HOST\n                        Value: !Ref DatabaseEndpoint\n                      - Name: HOST_NAME\n                        Value: !Ref CanonicalHostName\n                      - Name: DJANGO_SETTINGS_MODULE\n                        Value: concordia.settings_ecs\n                  MountPoints:\n                      - SourceVolume: images_volume\n                        ContainerPath: /concordia_images\n                - Name: celerybeat\n                  Cpu: 1024\n                  Image: !Sub '${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/concordia/celerybeat:${ConcordiaVersion}'\n                  LogConfiguration:\n                      LogDriver: awslogs\n                      Options:\n                          awslogs-group: !Ref 'ConcordiaAppLogsGroup'\n                          awslogs-region: !Ref 'AWS::Region'\n                          awslogs-stream-prefix: ConcordiaWorker\n                  Environment:\n                      - Name: AWS\n                        Value: '1'\n                      - Name: ENV_NAME\n                        Value: !Ref EnvName\n                      - Name: CONCORDIA_ENVIRONMENT\n                        Value: !Ref FullEnvironmentName\n                      - Name: S3_BUCKET_NAME\n                        Value: !Ref S3BucketName\n                      - Name: EXPORT_S3_BUCKET_NAME\n                        Value: !Ref ExportS3BucketName\n                      - Name: CELERY_BROKER_URL\n                        Value: !Sub 'redis://${RedisAddress}:${RedisPort}/0'\n                      - Name: AWS_DEFAULT_REGION\n                        Value: !Ref AWS::Region\n                      - Name: SENTRY_BACKEND_DSN\n                        Value: https://6727341eabcd47e3a48ce300432e840b@errorlogging.loc.gov/5\n                      - Name: SENTRY_FRONTEND_DSN\n                        Value: https://77a13a941ffd485dbf41dbf8e7a0bdd0@errorlogging.loc.gov/4\n                      - Name: REDIS_ADDRESS\n                        Value: !Ref RedisAddress\n                      - Name: REDIS_PORT\n                        Value: !Ref RedisPort\n                      - Name: POSTGRESQL_HOST\n                        Value: !Ref DatabaseEndpoint\n                      - Name: HOST_NAME\n                        Value: !Ref CanonicalHostName\n                      - Name: DJANGO_SETTINGS_MODULE\n                        Value: concordia.settings_ecs\n\n    ConcordiaExternalService:\n        Type: AWS::ECS::Service\n        Properties:\n            Cluster: crowd-test\n            LaunchType: FARGATE\n            DeploymentConfiguration:\n                DeploymentCircuitBreaker:\n                    Enable: true\n                    Rollback: false\n                MaximumPercent: 200\n                MinimumHealthyPercent: 75\n            DesiredCount: 1\n            EnableExecuteCommand: true\n            NetworkConfiguration:\n                AwsvpcConfiguration:\n                    SecurityGroups:\n                        - !Ref SecurityGroup\n                    Subnets:\n                        - subnet-0aa55b322229b945a\n                        - subnet-0f65558b319b2d4dc\n            TaskDefinition: !Ref ConcordiaTask\n            LoadBalancers:\n                - ContainerName: 'app'\n                  ContainerPort: 80\n                  TargetGroupArn: !Ref ConcordiaExternalTargetGroup\n"
  },
  {
    "path": "cloudformation/infrastructure/jenkins-server.yaml",
    "content": "Description: This template deploys an Ubuntu jenkins server in the default VPC.\n\nResources:\n    Jenkins:\n        Type: AWS::EC2::Instance\n        Properties:\n            ImageId: 'ami-042e8287309f5df03'\n            InstanceType: 't2.xlarge'\n            IamInstanceProfile: 'concordia-jenkins-ec2-role'\n            BlockDeviceMappings:\n                - DeviceName: /dev/sda1\n                  Ebs:\n                      VolumeSize: 128\n                      VolumeType: gp3\n                      DeleteOnTermination: true\n            NetworkInterfaces:\n                - AssociatePublicIpAddress: true\n                  DeviceIndex: '0'\n                  GroupSet:\n                      - 'sg-02ff28781d04fd191'\n                  SubnetId: 'subnet-3748107d'\n            UserData:\n                Fn::Base64: !Sub |\n                    #!/bin/bash -xe\n                    echo \"Running userdata for ${AWS::StackName}\"\n                    wget -q -O - https://pkg.jenkins.io/debian/jenkins.io.key | apt-key add -\n                    curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -\n                    sh -c 'echo deb http://pkg.jenkins.io/debian-stable binary/ > /etc/apt/sources.list.d/jenkins.list'\n                    add-apt-repository \\\n                      \"deb [arch=amd64] https://download.docker.com/linux/ubuntu \\\n                      $(lsb_release -cs) \\\n                      stable\"\n                    apt-get update\n                    apt-get install -qy -o Dpkg::Options::='--force-confnew' \\\n                      python3 python3-dev python3-venv python3-pip \\\n                      libtiff-dev libmemcached-dev libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev \\\n                      graphviz apt-transport-https libpq-dev \\\n                      ca-certificates \\\n                      curl \\\n                      gnupg-agent \\\n                      software-properties-common \\\n                      docker-ce docker-ce-cli containerd.io \\\n                      openjdk-8-jdk jenkins \\\n                      nginx awscli\n                    usermod -aG docker jenkins\n                    snap install postgresql12\n                    pip3 install awscli --upgrade\n            Tags:\n                - Key: Name\n                  Value: Jenkins\n                - Key: Environment\n                  Value: dev\n"
  },
  {
    "path": "cloudformation/infrastructure/network-acl.yaml",
    "content": "Description: >\n    This template contains the security groups required by our entire stack.\n    We create them in a seperate nested template, so they can be referenced\n    by all of the other nested templates.\n\nParameters:\n    EnvironmentName:\n        Description: An environment name that will be prefixed to resource names\n        Type: String\n\n    VPC:\n        Type: AWS::EC2::VPC::Id\n        Description: Choose which VPC the security groups should be deployed to\n\n    PublicSubnet1:\n        Description: A reference to the public subnet in the 1st Availability Zone\n        Type: AWS::EC2::Subnet::Id\n\n    PublicSubnet2:\n        Description: A reference to the public subnet in the 2nd Availability Zone\n        Type: AWS::EC2::Subnet::Id\n\n    PrivateSubnet1:\n        Description: A reference to the private subnet in the 1st Availability Zone\n        Type: AWS::EC2::Subnet::Id\n\n    PrivateSubnet2:\n        Description: A reference to the private subnet in the 2nd Availability Zone\n        Type: AWS::EC2::Subnet::Id\n\nResources:\n    NetworkAcl:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAcl\n        DeletionPolicy: Retain\n        Properties:\n            VpcId:\n                Ref: VPC\n            Tags:\n                - Key: Name\n                  Value: !Ref EnvironmentName\n\n    # TODO: Update these ACLs to the latest OCIO standard ones\n    # NOTE: These rules are for dev / test / stage only\n\n    acl4:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 0.0.0.0/0\n            Egress: true\n            Protocol: -1\n            RuleAction: allow\n            RuleNumber: 100\n            NetworkAclId: !Ref NetworkAcl\n    acl5:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 140.147.236.152/32\n            Protocol: -1\n            RuleAction: deny\n            RuleNumber: 10\n            NetworkAclId: !Ref NetworkAcl\n    acl6:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 140.147.236.214/32\n            Protocol: -1\n            RuleAction: deny\n            RuleNumber: 11\n            NetworkAclId: !Ref NetworkAcl\n    acl6b:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 140.147.236.213/32\n            Protocol: -1\n            RuleAction: deny\n            RuleNumber: 12\n            NetworkAclId: !Ref NetworkAcl\n    acl7:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 140.147.0.0/16\n            Protocol: 6\n            RuleAction: allow\n            RuleNumber: 100\n            PortRange:\n                From: 22\n                To: 22\n            NetworkAclId:\n                Ref: NetworkAcl\n    acl8:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 0.0.0.0/0\n            Protocol: 6\n            RuleAction: allow\n            RuleNumber: 110\n            PortRange:\n                From: 1024\n                To: 65535\n            NetworkAclId:\n                Ref: NetworkAcl\n    acl9:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 0.0.0.0/0\n            Protocol: 6\n            RuleAction: allow\n            RuleNumber: 200\n            PortRange:\n                From: 80\n                To: 80\n            NetworkAclId: !Ref NetworkAcl\n    acl10:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 0.0.0.0/0\n            Protocol: 6\n            RuleAction: allow\n            RuleNumber: 210\n            PortRange:\n                From: 443\n                To: 443\n            NetworkAclId: !Ref NetworkAcl\n\n    acl11:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NetworkAclEntry\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: 0.0.0.0/0\n            Protocol: -1\n            RuleAction: allow\n            RuleNumber: 300\n            NetworkAclId: !Ref NetworkAcl\n\n    subnetacl5:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SubnetNetworkAclAssociation\n        DeletionPolicy: Retain\n        Properties:\n            NetworkAclId: !Ref NetworkAcl\n            SubnetId: !Ref PrivateSubnet1\n\n    subnetacl6:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SubnetNetworkAclAssociation\n        DeletionPolicy: Retain\n        Properties:\n            NetworkAclId: !Ref NetworkAcl\n            SubnetId: !Ref PrivateSubnet2\n\n    subnetacl7:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SubnetNetworkAclAssociation\n        DeletionPolicy: Retain\n        Properties:\n            NetworkAclId: !Ref NetworkAcl\n            SubnetId: !Ref PublicSubnet1\n\n    subnetacl8:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SubnetNetworkAclAssociation\n        DeletionPolicy: Retain\n        Properties:\n            NetworkAclId: !Ref NetworkAcl\n            SubnetId: !Ref PublicSubnet2\n"
  },
  {
    "path": "cloudformation/infrastructure/opensearch.yaml",
    "content": "Description: >\n    This template deploys a VPC-based OpenSearch cluster.\n\nParameters:\n    EnvName:\n        Type: String\n        Description: which environment to target\n        AllowedValues:\n            - 'dev'\n            - 'test'\n            - 'stage'\n            - 'prod'\n        ConstraintDescription: Must match a location for secret storage in secretsmanager\n\n    SecurityGroup:\n        Description: Select the Security Group to use for the ECS cluster hosts\n        Type: AWS::EC2::SecurityGroup::Id\n\n    PrivateSubnet2:\n        Description: The private subnet in AZ2 for the VPC\n        Type: AWS::EC2::Subnet::Id\n\nResources:\n    ESCluster:\n        Type: AWS::OpenSearchService::Domain\n        Properties:\n            ClusterConfig:\n                InstanceCount: 1\n                ZoneAwarenessEnabled: false\n                InstanceType: 'm7g.xlarge.search'\n            EngineVersion: '1.3'\n            EBSOptions:\n                EBSEnabled: true\n                Iops: 0\n                VolumeSize: 30\n                VolumeType: 'gp3'\n            SnapshotOptions:\n                AutomatedSnapshotStartHour: 0\n            AccessPolicies:\n                Version: '2012-10-17'\n                Statement:\n                    - Effect: 'Allow'\n                      Principal:\n                          AWS: '*'\n                      Action: 'es:*'\n                      Resource: !Sub 'arn:aws:es:us-east-1:619333082511:domain/crowd-${EnvName}-vpc/*'\n            AdvancedOptions:\n                rest.action.multi.allow_explicit_index: 'true'\n            Tags:\n                - Key: Environment\n                  Value: !Ref EnvName\n            VPCOptions:\n                SubnetIds:\n                    - Ref: PrivateSubnet2\n                SecurityGroupIds:\n                    - Ref: SecurityGroup\n"
  },
  {
    "path": "cloudformation/infrastructure/rds.yaml",
    "content": "AWSTemplateFormatVersion: '2010-09-09'\nParameters:\n    DatabaseSecurityGroup:\n        Description: Sets the security group to use for RDS database access\n        Type: AWS::EC2::SecurityGroup::Id\n\n    PrivateSubnet1:\n        Description: A reference to the private subnet in the 1st Availability Zone\n        Type: AWS::EC2::Subnet::Id\n\n    PrivateSubnet2:\n        Description: A reference to the private subnet in the 2nd Availability Zone\n        Type: AWS::EC2::Subnet::Id\n\n    DbUsername:\n        Description: The username to use for the database\n        Type: String\n        NoEcho: true\n\n    DbPassword:\n        Description: The password to use for the database\n        Type: String\n        NoEcho: true\n\nResources:\n    PostgresSubnetGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::RDS::DBSubnetGroup\n        DeletionPolicy: Retain\n        Properties:\n            DBSubnetGroupDescription: Created from the RDS Management Console\n            SubnetIds:\n                - Ref: PrivateSubnet1\n                - Ref: PrivateSubnet2\n\n    PostgresService:\n        UpdateReplacePolicy: Retain\n        Type: AWS::RDS::DBInstance\n        DeletionPolicy: Retain\n        Properties:\n            AllocatedStorage: '20'\n            AllowMajorVersionUpgrade: false\n            AutoMinorVersionUpgrade: true\n            DBInstanceClass: db.t4g.medium\n            Port: '5432'\n            PubliclyAccessible: false\n            StorageType: gp3\n            StorageEncrypted: True\n            BackupRetentionPeriod: 31\n            MasterUsername: !Ref DbUsername\n            MasterUserPassword: !Ref DbPassword\n            PreferredBackupWindow: 03:47-04:17\n            PreferredMaintenanceWindow: tue:03:14-tue:03:44\n            DBName: concordia\n            Engine: postgres\n            EngineVersion: '15.5'\n            LicenseModel: postgresql-license\n            DBSubnetGroupName:\n                Ref: PostgresSubnetGroup\n            VPCSecurityGroups:\n                - Ref: DatabaseSecurityGroup\n            Tags:\n                - Key: workload-type\n                  Value: other\n\nOutputs:\n    DatabaseHostName:\n        Description: 'Hostname for the relational database service'\n        Value: !GetAtt PostgresService.Endpoint.Address\n"
  },
  {
    "path": "cloudformation/infrastructure/search-proxy-task.yaml",
    "content": "Description: >\n    This template deploys an opensearch dashboard proxy server to the specified VPC\n\nParameters:\n    VpcId:\n        Description: The Id of the VPC for this cluster\n        Type: AWS::EC2::VPC::Id\n\n    EnvName:\n        Type: String\n        Description: which environment to target\n        AllowedValues:\n            - 'dev'\n            - 'test'\n            - 'stage'\n            - 'prod'\n        ConstraintDescription: Must match a location for secret storage in secretsmanager\n\n    Priority:\n        Type: Number\n        Description: Priority of the subdomain listener rule, must be unique in the set of listener rules\n        Default: 100\n\nMappings:\n    EnvironmentMapping:\n        ListenerArn:\n            dev: 'arn:aws:elasticloadbalancing:us-east-1:619333082511:listener/app/crowd-dev/112d22a79e25de0b/8bb4cb9c8b054e91'\n            test: 'arn:aws:elasticloadbalancing:us-east-1:619333082511:listener/app/crowd-test/81e4820e354ea810/187fd94e534ad833'\n            stage: 'arn:aws:elasticloadbalancing:us-east-1:619333082511:listener/app/crowd-stage/7d954bca84b62358/ab34414a68f355f2'\n            prod: 'arn:aws:elasticloadbalancing:us-east-1:619333082511:listener/app/crowd-prod/746d0ae14ecc23e4/747212dd4e5706be'\n\n        TaskRoleArn:\n            dev: 'arn:aws:iam::619333082511:role/ConcordiaServerTaskRole-crowd-dev'\n            test: 'arn:aws:iam::619333082511:role/ConcordiaServerTaskRole-crowd-test'\n            stage: 'arn:aws:iam::619333082511:role/ConcordiaServerTaskRole-crowd-stage'\n            prod: 'arn:aws:iam::619333082511:role/ConcordiaServerTaskRole-crowd-prod'\n\n        # The ID of a private subnet\n        # Type: AWS::EC2::Subnet::Id\n        PrivateSubnet1:\n            dev: subnet-0c95a830ce007fa65\n            test: subnet-0aa55b322229b945a\n            stage: subnet-0f7c7d66b66d6dd90\n            prod: subnet-0da84976b66c32ce4\n\n        OpensearchEndpoint:\n            dev: 'https://vpc-crowd-dev-vpc-6xqqrxn5naqkvtdl6r6uanlhbe.us-east-1.es.amazonaws.com'\n            test: 'https://vpc-crowd-test-vpc-63g3ylzduyzywhqbsqotnnm7ke.us-east-1.es.amazonaws.com'\n            stage: 'https://vpc-crowd-stage-vpc-x5lgoj5yo76dvrxpfhmusss2b4.us-east-1.es.amazonaws.com'\n            prod: 'https://vpc-crowd-prod-vpc-zl5xdhmtpr7squr6mtl7znqyqa.us-east-1.es.amazonaws.com'\n\n        # The security group\n        # Type: AWS::EC2::SecurityGroup::Id\n        SecurityGroup:\n            dev: sg-0ceb6b1dc0de899b3\n            test: sg-09bc01194e6c52cb9\n            stage: sg-0f6145067777b1cc3\n            prod: sg-031594e2cfc8b25c7\n\nResources:\n    DashboardLogsGroup:\n        Type: AWS::Logs::LogGroup\n        Properties:\n            LogGroupName: !Ref AWS::StackName\n            RetentionInDays: 30\n\n    DashboardTargetGroup:\n        Type: AWS::ElasticLoadBalancingV2::TargetGroup\n        Properties:\n            HealthCheckIntervalSeconds: 30\n            HealthCheckPath: /\n            HealthCheckProtocol: HTTP\n            HealthCheckTimeoutSeconds: 5\n            HealthyThresholdCount: 2\n            UnhealthyThresholdCount: 10\n            TargetType: ip\n            Port: 80\n            Protocol: HTTP\n            VpcId: !Ref VpcId\n            Matcher:\n                HttpCode: '200,301' # Add this line for success codes\n            Tags:\n                - Key: Project\n                  Value: Concordia\n                - Key: Department\n                  Value: OCIO\n                - Key: ArcherID\n                  Value: LIB-361\n                - Key: Environment\n                  Value: Development\n                - Key: StackManaged\n                  Value: crowd-dev-searchproxy\n\n    SubdomainListenerRule:\n        Type: AWS::ElasticLoadBalancingV2::ListenerRule\n        Properties:\n            Actions:\n                - TargetGroupArn: !Ref DashboardTargetGroup\n                  Type: forward\n            Conditions:\n                - Field: path-pattern\n                  Values:\n                      - '/_dashboards*'\n            ListenerArn:\n                Fn::FindInMap:\n                    - EnvironmentMapping\n                    - ListenerArn\n                    - Ref: EnvName\n            Priority: !Ref Priority\n\n    DashboardTask:\n        Type: AWS::ECS::TaskDefinition\n        Properties:\n            Family: !Sub crowd-${EnvName}-searchproxy\n            Cpu: '256'\n            Memory: '512'\n            NetworkMode: awsvpc\n            RequiresCompatibilities:\n                - FARGATE\n            ExecutionRoleArn: !Sub 'arn:aws:iam::${AWS::AccountId}:role/ecsTaskExecutionRole'\n            TaskRoleArn:\n                Fn::FindInMap:\n                    - EnvironmentMapping\n                    - TaskRoleArn\n                    - Ref: EnvName\n            ContainerDefinitions:\n                - Name: sigv4proxy\n                  Image: public.ecr.aws/aws-observability/aws-sigv4-proxy:1.10\n                  Cpu: 256\n                  Memory: 512\n                  Essential: true\n                  PortMappings:\n                      - ContainerPort: 80\n                        Protocol: tcp\n                  LogConfiguration:\n                      LogDriver: awslogs\n                      Options:\n                          awslogs-group: !Ref 'DashboardLogsGroup'\n                          awslogs-region: !Ref 'AWS::Region'\n                          awslogs-stream-prefix: ConcordiaDashboardProxy\n                  Environment:\n                      - Name: OPENSEARCH_ENDPOINT\n                        Value:\n                            Fn::FindInMap:\n                                - EnvironmentMapping\n                                - OpensearchEndpoint\n                                - Ref: EnvName\n                  Command:\n                      - --name\n                      - es\n                      - --region\n                      - !Ref AWS::Region\n                      - --host\n                      - !Select\n                        - 1\n                        - !Split\n                          - '://'\n                          - !FindInMap [\n                                EnvironmentMapping,\n                                OpensearchEndpoint,\n                                !Ref EnvName,\n                            ]\n                      - --port\n                      - '0.0.0.0:80'\n                      - --sign-host\n                      - !Select\n                        - 1\n                        - !Split\n                          - '://'\n                          - !FindInMap [\n                                EnvironmentMapping,\n                                OpensearchEndpoint,\n                                !Ref EnvName,\n                            ]\n                      - --no-verify-ssl\n            Tags:\n                - Key: Project\n                  Value: Concordia\n                - Key: Department\n                  Value: OCIO\n                - Key: ArcherID\n                  Value: LIB-361\n                - Key: Environment\n                  Value: Development\n                - Key: StackManaged\n                  Value: crowd-dev-searchproxy\n\n    DashboardService:\n        Type: AWS::ECS::Service\n        Properties:\n            Cluster: !Sub crowd-${EnvName}\n            LaunchType: FARGATE\n            DeploymentConfiguration:\n                MaximumPercent: 200\n                MinimumHealthyPercent: 100\n            DesiredCount: 1\n            NetworkConfiguration:\n                AwsvpcConfiguration:\n                    SecurityGroups:\n                        - Fn::FindInMap:\n                              - EnvironmentMapping\n                              - SecurityGroup\n                              - Ref: EnvName\n                    Subnets:\n                        - Fn::FindInMap:\n                              - EnvironmentMapping\n                              - PrivateSubnet1\n                              - Ref: EnvName\n            TaskDefinition: !Ref DashboardTask\n            LoadBalancers:\n                - ContainerName: 'sigv4proxy'\n                  ContainerPort: 80\n                  TargetGroupArn: !Ref DashboardTargetGroup\n            EnableExecuteCommand: true\n            Tags:\n                - Key: Project\n                  Value: Concordia\n                - Key: Department\n                  Value: OCIO\n                - Key: ArcherID\n                  Value: LIB-361\n                - Key: Environment\n                  Value: Development\n                - Key: StackManaged\n                  Value: crowd-dev-searchproxy\n"
  },
  {
    "path": "cloudformation/infrastructure/security-groups.yaml",
    "content": "Description: >\n    This template contains the security groups required by our entire stack.\n    We create them in a seperate nested template, so they can be referenced\n    by all of the other nested templates.\n\nParameters:\n    EnvironmentName:\n        Description: An environment name that will be prefixed to resource names\n        Type: String\n\n    VPC:\n        Type: AWS::EC2::VPC::Id\n        Description: Choose which VPC the security groups should be deployed to\n\nResources:\n    # This security group defines who/where is allowed to access the ECS hosts directly.\n    # By default we're just allowing access from the load balancer.  If you want to SSH\n    # into the hosts, or expose non-load balanced services you can open their ports here.\n    ECSHostSecurityGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SecurityGroup\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            GroupDescription: Access to the ECS hosts and the tasks/containers that run on them\n            SecurityGroupIngress:\n                - Description: 'Open access to container hosts from the load balancer'\n                  SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup\n                  IpProtocol: '-1'\n                - Description: 'SSH access to container hosts from bastion hosts'\n                  SourceSecurityGroupId: !Ref BastionHostSecurityGroup\n                  IpProtocol: tcp\n                  FromPort: 22\n                  ToPort: 22\n            SecurityGroupEgress:\n                - Description: 'Explicit outbound access'\n                  IpProtocol: '-1'\n                  CidrIp: 0.0.0.0/0\n\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName}-ECS-Hosts\n\n    LoadBalancerSecurityGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SecurityGroup\n        Metadata:\n            cfn_nag:\n                rules_to_suppress:\n                    - id: W9\n                      reason: 'The CIDR block should only allow 140.147.*.* IPs so it should end in /16'\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            GroupDescription: Access to the load balancer that sits in front of ECS\n            SecurityGroupIngress:\n                - Description: 'Allow HTTP access from the LC network to our ECS services'\n                  CidrIp: 140.147.0.0/16\n                  IpProtocol: tcp\n                  FromPort: 80\n                  ToPort: 80\n                - Description: 'Allow HTTPS access from the LC network to our ECS services'\n                  CidrIp: 140.147.0.0/16\n                  IpProtocol: tcp\n                  FromPort: 443\n                  ToPort: 443\n            SecurityGroupEgress:\n                - Description: 'Explicit outbound access'\n                  IpProtocol: '-1'\n                  CidrIp: 0.0.0.0/0\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName}-LoadBalancers\n                - Key: AllowCloudFlareIngress\n                  Value: 'true'\n\n    DatabaseSecurityGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SecurityGroup\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            GroupDescription: Access to the RDS Postgres database\n            SecurityGroupIngress:\n                - Description: 'Postgresql access to RDS from container hosts'\n                  SourceSecurityGroupId: !Ref ECSHostSecurityGroup\n                  IpProtocol: tcp\n                  FromPort: 5432\n                  ToPort: 5432\n            SecurityGroupEgress:\n                - Description: 'Explicit outbound access'\n                  IpProtocol: '-1'\n                  CidrIp: 0.0.0.0/0\n\n    BastionHostSecurityGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SecurityGroup\n        Metadata:\n            cfn_nag:\n                rules_to_suppress:\n                    - id: W9\n                      reason: 'The CIDR block should only allow 140.147.*.* IPs so it should end in /16'\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            GroupDescription: Bastion hosts for ECS access\n            SecurityGroupIngress:\n                - Description: 'SSH access from LC network to bastion hosts'\n                  CidrIp: 140.147.0.0/16\n                  IpProtocol: tcp\n                  FromPort: 22\n                  ToPort: 22\n            SecurityGroupEgress:\n                - Description: 'Explicit outbound access'\n                  IpProtocol: '-1'\n                  CidrIp: 0.0.0.0/0\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName}-BastionHosts\n\n    CacheServiceSecurityGroup:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SecurityGroup\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            GroupDescription: Access to cache services for ECS hosts\n            SecurityGroupIngress:\n                - Description: 'Redis service access from container hosts'\n                  SourceSecurityGroupId: !Ref 'ECSHostSecurityGroup'\n                  IpProtocol: tcp\n                  FromPort: 6379\n                  ToPort: 6379\n            SecurityGroupEgress:\n                - Description: 'Explicit outbound access'\n                  IpProtocol: '-1'\n                  CidrIp: 0.0.0.0/0\n\nOutputs:\n    ECSHostSecurityGroup:\n        Description: A reference to the security group for ECS hosts\n        Value: !Ref ECSHostSecurityGroup\n\n    LoadBalancerSecurityGroup:\n        Description: A reference to the security group for load balancers\n        Value: !Ref LoadBalancerSecurityGroup\n\n    DatabaseSecurityGroup:\n        Description: A reference to the security group for RDS\n        Value: !Ref DatabaseSecurityGroup\n\n    BastionHostSecurityGroup:\n        Description: A reference to the security group for bastion hosts\n        Value: !Ref BastionHostSecurityGroup\n\n    CacheServiceSecurityGroup:\n        Description: A reference to the security group for cache services\n        Value: !Ref CacheServiceSecurityGroup\n"
  },
  {
    "path": "cloudformation/infrastructure/vpc.yaml",
    "content": "Description: >\n    This template deploys a VPC, with a pair of public and private subnets spread\n    across two Availabilty Zones. It deploys an Internet Gateway, with a default\n    route on the public subnets. It deploys a pair of NAT Gateways (one in each AZ),\n    and default routes for them in the private subnets.\n\nParameters:\n    EnvironmentName:\n        Description: An environment name that will be prefixed to resource names\n        Type: String\n\n    VpcCIDR:\n        Description: Please enter the IP range (CIDR notation) for this VPC\n        Type: String\n        Default: 10.192.0.0/16\n        AllowedPattern: '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\\/([0-9]|[1-2][0-9]|3[0-2]))$'\n\n    PublicSubnet1CIDR:\n        Description: Please enter the IP range (CIDR notation) for the public subnet in the first Availability Zone\n        Type: String\n        Default: 10.192.10.0/24\n        AllowedPattern: '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\\/([0-9]|[1-2][0-9]|3[0-2]))$'\n\n    PublicSubnet2CIDR:\n        Description: Please enter the IP range (CIDR notation) for the public subnet in the second Availability Zone\n        Type: String\n        Default: 10.192.11.0/24\n        AllowedPattern: '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\\/([0-9]|[1-2][0-9]|3[0-2]))$'\n\n    PrivateSubnet1CIDR:\n        Description: Please enter the IP range (CIDR notation) for the private subnet in the first Availability Zone\n        Type: String\n        Default: 10.192.20.0/24\n        AllowedPattern: '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\\/([0-9]|[1-2][0-9]|3[0-2]))$'\n\n    PrivateSubnet2CIDR:\n        Description: Please enter the IP range (CIDR notation) for the private subnet in the second Availability Zone\n        Type: String\n        Default: 10.192.21.0/24\n        AllowedPattern: '^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\\/([0-9]|[1-2][0-9]|3[0-2]))$'\n\n    AvailabilityZone1:\n        Description: The index of the availability zone for private and public subnet 1\n        Type: Number\n        Default: 0\n\n    AvailabilityZone2:\n        Description: The index of availability zone for private and public subnet 2\n        Type: Number\n        Default: 1\n\nResources:\n    VPC:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::VPC\n        DeletionPolicy: Retain\n        Properties:\n            CidrBlock: !Ref VpcCIDR\n            InstanceTenancy: default\n            EnableDnsHostnames: true\n            EnableDnsSupport: true\n            Tags:\n                - Key: Name\n                  Value: !Ref EnvironmentName\n\n    InternetGateway:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::InternetGateway\n        DeletionPolicy: Retain\n        Properties:\n            Tags:\n                - Key: Name\n                  Value: !Ref EnvironmentName\n\n    InternetGatewayAttachment:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::VPCGatewayAttachment\n        DeletionPolicy: Retain\n        Properties:\n            InternetGatewayId: !Ref InternetGateway\n            VpcId: !Ref VPC\n\n    PublicSubnet1:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::Subnet\n        Metadata:\n            cfn_nag:\n                rules_to_suppress:\n                    - id: W33\n                      reason: \"It's a public subnet\"\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            AvailabilityZone: !Select [!Ref AvailabilityZone1, !GetAZs '']\n            CidrBlock: !Ref PublicSubnet1CIDR\n            MapPublicIpOnLaunch: true\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName} Public Subnet (AZ1)\n\n    PublicSubnet2:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::Subnet\n        Metadata:\n            cfn_nag:\n                rules_to_suppress:\n                    - id: W33\n                      reason: \"It's a public subnet\"\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            AvailabilityZone: !Select [!Ref AvailabilityZone2, !GetAZs '']\n            CidrBlock: !Ref PublicSubnet2CIDR\n            MapPublicIpOnLaunch: true\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName} Public Subnet (AZ2)\n\n    PrivateSubnet1:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::Subnet\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            AvailabilityZone: !Select [!Ref AvailabilityZone1, !GetAZs '']\n            CidrBlock: !Ref PrivateSubnet1CIDR\n            MapPublicIpOnLaunch: false\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName} Private Subnet (AZ1)\n\n    PrivateSubnet2:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::Subnet\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            AvailabilityZone: !Select [!Ref AvailabilityZone2, !GetAZs '']\n            CidrBlock: !Ref PrivateSubnet2CIDR\n            MapPublicIpOnLaunch: false\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName} Private Subnet (AZ2)\n\n    NatGateway1EIP:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::EIP\n        DependsOn: InternetGatewayAttachment\n        DeletionPolicy: Retain\n        Properties:\n            Domain: vpc\n\n    NatGateway2EIP:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::EIP\n        DependsOn: InternetGatewayAttachment\n        DeletionPolicy: Retain\n        Properties:\n            Domain: vpc\n\n    NatGateway1:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NatGateway\n        DeletionPolicy: Retain\n        Properties:\n            AllocationId: !GetAtt NatGateway1EIP.AllocationId\n            SubnetId: !Ref PublicSubnet1\n\n    NatGateway2:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::NatGateway\n        DeletionPolicy: Retain\n        Properties:\n            AllocationId: !GetAtt NatGateway2EIP.AllocationId\n            SubnetId: !Ref PublicSubnet2\n\n    PublicRouteTable:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::RouteTable\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName} Public Routes\n\n    DefaultPublicRoute:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::Route\n        DependsOn: InternetGatewayAttachment\n        DeletionPolicy: Retain\n        Properties:\n            RouteTableId: !Ref PublicRouteTable\n            DestinationCidrBlock: 0.0.0.0/0\n            GatewayId: !Ref InternetGateway\n\n    PublicSubnet1RouteTableAssociation:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SubnetRouteTableAssociation\n        DeletionPolicy: Retain\n        Properties:\n            RouteTableId: !Ref PublicRouteTable\n            SubnetId: !Ref PublicSubnet1\n\n    PublicSubnet2RouteTableAssociation:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SubnetRouteTableAssociation\n        DeletionPolicy: Retain\n        Properties:\n            RouteTableId: !Ref PublicRouteTable\n            SubnetId: !Ref PublicSubnet2\n\n    PrivateRouteTable1:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::RouteTable\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName} Private Routes (AZ1)\n\n    DefaultPrivateRoute1:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::Route\n        DeletionPolicy: Retain\n        Properties:\n            RouteTableId: !Ref PrivateRouteTable1\n            DestinationCidrBlock: 0.0.0.0/0\n            NatGatewayId: !Ref NatGateway1\n\n    PrivateSubnet1RouteTableAssociation:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SubnetRouteTableAssociation\n        DeletionPolicy: Retain\n        Properties:\n            RouteTableId: !Ref PrivateRouteTable1\n            SubnetId: !Ref PrivateSubnet1\n\n    PrivateRouteTable2:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::RouteTable\n        DeletionPolicy: Retain\n        Properties:\n            VpcId: !Ref VPC\n            Tags:\n                - Key: Name\n                  Value: !Sub ${EnvironmentName} Private Routes (AZ2)\n\n    DefaultPrivateRoute2:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::Route\n        DeletionPolicy: Retain\n        Properties:\n            RouteTableId: !Ref PrivateRouteTable2\n            DestinationCidrBlock: 0.0.0.0/0\n            NatGatewayId: !Ref NatGateway2\n\n    PrivateSubnet2RouteTableAssociation:\n        UpdateReplacePolicy: Retain\n        Type: AWS::EC2::SubnetRouteTableAssociation\n        DeletionPolicy: Retain\n        Properties:\n            RouteTableId: !Ref PrivateRouteTable2\n            SubnetId: !Ref PrivateSubnet2\n\nOutputs:\n    VPC:\n        Description: A reference to the created VPC\n        Value: !Ref VPC\n\n    PublicSubnets:\n        Description: A list of the public subnets\n        Value: !Join [',', [!Ref PublicSubnet1, !Ref PublicSubnet2]]\n\n    PrivateSubnets:\n        Description: A list of the private subnets\n        Value: !Join [',', [!Ref PrivateSubnet1, !Ref PrivateSubnet2]]\n\n    PublicSubnet1:\n        Description: A reference to the public subnet in the 1st Availability Zone\n        Value: !Ref PublicSubnet1\n\n    PublicSubnet2:\n        Description: A reference to the public subnet in the 2nd Availability Zone\n        Value: !Ref PublicSubnet2\n\n    PrivateSubnet1:\n        Description: A reference to the private subnet in the 1st Availability Zone\n        Value: !Ref PrivateSubnet1\n\n    PrivateSubnet2:\n        Description: A reference to the private subnet in the 2nd Availability Zone\n        Value: !Ref PrivateSubnet2\n"
  },
  {
    "path": "cloudformation/master.yaml",
    "content": "---\nAWSTemplateFormatVersion: '2010-09-09'\nDescription: >\n\n    This template deploys a VPC, with a pair of public and private subnets spread\n    across two Availabilty Zones. It deploys an Internet Gateway, with a default\n    route on the public subnets. It deploys a pair of NAT Gateways (one in each AZ),\n    and default routes for them in the private subnets.\n\n    It then deploys a Fargate ECS cluster distributed across multiple\n    Availability Zones.\n\n    Finally, it deploys crowd ECS services from containers published in\n    Amazon EC2 Container Registry (Amazon ECR).\nMappings:\n    EnvironmentMapping:\n        AvailabilityZone1Map:\n            dev: 0\n            test: 2\n            stage: 2\n            prod: 0\n            cftest2: 0\n        AvailabilityZone2Map:\n            dev: 1\n            test: 3\n            stage: 3\n            prod: 1\n            cftest2: 1\n        VPCCIDRMap:\n            dev: 10.192.0.0/16\n            test: 10.193.0.0/16\n            stage: 10.194.0.0/16\n            prod: 10.195.0.0/16\n            cftest2: 10.196.0.0/16\n        PublicSubnet1CIDRMap:\n            dev: 10.192.10.0/24\n            test: 10.193.10.0/24\n            stage: 10.194.10.0/24\n            prod: 10.195.10.0/24\n            cftest2: 10.196.10.0/24\n        PublicSubnet2CIDRMap:\n            dev: 10.192.11.0/24\n            test: 10.193.11.0/24\n            stage: 10.194.11.0/24\n            prod: 10.195.11.0/24\n            cftest2: 10.196.11.0/24\n        PrivateSubnet1CIDRMap:\n            dev: 10.192.20.0/24\n            test: 10.193.20.0/24\n            stage: 10.194.20.0/24\n            prod: 10.195.20.0/24\n            cftest2: 10.196.20.0/24\n        PrivateSubnet2CIDRMap:\n            dev: 10.192.21.0/24\n            test: 10.193.21.0/24\n            stage: 10.194.21.0/24\n            prod: 10.195.21.0/24\n            cftest2: 10.196.21.0/24\n        S3BucketNameMap:\n            dev: crowd-dev-content\n            test: crowd-test-content\n            stage: crowd-stage-content\n            prod: crowd-content\n            cftest2: crowd-dev-content\n        ExportS3BucketNameMap:\n            dev: crowd-dev-export\n            test: crowd-test-export\n            stage: crowd-stage-export\n            prod: crowd-export\n            cftest2: crowd-dev-export\n\nParameters:\n    ConcordiaVersion:\n        Description: which version of the docker images to deploy\n        Type: String\n        Default: latest\n\n    EnvName:\n        Description: which type of environment we are setting up\n        Type: String\n        AllowedValues:\n            - 'dev'\n            - 'test'\n            - 'stage'\n            - 'prod'\n            - 'cftest2'\n\n    FullEnvironmentName:\n        Type: String\n        Description: Full name of deployment environment\n        AllowedValues:\n            - 'development'\n            - 'test'\n            - 'staging'\n            - 'production'\n\n    CanonicalHostName:\n        Description: the canonical host name for this environment\n        Type: String\n        AllowedValues:\n            - 'crowd-dev.loc.gov'\n            - 'crowd-test.loc.gov'\n            - 'crowd-stage.loc.gov'\n            - 'crowd.loc.gov'\n\n    DjangoKeyId:\n        Type: String\n        Description: unique ID appended to end of DjangoSecretKey ARN in secrets manager\n\n    DbSecretId:\n        Type: String\n        Description: unique ID appended to end of DB password ARN in secrets manager\n\nResources:\n    VPC:\n        UpdateReplacePolicy: Retain\n        Type: AWS::CloudFormation::Stack\n        DeletionPolicy: Retain\n        Properties:\n            #            TemplateURL: !Join [ \"/\", [ !Ref BasePath, \"/infrastructure/vpc.yaml\" ]]\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/vpc.yaml'\n            Parameters:\n                EnvironmentName: !Ref AWS::StackName\n                VpcCIDR:\n                    !FindInMap [EnvironmentMapping, VPCCIDRMap, !Ref EnvName]\n                PublicSubnet1CIDR:\n                    !FindInMap [\n                        EnvironmentMapping,\n                        PublicSubnet1CIDRMap,\n                        !Ref EnvName,\n                    ]\n                PublicSubnet2CIDR:\n                    !FindInMap [\n                        EnvironmentMapping,\n                        PublicSubnet2CIDRMap,\n                        !Ref EnvName,\n                    ]\n                PrivateSubnet1CIDR:\n                    !FindInMap [\n                        EnvironmentMapping,\n                        PrivateSubnet1CIDRMap,\n                        !Ref EnvName,\n                    ]\n                PrivateSubnet2CIDR:\n                    !FindInMap [\n                        EnvironmentMapping,\n                        PrivateSubnet2CIDRMap,\n                        !Ref EnvName,\n                    ]\n                AvailabilityZone1:\n                    !FindInMap [\n                        EnvironmentMapping,\n                        AvailabilityZone1Map,\n                        !Ref EnvName,\n                    ]\n                AvailabilityZone2:\n                    !FindInMap [\n                        EnvironmentMapping,\n                        AvailabilityZone2Map,\n                        !Ref EnvName,\n                    ]\n\n    NetworkACL:\n        UpdateReplacePolicy: Retain\n        Type: AWS::CloudFormation::Stack\n        DeletionPolicy: Retain\n        Properties:\n            #            TemplateURL: !Join [ \"/\", [ !Ref BasePath, \"/infrastructure/network-acl.yaml\" ]]\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/network-acl.yaml'\n            Parameters:\n                EnvironmentName: !Ref AWS::StackName\n                VPC: !GetAtt VPC.Outputs.VPC\n                PublicSubnet1: !GetAtt VPC.Outputs.PublicSubnet1\n                PublicSubnet2: !GetAtt VPC.Outputs.PublicSubnet2\n                PrivateSubnet1: !GetAtt VPC.Outputs.PrivateSubnet1\n                PrivateSubnet2: !GetAtt VPC.Outputs.PrivateSubnet2\n\n    SecurityGroups:\n        UpdateReplacePolicy: Retain\n        Type: AWS::CloudFormation::Stack\n        DeletionPolicy: Retain\n        Properties:\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/security-groups.yaml'\n            Parameters:\n                EnvironmentName: !Ref AWS::StackName\n                VPC: !GetAtt VPC.Outputs.VPC\n\n    RDS:\n        UpdateReplacePolicy: Retain\n        Type: AWS::CloudFormation::Stack\n        DeletionPolicy: Retain\n        Properties:\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/rds.yaml'\n            Parameters:\n                DbUsername: !Sub '{{resolve:secretsmanager:crowd/${EnvName}/DB/MasterUserPassword:SecretString:username}}'\n                DbPassword: !Sub '{{resolve:secretsmanager:crowd/${EnvName}/DB/MasterUserPassword:SecretString:password}}'\n                DatabaseSecurityGroup: !GetAtt SecurityGroups.Outputs.DatabaseSecurityGroup\n                PrivateSubnet1: !GetAtt VPC.Outputs.PrivateSubnet1\n                PrivateSubnet2: !GetAtt VPC.Outputs.PrivateSubnet2\n\n    ElastiCache:\n        UpdateReplacePolicy: Retain\n        Type: AWS::CloudFormation::Stack\n        DeletionPolicy: Retain\n        Properties:\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/elasticache.yaml'\n            Parameters:\n                EnvironmentName: !Ref AWS::StackName\n                SecurityGroup: !GetAtt SecurityGroups.Outputs.CacheServiceSecurityGroup\n                PrivateSubnets: !GetAtt VPC.Outputs.PrivateSubnets\n\n    FargateCluster:\n        UpdateReplacePolicy: Retain\n        Type: AWS::CloudFormation::Stack\n        DeletionPolicy: Retain\n        Properties:\n            TemplateURL: 'https://s3.amazonaws.com/crowd-deployment/infrastructure/fargate-cluster.yaml'\n            Parameters:\n                EnvironmentName: !Ref AWS::StackName\n                EnvName: !Ref EnvName\n                VpcId: !GetAtt VPC.Outputs.VPC\n                SecurityGroup: !GetAtt SecurityGroups.Outputs.ECSHostSecurityGroup\n                LoadBalancerSecurityGroup: !GetAtt SecurityGroups.Outputs.LoadBalancerSecurityGroup\n                PrivateSubnets: !GetAtt VPC.Outputs.PrivateSubnets\n                PublicSubnets: !GetAtt VPC.Outputs.PublicSubnets\n                ConcordiaVersion: !Ref ConcordiaVersion\n                RedisAddress: !GetAtt ElastiCache.Outputs.RedisAddress\n                RedisPort: !GetAtt ElastiCache.Outputs.RedisPort\n                CanonicalHostName: !Ref CanonicalHostName\n                DatabaseEndpoint: !GetAtt RDS.Outputs.DatabaseHostName\n                FullEnvironmentName: !Ref FullEnvironmentName\n                DjangoKeyId: !Ref DjangoKeyId\n                DbSecretId: !Ref DbSecretId\n                S3BucketName:\n                    !FindInMap [\n                        EnvironmentMapping,\n                        S3BucketNameMap,\n                        !Ref EnvName,\n                    ]\n                ExportS3BucketName:\n                    !FindInMap [\n                        EnvironmentMapping,\n                        ExportS3BucketNameMap,\n                        !Ref EnvName,\n                    ]\n\nOutputs:\n    WebsiteServiceUrl:\n        Description: The URL endpoint for the concordia website service\n        Value: !Join ['', [!GetAtt FargateCluster.Outputs.LoadBalancerUrl, '/']]\n"
  },
  {
    "path": "cloudformation/stack_drift.sh",
    "content": "#!/bin/bash\n\nset -eu -o pipefail\n\nSTACK_NAME=$1\nif [[ -z \"${STACK_NAME}\" ]]; then\n    echo \"STACK_NAME must be set prior to running this script.\"\n    exit 1\nfi\n\nTODAY=$(date +%Y%m%d)\n# job log and json output file names\nLOG_FILE=\"stack-drift-${STACK_NAME}-${TODAY}.log\"\necho $STACK_NAME | tee ${LOG_FILE}\n\nOUTPUT_FILE=\"stack-drift-${STACK_NAME}-${TODAY}.json\"\necho $OUTPUT_FILE | tee ${LOG_FILE}\n\n# to get a list of nested stack names for concordia's crowd environment based stack use the aws cli to\n#   to fetch the arns and extract the name needed for the drift results command\n#\nNESTED_STACK_ARNS=\"$(aws cloudformation list-stack-resources \\\n    --stack-name ${STACK_NAME} \\\n    --query \"StackResourceSummaries[*].PhysicalResourceId\")\"\necho \"NESTED_STACK_ARNS: $NESTED_STACK_ARNS\" | tee -a ${LOG_FILE}\n\n\nCOUNT=1;\n\nfor ARN in $NESTED_STACK_ARNS; do\n    # Extract logical nested stack name from the arn\n    NESTED_STACK_NAME=\"$(echo ${ARN} | awk -F'/' '{ print $2}')\"\n\n    # the list brackets in the result set will be blank - skip them\n    if [[ $NESTED_STACK_NAME == \"\" ]]; then\n        NESTED_STACK_NAME=None\n        echo \"skip not a valid value: $NESTED_STACK_NAME\" | tee -a ${LOG_FILE};\n    else\n        echo \"Nested stack name: $NESTED_STACK_NAME\" | tee -a ${LOG_FILE};\n        echo $COUNT;\n\n        # fetch logical resources names where drift exists to report out the details\n        # list of MODIFIED\n        LOGICAL_RESOURCE_IDS_MODIFIED=\"$(aws cloudformation describe-stack-resources \\\n            --stack-name ${NESTED_STACK_NAME} \\\n            --query 'StackResources[?DriftInformation.StackResourceDriftStatus==`MODIFIED`].LogicalResourceId' --output text)\"\n        echo \"LOGICAL_RESOURCE_IDS_MODIFIED: $LOGICAL_RESOURCE_IDS_MODIFIED\" | tee -a ${LOG_FILE}\n\n        for id in $LOGICAL_RESOURCE_IDS_MODIFIED; do\n            echo \"modified resource id: $id\";\n            aws cloudformation detect-stack-resource-drift --stack-name \"$NESTED_STACK_NAME\" --logical-resource-id \"$id\" >> \"$OUTPUT_FILE\" | tee -a ${LOG_FILE};\n        done;\n\n        # list of DELETED\n        LOGICAL_RESOURCE_IDS_DELETED=\"$(aws cloudformation describe-stack-resources \\\n            --stack-name ${NESTED_STACK_NAME} \\\n            --query 'StackResources[?DriftInformation.StackResourceDriftStatus==`DELETED`].LogicalResourceId' --output text)\"\n        echo \"LOGICAL_RESOURCE_IDS_DELETED: $LOGICAL_RESOURCE_IDS_DELETED\" | tee -a ${LOG_FILE}\n\n        for id in $LOGICAL_RESOURCE_IDS_DELETED; do\n            echo \"deleted resource id: $id\";\n            aws cloudformation detect-stack-resource-drift --stack-name \"$NESTED_STACK_NAME\" --logical-resource-id \"$id\" >> \"$OUTPUT_FILE\" | tee -a ${LOG_FILE};\n        done;\n        ((COUNT++));\n    fi;\ndone;\nRETURNCODE=$?\n\necho $RETURNCODE | tee -a ${LOG_FILE}\n\necho \"Drift results saved to $OUTPUT_FILE\" | tee -a ${LOG_FILE}\nexit $RETURNCODE\nEOF\n"
  },
  {
    "path": "cloudformation/sync_templates.sh",
    "content": "#!/bin/bash\n\nset -eu\n\naws s3 sync . s3://crowd-deployment\n"
  },
  {
    "path": "cloudformation/tests/validate-templates.sh",
    "content": "#!/bin/bash\nERROR_COUNT=0;\n\necho \"Validating AWS CloudFormation templates...\"\n\n# Loop through the YAML templates in this repository\nfor TEMPLATE in $(find . -name '*.yaml'); do\n\n    # Validate the template with CloudFormation\n    ERRORS=$(aws cloudformation validate-template --template-body file://$TEMPLATE 2>&1 >/dev/null);\n    if [ \"$?\" -gt \"0\" ]; then\n        ((ERROR_COUNT++));\n        echo \"[fail] $TEMPLATE: $ERRORS\";\n    else\n        echo \"[pass] $TEMPLATE\";\n    fi;\n\ndone;\n\necho \"$ERROR_COUNT template validation error(s)\";\nif [ \"$ERROR_COUNT\" -gt 0 ];\n    then exit 1;\nfi\n"
  },
  {
    "path": "concordia/__init__.py",
    "content": "from __future__ import absolute_import, unicode_literals\n\nfrom concordia.celery import app as celery_app\n\n__all__ = [\"celery_app\"]\n"
  },
  {
    "path": "concordia/admin/__init__.py",
    "content": "import io\nimport logging\nimport zipfile\nfrom typing import Any\n\nfrom django.contrib import admin, messages\nfrom django.contrib.admin.models import CHANGE, LogEntry\nfrom django.contrib.admin.options import get_content_type_for_model\nfrom django.contrib.auth import get_permission_codename\nfrom django.contrib.auth.admin import UserAdmin\nfrom django.contrib.auth.decorators import permission_required\nfrom django.contrib.auth.models import User\nfrom django.db.models import Exists, OuterRef, QuerySet\nfrom django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect\nfrom django.shortcuts import get_object_or_404, render\nfrom django.template.defaultfilters import truncatechars\nfrom django.template.response import TemplateResponse\nfrom django.urls import path, reverse\nfrom django.utils.decorators import method_decorator\nfrom django.utils.html import format_html\nfrom django.utils.http import url_has_allowed_host_and_scheme\nfrom django.views.decorators.csrf import csrf_protect\n\nfrom exporter import views as exporter_views\nfrom exporter.tabular_export.admin import export_to_csv_action, export_to_excel_action\nfrom exporter.tabular_export.core import export_to_csv_response, flatten_queryset\nfrom importer.tasks.items import import_items_into_project_from_url\n\nfrom ..models import (\n    Asset,\n    AssetTranscriptionReservation,\n    Banner,\n    Campaign,\n    CampaignRetirementProgress,\n    Card,\n    CardFamily,\n    CarouselSlide,\n    ConcordiaFile,\n    Guide,\n    HelpfulLink,\n    Item,\n    KeyMetricsReport,\n    NextReviewableCampaignAsset,\n    NextReviewableTopicAsset,\n    NextTranscribableCampaignAsset,\n    NextTranscribableTopicAsset,\n    Project,\n    ProjectTopic,\n    SimplePage,\n    SiteReport,\n    Tag,\n    Topic,\n    Transcription,\n    TutorialCard,\n    UserAssetTagCollection,\n    UserProfileActivity,\n)\nfrom ..tasks.retirement import retire_campaign\nfrom ..views.campaigns import ReportCampaignView\nfrom .actions import (\n    anonymize_action,\n    change_status_to_completed,\n    change_status_to_in_progress,\n    change_status_to_needs_review,\n    publish_action,\n    publish_item_action,\n    unpublish_action,\n    unpublish_item_action,\n    verify_assets_action,\n)\nfrom .filters import (\n    AcceptedFilter,\n    AssetCampaignListFilter,\n    AssetCampaignStatusListFilter,\n    AssetProjectListFilter,\n    CardCampaignListFilter,\n    HelpfulLinkCampaignListFilter,\n    HelpfulLinkCampaignStatusListFilter,\n    ItemCampaignListFilter,\n    ItemCampaignStatusListFilter,\n    ItemProjectListFilter,\n    NextAssetCampaignListFilter,\n    OcrGeneratedFilter,\n    OcrOriginatedFilter,\n    ProjectCampaignListFilter,\n    ProjectCampaignStatusListFilter,\n    RejectedFilter,\n    SiteReportCampaignListFilter,\n    SiteReportSortedCampaignListFilter,\n    SubmittedFilter,\n    SupersededListFilter,\n    TagCampaignListFilter,\n    TagCampaignStatusListFilter,\n    TopicListFilter,\n    TranscriptionCampaignListFilter,\n    TranscriptionCampaignStatusListFilter,\n    TranscriptionProjectListFilter,\n    UserAssetTagCollectionCampaignListFilter,\n    UserAssetTagCollectionCampaignStatusListFilter,\n    UserProfileActivityCampaignListFilter,\n    UserProfileActivityCampaignStatusListFilter,\n)\nfrom .forms import (\n    AdminItemImportForm,\n    AssetStatusActionForm,\n    CampaignAdminForm,\n    CardAdminForm,\n    GuideAdminForm,\n    ItemAdminForm,\n    KeyMetricsReportAdminForm,\n    ProjectAdminForm,\n    ProjectTopicInlineForm,\n    TopicAdminForm,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nclass ConcordiaUserAdmin(UserAdmin):\n    \"\"\"\n    Customize the Django admin for `User` objects.\n\n    Adds transcription and review counters to the changelist and provides\n    CSV and Excel export actions.\n    \"\"\"\n\n    list_display = (\n        \"username\",\n        \"email\",\n        \"is_staff\",\n        \"date_joined\",\n        \"transcription_count\",\n        \"review_count\",\n    )\n\n    def get_queryset(\n        self,\n        request: HttpRequest,\n    ) -> QuerySet[User]:\n        \"\"\"\n        Build the queryset used for the user changelist.\n\n        Adds a `select_related` on the related profile to reduce per-row\n        database queries when rendering counts.\n\n        Args:\n            request (HttpRequest): Current admin request.\n\n        Returns:\n            QuerySet[User]: Queryset with related profiles preloaded.\n        \"\"\"\n        qs = super().get_queryset(request).select_related(\"profile\")\n        return qs\n\n    @admin.display(\n        description=\"Transcription Count\",\n        ordering=\"profile__transcribe_count\",\n    )\n    def transcription_count(self, obj: User) -> int:\n        return obj.profile.transcribe_count\n\n    @admin.display(\n        description=\"Review Count\",\n        ordering=\"profile__review_count\",\n    )\n    def review_count(self, obj: User) -> int:\n        return obj.profile.review_count\n\n    EXPORT_FIELDS = (\n        \"username\",\n        \"email\",\n        \"first_name\",\n        \"last_name\",\n        \"is_active\",\n        \"is_staff\",\n        \"is_superuser\",\n        \"date_joined\",\n        \"last_login\",\n        \"profile__transcribe_count\",\n        \"profile__review_count\",\n    )\n\n    EXTRA_VERBOSE_NAMES = {\n        \"profile__transcribe_count\": \"transcription count\",\n        \"profile__review_count\": \"review count\",\n    }\n\n    def export_users_as_csv(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[User],\n    ) -> HttpResponse:\n        \"\"\"\n        Export selected users as a CSV file.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[User]): Selected users to export.\n\n        Returns:\n            HttpResponse: Response that streams a CSV download.\n        \"\"\"\n        return export_to_csv_action(\n            self,\n            request,\n            queryset,\n            field_names=self.EXPORT_FIELDS,\n            extra_verbose_names=self.EXTRA_VERBOSE_NAMES,\n        )\n\n    def export_users_as_excel(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[User],\n    ) -> HttpResponse:\n        \"\"\"\n        Export selected users as an Excel file.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[User]): Selected users to export.\n\n        Returns:\n            HttpResponse: Response that streams an Excel download.\n        \"\"\"\n        return export_to_excel_action(\n            self,\n            request,\n            queryset,\n            field_names=self.EXPORT_FIELDS,\n            extra_verbose_names=self.EXTRA_VERBOSE_NAMES,\n        )\n\n    actions = (anonymize_action, export_users_as_csv, export_users_as_excel)\n\n\nadmin.site.unregister(User)\nadmin.site.register(User, ConcordiaUserAdmin)\n\n\n@admin.register(Banner)\nclass BannerAdmin(admin.ModelAdmin):\n    list_display = (\n        \"text\",\n        \"active\",\n    )\n\n\nclass CustomListDisplayFieldsMixin:\n    \"\"\"\n    Provide reusable list display helpers for admin changelists.\n\n    This mixin defines helpers that truncate long text and render selected\n    fields using HTML formatting.\n    \"\"\"\n\n    @admin.display(description=\"Description\")\n    def truncated_description(self, obj):\n        return truncatechars(obj.description, 200)\n\n    @admin.display(description=\"Metadata\")\n    def truncated_metadata(self, obj):\n        if obj.metadata:\n            return format_html(\"<code>{}</code>\", truncatechars(obj.metadata, 200))\n        else:\n            return \"\"\n\n\n@admin.register(Campaign)\nclass CampaignAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin):\n    \"\"\"\n    Admin configuration for `Campaign` objects.\n\n    Adds filters, publishing actions, export links and a custom retirement\n    workflow.\n    \"\"\"\n\n    form = CampaignAdminForm\n\n    list_display = (\n        \"title\",\n        \"status\",\n        \"published\",\n        \"display_on_homepage\",\n        \"next_transcription_campaign\",\n        \"next_review_campaign\",\n        \"ordering\",\n        \"launch_date\",\n        \"completed_date\",\n    )\n    list_editable = (\n        \"display_on_homepage\",\n        \"next_transcription_campaign\",\n        \"next_review_campaign\",\n        \"ordering\",\n        \"published\",\n        \"unlisted\",\n        \"status\",\n        \"launch_date\",\n        \"completed_date\",\n    )\n    list_display_links = (\"title\",)\n    fields = (\n        \"published\",\n        \"unlisted\",\n        \"status\",\n        \"next_transcription_campaign\",\n        \"next_review_campaign\",\n        \"ordering\",\n        \"display_on_homepage\",\n        \"title\",\n        \"slug\",\n        \"card_family\",\n        \"thumbnail_image\",\n        \"image_alt_text\",\n        \"launch_date\",\n        \"completed_date\",\n        \"description\",\n        \"short_description\",\n        \"metadata\",\n        \"disable_ocr\",\n        \"research_centers\",\n    )\n    prepopulated_fields = {\"slug\": (\"title\",)}\n    raw_id_fields = (\"card_family\",)\n    search_fields = [\"title\", \"description\"]\n    list_filter = (\n        \"published\",\n        \"display_on_homepage\",\n        \"unlisted\",\n        \"status\",\n        \"next_transcription_campaign\",\n        \"next_review_campaign\",\n    )\n\n    actions = (publish_action, unpublish_action, verify_assets_action)\n\n    def get_form(\n        self,\n        request: HttpRequest,\n        obj: Campaign | None = None,\n        **kwargs: Any,\n    ):\n        \"\"\"\n        Build the model form used to edit a campaign.\n\n        Updates some field labels to be clearer for staff users before\n        returning the base form from `ModelAdmin`.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            obj (Campaign | None): Campaign being edited, or `None` when\n                creating a new one.\n            **kwargs (Any): Extra keyword arguments passed to\n                `ModelAdmin.get_form`.\n\n        Returns:\n            forms.ModelForm: Form class used by the admin for this model.\n        \"\"\"\n        form = super().get_form(request, obj, **kwargs)\n        form.base_fields[\"display_on_homepage\"].label = \"Display on homepage\"\n        form.base_fields[\"next_transcription_campaign\"].label = (\n            \"Next transcription campaign\"\n        )\n        form.base_fields[\"next_review_campaign\"].label = \"Next review campaign\"\n        return form\n\n    def get_urls(self):\n        \"\"\"\n        Add custom admin URLs for campaign exports, reports and retirement.\n\n        Returns:\n            list: List of URL patterns including the default admin URLs and\n            the custom campaign URLs.\n        \"\"\"\n        urls = super().get_urls()\n\n        app_label = self.model._meta.app_label\n        model_name = self.model._meta.model_name\n\n        custom_urls = [\n            path(\n                \"exportCSV/<path:campaign_slug>\",\n                exporter_views.ExportCampaignToCSV.as_view(),\n                name=f\"{app_label}_{model_name}_export-csv\",\n            ),\n            path(\n                \"exportBagIt/<path:campaign_slug>\",\n                exporter_views.ExportCampaignToBagIt.as_view(),\n                name=f\"{app_label}_{model_name}_export-bagit\",\n            ),\n            path(\n                \"report/<path:campaign_slug>\",\n                ReportCampaignView.as_view(),\n                name=f\"{app_label}_{model_name}_report\",\n            ),\n            path(\n                \"retire/<path:campaign_slug>\",\n                self.admin_site.admin_view(self.retire),\n                name=f\"{app_label}_{model_name}_retire\",\n            ),\n        ]\n\n        return custom_urls + urls\n\n    @method_decorator(csrf_protect)\n    @method_decorator(\n        permission_required(\"concordia.retire_campaign\", raise_exception=True)\n    )\n    @method_decorator(\n        permission_required(\"concordia.delete_project\", raise_exception=True)\n    )\n    @method_decorator(\n        permission_required(\"concordia.delete_item\", raise_exception=True)\n    )\n    @method_decorator(\n        permission_required(\"concordia.delete_asset\", raise_exception=True)\n    )\n    @method_decorator(\n        permission_required(\"concordia.delete_transcription\", raise_exception=True)\n    )\n    @method_decorator(\n        permission_required(\"concordia.delete_import_item_asset\", raise_exception=True)\n    )\n    def retire(\n        self,\n        request: HttpRequest,\n        campaign_slug: str,\n    ) -> HttpResponse:\n        \"\"\"\n        Start the retirement process for a campaign.\n\n        This view shows a confirmation page that lists how many projects,\n        items, assets and transcriptions will be removed. When the request\n        is `POST`, it enqueues the retirement task and redirects to the\n        progress object in the admin.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            campaign_slug (str): Slug of the campaign being retired.\n\n        Returns:\n            HttpResponse: Confirmation page or redirect to the progress\n            object.\n        \"\"\"\n        try:\n            campaign = Campaign.objects.filter(slug=campaign_slug)[0]\n        except IndexError:\n            return self._get_obj_does_not_exist_redirect(\n                request, self.opts, campaign_slug\n            )\n\n        projects = campaign.project_set.values_list(\"id\", flat=True)\n        items = Item.objects.filter(project__id__in=projects).values_list(\n            \"id\", flat=True\n        )\n        assets = Asset.objects.filter(item__id__in=items).values_list(\"id\", flat=True)\n        transcriptions = Transcription.objects.filter(asset__id__in=assets)\n\n        model_count = {\n            \"project\": len(projects),\n            \"item\": len(items),\n            \"asset\": len(assets),\n            \"transcription\": transcriptions.count(),\n        }\n\n        if request.POST:\n            # This means the user confirmed the retirement\n            obj_display = str(campaign)\n            self.log_retirement(request, campaign, obj_display)\n            progress = retire_campaign(campaign.id)\n            self.message_user(\n                request,\n                'The retirement process for %(name)s \"%(obj)s\" has begun.'\n                % {\n                    \"name\": self.opts.verbose_name,\n                    \"obj\": obj_display,\n                },\n                messages.SUCCESS,\n            )\n            post_url = reverse(\n                \"admin:concordia_campaignretirementprogress_change\",\n                args=[progress.id],\n                current_app=self.admin_site.name,\n            )\n            return HttpResponseRedirect(post_url)\n\n        context = {\n            **self.admin_site.each_context(request),\n            \"title\": \"Are you sure?\",\n            \"subtitle\": None,\n            \"object_name\": \"Campaign\",\n            \"object\": campaign,\n            \"model_count\": model_count.items(),\n            \"opts\": self.opts,\n            \"app_label\": self.opts.app_label,\n            \"preserved_filters\": self.get_preserved_filters(request),\n        }\n\n        return TemplateResponse(\n            request, \"admin/concordia/campaign/retire.html\", context\n        )\n\n    def log_retirement(\n        self,\n        request: HttpRequest,\n        obj: Campaign,\n        object_repr: str,\n    ) -> LogEntry:\n        \"\"\"\n        Create an admin log entry for a campaign retirement.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            obj (Campaign): Campaign that is being retired.\n            object_repr (str): Text representation of the campaign used in\n                the log entry.\n\n        Returns:\n            LogEntry: The created log entry instance.\n        \"\"\"\n        return LogEntry.objects.log_action(\n            user_id=request.user.pk,\n            content_type_id=get_content_type_for_model(obj).pk,\n            object_id=obj.pk,\n            object_repr=object_repr,\n            action_flag=CHANGE,\n        )\n\n\n@admin.register(HelpfulLink)\nclass HelpfulLinkAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin):\n    list_display = (\"campaign\", \"topic\", \"sequence\", \"title\", \"link_url\")\n    list_display_links = (\"campaign\", \"topic\", \"sequence\", \"title\")\n    list_filter = (\n        \"link_type\",\n        HelpfulLinkCampaignStatusListFilter,\n        TopicListFilter,\n        HelpfulLinkCampaignListFilter,\n    )\n\n    def formfield_for_foreignkey(\n        self,\n        db_field: Any,\n        request: HttpRequest,\n        **kwargs: Any,\n    ) -> Any:\n        \"\"\"\n        Customize the form field for foreign key relations.\n\n        Orders campaigns alphabetically when selecting a campaign.\n\n        Args:\n            db_field (Any): Model field being rendered.\n            request (HttpRequest): Current admin request.\n            **kwargs (Any): Extra keyword arguments for the base\n                implementation.\n\n        Returns:\n            Any: Form field instance for the foreign key.\n        \"\"\"\n        if db_field.name == \"campaign\":\n            kwargs[\"queryset\"] = Campaign.objects.order_by(\"title\")\n        return super().formfield_for_foreignkey(db_field, request, **kwargs)\n\n\n@admin.register(ConcordiaFile)\nclass ConcordiaFileAdmin(admin.ModelAdmin):\n    # Bulk delete bypasses file deletion, so we do not want any bulk actions\n    actions = None\n    list_display = (\"name\", \"file_url\", \"updated_on\")\n    readonly_fields = (\"file_url\", \"updated_on\")\n\n    def file_url(self, obj: ConcordiaFile) -> str:\n        \"\"\"\n        Return the public URL for this file without any query string.\n\n        Boto3 storage backends often append query parameters that are not\n        needed for public files. This helper strips them so the URL is\n        easier to copy and read.\n\n        Args:\n            obj (ConcordiaFile): File instance.\n\n        Returns:\n            str: Public URL without query parameters.\n        \"\"\"\n        # Boto3 adds querystring parameters to the URL to allow access\n        # to private files. In this case, all files are public, and we\n        # do not want the querystring, so we remove it.\n        # This looks hacky, but seems to be the least hacky way to do\n        # this without a third-party library.\n        return obj.uploaded_file.url.split(\"?\")[0]\n\n    def get_fields(\n        self,\n        request: HttpRequest,\n        obj: ConcordiaFile | None = None,\n    ) -> tuple[str, ...]:\n        \"\"\"\n        Control which fields are shown on the change and add views.\n\n        When editing an existing object some fields are read only, but\n        when creating a new one only editable fields are shown.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            obj (ConcordiaFile | None): File being edited, or\n                `None` when adding a new one.\n\n        Returns:\n            tuple[str, ...]: Field names to display in the form.\n        \"\"\"\n        if obj:\n            return (\n                \"name\",\n                \"file_url\",\n                \"uploaded_file\",\n                \"updated_on\",\n            )\n        return (\"name\", \"uploaded_file\")\n\n\nclass TopicProjectInline(admin.TabularInline):\n    model = ProjectTopic\n    form = ProjectTopicInlineForm\n    extra = 1\n    autocomplete_fields = [\"project\"]\n    fields = [\"project\", \"url_filter\", \"ordering\"]\n    fk_name = \"topic\"\n\n\n@admin.register(Topic)\nclass TopicAdmin(admin.ModelAdmin):\n    form = TopicAdminForm\n\n    inlines = [TopicProjectInline]\n\n    list_display = (\n        \"id\",\n        \"title\",\n        \"slug\",\n        \"short_description\",\n        \"published\",\n        \"unlisted\",\n        \"ordering\",\n    )\n\n    list_display_links = (\"id\", \"title\", \"slug\")\n    prepopulated_fields = {\"slug\": (\"title\",)}\n    search_fields = [\n        \"title\",\n    ]\n\n\nclass ProjectTopicInline(admin.TabularInline):\n    model = ProjectTopic\n    form = ProjectTopicInlineForm\n    extra = 1\n    autocomplete_fields = [\"topic\"]\n    fields = [\"topic\", \"url_filter\"]\n\n\n@admin.register(Project)\nclass ProjectAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin):\n    form = ProjectAdminForm\n\n    inlines = [ProjectTopicInline]\n\n    list_display = (\"id\", \"title\", \"slug\", \"campaign\", \"published\", \"ordering\")\n    list_editable = (\"ordering\",)\n    list_display_links = (\"id\", \"title\", \"slug\")\n    prepopulated_fields = {\"slug\": (\"title\",)}\n    search_fields = [\"title\", \"campaign__title\"]\n    list_filter = (\n        \"published\",\n        \"topics\",\n        ProjectCampaignStatusListFilter,\n        ProjectCampaignListFilter,\n    )\n\n    actions = (publish_action, unpublish_action, verify_assets_action)\n\n    def lookup_allowed(self, key: str, value: str) -> bool:\n        \"\"\"\n        Allow filtering by campaign id in the changelist.\n\n        Args:\n            key (str): Lookup parameter key.\n            value (str): Lookup parameter value.\n\n        Returns:\n            bool: True if the lookup is allowed.\n        \"\"\"\n        if key in (\"campaign__id__exact\"):\n            return True\n        else:\n            return super().lookup_allowed(key, value)\n\n    def get_urls(self):\n        \"\"\"\n        Add custom URLs for project item import and CSV export.\n\n        Returns:\n            list: Custom URL patterns combined with the default admin URLs.\n        \"\"\"\n        urls = super().get_urls()\n\n        app_label = self.model._meta.app_label\n        model_name = self.model._meta.model_name\n\n        custom_urls = [\n            path(\n                \"<path:object_id>/item-import/\",\n                self.admin_site.admin_view(self.item_import_view),\n                name=f\"{app_label}_{model_name}_item-import\",\n            ),\n            path(\n                \"exportCSV/<path:campaign_slug>/<path:project_slug>/\",\n                exporter_views.ExportProjectToCSV.as_view(),\n                name=f\"{app_label}_{model_name}_export-csv\",\n            ),\n        ]\n\n        return custom_urls + urls\n\n    @method_decorator(\n        permission_required(\"concordia.add_campaign\", raise_exception=True)\n    )\n    @method_decorator(\n        permission_required(\"concordia.change_campaign\", raise_exception=True)\n    )\n    @method_decorator(\n        permission_required(\"concordia.add_project\", raise_exception=True)\n    )\n    @method_decorator(\n        permission_required(\"concordia.change_project\", raise_exception=True)\n    )\n    @method_decorator(permission_required(\"concordia.add_item\", raise_exception=True))\n    @method_decorator(\n        permission_required(\"concordia.change_item\", raise_exception=True)\n    )\n    def item_import_view(\n        self,\n        request: HttpRequest,\n        object_id: str,\n    ) -> HttpResponse:\n        \"\"\"\n        Display and process the admin item import form for a project.\n\n        When the request is `GET`, this view shows a form where staff can\n        paste a URL for the import. When the request is `POST` and the form\n        is valid, it queues an item import job and redisplays the form with\n        basic job information.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            object_id (str): Primary key of the project being imported\n                into.\n\n        Returns:\n            HttpResponse: Rendered admin page for the import form.\n        \"\"\"\n        project = get_object_or_404(Project, pk=object_id)\n\n        if request.method == \"POST\":\n            form = AdminItemImportForm(request.POST)\n\n            if form.is_valid():\n                import_url = form.cleaned_data[\"import_url\"]\n\n                import_job = import_items_into_project_from_url(\n                    request.user, project, import_url\n                )\n            else:\n                import_job = None\n        else:\n            form = AdminItemImportForm()\n            import_job = None\n\n        media = self.media\n\n        context = {\n            **self.admin_site.each_context(request),\n            \"app_label\": self.model._meta.app_label,\n            \"add\": False,\n            \"change\": False,\n            \"save_as\": False,\n            \"save_on_top\": False,\n            \"opts\": self.model._meta,\n            \"title\": f\"Import Items into “{project.title}”\",\n            \"object_id\": object_id,\n            \"original\": project,\n            \"media\": media,\n            \"preserved_filters\": self.get_preserved_filters(request),\n            \"is_popup\": False,\n            \"has_view_permission\": True,\n            \"has_add_permission\": True,\n            \"has_change_permission\": True,\n            \"has_delete_permission\": False,\n            \"has_editable_inline_admin_formsets\": False,\n            \"project\": project,\n            \"form\": form,\n            \"import_job\": import_job,\n        }\n\n        return render(request, \"admin/concordia/project/item_import.html\", context)\n\n\n@admin.register(Item)\nclass ItemAdmin(admin.ModelAdmin):\n    form = ItemAdminForm\n    list_display = (\"title\", \"item_id\", \"campaign_title\", \"project\", \"published\")\n    list_display_links = (\"title\", \"item_id\")\n    search_fields = [\n        \"title\",\n        \"item_id\",\n        \"item_url\",\n        \"project__campaign__title\",\n        \"project__title\",\n    ]\n\n    list_filter = (\n        \"published\",\n        \"project__topics\",\n        ItemCampaignStatusListFilter,\n        ItemCampaignListFilter,\n        ItemProjectListFilter,\n    )\n\n    actions = (publish_item_action, unpublish_item_action, verify_assets_action)\n\n    def lookup_allowed(self, key: str, value: str) -> bool:\n        \"\"\"\n        Allow filtering by campaign id in the changelist.\n\n        Args:\n            key (str): Lookup parameter key.\n            value (str): Lookup parameter value.\n\n        Returns:\n            bool: True if the lookup is allowed.\n        \"\"\"\n        if key in (\"project__campaign__id__exact\",):\n            return True\n        else:\n            return super().lookup_allowed(key, value)\n\n    def get_deleted_objects(\n        self,\n        objs: list[Item],\n        request: HttpRequest,\n    ):\n        \"\"\"\n        Summarize the impact of deleting the given items.\n\n        This override includes counts of related assets and transcriptions\n        and enforces delete permissions for related models.\n\n        Args:\n            objs (list[Item]): Items selected for deletion.\n            request (HttpRequest): Current admin request.\n\n        Returns:\n            tuple[list[str], dict[str, int], set[str], list]:\n                Deleted object labels, counts per model, permissions that\n                are still needed and a list of protected objects.\n        \"\"\"\n        if len(objs) < 30:\n            deleted_objects = [str(obj) for obj in objs]\n        else:\n            deleted_objects = [str(obj) for obj in objs[:3]]\n            deleted_objects.append(\n                f\"… and {len(objs) - 3} more {Item._meta.verbose_name_plural}\"\n            )\n        perms_needed = set()\n        for model in (Item, Asset, Transcription):\n            perm = \"%s.%s\" % (\n                model._meta.app_label,\n                get_permission_codename(\"delete\", model._meta),\n            )\n            if not request.user.has_perm(perm):\n                perms_needed.add(model._meta.verbose_name)\n        protected = []\n\n        model_count = {\n            Item._meta.verbose_name_plural: len(objs),\n            Asset._meta.verbose_name_plural: Asset.objects.filter(\n                item__in=objs\n            ).count(),\n            Transcription._meta.verbose_name_plural: Transcription.objects.filter(\n                asset__item__in=objs\n            ).count(),\n        }\n\n        return (deleted_objects, model_count, perms_needed, protected)\n\n    def get_queryset(\n        self,\n        request: HttpRequest,\n    ):\n        \"\"\"\n        Optimize the queryset used on the item changelist.\n\n        Adds related project and campaign so list-display columns do not\n        trigger extra database queries.\n        \"\"\"\n        qs = super().get_queryset(request)\n        qs = qs.select_related(\"project\", \"project__campaign\")\n        return qs\n\n    def campaign_title(self, obj: Item) -> str:\n        \"\"\"\n        Return the campaign title for the item's project.\n\n        Args:\n            obj (Item): Item instance.\n\n        Returns:\n            str: Title of the related campaign.\n        \"\"\"\n        return obj.project.campaign.title\n\n\n@admin.register(AssetTranscriptionReservation)\nclass AssetTranscriptionReservationAdmin(\n    admin.ModelAdmin, CustomListDisplayFieldsMixin\n):\n    list_display = (\n        \"created_on\",\n        \"updated_on\",\n        \"asset\",\n        \"reservation_token\",\n        \"tombstoned\",\n    )\n    list_display_links = (\"reservation_token\", \"created_on\")\n    readonly_fields = (\"asset\", \"created_on\", \"updated_on\")\n\n\n@admin.register(Asset)\nclass AssetAdmin(admin.ModelAdmin, CustomListDisplayFieldsMixin):\n    list_display = (\n        \"published\",\n        \"transcription_status\",\n        \"item_id\",\n        \"year\",\n        \"sequence\",\n        \"difficulty\",\n        \"truncated_storage_image\",\n        \"media_type\",\n        \"truncated_metadata\",\n    )\n    list_display_links = (\"item_id\", \"sequence\")\n    prepopulated_fields = {\"slug\": (\"title\",)}\n    search_fields = [\n        \"title\",\n        \"storage_image\",\n        \"item__project__campaign__title\",\n        \"item__project__title\",\n        \"item__item_id\",\n    ]\n    list_filter = (\n        \"transcription_status\",\n        \"published\",\n        \"item__project__topics\",\n        AssetCampaignStatusListFilter,\n        AssetCampaignListFilter,\n        AssetProjectListFilter,\n        \"media_type\",\n    )\n    actions = (\n        publish_action,\n        change_status_to_completed,\n        change_status_to_in_progress,\n        change_status_to_needs_review,\n        unpublish_action,\n        export_to_csv_action,\n        export_to_excel_action,\n        verify_assets_action,\n    )\n    status_action_names = (\n        \"change_status_to_completed\",\n        \"change_status_to_needs_review\",\n        \"change_status_to_in_progress\",\n    )\n    autocomplete_fields = (\"item\",)\n    ordering = (\"item__item_id\", \"sequence\")\n    change_list_template = \"admin/concordia/asset/change_list.html\"\n\n    def get_queryset(\n        self,\n        request: HttpRequest,\n    ):\n        \"\"\"\n        Optimize the queryset used on the asset changelist.\n\n        Selects related items so fields displayed in the changelist do not\n        trigger per-row database queries.\n        \"\"\"\n        qs = super().get_queryset(request)\n        return qs.select_related(\"item\").order_by(\"item__item_id\", \"sequence\")\n\n    def lookup_allowed(self, key: str, value: str) -> bool:\n        \"\"\"\n        Allow filtering by project and campaign id on the changelist.\n\n        Args:\n            key (str): Lookup parameter key.\n            value (str): Lookup parameter value.\n\n        Returns:\n            bool: True if the lookup is allowed.\n        \"\"\"\n        if key in (\"item__project__id__exact\", \"item__project__campaign__id__exact\"):\n            return True\n        else:\n            return super().lookup_allowed(key, value)\n\n    def response_action(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[Asset],\n    ) -> HttpResponse:\n        \"\"\"\n        Run the selected action and optionally redirect to a `next` URL.\n\n        After the base implementation runs the action, this override checks\n        for a `next` parameter in `POST` and, if it is a safe URL, redirects\n        to it instead of the default changelist.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[Asset]): Selected assets for the action.\n\n        Returns:\n            HttpResponse: Default admin response or a redirect to the\n            `next` URL.\n        \"\"\"\n        # Let Django run the chosen action(s) normally\n        response = super().response_action(request, queryset)\n\n        # If a \"next\" came from our form, redirect there,\n        # after confirming it is either a relative path\n        # that starts with \"/\" or is an absolute URL\n        # pointing to our hostname\n        next_url = request.POST.get(\"next\")\n        if next_url:\n            if url_has_allowed_host_and_scheme(\n                url=next_url,\n                allowed_hosts={request.get_host()},\n                require_https=request.is_secure(),\n            ):\n                return HttpResponseRedirect(next_url)\n\n        # Otherwise, return whatever Django gave us\n        return response\n\n    def item_id(self, obj: Asset) -> str:\n        return obj.item.item_id\n\n    @admin.display(description=\"Media URL\")\n    def truncated_storage_image(self, obj: Asset) -> str:\n        return format_html(\n            '<a target=\"_blank\" href=\"{}\">{}</a>',\n            obj.storage_image.url,\n            truncatechars(obj.get_existing_storage_image_filename(), 100),\n        )\n\n    def get_readonly_fields(\n        self,\n        request: HttpRequest,\n        obj: Asset | None = None,\n    ) -> tuple[str, ...]:\n        \"\"\"\n        Mark some fields as read only after an asset has been created.\n\n        The item and campaign cannot be changed on existing assets but\n        remain editable when creating a new asset.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            obj (Asset | None): Asset being edited, or `None` when adding.\n\n        Returns:\n            tuple[str, ...]: Names of fields that should be read only.\n        \"\"\"\n        if obj:\n            return self.readonly_fields + (\"item\", \"campaign\")\n        return self.readonly_fields\n\n    def change_view(\n        self,\n        request: HttpRequest,\n        object_id: str,\n        extra_context: dict[str, Any] | None = None,\n        **kwargs: Any,\n    ) -> HttpResponse:\n        \"\"\"\n        Render the asset change form with extra status and transcription data.\n\n        This override injects a form for bulk status changes and a list of\n        related transcriptions into the template context.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            object_id (str): Primary key of the asset being edited.\n            extra_context (dict[str, Any] | None): Extra template context,\n                if any.\n            **kwargs (Any): Extra keyword arguments passed to the base\n                implementation.\n\n        Returns:\n            HttpResponse: Response from the admin change view.\n        \"\"\"\n        extra_context = extra_context or {}\n        asset = None\n        if object_id:\n            asset = self.get_object(request, object_id)\n            current_status = asset.transcription_status\n            # Dealing with this one special case let's use simplify the\n            # desired_actions filtering code here significantly\n            if current_status == \"submitted\":\n                current_status = \"needs_review\"\n            # We need the name of the action (for example,\n            # 'change_status_to_in_progress') and the description to show\n            # in the form (for example, \"Change status to In Progress\").\n            # We filter out any action matching the current status, since\n            # that is unneeded and potentially confusing.\n            desired_actions = [\n                (name, data[2])\n                for name, data in self.get_actions(request).items()\n                if name in self.status_action_names and current_status not in name\n            ]\n            status_form = AssetStatusActionForm(available_actions=desired_actions)\n            extra_context[\"status_action_form\"] = status_form\n\n        extra_context[\"transcriptions\"] = (\n            Transcription.objects.filter(asset__pk=object_id)\n            .select_related(\"user\", \"reviewed_by\")\n            .order_by(\"-pk\")\n        )\n\n        return super().change_view(\n            request, object_id, extra_context=extra_context, **kwargs\n        )\n\n    def has_reopen_permission(self, request: HttpRequest) -> bool:\n        \"\"\"\n        Check whether the user has the custom `reopen` permission.\n\n        Args:\n            request (HttpRequest): Current admin request.\n\n        Returns:\n            bool: True if the user has permission to reopen assets.\n        \"\"\"\n        opts = self.opts\n        codename = get_permission_codename(\"reopen\", opts)\n        return request.user.has_perm(f\"{opts.app_label}.{codename}\")\n\n\n@admin.register(Tag)\nclass TagAdmin(admin.ModelAdmin):\n    list_display = (\"id\", \"value\")\n    list_display_links = (\"id\", \"value\")\n    list_filter = (TagCampaignStatusListFilter, TagCampaignListFilter)\n\n    search_fields = [\"value\"]\n\n    actions = (\"export_tags_as_csv\",)\n\n    def lookup_allowed(self, key: str, value: str) -> bool:\n        \"\"\"\n        Allow filtering by campaign id when viewing tags.\n\n        Args:\n            key (str): Lookup parameter key.\n            value (str): Lookup parameter value.\n\n        Returns:\n            bool: True if the lookup is allowed.\n        \"\"\"\n        if key in [\"userassettagcollection__asset__item__project__campaign__id__exact\"]:\n            return True\n        return super().lookup_allowed(key, value)\n\n    def export_tags_as_csv(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[Tag],\n    ) -> HttpResponse:\n        \"\"\"\n        Export tag usage details as a CSV file.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[Tag]): Selected tags to export.\n\n        Returns:\n            HttpResponse: Response that streams a CSV download.\n        \"\"\"\n        tags = queryset.prefetch_related(\n            \"userassettagcollection\", \"userassettagcollection__asset\"\n        ).order_by(\"userassettagcollection__asset_id\")\n\n        headers, data = flatten_queryset(\n            tags,\n            field_names=[\n                \"value\",\n                \"userassettagcollection__created_on\",\n                \"userassettagcollection__user_id\",\n                \"userassettagcollection__asset_id\",\n                \"userassettagcollection__asset__title\",\n                \"userassettagcollection__asset__download_url\",\n                \"userassettagcollection__asset__resource_url\",\n                \"userassettagcollection__asset__item__project__campaign__slug\",\n            ],\n            extra_verbose_names={\n                \"value\": \"tag value\",\n                \"userassettagcollection__created_on\": \"user asset tag collection date created\",  # noqa: E501\n                \"userassettagcollection__user_id\": \"user asset tag collection user_id\",\n                \"userassettagcollection__asset_id\": \"asset id\",\n                \"userassettagcollection__asset__title\": \"asset title\",\n                \"userassettagcollection__asset__download_url\": \"asset download url\",\n                \"userassettagcollection__asset__resource_url\": \"asset resource url\",\n                \"userassettagcollection__asset__item__project__campaign__slug\": \"campaign slug\",  # noqa: E501\n            },\n        )\n\n        return export_to_csv_response(\"tags.csv\", headers, data)\n\n\n@admin.register(UserAssetTagCollection)\nclass UserAssetTagCollectionAdmin(admin.ModelAdmin):\n    list_display = (\"id\", \"asset\", \"user\", \"created_on\", \"updated_on\")\n    list_display_links = (\"id\", \"asset\")\n    date_hierarchy = \"created_on\"\n    search_fields = [\"asset__title\", \"asset__campaign__title\", \"asset__project__title\"]\n    list_filter = (\n        UserAssetTagCollectionCampaignStatusListFilter,\n        UserAssetTagCollectionCampaignListFilter,\n        \"asset__item__project\",\n        \"user__is_staff\",\n    )\n\n\n@admin.register(Transcription)\nclass TranscriptionAdmin(admin.ModelAdmin):\n    list_display = (\n        \"id\",\n        \"asset\",\n        \"user\",\n        \"campaign_slug\",\n        \"truncated_text\",\n        \"created_on\",\n        \"accepted\",\n        \"rejected\",\n        \"reviewed_by\",\n        \"superseded\",\n    )\n    list_display_links = (\"id\", \"asset\")\n\n    list_filter = (\n        SubmittedFilter,\n        AcceptedFilter,\n        RejectedFilter,\n        SupersededListFilter,\n        OcrGeneratedFilter,\n        OcrOriginatedFilter,\n        TranscriptionCampaignStatusListFilter,\n        TranscriptionCampaignListFilter,\n        TranscriptionProjectListFilter,\n    )\n\n    search_fields = [\"text\", \"user__username\", \"user__email\"]\n\n    readonly_fields = (\n        \"asset\",\n        \"user\",\n        \"created_on\",\n        \"updated_on\",\n        \"submitted\",\n        \"accepted\",\n        \"rejected\",\n        \"reviewed_by\",\n        \"supersedes\",\n        \"text\",\n        \"source\",\n    )\n\n    EXPORT_FIELDS = (\n        \"id\",\n        \"asset__id\",\n        \"asset__slug\",\n        \"user\",\n        \"created_on\",\n        \"updated_on\",\n        \"supersedes\",\n        \"submitted\",\n        \"accepted\",\n        \"rejected\",\n        \"reviewed_by\",\n        \"text\",\n        \"ocr_generated\",\n        \"ocr_originated\",\n    )\n\n    show_full_result_count = False\n\n    def get_queryset(\n        self,\n        request: HttpRequest,\n    ) -> QuerySet[Transcription]:\n        \"\"\"\n        Optimize the queryset used on the transcription changelist.\n\n        Selects related asset and user records and annotates a boolean flag\n        so the superseded column can be rendered without extra queries.\n\n        Args:\n            request (HttpRequest): Current admin request.\n\n        Returns:\n            QuerySet[Transcription]: Optimized queryset for the changelist.\n        \"\"\"\n        qs = super().get_queryset(request)\n        # Make FK columns cheaper to render\n        qs = qs.select_related(\"asset\", \"user\", \"reviewed_by\")\n\n        # Annotate a boolean so the \"Superseded?\" column is O(1) per row\n        return qs.annotate(\n            is_superseded=Exists(\n                Transcription.objects.filter(supersedes=OuterRef(\"pk\"))\n            )\n        )\n\n    @admin.display(description=\"Text\")\n    def truncated_text(self, obj: Transcription) -> str:\n        \"\"\"\n        Return a shortened version of the transcription text.\n\n        Args:\n            obj (Transcription): Transcription instance.\n\n        Returns:\n            str: Text truncated to a reasonable length for display.\n        \"\"\"\n        return truncatechars(obj.text, 100)\n\n    @admin.display(boolean=True, description=\"Superseded?\")\n    def superseded(self, obj: Transcription) -> bool:\n        \"\"\"\n        Indicate whether this transcription has been superseded.\n\n        Uses the `is_superseded` annotation added in `get_queryset` so the\n        column can be rendered without extra queries.\n\n        Args:\n            obj (Transcription): Transcription instance.\n\n        Returns:\n            bool: True if a later transcription supersedes this one.\n        \"\"\"\n        # Uses the annotation from get_queryset; no per-row queries.\n        return bool(getattr(obj, \"is_superseded\", False))\n\n    def lookup_allowed(self, key: str, value: str) -> bool:\n        \"\"\"\n        Allow filtering by campaign id in the transcription admin.\n\n        Args:\n            key (str): Lookup parameter key.\n            value (str): Lookup parameter value.\n\n        Returns:\n            bool: True if the lookup is allowed.\n        \"\"\"\n        if key in (\"asset__item__project__campaign__id__exact\",):\n            return True\n        return super().lookup_allowed(key, value)\n\n    def export_to_csv(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[Transcription],\n    ) -> HttpResponse:\n        \"\"\"\n        Export selected transcriptions as a CSV file.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[Transcription]): Transcriptions to export.\n\n        Returns:\n            HttpResponse: Response that streams a CSV download.\n        \"\"\"\n        return export_to_csv_action(\n            self, request, queryset, field_names=self.EXPORT_FIELDS\n        )\n\n    def export_to_excel(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[Transcription],\n    ) -> HttpResponse:\n        \"\"\"\n        Export selected transcriptions as an Excel file.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[Transcription]): Transcriptions to export.\n\n        Returns:\n            HttpResponse: Response that streams an Excel download.\n        \"\"\"\n        return export_to_excel_action(\n            self, request, queryset, field_names=self.EXPORT_FIELDS\n        )\n\n    actions = (export_to_csv, export_to_excel)\n\n\n@admin.register(CarouselSlide)\nclass CarouselSlideAdmin(admin.ModelAdmin):\n    list_display = (\"headline\", \"published\", \"ordering\")\n    readonly_fields = (\"created_on\", \"updated_on\")\n\n\n@admin.register(SimplePage)\nclass SimplePageAdmin(admin.ModelAdmin):\n    list_display = (\"path\", \"title\", \"created_on\", \"updated_on\")\n    readonly_fields = (\"created_on\", \"updated_on\")\n\n    fieldsets = (\n        (None, {\"fields\": (\"created_on\", \"updated_on\", \"path\", \"title\")}),\n        (\"Body\", {\"classes\": (\"markdown-preview\",), \"fields\": (\"body\",)}),\n    )\n\n\n@admin.register(SiteReport)\nclass SiteReportAdmin(admin.ModelAdmin):\n    list_display = (\"created_on\", \"report_type\")\n    readonly_fields = (\n        \"created_on\",\n        \"report_type\",\n        \"previous_in_series_link\",\n        \"next_in_series_link\",\n        \"report_json\",\n    )\n    fieldsets = (\n        (\"Summary\", {\"fields\": (\"created_on\", \"report_type\")}),\n        (\n            \"Navigation within series\",\n            {\"fields\": (\"previous_in_series_link\", \"next_in_series_link\")},\n        ),\n        (\n            \"Data\",\n            {\n                \"fields\": (\n                    \"report_name\",\n                    \"campaign\",\n                    \"topic\",\n                    \"assets_total\",\n                    \"assets_published\",\n                    \"assets_not_started\",\n                    \"assets_in_progress\",\n                    \"assets_waiting_review\",\n                    \"assets_completed\",\n                    \"assets_unpublished\",\n                    \"assets_started\",\n                    \"items_published\",\n                    \"items_unpublished\",\n                    \"projects_published\",\n                    \"projects_unpublished\",\n                    \"anonymous_transcriptions\",\n                    \"transcriptions_saved\",\n                    \"daily_review_actions\",\n                    \"distinct_tags\",\n                    \"tag_uses\",\n                    \"campaigns_published\",\n                    \"campaigns_unpublished\",\n                    \"users_registered\",\n                    \"users_activated\",\n                    \"registered_contributors\",\n                    \"daily_active_users\",\n                )\n            },\n        ),\n        (\"Debug\", {\"fields\": (\"report_json\",)}),\n    )\n\n    list_filter = (\n        \"report_name\",\n        SiteReportSortedCampaignListFilter,\n        SiteReportCampaignListFilter,\n        \"topic\",\n    )\n\n    @admin.display(description=\"Report type\")\n    def report_type(self, obj: \"SiteReport\") -> str:\n        \"\"\"\n        Describe what kind of report this SiteReport represents.\n\n        Args:\n            obj (SiteReport): Site report instance.\n\n        Returns:\n            str: Human readable description of the report source.\n        \"\"\"\n        if obj.report_name:\n            return f\"Report name: {obj.report_name}\"\n        elif obj.campaign:\n            return f\"Campaign: {obj.campaign}\"\n        elif obj.topic:\n            return f\"Topic: {obj.topic}\"\n        else:\n            return f\"SiteReport: <{obj.id}>\"\n\n    @admin.display(description=\"SiteReport as JSON\")\n    def report_json(self, obj: \"SiteReport\") -> str:\n        \"\"\"\n        Render a pretty printed JSON representation of this report.\n\n        Args:\n            obj (SiteReport): Site report instance.\n\n        Returns:\n            str: HTML snippet that shows the report JSON in a `<pre>` block.\n        \"\"\"\n        return format_html(\n            \"<pre style='white-space:pre-wrap;word-break:break-word;margin:0'>{}</pre>\",\n            obj.to_debug_json(),\n        )\n\n    @admin.display(description=\"Previous in series\")\n    def previous_in_series_link(self, obj: \"SiteReport\") -> str:\n        \"\"\"\n        Link to the previous report in this series, if any.\n\n        Args:\n            obj (SiteReport): Site report instance.\n\n        Returns:\n            str: HTML anchor tag for the previous report or a dash when\n            none exists.\n        \"\"\"\n        prev_obj = obj.previous_in_series()\n        if not prev_obj:\n            return \"—\"\n        url = reverse(\n            f\"admin:{prev_obj._meta.app_label}_{prev_obj._meta.model_name}_change\",\n            args=[prev_obj.pk],\n        )\n        label = f\"{prev_obj.created_on:%Y-%m-%d %H:%M:%S} (id {prev_obj.pk})\"\n        return format_html('<a href=\"{}\">{}</a>', url, label)\n\n    @admin.display(description=\"Next in series\")\n    def next_in_series_link(self, obj: \"SiteReport\") -> str:\n        \"\"\"\n        Link to the next report in this series, if any.\n\n        Args:\n            obj (SiteReport): Site report instance.\n\n        Returns:\n            str: HTML anchor tag for the next report or a dash when\n            none exists.\n        \"\"\"\n        next_obj = obj.next_in_series()\n        if not next_obj:\n            return \"—\"\n        url = reverse(\n            f\"admin:{next_obj._meta.app_label}_{next_obj._meta.model_name}_change\",\n            args=[next_obj.pk],\n        )\n        label = f\"{next_obj.created_on:%Y-%m-%d %H:%M:%S} (id {next_obj.pk})\"\n        return format_html('<a href=\"{}\">{}</a>', url, label)\n\n    def export_to_csv(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[SiteReport],\n    ) -> HttpResponse:\n        \"\"\"\n        Export selected site reports as a CSV file.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[SiteReport]): Site reports to export.\n\n        Returns:\n            HttpResponse: Response that streams a CSV download.\n        \"\"\"\n        return export_to_csv_action(\n            self, request, queryset, field_names=SiteReport.DEFAULT_EXPORT_FIELDNAMES\n        )\n\n    def export_to_excel(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[SiteReport],\n    ) -> HttpResponse:\n        \"\"\"\n        Export selected site reports as an Excel file.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[SiteReport]): Site reports to export.\n\n        Returns:\n            HttpResponse: Response that streams an Excel download.\n        \"\"\"\n        return export_to_excel_action(\n            self, request, queryset, field_names=SiteReport.DEFAULT_EXPORT_FIELDNAMES\n        )\n\n    actions = (export_to_csv, export_to_excel)\n\n\n@admin.register(UserProfileActivity)\nclass UserProfileActivityAdmin(admin.ModelAdmin):\n    list_display = (\n        \"id\",\n        \"user\",\n        \"campaign\",\n        \"get_status\",\n        \"transcribe_count\",\n        \"review_count\",\n    )\n    list_filter = (\n        UserProfileActivityCampaignStatusListFilter,\n        UserProfileActivityCampaignListFilter,\n    )\n    raw_id_fields = [\"user\", \"campaign\"]\n    read_only_fields = (\n        \"user\",\n        \"campaign\",\n        \"asset_count\",\n        \"asset_tag_count\",\n        \"transcribe_count\",\n        \"review_count\",\n    )\n    search_fields = [\n        \"user__username\",\n    ]\n\n\n@admin.register(CampaignRetirementProgress)\nclass CampaignRetirementProgressAdmin(admin.ModelAdmin):\n    list_display = (\"campaign\", \"started_on\", \"complete\", \"completed_on\")\n    readonly_fields = (\n        \"campaign\",\n        \"completion\",\n        \"projects_removed\",\n        \"project_total\",\n        \"items_removed\",\n        \"item_total\",\n        \"assets_removed\",\n        \"asset_total\",\n        \"complete\",\n        \"started_on\",\n        \"completed_on\",\n        \"removal_log\",\n    )\n    fieldsets = (\n        (\n            None,\n            {\n                \"fields\": (\n                    \"campaign\",\n                    \"completion\",\n                    \"projects_removed\",\n                    \"project_total\",\n                    \"items_removed\",\n                    \"item_total\",\n                    \"assets_removed\",\n                    \"asset_total\",\n                    \"complete\",\n                    \"started_on\",\n                    \"completed_on\",\n                ),\n            },\n        ),\n        (\n            \"Log\",\n            {\n                \"fields\": (\"removal_log\",),\n                \"classes\": (\"collapse\",),\n            },\n        ),\n    )\n\n    @admin.display(description=\"Completion percentage\")\n    def completion(self, obj: CampaignRetirementProgress) -> str:\n        \"\"\"\n        Compute a human readable completion percentage for display.\n\n        Args:\n            obj (CampaignRetirementProgress): Progress instance.\n\n        Returns:\n            str: Percentage text such as `\"100%\"`.\n        \"\"\"\n        if obj.complete:\n            return \"100%\"\n        total = obj.project_total + obj.item_total + obj.asset_total\n        removed = obj.projects_removed + obj.items_removed + obj.assets_removed\n        return \"{}%\".format(round(removed / total * 100, 2))\n\n\n@admin.register(Card)\nclass CardAdmin(admin.ModelAdmin):\n    form = CardAdminForm\n    fields = (\"title\", \"display_heading\", \"body_text\", \"image\", \"image_alt_text\")\n    list_display = [\"title\", \"display_heading\", \"created_on\", \"updated_on\"]\n    list_filter = (CardCampaignListFilter, \"updated_on\")\n\n\nclass TutorialInline(admin.TabularInline):\n    model = TutorialCard\n    extra = 1\n    raw_id_fields = (\"card\",)\n\n\n@admin.register(CardFamily)\nclass CardFamilyAdmin(admin.ModelAdmin):\n    inlines = (TutorialInline,)\n\n    class Media:\n        js = (\"dist/js/admin_custom-HASH.js\",)\n\n\n@admin.register(Guide)\nclass GuideAdmin(admin.ModelAdmin):\n    form = GuideAdminForm\n\n\n@admin.register(NextTranscribableCampaignAsset)\nclass NextTranscribableCampaignAssetAdmin(admin.ModelAdmin):\n    list_display = (\n        \"asset\",\n        \"transcription_status\",\n        \"campaign\",\n        \"created_on\",\n    )\n    list_filter = (\n        NextAssetCampaignListFilter,\n        \"transcription_status\",\n    )\n    search_fields = (\n        \"asset__title\",\n        \"item__title\",\n        \"project__title\",\n        \"campaign__title\",\n    )\n    readonly_fields = (\n        \"asset\",\n        \"sequence\",\n        \"item\",\n        \"item_item_id\",\n        \"project\",\n        \"project_slug\",\n        \"campaign\",\n        \"created_on\",\n    )\n    ordering = (\"-created_on\",)\n\n\n@admin.register(NextReviewableCampaignAsset)\nclass NextReviewableCampaignAssetAdmin(admin.ModelAdmin):\n    list_display = (\n        \"asset\",\n        \"campaign\",\n        \"created_on\",\n    )\n    list_filter = (NextAssetCampaignListFilter,)\n    search_fields = (\n        \"asset__title\",\n        \"item__title\",\n        \"project__title\",\n        \"campaign__title\",\n        \"transcriber_ids\",\n    )\n    readonly_fields = (\n        \"asset\",\n        \"item\",\n        \"project\",\n        \"campaign\",\n        \"transcriber_ids\",\n        \"created_on\",\n    )\n    ordering = (\"-created_on\",)\n\n\n@admin.register(NextTranscribableTopicAsset)\nclass NextTranscribableTopicAssetAdmin(admin.ModelAdmin):\n    list_display = (\n        \"asset\",\n        \"transcription_status\",\n        \"topic\",\n        \"created_on\",\n    )\n    list_filter = (\n        TopicListFilter,\n        \"transcription_status\",\n    )\n    search_fields = (\n        \"asset__title\",\n        \"item__title\",\n        \"project__title\",\n        \"topic__title\",\n    )\n    readonly_fields = (\n        \"asset\",\n        \"sequence\",\n        \"item\",\n        \"item_item_id\",\n        \"project\",\n        \"project_slug\",\n        \"topic\",\n        \"created_on\",\n    )\n    ordering = (\"-created_on\",)\n\n\n@admin.register(NextReviewableTopicAsset)\nclass NextReviewableTopicAssetAdmin(admin.ModelAdmin):\n    list_display = (\n        \"asset\",\n        \"topic\",\n        \"created_on\",\n    )\n    list_filter = (TopicListFilter,)\n    search_fields = (\n        \"asset__title\",\n        \"item__title\",\n        \"project__title\",\n        \"topic__title\",\n        \"transcriber_ids\",\n    )\n    readonly_fields = (\n        \"asset\",\n        \"item\",\n        \"project\",\n        \"topic\",\n        \"transcriber_ids\",\n        \"created_on\",\n    )\n    ordering = (\"-created_on\",)\n\n\n@admin.register(KeyMetricsReport)\nclass KeyMetricsReportAdmin(admin.ModelAdmin):\n    form = KeyMetricsReportAdminForm\n\n    readonly_fields = (\n        \"created_on\",\n        \"updated_on\",\n        \"period_type\",\n        \"period_start\",\n        \"period_end\",\n        \"fiscal_year\",\n        \"fiscal_quarter\",\n        \"month\",\n        \"download_csv_link\",\n    )\n\n    list_display = (\n        \"period_type\",\n        \"fiscal_year\",\n        \"fiscal_quarter\",\n        \"month\",\n        \"period_start\",\n        \"period_end\",\n        \"updated_on\",\n    )\n    list_filter = (\n        \"period_type\",\n        \"fiscal_year\",\n        \"fiscal_quarter\",\n        \"month\",\n    )\n    search_fields = (\"period_type\",)\n    ordering = (\"-period_start\", \"-period_end\", \"period_type\")\n\n    fieldsets = (\n        (\n            \"Report details\",\n            {\n                \"description\": (\n                    \"These fields describe which period this report covers and \"\n                    \"when it was last updated. They cannot be edited here.\"\n                ),\n                \"fields\": (\n                    \"period_type\",\n                    \"period_start\",\n                    \"period_end\",\n                    \"fiscal_year\",\n                    \"fiscal_quarter\",\n                    \"month\",\n                    \"created_on\",\n                    \"updated_on\",\n                    \"download_csv_link\",\n                ),\n            },\n        ),\n        (\n            \"Manual Fields (editable)\",\n            {\n                \"description\": (\n                    \"You can type values here if you track them outside of \"\n                    \"Concordia. Blank values are not included in quarterly or \"\n                    \"fiscal-year totals. If you later add values for the \"\n                    \"underlying months, those totals may update the quarterly \"\n                    \"and fiscal-year reports when reports are rebuilt.\"\n                ),\n                \"fields\": (\n                    \"crowd_emails_and_libanswers_sent\",\n                    \"crowd_visits\",\n                    \"crowd_page_views\",\n                    \"crowd_unique_visitors\",\n                    \"avg_visit_seconds\",\n                    \"transcriptions_added_to_loc_gov\",\n                    \"datasets_added_to_loc_gov\",\n                ),\n            },\n        ),\n        (\n            \"Calculated metrics (editable, may be overwritten)\",\n            {\n                \"description\": (\n                    \"These numbers are usually calculated from Site Reports. \"\n                    \"You can edit them here if needed, but they may be \"\n                    \"overwritten when reports are rebuilt. Monthly reports can \"\n                    \"be recomputed when new daily Site Reports arrive. \"\n                    \"Quarterly reports can be recomputed when any monthly \"\n                    \"report in the quarter is updated. Fiscal-year reports can \"\n                    \"be recomputed when any quarterly report in the year is \"\n                    \"updated.\"\n                ),\n                \"fields\": (\n                    \"assets_published\",\n                    \"assets_started\",\n                    \"assets_completed\",\n                    \"users_activated\",\n                    \"anonymous_transcriptions\",\n                    \"transcriptions_saved\",\n                    \"tag_uses\",\n                ),\n            },\n        ),\n    )\n\n    @admin.display(description=\"Download CSV\")\n    def download_csv_link(self, obj: \"KeyMetricsReport\") -> str:\n        \"\"\"\n        Provide a link to download this report as a CSV file.\n\n        Args:\n            obj (KeyMetricsReport): Report instance.\n\n        Returns:\n            str: HTML anchor tag for the CSV download.\n        \"\"\"\n        url = reverse(\n            f\"admin:{obj._meta.app_label}_{obj._meta.model_name}_download_csv\",\n            args=[obj.pk],\n        )\n        return format_html('<a class=\"button\" href=\"{}\">Download CSV</a>', url)\n\n    def get_urls(self):\n        \"\"\"\n        Register a custom admin view to serve CSV files for reports.\n\n        Returns:\n            list: Custom URL patterns combined with the default admin URLs.\n        \"\"\"\n        urls = super().get_urls()\n        opts = self.model._meta\n        custom_urls = [\n            path(\n                \"<path:object_id>/download_csv/\",\n                self.admin_site.admin_view(self.download_csv_view),\n                name=f\"{opts.app_label}_{opts.model_name}_download_csv\",\n            ),\n        ]\n        return custom_urls + urls\n\n    def download_csv_view(\n        self,\n        request: HttpRequest,\n        object_id: str,\n    ) -> HttpResponse:\n        \"\"\"\n        Serve the CSV for a single KeyMetricsReport instance.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            object_id (str): Primary key of the report to download.\n\n        Returns:\n            HttpResponse: CSV download response.\n\n        Raises:\n            Http404: If the report cannot be found.\n        \"\"\"\n        obj = self.get_object(request, object_id)\n        if obj is None:\n            raise Http404(\"Report not found.\")\n        csv_bytes = obj.render_csv()\n        response = HttpResponse(csv_bytes, content_type=\"text/csv\")\n        response[\"Content-Disposition\"] = f'attachment; filename=\"{obj.csv_filename()}\"'\n        return response\n\n    @admin.action(description=\"Download CSVs of selected reports as a ZIP\")\n    def download_selected_as_zip(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet[KeyMetricsReport],\n    ) -> HttpResponse:\n        \"\"\"\n        Stream a ZIP file containing one CSV per selected report.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet[KeyMetricsReport]): Reports to export.\n\n        Returns:\n            HttpResponse: ZIP download response.\n        \"\"\"\n        memory_file = io.BytesIO()\n        with zipfile.ZipFile(\n            memory_file, mode=\"w\", compression=zipfile.ZIP_DEFLATED\n        ) as zf:\n            for report in queryset.order_by(\"period_start\", \"period_type\"):\n                zf.writestr(report.csv_filename(), report.render_csv())\n        memory_file.seek(0)\n\n        response = HttpResponse(memory_file.getvalue(), content_type=\"application/zip\")\n        response[\"Content-Disposition\"] = (\n            'attachment; filename=\"key_metrics_reports.zip\"'\n        )\n        return response\n\n    actions = (\"download_selected_as_zip\",)\n"
  },
  {
    "path": "concordia/admin/actions.py",
    "content": "import uuid\nfrom logging import getLogger\n\nfrom django.contrib import admin, messages\nfrom django.db.models import QuerySet\nfrom django.http import HttpRequest\nfrom django.utils.timezone import now\n\nfrom importer.utils import create_verify_asset_image_job_batch\n\nfrom ..models import (\n    Asset,\n    Campaign,\n    Item,\n    Project,\n    Transcription,\n    TranscriptionStatus,\n)\nfrom .utils import _bulk_change_status\n\nlogger = getLogger(__name__)\n\n\n@admin.action(\n    permissions=[\"change\"],\n    description=\"Anonymize and disable user accounts\",\n)\ndef anonymize_action(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet,\n) -> None:\n    \"\"\"\n    Anonymize and disable selected user accounts.\n\n    Replaces identifying fields of each user account with placeholder values,\n    sets the account to inactive, and removes staff and superuser status.\n    Records a message with the number of accounts changed.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet): Selected user accounts to anonymize.\n\n    Returns:\n        None\n    \"\"\"\n    count = queryset.count()\n    for user_account in queryset:\n        user_account.username = \"Anonymized %s\" % uuid.uuid4()\n        user_account.first_name = \"\"\n        user_account.last_name = \"\"\n        user_account.email = \"\"\n        user_account.set_unusable_password()\n        user_account.is_staff = False\n        user_account.is_superuser = False\n        user_account.is_active = False\n        user_account.save()\n\n    messages.info(\n        request,\n        f\"Anonymized and disabled {count} user accounts\",\n        fail_silently=True,\n    )\n\n\n@admin.action(permissions=[\"change\"], description=\"Publish selected items and assets\")\ndef publish_item_action(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet[Item],\n) -> None:\n    \"\"\"\n    Publish selected items and their related assets.\n\n    Marks each selected `Item` as published and updates any related `Asset`\n    instances that are not yet published. Records a message with the number\n    of items and assets changed.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet[Item]): Selected items to publish.\n\n    Returns:\n        None\n    \"\"\"\n    count = queryset.filter(published=False).update(published=True)\n    asset_count = Asset.objects.filter(item__in=queryset, published=False).update(\n        published=True\n    )\n\n    messages.info(\n        request,\n        f\"Published {count} items and {asset_count} assets\",\n        fail_silently=True,\n    )\n\n\n@admin.action(\n    permissions=[\"change\"],\n    description=\"Unpublish selected items and assets\",\n)\ndef unpublish_item_action(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet[Item],\n) -> None:\n    \"\"\"\n    Unpublish selected items and their related assets.\n\n    Marks each selected `Item` as unpublished and updates any related `Asset`\n    instances that are currently published. Records a message with the number\n    of items and assets changed.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet[Item]): Selected items to unpublish.\n\n    Returns:\n        None\n    \"\"\"\n    count = queryset.filter(published=True).update(published=False)\n    asset_count = Asset.objects.filter(item__in=queryset, published=True).update(\n        published=False\n    )\n\n    messages.info(\n        request,\n        f\"Unpublished {count} items and {asset_count} assets\",\n        fail_silently=True,\n    )\n\n\n@admin.action(permissions=[\"change\"], description=\"Publish selected\")\ndef publish_action(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet,\n) -> None:\n    \"\"\"\n    Publish selected objects.\n\n    Marks each selected object in the queryset as published. This action\n    assumes the target model has a boolean `published` field. Records a\n    message with the number of objects changed.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet): Selected objects to publish.\n\n    Returns:\n        None\n    \"\"\"\n    count = queryset.filter(published=False).update(published=True)\n    messages.info(request, f\"Published {count} objects\", fail_silently=True)\n\n\n@admin.action(permissions=[\"change\"], description=\"Unpublish selected\")\ndef unpublish_action(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet,\n) -> None:\n    \"\"\"\n    Unpublish selected objects.\n\n    Marks each selected object in the queryset as unpublished. This action\n    assumes the target model has a boolean `published` field. Records a\n    message with the number of objects changed.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet): Selected objects to unpublish.\n\n    Returns:\n        None\n    \"\"\"\n    count = queryset.filter(published=True).update(published=False)\n    messages.info(request, f\"Unpublished {count} objects\", fail_silently=True)\n\n\n@admin.action(permissions=[\"reopen\"], description=\"Change status to Completed\")\ndef change_status_to_completed(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet[Asset],\n) -> None:\n    \"\"\"\n    Mark selected assets as completed by accepting a transcription.\n\n    For each asset whose `transcription_status` is not\n    `TranscriptionStatus.COMPLETED`, accepts the latest transcription or\n    creates a new one if none exists. The new or updated transcription is\n    marked as accepted by the current user and validated before saving.\n    Records a message describing which assets were changed.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet[Asset]): Selected assets to mark as completed.\n\n    Returns:\n        None\n    \"\"\"\n    assets = queryset.exclude(transcription_status=TranscriptionStatus.COMPLETED)\n    count = assets.count()\n    if count == 1:\n        changed_asset = assets.first()\n    else:\n        changed_asset = False\n\n    for asset in assets:\n        latest_transcription = asset.transcription_set.order_by(\"-pk\").first()\n        if latest_transcription is None:\n            kwargs = {\n                \"asset\": asset,\n                \"user\": request.user,\n            }\n            latest_transcription = Transcription(**kwargs)\n        latest_transcription.accepted = now()\n        latest_transcription.rejected = None\n        latest_transcription.reviewed_by = request.user\n        latest_transcription.clean_fields()\n        latest_transcription.validate_unique()\n        latest_transcription.save()\n\n    if changed_asset:\n        messages.info(\n            request,\n            f\"Changed status of {changed_asset.title} to Complete\",\n            fail_silently=True,\n        )\n    else:\n        messages.info(\n            request,\n            f\"Changed status of {count} assets to Complete\",\n            fail_silently=True,\n        )\n\n\n@admin.action(permissions=[\"reopen\"], description=\"Change status to Needs Review\")\ndef change_status_to_needs_review(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet[Asset],\n) -> None:\n    \"\"\"\n    Move selected assets to the Needs Review workflow status.\n\n    Filters out assets that are already submitted, then calls `_change_status`\n    to create new submitted transcriptions reviewed by the current user.\n    Records a message describing which assets were changed.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet[Asset]): Selected assets to move to Needs Review.\n\n    Returns:\n        None\n    \"\"\"\n    eligible = queryset.exclude(transcription_status=TranscriptionStatus.SUBMITTED)\n    rows = [\n        {\"slug\": asset.slug, \"status\": TranscriptionStatus.SUBMITTED}\n        for asset in eligible\n    ]\n    count = _bulk_change_status(request.user, rows)\n\n    if count == 1:\n        asset = queryset.first()\n        messages.info(\n            request,\n            f\"Changed status of {asset.title} to Needs Review\",\n            fail_silently=True,\n        )\n    else:\n        messages.info(\n            request,\n            f\"Changed status of {count} assets to Needs Review\",\n            fail_silently=True,\n        )\n\n\n@admin.action(permissions=[\"reopen\"], description=\"Change status to In Progress\")\ndef change_status_to_in_progress(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet[Asset],\n) -> None:\n    \"\"\"\n    Move selected assets to the In Progress workflow status.\n\n    Filters out assets that are already in progress, then calls\n    `_change_status` with `submit` set to false to create new rejected\n    transcriptions reviewed by the current user. Records a message describing\n    which assets were changed.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet[Asset]): Selected assets to move to In Progress.\n\n    Returns:\n        None\n    \"\"\"\n    eligible = queryset.exclude(transcription_status=TranscriptionStatus.IN_PROGRESS)\n    rows = [\n        {\"slug\": asset.slug, \"status\": TranscriptionStatus.IN_PROGRESS}\n        for asset in eligible\n    ]\n    count = _bulk_change_status(request.user, rows)\n\n    if count == 1:\n        asset = queryset.first()\n        messages.info(\n            request,\n            f\"Changed status of {asset.title} to In Progress\",\n            fail_silently=True,\n        )\n    else:\n        messages.info(\n            request,\n            f\"Changed status of {count} assets to In Progress\",\n            fail_silently=True,\n        )\n\n\n@admin.action(\n    permissions=[\"change\"],\n    description=\"Verify images for all assets for selected objects\",\n)\ndef verify_assets_action(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet,\n) -> None:\n    \"\"\"\n    Create image verification jobs for assets related to the selected objects.\n\n    Depending on which admin model invoked this action, it collects asset\n    primary keys from the selected `Campaign`, `Project`, `Item` or `Asset`\n    instances. It then calls `create_verify_asset_image_job_batch` to create\n    a batch of verification jobs and shows a link to the batch in the admin\n    messages.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class that owns this action.\n        request (HttpRequest): Current request.\n        queryset (QuerySet): Selected objects used to look up assets.\n\n    Returns:\n        None\n    \"\"\"\n    batch = str(uuid.uuid4())\n\n    if modeladmin.model == Campaign:\n        asset_pks = Asset.objects.filter(campaign__in=queryset).values_list(\n            \"id\", flat=True\n        )\n    elif modeladmin.model == Project:\n        asset_pks = Asset.objects.filter(item__project__in=queryset).values_list(\n            \"id\", flat=True\n        )\n    elif modeladmin.model == Item:\n        asset_pks = Asset.objects.filter(item__in=queryset).values_list(\n            \"id\",\n            flat=True,\n        )\n    elif modeladmin.model == Asset:\n        asset_pks = queryset.values_list(\"id\", flat=True)\n    else:\n        modeladmin.message_user(\n            request, \"This action is not available for this model.\", level=\"error\"\n        )\n        return\n\n    job_count, url = create_verify_asset_image_job_batch(asset_pks, batch)\n\n    modeladmin.message_user(\n        request,\n        f\"Created {job_count} VerifyAssetImageJobs as part of batch {batch}. \"\n        f'<a href=\"{url}\" target=\"_blank\">View the created jobs</a>',\n        extra_tags=\"marked-safe\",\n    )\n"
  },
  {
    "path": "concordia/admin/filters.py",
    "content": "from django.contrib import admin\nfrom django.db.models import Exists, OuterRef\nfrom django.utils.translation import gettext_lazy as _\n\nfrom ..models import Campaign, Project, Topic, Transcription\n\n\nclass NullableTimestampFilter(admin.SimpleListFilter):\n    \"\"\"\n    Base class for admin list filters that test if a datetime field is set.\n\n    Provides \"null\" and \"not-null\" choices based on a configured\n    `parameter_name` that points to a `DateTimeField` or similar attribute.\n    \"\"\"\n\n    # Title displayed on the list filter URL\n    title = \"\"\n    # Model field name:\n    parameter_name = \"\"\n    # Choices displayed\n    lookup_labels = (\"NULL\", \"NOT NULL\")\n\n    def lookups(self, request, model_admin):\n        return zip((\"null\", \"not-null\"), self.lookup_labels, strict=False)\n\n    def queryset(self, request, queryset):\n        kwargs = {\"%s__isnull\" % self.parameter_name: True}\n        if self.value() == \"null\":\n            return queryset.filter(**kwargs)\n        elif self.value() == \"not-null\":\n            return queryset.exclude(**kwargs)\n        return queryset\n\n\nclass SubmittedFilter(NullableTimestampFilter):\n    \"\"\"\n    Filter transcriptions by whether the `submitted` timestamp is set.\n    \"\"\"\n\n    title = \"Submitted\"\n    parameter_name = \"submitted\"\n    lookup_labels = (\"Pending\", \"Submitted\")\n\n\nclass AcceptedFilter(NullableTimestampFilter):\n    \"\"\"\n    Filter transcriptions by whether the `accepted` timestamp is set.\n    \"\"\"\n\n    title = \"Accepted\"\n    parameter_name = \"accepted\"\n    lookup_labels = (\"Pending\", \"Accepted\")\n\n\nclass RejectedFilter(NullableTimestampFilter):\n    \"\"\"\n    Filter transcriptions by whether the `rejected` timestamp is set.\n    \"\"\"\n\n    title = \"Rejected\"\n    parameter_name = \"rejected\"\n    lookup_labels = (\"Pending\", \"Rejected\")\n\n\nclass CampaignListFilter(admin.SimpleListFilter):\n    \"\"\"\n    Base class for campaign filters used in admin changelists.\n\n    Filters by a campaign identifier stored in `parameter_name` and\n    optionally narrows results by campaign status when a related status\n    query parameter is present.\n    \"\"\"\n\n    title = \"Campaign\"\n    template = \"admin/long_name_filter.html\"\n\n    def lookups(self, request, model_admin):\n        queryset = Campaign.objects.exclude(status=Campaign.Status.RETIRED)\n        if self.status_filter_parameter in request.GET:\n            queryset = queryset.filter(status=request.GET[self.status_filter_parameter])\n        return queryset.values_list(\"id\", \"title\").order_by(\"title\")\n\n    def queryset(self, request, queryset):\n        if self.value():\n            return queryset.filter(**{self.parameter_name: self.value()})\n        return queryset\n\n\nclass CardCampaignListFilter(admin.SimpleListFilter):\n    \"\"\"\n    Filter cards by the campaign that owns their card family.\n\n    Shows only campaigns with a non-null `card_family` and restricts\n    cards to those within the selected campaign's family.\n    \"\"\"\n\n    title = _(\"campaign\")\n    parameter_name = \"campaign\"\n\n    def lookups(self, request, model_admin):\n        return Campaign.objects.exclude(card_family__isnull=True).values_list(\n            \"pk\", \"title\"\n        )\n\n    def queryset(self, request, queryset):\n        campaign_id = self.value()\n        if campaign_id:\n            card_family = Campaign.objects.get(pk=campaign_id).card_family\n            if card_family is None:\n                pks = []\n            else:\n                pks = card_family.cards.values_list(\"pk\", flat=True)\n            queryset = queryset.filter(id__in=pks)\n        return queryset\n\n\nclass TopicListFilter(admin.SimpleListFilter):\n    \"\"\"\n    Base class for topic filters used in admin changelists.\n\n    Filters by topic identifier using the configured `parameter_name`.\n    \"\"\"\n\n    title = \"Topic\"\n    template = \"admin/long_name_filter.html\"\n    parameter_name = \"topic__id__exact\"\n\n    def lookups(self, request, model_admin):\n        queryset = Topic.objects.all()\n        return queryset.values_list(\"id\", \"title\").order_by(\"title\")\n\n    def queryset(self, request, queryset):\n        if self.value():\n            return queryset.filter(**{self.parameter_name: self.value()})\n        return queryset\n\n\nclass ProjectCampaignListFilter(CampaignListFilter):\n    parameter_name = \"campaign__id__exact\"\n    status_filter_parameter = \"campaign__status\"\n\n\nclass ItemCampaignListFilter(CampaignListFilter):\n    parameter_name = \"project__campaign__id__exact\"\n    status_filter_parameter = \"project__campaign__status\"\n\n\nclass AssetCampaignListFilter(CampaignListFilter):\n    parameter_name = \"item__project__campaign__id__exact\"\n    status_filter_parameter = \"item__project__campaign__status\"\n\n\nclass UserProfileActivityCampaignListFilter(CampaignListFilter):\n    parameter_name = \"campaign__id__exact\"\n    status_filter_parameter = \"campaign__status\"\n\n\nclass SiteReportCampaignListBaseFilter(CampaignListFilter):\n    \"\"\"\n    Base filter for site report campaigns that supports empty-campaign rows.\n\n    Extends `CampaignListFilter` to optionally include an explicit \"no\n    campaign\" choice controlled by `include_empty_choice` and the\n    `lookup_kwarg_isnull` query parameter.\n    \"\"\"\n\n    lookup_kwarg_isnull = \"campaign__isnull\"\n    include_empty_choice = True\n\n    def __init__(self, request, params, model, model_admin):\n        self.empty_value_display = model_admin.get_empty_value_display()\n        self.lookup_val_isnull = params.get(self.lookup_kwarg_isnull)\n        super().__init__(request, params, model, model_admin)\n\n    def has_output(self):\n        if self.include_empty_choice:\n            extra = 1\n        else:\n            extra = 0\n        return len(self.lookup_choices) + extra > 1\n\n    def expected_parameters(self):\n        return [self.parameter_name, self.lookup_kwarg_isnull]\n\n    def choices(self, changelist):\n        yield {\n            \"selected\": self.value() is None and not self.lookup_val_isnull,\n            \"query_string\": changelist.get_query_string(\n                remove=[self.parameter_name, self.lookup_kwarg_isnull]\n            ),\n            \"display\": \"All\",\n        }\n        for lookup, title in self.lookup_choices:\n            yield {\n                \"selected\": self.value() == str(lookup),\n                \"query_string\": changelist.get_query_string(\n                    {self.parameter_name: lookup}, self.lookup_kwarg_isnull\n                ),\n                \"display\": title,\n            }\n        if self.include_empty_choice:\n            yield {\n                \"selected\": bool(self.lookup_val_isnull),\n                \"query_string\": changelist.get_query_string(\n                    {self.lookup_kwarg_isnull: \"True\"}, [self.parameter_name]\n                ),\n                \"display\": self.empty_value_display,\n            }\n\n\nclass SiteReportSortedCampaignListFilter(SiteReportCampaignListBaseFilter):\n    title = \"Sorted Campaign\"\n    parameter_name = \"campaign__id__exact\"\n    status_filter_parameter = \"campaign__status\"\n\n\nclass SiteReportCampaignListFilter(SiteReportCampaignListBaseFilter):\n    parameter_name = \"campaign__id__exact\"\n    status_filter_parameter = \"campaign__status\"\n\n    def lookups(self, request, model_admin):\n        return Campaign.objects.values_list(\"id\", \"title\")\n\n\nclass HelpfulLinkCampaignListFilter(CampaignListFilter):\n    title = \"Campaign Sorted\"\n    parameter_name = \"campaign__id__exact\"\n    status_filter_parameter = \"campaign__status\"\n\n\nclass TagCampaignListFilter(CampaignListFilter):\n    parameter_name = \"userassettagcollection__asset__item__project__campaign__id__exact\"\n    status_filter_parameter = (\n        \"userassettagcollection__asset__item__project__campaign__status\"\n    )\n\n\nclass TranscriptionCampaignListFilter(CampaignListFilter):\n    parameter_name = \"asset__item__project__campaign__id__exact\"\n    status_filter_parameter = \"asset__item__project__campaign__status\"\n\n\nclass UserAssetTagCollectionCampaignListFilter(CampaignListFilter):\n    parameter_name = \"asset__item__project__campaign__id__exact\"\n    status_filter_parameter = \"asset__item__project__campaign__status\"\n\n\nclass NextAssetCampaignListFilter(CampaignListFilter):\n    parameter_name = \"campaign__id__exact\"\n\n    def lookups(self, request, model_admin):\n        campaigns = Campaign.objects.filter(\n            pk__in=model_admin.model.objects.values_list(\n                \"campaign_id\", flat=True\n            ).distinct()\n        )\n        return campaigns.values_list(\"id\", \"title\").order_by(\"title\")\n\n\nclass CampaignProjectListFilter(admin.SimpleListFilter):\n    \"\"\"\n    Base class for project filters grouped by campaign.\n\n    Provides a project dropdown whose choices can be narrowed by a related\n    campaign filter, then filters the changelist using `project_ref`.\n    \"\"\"\n\n    title = \"ProjectRedux\"\n    parameter_name = \"project\"\n    related_filter_parameter = \"\"\n    project_ref = \"\"\n    template = \"admin/long_name_filter.html\"\n\n    def lookups(self, request, model_admin):\n        list_of_questions = []\n        queryset = Project.objects.order_by(\"campaign_id\")\n        if self.related_filter_parameter in request.GET:\n            queryset = queryset.filter(\n                campaign_id=request.GET[self.related_filter_parameter]\n            )\n        for project in queryset:\n            list_of_questions.append((str(project.id), project.title))\n        return sorted(list_of_questions, key=lambda tp: tp[1])\n\n    def queryset(self, request, queryset):\n        if self.value():\n            return queryset.filter(**{self.project_ref: self.value()})\n        return queryset\n\n\nclass ItemProjectListFilter(CampaignProjectListFilter):\n    parameter_name = \"project__in\"\n    related_filter_parameter = \"project__campaign__id__exact\"\n    project_ref = \"project_id\"\n\n\nclass AssetProjectListFilter(CampaignProjectListFilter):\n    parameter_name = \"item__project__in\"\n    related_filter_parameter = \"item__project__campaign__id__exact\"\n    project_ref = \"item__project_id\"\n\n\nclass TranscriptionProjectListFilter(CampaignProjectListFilter):\n    parameter_name = \"asset__item__project__in\"\n    related_filter_parameter = \"asset__item__project__campaign__id__exact\"\n    project_ref = \"asset__item__project_id\"\n\n\nclass CampaignStatusListFilter(admin.SimpleListFilter):\n    \"\"\"\n    Base class for campaign status filters.\n\n    Filters changelist rows by campaign status using the configured\n    `parameter_name` and the `Campaign.Status` choices.\n    \"\"\"\n\n    title = \"Campaign status\"\n\n    def lookups(self, request, model_admin):\n        return Campaign.Status.choices\n\n    def queryset(self, request, queryset):\n        if self.value():\n            return queryset.filter(**{self.parameter_name: self.value()})\n        return queryset\n\n\nclass AssetCampaignStatusListFilter(CampaignStatusListFilter):\n    parameter_name = \"item__project__campaign__status\"\n\n\nclass ItemCampaignStatusListFilter(CampaignStatusListFilter):\n    parameter_name = \"project__campaign__status\"\n\n\nclass ProjectCampaignStatusListFilter(CampaignStatusListFilter):\n    parameter_name = \"campaign__status\"\n\n\nclass HelpfulLinkCampaignStatusListFilter(CampaignStatusListFilter):\n    parameter_name = \"campaign__status\"\n\n\nclass TranscriptionCampaignStatusListFilter(CampaignStatusListFilter):\n    parameter_name = \"asset__item__project__campaign__status\"\n\n\nclass TagCampaignStatusListFilter(CampaignStatusListFilter):\n    parameter_name = \"userassettagcollection__asset__item__project__campaign__status\"\n\n\nclass UserAssetTagCollectionCampaignStatusListFilter(CampaignStatusListFilter):\n    parameter_name = \"asset__item__project__campaign__status\"\n\n\nclass UserProfileActivityCampaignStatusListFilter(CampaignStatusListFilter):\n    parameter_name = \"campaign__status\"\n\n\nclass BooleanFilter(admin.SimpleListFilter):\n    \"\"\"\n    Base class for simple yes/no boolean filters.\n\n    Provides \"Yes\" and \"No\" choices and filters using the configured\n    `parameter_name`.\n    \"\"\"\n\n    def lookups(self, request, model_admin):\n        return [\n            (True, _(\"Yes\")),\n            (False, _(\"No\")),\n        ]\n\n    def queryset(self, request, queryset):\n        if self.value() is None:\n            return queryset\n        else:\n            return queryset.filter(**{self.parameter_name: self.value()})\n\n\nclass OcrGeneratedFilter(BooleanFilter):\n    title = \"OCR Generated\"\n    parameter_name = \"ocr_generated\"\n\n\nclass OcrOriginatedFilter(BooleanFilter):\n    title = \"OCR Originated\"\n    parameter_name = \"ocr_originated\"\n\n\nclass SupersededListFilter(admin.SimpleListFilter):\n    \"\"\"\n    Filter transcriptions by whether they have been superseded.\n\n    Uses an `Exists` subquery on the `Transcription.supersedes` relation to\n    efficiently determine superseded rows.\n    \"\"\"\n\n    title = \"superseded\"\n    parameter_name = \"superseded\"\n\n    def lookups(self, request, model_admin):\n        return ((\"yes\", \"Superseded\"), (\"no\", \"Not superseded\"))\n\n    def queryset(self, request, queryset):\n        # Uses Exists to make joining cheaper\n        superseded_exists = Transcription.objects.filter(supersedes=OuterRef(\"pk\"))\n        val = self.value()\n        if val == \"yes\":\n            return queryset.annotate(_is_superseded=Exists(superseded_exists)).filter(\n                _is_superseded=True\n            )\n        if val == \"no\":\n            return queryset.annotate(_is_superseded=Exists(superseded_exists)).filter(\n                _is_superseded=False\n            )\n        return queryset\n"
  },
  {
    "path": "concordia/admin/forms.py",
    "content": "import nh3\nfrom django import forms\nfrom django.core.cache import caches\nfrom tinymce.widgets import TinyMCE\n\nfrom ..models import (\n    Campaign,\n    Card,\n    Guide,\n    Item,\n    KeyMetricsReport,\n    Project,\n    ProjectTopic,\n    Topic,\n    TranscriptionStatus,\n)\n\nFRAGMENT_ALLOWED_TAGS = {\n    \"a\",\n    \"abbr\",\n    \"acronym\",\n    \"b\",\n    \"blockquote\",\n    \"br\",\n    \"code\",\n    \"em\",\n    \"i\",\n    \"kbd\",\n    \"li\",\n    \"ol\",\n    \"p\",\n    \"span\",\n    \"strong\",\n    \"ul\",\n}\n\nBLOCK_ALLOWED_TAGS = FRAGMENT_ALLOWED_TAGS | {\n    \"div\",\n    \"h1\",\n    \"h2\",\n    \"h3\",\n    \"h4\",\n    \"h5\",\n    \"h6\",\n    \"hr\",\n    \"section\",\n}\n\nALLOWED_ATTRIBUTES = {\n    \"a\": {\"class\", \"id\", \"href\", \"title\"},\n    \"abbr\": {\"title\"},\n    \"acronym\": {\"title\"},\n    \"div\": {\"class\", \"id\"},\n    \"span\": {\"class\", \"id\"},\n    \"p\": {\"class\", \"id\"},\n}\n\n\nclass AdminItemImportForm(forms.Form):\n    \"\"\"\n    Admin form for importing items into a project from a URL.\n\n    Provides a single `import_url` field pointing to an item, collection or\n    search page to import from.\n    \"\"\"\n\n    import_url = forms.URLField(\n        required=True, label=\"URL of the item/collection/search page to import\"\n    )\n\n\nclass AdminProjectBulkImportForm(forms.Form):\n    \"\"\"\n    Admin form for bulk importing campaigns, projects and items.\n\n    Accepts a spreadsheet file describing the content to import and an\n    optional `redownload` flag that controls whether existing items should\n    be fetched again.\n    \"\"\"\n\n    spreadsheet_file = forms.FileField(\n        required=True,\n        label=\"Spreadsheet containing the campaigns, projects, and items to import\",\n    )\n\n    redownload = forms.BooleanField(\n        required=False, label=\"Should existing items be redownloaded?\"\n    )\n\n\nclass AdminAssetsBulkChangeStatusForm(forms.Form):\n    \"\"\"\n    Admin form for changing status of assets across multiple items in bulk\n    via CSV upload.\n    \"\"\"\n\n    spreadsheet_file = forms.FileField(\n        required=True,\n        label=\"Spreadsheet containing the items to change\",\n    )\n\n\nclass SanitizedDescriptionAdminForm(forms.ModelForm):\n    \"\"\"\n    Base admin form that sanitizes HTML description fields.\n\n    Uses `nh3` to strip disallowed tags and attributes from `description`\n    and `short_description` fields while keeping a limited set of inline\n    and block-level markup.\n    \"\"\"\n\n    class Meta:\n        model = Campaign\n        fields = \"__all__\"\n\n    def clean_description(self) -> str:\n        \"\"\"\n        Clean the `description` field using the block-level sanitizer.\n\n        Returns:\n            str: Sanitized HTML content for `description`.\n        \"\"\"\n        return nh3.clean(\n            self.cleaned_data[\"description\"],\n            tags=BLOCK_ALLOWED_TAGS,\n            attributes=ALLOWED_ATTRIBUTES,\n        )\n\n    def clean_short_description(self) -> str:\n        \"\"\"\n        Clean the `short_description` field using the fragment sanitizer.\n\n        Returns:\n            str: Sanitized HTML content for `short_description`.\n        \"\"\"\n        return nh3.clean(\n            self.cleaned_data[\"short_description\"],\n            tags=FRAGMENT_ALLOWED_TAGS,\n            attributes=ALLOWED_ATTRIBUTES,\n        )\n\n\nclass TopicAdminForm(SanitizedDescriptionAdminForm):\n    \"\"\"\n    Admin form for topics with sanitized rich-text descriptions.\n    \"\"\"\n\n    class Meta(SanitizedDescriptionAdminForm.Meta):\n        model = Topic\n        widgets = {\n            \"description\": TinyMCE(),\n            \"short_description\": TinyMCE(),\n        }\n\n\nclass CampaignAdminForm(SanitizedDescriptionAdminForm):\n    \"\"\"\n    Admin form for campaigns with sanitized rich-text descriptions.\n    \"\"\"\n\n    class Meta(SanitizedDescriptionAdminForm.Meta):\n        model = Campaign\n        widgets = {\n            \"short_description\": TinyMCE(),\n            \"description\": TinyMCE(),\n        }\n        fields = \"__all__\"\n\n\nclass ProjectAdminForm(SanitizedDescriptionAdminForm):\n    \"\"\"\n    Admin form for projects with sanitized rich-text descriptions.\n    \"\"\"\n\n    class Meta(SanitizedDescriptionAdminForm.Meta):\n        model = Project\n        widgets = {\n            \"description\": TinyMCE(),\n        }\n\n\nclass ProjectTopicInlineForm(forms.ModelForm):\n    \"\"\"\n    Admin inline form that links `Project` and `Topic` with a URL filter.\n\n    Adds a `url_filter` choice field that maps to `TranscriptionStatus`\n    values and controls which asset statuses are shown in topic URLs.\n    \"\"\"\n\n    url_filter = forms.ChoiceField(\n        choices=[(\"\", \"-- All Statuses --\")] + list(TranscriptionStatus.CHOICES),\n        required=False,\n    )\n\n    class Meta:\n        model = ProjectTopic\n        fields = [\"topic\", \"url_filter\"]\n\n\nclass ItemAdminForm(forms.ModelForm):\n    \"\"\"\n    Admin form for items with a rich-text `description` field.\n    \"\"\"\n\n    class Meta:\n        model = Item\n        widgets = {\"description\": TinyMCE()}\n        fields = \"__all__\"\n\n\nclass CardAdminForm(forms.ModelForm):\n    \"\"\"\n    Admin form for tutorial cards with a rich-text `body_text` field.\n    \"\"\"\n\n    class Meta:\n        model = Card\n        widgets = {\n            \"body_text\": TinyMCE(),\n        }\n        fields = \"__all__\"\n\n\nclass GuideAdminForm(forms.ModelForm):\n    \"\"\"\n    Admin form for guides with a rich-text `body` field.\n    \"\"\"\n\n    class Meta:\n        model = Guide\n        widgets = {\n            \"body\": TinyMCE(),\n        }\n        fields = \"__all__\"\n\n\ndef get_cache_name_choices() -> list[tuple[str, str]]:\n    \"\"\"\n    Build choices for the cache-clearing admin form.\n\n    Skips the `default` cache, since it holds semi-persistent data that\n    should not be cleared through this form.\n\n    Returns:\n        list[tuple[str, str]]: `(cache_name, label)` pairs for non-default\n            cache aliases.\n    \"\"\"\n    # We don't want the default cache to be cleared,\n    # since it's meant to contain semi-persistent data\n    return [\n        (name, f\"{name} ({settings['BACKEND']})\")\n        for name, settings in caches.settings.items()\n        if name != \"default\"\n    ]\n\n\nclass ClearCacheForm(forms.Form):\n    \"\"\"\n    Admin form for clearing selected Django caches.\n\n    Presents a dropdown of non-default cache aliases built from\n    `get_cache_name_choices()`.\n    \"\"\"\n\n    cache_name = forms.ChoiceField(choices=get_cache_name_choices)\n\n\nclass AssetStatusActionForm(forms.Form):\n    \"\"\"\n    Admin form used to select an asset status action.\n\n    Renders a select box of available actions plus the hidden\n    `_selected_action` field that the changelist expects. You must pass\n    `available_actions` when creating the form.\n\n    This form only builds the choice list. The admin changelist view still\n    handles processing and execution of the selected action, just like\n    standard admin actions.\n    \"\"\"\n\n    action = forms.ChoiceField(\n        choices=(),\n        label=\"Change status\",\n        widget=forms.Select(attrs={\"class\": \"vSelectField\"}),\n    )\n\n    def __init__(\n        self,\n        *args,\n        available_actions: list[tuple[str, str]],\n        **kwargs,\n    ) -> None:\n        \"\"\"\n        Initialize the form with a list of available admin actions.\n\n        Args:\n            available_actions (list[tuple[str, str]]): Pairs of action name\n                and human-readable label for each action that should appear\n                in the dropdown.\n        \"\"\"\n        super().__init__(*args, **kwargs)\n\n        choices: list[tuple[str, str]] = [(\"\", \"---------\")]\n\n        for action_name, action_label in available_actions:\n            choices.append((action_name, action_label))\n\n        self.fields[\"action\"].choices = choices\n\n\nclass KeyMetricsReportAdminForm(forms.ModelForm):\n    \"\"\"\n    Admin form for `KeyMetricsReport` objects.\n\n    Keeps manual and calculated metric fields editable while period\n    metadata remains read-only through the `KeyMetricsReportAdmin`.\n    \"\"\"\n\n    class Meta:\n        model = KeyMetricsReport\n        fields = \"__all__\"\n        help_texts = {\n            # Manual fields\n            \"crowd_emails_and_libanswers_sent\": (\n                \"Optional. Leave blank if not known. \"\n                \"Blank values are not included in quarterly or fiscal-year \"\n                \"totals.\"\n            ),\n            \"crowd_visits\": (\n                \"Optional. Leave blank if not known. \"\n                \"Blank values are not included in quarterly or fiscal-year \"\n                \"totals.\"\n            ),\n            \"crowd_page_views\": (\n                \"Optional. Leave blank if not known. \"\n                \"Blank values are not included in quarterly or fiscal-year \"\n                \"totals.\"\n            ),\n            \"crowd_unique_visitors\": (\n                \"Optional. Leave blank if not known. \"\n                \"Blank values are not included in quarterly or fiscal-year \"\n                \"totals.\"\n            ),\n            \"avg_visit_seconds\": (\n                \"Optional average visit length in seconds. \"\n                \"If blank, no average is used for quarterly or fiscal-year \"\n                \"rollups.\"\n            ),\n            \"transcriptions_added_to_loc_gov\": (\n                \"Optional. Leave blank if not known. \"\n                \"Blank values are not included in quarterly or fiscal-year \"\n                \"totals.\"\n            ),\n            \"datasets_added_to_loc_gov\": (\n                \"Optional. Leave blank if not known. \"\n                \"Blank values are not included in quarterly or fiscal-year \"\n                \"totals.\"\n            ),\n            # Calculated fields (still editable)\n            \"assets_published\": (\n                \"Usually calculated from Site Reports. \"\n                \"If you edit this, it may be overwritten when reports are \"\n                \"rebuilt.\"\n            ),\n            \"assets_started\": (\n                \"Usually calculated from Site Reports. \"\n                \"If you edit this, it may be overwritten when reports are \"\n                \"rebuilt.\"\n            ),\n            \"assets_completed\": (\n                \"Usually calculated from Site Reports. \"\n                \"If you edit this, it may be overwritten when reports are \"\n                \"rebuilt.\"\n            ),\n            \"users_activated\": (\n                \"Usually calculated from Site Reports. \"\n                \"If you edit this, it may be overwritten when reports are \"\n                \"rebuilt.\"\n            ),\n            \"anonymous_transcriptions\": (\n                \"Usually calculated from Site Reports. \"\n                \"If you edit this, it may be overwritten when reports are \"\n                \"rebuilt.\"\n            ),\n            \"transcriptions_saved\": (\n                \"Usually calculated from Site Reports. \"\n                \"If you edit this, it may be overwritten when reports are \"\n                \"rebuilt.\"\n            ),\n            \"tag_uses\": (\n                \"Usually calculated from Site Reports. \"\n                \"If you edit this, it may be overwritten when reports are \"\n                \"rebuilt.\"\n            ),\n        }\n"
  },
  {
    "path": "concordia/admin/utils.py",
    "content": "from django.contrib.auth.models import User\nfrom django.db.models import Prefetch\nfrom django.utils.timezone import now\n\nfrom ..models import Asset, Transcription, TranscriptionStatus\nfrom ..utils import get_anonymous_user\n\n\ndef _change_status(\n    request_user: User,\n    asset: Asset,\n    status: str = TranscriptionStatus.SUBMITTED,\n    transcription_user: User = None,\n) -> int:\n    \"\"\"\n    Create transcriptions to move assets to a new workflow status.\n\n    For each asset in `assets` this helper creates a new `Transcription` that\n    supersedes the latest transcription when one exists. The new transcription\n    copies the latest text. Reviewer is only assigned for accepted/rejected.\n    It sets the appropriate timestamp depending on `status`. Signals are\n    preserved because this does not use `bulk_create`.\n\n    Args:\n        reviewer (User): user performing the action.\n        assets (QuerySet[Asset]): Assets whose status should be updated.\n        status (str): Workflow status to apply. Supported values are constants\n        user (User): User that should be credited with submitting the\n                    transcription. Defaults to None\n        from TranscriptionStatus: NOT_STARTED, IN_PROGRESS, SUBMITTED, COMPLETED.\n    Returns:\n        int: 1 if asset was updated, otherwise 0\n    \"\"\"\n    if hasattr(asset, \"prefetched_transcriptions\"):\n        latest_transcription = (\n            asset.prefetched_transcriptions[0]\n            if asset.prefetched_transcriptions\n            else None\n        )\n    else:\n        latest_transcription = asset.transcription_set.order_by(\"-pk\").first()\n\n    if status == TranscriptionStatus.NOT_STARTED:\n        return 0\n\n    kwargs = {\n        \"asset\": asset,\n        \"user\": transcription_user or get_anonymous_user(),\n    }\n    if latest_transcription is not None:\n        kwargs.update(\n            **{\n                \"supersedes\": latest_transcription,\n                \"text\": latest_transcription.text,\n            }\n        )\n\n    if status == TranscriptionStatus.SUBMITTED:\n        kwargs[\"submitted\"] = now()\n    elif status == TranscriptionStatus.COMPLETED:\n        kwargs[\"reviewed_by\"] = request_user\n        kwargs[\"accepted\"] = now()\n    elif status == TranscriptionStatus.IN_PROGRESS:\n        if (\n            latest_transcription\n            and latest_transcription.status == TranscriptionStatus.COMPLETED\n        ):\n            kwargs[\"rejected\"] = now()\n        kwargs[\"reviewed_by\"] = request_user\n\n    transcription = Transcription(**kwargs)\n    transcription.full_clean()\n    transcription.save()\n    return 1\n\n\ndef _bulk_change_status(\n    request_user: User,\n    rows: list,\n) -> int:\n    \"\"\"\n    Bulk update assets by delegating to _change_status\n    Args:\n        request_user: the staff user performing the bulk change.\n        asset_rows: iterable of dicts like:\n            {\"asset\": Asset, \"status\": TranscriptionStatus.SUBMITTED, \"user\": User}\n    \"\"\"\n    slugs = {row[\"slug\"] for row in rows if row.get(\"slug\")}\n    assets = Asset.objects.filter(slug__in=slugs).prefetch_related(\n        Prefetch(\n            \"transcription_set\",\n            queryset=Transcription.objects.order_by(\"-pk\"),\n            to_attr=\"prefetched_transcriptions\",\n        )\n    )\n    asset_map = {asset.slug: asset for asset in assets}\n\n    updated_total = 0\n    for row in rows:\n        asset = asset_map.get(row.get(\"slug\"))\n        if asset:\n            updated_total += _change_status(\n                request_user, asset, row[\"status\"], row.get(\"user\")\n            )\n\n    return updated_total\n"
  },
  {
    "path": "concordia/admin/views.py",
    "content": "import logging\nimport re\nimport tempfile\nimport time\nfrom http import HTTPStatus\nfrom typing import Any\n\nfrom django.apps import apps\nfrom django.contrib import messages\nfrom django.contrib.admin.views.decorators import staff_member_required\nfrom django.contrib.auth.decorators import permission_required, user_passes_test\nfrom django.contrib.auth.models import User\nfrom django.core.cache import caches\nfrom django.core.exceptions import ValidationError\nfrom django.db.models import OuterRef, Prefetch, Subquery\nfrom django.http import HttpRequest, HttpResponse, JsonResponse\nfrom django.shortcuts import render\nfrom django.urls import reverse_lazy\nfrom django.utils.decorators import method_decorator\nfrom django.utils.text import slugify\nfrom django.views import View\nfrom django.views.decorators.cache import never_cache\nfrom django.views.generic.edit import FormView\n\nfrom concordia.models import (\n    Asset,\n    Item,\n    Transcription,\n    TranscriptionStatus,\n    validated_get_or_create,\n)\nfrom exporter.tabular_export.core import export_to_csv_response, flatten_queryset\nfrom exporter.views import do_bagit_export\nfrom importer.models import ImportItem, ImportItemAsset, ImportJob\nfrom importer.tasks import fetch_all_urls\nfrom importer.tasks.items import import_items_into_project_from_url\nfrom importer.utils import slurp_excel\n\nfrom ..models import Campaign, Project, SiteReport\nfrom .forms import (\n    AdminAssetsBulkChangeStatusForm,\n    AdminProjectBulkImportForm,\n    ClearCacheForm,\n)\nfrom .utils import _bulk_change_status\n\nlogger = logging.getLogger(__name__)\n\n\n@never_cache\n@staff_member_required\n@permission_required(\"concordia.add_campaign\")\n@permission_required(\"concordia.change_campaign\")\n@permission_required(\"concordia.add_project\")\n@permission_required(\"concordia.change_project\")\n@permission_required(\"concordia.add_item\")\n@permission_required(\"concordia.change_item\")\ndef project_level_export(request: HttpRequest) -> HttpResponse:\n    \"\"\"\n    Render the project-level BagIt export admin view and run exports.\n\n    When called with `GET`, shows a form to select campaigns and projects.\n    When called with `POST`, builds a BagIt export for completed items in\n    the selected projects.\n\n    Request Parameters:\n        `id` (str, optional): Campaign primary key used to filter projects.\n        `slug` (str, optional): Campaign slug used when building the export\n            filename.\n\n    Args:\n        request (HttpRequest): Current admin request.\n\n    Returns:\n        HttpResponse: HTML response for the selection view or a streamed\n            BagIt export.\n    \"\"\"\n    request.current_app = \"admin\"\n    context = {\"title\": \"Project Level Bagit Exporter\"}\n    form = AdminProjectBulkImportForm()\n    context[\"campaigns\"] = all_campaigns = []\n    context[\"projects\"] = all_projects = []\n    idx = request.GET.get(\"id\")\n\n    if request.method == \"POST\":\n        project_list = request.POST.getlist(\"project_name\")\n        campaign_slug = request.GET.get(\"slug\")\n\n        proj_titles = \"_projects\"\n\n        item_qs = Item.objects.filter(\n            project__campaign__slug=campaign_slug, project__id__in=project_list\n        )\n        incomplete_item_assets = Asset.objects.filter(\n            item__in=item_qs,\n            transcription_status__in=(\n                TranscriptionStatus.NOT_STARTED,\n                TranscriptionStatus.IN_PROGRESS,\n                TranscriptionStatus.SUBMITTED,\n            ),\n        )\n        item_qs = item_qs.exclude(asset__in=incomplete_item_assets)\n        asset_qs = Asset.objects.filter(item__in=item_qs).order_by(\n            \"item__project\", \"item\", \"sequence\"\n        )\n        item_qs = asset_qs\n\n        latest_trans_subquery = (\n            Transcription.objects.filter(asset=OuterRef(\"pk\"))\n            .order_by(\"-pk\")\n            .values(\"text\")\n        )\n\n        assets = asset_qs.annotate(\n            latest_transcription=Subquery(latest_trans_subquery[:1])\n        )\n\n        campaign_slug_dbv = Campaign.objects.get(slug__exact=campaign_slug).slug\n\n        export_filename_base = \"%s%s\" % (\n            campaign_slug_dbv,\n            proj_titles,\n        )\n\n        with tempfile.TemporaryDirectory(\n            prefix=export_filename_base\n        ) as export_base_dir:\n            return do_bagit_export(\n                assets, export_base_dir, export_filename_base, request\n            )\n\n    if idx is not None:\n        context[\"campaigns\"] = []\n        form = AdminProjectBulkImportForm()\n        projects = Project.objects.filter(campaign_id=int(idx))\n        for project in projects:\n            proj_dict = {}\n            proj_dict[\"title\"] = project.title\n            proj_dict[\"id\"] = project.pk\n            proj_dict[\"campaign_id\"] = idx\n            all_projects.append(proj_dict)\n\n    else:\n        context[\"projects\"] = []\n        for campaigns in Campaign.objects.exclude(status=Campaign.Status.RETIRED):\n            all_campaigns.append(campaigns)\n        form = AdminProjectBulkImportForm()\n\n    context[\"form\"] = form\n    return render(request, \"admin/project_level_export.html\", context)\n\n\n@never_cache\n@staff_member_required\n@permission_required(\"concordia.add_campaign\")\n@permission_required(\"concordia.change_campaign\")\n@permission_required(\"concordia.add_project\")\n@permission_required(\"concordia.change_project\")\n@permission_required(\"concordia.add_item\")\n@permission_required(\"concordia.change_item\")\ndef celery_task_review(request: HttpRequest) -> HttpResponse:\n    \"\"\"\n    Inspect importer Celery tasks and summarize their status by project.\n\n    For a selected campaign, iterates through related projects, import\n    jobs and item assets to count successful, incomplete, unstarted and\n    failed tasks. Writes per-asset status messages to the admin message\n    framework and renders a summary table.\n\n    Request Parameters:\n        `id` (str, optional): Campaign primary key used to select which\n            projects to inspect.\n\n    Args:\n        request (HttpRequest): Current admin request.\n\n    Returns:\n        HttpResponse: HTML response showing task counts by project or a\n            campaign picker.\n    \"\"\"\n    request.current_app = \"admin\"\n    totalcount = 0\n    counter = 0\n    asset_successful = 0\n    asset_incomplete = 0\n    asset_unstarted = 0\n    asset_failure = 0\n    context = {\n        \"title\": \"Importer Tasks\",\n        \"campaigns\": [],\n        \"projects\": [],\n    }\n    idx = request.GET.get(\"id\")\n\n    if idx is not None:\n        for project in Project.objects.filter(campaign_id=int(idx)):\n            asset_successful = 0\n            asset_failure = 0\n            asset_incomplete = 0\n            asset_unstarted = 0\n            proj_dict = {\"title\": project.title, \"id\": project.pk, \"campaign_id\": idx}\n            messages.info(request, f\"{project.title}\")\n            for importjob in ImportJob.objects.filter(project_id=project.pk).order_by(\n                \"-created\"\n            ):\n                for asset in ImportItem.objects.filter(job_id=importjob.pk).order_by(\n                    \"-created\"\n                ):\n                    counter += 1\n                    countasset = 0\n                    for assettask in ImportItemAsset.objects.filter(\n                        import_item_id=asset.pk\n                    ):\n                        if (\n                            assettask.failed is not None\n                            and assettask.last_started is not None\n                        ):\n                            asset_failure += 1\n                            messages.warning(\n                                request,\n                                f\"{assettask.url}-{assettask.status}\",\n                            )\n                        elif (\n                            assettask.completed is None\n                            and assettask.last_started is not None\n                        ):\n                            asset_incomplete += 1\n                            messages.warning(\n                                request,\n                                f\"{assettask.url}-{assettask.status}\",\n                            )\n                        elif (\n                            assettask.completed is None\n                            and assettask.last_started is None\n                        ):\n                            asset_unstarted += 1\n                            messages.warning(\n                                request,\n                                f\"{assettask.url}-{assettask.status}\",\n                            )\n                        else:\n                            asset_successful += 1\n                            messages.info(\n                                request,\n                                f\"{assettask.url}-{assettask.status}\",\n                            )\n                        countasset += 1\n                        totalcount += 1\n            proj_dict[\"successful\"] = asset_successful\n            proj_dict[\"incomplete\"] = asset_incomplete\n            proj_dict[\"unstarted\"] = asset_unstarted\n            proj_dict[\"failure\"] = asset_failure\n            context[\"projects\"].append(proj_dict)\n        messages.info(request, f\"{totalcount} Total Assets Processed\")\n        context[\"totalassets\"] = totalcount\n    else:\n        context[\"campaigns\"] = Campaign.objects.exclude(\n            status=Campaign.Status.RETIRED\n        ).order_by(\"-launch_date\")\n\n    return render(request, \"admin/celery_task.html\", context)\n\n\n@never_cache\n@staff_member_required\n@permission_required(\"concordia.add_campaign\")\n@permission_required(\"concordia.change_campaign\")\n@permission_required(\"concordia.add_project\")\n@permission_required(\"concordia.change_project\")\n@permission_required(\"concordia.add_item\")\n@permission_required(\"concordia.change_item\")\ndef admin_bulk_import_review(request: HttpRequest) -> HttpResponse:\n    \"\"\"\n    Preview a bulk import spreadsheet without creating campaigns or items.\n\n    Parses the uploaded spreadsheet, validates required columns and slugs\n    and extracts all import URLs. Uses `fetch_all_urls` to preflight the\n    URLs then reports the results and total asset count in admin messages.\n\n    Request Parameters:\n        Uploaded file `spreadsheet_file` (multipart): Spreadsheet with one\n            row per campaign and project definition.\n\n    Args:\n        request (HttpRequest): Current admin request.\n\n    Returns:\n        HttpResponse: HTML response containing the review form and any\n            status messages.\n    \"\"\"\n    request.current_app = \"admin\"\n    url_regex = r\"[-\\w+]+\"\n    pattern = re.compile(url_regex)\n    context = {\"title\": \"Bulk Import Review\"}\n\n    urls = []\n    all_urls = []\n    url_counter = 0\n    sum_count = 0\n    if request.method == \"POST\":\n        form = AdminProjectBulkImportForm(request.POST, request.FILES)\n\n        if form.is_valid():\n            rows = slurp_excel(request.FILES[\"spreadsheet_file\"])\n            required_fields = [\n                \"Campaign\",\n                \"Campaign Short Description\",\n                \"Campaign Long Description\",\n                \"Campaign Slug\",\n                \"Project Slug\",\n                \"Project\",\n                \"Project Description\",\n                \"Import URLs\",\n            ]\n            try:\n                for idx, row in enumerate(rows):\n                    missing_fields = [i for i in required_fields if i not in row]\n                    if missing_fields:\n                        messages.warning(\n                            request,\n                            f\"Skipping row {idx}: missing fields {missing_fields}\",\n                        )\n                        continue\n\n                    campaign_title = row[\"Campaign\"]\n                    project_title = row[\"Project\"]\n                    import_url_blob = row[\"Import URLs\"]\n\n                    if not all((campaign_title, project_title, import_url_blob)):\n                        if not any(row.values()):\n                            # No messages for completely blank rows\n                            continue\n\n                        warning_message = (\n                            f\"Skipping row {idx}: at least one required field \"\n                            \"(Campaign, Project, Import URLs) is empty\"\n                        )\n                        messages.warning(request, warning_message)\n                        continue\n\n                    # Read Campaign slug value from excel\n                    campaign_slug = row[\"Campaign Slug\"]\n                    if campaign_slug and not pattern.fullmatch(campaign_slug):\n                        messages.warning(\n                            request, \"Campaign slug doesn't match pattern.\"\n                        )\n\n                    # Read Project slug value from excel\n                    project_slug = row[\"Project Slug\"]\n                    if project_slug and not pattern.fullmatch(project_slug):\n                        messages.warning(request, \"Project slug doesn't match pattern.\")\n\n                    potential_urls = filter(None, re.split(r\"[\\s]+\", import_url_blob))\n\n                    for url in potential_urls:\n                        if not url.startswith(\"http\"):\n                            messages.warning(\n                                request, f\"Skipping unrecognized URL value: {url}\"\n                            )\n                            continue\n\n                        urls.append(url)\n                        url_counter = url_counter + 1\n\n                        if url_counter == 50:\n                            all_urls.append(urls)\n                            url_counter = 0\n                            urls = []\n\n                all_urls.append(urls)\n                for _i, val in enumerate(all_urls):\n                    return_result = fetch_all_urls(val)\n                    for res in return_result[0]:\n                        messages.info(request, f\"{res}\")\n\n                    sum_count = sum_count + return_result[1]\n                    time.sleep(7)\n\n                messages.info(request, f\"Total Asset Count:{sum_count}\")\n            finally:\n                messages.info(request, \"All Processes Completed\")\n\n    else:\n        form = AdminProjectBulkImportForm()\n\n    context[\"form\"] = form\n\n    return render(request, \"admin/bulk_review.html\", context)\n\n\n@method_decorator(staff_member_required, name=\"dispatch\")\n@method_decorator(never_cache, name=\"dispatch\")\nclass AdminBulkChangeAssetStatusView(FormView):\n    template_name = \"admin/bulk_change.html\"\n    form_class = AdminAssetsBulkChangeStatusForm\n\n    def form_valid(self, form):\n        try:\n            rows = slurp_excel(self.request.FILES[\"spreadsheet_file\"])\n        except Exception as e:\n            messages.error(self.request, f\"Could not read spreadsheet: {e}\")\n\n            return self.render_to_response(self.get_context_data(form=form))\n        total_in_sheet = len(rows)\n\n        # Normalize and validate statuses from spreadsheet rows\n        def normalize_status(status):\n            if status is not None:\n                v = str(status).strip().lower()\n                # accept canonical keys from TranscriptionStatus\n                valid = {\n                    TranscriptionStatus.NOT_STARTED,\n                    TranscriptionStatus.IN_PROGRESS,\n                    TranscriptionStatus.SUBMITTED,\n                    TranscriptionStatus.COMPLETED,\n                }\n                if v in valid:\n                    return v\n            return None\n\n        normalized_rows = []\n        invalid_rows = 0\n        slugs_all = set()\n\n        user_ids = {row.get(\"user\") for row in rows if row.get(\"user\")}\n        users = {u.id: u for u in User.objects.filter(id__in=user_ids)}\n\n        for row in rows:\n            slug = row.get(\"asset__slug\")\n            status_raw = row.get(\"New Status\", TranscriptionStatus.SUBMITTED)\n            user_id = row.get(\"user\", None)\n            status = normalize_status(status_raw)\n            if slug and status_raw:\n                slugs_all.add(slug)\n                normalized_row = {\n                    \"slug\": slug,\n                    \"status\": status,\n                }\n                if user_id:\n                    normalized_row[\"user\"] = users.get(user_id)\n                normalized_rows.append(normalized_row)\n            else:\n                invalid_rows += 1\n\n        # Fetch matched assets once\n        assets_qs = Asset.objects.filter(slug__in=slugs_all).prefetch_related(\n            Prefetch(\n                \"transcription_set\",\n                queryset=Transcription.objects.order_by(\"-pk\"),\n                to_attr=\"prefetched_transcriptions\",\n            )\n        )\n        matched = assets_qs.count()\n\n        if matched == 0:\n            messages.warning(\n                self.request,\n                (\n                    f\"No matching assets found in database. \"\n                    f\"Spreadsheet contained {total_in_sheet} rows.\"\n                ),\n            )\n            return self.render_to_response(self.get_context_data(form=form))\n\n        updated_total = _bulk_change_status(self.request.user, normalized_rows)\n\n        unmatched = len(slugs_all) - matched\n\n        messages.success(\n            self.request,\n            (\n                f\"Processed spreadsheet with {total_in_sheet} rows. \"\n                f\"Updated {updated_total} assets. \"\n                f\"{invalid_rows} invalid rows. \"\n                f\"{unmatched} unmatched asset slugs. \"\n            ),\n        )\n        return self.render_to_response(self.get_context_data(form=form))\n\n\n@never_cache\n@staff_member_required\n@permission_required(\"concordia.add_campaign\")\n@permission_required(\"concordia.change_campaign\")\n@permission_required(\"concordia.add_project\")\n@permission_required(\"concordia.change_project\")\n@permission_required(\"concordia.add_item\")\n@permission_required(\"concordia.change_item\")\ndef admin_bulk_import_view(request: HttpRequest) -> HttpResponse:\n    \"\"\"\n    Queue bulk import jobs from a spreadsheet.\n\n    Reads an uploaded spreadsheet, creates or reuses `Campaign` and\n    `Project` records using `validated_get_or_create` then queues import\n    jobs via `import_items_into_project_from_url` for each URL.\n\n    Request Parameters:\n        Uploaded file `spreadsheet_file` (multipart): Spreadsheet defining\n            campaigns, projects and URLs.\n        Field `redownload` (bool, optional): If true, forces existing\n            items to be re-downloaded.\n\n    Args:\n        request (HttpRequest): Current admin request.\n\n    Returns:\n        HttpResponse: HTML response containing the bulk import form and\n            any queued job information.\n    \"\"\"\n    request.current_app = \"admin\"\n    url_regex = r\"[-\\w+]+\"\n    pattern = re.compile(url_regex)\n    context = {\"title\": \"Bulk Import\"}\n\n    if request.method == \"POST\":\n        form = AdminProjectBulkImportForm(request.POST, request.FILES)\n\n        if form.is_valid():\n            context[\"import_jobs\"] = import_jobs = []\n            redownload = form.cleaned_data.get(\"redownload\", False)\n\n            rows = slurp_excel(request.FILES[\"spreadsheet_file\"])\n            required_fields = [\n                \"Campaign\",\n                \"Campaign Short Description\",\n                \"Campaign Long Description\",\n                \"Campaign Slug\",\n                \"Project Slug\",\n                \"Project\",\n                \"Project Description\",\n                \"Import URLs\",\n            ]\n            for idx, row in enumerate(rows):\n                missing_fields = [i for i in required_fields if i not in row]\n                if missing_fields:\n                    messages.warning(\n                        request, f\"Skipping row {idx}: missing fields {missing_fields}\"\n                    )\n                    continue\n\n                campaign_title = row[\"Campaign\"]\n                project_title = row[\"Project\"]\n                import_url_blob = row[\"Import URLs\"]\n\n                if not all((campaign_title, project_title, import_url_blob)):\n                    if not any(row.values()):\n                        # No messages for completely blank rows\n                        continue\n\n                    warning_message = (\n                        f\"Skipping row {idx}: at least one required field \"\n                        \"(Campaign, Project, Import URLs) is empty\"\n                    )\n                    messages.warning(request, warning_message)\n                    continue\n\n                try:\n                    # Read Campaign slug value from excel\n                    campaign_slug = row[\"Campaign Slug\"]\n                    if campaign_slug and not pattern.fullmatch(campaign_slug):\n                        messages.warning(\n                            request, \"Campaign slug doesn't match pattern.\"\n                        )\n                    campaign, created = validated_get_or_create(\n                        Campaign,\n                        title=campaign_title,\n                        defaults={\n                            \"slug\": row[\"Campaign Slug\"]\n                            or slugify(campaign_title, allow_unicode=True),\n                            \"description\": row[\"Campaign Long Description\"] or \"\",\n                            \"short_description\": row[\"Campaign Short Description\"]\n                            or \"\",\n                        },\n                    )\n                except ValidationError as exc:\n                    messages.error(\n                        request, f\"Unable to create campaign {campaign_title}: {exc}\"\n                    )\n                    continue\n\n                if created:\n                    messages.info(request, f\"Created new campaign {campaign_title}\")\n                else:\n                    messages.info(\n                        request,\n                        f\"Reusing campaign {campaign_title} without modification\",\n                    )\n\n                try:\n                    # Read Project slug value from excel\n                    project_slug = row[\"Project Slug\"]\n                    if project_slug and not pattern.fullmatch(project_slug):\n                        messages.warning(request, \"Project slug doesn't match pattern.\")\n                    project, created = validated_get_or_create(\n                        Project,\n                        title=project_title,\n                        campaign=campaign,\n                        defaults={\n                            \"slug\": row[\"Project Slug\"]\n                            or slugify(project_title, allow_unicode=True),\n                            \"description\": row[\"Project Description\"] or \"\",\n                            \"campaign\": campaign,\n                        },\n                    )\n                except ValidationError as exc:\n                    messages.error(\n                        request, f\"Unable to create project {project_title}: {exc}\"\n                    )\n                    continue\n\n                if created:\n                    messages.info(request, f\"Created new project {project_title}\")\n                else:\n                    messages.info(\n                        request,\n                        f\"Reusing project {project_title} without modification\",\n                    )\n\n                potential_urls = filter(None, re.split(r\"[\\s]+\", import_url_blob))\n                for url in potential_urls:\n                    if not url.startswith(\"http\"):\n                        messages.warning(\n                            request, f\"Skipping unrecognized URL value: {url}\"\n                        )\n                        continue\n\n                    try:\n                        import_jobs.append(\n                            import_items_into_project_from_url(\n                                request.user, project, url, redownload\n                            )\n                        )\n\n                        messages.info(\n                            request,\n                            (\n                                f\"Queued {campaign_title} {project_title} \"\n                                f\"import for {url}\"\n                            ),\n                        )\n                    except Exception as exc:\n                        messages.error(\n                            request,\n                            f\"Unhandled error attempting to import {url}: {exc}\",\n                        )\n    else:\n        form = AdminProjectBulkImportForm()\n\n    context[\"form\"] = form\n\n    return render(request, \"admin/bulk_import.html\", context)\n\n\n@never_cache\n@staff_member_required\ndef admin_site_report_view(request: HttpRequest) -> HttpResponse:\n    \"\"\"\n    Export all `SiteReport` records as a CSV file.\n\n    Builds tabular data using `flatten_queryset` and returns a CSV\n    response suitable for download.\n\n    Args:\n        request (HttpRequest): Current admin request.\n\n    Returns:\n        HttpResponse: CSV download with one row per `SiteReport`.\n    \"\"\"\n    site_reports = SiteReport.objects.all()\n\n    headers, data = flatten_queryset(\n        site_reports,\n        field_names=SiteReport.DEFAULT_EXPORT_FIELDNAMES,\n        extra_verbose_names={\"created_on\": \"Date\", \"campaign__title\": \"Campaign\"},\n    )\n\n    return export_to_csv_response(\"site-report.csv\", headers, data)\n\n\n@never_cache\n@staff_member_required\ndef admin_retired_site_report_view(request: HttpRequest) -> HttpResponse:\n    \"\"\"\n    Export a CSV of the latest `SiteReport` per retired campaign.\n\n    Selects the most recent report per retired campaign then appends a\n    final summary row that totals numeric fields across all rows.\n\n    Args:\n        request (HttpRequest): Current admin request.\n\n    Returns:\n        HttpResponse: CSV download including per-campaign rows and a\n            `RETIRED TOTAL` row.\n    \"\"\"\n    site_reports = site_reports = (\n        SiteReport.objects.filter(campaign__status=Campaign.Status.RETIRED)\n        .order_by(\"campaign_id\", \"-created_on\")\n        .distinct(\"campaign_id\")\n    )\n\n    headers, data = flatten_queryset(\n        site_reports,\n        field_names=SiteReport.DEFAULT_EXPORT_FIELDNAMES,\n        extra_verbose_names={\"created_on\": \"Date\", \"campaign__title\": \"Campaign\"},\n    )\n    data = list(data)\n    row = [\"\", \"RETIRED TOTAL\", \"\", \"\"]\n    # You can't use aggregate with distinct(*fields), so the sum for each\n    # has to be done in Python\n    for field in SiteReport.DEFAULT_EXPORT_FIELDNAMES[4:]:\n        row.append(\n            sum(\n                [\n                    getattr(site_report, field) if getattr(site_report, field) else 0\n                    for site_report in site_reports\n                ]\n            )\n        )\n    data.append(row)\n\n    return export_to_csv_response(\"retired-site-report.csv\", headers, data)\n\n\nclass SerializedObjectView(View):\n    \"\"\"\n    Return a single field from a Concordia model instance as JSON.\n\n    The model, instance and field to fetch are provided through query\n    string parameters. This is intended for lightweight admin tools that\n    need to inspect or preview stored values.\n    \"\"\"\n\n    def get(\n        self,\n        request: HttpRequest,\n        *args: Any,\n        **kwargs: Any,\n    ) -> JsonResponse:\n        \"\"\"\n        Handle `GET` requests and serialize the requested field.\n\n        Request Parameters:\n            `model_name` (str): Name of the `concordia` app model to query.\n            `object_id` (str): Primary key of the model instance.\n            `field_name` (str): Name of the attribute or field to return.\n\n        Args:\n            request (HttpRequest): Current HTTP request.\n            *args (Any): Positional arguments passed by the URLconf.\n            **kwargs (Any): Keyword arguments passed by the URLconf.\n\n        Returns:\n            JsonResponse: JSON object containing the field value or a 404\n                status if the instance does not exist.\n        \"\"\"\n        model_name = request.GET.get(\"model_name\")\n        object_id = request.GET.get(\"object_id\")\n        field_name = request.GET.get(\"field_name\")\n\n        model = apps.get_model(app_label=\"concordia\", model_name=model_name)\n        try:\n            instance = model.objects.get(pk=object_id)\n            value = getattr(instance, field_name)\n            return JsonResponse({field_name: value})\n        except model.DoesNotExist:\n            return JsonResponse({\"status\": \"false\"}, status=HTTPStatus.NOT_FOUND)\n\n\n@method_decorator(never_cache, name=\"dispatch\")\n@method_decorator(user_passes_test(lambda u: u.is_superuser), name=\"dispatch\")\nclass ClearCacheView(FormView):\n    \"\"\"\n    Admin view for clearing non-default Django caches.\n\n    Uses `ClearCacheForm` to pick a cache alias then calls `clear()` on\n    the selected cache. Only superusers can access this view.\n    \"\"\"\n\n    form_class = ClearCacheForm\n    template_name = \"admin/clear_cache.html\"\n    success_url = reverse_lazy(\"admin:clear-cache\")\n\n    def form_valid(self, form: ClearCacheForm) -> HttpResponse:\n        \"\"\"\n        Clear the selected cache and redirect back to the form.\n\n        On success, adds a success message. On failure, logs an error\n        message then continues with the normal `FormView` redirect.\n\n        Args:\n            form (ClearCacheForm): Validated form containing the selected\n                cache alias.\n\n        Returns:\n            HttpResponse: Redirect to the configured `success_url` after\n                processing.\n        \"\"\"\n        try:\n            cache_name = form.cleaned_data[\"cache_name\"]\n            caches[cache_name].clear()\n            messages.success(self.request, f\"Successfully cleared '{cache_name}' cache\")\n        except Exception as err:\n            messages.error(\n                self.request,\n                (\n                    f\"Couldn't clear cache '{cache_name}', \"\n                    f\"something went wrong. Received error: {err}\"\n                ),\n            )\n        return super().form_valid(form)\n"
  },
  {
    "path": "concordia/admin_site.py",
    "content": "\"\"\"Admin site customizations for Concordia.\n\nProvides a subclass of Django's ``AdminSite`` that adds project-specific\nadmin URLs alongside the default admin views.\n\"\"\"\n\nfrom django.contrib import admin\nfrom django.urls import path\n\n\nclass ConcordiaAdminSite(admin.AdminSite):\n    \"\"\"Custom admin site with additional Concordia tools and views.\"\"\"\n\n    site_header = \"Concordia Admin\"\n    site_title = \"Concordia\"\n\n    def get_urls(self) -> list:\n        \"\"\"Return admin URL patterns including Concordia-specific routes.\n\n        This extends ``admin.AdminSite.get_urls`` by prepending a set of\n        project routes for bulk import, bulk review, Celery task review,\n        site reporting, project-level export, JSON object inspection\n        and the cache-clearing tool. The base admin URLs are returned\n        unchanged after the custom routes.\n\n        Returns:\n            list: URL patterns for the custom admin views followed by the\n            default admin URLs.\n        \"\"\"\n        from concordia.admin import views\n\n        urls = super().get_urls()\n\n        custom_urls = [\n            path(\"bulk-import/\", views.admin_bulk_import_view, name=\"bulk-import\"),\n            path(\"bulk-review/\", views.admin_bulk_import_review, name=\"bulk-review\"),\n            path(\"celery-review/\", views.celery_task_review, name=\"celery-review\"),\n            path(\"site-report/\", views.admin_site_report_view, name=\"site-report\"),\n            path(\n                \"retired-site-report/\",\n                views.admin_retired_site_report_view,\n                name=\"retired-site-report\",\n            ),\n            path(\n                \"project-level-export/\",\n                views.project_level_export,\n                name=\"project-level-export\",\n            ),\n            path(\n                \"serialized_object/\",\n                views.SerializedObjectView.as_view(),\n                name=\"serialized_object\",\n            ),\n            path(\"clear-cache/\", views.ClearCacheView.as_view(), name=\"clear-cache\"),\n            path(\n                \"bulk-change/\",\n                views.AdminBulkChangeAssetStatusView.as_view(),\n                name=\"bulk-change\",\n            ),\n        ]\n\n        return custom_urls + urls\n"
  },
  {
    "path": "concordia/api/__init__.py",
    "content": "\"\"\"\nExperimental API endpoints backing the React transcription page.\n\nStatus\n------\nThis module is in active development and not yet in its final form. Interfaces\nand behavior may change without deprecation.\n\nIntended Use\n------------\nThese endpoints exist to support the React app's transcription page during\nongoing development and trial rollout.\n\nDuplication and Future Plan\n---------------------------\nTo enable rapid iteration, this module currently duplicates substantial logic\nfrom existing Django views (e.g., validation, submission/review workflows,\nrollback/rollforward and serialization). When the React transcription page is\nready for production, one of the following will occur:\n\n1. Shared logic will be factored into reusable helpers imported by both these\n   endpoints and the legacy views; or\n2. The legacy views that these endpoints duplicate will be removed.\n\nUntil then, expect overlap and keep changes synchronized across both places.\n\nNote\n----\nThe React app still requires significant development work. Treat these endpoints\nas provisional and subject to change.\n\"\"\"\n\nimport re\nfrom time import time\nfrom typing import Optional\n\nfrom django.conf import settings\nfrom django.db.transaction import atomic\nfrom django.http import HttpRequest\nfrom django.shortcuts import get_object_or_404\nfrom django.urls import reverse\nfrom django.utils.timezone import now\nfrom ninja import NinjaAPI, Router\nfrom ninja.errors import HttpError\n\nfrom concordia.exceptions import RateLimitExceededError\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    Asset,\n    CardFamily,\n    ConcordiaUser,\n    Guide,\n    Transcription,\n    TranscriptionStatus,\n    TutorialCard,\n    UserAssetTagCollection,\n)\nfrom concordia.templatetags.concordia_media_tags import asset_media_url\nfrom concordia.utils import get_anonymous_user\nfrom concordia.utils.constants import URL_REGEX\nfrom configuration.utils import configuration_value\n\nfrom .schemas import CamelSchema\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\napi = NinjaAPI(version=None, urls_namespace=\"api\")\n\n\nclass AssetOut(CamelSchema):\n    \"\"\"\n    Serialized representation of an Asset for API responses.\n\n    Fields mirror what the web client needs to render the asset view,\n    including navigation context, image URLs, tagging, tutorial cards,\n    available languages and undo/redo availability.\n    \"\"\"\n\n    id: int  # noqa: A003\n    title: str\n    item_id: str\n    project_slug: str\n    campaign_slug: str\n    transcription: Optional[dict]\n    transcription_status: str\n    activity_mode: str\n    disable_ocr: bool\n    previous_asset_url: Optional[str]\n    next_asset_url: Optional[str]\n    asset_navigation: list[tuple[int, str]]\n    image_url: str\n    thumbnail_url: str\n    current_asset_url: str\n    tags: list[str]\n    registered_contributors: int\n    cards: list[str]\n    guides: Optional[list[dict[str, str]]]\n    languages: list[tuple[str, str]]\n    undo_available: bool\n    redo_available: bool\n\n\nclass ReviewIn(CamelSchema):\n    \"\"\"\n    Request Parameters:\n        action (str): Review action, either `\"accept\"` or `\"reject\"`.\n    \"\"\"\n\n    action: str  # \"accept\" or \"reject\"\n\n\nclass TranscriptionIn(CamelSchema):\n    \"\"\"\n    Request Parameters:\n        text (str): The transcription text to save.\n        supersedes (Optional[int]): The ID of the transcription being\n            superseded.\n    \"\"\"\n\n    text: str\n    supersedes: Optional[int]\n\n\nclass OcrTranscriptionIn(CamelSchema):\n    \"\"\"\n    Request Parameters:\n        language (str): The ISO 639-3 code of the OCR language to use.\n        supersedes (Optional[int]): The ID of the transcription being\n            superseded.\n    \"\"\"\n\n    language: str\n    supersedes: Optional[int]\n\n\nclass TranscriptionOut(CamelSchema):\n    \"\"\"\n    Serialized representation of a Transcription event/result returned\n    by API endpoints that create or mutate transcriptions.\n    \"\"\"\n\n    id: int  # noqa: A003\n    text: str\n    sent: float\n    submission_url: Optional[str] = None\n    asset: AssetOut\n    undo_available: bool\n    redo_available: bool\n\n\ndef serialize_asset(asset: Asset, request: HttpRequest) -> AssetOut:\n    \"\"\"\n    Build the `AssetOut` payload for a single asset.\n\n    Args:\n        asset (Asset): Published asset instance to serialize.\n        request (HttpRequest): Current request, used for absolute URLs.\n\n    Returns:\n        AssetOut: Serialized asset suitable for API responses.\n    \"\"\"\n    item = asset.item\n    project = item.project\n    campaign = project.campaign\n\n    transcription = asset.transcription_set.order_by(\"-pk\").first()\n    if transcription:\n        transcription_out = {\n            \"id\": transcription.pk,\n            \"status\": transcription.status,\n            \"text\": transcription.text,\n            \"contributors\": asset.get_contributor_count(),\n        }\n        if transcription.status in TranscriptionStatus.CHOICE_MAP.values():\n            transcription_status = [\n                k\n                for k, v in TranscriptionStatus.CHOICE_MAP.items()\n                if v == transcription.status\n            ][0]\n        else:\n            transcription_status = TranscriptionStatus.NOT_STARTED\n    else:\n        transcription_out = None\n        transcription_status = TranscriptionStatus.NOT_STARTED\n\n    if transcription_status in [\n        TranscriptionStatus.NOT_STARTED,\n        TranscriptionStatus.IN_PROGRESS,\n    ]:\n        activity_mode = \"transcribe\"\n        disable_ocr = asset.turn_off_ocr()\n    elif transcription_status == TranscriptionStatus.SUBMITTED:\n        activity_mode = \"review\"\n        disable_ocr = True\n    else:\n        activity_mode = \"transcribe\"\n        disable_ocr = True\n\n    current_asset_url = request.build_absolute_uri()\n    previous_asset = (\n        item.asset_set.published()\n        .filter(sequence__lt=asset.sequence)\n        .order_by(\"sequence\")\n        .last()\n    )\n    next_asset = (\n        item.asset_set.published()\n        .filter(sequence__gt=asset.sequence)\n        .order_by(\"sequence\")\n        .first()\n    )\n\n    # Build URLs\n    previous_asset_url = previous_asset.get_absolute_url() if previous_asset else None\n    next_asset_url = next_asset.get_absolute_url() if next_asset else None\n\n    # Navigation list\n    asset_navigation = list(\n        item.asset_set.published().order_by(\"sequence\").values_list(\"sequence\", \"slug\")\n    )\n\n    # Thumbnail URL\n    image_url = asset_media_url(asset)\n    if asset.download_url and \"iiif\" in asset.download_url:\n        thumbnail_url = asset.download_url.replace(\n            \"http://tile.loc.gov\", \"https://tile.loc.gov\"\n        ).replace(\"/pct:100/\", \"/!512,512/\")\n    else:\n        thumbnail_url = image_url\n\n    # Tags\n    tag_groups = UserAssetTagCollection.objects.filter(asset__slug=asset.slug)\n    tags = sorted({tag.value for tg in tag_groups for tag in tg.tags.all()})\n\n    # Cards\n    if project.campaign.card_family:\n        card_family = project.campaign.card_family\n    else:\n        card_family = CardFamily.objects.filter(default=True).first()\n    if card_family:\n        cards = list(\n            TutorialCard.objects.filter(tutorial=card_family)\n            .order_by(\"order\")\n            .values_list(\"card__title\", flat=True)\n        )\n    else:\n        cards = []\n\n    # Guides\n    guides_qs = Guide.objects.order_by(\"order\").values(\"title\", \"body\")\n    guides = list(guides_qs) if guides_qs.exists() else None\n\n    # Undo/redo availability\n    undo_available = asset.can_rollback()[0] if transcription else False\n    redo_available = asset.can_rollforward()[0] if transcription else False\n\n    return AssetOut(\n        id=asset.id,\n        title=asset.title,\n        item_id=item.item_id,\n        project_slug=project.slug,\n        campaign_slug=campaign.slug,\n        transcription=transcription_out,\n        transcription_status=transcription_status,\n        activity_mode=activity_mode,\n        disable_ocr=disable_ocr,\n        current_asset_url=current_asset_url,\n        previous_asset_url=previous_asset_url,\n        next_asset_url=next_asset_url,\n        asset_navigation=asset_navigation,\n        image_url=image_url,\n        thumbnail_url=thumbnail_url,\n        tags=tags,\n        registered_contributors=asset.get_contributor_count(),\n        cards=cards,\n        guides=guides,\n        languages=list(settings.LANGUAGE_CODES.items()),\n        undo_available=undo_available,\n        redo_available=redo_available,\n    )\n\n\nassets: Router = Router(tags=[\"assets\"])\n\n\n@assets.get(\n    \"/{campaign_slug}/{project_slug}/{item_id}/{asset_slug}/\",\n    response=AssetOut,\n    by_alias=True,\n)\ndef asset_detail_by_slugs(\n    request: HttpRequest,\n    campaign_slug: str,\n    project_slug: str,\n    item_id: str,\n    asset_slug: str,\n) -> AssetOut:\n    \"\"\"\n    Resolve and return a published asset using slugs and item_id.\n\n    Path Parameters:\n        campaign_slug (str): Campaign slug.\n        project_slug (str): Project slug.\n        item_id (str): Item identifier within the project.\n        asset_slug (str): Asset slug.\n\n    Returns:\n        AssetOut: Serialized asset record.\n    \"\"\"\n    asset = get_object_or_404(\n        Asset.objects.published()\n        .select_related(\"item__project__campaign\")\n        .filter(\n            item__project__campaign__slug=campaign_slug,\n            item__project__slug=project_slug,\n            item__item_id=item_id,\n            slug=asset_slug,\n        )\n    )\n    return serialize_asset(asset, request)\n\n\n@assets.get(\"/{asset_id}\", response=AssetOut, by_alias=True)\ndef asset_detail(request: HttpRequest, asset_id: int) -> AssetOut:\n    \"\"\"GET /assets/{asset_id}/ - basic asset record.\"\"\"\n    asset = get_object_or_404(\n        Asset.objects.published().select_related(\"item__project__campaign\"), pk=asset_id\n    )\n    return serialize_asset(asset, request)\n\n\n@assets.post(\"/{asset_id}/transcriptions\", response=TranscriptionOut, by_alias=True)\ndef create_transcription(\n    request: HttpRequest, asset_id: int, payload: TranscriptionIn\n) -> TranscriptionOut:\n    \"\"\"\n    Create a new draft transcription for the given asset.\n\n    Replaces any open draft transcription and validates content. Mirrors\n    the legacy `save_transcription` view.\n    \"\"\"\n    asset = get_object_or_404(\n        Asset.objects.published().select_related(\"item__project__campaign\"), pk=asset_id\n    )\n\n    user = request.user if not request.user.is_anonymous else get_anonymous_user()\n\n    structured_logger.info(\n        \"API transcription save start\",\n        event_code=\"transcription_save_start\",\n        user=user,\n        asset=asset,\n    )\n\n    # Validate transcription text (disallow URLs)\n    if re.search(URL_REGEX, payload.text):\n        structured_logger.warning(\n            \"API transcription rejected due to URL\",\n            event_code=\"transcription_save_rejected\",\n            reason=\"URL detected in transcription\",\n            reason_code=\"url_detected\",\n            user=user,\n            asset=asset,\n        )\n        raise HttpError(\n            400,\n            \"It looks like your text contains URLs. Please remove them and try again.\",\n        )\n\n    # Supersede logic\n    supersedes_pk = payload.supersedes\n    superseded = None\n\n    if not supersedes_pk:\n        if asset.transcription_set.filter(supersedes=None).exists():\n            structured_logger.warning(\n                \"API transcription save failed: open transcription exists\",\n                event_code=\"transcription_save_aborted\",\n                reason=\"Open transcription already exists\",\n                reason_code=\"already_exists\",\n                user=user,\n                asset=asset,\n            )\n            raise HttpError(409, \"An open transcription already exists\")\n    else:\n        if asset.transcription_set.filter(supersedes=supersedes_pk).exists():\n            structured_logger.warning(\n                \"API transcription save failed: already superseded\",\n                event_code=\"transcription_save_aborted\",\n                reason=\"Superseded transcription is invalid\",\n                reason_code=\"superseded_invalid\",\n                user=user,\n                asset=asset,\n                supersedes_pk=supersedes_pk,\n            )\n            raise HttpError(409, \"This transcription has been superseded\")\n\n        try:\n            superseded = asset.transcription_set.get(pk=supersedes_pk)\n        except Transcription.DoesNotExist as err:\n            structured_logger.warning(\n                \"API transcription save failed: supersedes not found\",\n                event_code=\"transcription_save_aborted\",\n                reason=\"Superseded transcription not found\",\n                reason_code=\"not_found\",\n                user=user,\n                asset=asset,\n                supersedes_pk=supersedes_pk,\n            )\n            raise HttpError(400, \"Invalid supersedes value\") from err\n\n    ocr_originated = bool(\n        superseded and (superseded.ocr_generated or superseded.ocr_originated)\n    )\n\n    transcription = Transcription(\n        asset=asset,\n        user=user,\n        supersedes=superseded,\n        text=payload.text,\n        ocr_originated=ocr_originated,\n    )\n    transcription.full_clean()\n    transcription.save()\n\n    structured_logger.info(\n        \"API transcription save success\",\n        event_code=\"transcription_save_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return TranscriptionOut(\n        id=transcription.pk,\n        sent=time(),\n        text=transcription.text,\n        submission_url=reverse(\"api:submit_transcription\", args=[transcription.pk]),\n        asset=serialize_asset(asset, request),\n        undo_available=asset.can_rollback()[0],\n        redo_available=asset.can_rollforward()[0],\n    )\n\n\n@assets.post(\"/{asset_id}/transcriptions/ocr\", response=TranscriptionOut, by_alias=True)\n@atomic\ndef create_ocr_transcription(\n    request: HttpRequest, asset_id: int, payload: OcrTranscriptionIn\n) -> TranscriptionOut:\n    \"\"\"\n    Create and save a new OCR-generated transcription for an asset.\n    \"\"\"\n    asset = get_object_or_404(\n        Asset.objects.published().select_related(\"item__project__campaign\"),\n        pk=asset_id,\n    )\n    user = request.user if not request.user.is_anonymous else get_anonymous_user()\n\n    supersedes_pk = payload.supersedes\n    language = payload.language\n\n    structured_logger.info(\n        \"API OCR transcription generation start\",\n        event_code=\"ocr_generation_start\",\n        user=user,\n        asset=asset,\n        supersedes_pk=supersedes_pk,\n        language=language,\n    )\n\n    # Determine superseded transcription\n    superseded = None\n    if supersedes_pk:\n        superseded_qs = asset.transcription_set.filter(pk=supersedes_pk)\n        if asset.transcription_set.filter(supersedes=supersedes_pk).exists():\n            structured_logger.warning(\n                \"API OCR generation aborted: superseded transcription is invalid.\",\n                event_code=\"ocr_generation_aborted\",\n                reason=\"Superseded transcription is already superseded\",\n                reason_code=\"superseded_invalid\",\n                user=user,\n                asset=asset,\n                supersedes_pk=supersedes_pk,\n            )\n            raise HttpError(409, \"This transcription has already been superseded\")\n        try:\n            superseded = superseded_qs.get()\n        except Transcription.DoesNotExist as err:\n            structured_logger.warning(\n                \"API OCR generation aborted: superseded transcription not found.\",\n                event_code=\"ocr_generation_aborted\",\n                reason=\"Superseded transcription not found\",\n                reason_code=\"not_found\",\n                user=user,\n                asset=asset,\n                supersedes_pk=supersedes_pk,\n            )\n            raise HttpError(400, \"Invalid supersedes value\") from err\n    else:\n        # No transcription exists, so we create a blank one\n        structured_logger.info(\n            \"No existing transcription; creating blank for OCR supersession\",\n            event_code=\"ocr_blank_supersede\",\n            user=user,\n            asset=asset,\n        )\n        superseded = Transcription(\n            asset=asset,\n            user=get_anonymous_user(),\n            text=\"\",\n        )\n        superseded.full_clean()\n        superseded.save()\n\n        structured_logger.info(\n            \"Blank transcription created for OCR supersession\",\n            event_code=\"ocr_blank_transcription_created\",\n            user=user,\n            transcription=superseded,\n        )\n\n    transcription_text = asset.get_ocr_transcript(language)\n\n    transcription = Transcription(\n        asset=asset,\n        user=user,\n        supersedes=superseded,\n        text=transcription_text,\n        ocr_generated=True,\n        ocr_originated=True,\n    )\n    transcription.full_clean()\n    transcription.save()\n\n    structured_logger.info(\n        \"API OCR transcription successfully created\",\n        event_code=\"ocr_generation_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return TranscriptionOut(\n        id=transcription.pk,\n        sent=time(),\n        text=transcription.text,\n        submission_url=reverse(\"api:submit_transcription\", args=[transcription.pk]),\n        asset=serialize_asset(asset, request),\n        undo_available=asset.can_rollback()[0],\n        redo_available=asset.can_rollforward()[0],\n    )\n\n\n@assets.post(\n    \"/{asset_id}/transcriptions/rollback\",\n    response=TranscriptionOut,\n    by_alias=True,\n)\n@atomic\ndef rollback(request: HttpRequest, asset_id: int) -> TranscriptionOut:\n    \"\"\"\n    Restores the asset's transcription to the previous version in its history.\n\n    Raises:\n        HttpError: If no previous transcription exists to roll back to.\n    \"\"\"\n    asset = get_object_or_404(Asset, pk=asset_id)\n    user = request.user if not request.user.is_anonymous else get_anonymous_user()\n\n    try:\n        transcription = asset.rollback_transcription(user)\n    except ValueError as e:\n        structured_logger.warning(\n            \"Rollback failed: no previous transcription to revert to.\",\n            event_code=\"rollback_failed\",\n            reason_code=\"no_valid_target\",\n            reason=str(e),\n            asset=asset,\n            user=user,\n        )\n        raise HttpError(400, \"No previous transcription available\") from e\n\n    structured_logger.info(\n        \"Rollback successfully performed.\",\n        event_code=\"rollback_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return TranscriptionOut(\n        id=transcription.pk,\n        sent=time(),\n        text=transcription.text,\n        submission_url=reverse(\"api:submit_transcription\", args=[transcription.pk]),\n        asset=serialize_asset(asset, request),\n        undo_available=asset.can_rollback()[0],\n        redo_available=asset.can_rollforward()[0],\n    )\n\n\n@assets.post(\n    \"/{asset_id}/transcriptions/rollforward\",\n    response=TranscriptionOut,\n    by_alias=True,\n)\n@atomic\ndef rollforward(request: HttpRequest, asset_id: int) -> TranscriptionOut:\n    \"\"\"\n    Restores the asset's transcription to the next version in its history.\n\n    Raises:\n        HttpError: If no future transcription exists to restore.\n    \"\"\"\n    asset = get_object_or_404(Asset, pk=asset_id)\n    user = request.user if not request.user.is_anonymous else get_anonymous_user()\n\n    try:\n        transcription = asset.rollforward_transcription(user)\n    except ValueError as e:\n        structured_logger.warning(\n            \"Rollforward failed: no transcription available to restore.\",\n            event_code=\"rollforward_failed\",\n            reason_code=\"no_valid_target\",\n            reason=str(e),\n            asset=asset,\n            user=user,\n        )\n        raise HttpError(400, \"No transcription to restore\") from e\n\n    structured_logger.info(\n        \"Rollforward successfully performed.\",\n        event_code=\"rollforward_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return TranscriptionOut(\n        id=transcription.pk,\n        sent=time(),\n        text=transcription.text,\n        submission_url=reverse(\"api:submit_transcription\", args=[transcription.pk]),\n        asset=serialize_asset(asset, request),\n        undo_available=asset.can_rollback()[0],\n        redo_available=asset.can_rollforward()[0],\n    )\n\n\ntranscriptions: Router = Router(tags=[\"transcriptions\"])\n\n\n@transcriptions.post(\"/{pk}/submit\", response=TranscriptionOut, by_alias=True)\ndef submit_transcription(request: HttpRequest, pk: int) -> TranscriptionOut:\n    \"\"\"\n    Submit a transcription for review (API version of legacy view).\n    \"\"\"\n    transcription = get_object_or_404(Transcription, pk=pk)\n    asset = transcription.asset\n\n    user = request.user if not request.user.is_anonymous else get_anonymous_user()\n\n    structured_logger.info(\n        \"API transcription submit start\",\n        event_code=\"transcription_submit_start\",\n        user=user,\n        transcription=transcription,\n    )\n\n    # Cannot submit already-submitted or superseded transcription\n    is_superseded = asset.transcription_set.filter(supersedes=pk).exists()\n    is_already_submitted = transcription.submitted and not transcription.rejected\n\n    if is_superseded or is_already_submitted:\n        structured_logger.warning(\n            \"API transcription submit failed: already submitted or superseded\",\n            event_code=\"transcription_submit_rejected\",\n            reason=\"Transcrition already submitted or superseded\",\n            reason_code=\"already_updated\",\n            user=user,\n            transcription=transcription,\n            is_superseded=is_superseded,\n            is_already_submitted=is_already_submitted,\n        )\n        raise HttpError(\n            400,\n            \"This transcription has already been updated. \"\n            \"Reload the current status before continuing.\",\n        )\n\n    # Perform the submission\n    transcription.submitted = now()\n    transcription.rejected = None\n    transcription.full_clean()\n    transcription.save()\n\n    structured_logger.info(\n        \"API transcription submitted\",\n        event_code=\"transcription_submit_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return TranscriptionOut(\n        id=transcription.pk,\n        text=transcription.text,\n        sent=time(),\n        asset=serialize_asset(asset, request),\n        undo_available=False,\n        redo_available=False,\n    )\n\n\n@transcriptions.patch(\n    \"/{pk}/review\",\n    response=TranscriptionOut,\n    by_alias=True,\n)\ndef review_transcription(\n    request: HttpRequest, pk: int, payload: ReviewIn\n) -> TranscriptionOut:\n    \"\"\"\n    Accept or reject a submitted transcription.\n\n    Request Parameters:\n        action (str): `\"accept\"` to accept or `\"reject\"` to reject.\n\n    Raises:\n        HttpError: If the action is invalid, the transcription was already\n            reviewed, the user attempts a self-accept, or the review rate\n            limit is exceeded.\n    \"\"\"\n    transcription = get_object_or_404(Transcription, pk=pk)\n    asset = transcription.asset\n    user = request.user if not request.user.is_anonymous else get_anonymous_user()\n\n    # Temporary workaround to allow self-accepts for testing\n    if payload.action == \"accept\" and transcription.user.pk == user.pk:\n        user = ConcordiaUser.objects.latest(\"date_joined\")\n    # End workaround\n\n    structured_logger.info(\n        \"API transcription review start\",\n        event_code=\"transcription_review_start\",\n        user=user,\n        transcription_id=pk,\n        action=payload.action,\n    )\n\n    if payload.action not in (\"accept\", \"reject\"):\n        structured_logger.warning(\n            \"API review rejected: invalid action\",\n            event_code=\"transcription_review_rejected\",\n            reason=\"Invalid review action\",\n            reason_code=\"invalid_action\",\n            user=user,\n            transcription_id=pk,\n        )\n        raise HttpError(400, \"Invalid action\")\n\n    if transcription.accepted or transcription.rejected:\n        structured_logger.warning(\n            \"API review rejected: already reviewed\",\n            event_code=\"transcription_review_rejected\",\n            reason=\"Transcription has already been reviewed\",\n            reason_code=\"already_reviewed\",\n            user=user,\n            transcription=transcription,\n        )\n        raise HttpError(400, \"This transcription has already been reviewed\")\n\n    if payload.action == \"accept\" and transcription.user.pk == user.pk:\n        structured_logger.warning(\n            \"API review rejected: self-accept\",\n            event_code=\"transcription_review_rejected\",\n            reason=\"User attempted to accept their own transcription\",\n            reason_code=\"self_accept\",\n            user=request.user,\n            transcription=transcription,\n        )\n        raise HttpError(400, \"You cannot accept your own transcription\")\n\n    transcription.reviewed_by = user\n\n    if payload.action == \"accept\":\n        concordia_user = ConcordiaUser.objects.get(pk=user.pk)\n        try:\n            concordia_user.check_and_track_accept_limit(transcription)\n        except RateLimitExceededError as err:\n            structured_logger.warning(\n                \"API review rejected: rate limit exceeded\",\n                event_code=\"transcription_review_rejected\",\n                reason=\"User exceeded review rate limit\",\n                reason_code=\"rate_limit_exceeded\",\n                user=user,\n                transcription=transcription,\n            )\n            raise HttpError(\n                429, configuration_value(\"review_rate_limit_banner_message\")\n            ) from err\n        transcription.accepted = now()\n    else:\n        transcription.rejected = now()\n\n    transcription.full_clean()\n    transcription.save()\n\n    structured_logger.info(\n        \"API transcription review success\",\n        event_code=\"transcription_review_success\",\n        user=user,\n        transcription=transcription,\n        action=payload.action,\n    )\n\n    return TranscriptionOut(\n        id=transcription.pk,\n        text=transcription.text,\n        sent=time(),\n        asset=serialize_asset(asset, request),\n        undo_available=False,\n        redo_available=False,\n    )\n\n\napi.add_router(\"/assets\", assets)\napi.add_router(\"/transcriptions\", transcriptions)\n"
  },
  {
    "path": "concordia/api/schemas.py",
    "content": "from ninja import Schema\n\n\ndef to_camel(string: str) -> str:\n    \"\"\"\n    Convert a snake_case string to camelCase.\n\n    Args:\n        string (str): Input string using snake_case.\n\n    Returns:\n        str: camelCase version of the input. The first segment remains lowercase,\n        and subsequent segments are capitalized and concatenated.\n    \"\"\"\n    parts = string.split(\"_\")\n    return parts[0] + \"\".join(word.capitalize() for word in parts[1:])\n\n\nclass CamelSchema(Schema):\n    \"\"\"\n    Base schema for Django Ninja that renders JSON with camelCase field names\n    while keeping snake_case attribute names in Python code.\n    \"\"\"\n\n    class Config(Schema.Config):\n        \"\"\"\n        Pydantic-style configuration (ninja.Schema is a thin wrapper around Pydantic)\n        that enables automatic camelCase aliases and allows population using original\n        snake_case field names.\n        \"\"\"\n\n        alias_generator = to_camel\n        populate_by_name = True\n"
  },
  {
    "path": "concordia/api_views.py",
    "content": "\"\"\"\nVery simple generic API views\n\nThese provide base classes for Django CBVs which behave differently when the URL\nends with \".json\".\n\nYou register the view twice in urls.py and it will default to the stock Django\nbehaviour for the non-JSON endpoint:\n\n    path(\"transcribe/\", views.TranscribeListView.as_view()),\n    path(\"transcribe.json\", views.TranscribeListView.as_view()),\n\nThe base APIViewMixin implements a base implementation of serialize_object which\nuses the generic django.forms.models.model_to_dict and can be overridden as needed.\n\"\"\"\n\nfrom time import time\n\nfrom django.core.serializers.json import DjangoJSONEncoder\nfrom django.forms.models import model_to_dict\nfrom django.http import JsonResponse\nfrom django.views.generic import DetailView, ListView\nfrom django.views.generic.base import TemplateResponseMixin\n\n\nclass URLAwareEncoder(DjangoJSONEncoder):\n    \"\"\"\n    JSON encoder subclass which handles things like ImageFieldFile which define\n    a url property\n    \"\"\"\n\n    def default(self, obj):\n        if not obj:\n            # Beyond the obvious, this handles the case where FileFields and\n            # their subclasses (e.g. ImageField) define a url property which\n            # will raise ValueError if accessed when the name property is empty.\n            return None\n        elif hasattr(obj, \"url\"):\n            return obj.url\n        elif hasattr(obj, \"get_absolute_url\"):\n            return obj.get_absolute_url()\n        else:\n            return super().default(obj)\n\n\nclass APIViewMixin(TemplateResponseMixin):\n    \"\"\"\n    TemplateResponseMixin subclass which will optionally render a JSON view of\n    the context data when the URL path ends in .json or the querystring has\n    \"format=json\"\n    \"\"\"\n\n    def render_to_response(self, context, **response_kwargs):\n        # This could also parse Accept headers if we wanted to take on the\n        # support overhead of content-negotiation:\n        req = self.request\n        if req.path.endswith(\".json\") or req.GET.get(\"format\") == \"json\":\n            return self.render_to_json_response(context)\n        else:\n            return super().render_to_response(context, **response_kwargs)\n\n    def render_to_json_response(self, context):\n        data = self.serialize_context(context)\n        self.make_absolute_urls(data)\n        return JsonResponse(data, encoder=URLAwareEncoder)\n\n    def serialize_context(self, context):\n        # Subclasses will want to selectively filter this but we\n        # will simply return the context verbatim:\n        return context\n\n    def serialize_object(self, obj):\n        data = model_to_dict(obj)\n        if hasattr(obj, \"get_absolute_url\"):\n            data[\"url\"] = obj.get_absolute_url()\n        return data\n\n    def make_absolute_urls(self, data):\n        if isinstance(data, dict):\n            for k, v in data.items():\n                if k.endswith(\"url\") and isinstance(v, str) and v.startswith(\"/\"):\n                    data[k] = self.request.build_absolute_uri(v)\n                elif isinstance(v, (dict, list)):\n                    self.make_absolute_urls(v)\n        elif isinstance(data, list):\n            for i in data:\n                self.make_absolute_urls(i)\n\n\nclass APIDetailView(APIViewMixin, DetailView):\n    \"\"\"DetailView which can also return JSON\"\"\"\n\n    def serialize_context(self, context):\n        return {\"object\": self.serialize_object(context[\"object\"])}\n\n\nclass APIListView(APIViewMixin, ListView):\n    \"\"\"ListView which can also return JSON with consistent pagination\"\"\"\n\n    def render_to_response(self, context, **response_kwargs):\n        page_obj = context[\"page_obj\"]\n\n        if page_obj:\n            per_page = context[\"paginator\"].per_page\n\n            context[\"pagination\"] = pagination = {\n                \"first\": self.build_url_for_page(1, per_page),\n                \"last\": self.build_url_for_page(page_obj.paginator.num_pages, per_page),\n            }\n            if page_obj.has_next():\n                pagination[\"next\"] = self.build_url_for_page(\n                    page_obj.next_page_number(), per_page\n                )\n\n        response = super().render_to_response(context, **response_kwargs)\n\n        if \"pagination\" in context:\n            response[\"Link\"] = \", \".join(\n                f'<{url}>; rel=\"{rel}\"' for rel, url in pagination.items()\n            )\n\n        return response\n\n    def build_url_for_page(self, page_number, per_page):\n        qs = self.request.GET.copy()\n        qs[\"page\"] = page_number\n        qs[\"per_page\"] = per_page\n        return self.request.build_absolute_uri(\n            \"%s?%s\" % (self.request.path, qs.urlencode())\n        )\n\n    def get_paginate_by(self, queryset):\n        per_page = self.request.GET.get(\"per_page\")\n\n        if per_page and per_page.isdigit():\n            return int(per_page)\n        else:\n            return self.paginate_by\n\n    def serialize_context(self, context):\n        data = {\n            \"objects\": [self.serialize_object(i) for i in context[\"object_list\"]],\n            \"sent\": time(),\n        }\n\n        if \"pagination\" in context:\n            data[\"pagination\"] = context[\"pagination\"]\n\n        return data\n"
  },
  {
    "path": "concordia/apps.py",
    "content": "from django.apps.config import AppConfig\nfrom django.contrib.admin.apps import AdminConfig\nfrom django.contrib.staticfiles.apps import StaticFilesConfig\n\n\nclass ConcordiaAppConfig(AppConfig):\n    name = \"concordia\"\n\n    def ready(self):\n        from .signals import handlers  # NOQA\n\n\nclass ConcordiaAdminConfig(AdminConfig):\n    default_site = \"concordia.admin_site.ConcordiaAdminSite\"\n\n    def ready(self):\n        self.module.autodiscover()\n\n\nclass ConcordiaStaticFilesConfig(StaticFilesConfig):\n    ignore_patterns = [\"scss\", \"js/src/*\"]\n"
  },
  {
    "path": "concordia/asgi.py",
    "content": "\"\"\"\nASGI entrypoint — see https://channels.readthedocs.io/en/latest/asgi.html\n\"\"\"\n\nimport django\nfrom channels.routing import get_default_application\n\ndjango.setup()\n\napplication = get_default_application()\n"
  },
  {
    "path": "concordia/authentication_backends.py",
    "content": "from typing import Any\n\nfrom django.contrib.auth import get_user_model\nfrom django.contrib.auth.backends import ModelBackend\nfrom django.contrib.auth.models import AbstractBaseUser\nfrom django.db.models import Q\nfrom django.http import HttpRequest\n\n\nclass EmailOrUsernameModelBackend(ModelBackend):\n    \"\"\"\n    Authentication backend that accepts either username or email.\n\n    Behavior:\n      * Looks up users by ``USERNAME_FIELD`` or case-insensitive ``email``.\n      * If multiple accounts match (e.g., same email in different fields),\n        iterates through matches and returns the first with a valid password.\n      * When no user matches, runs the hasher once to reduce timing\n        differences between existing and non-existing users.\n\n    Usage:\n        In ``settings.py``:\n\n            AUTHENTICATION_BACKENDS = [\n                \"concordia.authentication_backends.EmailOrUsernameModelBackend\",\n                \"django.contrib.auth.backends.ModelBackend\",\n            ]\n\n    Security notes:\n      * The fallback hash on a miss helps mitigate user enumeration via\n        timing side channels.\n    \"\"\"\n\n    def authenticate(\n        self,\n        request: HttpRequest | None,\n        username: str | None = None,\n        password: str | None = None,\n        **kwargs: Any,\n    ) -> AbstractBaseUser | None:\n        \"\"\"\n        Authenticate with either a username or an email address.\n\n        Args:\n            request:\n                The current HTTP request or ``None`` (older Django may pass\n                ``None``).\n            username:\n                The credential provided by the client. May be a username or an\n                email address. If ``None``, the method will read the\n                ``USERNAME_FIELD`` from ``kwargs``.\n            password:\n                The plaintext password to validate.\n\n        Returns:\n            The authenticated user instance, or ``None`` if authentication\n            fails.\n        \"\"\"\n        # n.b. Django <2.1 does not pass the `request`\n        user_model = get_user_model()\n\n        if username is None:\n            username = kwargs.get(user_model.USERNAME_FIELD)\n\n        # The `username` field is allowed to contain `@` characters so\n        # technically a given email address could be present in either field,\n        # possibly even for different users, so we'll query for all matching\n        # records and test each one.\n        users = user_model._default_manager.filter(\n            Q(**{user_model.USERNAME_FIELD: username}) | Q(email__iexact=username)\n        )\n\n        # Test whether any matched user has the provided password:\n        for user in users:\n            if user.check_password(password):\n                return user\n        if not users:\n            # Run the default password hasher once to reduce the timing\n            # difference between an existing and a non-existing user (see\n            # https://code.djangoproject.com/ticket/20760)\n            user_model().set_password(password)\n        return None\n"
  },
  {
    "path": "concordia/celery.py",
    "content": "import importlib\nimport os\nimport pkgutil\n\nimport sentry_sdk\nfrom celery import Celery\nfrom sentry_sdk.integrations.celery import CeleryIntegration\n\nfrom concordia.version import get_concordia_version\n\nSENTRY_BACKEND_DSN = os.environ.get(\"SENTRY_BACKEND_DSN\", None)\n\nif SENTRY_BACKEND_DSN:\n    CONCORDIA_ENVIRONMENT = os.environ.get(\"CONCORDIA_ENVIRONMENT\", None)\n    sentry_sdk.init(\n        SENTRY_BACKEND_DSN,\n        environment=CONCORDIA_ENVIRONMENT,\n        release=get_concordia_version(),\n        integrations=[CeleryIntegration()],\n    )\n\napp = Celery(\"concordia\")\n\n# Using a string here means the worker doesn't have to serialize\n# the configuration object to child processes.\n# - namespace='CELERY' means all celery-related configuration keys\n#   should have a `CELERY_` prefix.\napp.config_from_object(\"django.conf:settings\", namespace=\"CELERY\")\n\n# Load task modules from all registered Django app configs.\napp.autodiscover_tasks()\n\n\ndef import_all_submodules(package_name: str):\n    \"\"\"\n    Import a package and recursively import all submodules.\n    Used sparingly at Celery startup to ensure all task modules are loaded.\n    \"\"\"\n    pkg = importlib.import_module(package_name)\n    if not hasattr(pkg, \"__path__\"):\n        return\n    for mod in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + \".\"):\n        importlib.import_module(mod.name)\n\n\n# Import all task modules under these packages\n# We do this because celery autodiscovery won't\n# find anything not in tasks.py or tasks/__init__.py\n# We need to defer this until after Django is fully loaded\n@app.on_after_finalize.connect\ndef _load_all_task_modules(sender, **kwargs):\n    import_all_submodules(\"concordia.tasks\")\n    import_all_submodules(\"importer.tasks\")\n"
  },
  {
    "path": "concordia/consumers.py",
    "content": "import time\n\nfrom channels.generic.websocket import AsyncJsonWebsocketConsumer\n\n\nclass AssetConsumer(AsyncJsonWebsocketConsumer):\n    async def connect(self):\n        await self.channel_layer.group_add(\"asset_updates\", self.channel_name)\n        await self.accept()\n\n    async def disconnect(self, code):\n        await self.channel_layer.group_discard(\"asset_updates\", self.channel_name)\n\n    async def asset_update(self, message):\n        await self.send_json({\"message\": message, \"sent\": int(time.time())})\n\n    async def asset_reservation_obtained(self, message):\n        await self.send_json({\"message\": message, \"sent\": int(time.time())})\n\n    async def asset_reservation_released(self, message):\n        await self.send_json({\"message\": message, \"sent\": int(time.time())})\n"
  },
  {
    "path": "concordia/context_processors.py",
    "content": "from typing import Any, Dict\n\nfrom django.conf import settings\nfrom django.core.cache import cache\nfrom django.http import HttpRequest\n\n\ndef system_configuration(request: HttpRequest) -> Dict[str, Any]:\n    \"\"\"\n    Expose selected settings to templates via the default context.\n\n    Adds the following keys:\n      * SENTRY_FRONTEND_DSN: Front-end DSN string or None\n      * CONCORDIA_ENVIRONMENT: Current environment label\n      * S3_BUCKET_NAME: Bucket name for public media or None\n      * APPLICATION_VERSION: Deployed version string or None\n\n    Args:\n        request:\n            The current HTTP request. Included for the context processor\n            signature; it is not used.\n\n    Returns:\n        dict: Mapping of configuration keys to values for templates.\n    \"\"\"\n    return {\n        \"SENTRY_FRONTEND_DSN\": getattr(settings, \"SENTRY_FRONTEND_DSN\", None),\n        \"CONCORDIA_ENVIRONMENT\": settings.CONCORDIA_ENVIRONMENT,\n        \"S3_BUCKET_NAME\": getattr(settings, \"S3_BUCKET_NAME\", None),\n        \"APPLICATION_VERSION\": getattr(settings, \"APPLICATION_VERSION\", None),\n    }\n\n\ndef site_navigation(request: HttpRequest) -> Dict[str, Any]:\n    \"\"\"\n    Provide navigation helpers derived from the request.\n\n    Adds:\n      * VIEW_NAME: The resolved Django view name if available\n      * VIEW_NAME_FOR_CSS: VIEW_NAME with ``:`` replaced by ``--`` for CSS\n      * PATH_LEVEL_N: Each path segment by position, 1-indexed\n\n    Example:\n        For ``/campaigns/demo/item/123/`` this yields::\n\n            {\n                \"PATH_LEVEL_1\": \"campaigns\",\n                \"PATH_LEVEL_2\": \"demo\",\n                \"PATH_LEVEL_3\": \"item\",\n                \"PATH_LEVEL_4\": \"123\",\n            }\n\n    Args:\n        request:\n            The current HTTP request used to derive view and path data.\n\n    Returns:\n        dict: Mapping of helper keys to values for templates.\n    \"\"\"\n    data: Dict[str, Any] = {}\n\n    if request.resolver_match:\n        data[\"VIEW_NAME\"] = request.resolver_match.view_name\n        data[\"VIEW_NAME_FOR_CSS\"] = data[\"VIEW_NAME\"].replace(\":\", \"--\")\n\n    path_components = request.path.strip(\"/\").split(\"/\")\n    for i, component in enumerate(path_components, start=1):\n        data[\"PATH_LEVEL_%d\" % i] = component\n\n    return data\n\n\ndef maintenance_mode_frontend_available(request: HttpRequest) -> Dict[str, Any]:\n    \"\"\"\n    Expose a flag indicating front-end maintenance mode readiness.\n\n    Reads the ``maintenance_mode_frontend_available`` cache key and returns a\n    boolean under the same name in the template context.\n\n    Args:\n        request:\n            The current HTTP request. Included for the context processor\n            signature; it is not used.\n\n    Returns:\n        dict: ``{\"maintenance_mode_frontend_available\": bool}``.\n    \"\"\"\n    value = cache.get(\"maintenance_mode_frontend_available\", False)\n    return {\"maintenance_mode_frontend_available\": value}\n\n\ndef request_id_context(request: HttpRequest) -> Dict[str, Any]:\n    \"\"\"\n    Expose the per-request identifier, if present.\n\n    Relies on middleware attaching ``request.request_id``. Returns the value\n    or ``None`` if absent.\n\n    Args:\n        request:\n            The current HTTP request holding ``request_id`` if set.\n\n    Returns:\n        dict: ``{\"request_id\": str | None}``.\n    \"\"\"\n    return {\"request_id\": getattr(request, \"request_id\", None)}\n"
  },
  {
    "path": "concordia/contextmanagers.py",
    "content": "# Based on code from\n# https://docs.celeryq.dev/en/v5.5.0/tutorials/task-cookbook.html#ensuring-a-task-is-only-executed-one-at-a-time\n\nimport logging\nimport time\nfrom collections.abc import Generator\nfrom contextlib import contextmanager\n\nfrom django.core.cache import cache\n\nlogger = logging.getLogger(__name__)\n\nDEFAULT_LOCK_DURATION = 60 * 10  # 10 minutes\n\n\n@contextmanager\ndef cache_lock(\n    lock_id: str,\n    oid: str,\n    lock_duration: int = DEFAULT_LOCK_DURATION,\n) -> Generator[bool, None, None]:\n    \"\"\"\n    Context manager to acquire a distributed cache-based lock.\n\n    Ensures that only one process or thread can execute a block of code\n    associated with a given lock ID at a time. Uses Django's cache backend\n    and `cache.add` to store the lock key, then `cache.delete` to release the\n    lock when exiting the context if it was acquired and has not expired.\n\n    Args:\n        lock_id (str): Unique key identifying the lock in the cache.\n        oid (str): Identifier for the owner of the lock. Stored as the cache\n            value but not otherwise used.\n        lock_duration (int): How long to hold the lock in seconds. Defaults\n            to 10 minutes.\n\n    Yields:\n        bool: True if the lock was acquired, False otherwise.\n\n    Usage:\n        with cache_lock(\"my-task-lock\", \"worker-1\") as acquired:\n            if acquired:\n                # Do protected work here\n            else:\n                # Skip or retry later\n    \"\"\"\n    try:\n        timeout_at = time.monotonic() + lock_duration\n        # cache.add does nothing and returns False if the key already exists\n        status = cache.add(lock_id, oid, lock_duration)\n        yield status\n    finally:\n        if status and time.monotonic() < timeout_at:\n            # Don't release the lock if we did not acquire it\n            # Also, don't release the lock if we exceeded the timeout\n            # to reduce the chance of releasing an expired lock\n            # owned by someone else\n            cache.delete(lock_id)\n"
  },
  {
    "path": "concordia/converters.py",
    "content": "from django.urls.converters import SlugConverter, StringConverter\n\n\nclass UnicodeSlugConverter(SlugConverter):\n    # This is similar to the slug_unicode_re pattern but is not anchored to the\n    # start of the string:\n    regex = r\"[-\\w+]+\"\n\n\nclass ItemIdConverter(StringConverter):\n    # Allows . in the item ID\n    regex = r\"[-a-zA-Z0-9_\\.]+\"\n"
  },
  {
    "path": "concordia/decorators.py",
    "content": "# Based on code from https://gist.github.com/dmwyatt/d09da3f03cbdcad217db35f5cf8a9f94\nimport hashlib\nimport logging\nfrom functools import wraps\n\nfrom celery import Task\n\nfrom concordia.contextmanagers import cache_lock\n\nlogger = logging.getLogger(__name__)\n\n\ndef locked_task(function=None, lock_by_args: bool = True):\n    \"\"\"\n    Decorator to lock a task from concurrent execution.\n    This requires the task to be bound (bind=True) and for the\n    task decorate to be above this decorator.\n    ## Locking by task + arguments\n    Allows duplicate calls of the task as long as each call uses different arguments.\n    >>> from celery.task import task\n    >>> @task(bind=True)\n    ... @locked_task        # <=========== Note no-arg version of decorator\n    ... def a_task(self, some_arg):\n    ...     time.sleep(10)\n    Start a task.\n    >>> a_task.delay(\"foo\")\n    Try to start task with same args again. Nothing happens since it was just called\n    with those args and it's still running\n    >>> a_task.delay(\"foo\")\n    Will run even though first call started task since this call has different args.\n    >>> a_task.delay(\"bar\")\n\n    ## Locking by task\n    Lock task against concurrent calls regardless of arguments\n    >>> @task(bind=True)\n    ... @locked_task(lock_by_args=False)        # <=========== Note `lock_by_args`\n    ... def a_task(self, some_arg):\n    ...     time.sleep(10)\n\n    ## Forcing a run\n    You can force the task to run regardless of the lock by passing force=True\n    This is most useful if a lock is \"stuck\" or if you have a case where you don't\n    care about the lock\n    This can be used when directly (synchronously) calling the task and through\n    kwargs with apply_async. It cannot be used with delay.\n    >>> a_task(some_arg, force=True)\n    >>> a_task.apply_async(args=(some_arg,), kwargs={'force' : True})\n    \"\"\"\n\n    def decorator(f):\n        @wraps(f)\n        def wrapped(self: Task, *args, **kwargs):\n            force = kwargs.pop(\"force\", False)  # Remove 'force' before passing to task\n\n            if lock_by_args:\n                # lock with name of function and its hashed arguments.  This\n                # means that if any of the function, args or kwargs are\n                # different, then the lock won't match and another instance\n                # of the task will run\n                try:\n                    # We hash the arguments to make them safe for use as a cache key\n                    raw_key = f\"{repr(args)}:{repr(sorted(kwargs.items()))}\"\n                    key = f\"{self.name}:{hashlib.sha256(raw_key.encode()).hexdigest()}\"\n                except Exception:\n                    logger.exception(\n                        \"Unable to create cache key from arguments for %s.\", self.name\n                    )\n                    raise\n\n            else:\n                # Use name of task as key.\n                key = self.name\n\n            with cache_lock(key, self.request.hostname) as acquired:\n                if acquired or force:\n                    if not acquired:\n                        logger.warning(\n                            \"Force-running task %s with key %s; lock not acquired\",\n                            self.name,\n                            key,\n                        )\n                    return f(self, *args, **kwargs)\n                logger.info(\n                    \"Task %s with key %s is already running; skipping\", self.name, key\n                )\n\n        return wrapped\n\n    return decorator(function) if function else decorator\n"
  },
  {
    "path": "concordia/documents.py",
    "content": "# Contains OpenSearch documents for indexing models in the Concordia application.\nfrom django.contrib.auth.models import User\nfrom django.db.models import Count\nfrom django_opensearch_dsl import Document, fields\nfrom django_opensearch_dsl.registries import registry\n\nfrom .models import Asset, SiteReport, Transcription, UserAssetTagCollection\n\n\n@registry.register_document\nclass UserDocument(Document):\n    class Index:\n        # Name of the Opensearch index\n        name = \"users\"\n        # See Opensearch Indices API reference for available settings\n        settings = {\"number_of_shards\": 1, \"number_of_replicas\": 0}\n\n    transcription_count = fields.IntegerField()\n\n    class Django:\n        model = User\n        fields = [\"last_login\", \"date_joined\", \"is_active\", \"id\"]\n\n    def prepare_transcription_count(self, instance):\n        qs = User.objects.filter(id=instance.id).annotate(Count(\"transcription\"))\n        return qs[0].transcription__count\n\n\n@registry.register_document\nclass SiteReportDocument(Document):\n    class Index:\n        # Name of the Opensearch index\n        name = \"site_reports\"\n        # See Opensearch Indices API reference for available settings\n        settings = {\"number_of_shards\": 1, \"number_of_replicas\": 0}\n\n    campaign = fields.ObjectField(properties={\"slug\": fields.KeywordField()})\n    topic = fields.ObjectField(properties={\"slug\": fields.KeywordField()})\n\n    class Django:\n        model = SiteReport\n\n        fields = [\n            \"created_on\",\n            \"report_name\",\n            \"assets_total\",\n            \"assets_published\",\n            \"assets_not_started\",\n            \"assets_in_progress\",\n            \"assets_waiting_review\",\n            \"assets_completed\",\n            \"assets_unpublished\",\n            \"items_published\",\n            \"items_unpublished\",\n            \"projects_published\",\n            \"projects_unpublished\",\n            \"anonymous_transcriptions\",\n            \"transcriptions_saved\",\n            \"daily_review_actions\",\n            \"distinct_tags\",\n            \"tag_uses\",\n            \"campaigns_published\",\n            \"campaigns_unpublished\",\n            \"users_registered\",\n            \"users_activated\",\n            \"registered_contributors\",\n            \"daily_active_users\",\n        ]\n\n\n@registry.register_document\nclass TagCollectionDocument(Document):\n    class Index:\n        # Name of the Opensearch index\n        name = \"tags\"\n        # See Opensearch Indices API reference for available settings\n        settings = {\"number_of_shards\": 1, \"number_of_replicas\": 0}\n\n    tags = fields.NestedField(properties={\"value\": fields.TextField()})\n    asset = fields.ObjectField(\n        properties={\n            \"title\": fields.TextField(),\n            \"slug\": fields.TextField(),\n            \"transcription_status\": fields.KeywordField(),\n            \"item\": fields.ObjectField(\n                properties={\n                    \"item_id\": fields.TextField(),\n                    \"project\": fields.ObjectField(\n                        properties={\n                            \"slug\": fields.KeywordField(),\n                            \"campaign\": fields.ObjectField(\n                                properties={\"slug\": fields.KeywordField()}\n                            ),\n                        }\n                    ),\n                }\n            ),\n        }\n    )\n    user = fields.ObjectField(properties={\"id\": fields.IntegerField()})\n\n    class Django:\n        model = UserAssetTagCollection\n        fields = [\"created_on\", \"updated_on\"]\n\n    def get_queryset(self, *args, **kwargs):\n        return (\n            super()\n            .get_queryset(*args, **kwargs)\n            .order_by(\"pk\")\n            .prefetch_related(\n                \"asset__item\", \"asset__item__project\", \"asset__item__project__campaign\"\n            )\n        )\n\n\n@registry.register_document\nclass TranscriptionDocument(Document):\n    class Index:\n        # Name of the Opensearch index\n        name = \"transcriptions\"\n        # See Opensearch Indices API reference for available settings\n        settings = {\"number_of_shards\": 1, \"number_of_replicas\": 0}\n\n    asset = fields.ObjectField(\n        properties={\n            \"title\": fields.TextField(),\n            \"slug\": fields.TextField(),\n            \"transcription_status\": fields.KeywordField(),\n            \"item\": fields.ObjectField(\n                properties={\n                    \"item_id\": fields.TextField(),\n                    \"project\": fields.ObjectField(\n                        properties={\n                            \"slug\": fields.KeywordField(),\n                            \"campaign\": fields.ObjectField(\n                                properties={\"slug\": fields.KeywordField()}\n                            ),\n                            \"topics\": fields.NestedField(\n                                properties={\"slug\": fields.KeywordField()}\n                            ),\n                        }\n                    ),\n                }\n            ),\n        }\n    )\n    user = fields.ObjectField(properties={\"id\": fields.IntegerField()})\n    reviewed_by = fields.ObjectField(properties={\"id\": fields.IntegerField()})\n    supersedes = fields.ObjectField(properties={\"id\": fields.IntegerField()})\n\n    class Django:\n        model = Transcription\n\n        fields = [\n            \"id\",\n            \"created_on\",\n            \"updated_on\",\n            \"text\",\n            \"accepted\",\n            \"rejected\",\n            \"submitted\",\n        ]\n\n    def get_queryset(self, *args, **kwargs):\n        return (\n            super()\n            .get_queryset(*args, **kwargs)\n            .order_by(\"pk\")\n            .prefetch_related(\n                \"asset__item\",\n                \"asset__item__project\",\n                \"asset__item__project__topics\",\n                \"asset__item__project__campaign\",\n            )\n        )\n\n\n@registry.register_document\nclass AssetDocument(Document):\n    class Index:\n        # Name of the Opensearch index\n        name = \"assets\"\n        # See Opensearch Indices API reference for available settings\n        settings = {\"number_of_shards\": 1, \"number_of_replicas\": 0}\n\n    item = fields.ObjectField(\n        properties={\n            \"item_id\": fields.KeywordField(),\n            \"project\": fields.ObjectField(\n                properties={\n                    \"slug\": fields.KeywordField(),\n                    \"campaign\": fields.ObjectField(\n                        properties={\"slug\": fields.KeywordField()}\n                    ),\n                    \"topics\": fields.NestedField(\n                        properties={\"slug\": fields.KeywordField()}\n                    ),\n                }\n            ),\n        }\n    )\n\n    transcription_status = fields.KeywordField()\n\n    latest_transcription = fields.ObjectField(\n        properties={\n            \"created_on\": fields.DateField(),\n            \"updated_on\": fields.DateField(),\n            \"accepted\": fields.DateField(),\n            \"rejected\": fields.DateField(),\n            \"submitted\": fields.DateField(),\n        }\n    )\n\n    submission_count = fields.IntegerField()\n\n    def prepare_submission_count(self, instance):\n        return Transcription.objects.filter(\n            asset=instance, submitted__isnull=True\n        ).count()\n\n    class Django:\n        model = Asset\n        fields = [\"published\", \"difficulty\", \"slug\", \"sequence\", \"year\"]\n\n    def get_queryset(self, *args, **kwargs):\n        return (\n            super()\n            .get_queryset(*args, **kwargs)\n            .order_by(\"pk\")\n            .prefetch_related(\n                \"item\",\n                \"item__project\",\n                \"item__project__topics\",\n                \"item__project__campaign\",\n            )\n        )\n"
  },
  {
    "path": "concordia/exceptions.py",
    "content": "# Creating a specfic error for this, since our pre-commit\n# checks will not allow us to test for generic exceptions\nclass CacheLockedError(Exception):\n    def __init__(self, message, details=None):\n        super().__init__(message)\n        self.details = details\n\n\nclass RateLimitExceededError(Exception):\n    pass\n"
  },
  {
    "path": "concordia/forms.py",
    "content": "from logging import getLogger\nfrom typing import Any, Iterator\n\nfrom django import forms\nfrom django.contrib.auth import get_user_model\nfrom django.contrib.auth.forms import (\n    AuthenticationForm,\n    PasswordResetForm,\n    SetPasswordForm,\n    UsernameField,\n)\nfrom django.http import HttpRequest\nfrom django_registration.backends.activation.views import RegistrationView\nfrom django_registration.forms import RegistrationForm\nfrom django_registration.signals import user_activated\n\nfrom .turnstile.fields import TurnstileField\n\nUser = get_user_model()\n\nlogger = getLogger(__name__)\n\n\nclass AllowInactivePasswordResetForm(PasswordResetForm):\n    \"\"\"\n    Password reset form which includes inactive users.\n\n    Behavior:\n        Overrides Django's default user lookup so that inactive users with a\n        usable password are included, allowing a single reset flow to both\n        confirm email and activate the account.\n    \"\"\"\n\n    def get_users(self, email: str) -> Iterator[User]:\n        \"\"\"\n        Yield users matching the provided email, including inactive accounts.\n\n        Args:\n            email: Case-insensitive email address to search.\n\n        Returns:\n            Iterator over users that have a usable password.\n        \"\"\"\n        # Allow inactive users to reset their passwords and confirm their email\n        # account in one step.\n        all_users = User._default_manager.filter(\n            **{\"%s__iexact\" % User.get_email_field_name(): email}\n        )\n        return (u for u in all_users if u.has_usable_password())\n\n\nclass ActivateAndSetPasswordForm(SetPasswordForm):\n    \"\"\"\n    Set-password form which activates the user on successful save.\n\n    Behavior:\n        If the associated user is inactive, mark the user active, emit the\n        django-registration ``user_activated`` signal to trigger the welcome\n        email, then proceed with the normal password save.\n    \"\"\"\n\n    # A successful password reset means the user\n    # has confirmed their email address, so\n    # set is_active to True.\n    def save(self, commit: bool = True) -> User:\n        \"\"\"\n        Save the new password and ensure the user is marked active.\n\n        Also emits ``user_activated`` when activation occurs.\n\n        Args:\n            commit: Whether to persist changes immediately.\n\n        Returns:\n            The updated user instance.\n        \"\"\"\n        if not self.user.is_active:\n            logger.info(\"Activated user %s due to password reset\", self.user.username)\n            self.user.is_active = True\n            # send user_activation signal so that the user will\n            # receive a welcome email\n            user_activated.send(sender=self.__class__, user=self.user, request=None)\n        return super().save(commit=commit)\n\n\nclass UserRegistrationForm(RegistrationForm):\n    \"\"\"\n    Registration form with newsletter opt-in.\n\n    Adds a boolean field which, when selected, is later used to add the new\n    user to the newsletter group during signal handling.\n    \"\"\"\n\n    newsletterOptIn = forms.BooleanField(\n        label=\"Newsletter\",\n        initial=True,\n        required=False,\n        help_text=(\n            \"Email me 2-3 times a month about campaign updates, upcoming \"\n            \"events and new features.\"\n        ),\n    )\n\n    class Meta(RegistrationForm.Meta):\n        help_texts = {\n            \"username\": (\n                \"Can only contain letters, numbers and any of these symbols:\"\n                \" <kbd>@</kbd>, <kbd>.</kbd>, <kbd>+</kbd>, <kbd>-</kbd>\"\n                \" or <kbd>_</kbd>. 150 characters or fewer.\"\n            )\n        }\n\n\nclass UserLoginForm(AuthenticationForm):\n    \"\"\"\n    Login form which resends activation for inactive but valid credentials.\n\n    Behavior:\n        If credentials are correct but the user is inactive, resend an\n        activation email and raise a validation error with user guidance.\n    \"\"\"\n\n    username = UsernameField(\n        label=\"Username or email address\",\n        widget=forms.TextInput(attrs={\"autofocus\": True}),\n    )\n\n    def confirm_login_allowed(self, user: Any) -> None:\n        \"\"\"\n        Enforce activation: resend activation email and block login if inactive.\n\n        Args:\n            user: The authenticated user instance.\n\n        Raises:\n            forms.ValidationError: When the user account is inactive.\n        \"\"\"\n        inactive_message = (\n            \"This account has not yet been activated. \"\n            \"An activation email has been sent to the email \"\n            \"address associated with this account. \"\n            \"Please check for this message and click the link \"\n            \"to finish your account registration.\"\n        )\n\n        # If the user provided a correct username and password combination,\n        # but has not yet confirmed their email,\n        # resend the email activation request and display a custom message.\n        if not user.is_active:\n            logger.warning(\"Inactive user tried to log in with valid credentials.\")\n            view = RegistrationView(request=self.request)\n            view.send_activation_email(user)\n\n            raise forms.ValidationError(inactive_message, code=\"inactive\")\n\n\nclass UserNameForm(forms.Form):\n    \"\"\"\n    Minimal form for updating a user's first and last name.\n\n    Fields:\n        first_name: Optional first name.\n        last_name: Optional last name.\n    \"\"\"\n\n    first_name = forms.CharField(label=\"\", required=False)\n    last_name = forms.CharField(label=\"\", required=False)\n\n\nclass UserProfileForm(forms.Form):\n    \"\"\"\n    Profile form for updating the user's email address.\n\n    Validates that the email is not already in use and, for the current user,\n    is not unchanged to avoid unnecessary confirmation flows.\n    \"\"\"\n\n    email = forms.EmailField(label=\"\", required=True)\n\n    def __init__(self, *, request: HttpRequest, **kwargs) -> None:\n        \"\"\"\n        Store the request for later use.\n\n        Args:\n            request: The current HTTP request.\n        \"\"\"\n        self.request = request\n        super().__init__(**kwargs)\n\n    def clean_email(self) -> str:\n        \"\"\"\n        Validate that the submitted email is available and meaningful.\n\n        Rejects emails already in use by any account and the current user's\n        existing email to avoid triggering a redundant confirmation.\n\n        Returns:\n            The cleaned email string.\n\n        Raises:\n            forms.ValidationError: If the email is not available.\n        \"\"\"\n        data = self.cleaned_data[\"email\"]\n        # Previously, this code only checked against other users, but it\n        # is also an error if a user tries to change their email to the one\n        # they're already using--we don't want to initiate the email\n        # confirmation process when the user isn't actually checking their email.\n        if User.objects.filter(email__iexact=data).exists():\n            raise forms.ValidationError(\"That email address is not available\")\n        return data\n\n\nclass AccountDeletionForm(forms.Form):\n    \"\"\"\n    Trivial form that retains the request for view logic.\n\n    Used where the view needs the request object after validation.\n    \"\"\"\n\n    def __init__(self, *, request: HttpRequest, **kwargs) -> None:\n        \"\"\"\n        Store the request for later use.\n\n        Args:\n            request: The current HTTP request.\n        \"\"\"\n        self.request = request\n        super().__init__(**kwargs)\n\n\nclass TurnstileForm(forms.Form):\n    \"\"\"\n    Simple form embedding the Cloudflare Turnstile verification field.\n\n    Fields:\n        turnstile: A required TurnstileField that validates with the API.\n    \"\"\"\n\n    turnstile = TurnstileField()\n"
  },
  {
    "path": "concordia/logging.py",
    "content": "import warnings\nfrom types import MappingProxyType\nfrom typing import Any, Callable, Optional\n\nimport structlog\n\n\ndef get_logging_user_id(user: Any) -> str:\n    \"\"\"\n    Return a consistent identifier for logging purposes.\n\n    Args:\n        user (Any): A Django user object (possibly anonymous).\n\n    Returns:\n        user_id (str): User's ID or \"anonymous\" if unauthenticated, represents\n                         the Concordia anonymous user, or has no ID.\n    \"\"\"\n    if not getattr(user, \"is_authenticated\", False):\n        return \"anonymous\"\n\n    if getattr(user, \"username\", None) == \"anonymous\":\n        return \"anonymous\"\n\n    user_id = getattr(user, \"id\", None)\n    if user_id is None:\n        return \"anonymous\"\n\n    return str(user_id)\n\n\n# Default global registry for semantic context extractors\n_DEFAULT_EXTRACTORS: dict[str, Callable[[Any], dict[str, Any]]] = {}\n\n\ndef _register_default_extractor(\n    context_key: str, extractor_function: Callable[[Any], dict[str, Any]]\n):\n    _DEFAULT_EXTRACTORS[context_key] = extractor_function\n\n\n# Built-in extractors\n_register_default_extractor(\"user\", lambda user: {\"user_id\": get_logging_user_id(user)})\n\n# Extractors to use other extractors have to be registered in order, so\n# campaign must be registered before item, item before asset, asset before transcription\n_register_default_extractor(\n    \"campaign\",\n    lambda campaign: {\n        \"campaign_slug\": getattr(campaign, \"slug\", None),\n    },\n)\n\n_register_default_extractor(\n    \"item\",\n    lambda item: {\n        **_DEFAULT_EXTRACTORS[\"campaign\"](getattr(item, \"campaign\", None)),\n        \"item_id\": getattr(item, \"item_id\", None),\n    },\n)\n\n_register_default_extractor(\n    \"asset\",\n    lambda asset: {\n        **_DEFAULT_EXTRACTORS[\"item\"](getattr(asset, \"item\", None)),\n        \"asset_id\": getattr(asset, \"pk\", None),\n    },\n)\n\n_register_default_extractor(\n    \"transcription\",\n    lambda transcription: {\n        **_DEFAULT_EXTRACTORS[\"asset\"](getattr(transcription, \"asset\", None)),\n        \"transcription_id\": getattr(transcription, \"pk\", None),\n    },\n)\n\n_register_default_extractor(\n    \"topic\",\n    lambda topic: {\n        \"topic_slug\": getattr(topic, \"slug\", None),\n    },\n)\n\n# Freeze default extractors to prevent mutation\n_DEFAULT_EXTRACTORS = MappingProxyType(_DEFAULT_EXTRACTORS)\n\n\nclass ConcordiaLogger:\n    \"\"\"\n    A structured logging wrapper around structlog that enforces consistent logging\n    conventions across the Concordia application.\n\n    Features:\n        - Requires 'message' and 'event_code' for all logs, and 'reason'/'reason_code'\n          for warnings/errors.\n        - Automatically extracts common context from objects like Asset, User\n          and Transcription.\n        - Allows semantic binding of objects (e.g., asset=self) which are expanded\n          at log time.\n        - Supports binding persistent fields via structlog's context mechanism.\n\n    Usage:\n    -----\n\n    Create a logger:\n        ```python\n        structured_logger = ConcordiaLogger.get_logger(f\"{__name__}\")\n        ```\n\n    Log an info-level event:\n        ```python\n        structured_logger.info(\n            \"Started OCR processing.\",\n            event_code=\"asset_ocr_started\",\n            asset=my_asset,\n            user=request.user,\n        )\n        ```\n\n    Log a warning with reason:\n        ```python\n        structured_logger.warning(\n            \"Rollback failed.\",\n            event_code=\"rollback_attempt_failed\",\n            reason=\"No eligible transcription found.\",\n            reason_code=\"no_valid_target\",\n            asset=my_asset,\n            user=request.user,\n        )\n        ```\n\n    Bind a logger for repeated use:\n        ```python\n        logger = ConcordiaLogger.get_logger(f\"{__name__}\")\n        my_logger = logger.bind(asset=asset)\n        my_logger.info(\"Transcription updated.\", event_code=\"transcription_updated\")\n        ```\n\n        This is the equivalent of:\n        ```python\n        logger = ConcordiaLogger.get_logger(f\"{__name__}\")\n        logger.info(\n            \"Transcription updated.\",\n            event_code=\"transcription_updated\",\n            asset=asset\n        )\n        ```\n\n        This can save you from having to repeatedly pass in the same data to every\n        logging call. For instance, if you bind a logger to a particular model\n        instance like `.bind(asset=self)`, that bound logger will automatically\n        include the instance as context for all the logging statements done by it.\n\n    Special Context Expansion:\n    --------------------------\n\n    The logger recognizes certain context object names and extracts fields from them\n    automatically. These include:\n\n    - `user` -> `user_id`\n    - `asset` -> `asset_id`, `campaign_slug`, `item_id`\n    - `transcription` -> `transcription_id`\n    - `campaign` -> `campaign_slug`\n    - `item` -> `item_id`\n    - `topic` -> `topic_slug`\n\n    If these objects are passed directly (e.g., as `user=request.user`), their relevant\n    fields will be included automatically in the log entry.\n\n    Explicit values passed (e.g., `item_id=...`) override extracted ones. Fields with\n    `None` values are omitted from the final log output.\n\n    Extractor System:\n    -----------------\n\n    The logger uses a registry of extractor functions to convert common objects\n    (e.g., Asset, User, Transcription) into structured logging fields.\n\n    Each extractor is a callable that takes an object and returns a dictionary of\n    field names and values. Fields with `None` values are omitted.\n\n    Extractors can be:\n\n    - Global defaults (defined in concordia.logging and shared by all loggers)\n    - Per-logger overrides (via `register_extractor()`)\n\n    The default extractors may internally invoke other extractors to avoid code\n    duplication. For example, the `transcription` extractor invokes the `asset`\n    extractor, which calls the `item` extractor, which uses the `campaign` extractor.\n\n    Registering a new extractor on a logger overrides the default for that logger\n    only.\n\n    Extractors are callables that take a single object and return a dictionary.\n\n    Example:\n        ```python\n        logger = ConcordiaLogger.get_logger(__name__)\n        logger.register_extractor(\"session\", lambda s: {\"session_id\": s.id})\n        ```\n\n        Now, passing `session=session_obj` to `.info()` (or any other logging method)\n        will include `session_id`.\n\n    Note:\n        Chained extractors (e.g., `transcription` -> `asset` -> `item`) are hardcoded to\n        use the default global extractors. If you override an extractor on a logger,\n        chained calls will not reflect that override. So, if you override the \"asset\"\n        extractor, if you pass in \"transcription\", that extractor will use the default\n        `asset` extractor, rather than your newly registered one.\n    \"\"\"\n\n    def __init__(self, logger, context: Optional[dict[str, Any]] = None):\n        self._logger = logger\n        self._context = context or {}\n        self._extractors = _DEFAULT_EXTRACTORS.copy()\n\n    @classmethod\n    def get_logger(cls, name: str) -> \"ConcordiaLogger\":\n        \"\"\"\n        Factory method to create a ConcordiaLogger from a given logger name.\n\n        Args:\n            name (str): The logger name (typically f\"structlog.{__name__}\").\n\n        Returns:\n            ConcordiaLogger: A logger instance with enriched behavior.\n        \"\"\"\n        return cls(structlog.get_logger(f\"structlog.{name}\"))\n\n    def register_extractor(\n        self, key: str, extractor: Callable[[Any], dict[str, Any]]\n    ) -> None:\n        \"\"\"\n        Register a custom context extractor for this logger instance only.\n\n        Args:\n            key (str): The context key to extract (e.g., \"custom_object\").\n            extractor (Callable): A function that returns a dict of fields to log.\n        \"\"\"\n        self._extractors[key] = extractor\n        if key in _DEFAULT_EXTRACTORS:\n            warnings.warn(\n                f\"Extractor for '{key}' registered but default extractors may still \"\n                f\"reference the original implementation via chaining. Overriding it \"\n                f\"here will not affect those chained uses.\",\n                UserWarning,\n                stacklevel=2,\n            )\n\n    def unregister_extractor(self, key: str) -> None:\n        \"\"\"\n        Remove a previously registered extractor from this logger instance.\n\n        Args:\n            key (str): The context key to remove.\n        \"\"\"\n        self._extractors.pop(key, None)\n\n    def log(\n        self,\n        level: str,\n        message: str,\n        *,\n        event_code: str,\n        reason: Optional[str] = None,\n        reason_code: Optional[str] = None,\n        **context: Any,\n    ) -> None:\n        \"\"\"\n        Emit structured logs with standardized context. This shouldn't be called\n        directly under ordinary circumstances, with one of the level methods (\n        debug, info, warning, error) used instead.\n\n        Args:\n            level (str): Logging level ('debug', 'info', 'warning', 'error').\n            message (str): Human-readable log message.\n            event_code (str): Required short machine-readable identifier.\n            reason (str, optional): Human-readable reason for failure (required for\n                warnings/errors).\n            reason_code (str, optional): Short identifier for reason (required for\n                warnings/errors).\n            context (Any): Additional structured context for the log.\n\n        Raises:\n            ValueError: If required fields are missing for the given log level.\n        \"\"\"\n        if not message:\n            raise ValueError(\"Log message is required.\")\n        if not event_code:\n            raise ValueError(\"Structured logs must include an 'event_code' field.\")\n        if level in (\"warning\", \"error\") and (not reason or not reason_code):\n            raise ValueError(\n                \"Warnings and errors must include both 'reason' and 'reason_code'.\"\n            )\n\n        context_data = {\"event_code\": event_code}\n        if reason:\n            context_data[\"reason\"] = reason\n        if reason_code:\n            context_data[\"reason_code\"] = reason_code\n\n        bound_context = self._context\n\n        # Extract data from provided context, falling back to the bound context\n        # if it exists\n        for context_key, extractor_function in self._extractors.items():\n            context_object = context.pop(context_key, bound_context.get(context_key))\n            if context_object:\n                extracted_fields = extractor_function(context_object)\n                for key, value in extracted_fields.items():\n                    if value is not None:\n                        context_data.setdefault(key, value)\n\n        # Add remaining values in bound_context\n        # (i.e., keys that weren't already extracted)\n        for key, value in bound_context.items():\n            if key not in self._extractors and key not in context and value is not None:\n                context_data[key] = value\n\n        # Override extracted and bound context with any explicit values passed in\n        # For instance, if `asset` and `asset_id` were both passed in, we would\n        # have extracted `asset`.`asset_id`, `asset`.`item`.`item_id`, etc., and\n        # now the extracted `asset_id` would be overriden by the explicit `asset_id`\n        # in the passed-in context.\n        for key, value in context.items():\n            if value is not None:\n                context_data[key] = value\n\n        getattr(self._logger, level)(message, **context_data)\n\n    def debug(self, message: str, *, event_code: str, **kwargs):\n        \"\"\"Emit a debug-level structured log.\"\"\"\n        self.log(\"debug\", message, event_code=event_code, **kwargs)\n\n    def info(self, message: str, *, event_code: str, **kwargs):\n        \"\"\"Emit an info-level structured log.\"\"\"\n        self.log(\"info\", message, event_code=event_code, **kwargs)\n\n    def warning(\n        self, message: str, *, event_code: str, reason: str, reason_code: str, **kwargs\n    ):\n        \"\"\"Emit a warning-level structured log. Requires reason and reason_code.\"\"\"\n        self.log(\n            \"warning\",\n            message,\n            event_code=event_code,\n            reason=reason,\n            reason_code=reason_code,\n            **kwargs,\n        )\n\n    def error(\n        self, message: str, *, event_code: str, reason: str, reason_code: str, **kwargs\n    ):\n        \"\"\"Emit an error-level structured log. Requires reason and reason_code.\"\"\"\n        self.log(\n            \"error\",\n            message,\n            event_code=event_code,\n            reason=reason,\n            reason_code=reason_code,\n            **kwargs,\n        )\n\n    def exception(\n        self,\n        message: str,\n        *,\n        event_code: str,\n        reason: str,\n        reason_code: str,\n        **kwargs: Any,\n    ) -> None:\n        \"\"\"\n        Emit an error-level structured log with exception info.\n\n        This is equivalent to calling `.error(..., exc_info=True)` and should be used\n        within an exception handler to capture tracebacks.\n        \"\"\"\n        self.log(\n            \"error\",\n            message,\n            event_code=event_code,\n            reason=reason,\n            reason_code=reason_code,\n            exc_info=True,\n            **kwargs,\n        )\n\n    def bind(self, **kwargs: Any) -> \"ConcordiaLogger\":\n        \"\"\"\n        Return a new ConcordiaLogger with additional context permanently bound.\n\n        Bound context can include semantic objects like asset, user or transcription,\n        in addition to primitive data types. Objects with registered extractors\n        will be expanded into structured fields at log time.\n\n        Args:\n            **kwargs: Context to bind.\n\n        Returns:\n            ConcordiaLogger: A logger with the provided context bound.\n        \"\"\"\n        # We make our own bound context rather than using structlog's\n        # .bind so we can safely access it ourselves\n        new_context = self._context.copy()\n        new_context.update(kwargs)\n        return ConcordiaLogger(self._logger, context=new_context)\n"
  },
  {
    "path": "concordia/maintenance.py",
    "content": "\"\"\"\nMaintenance-mode helpers for conditional frontend availability.\n\nThis module wraps ``maintenance_mode.http.need_maintenance_response`` to allow\nstaff or superusers limited frontend access during maintenance when a cache\nflag is set.\n\"\"\"\n\nfrom django.core.cache import cache\nfrom django.http import HttpRequest\nfrom maintenance_mode.http import (\n    need_maintenance_response as base_need_maintenance_response,\n)\n\n\ndef _need_maintenence_frontend(request: HttpRequest) -> bool | None:\n    \"\"\"\n    Optionally allow frontend access for privileged users during maintenance.\n\n    When the cache key ``maintenance_mode_frontend_available`` is truthy and the\n    request has an authenticated user who is staff or a superuser, return\n    ``False`` to indicate maintenance should not block the response. Otherwise\n    return ``None`` to defer to the default logic.\n\n    Args:\n        request: Current HTTP request.\n\n    Returns:\n        False to allow access, None to defer to default handling.\n    \"\"\"\n    if not hasattr(request, \"user\"):\n        return None\n\n    user = request.user\n\n    frontend_available = cache.get(\"maintenance_mode_frontend_available\", False)\n    if frontend_available and (user.is_staff or user.is_superuser):\n        return False\n    return None\n\n\ndef need_maintenance_response(request: HttpRequest) -> bool:\n    \"\"\"\n    Determine whether maintenance mode should block this request.\n\n    First delegates to the upstream maintenance-mode check. If it indicates that\n    maintenance applies, call ``_need_maintenence_frontend`` to allow privileged\n    access when enabled via cache. Returns a boolean suitable for the middleware.\n\n    Args:\n        request: Current HTTP request.\n\n    Returns:\n        True if maintenance mode should block the request, else False.\n    \"\"\"\n    value = base_need_maintenance_response(request)\n    if value is True:\n        value = _need_maintenence_frontend(request)\n    if isinstance(value, bool):\n        return value\n    return True\n"
  },
  {
    "path": "concordia/management/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/management/commands/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/management/commands/calculate_difficulty_values.py",
    "content": "\"\"\"\nManagement command to populate initial difficulty values.\n\nUsage:\n    python manage.py calculate_difficulty_values\n    python manage.py calculate_difficulty_values --verbosity 2\n\"\"\"\n\nfrom timeit import default_timer\n\nfrom django.core.management.base import BaseCommand\n\nfrom concordia.tasks.assets import calculate_difficulty_values\n\n\nclass Command(BaseCommand):\n    \"\"\"\n    Run the task which calculates initial difficulty values for assets.\n\n    This command invokes `concordia.tasks.assets.calculate_difficulty_values()`\n    and, when verbosity is greater than 1, prints how many records were\n    updated and how long the run took.\n    \"\"\"\n\n    def handle(self, *, verbosity: int, **kwargs) -> None:\n        \"\"\"\n        Execute the command.\n\n        Args:\n            verbosity (int): Django's verbosity level (0, 1, 2, or 3).\n\n        Returns:\n            None\n        \"\"\"\n        start_time = default_timer()\n\n        updated_count = calculate_difficulty_values()\n\n        if verbosity > 1:\n            print(\n                \"Updated %d records in %0.1f seconds\"\n                % (updated_count, default_timer() - start_time)\n            )\n"
  },
  {
    "path": "concordia/management/commands/create_load_test_fixtures.py",
    "content": "# ruff: noqa: ERA001 A003\n# bandit:skip-file\n\nimport json\nimport uuid\nfrom pathlib import Path\n\nfrom django.contrib.auth import get_user_model\nfrom django.contrib.auth.hashers import make_password\nfrom django.core import serializers\nfrom django.core.management import BaseCommand, call_command\n\nfrom concordia.models import (\n    Asset,\n    Campaign,\n    CardFamily,\n    Item,\n    Project,\n    ProjectTopic,\n    ResearchCenter,\n    Topic,\n    Transcription,\n)\n\nASSETS_LIMIT_DEFAULT = 10_000\nTEST_USERS_DEFAULT = 10_000\nTEST_USER_PREFIX_DEFAULT = \"locusttest\"\nTEST_USER_PASSWORD_DEFAULT = \"locustpass123\"  # nosec B105\n\n\ndef _serialize_qs(qs):\n    return json.loads(serializers.serialize(\"json\", qs))\n\n\ndef _serialize_list(objs):\n    return json.loads(serializers.serialize(\"json\", objs))\n\n\nclass Command(BaseCommand):\n    help = (\n        \"Build a single JSON fixture for load-testing:\\n\"\n        \"- 2 published Topics by ascending `ordering`\\n\"\n        \"- Consider 5 published Campaigns by ascending `ordering`\\n\"\n        \"- Walk Items/Assets from Topic projects first (cap 10,000 assets),\\n\"\n        \"  then from Campaign projects if needed, until the cap\\n\"\n        \"- Include closure of Items/Projects/Campaigns/Topics actually used \"\n        \"by chosen Assets\\n\"\n        \"- Include all Transcriptions for those Assets and anonymized Users \"\n        \"from those Transcriptions\\n\"\n        \"- Add 10,000 new test users (locusttest00001..locusttest10000) \"\n        \"with a known password\\n\"\n        \"- Include ProjectTopic rows for selected Topic+Project links\\n\"\n        \"- Write one JSON fixture\"\n    )\n\n    def add_arguments(self, p):\n        p.add_argument(\n            \"--assets-limit\",\n            type=int,\n            default=ASSETS_LIMIT_DEFAULT,\n            help=f\"Max assets to include (default {ASSETS_LIMIT_DEFAULT})\",\n        )\n        p.add_argument(\n            \"--test-users\",\n            type=int,\n            default=TEST_USERS_DEFAULT,\n            help=f\"How many new test users to include (default {TEST_USERS_DEFAULT})\",\n        )\n        p.add_argument(\n            \"--test-user-prefix\",\n            default=TEST_USER_PREFIX_DEFAULT,\n            help=f\"Prefix for test usernames (default '{TEST_USER_PREFIX_DEFAULT}')\",\n        )\n        p.add_argument(\n            \"--test-user-password\",\n            default=TEST_USER_PASSWORD_DEFAULT,\n            help=(\n                f\"Password for all test users (default \"\n                f\"'{TEST_USER_PASSWORD_DEFAULT}')\"\n            ),\n        )\n        p.add_argument(\n            \"--output\",\n            default=\"loadtest_fixture.json\",\n            help=\"Path to write the fixture JSON (default loadtest_fixture.json)\",\n        )\n        p.add_argument(\n            \"--no-validate\",\n            action=\"store_true\",\n            help=(\n                \"Do not load the fixture into a test database. \"\n                \"WARNING: fixture will not be verified.\"\n            ),\n        )\n        p.add_argument(\n            \"--validate-drop\",\n            action=\"store_true\",\n            help=(\n                \"Validate by loading into a fresh test DB, \"\n                \"then drop it after loading.\"\n            ),\n        )\n        p.add_argument(\n            \"--validate-db-name\",\n            default=None,\n            help=(\n                \"Override the test DB name used for validation \"\n                \"(default: <default.NAME>_lt).\"\n            ),\n        )\n        p.add_argument(\n            \"--validate-recreate\",\n            action=\"store_true\",\n            help=\"Force recreation of the validation DB if it already exists.\",\n        )\n\n    def handle(self, *args, **o):\n        assets_limit = int(o[\"assets_limit\"])\n        out_path = Path(o[\"output\"]).resolve()\n\n        # Select 2 published Topics by ordering\n        topics_qs = Topic.objects.filter(published=True).order_by(\"ordering\")[:2]\n        topics = list(topics_qs)\n        topic_ids = {t.id for t in topics}\n        if not topics:\n            self.stderr.write(\n                self.style.WARNING(\n                    \"No published Topics found. \"\n                    \"Proceeding with Campaign-only selection.\"\n                )\n            )\n\n        # Projects in those topics via ProjectTopics\n        proj_ids_from_topics = set(\n            ProjectTopic.objects.filter(topic_id__in=topic_ids).values_list(\n                \"project_id\", flat=True\n            )\n        )\n\n        # ensure we consider 5 published Campaigns\n        # campaigns connected to the topic-derived projects:\n        campaigns_from_topics_qs = Campaign.objects.filter(\n            published=True,\n            id__in=Project.objects.filter(id__in=proj_ids_from_topics).values_list(\n                \"campaign_id\", flat=True\n            ),\n        ).distinct()\n\n        needed = max(0, 5 - campaigns_from_topics_qs.count())\n        if needed > 0:\n            # take extra published campaigns (not already counted) by ordering ASC\n            extra_campaigns_qs = (\n                Campaign.objects.filter(published=True)\n                .exclude(id__in=campaigns_from_topics_qs.values_list(\"id\", flat=True))\n                .order_by(\"ordering\")[:needed]\n            )\n            selected_campaigns_qs = campaigns_from_topics_qs.union(extra_campaigns_qs)\n        else:\n            selected_campaigns_qs = campaigns_from_topics_qs\n\n        # We might end up with <5 if not enough published; that's fine\n\n        # Collect assets up to cap\n        asset_ids = set()\n        item_ids = set()\n        project_ids = set()\n\n        # walk projects from Topics first\n        for proj in (\n            Project.objects.filter(id__in=proj_ids_from_topics)\n            .order_by(\"id\")\n            .iterator()\n        ):\n            if len(asset_ids) >= assets_limit:\n                break\n            project_ids.add(proj.id)\n\n            for item in (\n                Item.objects.filter(project_id=proj.id).order_by(\"id\").iterator()\n            ):\n                if len(asset_ids) >= assets_limit:\n                    break\n                item_ids.add(item.id)\n\n                for a in (\n                    Asset.objects.filter(item_id=item.id)\n                    .order_by(\"id\")\n                    .values_list(\"id\", flat=True)\n                    .iterator()\n                ):\n                    if len(asset_ids) >= assets_limit:\n                        break\n                    asset_ids.add(int(a))\n\n        # If needed, walk projects from selected campaigns (not already included)\n        if len(asset_ids) < assets_limit and selected_campaigns_qs.exists():\n            proj_ids_from_campaigns = set(\n                Project.objects.filter(\n                    campaign_id__in=selected_campaigns_qs.values_list(\"id\", flat=True)\n                )\n                .exclude(id__in=project_ids)\n                .values_list(\"id\", flat=True)\n            )\n            for proj in (\n                Project.objects.filter(id__in=proj_ids_from_campaigns)\n                .order_by(\"id\")\n                .iterator()\n            ):\n                if len(asset_ids) >= assets_limit:\n                    break\n                project_ids.add(proj.id)\n\n                for item in (\n                    Item.objects.filter(project_id=proj.id).order_by(\"id\").iterator()\n                ):\n                    if len(asset_ids) >= assets_limit:\n                        break\n                    item_ids.add(item.id)\n\n                    for a in (\n                        Asset.objects.filter(item_id=item.id)\n                        .order_by(\"id\")\n                        .values_list(\"id\", flat=True)\n                        .iterator()\n                    ):\n                        if len(asset_ids) >= assets_limit:\n                            break\n                        asset_ids.add(int(a))\n\n        # recompute exact asset set\n        assets_qs = Asset.objects.filter(id__in=asset_ids)\n\n        # Items actually referenced by chosen assets\n        items_qs = Item.objects.filter(\n            id__in=assets_qs.values_list(\"item_id\", flat=True).distinct()\n        )\n        item_ids = set(items_qs.values_list(\"id\", flat=True))\n\n        # Projects from those items\n        projects_qs = Project.objects.filter(\n            id__in=items_qs.values_list(\"project_id\", flat=True).distinct()\n        )\n        project_ids = set(projects_qs.values_list(\"id\", flat=True))\n\n        # Campaigns from those projects\n        campaigns_qs = Campaign.objects.filter(\n            id__in=projects_qs.values_list(\"campaign_id\", flat=True).distinct()\n        )\n\n        # CardFamilies referenced by the selected Campaigns (FK target)\n        card_families_qs = CardFamily.objects.filter(\n            id__in=campaigns_qs.exclude(card_family__isnull=True)\n            .values_list(\"card_family_id\", flat=True)\n            .distinct()\n        )\n\n        # ResearchCenters referenced by the selected Campaigns (M2M target)\n        rc_through = Campaign.research_centers.through\n        rc_ids = (\n            rc_through.objects.filter(\n                campaign_id__in=campaigns_qs.values_list(\"id\", flat=True)\n            )\n            .values_list(\"researchcenter_id\", flat=True)\n            .distinct()\n        )\n        research_centers_qs = ResearchCenter.objects.filter(id__in=rc_ids)\n\n        # Topics linked to those projects\n        topics_from_projects_qs = Topic.objects.filter(\n            id__in=ProjectTopic.objects.filter(project_id__in=project_ids)\n            .values_list(\"topic_id\", flat=True)\n            .distinct()\n        )\n        # Merge with the initial two topics (won't duplicate)\n        topics_final_qs = Topic.objects.filter(\n            id__in=set(topics_from_projects_qs.values_list(\"id\", flat=True)) | topic_ids\n        )\n\n        # ProjectTopic rows for selected Topic+Project pairs (needed to preserve M2M)\n        project_topics_final_qs = ProjectTopic.objects.filter(\n            topic_id__in=topics_final_qs.values_list(\"id\", flat=True),\n            project_id__in=project_ids,\n        )\n\n        # transcriptions + users (anonymize users in-memory)\n        trans_qs = Transcription.objects.filter(asset_id__in=asset_ids)\n        User = get_user_model()\n\n        # Collect users from both author and reviewer fields, dropping Nones\n        author_ids = set(trans_qs.values_list(\"user_id\", flat=True))\n        reviewer_ids = set(trans_qs.values_list(\"reviewed_by_id\", flat=True))\n        user_ids = {uid for uid in (author_ids | reviewer_ids) if uid is not None}\n\n        users_qs = User.objects.filter(id__in=user_ids)\n\n        # Build anonymized user fixtures explicitly (no M2M)\n        user_app_label = User._meta.app_label\n        user_model_name = User._meta.model_name\n        anonymized_user_fixtures = []\n        for u in users_qs:\n            anonymized_user_fixtures.append(\n                {\n                    \"model\": f\"{user_app_label}.{user_model_name}\",\n                    \"pk\": int(u.pk) if u.pk is not None else None,\n                    \"fields\": {\n                        User.USERNAME_FIELD: f\"Anonymized {uuid.uuid4()}\",\n                        \"email\": f\"anon-{uuid.uuid4()}@example.com\",\n                        \"password\": \"!\",\n                        \"is_active\": False if hasattr(u, \"is_active\") else False,\n                        \"is_staff\": False if hasattr(u, \"is_staff\") else False,\n                        \"is_superuser\": False if hasattr(u, \"is_superuser\") else False,\n                        **({\"first_name\": \"\"} if hasattr(u, \"first_name\") else {}),\n                        **({\"last_name\": \"\"} if hasattr(u, \"last_name\") else {}),\n                        # no groups / permissions\n                    },\n                }\n            )\n\n        # build test users\n        test_user_count = int(o[\"test_users\"])\n        test_prefix = o[\"test_user_prefix\"]\n        test_pw_hash = make_password(o[\"test_user_password\"])\n\n        # ensure test user PKs cannot collide with anonymized users\n        max_existing_pk = 0\n        if anonymized_user_fixtures:\n            max_existing_pk = max(\n                int(obj[\"pk\"])\n                for obj in anonymized_user_fixtures\n                if obj[\"pk\"] is not None\n            )\n        start_test_pk = max_existing_pk + 10_000\n\n        test_user_fixtures = []\n        for i in range(1, test_user_count + 1):\n            uname = f\"{test_prefix}{i:05d}\"\n            test_user_fixtures.append(\n                {\n                    \"model\": f\"{user_app_label}.{user_model_name}\",\n                    \"pk\": start_test_pk\n                    + i,  # explicit PKs to avoid sequence collisions\n                    \"fields\": {\n                        User.USERNAME_FIELD: uname,\n                        \"password\": test_pw_hash,\n                        \"email\": f\"{uname}@example.test\",\n                        \"is_active\": True if hasattr(User, \"is_active\") else True,\n                        \"is_staff\": False if hasattr(User, \"is_staff\") else False,\n                        \"is_superuser\": (\n                            False if hasattr(User, \"is_superuser\") else False\n                        ),\n                        **({\"first_name\": \"\"} if hasattr(User, \"first_name\") else {}),\n                        **({\"last_name\": \"\"} if hasattr(User, \"last_name\") else {}),\n                        # no groups / permissions\n                    },\n                }\n            )\n\n        # Serialize everything into one fixture list\n        fixture_objs = []\n        # Core, ensure FK/M2M targets appear before dependents\n        fixture_objs += _serialize_qs(topics_final_qs.order_by(\"id\"))\n        fixture_objs += _serialize_qs(card_families_qs.order_by(\"id\"))\n        fixture_objs += _serialize_qs(research_centers_qs.order_by(\"id\"))\n        fixture_objs += _serialize_qs(campaigns_qs.order_by(\"id\"))\n        fixture_objs += _serialize_qs(projects_qs.order_by(\"id\"))\n        fixture_objs += _serialize_qs(items_qs.order_by(\"id\"))\n        fixture_objs += _serialize_qs(assets_qs.order_by(\"id\"))\n        # Users must appear before Transcriptions (FK dependency)\n        fixture_objs += anonymized_user_fixtures\n        fixture_objs += test_user_fixtures\n        # Transcriptions\n        fixture_objs += _serialize_qs(trans_qs.order_by(\"id\"))\n        # Through model rows\n        fixture_objs += _serialize_qs(project_topics_final_qs.order_by(\"id\"))\n\n        # Warn if below cap, but we don't need to abort\n        if len(asset_ids) < assets_limit:\n            self.stderr.write(\n                self.style.WARNING(\n                    f\"Collected {len(asset_ids)} assets \"\n                    f\"(cap {assets_limit}). Proceeding.\"\n                )\n            )\n\n        # write file\n        out_path.parent.mkdir(parents=True, exist_ok=True)\n        out_path.write_text(json.dumps(fixture_objs, indent=2), encoding=\"utf-8\")\n        self.stdout.write(\n            self.style.SUCCESS(\n                f\"Wrote fixture with {len(fixture_objs)} objects -> {out_path}\"\n            )\n        )\n\n        # optionally validate by loading into a test DB (migrate + loaddata)\n        if o[\"no_validate\"]:\n            self.stderr.write(\n                self.style.WARNING(\"Fixture NOT validated (--no-validate set).\")\n            )\n        else:\n            call_command(\n                \"prepare_load_test_db\",\n                db_alias=\"default\",\n                db_name=o[\"validate_db_name\"] or None,\n                recreate=bool(o[\"validate_recreate\"]),\n                fixtures=[str(out_path)],\n                drop_after=bool(o[\"validate_drop\"]),\n            )\n"
  },
  {
    "path": "concordia/management/commands/ensure_initial_site_configuration.py",
    "content": "\"\"\"\nEnsure that basic site configuration has been applied.\n\nThis command is intended for automated scenarios: a fresh database should be\nconfigured on first run, but a newly launched container should not make any\nchanges. For convenience with Docker, default values for each argument are\nread from environment variables.\n\nUsage:\n    python manage.py ensure_initial_site_configuration\n    python manage.py ensure_initial_site_configuration \\\n        --admin-username admin --admin-email admin@example.com \\\n        --site-name \"Example\" --site-domain example.com\n\nEnvironment defaults:\n    CONCORDIA_ADMIN_USERNAME -> --admin-username (default: \"admin\")\n    CONCORDIA_ADMIN_EMAIL    -> --admin-email    (default: \"crowd@loc.gov\")\n    HOST_NAME                -> --site-name and --site-domain\n                                (default: \"example.com\")\n\nTasks performed:\n  1. Ensure at least one admin user exists. If missing, create one with an\n     unusable password so the password reset flow must be used.\n  2. Ensure the Sites framework has the intended site name and domain.\n\"\"\"\n\nimport os\nfrom argparse import ArgumentParser\n\nfrom django.contrib.auth.models import User\nfrom django.contrib.sites.models import Site\nfrom django.core.management.base import BaseCommand\nfrom django.db.transaction import atomic\n\n\nclass Command(BaseCommand):\n    help = \"Ensure that core site configuration has been applied\"  # NOQA: A003\n\n    def add_arguments(self, parser: ArgumentParser) -> None:\n        \"\"\"\n        Add command-line arguments with environment-based defaults.\n\n        Notes:\n            The defaults mirror container-friendly env vars so this command can\n            run non-interactively during provisioning.\n        \"\"\"\n        parser.add_argument(\n            \"--admin-username\",\n            default=os.environ.get(\"CONCORDIA_ADMIN_USERNAME\", \"admin\"),\n            help=\"Admin user's username (default=%(default)s)\",\n        )\n        parser.add_argument(\n            \"--admin-email\",\n            default=os.environ.get(\"CONCORDIA_ADMIN_EMAIL\", \"crowd@loc.gov\"),\n            help=\"Admin user's email address (default=%(default)s)\",\n        )\n        parser.add_argument(\n            \"--site-name\",\n            default=os.environ.get(\"HOST_NAME\", \"example.com\"),\n            help=\"Site name (default=%(default)s)\",\n        )\n        parser.add_argument(\n            \"--site-domain\",\n            default=os.environ.get(\"HOST_NAME\", \"example.com\"),\n            help=\"Site domain (default=%(default)s)\",\n        )\n\n    @atomic\n    def handle(\n        self,\n        *,\n        admin_username: str,\n        admin_email: str,\n        site_name: str,\n        site_domain: str,\n        **options,\n    ) -> None:\n        \"\"\"\n        Ensure an admin user and the Site record are in the desired state.\n\n        Behavior:\n            - Get or create a superuser with the provided username and email.\n              If created, set an unusable password.\n            - Update the user's email if it differs.\n            - If the site domain is not the placeholder \"example.com\", update\n              all Site rows to use the provided name and domain.\n\n        Args:\n            admin_username (str): Username for the admin user.\n            admin_email (str): Email for the admin user.\n            site_name (str): Desired Site.name value.\n            site_domain (str): Desired Site.domain value.\n\n        Returns:\n            None\n        \"\"\"\n        user, user_created = User.objects.get_or_create(\n            username=admin_username, defaults={\"email\": admin_email}\n        )\n        user.is_staff = user.is_superuser = True\n\n        if user.email != admin_email:\n            self.stdout.write(\n                f\"Changing {admin_username} email from {user.email} to {admin_email}\"\n            )\n            user.email = admin_email\n\n        if user_created:\n            user.set_unusable_password()\n\n        user.full_clean()\n        user.save()\n\n        if user_created:\n            self.stdout.write(\n                f\"Created superuser {admin_username} account for {admin_email}.\"\n                \" Use the password reset form to change the unusable password.\"\n            )\n\n        if site_domain != \"example.com\":\n            updated = Site.objects.update(name=site_name, domain=site_domain)\n            if updated:\n                self.stdout.write(\n                    f\"Configured site with name {site_name} and domain {site_domain}\"\n                )\n"
  },
  {
    "path": "concordia/management/commands/import_site_reports.py",
    "content": "\"\"\"\nImport CSV Site Report data into the database.\n\nThis command reads a CSV file, maps each row to `SiteReport` fields and\ncreates `SiteReport` rows. If a \"campaign\" column is present and non-empty,\nits value is treated as a `Campaign.id` and looked up before creation.\n\nUsage:\n    python manage.py import_site_reports --csv-file path/to/file.csv\n\nArguments:\n    --csv-file  Path to the CSV file. Defaults to \"site_reports.csv\".\n\nCSV expectations:\n    - The first row is a header. Field names must match `SiteReport` fields,\n      except:\n        * \"time\" is combined with \"created_on\" to form a single datetime.\n    - Empty strings are ignored and not included in the create kwargs.\n    - \"created_on\" and \"time\" are combined then parsed with the format:\n        %m/%d/%Y %I:%M %p %Z\n      Example: \"04/30/2024 09:15 AM UTC\"\n    - \"campaign\" is optional. If present and non-empty, it must be a valid\n      `Campaign.id`.\n\nNotes:\n    - Rows are created one by one. This is intentional to match current\n      behavior.\n\"\"\"\n\nimport csv\nfrom argparse import ArgumentParser\nfrom datetime import datetime\n\nfrom django.core.management.base import BaseCommand\n\nfrom concordia.models import Campaign, SiteReport\n\n\nclass Command(BaseCommand):\n    help = \"Import CSV Site Report data\"  # NOQA: A003\n\n    def add_arguments(self, parser: ArgumentParser) -> None:\n        \"\"\"\n        Add the --csv-file argument with a sensible default.\n\n        Args:\n            parser: The Django command argument parser.\n        \"\"\"\n        parser.add_argument(\n            \"--csv-file\",\n            default=\"site_reports.csv\",\n            help=\"Path to CSV file to import (default=%(default)s)\",\n        )\n\n    def handle(self, *, csv_file: str, **options) -> None:\n        \"\"\"\n        Read the CSV, normalize fields and create `SiteReport` rows.\n\n        Behavior:\n            - Reads the header row to build a name->value mapping for each row.\n            - Drops keys with empty-string values.\n            - Concatenates \"created_on\" and \"time\" to a single string,\n              parses with `%m/%d/%Y %I:%M %p %Z`, assigns to \"created_on\".\n            - Removes the \"time\" key after parsing.\n            - If \"campaign\" is present, replaces it with the model instance\n              using `Campaign.objects.get(id=...)`.\n            - Creates a `SiteReport` with the remaining data.\n\n        Args:\n            csv_file: Path to the CSV file to import.\n\n        Returns:\n            None\n        \"\"\"\n        with open(csv_file, \"r\") as csv_file:\n            reader = csv.reader(csv_file, delimiter=\",\")\n            header = reader.__next__()\n            for row in reader:\n                site_report_data = dict(zip(header, row, strict=True))\n                site_report = {}\n\n                for key in site_report_data:\n                    if site_report_data[key] != \"\":\n                        site_report[key] = site_report_data[key]\n\n                site_report[\"created_on\"] = \"%s %s\" % (\n                    site_report[\"created_on\"],\n                    site_report[\"time\"],\n                )\n\n                site_report[\"created_on\"] = datetime.strptime(\n                    site_report[\"created_on\"], \"%m/%d/%Y %I:%M %p %Z\"\n                )\n\n                site_report.pop(\"time\")\n\n                if site_report.get(\"campaign\"):\n                    campaign = Campaign.objects.get(id=site_report[\"campaign\"])\n                    site_report[\"campaign\"] = campaign\n\n                SiteReport.objects.create(**site_report)\n"
  },
  {
    "path": "concordia/management/commands/prepare_load_test_db.py",
    "content": "# ruff: noqa: ERA001 A003\n# bandit:skip-file\n\nfrom contextlib import contextmanager\nfrom pathlib import Path\n\nfrom django.conf import settings\nfrom django.core.management import BaseCommand, CommandError, call_command\nfrom django.db import connections\n\n\ndef _dbinfo(alias: str):\n    cfg = settings.DATABASES[alias]\n    return {\n        \"engine\": cfg[\"ENGINE\"],\n        \"name\": cfg[\"NAME\"],\n        \"user\": cfg.get(\"USER\"),\n        \"password\": cfg.get(\"PASSWORD\"),\n        \"host\": cfg.get(\"HOST\"),\n        \"port\": cfg.get(\"PORT\"),\n    }\n\n\ndef _require_postgres(engine: str):\n    if \"postgresql\" not in engine:\n        raise CommandError(f\"PostgreSQL only. ENGINE={engine!r}.\")\n\n\ndef _maintenance_dsn(info: dict) -> str:\n    parts = [\"dbname=postgres\"]\n    if info.get(\"user\"):\n        parts.append(f\"user={info['user']}\")\n    if info.get(\"password\"):\n        parts.append(f\"password={info['password']}\")\n    if info.get(\"host\"):\n        parts.append(f\"host={info['host']}\")\n    if info.get(\"port\"):\n        parts.append(f\"port={info['port']}\")\n    return \" \".join(parts)\n\n\ndef _pg_connect(dsn: str):\n    \"\"\"\n    Return a live psycopg connection (supports psycopg3 or psycopg2).\n    \"\"\"\n    try:\n        import psycopg  # psycopg3\n\n        return psycopg.connect(dsn)\n    except Exception:\n        try:\n            import psycopg2  # type: ignore\n\n            return psycopg2.connect(dsn)  # type: ignore\n        except Exception as e2:\n            raise CommandError(\n                \"Could not import psycopg (v3) or psycopg2. \"\n                \"Install one of them to manage databases.\"\n            ) from e2\n\n\ndef _db_exists(cur, name: str) -> bool:\n    cur.execute(\"SELECT 1 FROM pg_database WHERE datname = %s\", (name,))\n    return cur.fetchone() is not None\n\n\ndef _create_db_if_needed(src_info: dict, name: str, *, recreate: bool = False):\n    dsn = _maintenance_dsn(src_info)\n    conn = _pg_connect(dsn)\n    try:\n        conn.autocommit = True\n        with conn.cursor() as cur:\n            if _db_exists(cur, name):\n                if recreate:\n                    cur.execute(\n                        \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity \"\n                        \"WHERE datname = %s AND pid <> pg_backend_pid()\",\n                        (name,),\n                    )\n                    cur.execute(f'DROP DATABASE \"{name}\"')\n                else:\n                    return\n            cur.execute(f'CREATE DATABASE \"{name}\"')\n    finally:\n        conn.close()\n\n\ndef _drop_db(src_info: dict, name: str):\n    dsn = _maintenance_dsn(src_info)\n    conn = _pg_connect(dsn)\n    try:\n        conn.autocommit = True\n        with conn.cursor() as cur:\n            cur.execute(\n                \"SELECT pg_terminate_backend(pid) FROM pg_stat_activity \"\n                \"WHERE datname = %s AND pid <> pg_backend_pid()\",\n                (name,),\n            )\n            cur.execute(f'DROP DATABASE IF EXISTS \"{name}\"')\n    finally:\n        conn.close()\n\n\ndef _switch_process_db(alias: str, new_name: str):\n    settings.DATABASES[alias][\"NAME\"] = new_name\n    connections.close_all()\n\n\n@contextmanager\ndef _suppress_all_django_signals(active: bool):\n    \"\"\"\n    Monkey-patch Django's Signal dispatch to no-op while active is True.\n    This suppresses all signals (model and custom) during fixture loading.\n    \"\"\"\n    if not active:\n        yield\n        return\n\n    from django.dispatch import dispatcher as _dispatcher\n\n    orig_send = _dispatcher.Signal.send\n    orig_send_robust = _dispatcher.Signal.send_robust\n\n    def _no_send(self, sender, **named):\n        return []\n\n    def _no_send_robust(self, sender, **named):\n        return []\n\n    _dispatcher.Signal.send = _no_send\n    _dispatcher.Signal.send_robust = _no_send_robust\n    try:\n        yield\n    finally:\n        _dispatcher.Signal.send = orig_send\n        _dispatcher.Signal.send_robust = orig_send_robust\n\n\nclass Command(BaseCommand):\n    help = (\n        \"Create (or reuse) a PostgreSQL database, switch the process to it, run \"\n        \"migrate, and load one or more fixtures. Optionally drop the DB afterward.\"\n    )\n\n    def add_arguments(self, p):\n        p.add_argument(\n            \"--db-alias\", default=\"default\", help=\"DATABASES alias (default: default).\"\n        )\n        p.add_argument(\n            \"--db-name\",\n            default=None,\n            help=(\n                \"Target DB name (default: <alias.NAME>_lt). If it exists and \"\n                \"--recreate is not set, it will be reused.\"\n            ),\n        )\n        p.add_argument(\n            \"--recreate\",\n            action=\"store_true\",\n            help=\"Drop existing DB first, then create.\",\n        )\n        p.add_argument(\n            \"--fixtures\",\n            nargs=\"+\",\n            default=[\"loadtest_fixture.json\"],\n            help=(\"Fixture file(s) to load. \" \"Defaults to loadtest_fixture.json.\"),\n        )\n        p.add_argument(\n            \"--drop-after\",\n            action=\"store_true\",\n            help=\"Drop the DB after loading (validation-only).\",\n        )\n        p.add_argument(\n            \"--enable-signals\",\n            action=\"store_true\",\n            help=\"Do NOT suppress Django signals during loaddata (default suppresses).\",\n        )\n\n    def handle(self, *args, **o):\n        alias = o[\"db_alias\"]\n        info = _dbinfo(alias)\n        _require_postgres(info[\"engine\"])\n\n        base_name = info[\"name\"]\n        if not base_name:\n            raise CommandError(f\"DATABASES[{alias!r}]['NAME'] is empty.\")\n\n        db_name = o[\"db_name\"] or f\"{base_name}_lt\"\n\n        fixture_paths = [Path(f).resolve() for f in o[\"fixtures\"]]\n        missing = [str(p) for p in fixture_paths if not p.exists()]\n        if missing:\n            raise CommandError(f\"Fixture(s) not found: {', '.join(missing)}\")\n\n        self.stdout.write(self.style.NOTICE(f\"Preparing DB {db_name!r}\"))\n        _create_db_if_needed(info, db_name, recreate=bool(o[\"recreate\"]))\n\n        self.stdout.write(self.style.SUCCESS(f\"Switching process to DB {db_name!r}\"))\n        _switch_process_db(alias, db_name)\n\n        self.stdout.write(self.style.NOTICE(\"Applying migrations...\"))\n        call_command(\"migrate\", database=alias, interactive=False, run_syncdb=True)\n\n        # Suppress signals by default; --enable-signals turns suppression off\n        suppress = not bool(o.get(\"enable_signals\"))\n        if suppress:\n            self.stdout.write(\n                self.style.NOTICE(\"Suppressing Django signals during loaddata...\")\n            )\n        else:\n            self.stdout.write(self.style.NOTICE(\"Signals ENABLED during loaddata.\"))\n\n        self.stdout.write(self.style.NOTICE(\"Loading fixtures...\"))\n        with _suppress_all_django_signals(active=suppress):\n            for fp in fixture_paths:\n                call_command(\"loaddata\", str(fp), database=alias)\n\n        if o[\"drop_after\"]:\n            self.stdout.write(self.style.NOTICE(f\"Dropping DB {db_name!r}\"))\n            # switch away to avoid dropping the active DB\n            _switch_process_db(alias, base_name)\n            _drop_db(info, db_name)\n            self.stdout.write(self.style.SUCCESS(f\"Dropped {db_name!r}\"))\n        else:\n            self.stdout.write(self.style.SUCCESS(f\"Loaded fixtures into {db_name!r}\"))\n"
  },
  {
    "path": "concordia/management/commands/print_frontend_test_urls.py",
    "content": "\"\"\"\nPrint a list of URLs (derived from local database content) suitable for\nfront-end testing.\n\nUsage:\n    python manage.py print_frontend_test_urls \\\n        --base-url \"http://localhost:8000/\"\n\nNotes:\n    - Always prints a core set of static paths.\n    - If a visible Asset exists it also prints detail pages for that\n      asset, its item, project and campaign.\n\"\"\"\n\nimport argparse\nfrom urllib.parse import urljoin\n\nfrom django.core.management.base import BaseCommand\nfrom django.urls import reverse\n\nfrom concordia.models import Asset\n\n\nclass Command(BaseCommand):\n    \"\"\"Management command to emit front-end test URLs.\"\"\"\n\n    help = \"Print URLs for front-end testing\"  # NOQA: A003\n\n    def add_arguments(self, parser: \"argparse.ArgumentParser\") -> None:\n        \"\"\"Register command-line arguments.\"\"\"\n        parser.add_argument(\n            \"--base-url\",\n            default=\"http://localhost:8000/\",\n            help=\"Change the base URL for all generated URLs from %(default)s\",\n        )\n\n    def handle(self, *, base_url: str, **options) -> None:\n        \"\"\"Generate and print URLs, prefixed by ``base_url``.\"\"\"\n        paths = [\n            reverse(\"homepage\"),\n            reverse(\"about\"),\n            reverse(\"contact\"),\n            # Help pages\n            reverse(\"help-center\"),\n            reverse(\"welcome-guide\"),\n            reverse(\"transcription-basic-rules\"),\n            reverse(\"how-to-review\"),\n            reverse(\"how-to-tag\"),\n            reverse(\"for-educators\"),\n            reverse(\"questions\"),\n            # Account pages\n            reverse(\"registration_register\"),\n            reverse(\"registration_login\"),\n            reverse(\"password_reset\"),\n            reverse(\"login\"),\n            reverse(\"transcriptions:campaign-list\"),\n            reverse(\"campaign-topic-list\"),\n        ]\n\n        # Database content\n        # First find an asset which is actually visible:\n        asset_qs = Asset.objects.filter(\n            published=True,\n            item__published=True,\n            item__project__published=True,\n            item__project__campaign__published=True,\n        )\n        if asset_qs.exists():\n            asset = asset_qs.first()\n            item = asset.item\n            project = item.project\n            campaign = project.campaign\n\n            paths.extend(\n                [\n                    reverse(\n                        \"transcriptions:asset-detail\",\n                        kwargs={\n                            \"campaign_slug\": campaign.slug,\n                            \"project_slug\": project.slug,\n                            \"item_id\": item.item_id,\n                            \"slug\": asset.slug,\n                        },\n                    ),\n                    reverse(\n                        \"transcriptions:item-detail\",\n                        kwargs={\n                            \"campaign_slug\": campaign.slug,\n                            \"project_slug\": project.slug,\n                            \"item_id\": item.item_id,\n                        },\n                    ),\n                    reverse(\n                        \"transcriptions:project-detail\",\n                        kwargs={\"campaign_slug\": campaign.slug, \"slug\": project.slug},\n                    ),\n                    reverse(\n                        \"transcriptions:campaign-detail\", kwargs={\"slug\": campaign.slug}\n                    ),\n                ]\n            )\n        for path in sorted(paths):\n            print(urljoin(base_url, path))\n"
  },
  {
    "path": "concordia/middleware.py",
    "content": "from maintenance_mode.http import get_maintenance_response\nfrom maintenance_mode.middleware import (\n    MaintenanceModeMiddleware as BaseMaintenanceModeMiddleware,\n)\n\nfrom .maintenance import need_maintenance_response\n\n\nclass MaintenanceModeMiddleware(BaseMaintenanceModeMiddleware):\n    def process_request(self, request):\n        if need_maintenance_response(request):\n            return get_maintenance_response(request)\n        return None\n"
  },
  {
    "path": "concordia/migrations/0001_initial.py",
    "content": "# Generated by Django 2.0.4 on 2018-04-17 18:59\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    initial = True\n\n    dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"UserProfile\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"myfile\", models.FileField(upload_to=\"profile_pics/\")),\n                (\n                    \"user\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0001_squashed_0040_remove_campaign_is_active.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-03 20:04\n\nimport django.contrib.postgres.fields.jsonb\nimport django.core.validators\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\ndef create_groups(apps, schema_editor):\n    Group = apps.get_model(\"auth\", \"Group\")\n    Group.objects.get_or_create(name=settings.COMMUNITY_MANAGER_GROUP_NAME)\n    Group.objects.get_or_create(name=settings.NEWSLETTER_GROUP_NAME)\n\n\nclass Migration(migrations.Migration):\n    replaces = [\n        (\"concordia\", \"0001_initial\"),\n        (\"concordia\", \"0002_auto_20180511_1722\"),\n        (\"concordia\", \"0003_campaign_is_active\"),\n        (\"concordia\", \"0004_auto_20180712_1857\"),\n        (\"concordia\", \"0005_auto_20180713_1753\"),\n        (\"concordia\", \"0006_auto_20180713_1759\"),\n        (\"concordia\", \"0007_pageinuse\"),\n        (\"concordia\", \"0008_auto_20180727_2021\"),\n        (\"concordia\", \"0009_auto_20180730_2017\"),\n        (\"concordia\", \"0010_auto_20180730_2032\"),\n        (\"concordia\", \"0011_auto_20180730_2046\"),\n        (\"concordia\", \"0007_campaign_s3_storage\"),\n        (\"concordia\", \"0012_merge_20180806_1254\"),\n        (\"concordia\", \"0013_auto_20180826_0928\"),\n        (\"concordia\", \"0014_auto_20180904_1758\"),\n        (\"concordia\", \"0015_auto_20180905_1756\"),\n        (\"concordia\", \"0016_auto_20180906_1720\"),\n        (\"concordia\", \"0017_auto_20180912_0229\"),\n        (\"concordia\", \"0018_auto_20180917_1654\"),\n        (\"concordia\", \"0019_auto_20180920_1503\"),\n        (\"concordia\", \"0020_auto_20180922_0139\"),\n        (\"concordia\", \"0021_auto_20180922_0202\"),\n        (\"concordia\", \"0022_auto_20180924_1511\"),\n        (\"concordia\", \"0023_auto_20180924_1511\"),\n        (\"concordia\", \"0024_auto_20180924_1529\"),\n        (\"concordia\", \"0025_auto_20180924_2022\"),\n        (\"concordia\", \"0026_auto_20180925_2000\"),\n        (\"concordia\", \"0027_auto_20180926_1705\"),\n        (\"concordia\", \"0026_creategroups\"),\n        (\"concordia\", \"0028_merge_20180927_1529\"),\n        (\"concordia\", \"0029_remove_userprofile_myfile\"),\n        (\"concordia\", \"0029_auto_20180928_1437\"),\n        (\"concordia\", \"0030_merge_20181002_1350\"),\n        (\"concordia\", \"0031_auto_20181002_1900\"),\n        (\"concordia\", \"0032_auto_20181002_1901\"),\n        (\"concordia\", \"0033_auto_20181002_1909\"),\n        (\"concordia\", \"0034_remove_transcription_parent\"),\n        (\"concordia\", \"0035_auto_20181002_1914\"),\n        (\"concordia\", \"0036_remove_item_slug\"),\n        (\"concordia\", \"0037_auto_20181002_1939\"),\n        (\"concordia\", \"0030_merge_20180928_1740\"),\n        (\"concordia\", \"0031_merge_20181002_1846\"),\n        (\"concordia\", \"0038_merge_20181002_1949\"),\n        (\"concordia\", \"0039_remove_campaign_s3_storage\"),\n        (\"concordia\", \"0040_remove_campaign_is_active\"),\n    ]\n\n    initial = True\n\n    dependencies = [\n        (\"auth\", \"0001_initial\"),\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"UserProfile\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"user\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"Asset\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(max_length=100)),\n                (\"slug\", models.SlugField(max_length=100)),\n                (\"description\", models.TextField(blank=True)),\n                (\"media_url\", models.URLField(max_length=255)),\n                (\n                    \"media_type\",\n                    models.CharField(\n                        choices=[(\"IMG\", \"Image\"), (\"AUD\", \"Audio\"), (\"VID\", \"Video\")],\n                        db_index=True,\n                        max_length=4,\n                    ),\n                ),\n                (\"sequence\", models.PositiveIntegerField(default=1)),\n                (\n                    \"metadata\",\n                    django.contrib.postgres.fields.jsonb.JSONField(default=dict),\n                ),\n                (\n                    \"status\",\n                    models.CharField(\n                        choices=[\n                            (\"0\", \"0%\"),\n                            (\"25\", \"25%\"),\n                            (\"50\", \"50%\"),\n                            (\"75\", \"75%\"),\n                            (\"100\", \"100%\"),\n                            (\"DONE\", \"Complete\"),\n                        ],\n                        default=\"0\",\n                        max_length=4,\n                    ),\n                ),\n            ],\n            options={\"ordering\": [\"title\", \"sequence\"]},\n        ),\n        migrations.CreateModel(\n            name=\"Campaign\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(max_length=50)),\n                (\"slug\", models.SlugField(unique=True)),\n                (\"description\", models.TextField(blank=True)),\n                (\"start_date\", models.DateTimeField(blank=True, null=True)),\n                (\"end_date\", models.DateTimeField(blank=True, null=True)),\n                (\n                    \"metadata\",\n                    django.contrib.postgres.fields.jsonb.JSONField(default=dict),\n                ),\n                (\n                    \"status\",\n                    models.CharField(\n                        choices=[\n                            (\"0\", \"0%\"),\n                            (\"25\", \"25%\"),\n                            (\"50\", \"50%\"),\n                            (\"75\", \"75%\"),\n                            (\"100\", \"100%\"),\n                            (\"DONE\", \"Complete\"),\n                        ],\n                        default=\"0\",\n                        max_length=4,\n                    ),\n                ),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"Project\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(max_length=50)),\n                (\"slug\", models.SlugField()),\n                (\"category\", models.CharField(blank=True, max_length=12)),\n                (\n                    \"metadata\",\n                    django.contrib.postgres.fields.jsonb.JSONField(default=dict),\n                ),\n                (\n                    \"status\",\n                    models.CharField(\n                        choices=[\n                            (\"Edit\", \"Open for Edit\"),\n                            (\"Submitted\", \"Submitted for Review\"),\n                            (\"Completed\", \"Transcription Completed\"),\n                            (\"Inactive\", \"Inactive\"),\n                            (\"Active\", \"Active\"),\n                        ],\n                        default=\"Edit\",\n                        max_length=10,\n                    ),\n                ),\n                (\n                    \"campaign\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Campaign\",\n                    ),\n                ),\n                (\"is_publish\", models.BooleanField(default=False)),\n            ],\n            options={\"ordering\": [\"title\"]},\n        ),\n        migrations.CreateModel(\n            name=\"Tag\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"value\", models.CharField(max_length=50, unique=True)),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"Transcription\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"user_id\", models.PositiveIntegerField(db_index=True)),\n                (\"text\", models.TextField(blank=True)),\n                (\n                    \"status\",\n                    models.CharField(\n                        choices=[\n                            (\"Edit\", \"Open for Edit\"),\n                            (\"Submitted\", \"Submitted for Review\"),\n                            (\"Completed\", \"Transcription Completed\"),\n                            (\"Inactive\", \"Inactive\"),\n                            (\"Active\", \"Active\"),\n                        ],\n                        default=\"Edit\",\n                        max_length=10,\n                    ),\n                ),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True)),\n                (\n                    \"asset\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Asset\",\n                    ),\n                ),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"UserAssetTagCollection\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"user_id\", models.PositiveIntegerField(db_index=True)),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True)),\n                (\n                    \"asset\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Asset\",\n                    ),\n                ),\n                (\"tags\", models.ManyToManyField(blank=True, to=\"concordia.Tag\")),\n            ],\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"project\", unique_together={(\"slug\", \"campaign\")}\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"status\",\n            field=models.CharField(\n                choices=[\n                    (\"Edit\", \"Open for Edit\"),\n                    (\"Submitted\", \"Submitted for Review\"),\n                    (\"Completed\", \"Transcription Completed\"),\n                ],\n                default=\"Edit\",\n                max_length=4,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"status\",\n            field=models.CharField(\n                choices=[\n                    (\"Edit\", \"Open for Edit\"),\n                    (\"Submitted\", \"Submitted for Review\"),\n                    (\"Completed\", \"Transcription Completed\"),\n                ],\n                default=\"Edit\",\n                max_length=10,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"status\",\n            field=models.CharField(\n                choices=[\n                    (\"Edit\", \"Open for Edit\"),\n                    (\"Submitted\", \"Submitted for Review\"),\n                    (\"Completed\", \"Transcription Completed\"),\n                    (\"Inactive\", \"Inactive\"),\n                    (\"Active\", \"Active\"),\n                ],\n                default=\"Edit\",\n                max_length=10,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"status\",\n            field=models.CharField(\n                choices=[\n                    (\"Edit\", \"Open for Edit\"),\n                    (\"Submitted\", \"Submitted for Review\"),\n                    (\"Completed\", \"Transcription Completed\"),\n                ],\n                default=\"Edit\",\n                max_length=4,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"status\",\n            field=models.CharField(\n                choices=[\n                    (\"Edit\", \"Open for Edit\"),\n                    (\"Submitted\", \"Submitted for Review\"),\n                    (\"Completed\", \"Transcription Completed\"),\n                ],\n                default=\"Edit\",\n                max_length=10,\n            ),\n        ),\n        migrations.CreateModel(\n            name=\"PageInUse\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"page_url\", models.CharField(max_length=256)),\n                (\"created_on\", models.DateTimeField(editable=False)),\n                (\"updated_on\", models.DateTimeField()),\n                (\n                    \"user\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.DO_NOTHING,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"is_publish\",\n            field=models.BooleanField(default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"status\",\n            field=models.CharField(\n                choices=[\n                    (\"Edit\", \"Open for Edit\"),\n                    (\"Submitted\", \"Submitted for Review\"),\n                    (\"Completed\", \"Transcription Completed\"),\n                    (\"Inactive\", \"Inactive\"),\n                    (\"Active\", \"Active\"),\n                ],\n                default=\"Edit\",\n                max_length=10,\n            ),\n        ),\n        migrations.CreateModel(\n            name=\"Item\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(max_length=100)),\n                (\"description\", models.TextField(blank=True)),\n                (\"item_url\", models.URLField(max_length=255)),\n                (\"item_id\", models.CharField(blank=True, max_length=100)),\n                (\n                    \"metadata\",\n                    django.contrib.postgres.fields.jsonb.JSONField(\n                        blank=True, default=dict, null=True\n                    ),\n                ),\n                (\n                    \"thumbnail_url\",\n                    models.URLField(blank=True, max_length=255, null=True),\n                ),\n                (\n                    \"status\",\n                    models.CharField(\n                        choices=[\n                            (\"Edit\", \"Open for Edit\"),\n                            (\"Submitted\", \"Submitted for Review\"),\n                            (\"Completed\", \"Transcription Completed\"),\n                            (\"Inactive\", \"Inactive\"),\n                            (\"Active\", \"Active\"),\n                        ],\n                        default=\"Edit\",\n                        max_length=10,\n                    ),\n                ),\n                (\"is_publish\", models.BooleanField(default=False)),\n                (\n                    \"project\",\n                    models.ForeignKey(\n                        blank=True,\n                        null=True,\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Project\",\n                    ),\n                ),\n            ],\n            options={\"ordering\": [\"item_id\"]},\n        ),\n        migrations.AlterModelOptions(\n            name=\"asset\", options={\"ordering\": [\"item\", \"sequence\"]}\n        ),\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"item\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"concordia.Item\",\n            ),\n        ),\n        migrations.AlterModelOptions(\n            name=\"asset\", options={\"ordering\": [\"title\", \"sequence\"]}\n        ),\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"download_url\",\n            field=models.CharField(blank=True, max_length=255, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"resource_id\",\n            field=models.CharField(blank=True, max_length=100, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"slug\",\n            field=models.SlugField(max_length=500, unique=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\", name=\"title\", field=models.CharField(max_length=500)\n        ),\n        migrations.AlterField(\n            model_name=\"project\", name=\"slug\", field=models.SlugField(max_length=500)\n        ),\n        migrations.AlterField(\n            model_name=\"project\", name=\"title\", field=models.CharField(max_length=500)\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"slug\",\n            field=models.SlugField(max_length=80, unique=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\", name=\"title\", field=models.CharField(max_length=80)\n        ),\n        migrations.AlterField(\n            model_name=\"project\", name=\"slug\", field=models.SlugField(max_length=80)\n        ),\n        migrations.AlterField(\n            model_name=\"project\", name=\"title\", field=models.CharField(max_length=80)\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"metadata\",\n            field=django.contrib.postgres.fields.jsonb.JSONField(\n                blank=True, default=dict, null=True\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"metadata\",\n            field=django.contrib.postgres.fields.jsonb.JSONField(\n                blank=True, default=dict, null=True\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"metadata\",\n            field=django.contrib.postgres.fields.jsonb.JSONField(\n                blank=True, default=dict, null=True\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"media_url\",\n            field=models.TextField(\n                max_length=255, verbose_name=\"Path component of the URL\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"item\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.Item\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"item\", name=\"title\", field=models.CharField(max_length=300)\n        ),\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"metadata\",\n            field=django.contrib.postgres.fields.jsonb.JSONField(\n                blank=True,\n                default=dict,\n                help_text=\"Raw metadata returned by the remote API\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"tag\", name=\"value\", field=models.CharField(max_length=50)\n        ),\n        migrations.RenameField(\n            model_name=\"campaign\", old_name=\"is_publish\", new_name=\"published\"\n        ),\n        migrations.RenameField(\n            model_name=\"item\", old_name=\"is_publish\", new_name=\"published\"\n        ),\n        migrations.RenameField(\n            model_name=\"project\", old_name=\"is_publish\", new_name=\"published\"\n        ),\n        migrations.RunPython(code=create_groups),\n        migrations.AlterField(\n            model_name=\"tag\",\n            name=\"value\",\n            field=models.CharField(\n                max_length=50,\n                validators=[django.core.validators.RegexValidator(\"^[- _'\\\\w]{1,50}$\")],\n            ),\n        ),\n        migrations.RenameField(\n            model_name=\"transcription\", old_name=\"user_id\", new_name=\"user\"\n        ),\n        migrations.RenameField(\n            model_name=\"userassettagcollection\", old_name=\"user_id\", new_name=\"user\"\n        ),\n        migrations.AlterField(\n            model_name=\"transcription\",\n            name=\"user\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"userassettagcollection\",\n            name=\"user\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"pageinuse\",\n            name=\"user\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL\n            ),\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"item\", unique_together={(\"item_id\", \"project\")}\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"asset\", unique_together={(\"slug\", \"item\")}\n        ),\n        migrations.AlterModelOptions(name=\"item\", options={}),\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"item_id\",\n            field=models.CharField(\n                help_text=\"Unique item ID assigned by the upstream source\",\n                max_length=100,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0002_auto_20181004_1848.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-04 18:48\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0001_squashed_0040_remove_campaign_is_active\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"pageinuse\",\n            name=\"created_on\",\n            field=models.DateTimeField(auto_now_add=True),\n        ),\n        migrations.AlterField(\n            model_name=\"pageinuse\",\n            name=\"page_url\",\n            field=models.URLField(max_length=768),\n        ),\n        migrations.AlterField(\n            model_name=\"pageinuse\",\n            name=\"updated_on\",\n            field=models.DateTimeField(auto_now=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0003_auto_20181004_2103.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-04 21:03\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"concordia\", \"0002_auto_20181004_1848\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"AssetTranscriptionReservation\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True)),\n                (\n                    \"asset\",\n                    models.OneToOneField(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Asset\",\n                    ),\n                ),\n                (\n                    \"user\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n        migrations.RemoveField(model_name=\"pageinuse\", name=\"user\"),\n        migrations.DeleteModel(name=\"PageInUse\"),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0004_auto_20181010_1715.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-10 17:15\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"concordia\", \"0003_auto_20181004_2103\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(model_name=\"asset\", name=\"status\"),\n        migrations.RemoveField(model_name=\"campaign\", name=\"status\"),\n        migrations.RemoveField(model_name=\"item\", name=\"status\"),\n        migrations.RemoveField(model_name=\"project\", name=\"status\"),\n        migrations.RemoveField(model_name=\"transcription\", name=\"status\"),\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"published\",\n            field=models.BooleanField(default=False),\n        ),\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"transcription_status\",\n            field=models.CharField(\n                choices=[\n                    (\"edit\", \"Open for Edit\"),\n                    (\"submitted\", \"Submitted for Review\"),\n                    (\"completed\", \"Completed\"),\n                ],\n                default=\"edit\",\n                editable=False,\n                max_length=10,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"accepted\",\n            field=models.DateTimeField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"rejected\",\n            field=models.DateTimeField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"reviewed_by\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                related_name=\"transcription_reviewers\",\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"submitted\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Timestamp when the creator submitted this for review\",\n                null=True,\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"supersedes\",\n            field=models.ForeignKey(\n                blank=True,\n                help_text=\"A previous transcription record which is replaced by this one\",  # NOQA\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"concordia.Transcription\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0005_campaign_short_description.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-10 19:31\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0004_auto_20181010_1715\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"short_description\",\n            field=models.TextField(blank=True),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0006_campaignresource.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-10 20:19\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0005_campaign_short_description\")]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Resource\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"sequence\", models.PositiveIntegerField(default=1)),\n                (\"title\", models.TextField(max_length=255)),\n                (\"resource_url\", models.URLField()),\n                (\n                    \"campaign\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Campaign\",\n                    ),\n                ),\n            ],\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0007_thumbnail_images.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-10 18:20\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0006_campaignresource\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"thumbnail_image\",\n            field=models.ImageField(\n                blank=True, null=True, default=\"\", upload_to=\"campaign-thumbnails\"\n            ),\n            preserve_default=False,\n        ),\n        migrations.AddField(\n            model_name=\"project\",\n            name=\"thumbnail_image\",\n            field=models.ImageField(\n                blank=True, null=True, default=\"\", upload_to=\"project-thumbnails\"\n            ),\n            preserve_default=False,\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0008_auto_20181015_1711.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-15 17:11\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0007_thumbnail_images\")]\n\n    operations = [\n        migrations.AlterModelOptions(name=\"asset\", options={}),\n        migrations.AlterModelOptions(\n            name=\"resource\", options={\"ordering\": [\"campaign\", \"sequence\"]}\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"resource\", unique_together={(\"campaign\", \"sequence\")}\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0009_project_description.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-17 15:13\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0008_auto_20181015_1711\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"project\", name=\"description\", field=models.TextField(blank=True)\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0010_auto_20181021_1659.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-21 16:59\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0009_project_description\")]\n\n    operations = [\n        migrations.RemoveField(model_name=\"asset\", name=\"resource_id\"),\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"resource_url\",\n            field=models.URLField(blank=True, max_length=255, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0010_auto_20181022_1530.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-22 15:30\n\nfrom django.db import migrations\n\n\ndef handle_items_without_projects(apps, schema_editor):\n    Item = apps.get_model(\"concordia\", \"Item\")\n    Item.objects.filter(project=None).delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0009_project_description\")]\n\n    operations = [migrations.RunPython(handle_items_without_projects)]\n"
  },
  {
    "path": "concordia/migrations/0011_auto_20181022_1532.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-22 15:32\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0010_auto_20181022_1530\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"project\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.Project\"\n            ),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0012_merge_20181022_1554.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-22 15:54\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0010_auto_20181021_1659\"),\n        (\"concordia\", \"0011_auto_20181022_1532\"),\n    ]\n\n    operations = []\n"
  },
  {
    "path": "concordia/migrations/0013_auto_20181031_1305.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-31 17:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0012_merge_20181022_1554\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"resource\", name=\"title\", field=models.CharField(max_length=255)\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0014_auto_20181115_1411.py",
    "content": "# Generated by Django 2.0.9 on 2018-11-15 19:11\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0013_auto_20181031_1305\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"transcription_status\",\n            field=models.CharField(\n                choices=[\n                    (\"not_started\", \"Not Started\"),\n                    (\"in_progress\", \"In Progress\"),\n                    (\"submitted\", \"Submitted for Review\"),\n                    (\"completed\", \"Completed\"),\n                ],\n                default=\"not_started\",\n                editable=False,\n                max_length=20,\n            ),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0015_auto_20181115_1436.py",
    "content": "# Generated by Django 2.0.9 on 2018-11-15 19:36\n\nfrom django.db import migrations\n\nfrom concordia.models import TranscriptionStatus\n\n\ndef split_edit_statuses(apps, schema_editor):\n    Transcription = apps.get_model(\"concordia\", \"Transcription\")\n    Asset = apps.get_model(\"concordia\", \"Asset\")\n\n    Asset.objects.filter(\n        pk__in=Transcription.objects.values(\"asset_id\"), transcription_status=\"edit\"\n    ).update(transcription_status=TranscriptionStatus.IN_PROGRESS)\n    Asset.objects.filter(transcription_status=\"edit\").update(\n        transcription_status=TranscriptionStatus.NOT_STARTED\n    )\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0014_auto_20181115_1411\")]\n\n    operations = [migrations.RunPython(split_edit_statuses)]\n"
  },
  {
    "path": "concordia/migrations/0016_auto_20181115_1803.py",
    "content": "# Generated by Django 2.0.9 on 2018-11-15 23:03\n\nfrom django.db import migrations\n\nfrom concordia.models import TranscriptionStatus\n\n\ndef update_new_statuses(apps, schema_editor):\n    Asset = apps.get_model(\"concordia\", \"Asset\")\n\n    Asset.objects.filter(transcription_status=\"in progress\").update(\n        transcription_status=TranscriptionStatus.IN_PROGRESS\n    )\n    Asset.objects.filter(transcription_status=\"not started\").update(\n        transcription_status=TranscriptionStatus.NOT_STARTED\n    )\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0015_auto_20181115_1436\")]\n\n    operations = [migrations.RunPython(update_new_statuses)]\n"
  },
  {
    "path": "concordia/migrations/0017_change_transcription_supersedes_related_name.py",
    "content": "# Generated by Django 2.0.9 on 2018-11-20 17:07\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0016_auto_20181115_1803\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"transcription\",\n            name=\"supersedes\",\n            field=models.ForeignKey(\n                blank=True,\n                help_text=\"A previous transcription record which is replaced by this one\",  # NOQA\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                related_name=\"superseded_by\",\n                to=\"concordia.Transcription\",\n            ),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0018_auto_20181128_1611.py",
    "content": "# Generated by Django 2.0.9 on 2018-11-28 21:11\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0017_change_transcription_supersedes_related_name\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"display_on_homepage\",\n            field=models.BooleanField(default=True),\n        ),\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"ordering\",\n            field=models.IntegerField(\n                default=0,\n                help_text=\"Sort order override: higher values will be listed first\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0018_simplepage.py",
    "content": "# Generated by Django 2.0.9 on 2018-11-26 21:58\n\nimport django.core.validators\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0017_change_transcription_supersedes_related_name\")]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"SimplePage\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True)),\n                (\n                    \"path\",\n                    models.CharField(\n                        help_text=\"URL path where this page will be accessible from\",\n                        max_length=255,\n                        validators=[django.core.validators.RegexValidator(\"^/.+/$\")],\n                    ),\n                ),\n                (\"title\", models.CharField(max_length=200)),\n                (\"body\", models.TextField()),\n            ],\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0019_merge_20181128_1715.py",
    "content": "# Generated by Django 2.0.9 on 2018-11-28 22:15\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0018_simplepage\"),\n        (\"concordia\", \"0018_auto_20181128_1611\"),\n    ]\n\n    operations = []\n"
  },
  {
    "path": "concordia/migrations/0020_auto_20181128_1718.py",
    "content": "# Generated by Django 2.0.9 on 2018-11-28 22:18\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0019_merge_20181128_1715\")]\n\n    operations = [\n        migrations.RemoveField(model_name=\"campaign\", name=\"end_date\"),\n        migrations.RemoveField(model_name=\"campaign\", name=\"start_date\"),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0021_sitereport.py",
    "content": "# Generated by Django 2.0.9 on 2018-12-04 18:26\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0020_auto_20181128_1718\")]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"SiteReport\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"assets_total\", models.IntegerField()),\n                (\"assets_published\", models.IntegerField()),\n                (\"assets_not_started\", models.IntegerField()),\n                (\"assets_in_progress\", models.IntegerField()),\n                (\"assets_waiting_review\", models.IntegerField()),\n                (\"assets_completed\", models.IntegerField()),\n                (\"assets_unpublished\", models.IntegerField()),\n                (\"items_published\", models.IntegerField()),\n                (\"items_unpublished\", models.IntegerField()),\n                (\"projects_published\", models.IntegerField()),\n                (\"projects_unpublished\", models.IntegerField()),\n                (\"anonymous_transcriptions\", models.IntegerField()),\n                (\"transcriptions_saved\", models.IntegerField()),\n                (\"distinct_tags\", models.IntegerField()),\n                (\"tag_uses\", models.IntegerField()),\n                (\"campaigns_published\", models.IntegerField(blank=True, null=True)),\n                (\"campaigns_unpublished\", models.IntegerField(blank=True, null=True)),\n                (\"users_registered\", models.IntegerField(blank=True, null=True)),\n                (\"users_activated\", models.IntegerField(blank=True, null=True)),\n                (\n                    \"campaign\",\n                    models.ForeignKey(\n                        blank=True,\n                        null=True,\n                        on_delete=django.db.models.deletion.DO_NOTHING,\n                        to=\"concordia.Campaign\",\n                    ),\n                ),\n            ],\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0022_auto_20181211_1310.py",
    "content": "# Generated by Django 2.0.9 on 2018-12-11 18:10\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0021_sitereport\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"ordering\",\n            field=models.IntegerField(\n                default=0,\n                help_text=\"Sort order override: lower values will be listed first\",\n            ),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0023_auto_20190130_1555.py",
    "content": "# Generated by Django 2.1.5 on 2019-01-30 20:55\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0022_auto_20181211_1310\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"transcription_status\",\n            field=models.CharField(\n                choices=[\n                    (\"not_started\", \"Not Started\"),\n                    (\"in_progress\", \"In Progress\"),\n                    (\"submitted\", \"Needs Review\"),\n                    (\"completed\", \"Completed\"),\n                ],\n                default=\"not_started\",\n                editable=False,\n                max_length=20,\n            ),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0024_add_site_report_ordering.py",
    "content": "# Generated by Django 2.2 on 2019-04-19 15:25\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0023_auto_20190130_1555\")]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"sitereport\", options={\"ordering\": (\"created_on\",)}\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0024_auto_20190211_1420.py",
    "content": "# Generated by Django 2.1.7 on 2019-02-11 19:20\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0023_auto_20190130_1555\")]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"sitereport\", options={\"ordering\": (\"created_on\",)}\n        ),\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"difficulty\",\n            field=models.PositiveIntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"slug\",\n            field=models.SlugField(allow_unicode=True, max_length=100),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"slug\",\n            field=models.SlugField(allow_unicode=True, max_length=80, unique=True),\n        ),\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"slug\",\n            field=models.SlugField(allow_unicode=True, max_length=80),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0025_auto_20190329_1705.py",
    "content": "# Generated by Django 2.1.7 on 2019-03-29 21:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0024_auto_20190211_1420\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"difficulty\",\n            field=models.PositiveIntegerField(blank=True, default=0, null=True),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0025_unicode_slugs.py",
    "content": "# Generated by Django 2.2 on 2019-04-19 15:31\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0024_add_site_report_ordering\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"slug\",\n            field=models.SlugField(allow_unicode=True, max_length=100),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"slug\",\n            field=models.SlugField(allow_unicode=True, max_length=80, unique=True),\n        ),\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"slug\",\n            field=models.SlugField(allow_unicode=True, max_length=80),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0026_update_published_field_definition.py",
    "content": "# Generated by Django 2.2 on 2019-04-19 15:41\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0025_unicode_slugs\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0027_merge_20190423_1657.py",
    "content": "# Generated by Django 2.2 on 2019-04-23 20:57\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0025_auto_20190329_1705\"),\n        (\"concordia\", \"0026_update_published_field_definition\"),\n    ]\n\n    operations = []\n"
  },
  {
    "path": "concordia/migrations/0028_asset_year.py",
    "content": "# Generated by Django 2.2 on 2019-04-23 20:57\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0027_merge_20190423_1657\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"year\",\n            field=models.CharField(blank=True, max_length=50),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0029_assettranscriptionreservation_reservation_token.py",
    "content": "# Generated by Django 2.2 on 2019-04-23 15:13\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0028_asset_year\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"assettranscriptionreservation\",\n            name=\"reservation_token\",\n            field=models.CharField(max_length=50, default=\"migration\"),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0030_auto_20190503_1559.py",
    "content": "# Generated by Django 2.2 on 2019-05-03 19:59\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0029_assettranscriptionreservation_reservation_token\")\n    ]\n\n    operations = [\n        migrations.RemoveField(model_name=\"assettranscriptionreservation\", name=\"user\"),\n        migrations.AlterField(\n            model_name=\"assettranscriptionreservation\",\n            name=\"reservation_token\",\n            field=models.CharField(max_length=50),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0031_auto_20190509_1142.py",
    "content": "# Generated by Django 2.2 on 2019-05-09 15:42\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0030_auto_20190503_1559\")]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Topic\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"published\", models.BooleanField(blank=True, default=False)),\n                (\"title\", models.CharField(max_length=255)),\n                (\"slug\", models.SlugField(allow_unicode=True, max_length=80)),\n                (\"description\", models.TextField(blank=True)),\n                (\n                    \"thumbnail_image\",\n                    models.ImageField(\n                        blank=True, null=True, upload_to=\"topic-thumbnails\"\n                    ),\n                ),\n                (\"short_description\", models.TextField(blank=True)),\n            ],\n            options={\"ordering\": [\"title\"]},\n        ),\n        migrations.AlterModelOptions(name=\"resource\", options={}),\n        migrations.RemoveField(model_name=\"project\", name=\"category\"),\n        migrations.AlterField(\n            model_name=\"resource\",\n            name=\"campaign\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"concordia.Campaign\",\n            ),\n        ),\n        migrations.AlterUniqueTogether(name=\"resource\", unique_together=set()),\n        migrations.AddField(\n            model_name=\"project\",\n            name=\"topics\",\n            field=models.ManyToManyField(to=\"concordia.Topic\"),\n        ),\n        migrations.AddField(\n            model_name=\"resource\",\n            name=\"topic\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"concordia.Topic\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0032_topic_ordering.py",
    "content": "# Generated by Django 2.2 on 2019-05-29 18:11\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0031_auto_20190509_1142\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"topic\",\n            name=\"ordering\",\n            field=models.IntegerField(\n                default=0,\n                help_text=\"Sort order override: lower values will be listed first\",\n            ),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0033_simple_content_blocks.py",
    "content": "# Generated by Django 2.2.2 on 2019-06-21 18:39\n\nfrom django.db import migrations, models\n\n\ndef load_legacy_content_blocks(apps, schema_editor):\n    SimpleContentBlock = apps.get_model(\"concordia\", \"SimpleContentBlock\")\n\n    prototype_quicktips = SimpleContentBlock(\n        label=\"prototype_quicktips\", body=PROTOTYPE_QUICKTIPS\n    )\n    prototype_quicktips.full_clean()\n    prototype_quicktips.save()\n\n    classic_quicktips = SimpleContentBlock(\n        label=\"classic_quicktips\", body=CLASSIC_QUICKTIPS\n    )\n    classic_quicktips.full_clean()\n    classic_quicktips.save()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0032_topic_ordering\")]\n\n    operations = [\n        migrations.AlterModelOptions(name=\"topic\", options={}),\n        migrations.CreateModel(\n            name=\"SimpleContentBlock\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True)),\n                (\n                    \"label\",\n                    models.CharField(\n                        help_text=\"Label that is used to refer to this content in the code\",\n                        max_length=255,\n                        unique=True,\n                    ),\n                ),\n                (\"body\", models.TextField()),\n            ],\n        ),\n        migrations.RunPython(code=load_legacy_content_blocks),\n        migrations.RenameField(\n            model_name=\"simplecontentblock\", old_name=\"label\", new_name=\"slug\"\n        ),\n        migrations.AlterField(\n            model_name=\"simplecontentblock\",\n            name=\"slug\",\n            field=models.SlugField(\n                help_text=\"Label that templates use to retrieve this block\",\n                max_length=255,\n                unique=True,\n            ),\n        ),\n    ]\n\n\nPROTOTYPE_QUICKTIPS = \"\"\"\n<h2 class=\"sr-only\">Help</h2>\n<section>\n    <h3>Transcription tips</h3>\n    <ul>\n        <li>Type what you see: Preserve line breaks, original spelling, and punctuation.</li>\n        <li>Use brackets [ ] around deleted, illegible or partially legible text.</li>\n        <li>Use question mark ? for any words or letters you can't identify.</li>\n        <li>Use square brackets and asterisks [ * * ] around text from margins.</li>\n        <li>Include insertions where you would read them in the text.</li>\n        <li>Click “Save” to save work in progress and “Submit” when complete</li>\n    </ul>\n</section>\n<hr />\n<section>\n    <h3>Review tips</h3>\n    <ul>\n        <li>Carefully compare each line of the transcription to the original.</li>\n        <li>Use “Transcription tips” as a guide.</li>\n        <li>Click “Accept” if accurate or “Edit” if page needs correction.</li>\n    </ul>\n</section>\n<hr />\n<section>\n    <h3 class=\"sr-only\">More information</h3>\n    <p>\n    Find more detailed instructions in the <a href=\"/help-center/\" target=\"_blank\">Help Center</a>\n    </p>\n</section>\n<hr />\n<section>\n    <h3>Keyboard Shortcuts</h3>\n    <ul class=\"list-unstyled d-table\">\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>w</kbd> or <kbd>up</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2 w-60\">Scroll the viewport up</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>s</kbd> or <kbd>down</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Scroll the viewport down</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>a</kbd> or <kbd>left</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Scroll the viewport left</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>d</kbd> or <kbd>right</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Scroll the viewport right</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>0</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Fit the entire image to the viewport</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>-</kbd> or <kbd>_</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Zoom the viewport out</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>=</kbd> or <kbd>+</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Zoom the viewport in</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>r</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Rotate the viewport clockwise</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>R</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Rotate the viewport counterclockwise</div>\n        </li>\n        <li class=\"d-table-row\">\n            <div class=\"d-table-cell align-middle border-top py-2\"><kbd>f</kbd></div>\n            <div class=\"d-table-cell align-middle border-top py-2 pl-2\">Flip the viewport horizontally</div>\n        </li>\n    </ul>\n</section>\n\"\"\"\n\nCLASSIC_QUICKTIPS = \"\"\"\n<ul>\n    <li>Transcribe original spelling, punctuation, word order, and any page numbers or catalog marks.</li>\n    <li>Preserve line breaks except when a word breaks over a line or page. Then transcribe it on the line or page where it starts.</li>\n    <li>Use brackets [ ] around deleted, illegible or partially legible text, and square brackets and asterisks around text in margins [ * * ].</li>\n    <li>Transcribe any words or letters you can't identify as [?].</li>\n    <li>Include insertions where you would read them in the text.</li>\n</ul>\nFind more detailed instructions in the <a href=\"/help-center/\">Help Center</a>\n\"\"\"\n"
  },
  {
    "path": "concordia/migrations/0034_auto_20190627_1438.py",
    "content": "# Generated by Django 2.2.2 on 2019-06-27 18:38\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0033_simple_content_blocks\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"anonymous_transcriptions\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"assets_completed\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"assets_in_progress\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"assets_not_started\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"assets_published\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"assets_total\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"assets_unpublished\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"assets_waiting_review\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"distinct_tags\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"items_published\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"items_unpublished\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"projects_published\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"projects_unpublished\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"tag_uses\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"transcriptions_saved\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0035_auto_20190627_1455.py",
    "content": "# Generated by Django 2.2.2 on 2019-06-27 18:55\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0034_auto_20190627_1438\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"created_on\",\n            field=models.DateTimeField(editable=False),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0036_auto_20190703_1203.py",
    "content": "# Generated by Django 2.2.2 on 2019-07-03 16:09\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0035_auto_20190627_1455\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"created_on\",\n            field=models.DateTimeField(auto_now_add=True),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0037_carouselslide.py",
    "content": "# Generated by Django 2.2.3 on 2019-07-31 16:29\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0036_auto_20190703_1203\")]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"CarouselSlide\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True)),\n                (\n                    \"ordering\",\n                    models.IntegerField(\n                        default=0,\n                        help_text=\"Sort order: lower values will be listed first\",\n                    ),\n                ),\n                (\"published\", models.BooleanField(blank=True, default=False)),\n                (\n                    \"overlay_position\",\n                    models.CharField(\n                        choices=[(\"left\", \"Left\"), (\"right\", \"Right\")], max_length=5\n                    ),\n                ),\n                (\"headline\", models.CharField(max_length=255)),\n                (\"body\", models.TextField(blank=True)),\n                (\"image_alt_text\", models.TextField(blank=True)),\n                (\n                    \"carousel_image\",\n                    models.ImageField(\n                        blank=True, null=True, upload_to=\"carousel-slides\"\n                    ),\n                ),\n                (\"lets_go_url\", models.CharField(max_length=255)),\n            ],\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0038_sitereport_topic.py",
    "content": "# Generated by Django 2.2.3 on 2019-07-31 22:09\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"concordia\", \"0037_carouselslide\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"sitereport\",\n            name=\"topic\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.DO_NOTHING,\n                to=\"concordia.Topic\",\n            ),\n        )\n    ]\n"
  },
  {
    "path": "concordia/migrations/0039_auto_20200129_1536.py",
    "content": "# Generated by Django 2.2.7 on 2020-01-29 20:36\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0038_sitereport_topic\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"title\",\n            field=models.CharField(max_length=500),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"campaign\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                to=\"concordia.Campaign\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"topic\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                to=\"concordia.Topic\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0040_auto_20200130_1756.py",
    "content": "# Generated by Django 2.2.7 on 2020-01-30 22:56\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0039_auto_20200129_1536\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"assettranscriptionreservation\",\n            name=\"tombstoned\",\n            field=models.BooleanField(blank=True, default=False, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"assettranscriptionreservation\",\n            name=\"asset\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.Asset\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0041_auto_20200203_1351.py",
    "content": "# Generated by Django 2.2.7 on 2020-02-03 18:51\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0040_auto_20200130_1756\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"title\",\n            field=models.CharField(max_length=600),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0042_auto_20200316_1623.py",
    "content": "# Generated by Django 2.2.10 on 2020-03-16 20:23\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0041_auto_20200203_1351\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"unlisted\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AddField(\n            model_name=\"topic\",\n            name=\"unlisted\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0043_auto_20200323_1729.py",
    "content": "# Generated by Django 2.2.11 on 2020-03-23 21:29\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0042_auto_20200316_1623\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"unlisted\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"topic\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"topic\",\n            name=\"unlisted\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0044_auto_20200323_1827.py",
    "content": "# Generated by Django 2.2.11 on 2020-03-23 22:27\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0043_auto_20200323_1729\"),\n    ]\n\n    operations = [\n        migrations.AddIndex(\n            model_name=\"asset\",\n            index=models.Index(\n                fields=[\"id\", \"published\", \"transcription_status\"],\n                name=\"concordia_a_id_0c37bf_idx\",\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"campaign\",\n            index=models.Index(\n                fields=[\"published\", \"unlisted\"], name=\"concordia_c_publish_2c3b1c_idx\"\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"topic\",\n            index=models.Index(\n                fields=[\"published\", \"unlisted\"], name=\"concordia_t_publish_7f5b9d_idx\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0045_auto_20200323_1832.py",
    "content": "# Generated by Django 2.2.11 on 2020-03-23 22:32\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0044_auto_20200323_1827\"),\n    ]\n\n    operations = [\n        migrations.AddIndex(\n            model_name=\"asset\",\n            index=models.Index(\n                fields=[\"published\", \"transcription_status\"],\n                name=\"concordia_a_publish_4761f1_idx\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0046_auto_20200323_1907.py",
    "content": "# Generated by Django 2.2.11 on 2020-03-23 23:07\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0045_auto_20200323_1832\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"transcription_status\",\n            field=models.CharField(\n                choices=[\n                    (\"not_started\", \"Not Started\"),\n                    (\"in_progress\", \"In Progress\"),\n                    (\"submitted\", \"Needs Review\"),\n                    (\"completed\", \"Completed\"),\n                ],\n                db_index=True,\n                default=\"not_started\",\n                editable=False,\n                max_length=20,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0047_auto_20200324_1103.py",
    "content": "# Generated by Django 2.2.11 on 2020-03-24 15:03\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0046_auto_20200323_1907\"),\n    ]\n\n    operations = [\n        migrations.AddIndex(\n            model_name=\"transcription\",\n            index=models.Index(\n                fields=[\"asset\", \"user\"], name=\"concordia_t_asset_i_4fcaa1_idx\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0048_auto_20200324_1820.py",
    "content": "# Generated by Django 2.2.11 on 2020-03-24 22:20\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0047_auto_20200324_1103\"),\n    ]\n\n    operations = [\n        migrations.RemoveIndex(\n            model_name=\"asset\",\n            name=\"concordia_a_id_0c37bf_idx\",\n        ),\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n        migrations.AddIndex(\n            model_name=\"asset\",\n            index=models.Index(\n                fields=[\"id\", \"item\", \"published\", \"transcription_status\"],\n                name=\"concordia_a_id_137ca8_idx\",\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"item\",\n            index=models.Index(\n                fields=[\"project\", \"published\"], name=\"concordia_i_project_d8caf0_idx\"\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"project\",\n            index=models.Index(\n                fields=[\"id\", \"campaign\", \"published\"], name=\"concordia_p_id_17c9c9_idx\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0049_auto_20200324_2004.py",
    "content": "# Generated by Django 2.2.11 on 2020-03-25 00:04\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0048_auto_20200324_1820\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"published\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0050_auto_20210920_1544.py",
    "content": "# Generated by Django 2.2.20 on 2021-09-20 19:44\n\nimport django.core.validators\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0049_auto_20200324_2004\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"project\",\n            name=\"ordering\",\n            field=models.IntegerField(\n                default=0,\n                help_text=\"Sort order override: lower values will be listed first\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"tag\",\n            name=\"value\",\n            field=models.CharField(\n                max_length=50,\n                validators=[\n                    django.core.validators.RegexValidator(\"^[- _À-ž'\\\\w]{1,50}$\")\n                ],\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0051_asset_storage_image.py",
    "content": "# Generated by Django 2.2.24 on 2022-01-11 18:14\n\nfrom django.db import migrations, models\n\nimport concordia.models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0050_auto_20210920_1544\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"storage_image\",\n            field=models.ImageField(\n                blank=True,\n                max_length=255,\n                null=True,\n                upload_to=concordia.models.Asset.get_storage_path,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0052_auto_20220531_1331.py",
    "content": "# Generated by Django 3.2.13 on 2022-05-31 17:31\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0051_asset_storage_image\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"resource\",\n            options={\"ordering\": (\"sequence\",)},\n        ),\n        migrations.AlterModelOptions(\n            name=\"sitereport\",\n            options={\"ordering\": (\"-created_on\",)},\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"metadata\",\n            field=models.JSONField(blank=True, default=dict, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"metadata\",\n            field=models.JSONField(blank=True, default=dict, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"metadata\",\n            field=models.JSONField(\n                blank=True,\n                default=dict,\n                help_text=\"Raw metadata returned by the remote API\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"project\",\n            name=\"metadata\",\n            field=models.JSONField(blank=True, default=dict, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0053_banner.py",
    "content": "# Generated by Django 3.2.14 on 2022-08-09 17:28\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0052_auto_20220531_1331\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Banner\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True)),\n                (\"text\", models.CharField(max_length=255)),\n                (\"link\", models.CharField(max_length=255)),\n                (\n                    \"open_in_new_window_tab\",\n                    models.BooleanField(blank=True, default=True),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0054_banner_active.py",
    "content": "# Generated by Django 3.2.14 on 2022-09-16 17:10\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0053_banner\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"banner\",\n            name=\"active\",\n            field=models.BooleanField(blank=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0055_campaign_status.py",
    "content": "# Generated by Django 3.2.15 on 2022-09-19 19:16\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0054_banner_active\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"status\",\n            field=models.IntegerField(\n                choices=[(1, \"Active\"), (2, \"Completed\"), (3, \"Retired\")], default=1\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0056_auto_20220922_1508.py",
    "content": "# Generated by Django 3.2.15 on 2022-09-22 19:08\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0055_campaign_status\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"completed_date\",\n            field=models.DateField(blank=True, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"launch_date\",\n            field=models.DateField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0057_resource_resource_type.py",
    "content": "# Generated by Django 3.2.15 on 2022-09-26 17:20\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0056_auto_20220922_1508\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"resource\",\n            name=\"resource_type\",\n            field=models.IntegerField(\n                choices=[(1, \"Related Link\"), (2, \"Completed Transcription Link\")],\n                default=1,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0058_banner_slug.py",
    "content": "# Generated by Django 3.2.14 on 2022-10-18 17:28\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0057_resource_resource_type\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"banner\",\n            name=\"slug\",\n            field=models.SlugField(\n                allow_unicode=True, default=\"banner_1\", max_length=80, unique=True\n            ),\n            preserve_default=False,\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0059_resourcefile.py",
    "content": "# Generated by Django 3.2.15 on 2022-12-17 20:47\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0058_banner_slug\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"ResourceFile\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"name\", models.CharField(max_length=255)),\n                (\"resource\", models.FileField(upload_to=\"cm-uploads/\")),\n            ],\n            options={\n                \"ordering\": [\"name\"],\n            },\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0060_alter_resourcefile_resource.py",
    "content": "# Generated by Django 3.2.15 on 2022-12-17 21:53\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0059_resourcefile\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"resourcefile\",\n            name=\"resource\",\n            field=models.FileField(upload_to=\"cm-uploads/resources/%Y/\"),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0061_auto_20230201_1453.py",
    "content": "# Generated by Django 3.2.15 on 2023-02-01 19:53\n\nfrom django.db import migrations, models\n\nimport concordia.models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0060_alter_resourcefile_resource\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"resourcefile\",\n            name=\"path\",\n            field=models.CharField(blank=True, default=\"\", max_length=255),\n        ),\n        migrations.AlterField(\n            model_name=\"resourcefile\",\n            name=\"resource\",\n            field=models.FileField(\n                upload_to=concordia.models.resource_file_upload_path\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0061_sitereport_registered_contributors.py",
    "content": "# Generated by Django 3.2.15 on 2022-12-15 01:18\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0060_alter_resourcefile_resource\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"sitereport\",\n            name=\"registered_contributors\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0062_resourcefile_updated_on.py",
    "content": "# Generated by Django 3.2.15 on 2023-02-07 20:45\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0061_auto_20230201_1453\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"resourcefile\",\n            name=\"updated_on\",\n            field=models.DateTimeField(auto_now=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0062_userretiredcampaign.py",
    "content": "# Generated by Django 3.2.14 on 2023-01-20 18:42\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"concordia\", \"0061_sitereport_registered_contributors\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"UserRetiredCampaign\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"asset_count\", models.IntegerField(blank=True, null=True)),\n                (\"asset_tag_count\", models.IntegerField(blank=True, null=True)),\n                (\n                    \"transcribe_count\",\n                    models.IntegerField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"transcription save/submit count\",\n                    ),\n                ),\n                (\n                    \"review_count\",\n                    models.IntegerField(\n                        blank=True, null=True, verbose_name=\"transcription review count\"\n                    ),\n                ),\n                (\n                    \"campaign\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.campaign\",\n                    ),\n                ),\n                (\n                    \"user\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0063_banner_alert_status.py",
    "content": "# Generated by Django 3.2.17 on 2023-02-16 17:33\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0062_resourcefile_updated_on\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"banner\",\n            name=\"alert_status\",\n            field=models.CharField(\n                choices=[\n                    (\"DANGER\", \"Danger\"),\n                    (\"INFO\", \"Information\"),\n                    (\"SUCCESS\", \"Success\"),\n                    (\"WARN\", \"Warning\"),\n                ],\n                default=\"SUCCESS\",\n                max_length=7,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0064_alter_banner_alert_status.py",
    "content": "# Generated by Django 3.2.17 on 2023-02-23 17:37\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0063_banner_alert_status\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"banner\",\n            name=\"alert_status\",\n            field=models.CharField(\n                choices=[\n                    (\"DANGER\", \"Red\"),\n                    (\"INFO\", \"Teal\"),\n                    (\"PRIMARY\", \"Blue\"),\n                    (\"SECONDA\", \"Grey\"),\n                    (\"SUCCESS\", \"Green\"),\n                    (\"WARN\", \"Yellow\"),\n                ],\n                default=\"SUCCESS\",\n                max_length=7,\n                verbose_name=\"Color\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0065_alter_userretiredcampaign_unique_together.py",
    "content": "# Generated by Django 3.2.14 on 2023-02-13 20:51\n\nfrom django.conf import settings\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"concordia\", \"0062_userretiredcampaign\"),\n    ]\n\n    operations = [\n        migrations.AlterUniqueTogether(\n            name=\"userretiredcampaign\",\n            unique_together={(\"user\", \"campaign\")},\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0066_auto_20230217_1302.py",
    "content": "# Generated by Django 3.2.17 on 2023-02-17 13:02\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"concordia\", \"0065_alter_userretiredcampaign_unique_together\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"userretiredcampaign\",\n            options={\"verbose_name\": \"user completed campaign count\"},\n        ),\n        migrations.AlterField(\n            model_name=\"userretiredcampaign\",\n            name=\"campaign\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"concordia.campaign\",\n                verbose_name=\"Campaign Id\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"userretiredcampaign\",\n            name=\"user\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE,\n                to=settings.AUTH_USER_MODEL,\n                verbose_name=\"User Id\",\n            ),\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"userretiredcampaign\",\n            unique_together=set(),\n        ),\n        migrations.AddConstraint(\n            model_name=\"userretiredcampaign\",\n            constraint=models.UniqueConstraint(\n                fields=(\"user\", \"campaign\"), name=\"user_profile_activity\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0066_campaignretirementprogress.py",
    "content": "# Generated by Django 3.2.16 on 2023-02-22 20:26\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0065_alter_userretiredcampaign_unique_together\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"CampaignRetirementProgress\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"project_total\", models.IntegerField(default=0)),\n                (\"projects_removed\", models.IntegerField(default=0)),\n                (\"item_total\", models.IntegerField(default=0)),\n                (\"items_removed\", models.IntegerField(default=0)),\n                (\"asset_total\", models.IntegerField(default=0)),\n                (\"assets_removed\", models.IntegerField(default=0)),\n                (\n                    \"campaign\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.campaign\",\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0067_alter_campaignretirementprogress_campaign.py",
    "content": "# Generated by Django 3.2.16 on 2023-02-22 20:56\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0066_campaignretirementprogress\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaignretirementprogress\",\n            name=\"campaign\",\n            field=models.OneToOneField(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.campaign\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0068_campaignretirementprogress_complete.py",
    "content": "# Generated by Django 3.2.16 on 2023-02-23 20:35\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0067_alter_campaignretirementprogress_campaign\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaignretirementprogress\",\n            name=\"complete\",\n            field=models.BooleanField(default=False),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0069_merge_20230224_1446.py",
    "content": "# Generated by Django 3.2.16 on 2023-02-24 19:46\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0066_auto_20230217_1302\"),\n        (\"concordia\", \"0068_campaignretirementprogress_complete\"),\n    ]\n\n    operations = []\n"
  },
  {
    "path": "concordia/migrations/0070_alter_campaign_options.py",
    "content": "# Generated by Django 3.2.16 on 2023-02-27 16:29\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0069_merge_20230224_1446\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"campaign\",\n            options={\"permissions\": [(\"retire_campaign\", \"Can retire campaign\")]},\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0071_auto_20230306_1456.py",
    "content": "# Generated by Django 3.2.16 on 2023-03-06 19:56\n\nimport django.utils.timezone\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0070_alter_campaign_options\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaignretirementprogress\",\n            name=\"completed_on\",\n            field=models.DateTimeField(null=True),\n        ),\n        migrations.AddField(\n            model_name=\"campaignretirementprogress\",\n            name=\"removal_log\",\n            field=models.JSONField(default=list),\n        ),\n        migrations.AddField(\n            model_name=\"campaignretirementprogress\",\n            name=\"started_on\",\n            field=models.DateTimeField(\n                auto_now_add=True, default=django.utils.timezone.now\n            ),\n            preserve_default=False,\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0072_merge_20230313_1047.py",
    "content": "# Generated by Django 3.2.18 on 2023-03-13 14:47\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0064_alter_banner_alert_status\"),\n        (\"concordia\", \"0071_auto_20230306_1456\"),\n    ]\n\n    operations = []\n"
  },
  {
    "path": "concordia/migrations/0073_auto_20230314_1327.py",
    "content": "# Generated by Django 3.2.18 on 2023-03-14 13:27\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"concordia\", \"0072_merge_20230313_1047\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"banner\",\n            name=\"alert_status\",\n            field=models.CharField(\n                choices=[\n                    (\"DANGER\", \"Red\"),\n                    (\"INFO\", \"Blue\"),\n                    (\"SUCCESS\", \"Green\"),\n                    (\"WARNING\", \"Grey\"),\n                ],\n                default=\"SUCCESS\",\n                max_length=7,\n                verbose_name=\"Color\",\n            ),\n        ),\n        migrations.CreateModel(\n            name=\"UserProfileActivity\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"asset_count\", models.IntegerField(blank=True, null=True)),\n                (\"asset_tag_count\", models.IntegerField(blank=True, null=True)),\n                (\n                    \"transcribe_count\",\n                    models.IntegerField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"transcription save/submit count\",\n                    ),\n                ),\n                (\n                    \"review_count\",\n                    models.IntegerField(\n                        blank=True, null=True, verbose_name=\"transcription review count\"\n                    ),\n                ),\n                (\n                    \"campaign\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.campaign\",\n                        verbose_name=\"Campaign Id\",\n                    ),\n                ),\n                (\n                    \"user\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=settings.AUTH_USER_MODEL,\n                        verbose_name=\"User Id\",\n                    ),\n                ),\n            ],\n        ),\n        migrations.AddConstraint(\n            model_name=\"userprofileactivity\",\n            constraint=models.UniqueConstraint(\n                fields=(\"user\", \"campaign\"), name=\"user_campaign_count\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0074_auto_20230314_1341.py",
    "content": "# Generated by Django 3.2.18 on 2023-03-14 13:41\n\nfrom django.db import migrations\n\n\ndef forwards_func(apps, schema_editor):\n    # moved all of this functionality to tasks.py\n    # leaving this migration here, just in case any environments still reference it\n    pass\n\n\ndef reverse_func(apps, schema_editor):\n    # reverse_func() should delete instances.\n    UserProfileActivity = apps.get_model(\"concordia\", \"UserProfileActivity\")\n    db_alias = schema_editor.connection.alias\n    UserProfileActivity.objects.using(db_alias).all().delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0073_auto_20230314_1327\"),\n    ]\n\n    operations = [\n        migrations.RunPython(forwards_func, reverse_func),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0075_auto_20230327_1333.py",
    "content": "# Generated by Django 3.2.18 on 2023-03-27 17:33\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0074_auto_20230314_1341\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"asset\",\n            options={\"permissions\": [(\"reopen_asset\", \"Can reopen asset\")]},\n        ),\n        migrations.AlterModelOptions(\n            name=\"userprofileactivity\",\n            options={\"verbose_name_plural\": \"User profile activities\"},\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0076_sitereport_report_name.py",
    "content": "# Generated by Django 3.2.18 on 2023-04-27 17:17\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0075_auto_20230327_1333\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"sitereport\",\n            name=\"report_name\",\n            field=models.CharField(blank=True, default=\"\", max_length=80),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0077_alter_sitereport_report_name.py",
    "content": "# Generated by Django 3.2.18 on 2023-05-04 15:33\n\nfrom django.db import migrations, models\n\n\ndef update_report_names(apps, schema_editor):\n    SiteReport = apps.get_model(\"concordia\", \"SiteReport\")\n    for report in SiteReport.objects.filter(report_name=\"RETIRED TOTAL\"):\n        report.report_name = \"RETIRED_TOTAL\"\n        report.save()\n    for report in SiteReport.objects.filter(report_name=\"Retired campaigns\"):\n        report.report_name = \"RETIRED_TOTAL\"\n        report.save()\n    for report in SiteReport.objects.filter(\n        report_name=\"Active and completed campaigns\"\n    ):\n        report.report_name = \"TOTAL\"\n        report.save()\n    for report in SiteReport.objects.filter(\n        report_name=\"\", campaign__isnull=True, topic__isnull=True\n    ):\n        report.report_name = \"TOTAL\"\n        report.save()\n\n\ndef backwards(apps, schema_editor):\n    # This can't be reversed, so we leave the report_names alone\n    return\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0076_sitereport_report_name\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"report_name\",\n            field=models.CharField(\n                blank=True,\n                choices=[\n                    (\"TOTAL\", \"Active and completed campaigns\"),\n                    (\"RETIRED_TOTAL\", \"Retired campaigns\"),\n                ],\n                default=\"\",\n                max_length=80,\n            ),\n        ),\n        migrations.RunPython(update_report_names, backwards),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0078_alter_sitereport_report_name.py",
    "content": "# Generated by Django 3.2.18 on 2023-05-08 15:13\n\nfrom django.db import migrations, models\n\n\ndef update_report_names(apps, schema_editor):\n    SiteReport = apps.get_model(\"concordia\", \"SiteReport\")\n    for report in SiteReport.objects.filter(report_name=\"RETIRED_TOTAL\"):\n        report.report_name = \"Retired campaigns\"\n        report.save()\n    for report in SiteReport.objects.filter(report_name=\"TOTAL\"):\n        report.report_name = \"Active and completed campaigns\"\n        report.save()\n\n\ndef backwards(apps, schema_editor):\n    SiteReport = apps.get_model(\"concordia\", \"SiteReport\")\n    for report in SiteReport.objects.filter(report_name=\"Retired campaigns\"):\n        report.report_name = \"RETIRED_TOTAL\"\n        report.save()\n    for report in SiteReport.objects.filter(\n        report_name=\"Active and completed campaigns\"\n    ):\n        report.report_name = \"TOTAL\"\n        report.save()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0077_alter_sitereport_report_name\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"sitereport\",\n            name=\"report_name\",\n            field=models.CharField(\n                blank=True,\n                choices=[\n                    (\n                        \"Active and completed campaigns\",\n                        \"Active and completed campaigns\",\n                    ),\n                    (\"Retired campaigns\", \"Retired campaigns\"),\n                ],\n                default=\"\",\n                max_length=80,\n            ),\n        ),\n        migrations.RunPython(update_report_names, backwards),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0079_auto_20230601_1234.py",
    "content": "# Generated by Django 3.2.18 on 2023-06-01 16:34\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0078_alter_sitereport_report_name\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"userprofileactivity\",\n            name=\"asset_count\",\n            field=models.IntegerField(default=0),\n        ),\n        migrations.AlterField(\n            model_name=\"userprofileactivity\",\n            name=\"asset_tag_count\",\n            field=models.IntegerField(default=0),\n        ),\n        migrations.AlterField(\n            model_name=\"userprofileactivity\",\n            name=\"review_count\",\n            field=models.IntegerField(\n                default=0, verbose_name=\"transcription review count\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"userprofileactivity\",\n            name=\"transcribe_count\",\n            field=models.IntegerField(\n                default=0, verbose_name=\"transcription save/submit count\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0080_auto_20230602_0920.py",
    "content": "# Generated by Django 3.2.19 on 2023-06-02 13:20\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0079_auto_20230601_1234\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"ocr_generated\",\n            field=models.BooleanField(\n                default=False,\n                help_text=\"Flags transcription as generated directly by OCR\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"ocr_originated\",\n            field=models.BooleanField(\n                default=False,\n                help_text=\"Flags transcription as originated from an OCR transcription\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0081_sitereport_review_actions.py",
    "content": "# Generated by Django 3.2.19 on 2023-06-28 16:53\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0080_auto_20230602_0920\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"sitereport\",\n            name=\"review_actions\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0082_delete_userretiredcampaign.py",
    "content": "# Generated by Django 3.2.19 on 2023-07-10 13:06\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0081_sitereport_review_actions\"),\n    ]\n\n    operations = [\n        migrations.DeleteModel(\n            name=\"UserRetiredCampaign\",\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0083_sitereport_daily_active_users.py",
    "content": "# Generated by Django 3.2.19 on 2023-07-10 14:47\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0082_delete_userretiredcampaign\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"sitereport\",\n            name=\"daily_active_users\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0084_rename_review_actions_sitereport_daily_review_actions.py",
    "content": "# Generated by Django 3.2.19 on 2023-07-21 14:36\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0083_sitereport_daily_active_users\"),\n    ]\n\n    operations = [\n        migrations.RenameField(\n            model_name=\"sitereport\",\n            old_name=\"review_actions\",\n            new_name=\"daily_review_actions\",\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0085_auto_20231016_1432.py",
    "content": "# Generated by Django 3.2.22 on 2023-10-16 18:32\n\nfrom django.db import migrations, models\n\nimport concordia.models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0084_rename_review_actions_sitereport_daily_review_actions\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"storage_image\",\n            field=models.ImageField(\n                max_length=255, upload_to=concordia.models.Asset.get_storage_path\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0086_auto_20231215_1311.py",
    "content": "# Generated by Django 3.2.23 on 2023-12-15 18:11\n\nimport django.contrib.auth.models\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"auth\", \"0012_alter_user_first_name_max_length\"),\n        (\"concordia\", \"0085_auto_20231016_1432\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"ConcordiaUser\",\n            fields=[],\n            options={\n                \"proxy\": True,\n                \"indexes\": [],\n                \"constraints\": [],\n            },\n            bases=(\"auth.user\",),\n            managers=[\n                (\"objects\", django.contrib.auth.models.UserManager()),\n            ],\n        ),\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"next_review_campaign\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"next_transcription_campaign\",\n            field=models.BooleanField(blank=True, db_index=True, default=False),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0087_auto_20240213_0756.py",
    "content": "# Generated by Django 3.2.23 on 2024-02-13 12:56\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0086_auto_20231215_1311\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Card\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"image_alt_text\", models.TextField(blank=True)),\n                (\n                    \"image\",\n                    models.ImageField(blank=True, null=True, upload_to=\"card_images\"),\n                ),\n                (\"title\", models.CharField(max_length=80)),\n                (\"body_text\", models.TextField(blank=True)),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True, null=True)),\n                (\n                    \"display_heading\",\n                    models.CharField(blank=True, max_length=80, null=True),\n                ),\n            ],\n            options={\n                \"ordering\": (\"title\",),\n            },\n        ),\n        migrations.CreateModel(\n            name=\"CardFamily\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"slug\",\n                    models.SlugField(allow_unicode=True, max_length=80, unique=True),\n                ),\n                (\"default\", models.BooleanField(default=False)),\n            ],\n            options={\n                \"verbose_name_plural\": \"card families\",\n            },\n        ),\n        migrations.CreateModel(\n            name=\"Guide\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(max_length=80)),\n                (\"body\", models.TextField(blank=True)),\n                (\"order\", models.IntegerField(default=1)),\n                (\"link_text\", models.CharField(blank=True, max_length=80, null=True)),\n                (\"link_url\", models.CharField(blank=True, max_length=255, null=True)),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"TutorialCard\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"order\", models.IntegerField(default=0)),\n                (\n                    \"card\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE, to=\"concordia.card\"\n                    ),\n                ),\n                (\n                    \"tutorial\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.cardfamily\",\n                    ),\n                ),\n            ],\n            options={\n                \"verbose_name_plural\": \"cards\",\n            },\n        ),\n        migrations.DeleteModel(\n            name=\"SimpleContentBlock\",\n        ),\n        migrations.AddField(\n            model_name=\"cardfamily\",\n            name=\"cards\",\n            field=models.ManyToManyField(\n                through=\"concordia.TutorialCard\", to=\"concordia.Card\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"card_family\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"concordia.cardfamily\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0088_alter_simplepage_body.py",
    "content": "# Generated by Django 3.2.24 on 2024-02-21 18:37\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0087_auto_20240213_0756\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"simplepage\",\n            name=\"body\",\n            field=models.TextField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0089_campaign_image_alt_text.py",
    "content": "# Generated by Django 3.2.24 on 2024-02-26 14:13\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0088_alter_simplepage_body\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"image_alt_text\",\n            field=models.TextField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0090_auto_20240408_1334.py",
    "content": "# Generated by Django 3.2.25 on 2024-04-09 15:25\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0089_campaign_image_alt_text\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"disable_ocr\",\n            field=models.BooleanField(\n                default=False, help_text=\"Turn OCR off for this asset\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"disable_ocr\",\n            field=models.BooleanField(\n                default=False, help_text=\"Turn OCR off for all assets of this campaign\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"item\",\n            name=\"disable_ocr\",\n            field=models.BooleanField(\n                default=False, help_text=\"Turn OCR off for all assets of this item\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"project\",\n            name=\"disable_ocr\",\n            field=models.BooleanField(\n                default=False, help_text=\"Turn OCR off for all assets of this project\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0091_guide_simple_page.py",
    "content": "# Generated by Django 4.2.13 on 2024-05-09 19:21\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0090_auto_20240408_1334\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"guide\",\n            name=\"page\",\n            field=models.ForeignKey(\n                blank=True,\n                null=True,\n                on_delete=django.db.models.deletion.SET_NULL,\n                to=\"concordia.simplepage\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0092_auto_20240509_1522.py",
    "content": "# Generated by Django 4.2.13 on 2024-05-09 19:22\n\nfrom django.db import migrations\n\n\ndef set_simplepages(apps, schema_editor):\n    SimplePage = apps.get_model(\"concordia\", \"SimplePage\")\n    Guide = apps.get_model(\"concordia\", \"Guide\")\n    for guide in Guide.objects.all():\n        page = SimplePage.objects.get(title=guide.title)\n        guide.page = page\n        guide.save()\n\n\ndef backwards(apps, schema_editor):\n    Guide = apps.get_model(\"concordia\", \"Guide\")\n    for guide in Guide.objects.all():\n        guide.page = None\n        guide.save()\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0091_guide_simple_page\"),\n    ]\n\n    operations = [\n        migrations.RunPython(set_simplepages, backwards),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0093_asset_campaign.py",
    "content": "# Generated by Django 4.2.13 on 2024-06-17 17:13\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0092_auto_20240509_1522\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"asset\",\n            name=\"campaign\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"concordia.campaign\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0094_alter_asset_campaign.py",
    "content": "# Generated by Django 4.2.13 on 2024-06-17 17:13\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\ndef set_field_values(apps, schema_editor):\n    Asset = apps.get_model(\"concordia\", \"asset\")\n    db_alias = schema_editor.connection.alias\n    assets = (\n        Asset.objects.using(db_alias)\n        .select_related(\"item__project__campaign\")\n        .only(\"item__project__campaign\", \"campaign\")\n        .iterator(chunk_size=10000)\n    )\n\n    updated = []\n    for asset in assets:\n        # Can't use an F object across tables\n        # using update/bulk_update, so we have\n        # loop through all of them\n        asset.campaign = asset.item.project.campaign\n        updated.append(asset)\n        # To avoid running out of memory, we only\n        # keep 10,000 assets in memory at a time\n        if len(updated) >= 10000:\n            Asset.objects.bulk_update(updated, [\"campaign\"])\n            updated = []\n    if updated:\n        Asset.objects.bulk_update(updated, [\"campaign\"])\n\n\ndef revert_field_values(apps, schema_editor):\n    # We can't actually revert the data, and there's\n    # no need to, but we need this function to be\n    # able to reverse this migration\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0093_asset_campaign\"),\n    ]\n\n    operations = [\n        migrations.RunPython(set_field_values, revert_field_values),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"campaign\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.campaign\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0095_transcription_rolled_back_and_more.py",
    "content": "# Generated by Django 4.2.13 on 2024-06-26 23:26\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0094_alter_asset_campaign\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"rolled_back\",\n            field=models.BooleanField(\n                default=False,\n                help_text=\"Flags transcription as being the result of a rollback (undo)\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"rolled_forward\",\n            field=models.BooleanField(\n                default=False,\n                help_text=\"Flags transcription as being the result of a rollforward (redo)\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0096_transcription_source.py",
    "content": "# Generated by Django 4.2.13 on 2024-06-26 23:41\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0095_transcription_rolled_back_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"transcription\",\n            name=\"source\",\n            field=models.ForeignKey(\n                blank=True,\n                help_text=\"The transcription source for the roll back or roll forward\",\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                related_name=\"source_of\",\n                to=\"concordia.transcription\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0097_alter_sitereport_options_userprofile_review_count_and_more.py",
    "content": "# Generated by Django 4.2.13 on 2024-07-29 17:30\n\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"concordia\", \"0096_transcription_source\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"sitereport\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"-created_on\",)},\n        ),\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"review_count\",\n            field=models.IntegerField(\n                default=0, verbose_name=\"transcription review count\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"userprofile\",\n            name=\"transcribe_count\",\n            field=models.IntegerField(\n                default=0, verbose_name=\"transcription save/submit count\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"userprofile\",\n            name=\"user\",\n            field=models.OneToOneField(\n                on_delete=django.db.models.deletion.CASCADE,\n                related_name=\"profile\",\n                to=settings.AUTH_USER_MODEL,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0098_userprofile_create_and_population.py",
    "content": "# Generated by Django 4.2.13 on 2024-07-29 17:40\n\nfrom django.conf import settings\nfrom django.db import migrations\n\n\ndef create_and_populate_profiles(apps, schema_editor):\n    User = apps.get_model(\"auth\", \"User\")\n    UserProfile = apps.get_model(\"concordia\", \"UserProfile\")\n    db_alias = schema_editor.connection.alias\n    for user in User.objects.using(db_alias).all().iterator(chunk_size=10000):\n        profile, created = UserProfile.objects.using(db_alias).get_or_create(user=user)\n        for activity in user.userprofileactivity_set.all():\n            profile.transcribe_count += activity.transcribe_count\n            profile.review_count += activity.review_count\n        profile.save()\n\n\ndef revert_create_and_populate_profiles(apps, schema_editor):\n    # We can't actually revert the data to the state it was before,\n    # and there's no actual need to, but we need this function to be\n    # able to reverse this migration\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\n            \"concordia\",\n            \"0097_alter_sitereport_options_userprofile_review_count_and_more\",\n        ),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            create_and_populate_profiles, revert_create_and_populate_profiles\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0099_alter_campaign_display_on_homepage_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2024-11-01 17:49\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0098_userprofile_create_and_population\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"display_on_homepage\",\n            field=models.BooleanField(default=True, verbose_name=\"Homepage\"),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"next_review_campaign\",\n            field=models.BooleanField(\n                blank=True, db_index=True, default=False, verbose_name=\"Next-rev.\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"campaign\",\n            name=\"next_transcription_campaign\",\n            field=models.BooleanField(\n                blank=True, db_index=True, default=False, verbose_name=\"Next-tran.\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0100_researchcenter.py",
    "content": "# Generated by Django 4.2.16 on 2024-11-19 17:15\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0099_alter_campaign_display_on_homepage_and_more\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"ResearchCenter\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"title\", models.CharField(max_length=80)),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0101_auto_20241119_1215.py",
    "content": "# Generated by Django 4.2.16 on 2024-11-19 17:15\n\nfrom django.db import migrations\n\nTITLES = (\n    \"American Folklife Center\",\n    \"Law Library\",\n    \"Manuscript\",\n    \"Performing Arts\",\n    \"Rare Book\",\n)\n\n\ndef forwards_func(apps, schema_editor):\n    # create initial data\n    ResearchCenter = apps.get_model(\"concordia\", \"ResearchCenter\")\n    db_alias = schema_editor.connection.alias\n    ResearchCenter.objects.using(db_alias).bulk_create(\n        [\n            ResearchCenter(title=\"American Folklife Center\"),\n            ResearchCenter(title=\"Law Library\"),\n            ResearchCenter(title=\"Manuscript\"),\n            ResearchCenter(title=\"Performing Arts\"),\n            ResearchCenter(title=\"Rare Book\"),\n        ]\n    )\n\n\ndef reverse_func(apps, schema_editor):\n    ResearchCenter = apps.get_model(\"concordia\", \"ResearchCenter\")\n    db_alias = schema_editor.connection.alias\n    for title in TITLES:\n        ResearchCenter.objects.using(db_alias).filter(title=title).delete()\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0100_researchcenter\"),\n    ]\n\n    operations = [migrations.RunPython(forwards_func, reverse_func)]\n"
  },
  {
    "path": "concordia/migrations/0102_campaign_research_centers.py",
    "content": "# Generated by Django 4.2.16 on 2024-11-20 12:06\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0101_auto_20241119_1215\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaign\",\n            name=\"research_centers\",\n            field=models.ManyToManyField(blank=True, to=\"concordia.researchcenter\"),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0103_alter_item_title.py",
    "content": "# Generated by Django 4.2.16 on 2024-12-16 18:03\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0102_campaign_research_centers\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"title\",\n            field=models.CharField(max_length=700),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0104_nexttranscribabletopicasset_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-04-04 18:55\n\nimport uuid\n\nimport django.contrib.postgres.fields\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0103_alter_item_title\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"NextTranscribableTopicAsset\",\n            fields=[\n                (\n                    \"id\",\n                    models.UUIDField(\n                        default=uuid.uuid4,\n                        editable=False,\n                        primary_key=True,\n                        serialize=False,\n                    ),\n                ),\n                (\"item_item_id\", models.CharField(max_length=100)),\n                (\"project_slug\", models.SlugField(allow_unicode=True, max_length=80)),\n                (\"sequence\", models.PositiveIntegerField(default=1)),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\n                    \"transcription_status\",\n                    models.CharField(\n                        choices=[\n                            (\"not_started\", \"Not Started\"),\n                            (\"in_progress\", \"In Progress\"),\n                            (\"submitted\", \"Needs Review\"),\n                            (\"completed\", \"Completed\"),\n                        ],\n                        db_index=True,\n                        default=\"not_started\",\n                        editable=False,\n                        max_length=20,\n                    ),\n                ),\n                (\n                    \"asset\",\n                    models.OneToOneField(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.asset\",\n                    ),\n                ),\n                (\n                    \"item\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE, to=\"concordia.item\"\n                    ),\n                ),\n                (\n                    \"project\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.project\",\n                    ),\n                ),\n                (\n                    \"topic\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.topic\",\n                    ),\n                ),\n            ],\n            options={\n                \"abstract\": False,\n            },\n        ),\n        migrations.CreateModel(\n            name=\"NextTranscribableCampaignAsset\",\n            fields=[\n                (\n                    \"id\",\n                    models.UUIDField(\n                        default=uuid.uuid4,\n                        editable=False,\n                        primary_key=True,\n                        serialize=False,\n                    ),\n                ),\n                (\"item_item_id\", models.CharField(max_length=100)),\n                (\"project_slug\", models.SlugField(allow_unicode=True, max_length=80)),\n                (\"sequence\", models.PositiveIntegerField(default=1)),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\n                    \"transcription_status\",\n                    models.CharField(\n                        choices=[\n                            (\"not_started\", \"Not Started\"),\n                            (\"in_progress\", \"In Progress\"),\n                            (\"submitted\", \"Needs Review\"),\n                            (\"completed\", \"Completed\"),\n                        ],\n                        db_index=True,\n                        default=\"not_started\",\n                        editable=False,\n                        max_length=20,\n                    ),\n                ),\n                (\n                    \"asset\",\n                    models.OneToOneField(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.asset\",\n                    ),\n                ),\n                (\n                    \"campaign\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.campaign\",\n                    ),\n                ),\n                (\n                    \"item\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE, to=\"concordia.item\"\n                    ),\n                ),\n                (\n                    \"project\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.project\",\n                    ),\n                ),\n            ],\n            options={\n                \"abstract\": False,\n            },\n        ),\n        migrations.CreateModel(\n            name=\"NextReviewableTopicAsset\",\n            fields=[\n                (\n                    \"id\",\n                    models.UUIDField(\n                        default=uuid.uuid4,\n                        editable=False,\n                        primary_key=True,\n                        serialize=False,\n                    ),\n                ),\n                (\"item_item_id\", models.CharField(max_length=100)),\n                (\"project_slug\", models.SlugField(allow_unicode=True, max_length=80)),\n                (\"sequence\", models.PositiveIntegerField(default=1)),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\n                    \"transcriber_ids\",\n                    django.contrib.postgres.fields.ArrayField(\n                        base_field=models.IntegerField(),\n                        blank=True,\n                        default=list,\n                        size=None,\n                    ),\n                ),\n                (\n                    \"asset\",\n                    models.OneToOneField(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.asset\",\n                    ),\n                ),\n                (\n                    \"item\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE, to=\"concordia.item\"\n                    ),\n                ),\n                (\n                    \"project\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.project\",\n                    ),\n                ),\n                (\n                    \"topic\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.topic\",\n                    ),\n                ),\n            ],\n            options={\n                \"abstract\": False,\n            },\n        ),\n        migrations.CreateModel(\n            name=\"NextReviewableCampaignAsset\",\n            fields=[\n                (\n                    \"id\",\n                    models.UUIDField(\n                        default=uuid.uuid4,\n                        editable=False,\n                        primary_key=True,\n                        serialize=False,\n                    ),\n                ),\n                (\"item_item_id\", models.CharField(max_length=100)),\n                (\"project_slug\", models.SlugField(allow_unicode=True, max_length=80)),\n                (\"sequence\", models.PositiveIntegerField(default=1)),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\n                    \"transcriber_ids\",\n                    django.contrib.postgres.fields.ArrayField(\n                        base_field=models.IntegerField(),\n                        blank=True,\n                        default=list,\n                        size=None,\n                    ),\n                ),\n                (\n                    \"asset\",\n                    models.OneToOneField(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.asset\",\n                    ),\n                ),\n                (\n                    \"campaign\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.campaign\",\n                    ),\n                ),\n                (\n                    \"item\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE, to=\"concordia.item\"\n                    ),\n                ),\n                (\n                    \"project\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.project\",\n                    ),\n                ),\n            ],\n            options={\n                \"abstract\": False,\n            },\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0105_nextreviewablecampaignasset_concordia_n_transcr_aafdba_gin_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-04-04 19:09\n\nimport django.contrib.postgres.indexes\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0104_nexttranscribabletopicasset_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddIndex(\n            model_name=\"nextreviewablecampaignasset\",\n            index=django.contrib.postgres.indexes.GinIndex(\n                fields=[\"transcriber_ids\"], name=\"concordia_n_transcr_aafdba_gin\"\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"nextreviewabletopicasset\",\n            index=django.contrib.postgres.indexes.GinIndex(\n                fields=[\"transcriber_ids\"], name=\"concordia_n_transcr_415832_gin\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0106_alter_nextreviewablecampaignasset_options_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-04-09 17:44\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\n            \"concordia\",\n            \"0105_nextreviewablecampaignasset_concordia_n_transcr_aafdba_gin_and_more\",\n        ),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"nextreviewablecampaignasset\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"-created_on\",)},\n        ),\n        migrations.AlterModelOptions(\n            name=\"nextreviewabletopicasset\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"-created_on\",)},\n        ),\n        migrations.AlterModelOptions(\n            name=\"nexttranscribablecampaignasset\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"-created_on\",)},\n        ),\n        migrations.AlterModelOptions(\n            name=\"nexttranscribabletopicasset\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"-created_on\",)},\n        ),\n        migrations.AlterField(\n            model_name=\"nextreviewablecampaignasset\",\n            name=\"asset\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.asset\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"nextreviewabletopicasset\",\n            name=\"asset\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.asset\"\n            ),\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"nextreviewabletopicasset\",\n            unique_together={(\"asset\", \"topic\")},\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"nexttranscribabletopicasset\",\n            unique_together={(\"asset\", \"topic\")},\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0107_alter_nextreviewablecampaignasset_options_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-04-09 17:50\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0106_alter_nextreviewablecampaignasset_options_and_more\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"nextreviewablecampaignasset\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"created_on\",)},\n        ),\n        migrations.AlterModelOptions(\n            name=\"nextreviewabletopicasset\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"created_on\",)},\n        ),\n        migrations.AlterModelOptions(\n            name=\"nexttranscribablecampaignasset\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"created_on\",)},\n        ),\n        migrations.AlterModelOptions(\n            name=\"nexttranscribabletopicasset\",\n            options={\"get_latest_by\": \"created_on\", \"ordering\": (\"created_on\",)},\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0108_add_next_asset_cache_periodic_task.py",
    "content": "# Generated by Django 4.2.16 on 2025-04-10 13:52\n\nfrom django.db import migrations\n\n\ndef add_renew_next_asset_cache_task(apps, schema_editor):\n    PeriodicTask = apps.get_model(\"django_celery_beat\", \"PeriodicTask\")\n    IntervalSchedule = apps.get_model(\"django_celery_beat\", \"IntervalSchedule\")\n\n    schedule, _ = IntervalSchedule.objects.get_or_create(every=1, period=\"hours\")\n\n    PeriodicTask.objects.update_or_create(\n        name=\"Renew next asset cache\",\n        defaults={\n            \"interval\": schedule,\n            \"task\": \"concordia.tasks.renew_next_asset_cache\",\n            \"enabled\": True,\n            \"description\": (\n                \"Run every hour to refresh cache of transcribable and reviewable assets\"\n            ),\n        },\n    )\n\n\ndef remove_renew_next_asset_cache_task(apps, schema_editor):\n    PeriodicTask = apps.get_model(\"django_celery_beat\", \"PeriodicTask\")\n    PeriodicTask.objects.filter(name=\"Renew next asset cache\").delete()\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0107_alter_nextreviewablecampaignasset_options_and_more\"),\n        (\"django_celery_beat\", \"0019_alter_periodictasks_options\"),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            add_renew_next_asset_cache_task,\n            reverse_code=remove_renew_next_asset_cache_task,\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0109_alter_nextreviewablecampaignasset_asset_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-04-10 19:19\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0108_add_next_asset_cache_periodic_task\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"nextreviewablecampaignasset\",\n            name=\"asset\",\n            field=models.OneToOneField(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.asset\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"nexttranscribabletopicasset\",\n            name=\"asset\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.asset\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0110_remove_asset_media_url_alter_asset_storage_image.py",
    "content": "# Generated by Django 4.2.20 on 2025-04-23 19:22\n\nimport storages.backends.s3\nfrom django.db import migrations, models\n\nimport concordia.models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0109_alter_nextreviewablecampaignasset_asset_and_more\"),\n    ]\n\n    operations = [\n        migrations.RemoveField(\n            model_name=\"asset\",\n            name=\"media_url\",\n        ),\n        migrations.AlterField(\n            model_name=\"asset\",\n            name=\"storage_image\",\n            field=models.ImageField(\n                max_length=255,\n                storage=storages.backends.s3.S3Storage(querystring_auth=False),\n                upload_to=concordia.models.Asset.get_storage_path,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0111_auto_20250428_1023.py",
    "content": "# Generated by Django 4.2.20 on 2025-04-28 14:23\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0110_remove_asset_media_url_alter_asset_storage_image\"),\n    ]\n\n    operations = [\n        # We need to update Django so it knows about our intermediate\n        # model, but since we're re-using the existing intermediate\n        # table, we don't want to change anything in the database itself\n        migrations.SeparateDatabaseAndState(\n            database_operations=[],  # No DB changes, only Django state\n            state_operations=[\n                migrations.CreateModel(\n                    name=\"ProjectTopic\",\n                    fields=[\n                        (\n                            \"id\",\n                            models.AutoField(\n                                auto_created=True,\n                                primary_key=True,\n                                serialize=False,\n                                verbose_name=\"ID\",\n                            ),\n                        ),\n                        (\n                            \"project\",\n                            models.ForeignKey(\n                                on_delete=models.CASCADE, to=\"concordia.project\"\n                            ),\n                        ),\n                        (\n                            \"topic\",\n                            models.ForeignKey(\n                                on_delete=models.CASCADE, to=\"concordia.topic\"\n                            ),\n                        ),\n                    ],\n                    options={\n                        \"db_table\": \"concordia_project_topics\",\n                        \"unique_together\": {(\"project\", \"topic\")},\n                    },\n                ),\n                migrations.AlterField(\n                    model_name=\"project\",\n                    name=\"topics\",\n                    field=models.ManyToManyField(\n                        to=\"concordia.Topic\",\n                        through=\"concordia.ProjectTopic\",\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0112_projecttopic_url_filter_alter_projecttopic_id.py",
    "content": "# Generated by Django 4.2.20 on 2025-04-28 14:26\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0111_auto_20250428_1023\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"projecttopic\",\n            name=\"url_filter\",\n            field=models.CharField(\n                blank=True,\n                null=True,\n                max_length=20,\n                choices=[\n                    (\"not_started\", \"Not Started\"),\n                    (\"in_progress\", \"In Progress\"),\n                    (\"submitted\", \"Needs Review\"),\n                    (\"completed\", \"Completed\"),\n                ],\n                help_text=\"Optional filter on the status for this project-topic link\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0113_create_asset_status_periodic_task.py",
    "content": "# Generated by Django 4.2.21 on 2025-06-05 14:03\n\nfrom django.db import migrations\n\n\ndef create_asset_status_task(apps, schema_editor):\n    IntervalSchedule = apps.get_model(\"django_celery_beat\", \"IntervalSchedule\")\n    PeriodicTask = apps.get_model(\"django_celery_beat\", \"PeriodicTask\")\n\n    # Ensure an IntervalSchedule of every 5 minutes exists (or get it).\n    interval, created = IntervalSchedule.objects.get_or_create(\n        every=5,\n        period=\"minutes\",\n    )\n\n    # Create the PeriodicTask if it doesn’t already exist\n    PeriodicTask.objects.get_or_create(\n        name=\"Populate asset status visualization cache\",\n        task=\"concordia.tasks.populate_asset_status_visualization_cache\",\n        interval=interval,\n        defaults={\n            \"enabled\": True,\n            \"description\": \"Populates the cache for the asset-status-overview and asset-status-by-campaign visualizations\",\n        },\n    )\n\n\ndef delete_asset_status_task(apps, schema_editor):\n    PeriodicTask = apps.get_model(\"django_celery_beat\", \"PeriodicTask\")\n    # Delete by the exact name we used above\n    PeriodicTask.objects.filter(\n        name=\"Populate asset status visualization cache\"\n    ).delete()\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0112_projecttopic_url_filter_alter_projecttopic_id\"),\n        (\"django_celery_beat\", \"0019_alter_periodictasks_options\"),\n    ]\n\n    operations = [\n        migrations.RunPython(create_asset_status_task, delete_asset_status_task),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0114_create_daily_activity_periodic_task.py",
    "content": "# Generated by Django 4.2.21 on 2025-06-05 14:10\n\nfrom django.db import migrations\n\n\ndef create_daily_activity_task(apps, schema_editor):\n    CrontabSchedule = apps.get_model(\"django_celery_beat\", \"CrontabSchedule\")\n    PeriodicTask = apps.get_model(\"django_celery_beat\", \"PeriodicTask\")\n\n    # Ensure a CrontabSchedule for daily at 4:00 AM exists.\n    crontab, created = CrontabSchedule.objects.get_or_create(\n        minute=\"0\",\n        hour=\"4\",\n        day_of_week=\"*\",\n        day_of_month=\"*\",\n        month_of_year=\"*\",\n        timezone=\"America/New_York\",\n    )\n\n    # Create the PeriodicTask if it doesn’t already exist\n    PeriodicTask.objects.get_or_create(\n        name=\"Populate daily activity visualization cache\",\n        task=\"concordia.tasks.populate_daily_activity_visualization_cache\",\n        crontab=crontab,\n        defaults={\n            \"enabled\": True,\n            \"description\": \"Populates the cache for the daily-activity visualization\",\n        },\n    )\n\n\ndef delete_daily_activity_task(apps, schema_editor):\n    PeriodicTask = apps.get_model(\"django_celery_beat\", \"PeriodicTask\")\n    PeriodicTask.objects.filter(\n        name=\"Populate daily activity visualization cache\"\n    ).delete()\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0113_create_asset_status_periodic_task\"),\n        (\"django_celery_beat\", \"0019_alter_periodictasks_options\"),\n    ]\n\n    operations = [\n        migrations.RunPython(create_daily_activity_task, delete_daily_activity_task),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0115_alter_asset_storage_image_alter_banner_link_and_more.py",
    "content": "# Generated by Django 4.2.22 on 2025-06-16 13:24\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0114_create_daily_activity_periodic_task\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"banner\",\n            name=\"link\",\n            field=models.CharField(blank=True, max_length=255, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0116_item_thumbnail_image.py",
    "content": "# Generated by Django 4.2.22 on 2025-08-13 17:43\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0115_alter_asset_storage_image_alter_banner_link_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"item\",\n            name=\"thumbnail_image\",\n            field=models.ImageField(blank=True, null=True, upload_to=\"item-thumbnails\"),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0117_alter_projecttopic_options_projecttopic_ordering.py",
    "content": "# Generated by Django 4.2.22 on 2025-08-25 18:39\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0116_item_thumbnail_image\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"projecttopic\",\n            options={\"ordering\": (\"ordering\",)},\n        ),\n        migrations.AddField(\n            model_name=\"projecttopic\",\n            name=\"ordering\",\n            field=models.IntegerField(\n                default=0,\n                help_text=\"Sort order override: lower values will be listed first\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0118_asset_concordia_a_item_id_f10916_idx_and_more.py",
    "content": "# Generated by Django 4.2.22 on 2025-08-26 13:27\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0117_alter_projecttopic_options_projecttopic_ordering\"),\n    ]\n\n    operations = [\n        migrations.AddIndex(\n            model_name=\"asset\",\n            index=models.Index(\n                fields=[\"item\", \"published\", \"transcription_status\"],\n                name=\"concordia_a_item_id_f10916_idx\",\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"project\",\n            index=models.Index(\n                fields=[\"published\", \"campaign\", \"title\"],\n                name=\"concordia_p_publish_0a0f1e_idx\",\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"projecttopic\",\n            index=models.Index(\n                fields=[\"topic\", \"project\"], name=\"concordia_p_topic_i_bf12cc_idx\"\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"projecttopic\",\n            index=models.Index(\n                fields=[\"topic\", \"ordering\"], name=\"concordia_p_topic_i_dcbe8c_idx\"\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"projecttopic\",\n            index=models.Index(\n                fields=[\"topic\", \"url_filter\"], name=\"concordia_p_topic_i_ee5c9d_idx\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0119_remove_asset_concordia_a_id_137ca8_idx_and_more.py",
    "content": "# Generated by Django 4.2.22 on 2025-09-08 14:19\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0118_asset_concordia_a_item_id_f10916_idx_and_more\"),\n    ]\n\n    operations = [\n        migrations.RemoveIndex(\n            model_name=\"asset\",\n            name=\"concordia_a_id_137ca8_idx\",\n        ),\n        migrations.RemoveIndex(\n            model_name=\"asset\",\n            name=\"concordia_a_item_id_f10916_idx\",\n        ),\n        migrations.AddIndex(\n            model_name=\"asset\",\n            index=models.Index(\n                fields=[\"item\", \"published\", \"transcription_status\", \"sequence\"],\n                name=\"concordia_a_item_id_0926c0_idx\",\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"asset\",\n            index=models.Index(\n                fields=[\"published\", \"transcription_status\", \"item\", \"sequence\"],\n                name=\"concordia_a_publish_b60d2f_idx\",\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"asset\",\n            index=models.Index(\n                fields=[\"item\", \"sequence\"], name=\"concordia_a_item_id_24ea05_idx\"\n            ),\n        ),\n        migrations.AddIndex(\n            model_name=\"asset\",\n            index=models.Index(\n                fields=[\"campaign\", \"sequence\"], name=\"concordia_a_campaig_d64e2f_idx\"\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0120_sitereport_assets_started.py",
    "content": "# Generated by Django 4.2.22 on 2025-09-10 16:09\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0119_remove_asset_concordia_a_id_137ca8_idx_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"sitereport\",\n            name=\"assets_started\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0121_keymetricsreport.py",
    "content": "# Generated by Django 4.2.22 on 2025-09-11 16:13\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0120_sitereport_assets_started\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"KeyMetricsReport\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created_on\", models.DateTimeField(auto_now_add=True)),\n                (\"updated_on\", models.DateTimeField(auto_now=True)),\n                (\n                    \"period_type\",\n                    models.CharField(\n                        choices=[\n                            (\"MONTHLY\", \"Monthly\"),\n                            (\"QUARTERLY\", \"Quarterly\"),\n                            (\"FISCAL_YEAR\", \"Fiscal year\"),\n                        ],\n                        max_length=20,\n                    ),\n                ),\n                (\"period_start\", models.DateField()),\n                (\"period_end\", models.DateField()),\n                (\"fiscal_year\", models.IntegerField()),\n                (\"fiscal_quarter\", models.IntegerField(blank=True, null=True)),\n                (\"month\", models.IntegerField(blank=True, null=True)),\n                (\"assets_published\", models.IntegerField(blank=True, null=True)),\n                (\"assets_started\", models.IntegerField(blank=True, null=True)),\n                (\"assets_completed\", models.IntegerField(blank=True, null=True)),\n                (\"users_activated\", models.IntegerField(blank=True, null=True)),\n                (\n                    \"anonymous_transcriptions\",\n                    models.IntegerField(blank=True, null=True),\n                ),\n                (\"transcriptions_saved\", models.IntegerField(blank=True, null=True)),\n                (\"tag_uses\", models.IntegerField(blank=True, null=True)),\n                (\n                    \"crowd_emails_and_libanswers_sent\",\n                    models.IntegerField(blank=True, null=True),\n                ),\n                (\"crowd_visits\", models.IntegerField(blank=True, null=True)),\n                (\"crowd_page_views\", models.IntegerField(blank=True, null=True)),\n                (\"crowd_unique_visitors\", models.IntegerField(blank=True, null=True)),\n                (\n                    \"avg_visit_seconds\",\n                    models.DecimalField(\n                        blank=True, decimal_places=2, max_digits=8, null=True\n                    ),\n                ),\n                (\n                    \"transcriptions_added_to_loc_gov\",\n                    models.IntegerField(blank=True, null=True),\n                ),\n                (\n                    \"datasets_added_to_loc_gov\",\n                    models.IntegerField(blank=True, null=True),\n                ),\n            ],\n            options={\n                \"ordering\": (\"period_start\", \"period_end\", \"period_type\"),\n                \"indexes\": [\n                    models.Index(\n                        fields=[\"period_type\", \"period_start\", \"period_end\"],\n                        name=\"concordia_k_period__d8d9b6_idx\",\n                    ),\n                    models.Index(\n                        fields=[\"period_type\", \"fiscal_year\"],\n                        name=\"concordia_k_period__3d99e1_idx\",\n                    ),\n                    models.Index(\n                        fields=[\"period_type\", \"fiscal_year\", \"fiscal_quarter\"],\n                        name=\"concordia_k_period__420f19_idx\",\n                    ),\n                    models.Index(\n                        fields=[\"period_type\", \"fiscal_year\", \"month\"],\n                        name=\"concordia_k_period__06112a_idx\",\n                    ),\n                ],\n                \"unique_together\": {(\"period_type\", \"period_start\", \"period_end\")},\n            },\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0122_alter_item_title.py",
    "content": "# Generated by Django 4.2.22 on 2025-10-06 18:05\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0121_keymetricsreport\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"item\",\n            name=\"title\",\n            field=models.CharField(max_length=1000),\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0123_alter_campaignretirementprogress_options.py",
    "content": "# Generated by Django 4.2.22 on 2025-10-06 18:11\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0122_alter_item_title\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"campaignretirementprogress\",\n            options={\"verbose_name_plural\": \"campaign retirement progresses\"},\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0124_update_periodic_task_paths.py",
    "content": "# Generated by Django 4.2.22 on 2025-10-22 16:03\n\nfrom django.db import migrations\n\nOLD_TO_NEW = {\n    # reservations.py\n    \"concordia.tasks.expire_inactive_asset_reservations\": \"concordia.tasks.reservations.expire_inactive_asset_reservations\",\n    \"concordia.tasks.tombstone_old_active_asset_reservations\": \"concordia.tasks.reservations.tombstone_old_active_asset_reservations\",\n    \"concordia.tasks.delete_old_tombstoned_reservations\": \"concordia.tasks.reservations.delete_old_tombstoned_reservations\",\n    # reports/sitereport.py\n    \"concordia.tasks.site_report\": \"concordia.tasks.reports.sitereport.site_report\",\n    # visualizations.py\n    \"concordia.tasks.populate_asset_status_visualization_cache\": \"concordia.tasks.visualizations.populate_asset_status_visualization_cache\",\n    \"concordia.tasks.populate_daily_activity_visualization_cache\": \"concordia.tasks.visualizations.populate_daily_activity_visualization_cache\",\n    # next_asset/renew.py\n    \"concordia.tasks.renew_next_asset_cache\": \"concordia.tasks.next_asset.renew.renew_next_asset_cache\",\n    # search_index.py\n    \"concordia.tasks.create_opensearch_indices\": \"concordia.tasks.search_index.create_opensearch_indices\",\n    \"concordia.tasks.delete_opensearch_indices\": \"concordia.tasks.search_index.delete_opensearch_indices\",\n    \"concordia.tasks.rebuild_opensearch_indices\": \"concordia.tasks.search_index.rebuild_opensearch_indices\",\n    \"concordia.tasks.populate_opensearch_users_indices\": \"concordia.tasks.search_index.populate_opensearch_users_indices\",\n    \"concordia.tasks.populate_opensearch_assets_indices\": \"concordia.tasks.search_index.populate_opensearch_assets_indices\",\n    \"concordia.tasks.populate_opensearch_indices\": \"concordia.tasks.search_index.populate_opensearch_indices\",\n    # assets.py\n    \"concordia.tasks.calculate_difficulty_values\": \"concordia.tasks.assets.calculate_difficulty_values\",\n    \"concordia.tasks.populate_asset_years\": \"concordia.tasks.assets.populate_asset_years\",\n    \"concordia.tasks.fix_storage_images\": \"concordia.tasks.assets.fix_storage_images\",\n    # resources.py\n    \"concordia.tasks.populate_resource_files\": \"concordia.tasks.resources.populate_resource_files\",\n    # housekeeping.py\n    \"concordia.tasks.clear_sessions\": \"concordia.tasks.housekeeping.clear_sessions\",\n    # unusualactivity.py\n    \"concordia.tasks.unusual_activity\": \"concordia.tasks.unusualactivity.unusual_activity\",\n    # useractivity.py\n    \"concordia.tasks.populate_completed_campaign_counts\": \"concordia.tasks.useractivity.populate_completed_campaign_counts\",\n    \"concordia.tasks.populate_active_campaign_counts\": \"concordia.tasks.useractivity.populate_active_campaign_counts\",\n    \"concordia.tasks.update_userprofileactivity_from_cache\": \"concordia.tasks.useractivity.update_userprofileactivity_from_cache\",\n    # thumbnails.py\n    \"concordia.tasks.download_missing_thumbnails_task\": \"concordia.tasks.thumbnails.download_missing_thumbnails_task\",\n}\n\nNEW_TO_OLD = {v: k for k, v in OLD_TO_NEW.items()}\n\n\ndef forwards(apps, schema_editor):\n    PeriodicTask = apps.get_model(\"django_celery_beat\", \"PeriodicTask\")\n    for pt in PeriodicTask.objects.all().only(\"id\", \"task\"):\n        new = OLD_TO_NEW.get(pt.task)\n        if new and new != pt.task:\n            PeriodicTask.objects.filter(id=pt.id).update(task=new)\n\n\ndef backwards(apps, schema_editor):\n    PeriodicTask = apps.get_model(\"django_celery_beat\", \"PeriodicTask\")\n    for pt in PeriodicTask.objects.all().only(\"id\", \"task\"):\n        old = NEW_TO_OLD.get(pt.task)\n        if old and old != pt.task:\n            PeriodicTask.objects.filter(id=pt.id).update(task=old)\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0123_alter_campaignretirementprogress_options\"),\n    ]\n\n    operations = [\n        migrations.RunPython(forwards, backwards),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0125_update_userprofile_tasks.py",
    "content": "# Generated by Django 4.2.26 on 2025-11-14 15:05\n\nfrom django.db import migrations\n\nTITLE = \"Geography and Map\"\n\n\ndef forwards(apps, schema_editor):\n    db_alias = schema_editor.connection.alias\n    ResearchCenter = apps.get_model(\"concordia\", \"ResearchCenter\")\n    ResearchCenter.objects.using(db_alias).create(title=TITLE)\n\n\ndef reverse_func(apps, schema_editor):\n    db_alias = schema_editor.connection.alias\n    ResearchCenter = apps.get_model(\"concordia\", \"ResearchCenter\")\n    ResearchCenter.objects.using(db_alias).get(title=TITLE).delete()\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0124_update_periodic_task_paths\"),\n    ]\n\n    operations = [migrations.RunPython(forwards, reverse_func)]\n"
  },
  {
    "path": "concordia/migrations/0126_concordiafile_helpfullink_remove_resource_campaign_and_more.py",
    "content": "# Generated by Django 4.2.24 on 2025-12-15 15:49\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0125_update_userprofile_tasks\"),\n    ]\n\n    operations = [\n        migrations.SeparateDatabaseAndState(\n            database_operations=[],\n            state_operations=[\n                migrations.RenameModel(\n                    old_name=\"Resource\",\n                    new_name=\"HelpfulLink\",\n                ),\n                migrations.RenameField(\n                    model_name=\"helpfullink\",\n                    old_name=\"resource_type\",\n                    new_name=\"link_type\",\n                ),\n                migrations.RenameField(\n                    model_name=\"helpfullink\",\n                    old_name=\"resource_url\",\n                    new_name=\"link_url\",\n                ),\n                migrations.RenameModel(\n                    old_name=\"ResourceFile\",\n                    new_name=\"ConcordiaFile\",\n                ),\n                migrations.RenameField(\n                    model_name=\"concordiafile\",\n                    old_name=\"resource\",\n                    new_name=\"uploaded_file\",\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0127_alter_campaignretirementprogress_options_and_more.py",
    "content": "# Generated by Django 4.2.24 on 2025-12-15 16:37\n\nfrom django.db import migrations, models\n\nimport concordia.models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\n            \"concordia\",\n            \"0126_concordiafile_helpfullink_remove_resource_campaign_and_more\",\n        ),\n    ]\n\n    operations = [\n        migrations.SeparateDatabaseAndState(\n            database_operations=[],\n            state_operations=[\n                migrations.AlterField(\n                    model_name=\"concordiafile\",\n                    name=\"uploaded_file\",\n                    field=models.FileField(\n                        db_column=\"resource\",\n                        upload_to=concordia.models.resource_file_upload_path,\n                    ),\n                ),\n                migrations.AlterField(\n                    model_name=\"helpfullink\",\n                    name=\"link_type\",\n                    field=models.IntegerField(\n                        choices=[\n                            (1, \"Related Link\"),\n                            (2, \"Completed Transcription Link\"),\n                        ],\n                        db_column=\"resource_type\",\n                        default=1,\n                    ),\n                ),\n                migrations.AlterField(\n                    model_name=\"helpfullink\",\n                    name=\"link_url\",\n                    field=models.URLField(db_column=\"resource_url\"),\n                ),\n                migrations.AlterModelTable(\n                    name=\"concordiafile\",\n                    table=\"concordia_resourcefile\",\n                ),\n                migrations.AlterModelTable(\n                    name=\"helpfullink\",\n                    table=\"concordia_resource\",\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/0128_alter_campaignretirementprogress_options.py",
    "content": "# Generated by Django 4.2.24 on 2025-12-15 16:41\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0127_alter_campaignretirementprogress_options_and_more\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"campaignretirementprogress\",\n            options={\"verbose_name_plural\": \"campaign retirement progress\"},\n        ),\n    ]\n"
  },
  {
    "path": "concordia/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/models.py",
    "content": "import calendar\nimport csv\nimport datetime\nimport io\nimport json\nimport os.path\nimport time\nimport uuid\nfrom decimal import Decimal\nfrom itertools import chain\nfrom logging import getLogger\nfrom typing import Optional, Tuple, Union\n\nimport pytesseract\nfrom django.conf import settings\nfrom django.contrib.auth.models import User\nfrom django.contrib.postgres.fields import ArrayField\nfrom django.contrib.postgres.indexes import GinIndex\nfrom django.core import signing\nfrom django.core.cache import cache\nfrom django.core.exceptions import ObjectDoesNotExist, ValidationError\nfrom django.core.serializers.json import DjangoJSONEncoder\nfrom django.core.validators import RegexValidator\nfrom django.db import models\nfrom django.db.models import (\n    Avg,\n    Case,\n    Count,\n    ExpressionWrapper,\n    F,\n    JSONField,\n    Q,\n    Sum,\n    Value,\n    When,\n)\nfrom django.db.models.functions import Round\nfrom django.db.models.signals import post_save\nfrom django.urls import reverse\nfrom django.utils import timezone\nfrom django.utils.functional import cached_property\nfrom PIL import Image\n\nfrom concordia.exceptions import RateLimitExceededError\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.storage import ASSET_STORAGE\nfrom configuration.utils import configuration_value\nfrom prometheus_metrics.models import MetricsModelMixin\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\nmetadata_default = dict\n\nUser._meta.get_field(\"email\").__dict__[\"_unique\"] = True\n\nONE_MINUTE = datetime.timedelta(minutes=1)\nONE_DAY = datetime.timedelta(days=1)\nONE_DAY_AGO = timezone.now() - ONE_DAY\nTHRESHOLD = 2\n\n\ndef resource_file_upload_path(instance, filename):\n    \"\"\"\n    Return the upload path for a ConcordiaFile instance.\n\n    If the instance already has a primary key and a stored path, that path is\n    reused so the file is not moved on subsequent saves. Otherwise, a dated\n    path is generated under ``cm-uploads/resources/`` using the lowercased\n    filename.\n    \"\"\"\n    if instance.id and instance.path:\n        return instance.path\n    path = \"cm-uploads/resources/%Y/{0}\".format(filename.lower())\n    return time.strftime(path)\n\n\nclass ConcordiaUser(User):\n    \"\"\"\n    Proxy model adding Concordia-specific helpers and rate-limit tracking.\n\n    This avoids changing the base ``auth.User`` model while still attaching\n    project-specific behavior such as email reconfirmation flow and review\n    rate limiting.\n    \"\"\"\n\n    class Meta:\n        proxy = True\n\n    @property\n    def email_reconfirmation_cache_key(self):\n        \"\"\"\n        Return the cache key used to store the pending reconfirmation email.\n        \"\"\"\n        return settings.EMAIL_RECONFIRMATION_KEY.format(id=self.id)\n\n    def set_email_for_reconfirmation(self, email):\n        \"\"\"\n        Store a pending reconfirmation email address in the cache.\n\n        The value is stored under :attr:`email_reconfirmation_cache_key` for\n        the duration configured by ``EMAIL_RECONFIRMATION_TIMEOUT``.\n        \"\"\"\n        cache.set(\n            self.email_reconfirmation_cache_key,\n            email,\n            settings.EMAIL_RECONFIRMATION_TIMEOUT,\n        )\n\n    def get_email_for_reconfirmation(self):\n        \"\"\"\n        Return the cached reconfirmation email address, if present.\n\n        Returns:\n            str | None: The pending reconfirmation email, or None if no value\n            is cached.\n        \"\"\"\n        return cache.get(self.email_reconfirmation_cache_key)\n\n    def delete_email_for_reconfirmation(self):\n        \"\"\"\n        Remove any cached reconfirmation email address for this user.\n        \"\"\"\n        cache.delete(self.email_reconfirmation_cache_key)\n\n    def get_email_reconfirmation_key(self):\n        \"\"\"\n        Build a signed reconfirmation token for the cached email address.\n\n        The token encodes the username and pending email address using\n        Django's :mod:`signing` utilities.\n\n        Returns:\n            str: A signed string suitable for use in reconfirmation URLs.\n\n        Raises:\n            ValueError: If no email address has been cached for this user.\n        \"\"\"\n        email = self.get_email_for_reconfirmation()\n        if email:\n            return signing.dumps(obj={\"username\": self.get_username(), \"email\": email})\n        else:\n            raise ValueError(\"No email cached for reconfirmation\")\n\n    def validate_reconfirmation_email(self, email):\n        \"\"\"\n        Check whether the supplied email matches the cached reconfirmation one.\n\n        Args:\n            email (str): Email address to validate.\n\n        Returns:\n            bool: True if the email matches the cached value, otherwise False.\n        \"\"\"\n        return email == self.get_email_for_reconfirmation()\n\n    def review_incidents(self, recent_accepts, threshold=THRESHOLD):\n        \"\"\"\n        Count review-rate incidents for this user within a queryset.\n\n        An incident is counted when this user records ``threshold`` or more\n        accepts within any rolling 60-second window among the provided\n        ``recent_accepts`` queryset.\n\n        Args:\n            recent_accepts (QuerySet): Transcription queryset filtered to rows\n                with non-null ``accepted`` timestamps.\n            threshold (int): Minimum number of accepts in a 60-second window\n                required to count as one incident.\n\n        Returns:\n            int: Number of detected review incidents.\n        \"\"\"\n        accepts = recent_accepts.filter(reviewed_by=self).values_list(\n            \"accepted\", flat=True\n        )\n        timestamps = list(accepts)\n        timestamps.sort()\n        incidents = 0\n        for i in range(len(timestamps)):\n            count = 1\n            for j in range(i + 1, len(timestamps)):\n                if (timestamps[j] - timestamps[i]).seconds <= 60:\n                    count += 1\n                    if count == threshold:\n                        incidents += 1\n                        break\n                else:\n                    break\n        return incidents\n\n    def transcribe_incidents(self, transcriptions):\n        \"\"\"\n        Count transcription-speed incidents for this user.\n\n        An incident is counted when the user submits more than one distinct\n        asset's transcription within a 60-second window.\n\n        Args:\n            transcriptions (QuerySet): Transcription queryset to inspect. It\n                should already be filtered to this user and the desired time\n                range.\n\n        Returns:\n            int: Number of detected transcription incidents.\n        \"\"\"\n        transcriptions = transcriptions.filter(user=self).order_by(\"submitted\")\n        incidents = 0\n        for transcription in transcriptions:\n            start = transcription.submitted\n            end = transcription.submitted + datetime.timedelta(minutes=1)\n            if (\n                transcriptions.filter(submitted__lte=end, submitted__gt=start)\n                .exclude(asset=transcription.asset)\n                .count()\n                > 0\n            ):\n                incidents += 1\n        return incidents\n\n    @property\n    def transcription_accepted_cache_key(self):\n        \"\"\"\n        Return the cache key used to track this user's recent accept timestamps.\n        \"\"\"\n        return settings.TRANSCRIPTION_ACCEPTED_TRACKING_KEY.format(user_id=self.id)\n\n    def check_and_track_accept_limit(self, transcription):\n        \"\"\"\n        Enforce and update the per-minute accept-rate limit for this user.\n\n        For non-superusers, this loads the recent acceptance timestamps from\n        the cache, discards values older than one minute, and checks the\n        resulting count against the ``review_rate_limit`` configuration\n        value. If recording another acceptance would exceed that limit, a\n        :class:`RateLimitExceededError` is raised. Otherwise, the current\n        timestamp is appended and written back to the cache.\n\n        Args:\n            transcription (Transcription): The transcription being accepted.\n                (The argument is not inspected, but kept for call-site\n                clarity.)\n\n        Raises:\n            RateLimitExceededError: If the user would exceed the configured\n                rate limit.\n        \"\"\"\n        if not self.is_superuser:\n            key = self.transcription_accepted_cache_key\n            now = timezone.now()\n            one_minute_ago = now - ONE_MINUTE\n\n            timestamps = cache.get(key, [])\n            valid_timestamps = [ts for ts in timestamps if ts >= one_minute_ago]\n\n            if len(valid_timestamps) and len(valid_timestamps) >= configuration_value(\n                \"review_rate_limit\"\n            ):\n                raise RateLimitExceededError()\n\n            valid_timestamps.append(now)\n            cache.set(key, valid_timestamps, 60)\n\n\nclass UserProfile(MetricsModelMixin(\"userprofile\"), models.Model):\n    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name=\"profile\")\n    transcribe_count = models.IntegerField(\n        default=0, verbose_name=\"transcription save/submit count\"\n    )\n    review_count = models.IntegerField(\n        default=0, verbose_name=\"transcription review count\"\n    )\n\n\nclass OverlayPosition(object):\n    \"\"\"\n    Used in carousel slide content management\n    \"\"\"\n\n    LEFT = \"left\"\n    RIGHT = \"right\"\n\n    CHOICES = ((LEFT, \"Left\"), (RIGHT, \"Right\"))\n    CHOICE_MAP = dict(CHOICES)\n\n\nclass TranscriptionStatus(object):\n    \"\"\"\n    Status values used for rollup summaries of an asset's transcription status\n    to avoid needing to do nested queries in views\n    \"\"\"\n\n    NOT_STARTED = \"not_started\"\n    IN_PROGRESS = \"in_progress\"\n    SUBMITTED = \"submitted\"\n    COMPLETED = \"completed\"\n\n    CHOICES = (\n        (NOT_STARTED, \"Not Started\"),\n        (IN_PROGRESS, \"In Progress\"),\n        (SUBMITTED, \"Needs Review\"),\n        (COMPLETED, \"Completed\"),\n    )\n    CHOICE_MAP = dict(CHOICES)\n\n\nSTATUS_COUNT_KEYS = {\n    status: f\"{status}_count\" for status in TranscriptionStatus.CHOICE_MAP\n}\n\n\nclass MediaType:\n    \"\"\"\n    Enumeration of supported asset media types.\n    \"\"\"\n\n    IMAGE = \"IMG\"\n    AUDIO = \"AUD\"\n    VIDEO = \"VID\"\n\n    CHOICES = ((IMAGE, \"Image\"), (AUDIO, \"Audio\"), (VIDEO, \"Video\"))\n\n\nclass PublicationQuerySet(models.QuerySet):\n    def published(self):\n        \"\"\"\n        Return queryset filtered to published objects.\n        \"\"\"\n        return self.filter(published=True)\n\n    def unpublished(self):\n        \"\"\"\n        Return queryset filtered to unpublished objects.\n        \"\"\"\n        return self.filter(published=False)\n\n\nclass UnlistedPublicationQuerySet(PublicationQuerySet):\n    def annotated(self):\n        \"\"\"\n        Return campaigns/topics annotated with asset counts and completion data.\n\n        The returned queryset includes:\n\n        - ``asset_count``: Number of published assets reachable through the\n          associated projects and items.\n        - Per-status counts based on :data:`STATUS_COUNT_KEYS`, such as\n          ``completed_count`` and ``submitted_count``.\n        - ``completed_percent`` and ``needs_review_percent``: Rounded\n          percentages of assets in the completed or needs-review state,\n          clamped so that 100 percent is only returned if all assets are in\n          that state.\n        \"\"\"\n        return (\n            self.annotate(\n                asset_count=Count(\n                    \"project__item__asset\",\n                    filter=Q(\n                        project__published=True,\n                        project__item__published=True,\n                        project__item__asset__published=True,\n                    ),\n                )\n            )\n            .filter(asset_count__gt=0)\n            .annotate(\n                **{\n                    v: Count(\n                        \"project__item__asset\",\n                        filter=Q(\n                            project__published=True,\n                            project__item__published=True,\n                            project__item__asset__published=True,\n                            project__item__asset__transcription_status=k,\n                        ),\n                    )\n                    for k, v in STATUS_COUNT_KEYS.items()\n                }\n            )\n            # PostgreSQL does integer division when given two integers, which results\n            # in the decimal results being dropped. We implicitly cast one field to\n            # be a float through multiplication in order to do floating point division\n            .annotate(\n                completed_raw_percent=ExpressionWrapper(\n                    100 * F(\"completed_count\") * 1.0 / F(\"asset_count\"),\n                    output_field=models.FloatField(),\n                ),\n                needs_review_raw_percent=ExpressionWrapper(\n                    100 * F(\"submitted_count\") * 1.0 / F(\"asset_count\"),\n                    output_field=models.FloatField(),\n                ),\n            )\n            # Due to rounding issues, we explicitly only allow a 100% value if all\n            # assets are in a particular status. Otherwise, we clamp to a maximum of\n            # 99%\n            .annotate(\n                completed_percent=Case(\n                    When(\n                        completed_raw_percent__gte=99,\n                        completed_raw_percent__lt=100,\n                        then=Value(99),\n                    ),\n                    default=Round(F(\"completed_raw_percent\")),\n                    output_field=models.FloatField(),\n                ),\n                needs_review_percent=Case(\n                    When(\n                        needs_review_raw_percent__gte=99,\n                        needs_review_raw_percent__lt=100,\n                        then=Value(99),\n                    ),\n                    default=Round(F(\"needs_review_raw_percent\")),\n                    output_field=models.FloatField(),\n                ),\n            )\n        )\n\n    def listed(self):\n        return self.filter(unlisted=False)\n\n    def unlisted(self):\n        return self.filter(unlisted=True)\n\n    def active(self):\n        return self.filter(status=Campaign.Status.ACTIVE)\n\n    def completed(self):\n        return self.filter(status=Campaign.Status.COMPLETED)\n\n    def retired(self):\n        return self.filter(status=Campaign.Status.RETIRED)\n\n    def get_next_transcription_campaigns(self):\n        return self.filter(next_transcription_campaign=True)\n\n    def get_next_review_campaigns(self):\n        return self.filter(next_review_campaign=True)\n\n\nclass Card(models.Model):\n    image_alt_text = models.TextField(blank=True)\n    image = models.ImageField(upload_to=\"card_images\", blank=True, null=True)\n    title = models.CharField(max_length=80)\n    body_text = models.TextField(blank=True)\n    created_on = models.DateTimeField(editable=False, auto_now_add=True)\n    updated_on = models.DateTimeField(editable=False, auto_now=True, null=True)\n    display_heading = models.CharField(max_length=80, blank=True, null=True)\n\n    def __str__(self):\n        return self.title\n\n    class Meta:\n        ordering = (\"title\",)\n\n\nclass CardFamily(models.Model):\n    slug = models.SlugField(max_length=80, unique=True, allow_unicode=True)\n    default = models.BooleanField(default=False)\n    cards = models.ManyToManyField(Card, through=\"TutorialCard\")\n\n    class Meta:\n        verbose_name_plural = \"card families\"\n\n    def __str__(self):\n        return self.slug\n\n\ndef on_cardfamily_save(sender, instance, **kwargs):\n    # Only one tutorial/ list of cards should be marked as \"default\".\n    # If the flag is set on a tutorial, it needs to be cleared from\n    # any other existing tutorials.\n    if instance.default:\n        CardFamily.objects.filter(default=True).exclude(pk=instance.pk).update(\n            default=False\n        )\n\n\npost_save.connect(on_cardfamily_save, sender=CardFamily)\n\n\nclass ResearchCenter(models.Model):\n    title = models.CharField(max_length=80)\n\n    def __str__(self):\n        return self.title\n\n\nclass Campaign(MetricsModelMixin(\"campaign\"), models.Model):\n    class Status(models.IntegerChoices):\n        ACTIVE = 1\n        COMPLETED = 2\n        RETIRED = 3\n\n    objects = UnlistedPublicationQuerySet.as_manager()\n\n    published = models.BooleanField(default=False, blank=True, db_index=True)\n    unlisted = models.BooleanField(default=False, blank=True, db_index=True)\n    status = models.IntegerField(choices=Status.choices, default=Status.ACTIVE)\n    next_transcription_campaign = models.BooleanField(\n        default=False, blank=True, db_index=True, verbose_name=\"Next-tran.\"\n    )\n    next_review_campaign = models.BooleanField(\n        default=False, blank=True, db_index=True, verbose_name=\"Next-rev.\"\n    )\n\n    ordering = models.IntegerField(\n        default=0, help_text=\"Sort order override: lower values will be listed first\"\n    )\n    display_on_homepage = models.BooleanField(default=True, verbose_name=\"Homepage\")\n\n    title = models.CharField(max_length=80)\n    slug = models.SlugField(max_length=80, unique=True, allow_unicode=True)\n\n    card_family = models.ForeignKey(\n        CardFamily, on_delete=models.CASCADE, blank=True, null=True\n    )\n    thumbnail_image = models.ImageField(\n        upload_to=\"campaign-thumbnails\", blank=True, null=True\n    )\n    image_alt_text = models.TextField(blank=True, null=True)\n\n    launch_date = models.DateField(null=True, blank=True)\n    completed_date = models.DateField(null=True, blank=True)\n\n    description = models.TextField(blank=True)\n    short_description = models.TextField(blank=True)\n\n    metadata = JSONField(default=metadata_default, blank=True, null=True)\n\n    disable_ocr = models.BooleanField(\n        default=False, help_text=\"Turn OCR off for all assets of this campaign\"\n    )\n\n    research_centers = models.ManyToManyField(ResearchCenter, blank=True)\n\n    class Meta:\n        indexes = [\n            models.Index(fields=[\"published\", \"unlisted\"]),\n        ]\n        permissions = [\n            (\"retire_campaign\", \"Can retire campaign\"),\n        ]\n\n    def __str__(self):\n        return self.title\n\n    def get_absolute_url(self):\n        return reverse(\"transcriptions:campaign-detail\", args=(self.slug,))\n\n\nclass Topic(models.Model):\n    objects = UnlistedPublicationQuerySet.as_manager()\n\n    published = models.BooleanField(default=False, blank=True, db_index=True)\n    unlisted = models.BooleanField(default=False, blank=True, db_index=True)\n\n    ordering = models.IntegerField(\n        default=0, help_text=\"Sort order override: lower values will be listed first\"\n    )\n    title = models.CharField(blank=False, max_length=255)\n    slug = models.SlugField(blank=False, allow_unicode=True, max_length=80)\n    description = models.TextField(blank=True)\n    thumbnail_image = models.ImageField(\n        upload_to=\"topic-thumbnails\", blank=True, null=True\n    )\n    short_description = models.TextField(blank=True)\n\n    class Meta:\n        indexes = [\n            models.Index(fields=[\"published\", \"unlisted\"]),\n        ]\n\n    def __str__(self):\n        return self.title\n\n    def get_absolute_url(self):\n        return reverse(\"topic-detail\", kwargs={\"slug\": self.slug})\n\n\nclass HelpfulLinkTypeQuerySet(models.QuerySet):\n    def related_links(self):\n        return self.filter(link_type=HelpfulLink.HelpfulLinkType.RELATED_LINK)\n\n    def completed_transcription_links(self):\n        return self.filter(\n            link_type=HelpfulLink.HelpfulLinkType.COMPLETED_TRANSCRIPTION_LINK\n        )\n\n\nclass HelpfulLink(MetricsModelMixin(\"resource\"), models.Model):\n    \"\"\"\n    This model was previously known as `Resource`. It was renamed to avoid\n    conflict with the same name being used on loc.gov.\n\n    The original table and row names have been maintained.\n    \"\"\"\n\n    class HelpfulLinkType(models.IntegerChoices):\n        RELATED_LINK = 1\n        COMPLETED_TRANSCRIPTION_LINK = 2\n\n    objects = HelpfulLinkTypeQuerySet.as_manager()\n\n    sequence = models.PositiveIntegerField(default=1)\n    title = models.CharField(blank=False, max_length=255)\n    link_type = models.IntegerField(\n        choices=HelpfulLinkType.choices,\n        default=HelpfulLinkType.RELATED_LINK,\n        db_column=\"resource_type\",\n    )\n    link_url = models.URLField(db_column=\"resource_url\")\n\n    campaign = models.ForeignKey(\n        Campaign, on_delete=models.CASCADE, blank=True, null=True\n    )\n    topic = models.ForeignKey(Topic, on_delete=models.CASCADE, blank=True, null=True)\n\n    class Meta:\n        ordering = (\"sequence\",)\n        db_table = \"concordia_resource\"\n\n    def __str__(self):\n        return self.title\n\n\nclass ConcordiaFile(models.Model):\n    \"\"\"\n    This model was previously known as `ResourceFile`. I twas renamed to avoid\n    conflict with the same name being used on loc.gov.\n\n    The original table and row names have been maintained.\n    \"\"\"\n\n    name = models.CharField(blank=False, max_length=255)\n    path = models.CharField(blank=True, default=\"\", max_length=255)\n    uploaded_file = models.FileField(\n        upload_to=resource_file_upload_path, db_column=\"resource\"\n    )\n    updated_on = models.DateTimeField(auto_now=True)\n\n    class Meta:\n        ordering = [\"name\"]\n        db_table = \"concordia_resourcefile\"\n\n    def __str__(self):\n        return self.name\n\n    def save(self, *args, **kwargs):\n        super().save(*args, **kwargs)\n        if self.id and not self.path:\n            self.path = self.uploaded_file.name\n            self.save()\n\n    def delete(self, *args, **kwargs):\n        storage = self.uploaded_file.storage\n\n        if storage.exists(self.uploaded_file.name):\n            self.uploaded_file.delete(save=False)\n\n        super().delete(*args, **kwargs)\n\n\nclass Project(MetricsModelMixin(\"project\"), models.Model):\n    objects = PublicationQuerySet.as_manager()\n\n    campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE)\n\n    published = models.BooleanField(default=False, blank=True, db_index=True)\n    ordering = models.IntegerField(\n        default=0, help_text=\"Sort order override: lower values will be listed first\"\n    )\n    title = models.CharField(max_length=80)\n    slug = models.SlugField(max_length=80, allow_unicode=True)\n    thumbnail_image = models.ImageField(\n        upload_to=\"project-thumbnails\", blank=True, null=True\n    )\n\n    description = models.TextField(blank=True)\n    metadata = JSONField(default=metadata_default, blank=True, null=True)\n\n    topics = models.ManyToManyField(\"Topic\", through=\"ProjectTopic\")\n\n    disable_ocr = models.BooleanField(\n        default=False, help_text=\"Turn OCR off for all assets of this project\"\n    )\n\n    class Meta:\n        unique_together = ((\"slug\", \"campaign\"),)\n        ordering = [\"title\"]\n        indexes = [\n            models.Index(fields=[\"id\", \"campaign\", \"published\"]),\n            models.Index(fields=[\"published\", \"campaign\", \"title\"]),\n        ]\n\n    def __str__(self):\n        return self.title\n\n    def get_absolute_url(self):\n        return reverse(\n            \"transcriptions:project-detail\",\n            kwargs={\"campaign_slug\": self.campaign.slug, \"slug\": self.slug},\n        )\n\n    def turn_off_ocr(self):\n        return self.disable_ocr or self.campaign.disable_ocr\n\n\nclass Item(MetricsModelMixin(\"item\"), models.Model):\n    objects = PublicationQuerySet.as_manager()\n\n    project = models.ForeignKey(Project, on_delete=models.CASCADE)\n\n    published = models.BooleanField(default=False, blank=True)\n\n    title = models.CharField(max_length=1000)\n    item_url = models.URLField(max_length=255)\n    item_id = models.CharField(\n        max_length=100, help_text=\"Unique item ID assigned by the upstream source\"\n    )\n    description = models.TextField(blank=True)\n    metadata = JSONField(\n        default=metadata_default,\n        blank=True,\n        null=True,\n        help_text=\"Raw metadata returned by the remote API\",\n    )\n    thumbnail_url = models.URLField(max_length=255, blank=True, null=True)\n    thumbnail_image = models.ImageField(\n        upload_to=\"item-thumbnails\", blank=True, null=True\n    )\n\n    disable_ocr = models.BooleanField(\n        default=False, help_text=\"Turn OCR off for all assets of this item\"\n    )\n\n    class Meta:\n        unique_together = ((\"item_id\", \"project\"),)\n        indexes = [models.Index(fields=[\"project\", \"published\"])]\n\n    def __str__(self):\n        return f\"{self.item_id}: {self.title}\"\n\n    def get_absolute_url(self):\n        return reverse(\n            \"transcriptions:item-detail\",\n            kwargs={\n                \"campaign_slug\": self.project.campaign.slug,\n                \"project_slug\": self.project.slug,\n                \"item_id\": self.item_id,\n            },\n        )\n\n    @property\n    def thumbnail_link(self) -> str | None:\n        \"\"\"\n        Return the preferred thumbnail URL.\n\n        Prefers thumbnail_image if present and valid; otherwise falls back to\n        thumbnail_url. Returns None if neither is available.\n\n        TODO: Remove this when removing thumbnail_url and switch template\n        to use thumbnail_image directly (transcriptions/project_detail.html)\n        \"\"\"\n        if self.thumbnail_image:\n            try:\n                return self.thumbnail_image.url\n            except ValueError:\n                # File missing from storage, fall back to thumbnail_url\n                # since we can for now\n                pass\n        return self.thumbnail_url or None\n\n    def turn_off_ocr(self):\n        return self.disable_ocr or self.project.turn_off_ocr()\n\n\nclass AssetQuerySet(PublicationQuerySet):\n    def add_contribution_counts(self):\n        \"\"\"Add annotations for the number of transcriptions & users\"\"\"\n\n        return self.annotate(\n            transcription_count=Count(\"transcription\", distinct=True),\n            transcriber_count=Count(\"transcription__user\", distinct=True),\n            reviewer_count=Count(\"transcription__reviewed_by\", distinct=True),\n        )\n\n\nclass Asset(MetricsModelMixin(\"asset\"), models.Model):\n    def get_storage_path(self, filename):\n        extension = os.path.splitext(filename)[1].lstrip(\".\").lower()\n        if extension == \"jpeg\":\n            extension = \"jpg\"\n        return self.get_asset_image_filename(extension)\n\n    objects = AssetQuerySet.as_manager()\n\n    item = models.ForeignKey(Item, on_delete=models.CASCADE)\n    campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE)\n\n    published = models.BooleanField(default=False, blank=True, db_index=True)\n\n    title = models.CharField(max_length=100)\n    slug = models.SlugField(max_length=100, allow_unicode=True)\n\n    description = models.TextField(blank=True)\n    media_type = models.CharField(\n        max_length=4, choices=MediaType.CHOICES, db_index=True\n    )\n    sequence = models.PositiveIntegerField(default=1)\n    year = models.CharField(blank=True, max_length=50)\n\n    # The original ID of the image resource on loc.gov\n    resource_url = models.URLField(max_length=255, blank=True, null=True)\n    # The URL used to download this image from loc.gov\n    download_url = models.CharField(max_length=255, blank=True, null=True)\n\n    metadata = JSONField(default=metadata_default, blank=True, null=True)\n\n    # This is computed from the Transcription records and should never\n    # be directly modified except by the Transcription signal handler:\n    transcription_status = models.CharField(\n        editable=False,\n        max_length=20,\n        default=TranscriptionStatus.NOT_STARTED,\n        choices=TranscriptionStatus.CHOICES,\n        db_index=True,\n    )\n\n    difficulty = models.PositiveIntegerField(default=0, blank=True, null=True)\n\n    storage_image = models.ImageField(\n        upload_to=get_storage_path, storage=ASSET_STORAGE, max_length=255\n    )\n\n    disable_ocr = models.BooleanField(\n        default=False, help_text=\"Turn OCR off for this asset\"\n    )\n\n    class Meta:\n        unique_together = ((\"slug\", \"item\"),)\n        indexes = [\n            models.Index(\n                fields=[\"item\", \"published\", \"transcription_status\", \"sequence\"]\n            ),\n            models.Index(\n                fields=[\"published\", \"transcription_status\", \"item\", \"sequence\"]\n            ),\n            models.Index(fields=[\"published\", \"transcription_status\"]),\n            models.Index(fields=[\"item\", \"sequence\"]),\n            models.Index(fields=[\"campaign\", \"sequence\"]),\n        ]\n        permissions = [\n            (\"reopen_asset\", \"Can reopen asset\"),\n        ]\n\n    def __str__(self):\n        return self.title\n\n    def save(self, *args, **kwargs):\n        try:\n            self.campaign  # noqa: B018\n        except ObjectDoesNotExist:\n            self.campaign = self.item.project.campaign\n        # This ensures all 'required' fields really are required\n        # even when creating objects programmatically. Particularly,\n        # we want to make sure we don't end up with an empty storage_image\n        self.full_clean()\n        super().save(*args, **kwargs)\n\n    def get_absolute_url(self):\n        return reverse(\n            \"transcriptions:asset-detail\",\n            kwargs={\n                \"campaign_slug\": self.item.project.campaign.slug,\n                \"project_slug\": self.item.project.slug,\n                \"item_id\": self.item.item_id,\n                \"slug\": self.slug,\n            },\n        )\n\n    @cached_property\n    def logger(self):\n        return structured_logger.bind(asset=self)\n\n    def latest_transcription(self):\n        return self.transcription_set.order_by(\"-pk\").first()\n\n    @staticmethod\n    def get_asset_image_path(item):\n        return os.path.join(item.project.campaign.slug, item.project.slug, item.item_id)\n\n    def get_asset_image_filename(self, extension=\"jpg\"):\n        return os.path.join(\n            self.get_asset_image_path(self.item), f\"{self.sequence}.{extension}\"\n        )\n\n    def get_existing_storage_image_filename(self):\n        return os.path.basename(self.storage_image.name)\n\n    def get_ocr_transcript(self, language=None):\n        if language and language not in settings.PYTESSERACT_ALLOWED_LANGUAGES:\n            logger.warning(\n                \"OCR language '%s' not in settings.PYTESSERACT_ALLOWED_LANGUAGES. \"\n                \"Allowed languages: %s\",\n                language,\n                settings.PYTESSERACT_ALLOWED_LANGUAGES,\n            )\n            structured_logger.warning(\n                \"OCR language not allowed; falling back to default.\",\n                event_code=\"ocr_language_not_allowed\",\n                reason=\"The requested OCR language is not in the allowed list.\",\n                reason_code=\"ocr_language_not_permitted\",\n                language=language,\n                allowed_languages=settings.PYTESSERACT_ALLOWED_LANGUAGES,\n            )\n            language = None\n        structured_logger.info(\n            \"Running OCR on asset image.\",\n            event_code=\"ocr_run_started\",\n            asset=self,\n            language=language,\n        )\n        return pytesseract.image_to_string(\n            Image.open(self.storage_image), lang=language\n        )\n\n    def get_contributor_count(self):\n        transcriptions = Transcription.objects.filter(asset=self)\n        reviewer_ids = (\n            transcriptions.exclude(reviewed_by__isnull=True)\n            .values_list(\"reviewed_by\", flat=True)\n            .distinct()\n        )\n        transcriber_ids = transcriptions.values_list(\"user\", flat=True).distinct()\n        user_ids = list(set(list(reviewer_ids) + list(transcriber_ids)))\n        return len(user_ids)\n\n    def turn_off_ocr(self):\n        return self.disable_ocr or self.item.turn_off_ocr()\n\n    def can_rollback(\n        self,\n    ) -> Tuple[bool, Union[str, \"Transcription\"], Optional[\"Transcription\"]]:\n        \"\"\"\n        Determine whether the latest transcription on this asset can be rolled back.\n\n        This checks the transcription history for the most recent non-rolled-forward\n        transcription that precedes the current latest transcription, excluding any\n        transcriptions that are rollforwards or are sources of rollforwards.\n\n        A rollback is only possible if:\n        - There is more than one transcription.\n        - There is a prior transcription that is not a rollforward or source of one.\n\n        This method does not perform the rollback, only checks feasibility.\n\n        Returns:\n            result (tuple): A (bool, value, latest) tuple describing rollback\n                possibility.\n\n        Return Behavior:\n            - If no transcriptions exist: returns (False, reason_string, None).\n            - If no eligible rollback target exists: returns (False, reason_string,\n              None).\n            - If rollback is possible: returns (True, target_transcription,\n              latest_transcription).\n        \"\"\"\n        # original_latest_transcription holds the actual latest transcription\n        # latest_transcription starts by holding the actual latest transcription,\n        # but if it's a rolled forward or backward transcription, we use it to\n        # find the most recent non-rolled transcription and store it instead\n        original_latest_transcription = latest_transcription = (\n            self.latest_transcription()\n        )\n        if original_latest_transcription is None:\n            self.logger.debug(\n                \"No transcriptions exist for this asset.\",\n                event_code=\"rollback_check_failed\",\n                reason_code=\"no_transcriptions\",\n                reason=\"This asset has no transcriptions, so rollback is not possible.\",\n            )\n            return (\n                False,\n                \"Can not rollback transcription on an asset with no transcriptions\",\n                None,\n            )\n\n        # If the latest transcription has a source (i.e., is a rollback\n        # or rollforward transcription), we want the original transcription\n        # that it's based on, back to the original source\n        while latest_transcription.source:\n            latest_transcription = latest_transcription.source\n\n        if original_latest_transcription.source:\n            self.logger.debug(\n                \"Using source transcription as effective latest transcription \"\n                \"for rollback.\",\n                event_code=\"rollback_resolve_source\",\n                original_transcription_id=original_latest_transcription.id,\n                resolved_transcription_id=latest_transcription.id,\n            )\n\n        # We look back from the latest non-rolled transcription,\n        # ignoring any rolled forward or sources of rolled forward\n        # transcriptions\n        transcription_to_rollback_to = (\n            self.transcription_set.exclude(rolled_forward=True)\n            .exclude(source_of__rolled_forward=True)\n            .exclude(pk__gte=latest_transcription.pk)\n            .order_by(\"-pk\")\n            .first()\n        )\n        if transcription_to_rollback_to is None:\n            # We did not find one, which means there is no eligible\n            # transcription to rollback to, because everything before\n            # is either a rollforward or the source of a rollforward\n            # (or there just is not an earlier transcription at all)\n            self.logger.debug(\n                \"No eligible transcription found for rollback.\",\n                event_code=\"rollback_check_failed\",\n                reason_code=\"no_eligible_transcription\",\n                reason=(\n                    \"There are no earlier transcriptions that can be rolled back to. \"\n                    \"All earlier transcriptions are rollforwards or sources of \"\n                    \"rollforwards.\"\n                ),\n                latest_transcription_id=original_latest_transcription.id,\n            )\n            return (\n                False,\n                (\n                    \"Can not rollback transcription on an asset \"\n                    \"with no non-rollforward older transcriptions\"\n                ),\n                None,\n            )\n\n        self.logger.debug(\n            \"Eligible rollback target found.\",\n            event_code=\"rollback_check_passed\",\n            reason_code=\"rollback_target_identified\",\n            reason=\"Found older transcription not marked as rollforward.\",\n            target_transcription_id=transcription_to_rollback_to.id,\n            latest_transcription_id=original_latest_transcription.id,\n        )\n        return True, transcription_to_rollback_to, original_latest_transcription\n\n    def rollback_transcription(self, user: User) -> \"Transcription\":\n        \"\"\"\n        Perform a rollback of the latest transcription on this asset.\n\n        This creates a new transcription that copies the text of the most recent\n        eligible prior transcription (as determined by ``can_rollback``) and marks\n        it as rolled back. It also updates the original latest transcription to\n        reflect that it has been superseded.\n\n        If rollback is not possible, raises a ``ValueError``.\n\n        The new transcription will:\n            - Have ``rolled_back=True``.\n            - Set its ``source`` to the transcription it is rolled back to.\n            - Set ``supersedes`` to the current latest transcription.\n\n        Args:\n            user (User): The user performing the rollback.\n\n        Returns:\n            Transcription: The newly created rollback transcription.\n\n        Raises:\n            ValueError: If rollback is not possible due to invalid or missing\n                history.\n        \"\"\"\n        results = self.can_rollback()\n        if results[0] is not True:\n            self.logger.warning(\n                \"Rollback attempt failed: no valid rollback target.\",\n                event_code=\"rollback_attempt_failed\",\n                reason_code=\"no_valid_target\",\n                reason=results[1],\n                user=user,\n            )\n            raise ValueError(results[1])\n\n        transcription_to_rollback_to = results[1]\n        original_latest_transcription = results[2]\n\n        self.logger.debug(\n            \"Preparing rollback transcription.\",\n            event_code=\"rollback_prepare\",\n            user=user,\n            source_transcription_id=transcription_to_rollback_to.id,\n            superseded_transcription_id=original_latest_transcription.id,\n        )\n\n        kwargs = {\n            \"asset\": self,\n            \"user\": user,\n            \"supersedes\": original_latest_transcription,\n            \"text\": transcription_to_rollback_to.text,\n            \"rolled_back\": True,\n            \"source\": transcription_to_rollback_to,\n        }\n        new_transcription = Transcription(**kwargs)\n        new_transcription.full_clean()\n        new_transcription.save()\n\n        self.logger.info(\n            \"Rollback successfully performed.\",\n            event_code=\"rollback_success\",\n            user=user,\n            new_transcription_id=new_transcription.id,\n            rolled_back_from_id=original_latest_transcription.id,\n            rolled_back_to_id=transcription_to_rollback_to.id,\n        )\n        return new_transcription\n\n    def can_rollforward(\n        self,\n    ) -> Tuple[bool, Union[str, \"Transcription\"], Optional[\"Transcription\"]]:\n        \"\"\"\n        Determine whether a previous rollback on this asset can be rolled forward.\n\n        This checks whether the most recent transcription is a rollback transcription\n        and whether the transcription it replaced (its ``supersedes``) can be\n        restored.\n\n        This method handles cases where multiple rollforwards were applied,\n        walking backward through the transcription chain to find the appropriate\n        rollback origin.\n\n        A rollforward is only possible if:\n        - The latest transcription is a rollback.\n        - The rollback's superseded transcription still exists and can be\n          restored.\n\n        This method does not perform the rollforward, only checks feasibility.\n\n        Returns:\n            result (tuple): A (bool, value, latest) tuple describing rollforward\n                possibility.\n\n        Return Behavior:\n            - If no transcriptions exist: returns (False, reason_string, None).\n            - If rollforward is not possible: returns (False, reason_string, None).\n            - If rollforward is possible: returns\n              (True, transcription_to_rollforward, latest_transcription).\n        \"\"\"\n        # original_latest_transcription holds the actual latest transcription\n        # latest_transcription starts by holding the actual latest transcription,\n        # but if it is a rolled forward transcription, we use it to find the most\n        # recent non-rolled-forward transcription and store that in\n        # latest_transcription\n        original_latest_transcription = latest_transcription = (\n            self.latest_transcription()\n        )\n\n        if original_latest_transcription is None:\n            self.logger.debug(\n                \"No transcriptions exist for this asset.\",\n                event_code=\"rollforward_check_failed\",\n                reason_code=\"no_transcriptions\",\n                reason=(\n                    \"This asset has no transcriptions, \"\n                    \"so rollforward is not possible.\"\n                ),\n            )\n            return (\n                False,\n                (\n                    \"Can not rollforward transcription on an asset \"\n                    \"with no transcriptions\"\n                ),\n                None,\n            )\n\n        # Rollforwards can be chained through multiple rollback/forward cycles,\n        # so we may need to walk back the supersedes chain to find the original.\n        if latest_transcription.rolled_forward:\n            # We need to find the latest transcription that was not rolled forward\n            rolled_forward_count = 0\n            try:\n                while latest_transcription.rolled_forward:\n                    latest_transcription = latest_transcription.supersedes\n                    rolled_forward_count += 1\n                self.logger.debug(\n                    \"Walking back through rolled_forward transcriptions.\",\n                    event_code=\"rollforward_resolve_chain\",\n                    reason_code=\"resolve_rolled_forward_chain\",\n                    reason=(\n                        f\"Resolved {rolled_forward_count} rolled_forward \"\n                        \"transcription(s) before identifying rollback target.\"\n                    ),\n                    rolled_forward_count=rolled_forward_count,\n                )\n            except AttributeError:\n                self.logger.warning(\n                    (\n                        \"Rollforward failed: unable to resolve chain of \"\n                        \"rolled_forward transcriptions.\"\n                    ),\n                    event_code=\"rollforward_check_failed\",\n                    reason_code=\"unresolvable_rolled_forward_chain\",\n                    reason=(\n                        \"Could not walk back through rolled_forward transcriptions \"\n                        \"to find a valid rollback base. Possibly malformed \"\n                        \"transcription history (missing supersedes).\"\n                    ),\n                )\n                return (\n                    False,\n                    (\n                        \"Can not rollforward transcription on an asset with no \"\n                        \"non-rollforward transcriptions\"\n                    ),\n                    None,\n                )\n            # latest_transcription is now the most recent non-rolled-forward\n            # transcription, but we need to go back fruther based on the number\n            # of rolled-forward transcriptions we have seen to get to the actual\n            # rollback transcription we need to rollforward from\n            try:\n                while rolled_forward_count >= 1:\n                    latest_transcription = latest_transcription.supersedes\n                    if not latest_transcription:\n                        # We do this here to handle the error rather than letting\n                        # it be raised below when we try to process this\n                        # non-existent transcription\n                        raise AttributeError\n                    rolled_forward_count -= 1\n            except AttributeError:\n                # This error is raised manually if latest_transcription ends up\n                # being None at the end of the loop or automatically if it is None\n                # when the loop continues\n                # In either case, his should only happen if the transcription\n                # history was manually edited.\n                self.logger.warning(\n                    (\n                        \"Corrupt transcription state: too \"\n                        \"many rollforwards without originals.\"\n                    ),\n                    event_code=\"rollforward_check_failed\",\n                    reason_code=\"corrupt_state\",\n                    reason=(\n                        \"More rollforward transcriptions exist than \"\n                        \"non-rollforward ones. This suggests a manually \"\n                        \"corrupted transcription history.\"\n                    ),\n                    latest_transcription_id=original_latest_transcription.id,\n                )\n                return (\n                    False,\n                    (\n                        \"More rollforward transcription exist than non-roll-forward \"\n                        \"transcriptions, which shouldn't be possible. Possibly \"\n                        \"incorrectly modified transcriptions for this asset.\"\n                    ),\n                    None,\n                )\n\n        # If the latest_transcription we end up with is a rollback transcription,\n        # we want to rollforward to the transcription it replaced. If not,\n        # nothing can be rolled forward\n        if latest_transcription.rolled_back:\n            transcription_to_rollforward = latest_transcription.supersedes\n        else:\n            self.logger.debug(\n                \"Rollforward failed: latest transcription is not a rollback.\",\n                event_code=\"rollforward_check_failed\",\n                reason_code=\"not_a_rollback\",\n                reason=(\n                    \"Can not rollforward transcription on an asset if the latest \"\n                    \"non-rollforward transcription is not a rollback transcription.\"\n                ),\n            )\n            return (\n                False,\n                (\n                    \"Can not rollforward transcription on an asset if the latest \"\n                    \"non-rollforward transcription is not a rollback transcription\"\n                ),\n                None,\n            )\n\n        # If that replaced transcription does not exist, we cannot do anything\n        # This should not be possible normally, but if a transcription history\n        # is manually edited, you could end up in this state.\n        if not transcription_to_rollforward:\n            self.logger.debug(\n                \"Rollforward failed: rollback transcription has no superseded value.\",\n                event_code=\"rollforward_check_failed\",\n                reason_code=\"no_superseded_transcription\",\n                reason=(\n                    \"Can not rollforward transcription on an asset if the latest \"\n                    \"rollback transcription did not supersede a previous \"\n                    \"transcription.\"\n                ),\n            )\n            return (\n                False,\n                (\n                    \"Can not rollforward transcription on an asset if the latest \"\n                    \"rollback transcription did not supersede a previous \"\n                    \"transcription\"\n                ),\n                None,\n            )\n\n        self.logger.debug(\n            \"Eligible rollforward target found.\",\n            event_code=\"rollforward_check_passed\",\n            target_transcription_id=transcription_to_rollforward.id,\n            latest_transcription_id=original_latest_transcription.id,\n        )\n\n        return True, transcription_to_rollforward, original_latest_transcription\n\n    def rollforward_transcription(self, user: User) -> \"Transcription\":\n        \"\"\"\n         Perform a rollforward of the most recent rollback transcription.\n\n        This creates a new transcription that restores the text from the\n        rollback's superseded transcription and marks it as a rollforward. A\n        rollforward is only possible if the latest transcription is a rollback\n        and the replaced transcription still exists.\n\n        If rollforward is not possible, raises a ``ValueError``.\n\n        The new transcription will:\n            - Have ``rolled_forward=True``.\n            - Set its ``source`` to the transcription being rolled forward to.\n            - Set ``supersedes`` to the current latest transcription.\n\n        Args:\n            user (User): The user initiating the rollforward.\n\n        Returns:\n            Transcription: The newly created rollforward transcription.\n\n        Raises:\n            ValueError: If rollforward is not possible, such as when no rollback\n                exists or the history is malformed.\n\n        Return Behavior:\n            - If rollforward is possible:\n                - Creates a new transcription restoring the original text.\n                - Marks it with ``rolled_forward=True``.\n            - If rollforward is not possible:\n                - Raises ``ValueError`` with a descriptive message.\n        \"\"\"\n        results = self.can_rollforward()\n        if results[0] is not True:\n            self.logger.warning(\n                \"Rollforward attempt failed: no valid rollforward target.\",\n                event_code=\"rollforward_attempt_failed\",\n                reason_code=\"no_valid_target\",\n                reason=results[1],\n                user=user,\n            )\n            raise ValueError(results[1])\n\n        transcription_to_rollforward = results[1]\n        original_latest_transcription = results[2]\n\n        self.logger.debug(\n            \"Preparing rollforward transcription.\",\n            event_code=\"rollforward_prepare\",\n            user=user,\n            source_transcription_id=transcription_to_rollforward.id,\n            superseded_transcription_id=original_latest_transcription.id,\n        )\n\n        kwargs = {\n            \"asset\": self,\n            \"user\": user,\n            \"supersedes\": original_latest_transcription,\n            \"text\": transcription_to_rollforward.text,\n            \"rolled_forward\": True,\n            \"source\": transcription_to_rollforward,\n        }\n        new_transcription = Transcription(**kwargs)\n        new_transcription.full_clean()\n        new_transcription.save()\n\n        self.logger.info(\n            \"Rollforward successfully performed.\",\n            event_code=\"rollforward_success\",\n            user=user,\n            new_transcription_id=new_transcription.id,\n            rolled_forward_from_id=original_latest_transcription.id,\n            rolled_forward_to_id=transcription_to_rollforward.id,\n        )\n        return new_transcription\n\n\nclass Tag(MetricsModelMixin(\"tag\"), models.Model):\n    TAG_VALIDATOR = RegexValidator(r\"^[- _À-ž'\\w]{1,50}$\")\n    value = models.CharField(max_length=50, validators=[TAG_VALIDATOR])\n\n    def __str__(self):\n        return self.value\n\n\nclass UserAssetTagCollection(\n    MetricsModelMixin(\"user_asset_tag_collection\"), models.Model\n):\n    asset = models.ForeignKey(Asset, on_delete=models.CASCADE)\n\n    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)\n\n    tags = models.ManyToManyField(Tag, blank=True)\n    created_on = models.DateTimeField(auto_now_add=True)\n    updated_on = models.DateTimeField(auto_now=True)\n\n    def __str__(self):\n        return \"{} - {}\".format(self.asset, self.user)\n\n\nclass TranscriptionManager(models.Manager):\n    def review_actions(self, start, end=None):\n        q_accepted = Q(accepted__gte=start)\n        q_rejected = Q(rejected__gte=start)\n        if end is not None:\n            q_accepted &= Q(accepted__lte=end)\n            q_rejected &= Q(rejected__lte=end)\n        return self.filter(q_accepted | q_rejected)\n\n    def recent_review_actions(self, days=1):\n        START = timezone.now() - datetime.timedelta(days=days)\n        return self.review_actions(START)\n\n    def review_incidents(self, start=ONE_DAY_AGO):\n        user_incident_count = []\n        recent_accepts = self.filter(\n            accepted__gte=start,\n            reviewed_by__is_superuser=False,\n            reviewed_by__is_staff=False,\n        )\n        user_ids = set(\n            recent_accepts.order_by(\"reviewed_by\").values_list(\"reviewed_by\", flat=True)\n        )\n\n        for user_id in user_ids:\n            user = ConcordiaUser.objects.get(id=user_id)\n            incident_count = user.review_incidents(recent_accepts)\n            if incident_count > 0:\n                accept_count = Transcription.objects.filter(\n                    reviewed_by=user, accepted__isnull=False\n                ).count()\n                user_incident_count.append(\n                    (user.id, user.username, incident_count, accept_count)\n                )\n\n        return user_incident_count\n\n    def recent_transcriptions(self, start=ONE_DAY_AGO):\n        return self.get_queryset().filter(\n            submitted__gte=start, user__is_superuser=False, user__is_staff=False\n        )\n\n    def transcribe_incidents(self, start=ONE_DAY_AGO):\n        user_incident_count = []\n        transcriptions = self.recent_transcriptions(start)\n        user_ids = (\n            transcriptions.order_by(\"user\")\n            .distinct(\"user\")\n            .values_list(\"user\", flat=True)\n        )\n\n        for user_id in user_ids:\n            user = ConcordiaUser.objects.get(id=user_id)\n            incident_count = user.transcribe_incidents(transcriptions)\n            if incident_count > 0:\n                transcribe_count = Transcription.objects.filter(user=user).count()\n                user_incident_count.append(\n                    (\n                        user.id,\n                        user.username,\n                        incident_count,\n                        transcribe_count,\n                    )\n                )\n\n        return user_incident_count\n\n\nclass Transcription(MetricsModelMixin(\"transcription\"), models.Model):\n    asset = models.ForeignKey(Asset, on_delete=models.CASCADE)\n\n    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)\n\n    created_on = models.DateTimeField(auto_now_add=True)\n    updated_on = models.DateTimeField(auto_now=True)\n\n    supersedes = models.ForeignKey(\n        \"self\",\n        blank=True,\n        null=True,\n        on_delete=models.CASCADE,\n        help_text=\"A previous transcription record which is replaced by this one\",\n        related_name=\"superseded_by\",\n    )\n\n    submitted = models.DateTimeField(\n        blank=True,\n        null=True,\n        help_text=\"Timestamp when the creator submitted this for review\",\n    )\n\n    # Review tracking:\n    accepted = models.DateTimeField(blank=True, null=True)\n    rejected = models.DateTimeField(blank=True, null=True)\n    reviewed_by = models.ForeignKey(\n        settings.AUTH_USER_MODEL,\n        blank=True,\n        null=True,\n        on_delete=models.SET_NULL,\n        related_name=\"transcription_reviewers\",\n    )\n\n    text = models.TextField(blank=True)\n\n    # ocr tracking\n    ocr_generated = models.BooleanField(\n        default=False,\n        help_text=\"Flags transcription as generated directly by OCR\",\n    )\n    ocr_originated = models.BooleanField(\n        default=False,\n        help_text=\"Flags transcription as originated from an OCR transcription\",\n    )\n\n    rolled_back = models.BooleanField(\n        default=False,\n        help_text=\"Flags transcription as being the result of a rollback (undo)\",\n    )\n    rolled_forward = models.BooleanField(\n        default=False,\n        help_text=\"Flags transcription as being the result of a rollforward (redo)\",\n    )\n    source = models.ForeignKey(\n        \"self\",\n        blank=True,\n        null=True,\n        on_delete=models.CASCADE,\n        help_text=\"The transcription source for the roll back or roll forward\",\n        related_name=\"source_of\",\n    )\n\n    objects = TranscriptionManager()\n\n    class Meta:\n        indexes = [\n            models.Index(fields=[\"asset\", \"user\"]),\n        ]\n\n    def __str__(self):\n        return f\"Transcription #{self.pk}\"\n\n    def campaign_slug(self):\n        return self.asset.item.project.campaign.slug\n\n    def clean(self):\n        if (\n            self.user\n            and self.reviewed_by\n            and self.user == self.reviewed_by\n            and self.accepted\n        ):\n            raise ValidationError(\"Transcriptions cannot be self-accepted\")\n        if self.accepted and self.rejected:\n            raise ValidationError(\"Transcriptions cannot be both accepted and rejected\")\n        return super().clean()\n\n    @property\n    def status(self):\n        if self.accepted:\n            return TranscriptionStatus.CHOICE_MAP[TranscriptionStatus.COMPLETED]\n        elif self.submitted and not self.rejected:\n            return TranscriptionStatus.CHOICE_MAP[TranscriptionStatus.SUBMITTED]\n        else:\n            return TranscriptionStatus.CHOICE_MAP[TranscriptionStatus.IN_PROGRESS]\n\n\ndef update_userprofileactivity_table(user, campaign_id, field, increment=1):\n    \"\"\"\n    Update per-user activity counters for a campaign and the user's profile.\n\n    This function updates or creates a ``UserProfileActivity`` row for the given\n    user and campaign, adjusts the requested counter field by ``increment``,\n    and recalculates the number of distinct assets the user has contributed to\n    in that campaign. It also updates the corresponding ``UserProfile`` record\n    to keep global counters in sync.\n\n    Args:\n        user: The Django user whose activity should be updated.\n        campaign_id: Primary key of the campaign to update activity for.\n        field: Name of the integer field to increment (for example,\n            ``\"transcribe_count\"`` or ``\"review_count\"``).\n        increment: Amount to add to the chosen field. Defaults to ``1``.\n\n    \"\"\"\n    structured_logger.info(\n        \"Updating user profile activity table.\",\n        event_code=\"userprofileactivity_update_start\",\n        user=user,\n        campaign_id=campaign_id,\n        activity_field=field,\n        increment=increment,\n    )\n    user_profile_activity, created = UserProfileActivity.objects.get_or_create(\n        user=user,\n        campaign_id=campaign_id,\n    )\n    if created:\n        value = increment\n        structured_logger.info(\n            \"Created new UserProfileActivity object\",\n            event_code=\"userprofileactivity_created\",\n            user=user,\n            campaign_id=campaign_id,\n        )\n\n    else:\n        value = F(field) + increment\n    setattr(user_profile_activity, field, value)\n    q = Q(transcription__user=user) | Q(transcription__reviewed_by=user)\n    user_profile_activity.asset_count = (\n        Asset.objects.filter(q)\n        .filter(item__project__campaign=campaign_id)\n        .distinct()\n        .count()\n    )\n    user_profile_activity.save()\n    structured_logger.info(\n        \"Saved UserProfileActivity.\",\n        event_code=\"userprofileactivity_saved\",\n        user=user,\n        campaign_id=campaign_id,\n        updated_field=field,\n    )\n    if hasattr(user, \"profile\"):\n        profile = user.profile\n        value = F(field) + increment\n    else:\n        profile = UserProfile.objects.create(user=user)\n        value = increment\n        structured_logger.info(\n            \"Created new UserProfile OBJECT\",\n            event_code=\"userprofile_created\",\n            user=user,\n        )\n\n    setattr(profile, field, value)\n    profile.save()\n    structured_logger.info(\n        \"Saved UserProfile\",\n        event_code=\"userprofile_saved\",\n        user=user,\n        updated_field=field,\n    )\n\n\ndef _update_useractivity_cache(user_id, campaign_id, attr_name):\n    \"\"\"\n    Update the in-memory cache of user activity for a campaign.\n\n    The cache stores a mapping of ``user_id`` to a tuple\n    ``(transcribe_count, review_count)`` for each campaign. This helper\n    increments the requested attribute and persists the updated mapping.\n\n    Args:\n        user_id: ID of the user whose cached counters should be updated.\n        campaign_id: ID of the related campaign.\n        attr_name: Name of the activity type to increment, either\n            ``\"transcribe\"`` or ``\"review\"``.\n    \"\"\"\n    key = f\"userprofileactivity_{campaign_id}\"\n    updates = cache.get(key, {})\n    transcribe_count, review_count = updates.get(user_id, (0, 0))\n    if attr_name == \"transcribe\":\n        transcribe_count += 1\n    else:\n        review_count += 1\n    updates[user_id] = (transcribe_count, review_count)\n    cache.set(key, updates, timeout=None)\n    structured_logger.info(\n        \"Updated user activity cache\",\n        event_code=\"useractivity_cache_updated\",\n        user_id=user_id,\n        campaign_id=campaign_id,\n        updated_field=attr_name,\n        new_transcribe_count=transcribe_count,\n        new_review_count=review_count,\n    )\n\n\nclass AssetTranscriptionReservation(models.Model):\n    \"\"\"\n    Record a user's reservation to transcribe a particular asset.\n\n    The reservation token encodes both a short reservation identifier and the\n    user information. Convenience methods slice the stored token to return\n    each component.\n    \"\"\"\n\n    asset = models.ForeignKey(Asset, on_delete=models.CASCADE)\n    reservation_token = models.CharField(max_length=50)\n\n    created_on = models.DateTimeField(editable=False, auto_now_add=True)\n    updated_on = models.DateTimeField(auto_now=True)\n    tombstoned = models.BooleanField(default=False, blank=True, null=True)\n\n    def get_token(self):\n        return self.reservation_token[:44]\n\n    def get_user(self):\n        return self.reservation_token[44:]\n\n\nclass SimplePage(models.Model):\n    \"\"\"\n    Simple, CMS-like content page addressable by a URL path.\n\n    These records back lightweight informational pages that can be edited\n    via the Django admin instead of being hard-coded in templates.\n    \"\"\"\n\n    created_on = models.DateTimeField(editable=False, auto_now_add=True)\n    updated_on = models.DateTimeField(editable=False, auto_now=True)\n\n    path = models.CharField(\n        max_length=255,\n        help_text=\"URL path where this page will be accessible from\",\n        validators=[RegexValidator(r\"^/.+/$\")],\n    )\n\n    title = models.CharField(max_length=200)\n\n    body = models.TextField(blank=True, null=True)\n\n    def __str__(self):\n        return f\"SimplePage: {self.path}\"\n\n\nclass Banner(models.Model):\n    \"\"\"\n    Site-wide banner for alerts or announcements.\n\n    Banners can link out to supporting pages and use a limited set of\n    alert-style color classes.\n    \"\"\"\n\n    created_on = models.DateTimeField(editable=False, auto_now_add=True)\n    updated_on = models.DateTimeField(editable=False, auto_now=True)\n\n    slug = models.SlugField(max_length=80, unique=True, allow_unicode=True)\n    text = models.CharField(max_length=255)\n    link = models.CharField(max_length=255, blank=True, null=True)\n    open_in_new_window_tab = models.BooleanField(default=True, blank=True)\n    active = models.BooleanField(default=False, blank=True)\n    DANGER = \"DANGER\"\n    INFO = \"INFO\"\n    SUCCESS = \"SUCCESS\"\n    WARN = \"WARN\"\n    ALERT_STATUS_CHOICES = [\n        (\"DANGER\", \"Red\"),\n        (\"INFO\", \"Blue\"),\n        (\"SUCCESS\", \"Green\"),\n        (\"WARNING\", \"Grey\"),\n    ]\n    alert_status = models.CharField(\n        max_length=7,\n        choices=ALERT_STATUS_CHOICES,\n        default=SUCCESS,\n        verbose_name=\"Color\",\n    )\n\n    def __str__(self):\n        return f\"Banner: {self.slug}\"\n\n    def alert_class(self):\n        return \"alert-\" + self.alert_status.lower()\n\n    def btn_class(self):\n        return \"btn-\" + self.alert_status.lower()\n\n\nclass CarouselSlide(models.Model):\n    \"\"\"\n    Configurable slide for the homepage carousel.\n\n    Each slide can show an image, text overlay, and call-to-action URL, with\n    simple ordering and publication controls.\n    \"\"\"\n\n    objects = PublicationQuerySet.as_manager()\n\n    created_on = models.DateTimeField(editable=False, auto_now_add=True)\n    updated_on = models.DateTimeField(editable=False, auto_now=True)\n\n    ordering = models.IntegerField(\n        default=0, help_text=\"Sort order: lower values will be listed first\"\n    )\n    published = models.BooleanField(default=False, blank=True)\n\n    overlay_position = models.CharField(max_length=5, choices=OverlayPosition.CHOICES)\n\n    headline = models.CharField(max_length=255, blank=False)\n    body = models.TextField(blank=True)\n    image_alt_text = models.TextField(blank=True)\n\n    carousel_image = models.ImageField(\n        upload_to=\"carousel-slides\", blank=True, null=True\n    )\n\n    lets_go_url = models.CharField(max_length=255)\n\n    def __str__(self):\n        return f\"CarouselSlide: {self.headline}\"\n\n\nclass SiteReportManager(models.Manager):\n    \"\"\"\n    Manager providing series-aware helpers for SiteReport.\n\n    A \"series\" is the set of SiteReport rows that belong to the same logical\n    reporting stream:\n\n      - Site-wide TOTAL:          report_name=TOTAL, campaign=None, topic=None\n      - Site-wide RETIRED_TOTAL:  report_name=RETIRED_TOTAL\n      - Per-campaign:             campaign=<campaign>, topic=None\n      - Per-topic:                topic=<topic>, campaign=None\n\n    These helpers avoid duplicating series filtering logic in tasks.\n    \"\"\"\n\n    def _series_filter(\n        self,\n        *,\n        report_name: Optional[str] = None,\n        campaign: Optional[\"Campaign\"] = None,\n        topic: Optional[\"Topic\"] = None,\n    ) -> Q:\n        \"\"\"\n        Build a Q filter for a single SiteReport series based on the inputs.\n\n        Args:\n            report_name: One of the SiteReport.ReportName values for site-wide\n                series (TOTAL, RETIRED_TOTAL). Ignored for per-campaign/topic.\n            campaign: Campaign instance for per-campaign series.\n            topic: Topic instance for per-topic series.\n\n        Returns:\n            Q: A Django Q object representing the series filter.\n        \"\"\"\n        if campaign is not None:\n            return Q(campaign=campaign, topic__isnull=True)\n        if topic is not None:\n            return Q(topic=topic, campaign__isnull=True)\n        if report_name == SiteReport.ReportName.TOTAL:\n            return Q(\n                report_name=SiteReport.ReportName.TOTAL,\n                campaign__isnull=True,\n                topic__isnull=True,\n            )\n        if report_name == SiteReport.ReportName.RETIRED_TOTAL:\n            return Q(report_name=SiteReport.ReportName.RETIRED_TOTAL)\n        # Fallback: no rows (prevents accidental wide queries)\n        return Q(pk__in=[])  # pragma: no cover\n\n    def previous_in_series(\n        self,\n        *,\n        report_name: Optional[str] = None,\n        campaign: Optional[\"Campaign\"] = None,\n        topic: Optional[\"Topic\"] = None,\n        before: Optional[datetime.datetime] = None,\n    ) -> Optional[\"SiteReport\"]:\n        \"\"\"\n        Return the latest SiteReport in the same series strictly before 'before'.\n\n        Args:\n            report_name: Series selector for site-wide reports (TOTAL/RETIRED_TOTAL).\n            campaign: Series selector for per-campaign reports.\n            topic: Series selector for per-topic reports.\n            before: A timezone-aware datetime; defaults to now() if omitted.\n\n        Returns:\n            SiteReport or None: The most recent prior report in the series.\n        \"\"\"\n        if before is None:\n            before = timezone.now()\n        q = self._series_filter(report_name=report_name, campaign=campaign, topic=topic)\n        return (\n            self.filter(q, created_on__lt=before).order_by(\"-created_on\", \"-pk\").first()\n        )\n\n    def series_filter_for_instance(self, instance: \"SiteReport\") -> Q:\n        \"\"\"\n        Build a Q filter that selects the same logical 'series' as the given\n        SiteReport instance (site-wide TOTAL, RETIRED_TOTAL,\n        per-campaign, or per-topic).\n        \"\"\"\n        if instance.campaign_id is not None:\n            return Q(campaign=instance.campaign, topic__isnull=True)\n        if instance.topic_id is not None:\n            return Q(topic=instance.topic, campaign__isnull=True)\n        if instance.report_name == SiteReport.ReportName.TOTAL:\n            return Q(\n                report_name=SiteReport.ReportName.TOTAL,\n                campaign__isnull=True,\n                topic__isnull=True,\n            )\n        if instance.report_name == SiteReport.ReportName.RETIRED_TOTAL:\n            return Q(report_name=SiteReport.ReportName.RETIRED_TOTAL)\n        return Q(pk__in=[])\n\n    def previous_for_instance(self, instance: \"SiteReport\") -> \"SiteReport | None\":\n        \"\"\"\n        Return the previous SiteReport within the same series (strictly earlier).\n        \"\"\"\n        q = self.series_filter_for_instance(instance)\n        return (\n            self.filter(q, created_on__lt=instance.created_on)\n            .order_by(\"-created_on\", \"-pk\")\n            .first()\n        )\n\n    def next_for_instance(self, instance: \"SiteReport\") -> \"SiteReport | None\":\n        \"\"\"\n        Return the next SiteReport within the same series (strictly later).\n        \"\"\"\n        q = self.series_filter_for_instance(instance)\n        return (\n            self.filter(q, created_on__gt=instance.created_on)\n            .order_by(\"created_on\", \"pk\")\n            .first()\n        )\n\n    def last_on_or_before_date_for_series(\n        self,\n        *,\n        report_name: Optional[str] = None,\n        campaign: Optional[\"Campaign\"] = None,\n        topic: Optional[\"Topic\"] = None,\n        on_or_before_date: datetime.date,\n    ) -> Optional[\"SiteReport\"]:\n        \"\"\"\n        Return the latest SiteReport within the series with\n        created_on.date() <= on_or_before_date.\n        \"\"\"\n        q = self._series_filter(report_name=report_name, campaign=campaign, topic=topic)\n        return (\n            self.filter(q, created_on__date__lte=on_or_before_date)\n            .order_by(\"-created_on\", \"-pk\")\n            .first()\n        )\n\n    def first_on_or_after_date_for_series(\n        self,\n        *,\n        report_name: Optional[str] = None,\n        campaign: Optional[\"Campaign\"] = None,\n        topic: Optional[\"Topic\"] = None,\n        on_or_after_date: datetime.date,\n        on_or_before_date: Optional[datetime.date] = None,\n    ) -> Optional[\"SiteReport\"]:\n        \"\"\"\n        Return the earliest SiteReport within the series with\n        created_on.date() >= on_or_after_date (and optionally\n        <= on_or_before_date).\n        \"\"\"\n        q = self._series_filter(report_name=report_name, campaign=campaign, topic=topic)\n        filters = {\"created_on__date__gte\": on_or_after_date}\n        if on_or_before_date is not None:\n            filters[\"created_on__date__lte\"] = on_or_before_date\n        return self.filter(q, **filters).order_by(\"created_on\", \"pk\").first()\n\n    def sum_assets_started_for_series_between_dates(\n        self,\n        *,\n        report_name: str,\n        start_date: datetime.date,\n        end_date: datetime.date,\n    ) -> int:\n        \"\"\"\n        Sum `assets_started` for a site-wide series\n        (TOTAL or RETIRED_TOTAL) inclusive of both dates.\n        Treat NULLs as zeros.\n        \"\"\"\n        agg = self.filter(\n            report_name=report_name,\n            campaign__isnull=True,\n            topic__isnull=True,\n            created_on__date__gte=start_date,\n            created_on__date__lte=end_date,\n        ).aggregate(total=Sum(\"assets_started\"))\n        return int(agg[\"total\"] or 0)\n\n\nclass SiteReport(models.Model):\n    class ReportName(models.TextChoices):\n        TOTAL = \"Active and completed campaigns\", \"Active and completed campaigns\"\n        RETIRED_TOTAL = \"Retired campaigns\", \"Retired campaigns\"\n\n    created_on = models.DateTimeField(auto_now_add=True)\n    report_name = models.CharField(\n        max_length=80, blank=True, default=\"\", choices=ReportName.choices\n    )\n    campaign = models.ForeignKey(\n        Campaign, on_delete=models.SET_NULL, blank=True, null=True\n    )\n    topic = models.ForeignKey(Topic, on_delete=models.SET_NULL, blank=True, null=True)\n    assets_total = models.IntegerField(blank=True, null=True)\n    assets_published = models.IntegerField(blank=True, null=True)\n    assets_not_started = models.IntegerField(blank=True, null=True)\n    assets_in_progress = models.IntegerField(blank=True, null=True)\n    assets_waiting_review = models.IntegerField(blank=True, null=True)\n    assets_completed = models.IntegerField(blank=True, null=True)\n    assets_unpublished = models.IntegerField(blank=True, null=True)\n    assets_started = models.IntegerField(blank=True, null=True)\n    items_published = models.IntegerField(blank=True, null=True)\n    items_unpublished = models.IntegerField(blank=True, null=True)\n    projects_published = models.IntegerField(blank=True, null=True)\n    projects_unpublished = models.IntegerField(blank=True, null=True)\n    anonymous_transcriptions = models.IntegerField(blank=True, null=True)\n    transcriptions_saved = models.IntegerField(blank=True, null=True)\n    daily_review_actions = models.IntegerField(blank=True, null=True)\n    distinct_tags = models.IntegerField(blank=True, null=True)\n    tag_uses = models.IntegerField(blank=True, null=True)\n    campaigns_published = models.IntegerField(blank=True, null=True)\n    campaigns_unpublished = models.IntegerField(blank=True, null=True)\n    users_registered = models.IntegerField(blank=True, null=True)\n    users_activated = models.IntegerField(blank=True, null=True)\n    registered_contributors = models.IntegerField(blank=True, null=True)\n    daily_active_users = models.IntegerField(blank=True, null=True)\n\n    objects = SiteReportManager()\n\n    class Meta:\n        ordering = (\"-created_on\",)\n        get_latest_by = \"created_on\"\n\n    # We have several places where these are exported as CSV/Excel. By default\n    # the ORM will be told to retrieve these fields & lookups:\n    DEFAULT_EXPORT_FIELDNAMES = [\n        \"created_on\",\n        \"report_name\",\n        \"campaign__title\",\n        \"topic__title\",\n        \"assets_total\",\n        \"assets_published\",\n        \"assets_not_started\",\n        \"assets_in_progress\",\n        \"assets_waiting_review\",\n        \"assets_completed\",\n        \"assets_unpublished\",\n        \"assets_started\",\n        \"items_published\",\n        \"items_unpublished\",\n        \"projects_published\",\n        \"projects_unpublished\",\n        \"anonymous_transcriptions\",\n        \"transcriptions_saved\",\n        \"daily_review_actions\",\n        \"distinct_tags\",\n        \"tag_uses\",\n        \"campaigns_published\",\n        \"campaigns_unpublished\",\n        \"users_registered\",\n        \"users_activated\",\n        \"registered_contributors\",\n        \"daily_active_users\",\n    ]\n\n    @staticmethod\n    def calculate_assets_started(\n        *,\n        previous_assets_total: Optional[int],\n        previous_assets_not_started: Optional[int],\n        current_assets_total: Optional[int],\n        current_assets_not_started: Optional[int],\n    ) -> int:\n        \"\"\"\n        Calculate the daily \"assets started\" value between two reports.\n\n        Let, for each snapshot:\n            total_prev = previous_assets_total\n            ns_prev    = previous_assets_not_started\n            total_cur  = current_assets_total\n            ns_cur     = current_assets_not_started\n            started_prev = max(0, total_prev - ns_prev)\n            started_cur  = max(0, total_cur - ns_cur)\n\n        Then:\n            assets_started = max(0, started_cur - started_prev)\n\n        This treats \"started\" as any asset that is in progress, waiting review,\n        or completed, regardless of published/unpublished status. Using\n        assets_total and assets_not_started makes the metric insensitive to\n        publish/unpublish changes: moving assets between published and\n        unpublished does not affect assets_started as long as their not-started\n        status and total count remain consistent.\n\n        All None inputs are treated as zero. The final result is floored at\n        zero to avoid negative values that can arise from administrative\n        actions such as deleting assets that were already started.\n        \"\"\"\n        total_prev = int(previous_assets_total or 0)\n        ns_prev = int(previous_assets_not_started or 0)\n        total_cur = int(current_assets_total or 0)\n        ns_cur = int(current_assets_not_started or 0)\n\n        started_prev = max(0, total_prev - ns_prev)\n        started_cur = max(0, total_cur - ns_cur)\n\n        return max(0, started_cur - started_prev)\n\n    def previous_in_series(self) -> \"SiteReport | None\":\n        \"\"\"\n        Return the previous SiteReport within this object's series.\n        \"\"\"\n        return SiteReport.objects.previous_for_instance(self)\n\n    def next_in_series(self) -> \"SiteReport | None\":\n        \"\"\"\n        Return the next SiteReport within this object's series.\n        \"\"\"\n        return SiteReport.objects.next_for_instance(self)\n\n    def to_debug_dict(self) -> dict:\n        \"\"\"\n        Return a JSON-serializable dictionary of this site report suitable for\n        copy/paste debugging. Includes core identifiers, related object info\n        (if available), and all numeric counters.\n\n        Related objects are expanded into small dicts with common attributes\n        when present (id, title, slug, status). Missing attributes are omitted.\n        \"\"\"\n        data: dict = {\n            \"id\": self.id,\n            \"created_on\": self.created_on,\n            \"report_name\": self.report_name,\n        }\n\n        if self.campaign_id:\n            campaign_info = {\"id\": self.campaign_id}\n            for attr in (\"title\", \"slug\", \"status\"):\n                value = getattr(self.campaign, attr, None)\n                if value is not None:\n                    campaign_info[attr] = value\n            data[\"campaign\"] = campaign_info\n\n        if self.topic_id:\n            topic_info = {\"id\": self.topic_id}\n            for attr in (\"title\", \"slug\"):\n                value = getattr(self.topic, attr, None)\n                if value is not None:\n                    topic_info[attr] = value\n            data[\"topic\"] = topic_info\n\n        # Numeric counters (explicit list to keep ordering predictable)\n        counters = {\n            \"assets_total\": self.assets_total,\n            \"assets_published\": self.assets_published,\n            \"assets_not_started\": self.assets_not_started,\n            \"assets_in_progress\": self.assets_in_progress,\n            \"assets_waiting_review\": self.assets_waiting_review,\n            \"assets_completed\": self.assets_completed,\n            \"assets_unpublished\": self.assets_unpublished,\n            \"assets_started\": self.assets_started,\n            \"items_published\": self.items_published,\n            \"items_unpublished\": self.items_unpublished,\n            \"projects_published\": self.projects_published,\n            \"projects_unpublished\": self.projects_unpublished,\n            \"anonymous_transcriptions\": self.anonymous_transcriptions,\n            \"transcriptions_saved\": self.transcriptions_saved,\n            \"daily_review_actions\": self.daily_review_actions,\n            \"distinct_tags\": self.distinct_tags,\n            \"tag_uses\": self.tag_uses,\n            \"campaigns_published\": self.campaigns_published,\n            \"campaigns_unpublished\": self.campaigns_unpublished,\n            \"users_registered\": self.users_registered,\n            \"users_activated\": self.users_activated,\n            \"registered_contributors\": self.registered_contributors,\n            \"daily_active_users\": self.daily_active_users,\n        }\n        data[\"counters\"] = counters\n        return data\n\n    def to_debug_json(self) -> str:\n        \"\"\"\n        Return a pretty-printed JSON string of `to_debug_dict()` with ISO\n        datetimes.\n        \"\"\"\n        return json.dumps(\n            self.to_debug_dict(), cls=DjangoJSONEncoder, indent=2, sort_keys=True\n        )\n\n\nclass KeyMetricsReport(models.Model):\n    \"\"\"\n    Site-wide Key Metrics report persisted for three period types:\n\n    - MONTHLY: per calendar month (with special handling for the very first\n      month)\n    - QUARTERLY: fiscal quarter rollup (Q1=Oct-Dec, Q2=Jan-Mar, Q3=Apr-Jun,\n      Q4=Jul-Sep)\n    - FISCAL_YEAR: fiscal year rollup (Oct 1 - Sep 30)\n\n    Monthly numbers are computed from SiteReport as follows:\n\n    - For cumulative counters (for example, assets_published, assets_completed,\n      transcriptions_saved, users_activated, anonymous_transcriptions,\n      tag_uses): the monthly value is the non-negative difference between the\n      combined site-wide TOTAL + RETIRED_TOTAL values at the end of the month\n      and the baseline snapshot. Baseline is the latest snapshot strictly\n      before the first day of the month; if none exists, baseline is the first\n      snapshot within the month (yielding the delta within that month).\n    - For assets_started: the monthly value is the sum of the daily\n      ``assets_started`` field across the month for the TOTAL and\n      RETIRED_TOTAL site-wide series.\n\n    Quarterly and fiscal-year numbers are rollups from the monthly rows:\n\n    - For count metrics: sum of the months in the period.\n    - For avg_visit_seconds: arithmetic mean of the months that have a value.\n      If no month has a value, the rollup is NULL.\n\n    Manual fields are stored here too so CMs can edit them in the admin and\n    have them included when exporting CSVs. If unset they remain NULL and are\n    rendered as empty strings in exports. Manual fields only roll up if at\n    least one of the rolled up reports' value is not NULL, so manual values in\n    \"higher\" reports will not be set to NULL if none of the \"lower\" reports\n    have values. This allows manual values to be set only in quarterly and/or\n    yearly reports instead of every month.\n    \"\"\"\n\n    class PeriodType(models.TextChoices):\n        MONTHLY = \"MONTHLY\", \"Monthly\"\n        QUARTERLY = \"QUARTERLY\", \"Quarterly\"\n        FISCAL_YEAR = \"FISCAL_YEAR\", \"Fiscal year\"\n\n    created_on = models.DateTimeField(auto_now_add=True)\n    updated_on = models.DateTimeField(auto_now=True)\n\n    period_type = models.CharField(max_length=20, choices=PeriodType.choices)\n    period_start = models.DateField()  # inclusive\n    period_end = models.DateField()  # inclusive\n\n    fiscal_year = models.IntegerField()\n    fiscal_quarter = models.IntegerField(blank=True, null=True)  # 1..4 for quarters\n    month = models.IntegerField(blank=True, null=True)  # 1..12 for monthly\n\n    # Derived from SiteReport metrics\n    assets_published = models.IntegerField(blank=True, null=True)\n    assets_started = models.IntegerField(blank=True, null=True)\n    assets_completed = models.IntegerField(blank=True, null=True)\n    users_activated = models.IntegerField(blank=True, null=True)\n    anonymous_transcriptions = models.IntegerField(blank=True, null=True)\n    transcriptions_saved = models.IntegerField(blank=True, null=True)\n    tag_uses = models.IntegerField(blank=True, null=True)\n\n    # Manual metrics\n    crowd_emails_and_libanswers_sent = models.IntegerField(blank=True, null=True)\n    crowd_visits = models.IntegerField(blank=True, null=True)\n    crowd_page_views = models.IntegerField(blank=True, null=True)\n    crowd_unique_visitors = models.IntegerField(blank=True, null=True)\n    avg_visit_seconds = models.DecimalField(\n        max_digits=8, decimal_places=2, blank=True, null=True\n    )\n    transcriptions_added_to_loc_gov = models.IntegerField(blank=True, null=True)\n    datasets_added_to_loc_gov = models.IntegerField(blank=True, null=True)\n\n    class Meta:\n        indexes = [\n            models.Index(fields=[\"period_type\", \"period_start\", \"period_end\"]),\n            models.Index(fields=[\"period_type\", \"fiscal_year\"]),\n            models.Index(fields=[\"period_type\", \"fiscal_year\", \"fiscal_quarter\"]),\n            models.Index(fields=[\"period_type\", \"fiscal_year\", \"month\"]),\n        ]\n        unique_together = ((\"period_type\", \"period_start\", \"period_end\"),)\n        ordering = (\"period_start\", \"period_end\", \"period_type\")\n\n    CSV_METRIC_COLUMNS: tuple[tuple[str, str], ...] = (\n        # Derived metrics (from SiteReport)\n        (\"assets_published\", \"Assets published\"),\n        (\"assets_started\", \"Assets started\"),\n        (\"assets_completed\", \"Assets completed\"),\n        (\"users_activated\", \"User accounts activated\"),\n        (\"anonymous_transcriptions\", \"Anonymous transcriptions\"),\n        (\"transcriptions_saved\", \"Transcriptions saved\"),\n        (\"tag_uses\", \"Tag uses\"),\n        # Manual metrics\n        (\"crowd_emails_and_libanswers_sent\", \"Crowd emails & LibAnswers sent\"),\n        (\"crowd_visits\", \"Crowd.loc.gov visits\"),\n        (\"crowd_page_views\", \"Crowd.loc.gov page views\"),\n        (\"crowd_unique_visitors\", \"Crowd.loc.gov unique visitors\"),\n        (\"avg_visit_seconds\", \"Avg. crowd.loc.gov visit (in seconds)\"),\n        (\"transcriptions_added_to_loc_gov\", \"Transcriptions added to loc.gov\"),\n        (\"datasets_added_to_loc_gov\", \"Datasets added to loc.gov\"),\n    )\n\n    MANUAL_FIELDS: tuple[str, ...] = (\n        \"crowd_emails_and_libanswers_sent\",\n        \"crowd_visits\",\n        \"crowd_page_views\",\n        \"crowd_unique_visitors\",\n        \"avg_visit_seconds\",\n        \"transcriptions_added_to_loc_gov\",\n        \"datasets_added_to_loc_gov\",\n    )\n\n    CALCULATED_FIELDS: tuple[str, ...] = (\n        \"assets_published\",\n        \"assets_started\",\n        \"assets_completed\",\n        \"users_activated\",\n        \"anonymous_transcriptions\",\n        \"transcriptions_saved\",\n        \"tag_uses\",\n    )\n\n    def __str__(self) -> str:\n        \"\"\"\n        Return a human-friendly name for the report.\n\n        Formats:\n        - Fiscal year: ``\"FY2024 Report\"``\n        - Quarter: ``\"FY2023 Q2 Report\"``\n        - Monthly: ``\"FY2022M06 Report (June 2022)\"``\n        \"\"\"\n        if self.period_type == self.PeriodType.FISCAL_YEAR:\n            return f\"FY{self.fiscal_year} Report\"\n\n        if self.period_type == self.PeriodType.QUARTERLY and self.fiscal_quarter:\n            return f\"FY{self.fiscal_year} Q{self.fiscal_quarter} Report\"\n\n        if self.period_type == self.PeriodType.MONTHLY and self.month:\n            # Calendar year for this month within the fiscal year\n            calendar_year = (\n                self.fiscal_year - 1 if self.month >= 10 else self.fiscal_year\n            )\n            month_name = calendar.month_name[self.month]\n            return (\n                f\"FY{self.fiscal_year}M{self.month:02d} Report \"\n                f\"({month_name} {calendar_year})\"\n            )\n\n        # Fallback if fields are incomplete\n        return (\n            \"KeyMetricsReport \"\n            f\"{self.period_type} {self.period_start}-{self.period_end}\"\n        )\n\n    @staticmethod\n    def get_fiscal_year_for_date(d: datetime.date) -> int:\n        \"\"\"Return the fiscal year for a date (Oct 1-Sep 30).\"\"\"\n        return d.year + 1 if d.month >= 10 else d.year\n\n    @staticmethod\n    def get_fiscal_quarter_for_date(d: datetime.date) -> int:\n        \"\"\"Return the fiscal quarter for a date (Q1=Oct-Dec, ..., Q4=Jul-Sep).\"\"\"\n        if 10 <= d.month <= 12:\n            return 1\n        if 1 <= d.month <= 3:\n            return 2\n        if 4 <= d.month <= 6:\n            return 3\n        return 4\n\n    @staticmethod\n    def month_bounds(d: datetime.date) -> tuple[datetime.date, datetime.date]:\n        \"\"\"Return (first_day, last_day) for the month containing d, in local time.\"\"\"\n        first = d.replace(day=1)\n        if first.month == 12:\n            next_month_first = first.replace(year=first.year + 1, month=1, day=1)\n        else:\n            next_month_first = first.replace(month=first.month + 1, day=1)\n        last = next_month_first - datetime.timedelta(days=1)\n        return first, last\n\n    @classmethod\n    def _monthly_from_sitereports(\n        cls, *, month_start: datetime.date, month_end: datetime.date\n    ) -> dict[str, int | Decimal | None]:\n        \"\"\"\n        Compute monthly site-wide metrics from SiteReport.\n\n        The month is defined by [month_start, month_end]. Snapshot-delta\n        metrics are computed as the non-negative difference between:\n\n        ``(total_eom + retired_eom)`` and\n        ``(total_baseline + retired_baseline)``\n\n        where baseline is the latest snapshot strictly before month_start. If\n        none exists, baseline is the first snapshot within the month.\n\n        assets_started is computed as the sum of daily ``assets_started``\n        across the month for TOTAL + RETIRED_TOTAL.\n        \"\"\"\n        # Identify the current (EOM) snapshots by series\n        total_eom = SiteReport.objects.last_on_or_before_date_for_series(\n            report_name=SiteReport.ReportName.TOTAL,\n            on_or_before_date=month_end,\n        )\n        retired_eom = SiteReport.objects.last_on_or_before_date_for_series(\n            report_name=SiteReport.ReportName.RETIRED_TOTAL,\n            on_or_before_date=month_end,\n        )\n\n        # If there is literally no snapshot by month_end for both series,\n        # we cannot produce a month.\n        if total_eom is None and retired_eom is None:\n            return {}\n\n        # Find baselines (strictly before the month start). If missing, fall back\n        # to the first snapshot within the month.\n        total_baseline = SiteReport.objects.previous_in_series(\n            report_name=SiteReport.ReportName.TOTAL,\n            before=datetime.datetime.combine(\n                month_start, datetime.time.min, tzinfo=timezone.get_current_timezone()\n            ),\n        )\n        if total_baseline is None and total_eom is not None:\n            total_baseline = SiteReport.objects.first_on_or_after_date_for_series(\n                report_name=SiteReport.ReportName.TOTAL,\n                on_or_after_date=month_start,\n                on_or_before_date=month_end,\n            )\n\n        retired_baseline = SiteReport.objects.previous_in_series(\n            report_name=SiteReport.ReportName.RETIRED_TOTAL,\n            before=datetime.datetime.combine(\n                month_start, datetime.time.min, tzinfo=timezone.get_current_timezone()\n            ),\n        )\n        if retired_baseline is None and retired_eom is not None:\n            retired_baseline = SiteReport.objects.first_on_or_after_date_for_series(\n                report_name=SiteReport.ReportName.RETIRED_TOTAL,\n                on_or_after_date=month_start,\n                on_or_before_date=month_end,\n            )\n\n        def val(obj: Optional[SiteReport], field: str) -> int:\n            \"\"\"\n            Safely extract an integer field from a SiteReport.\n\n            Missing objects or missing fields are treated as zero.\n            \"\"\"\n            if obj is None:\n                return 0\n            return int(getattr(obj, field, 0) or 0)\n\n        def delta(field: str) -> int:\n            cur_total = val(total_eom, field) + val(retired_eom, field)\n            base_total = val(total_baseline, field) + val(retired_baseline, field)\n            return max(0, cur_total - base_total)\n\n        # Snapshot-delta fields\n        assets_published = delta(\"assets_published\")\n        assets_completed = delta(\"assets_completed\")\n        users_activated = delta(\"users_activated\")\n        anonymous_transcriptions = delta(\"anonymous_transcriptions\")\n        transcriptions_saved = delta(\"transcriptions_saved\")\n        tag_uses = delta(\"tag_uses\")\n\n        # assets_started is the sum across the month for TOTAL and RETIRED_TOTAL\n        total_started = SiteReport.objects.sum_assets_started_for_series_between_dates(\n            report_name=SiteReport.ReportName.TOTAL,\n            start_date=month_start,\n            end_date=month_end,\n        )\n        retired_started = (\n            SiteReport.objects.sum_assets_started_for_series_between_dates(\n                report_name=SiteReport.ReportName.RETIRED_TOTAL,\n                start_date=month_start,\n                end_date=month_end,\n            )\n        )\n        assets_started = int(total_started + retired_started)\n\n        return {\n            \"assets_published\": assets_published,\n            \"assets_started\": assets_started,\n            \"assets_completed\": assets_completed,\n            \"users_activated\": users_activated,\n            \"anonymous_transcriptions\": anonymous_transcriptions,\n            \"transcriptions_saved\": transcriptions_saved,\n            \"tag_uses\": tag_uses,\n        }\n\n    @classmethod\n    def upsert_month(cls, *, year: int, month: int) -> Optional[\"KeyMetricsReport\"]:\n        \"\"\"\n        Create or update the MONTHLY report for the given (year, month).\n\n        Returns the saved instance, or None if the month cannot be computed\n        (no end-of-month snapshots exist in either series).\n        \"\"\"\n        month_start = datetime.date(year, month, 1)\n        _, month_end = cls.month_bounds(month_start)\n\n        values = cls._monthly_from_sitereports(\n            month_start=month_start, month_end=month_end\n        )\n        if not values:\n            return None  # Nothing computable for this month.\n\n        fiscal_year = cls.get_fiscal_year_for_date(month_end)\n        obj, _ = cls.objects.get_or_create(\n            period_type=cls.PeriodType.MONTHLY,\n            period_start=month_start,\n            period_end=month_end,\n            defaults={\n                \"fiscal_year\": fiscal_year,\n                \"fiscal_quarter\": cls.get_fiscal_quarter_for_date(month_end),\n                \"month\": month,\n            },\n        )\n        # Update derived fields; keep manual fields as-is.\n        for key, value in values.items():\n            setattr(obj, key, value)\n        obj.fiscal_year = fiscal_year\n        obj.fiscal_quarter = cls.get_fiscal_quarter_for_date(month_end)\n        obj.month = month\n        obj.save()\n        return obj\n\n    @classmethod\n    def upsert_quarter(\n        cls, *, fiscal_year: int, fiscal_quarter: int\n    ) -> Optional[\"KeyMetricsReport\"]:\n        \"\"\"\n        Create or update the QUARTERLY report by rolling up existing monthly rows.\n\n        If no monthly rows exist for the quarter, returns None. We sum all\n        monthly rows present in the quarter; partial quarters are allowed (for\n        example, at the very beginning of history).\n        \"\"\"\n        if fiscal_quarter not in (1, 2, 3, 4):\n            raise ValueError(\"fiscal_quarter must be 1..4\")\n\n        # Determine the calendar months for the fiscal quarter\n        if fiscal_quarter == 1:\n            month_specs = [\n                (fiscal_year - 1, 10),\n                (fiscal_year - 1, 11),\n                (fiscal_year - 1, 12),\n            ]\n        elif fiscal_quarter == 2:\n            month_specs = [(fiscal_year, 1), (fiscal_year, 2), (fiscal_year, 3)]\n        elif fiscal_quarter == 3:\n            month_specs = [(fiscal_year, 4), (fiscal_year, 5), (fiscal_year, 6)]\n        else:\n            month_specs = [(fiscal_year, 7), (fiscal_year, 8), (fiscal_year, 9)]\n\n        monthly_queryset = cls.objects.filter(\n            period_type=cls.PeriodType.MONTHLY,\n            fiscal_year=fiscal_year,\n            month__in=[m for (_, m) in month_specs],\n        )\n        if not monthly_queryset.exists():\n            return None\n\n        rollup_sums = monthly_queryset.aggregate(\n            # Derived (always recompute)\n            assets_published=Sum(\"assets_published\"),\n            assets_started=Sum(\"assets_started\"),\n            assets_completed=Sum(\"assets_completed\"),\n            users_activated=Sum(\"users_activated\"),\n            anonymous_transcriptions=Sum(\"anonymous_transcriptions\"),\n            transcriptions_saved=Sum(\"transcriptions_saved\"),\n            tag_uses=Sum(\"tag_uses\"),\n            # Manual (only set if aggregate is not None)\n            crowd_emails_and_libanswers_sent=Sum(\"crowd_emails_and_libanswers_sent\"),\n            crowd_visits=Sum(\"crowd_visits\"),\n            crowd_page_views=Sum(\"crowd_page_views\"),\n            crowd_unique_visitors=Sum(\"crowd_unique_visitors\"),\n            transcriptions_added_to_loc_gov=Sum(\"transcriptions_added_to_loc_gov\"),\n            datasets_added_to_loc_gov=Sum(\"datasets_added_to_loc_gov\"),\n        )\n        avg_series = monthly_queryset.exclude(avg_visit_seconds__isnull=True).aggregate(\n            avg=Avg(\"avg_visit_seconds\")\n        )\n        average_visit_seconds = avg_series[\"avg\"]\n\n        # Quarter bounds (full quarter)\n        quarter_start = datetime.date(month_specs[0][0], month_specs[0][1], 1)\n        _, quarter_end = cls.month_bounds(\n            datetime.date(month_specs[-1][0], month_specs[-1][1], 1)\n        )\n\n        report, _ = cls.objects.get_or_create(\n            period_type=cls.PeriodType.QUARTERLY,\n            period_start=quarter_start,\n            period_end=quarter_end,\n            defaults={\n                \"fiscal_year\": fiscal_year,\n                \"fiscal_quarter\": fiscal_quarter,\n            },\n        )\n\n        derived_fields = (\n            \"assets_published\",\n            \"assets_started\",\n            \"assets_completed\",\n            \"users_activated\",\n            \"anonymous_transcriptions\",\n            \"transcriptions_saved\",\n            \"tag_uses\",\n        )\n        for field_name in derived_fields:\n            setattr(report, field_name, int(rollup_sums[field_name] or 0))\n\n        manual_fields = (\n            \"crowd_emails_and_libanswers_sent\",\n            \"crowd_visits\",\n            \"crowd_page_views\",\n            \"crowd_unique_visitors\",\n            \"transcriptions_added_to_loc_gov\",\n            \"datasets_added_to_loc_gov\",\n        )\n        for field_name in manual_fields:\n            if rollup_sums[field_name] is not None:\n                setattr(report, field_name, int(rollup_sums[field_name]))\n\n        if average_visit_seconds is not None:\n            report.avg_visit_seconds = average_visit_seconds\n\n        report.fiscal_year = fiscal_year\n        report.fiscal_quarter = fiscal_quarter\n        report.month = None\n        report.save()\n        return report\n\n    @classmethod\n    def upsert_fiscal_year(cls, *, fiscal_year: int) -> Optional[\"KeyMetricsReport\"]:\n        \"\"\"\n        Create or update the FISCAL_YEAR report by rolling up monthly rows.\n\n        Returns None if no monthly rows exist for the fiscal year.\n        \"\"\"\n        monthly_qs = cls.objects.filter(\n            period_type=cls.PeriodType.MONTHLY, fiscal_year=fiscal_year\n        )\n        if not monthly_qs.exists():\n            return None\n\n        sums = monthly_qs.aggregate(\n            # Derived (always recompute)\n            assets_published=Sum(\"assets_published\"),\n            assets_started=Sum(\"assets_started\"),\n            assets_completed=Sum(\"assets_completed\"),\n            users_activated=Sum(\"users_activated\"),\n            anonymous_transcriptions=Sum(\"anonymous_transcriptions\"),\n            transcriptions_saved=Sum(\"transcriptions_saved\"),\n            tag_uses=Sum(\"tag_uses\"),\n            # Manual (only set if aggregate is not None)\n            crowd_emails_and_libanswers_sent=Sum(\"crowd_emails_and_libanswers_sent\"),\n            crowd_visits=Sum(\"crowd_visits\"),\n            crowd_page_views=Sum(\"crowd_page_views\"),\n            crowd_unique_visitors=Sum(\"crowd_unique_visitors\"),\n            transcriptions_added_to_loc_gov=Sum(\"transcriptions_added_to_loc_gov\"),\n            datasets_added_to_loc_gov=Sum(\"datasets_added_to_loc_gov\"),\n        )\n        avg_series = monthly_qs.exclude(avg_visit_seconds__isnull=True).aggregate(\n            avg=Avg(\"avg_visit_seconds\")\n        )\n        avg_visit_seconds = avg_series[\"avg\"]\n\n        # FY period bounds: Oct 1 .. Sep 30\n        start = datetime.date(fiscal_year - 1, 10, 1)\n        end = datetime.date(fiscal_year, 9, 30)\n\n        obj, _ = cls.objects.get_or_create(\n            period_type=cls.PeriodType.FISCAL_YEAR,\n            period_start=start,\n            period_end=end,\n            defaults={\"fiscal_year\": fiscal_year},\n        )\n\n        derived_fields = (\n            \"assets_published\",\n            \"assets_started\",\n            \"assets_completed\",\n            \"users_activated\",\n            \"anonymous_transcriptions\",\n            \"transcriptions_saved\",\n            \"tag_uses\",\n        )\n        for field in derived_fields:\n            setattr(obj, field, int(sums[field] or 0))\n\n        manual_fields = (\n            \"crowd_emails_and_libanswers_sent\",\n            \"crowd_visits\",\n            \"crowd_page_views\",\n            \"crowd_unique_visitors\",\n            \"transcriptions_added_to_loc_gov\",\n            \"datasets_added_to_loc_gov\",\n        )\n        for field in manual_fields:\n            if sums[field] is not None:\n                setattr(obj, field, int(sums[field]))\n\n        if avg_visit_seconds is not None:\n            obj.avg_visit_seconds = avg_visit_seconds\n\n        obj.fiscal_year = fiscal_year\n        obj.fiscal_quarter = None\n        obj.month = None\n        obj.save()\n        return obj\n\n    def csv_filename(self) -> str:\n        \"\"\"\n        Build a descriptive filename for this report CSV.\n        \"\"\"\n        if self.period_type == self.PeriodType.MONTHLY:\n            return (\n                f\"key_metrics_monthly_fy{self.fiscal_year}_\"\n                f\"m{self.month:02d}_{self.period_start}_{self.period_end}.csv\"\n            )\n        if self.period_type == self.PeriodType.QUARTERLY:\n            return (\n                f\"key_metrics_quarterly_fy{self.fiscal_year}_\"\n                f\"q{self.fiscal_quarter}_{self.period_start}_{self.period_end}.csv\"\n            )\n        # FISCAL_YEAR\n        return (\n            f\"key_metrics_fiscal_year_fy{self.fiscal_year}_\"\n            f\"{self.period_start}_{self.period_end}.csv\"\n        )\n\n    def _format_value_for_csv(self, field_name: str, value) -> str | int | float:\n        \"\"\"\n        Convert model values to CSV-friendly outputs.\n\n        Rules:\n        - Calculated numeric fields: default to 0 if NULL.\n        - Manual fields: default to empty string if NULL.\n        - avg_visit_seconds (Decimal) renders as a string with up to 2 decimals;\n          empty string if NULL.\n        \"\"\"\n        if field_name in self.MANUAL_FIELDS:\n            if value is None:\n                return \"\"\n            if field_name == \"avg_visit_seconds\":\n                if isinstance(value, Decimal):\n                    quantized = value.quantize(Decimal(\"0.01\"))\n                    return f\"{quantized}\"\n                return f\"{value}\"\n            return int(value)\n        if field_name in self.CALCULATED_FIELDS:\n            return int(value or 0)\n        # Should not be reached (we only export metrics), but be safe.\n        return \"\" if value is None else value\n\n    def _calendar_year_for_month_in_fy(self, month: int, fiscal_year: int) -> int:\n        \"\"\"\n        Return the calendar year for a month number interpreted in FY context.\n        \"\"\"\n        return fiscal_year - 1 if 10 <= month <= 12 else fiscal_year\n\n    def _fy_abbrev(self, fiscal_year: int) -> str:\n        \"\"\"\n        Return an \"FY##\" abbreviation for a fiscal year number.\n\n        Example:\n            2024 -> \"FY24\".\n        \"\"\"\n        return f\"FY{fiscal_year % 100:02d}\"\n\n    def _month_label(self, fiscal_year: int, month: int) -> str:\n        \"\"\"\n        Return the month name label (for example, \"June\").\n        \"\"\"\n        return calendar.month_name[month]\n\n    def _format_cell(self, field_name: str, value):\n        \"\"\"\n        Format a single cell for CSV using the existing per-field rules.\n        \"\"\"\n        return self._format_value_for_csv(field_name, value)\n\n    def _csv_matrix_monthly(self) -> tuple[list[str], list[list[str | int | float]]]:\n        \"\"\"\n        Build the CSV header and rows for a MONTHLY report.\n\n        MONTHLY CSV:\n        - Headers: ``[\"Metric\", \"<Month>\"]`` (month name only)\n        - Rows: one per metric.\n        \"\"\"\n        headers = [\"Metric\", self._month_label(self.fiscal_year, int(self.month))]\n\n        rows: list[list[str | int | float]] = []\n        for field_name, label in self.CSV_METRIC_COLUMNS:\n            value = getattr(self, field_name)\n            rows.append([label, self._format_cell(field_name, value)])\n        return headers, rows\n\n    def _quarter_month_specs(self) -> list[tuple[int, int]]:\n        \"\"\"\n        Return [(year, month), ...] for months in this object's quarter.\n\n        Months are interpreted in the fiscal-year (FY) context.\n        \"\"\"\n        fy = int(self.fiscal_year)\n        fq = int(self.fiscal_quarter)\n        if fq == 1:\n            return [(fy - 1, 10), (fy - 1, 11), (fy - 1, 12)]\n        if fq == 2:\n            return [(fy, 1), (fy, 2), (fy, 3)]\n        if fq == 3:\n            return [(fy, 4), (fy, 5), (fy, 6)]\n        return [(fy, 7), (fy, 8), (fy, 9)]\n\n    def _csv_matrix_quarterly(self) -> tuple[list[str], list[list[str | int | float]]]:\n        \"\"\"\n        Build the CSV header and rows for a QUARTERLY report.\n\n        QUARTERLY CSV:\n\n        Headers:\n            [\"Metric\", \"<M1>\", \"<M2>\", \"<M3>\", \"FY## Q# totals\",\n             \"FY## Lifetime totals\"]\n\n        - Month columns include only months that have MONTHLY rows.\n        - Month labels are month names only (\"June\", \"September\", ...).\n\n        Lifetime for a quarter:\n            sum(all prior fiscal-year reports) + sum(quarters in current FY with\n            quarter < current quarter). Manual fields are blank if all inputs\n            are blank.\n        \"\"\"\n        # Which months exist for this quarter?\n        specs = self._quarter_month_specs()\n        months_in_quarter = [m for (_y, m) in specs]\n        monthly_rows = (\n            KeyMetricsReport.objects.filter(\n                period_type=self.PeriodType.MONTHLY,\n                fiscal_year=self.fiscal_year,\n                month__in=months_in_quarter,\n            )\n            .only(\"fiscal_year\", \"month\", *[f for f, _ in self.CSV_METRIC_COLUMNS])\n            .order_by(\"month\")\n        )\n        month_map: dict[int, KeyMetricsReport] = {r.month: r for r in monthly_rows}\n        present_months = [m for m in months_in_quarter if m in month_map]\n        month_headers = [self._month_label(self.fiscal_year, m) for m in present_months]\n\n        fy_abbrev = self._fy_abbrev(self.fiscal_year)\n        quarter_totals_label = f\"{fy_abbrev} Q{int(self.fiscal_quarter)} totals\"\n        lifetime_totals_label = f\"{fy_abbrev} Lifetime totals\"\n\n        headers = [\n            \"Metric\",\n            *month_headers,\n            quarter_totals_label,\n            lifetime_totals_label,\n        ]\n\n        # Pre-fetch prior FY rows and prior quarters in current FY for lifetime calc\n        prior_fy_rows = KeyMetricsReport.objects.filter(\n            period_type=self.PeriodType.FISCAL_YEAR,\n            fiscal_year__lt=self.fiscal_year,\n        ).only(*[f for f, _ in self.CSV_METRIC_COLUMNS])\n\n        prior_quarter_rows = KeyMetricsReport.objects.filter(\n            period_type=self.PeriodType.QUARTERLY,\n            fiscal_year=self.fiscal_year,\n            fiscal_quarter__lt=self.fiscal_quarter,\n        ).only(*[f for f, _ in self.CSV_METRIC_COLUMNS])\n\n        rows: list[list[str | int | float]] = []\n        for field_name, label in self.CSV_METRIC_COLUMNS:\n            # Month cells\n            per_month_values: list[str | int | float] = []\n            quarter_numeric_sum = 0\n            saw_manual_value_in_quarter = False\n\n            for m in present_months:\n                mv = getattr(month_map[m], field_name, None)\n                cell = self._format_cell(field_name, mv)\n                per_month_values.append(cell)\n\n                if field_name in self.CALCULATED_FIELDS:\n                    quarter_numeric_sum += int(mv or 0)\n                else:\n                    if mv is not None:\n                        saw_manual_value_in_quarter = True\n                        quarter_numeric_sum += int(mv)\n\n            if field_name in self.CALCULATED_FIELDS:\n                quarter_total_cell: str | int = int(quarter_numeric_sum)\n            else:\n                quarter_total_cell = (\n                    int(quarter_numeric_sum) if saw_manual_value_in_quarter else \"\"\n                )\n\n            # Lifetime = prior FY totals + prior quarters this FY\n            lifetime_numeric_sum = 0\n            saw_manual_value_lifetime = False\n\n            # Prior FY rows\n            for fy_row in prior_fy_rows:\n                v = getattr(fy_row, field_name, None)\n                if field_name in self.CALCULATED_FIELDS:\n                    lifetime_numeric_sum += int(v or 0)\n                else:\n                    if v is not None:\n                        saw_manual_value_lifetime = True\n                        lifetime_numeric_sum += int(v)\n\n            # Prior quarters in current FY\n            for q_row in prior_quarter_rows:\n                v = getattr(q_row, field_name, None)\n                if field_name in self.CALCULATED_FIELDS:\n                    lifetime_numeric_sum += int(v or 0)\n                else:\n                    if v is not None:\n                        saw_manual_value_lifetime = True\n                        lifetime_numeric_sum += int(v)\n\n            if field_name in self.CALCULATED_FIELDS:\n                lifetime_total_cell: str | int = int(lifetime_numeric_sum)\n            else:\n                lifetime_total_cell = (\n                    int(lifetime_numeric_sum) if saw_manual_value_lifetime else \"\"\n                )\n\n            rows.append(\n                [label, *per_month_values, quarter_total_cell, lifetime_total_cell]\n            )\n\n        return headers, rows\n\n    def _csv_matrix_fiscal_year(\n        self,\n    ) -> tuple[list[str], list[list[str | int | float]]]:\n        \"\"\"\n        Build the CSV header and rows for a FISCAL_YEAR report.\n\n        Headers:\n\n        - \"Metric\"\n        - \"FY## Q1 totals\" (if Q1 present)\n        - \"Q2 totals\" (if present)\n        - \"Q3 totals\" (if present)\n        - \"Q4 totals\" (if present)\n        - \"FY## totals\"\n        - \"FY## Lifetime totals\"\n\n        Lifetime for a fiscal year:\n            sum of all FY rows up to and including this FY. Manual fields are\n            blank if all inputs are blank.\n        \"\"\"\n        # Quarter rows present in this FY\n        quarter_rows = (\n            KeyMetricsReport.objects.filter(\n                period_type=self.PeriodType.QUARTERLY,\n                fiscal_year=self.fiscal_year,\n            )\n            .only(\"fiscal_quarter\", *[f for f, _ in self.CSV_METRIC_COLUMNS])\n            .order_by(\"fiscal_quarter\")\n        )\n        quarter_map: dict[int, KeyMetricsReport] = {\n            r.fiscal_quarter: r for r in quarter_rows\n        }\n        present_quarters = [q for q in (1, 2, 3, 4) if q in quarter_map]\n\n        # Build quarter headers per spec\n        headers = [\"Metric\"]\n        if 1 in present_quarters:\n            headers.append(f\"{self._fy_abbrev(self.fiscal_year)} Q1 totals\")\n        for quarter_number in (2, 3, 4):\n            if quarter_number in present_quarters:\n                headers.append(f\"Q{quarter_number} totals\")\n\n        fiscal_year_abbrev = self._fy_abbrev(self.fiscal_year)\n        headers.extend(\n            [f\"{fiscal_year_abbrev} totals\", f\"{fiscal_year_abbrev} Lifetime totals\"]\n        )\n\n        # Order quarters to match headers\n        header_quarter_order: list[int] = []\n        if 1 in present_quarters:\n            header_quarter_order.append(1)\n        for quarter_number in (2, 3, 4):\n            if quarter_number in present_quarters:\n                header_quarter_order.append(quarter_number)\n\n        # Lifetime basis: all FY rows <= this FY\n        lifetime_fy_rows = KeyMetricsReport.objects.filter(\n            period_type=self.PeriodType.FISCAL_YEAR,\n            fiscal_year__lte=self.fiscal_year,\n        ).only(*[f for f, _ in self.CSV_METRIC_COLUMNS])\n\n        rows: list[list[str | int | float]] = []\n        for field_name, label in self.CSV_METRIC_COLUMNS:\n            per_quarter_values: list[str | int | float] = []\n            year_numeric_sum = 0\n            saw_manual_value_in_year = False\n\n            for q in header_quarter_order:\n                q_value = getattr(quarter_map[q], field_name, None)\n                cell = self._format_cell(field_name, q_value)\n                per_quarter_values.append(cell)\n\n                if field_name in self.CALCULATED_FIELDS:\n                    year_numeric_sum += int(q_value or 0)\n                else:\n                    if q_value is not None:\n                        saw_manual_value_in_year = True\n                        year_numeric_sum += int(q_value)\n\n            if field_name in self.CALCULATED_FIELDS:\n                year_total_cell: str | int = int(year_numeric_sum)\n            else:\n                year_total_cell = (\n                    int(year_numeric_sum) if saw_manual_value_in_year else \"\"\n                )\n\n            # Lifetime across FY rows <= current FY\n            lifetime_numeric_sum = 0\n            saw_manual_value_in_lifetime = False\n            for fy_row in lifetime_fy_rows:\n                v = getattr(fy_row, field_name, None)\n                if field_name in self.CALCULATED_FIELDS:\n                    lifetime_numeric_sum += int(v or 0)\n                else:\n                    if v is not None:\n                        saw_manual_value_in_lifetime = True\n                        lifetime_numeric_sum += int(v)\n\n            if field_name in self.CALCULATED_FIELDS:\n                lifetime_total_cell: str | int = int(lifetime_numeric_sum)\n            else:\n                lifetime_total_cell = (\n                    int(lifetime_numeric_sum) if saw_manual_value_in_lifetime else \"\"\n                )\n\n            rows.append(\n                [label, *per_quarter_values, year_total_cell, lifetime_total_cell]\n            )\n\n        return headers, rows\n\n    def render_csv(self) -> bytes:\n        \"\"\"\n        Render this report as a CSV pivot.\n\n        Rows:\n            Metrics in CSV_METRIC_COLUMNS order.\n\n        Columns:\n            - MONTHLY:     Metric | <Month>\n            - QUARTERLY:   Metric | months present | FY## Q# totals |\n                            FY## Lifetime totals\n            - FISCAL_YEAR: Metric | (\"FY## Q1 totals\" if present) | Q2 totals |\n                            Q3 totals | Q4 totals | FY## totals |\n                            FY## Lifetime totals\n        \"\"\"\n        if self.period_type == self.PeriodType.MONTHLY:\n            headers, rows = self._csv_matrix_monthly()\n        elif self.period_type == self.PeriodType.QUARTERLY:\n            headers, rows = self._csv_matrix_quarterly()\n        else:\n            headers, rows = self._csv_matrix_fiscal_year()\n\n        buffer = io.StringIO(newline=\"\")\n        writer = csv.writer(buffer)\n        writer.writerow(headers)\n        writer.writerows(rows)\n        return buffer.getvalue().encode(\"utf-8\")\n\n\nclass UserProfileActivity(models.Model):\n    \"\"\"\n    Per-campaign activity summary for a single user.\n\n    This model stores campaign-scoped counts such as how many assets a user\n    has touched and how many transcriptions or reviews they have performed.\n    \"\"\"\n\n    user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name=\"User Id\")\n    campaign = models.ForeignKey(\n        Campaign, on_delete=models.CASCADE, verbose_name=\"Campaign Id\"\n    )\n    asset_count = models.IntegerField(default=0)\n    asset_tag_count = models.IntegerField(default=0)\n    transcribe_count = models.IntegerField(\n        default=0, verbose_name=\"transcription save/submit count\"\n    )\n    review_count = models.IntegerField(\n        default=0, verbose_name=\"transcription review count\"\n    )\n\n    class Meta:\n        constraints = [\n            models.UniqueConstraint(\n                fields=[\"user\", \"campaign\"], name=\"user_campaign_count\"\n            )\n        ]\n        verbose_name_plural = \"User profile activities\"\n\n    def __str__(self):\n        return f\"{self.user} - {self.campaign}\"\n\n    def get_status(self):\n        display = [None, \"Active\", \"Completed\", \"Retired\"]\n        return display[self.campaign.status]\n\n    def total_actions(self):\n        transcribe_count = self.transcribe_count or 0\n        review_count = self.review_count or 0\n        return transcribe_count + review_count\n\n\nclass CampaignRetirementProgress(models.Model):\n    \"\"\"\n    Track progress while retiring a campaign and deleting related content.\n\n    This model stores counts of projects, items, and assets processed for a\n    retiring campaign, along with a log of removal operations.\n    \"\"\"\n\n    campaign = models.OneToOneField(Campaign, on_delete=models.CASCADE)\n    project_total = models.IntegerField(default=0)\n    projects_removed = models.IntegerField(default=0)\n    item_total = models.IntegerField(default=0)\n    items_removed = models.IntegerField(default=0)\n    asset_total = models.IntegerField(default=0)\n    assets_removed = models.IntegerField(default=0)\n    complete = models.BooleanField(default=False)\n    started_on = models.DateTimeField(auto_now_add=True)\n    completed_on = models.DateTimeField(null=True)\n    removal_log = models.JSONField(default=list)\n\n    def __str__(self):\n        return f\"Removal progress for {self.campaign}\"\n\n    class Meta:\n        verbose_name_plural = \"campaign retirement progress\"\n\n\nclass TutorialCard(models.Model):\n    \"\"\"\n    Through model for ordering cards within a CardFamily tutorial.\n    \"\"\"\n\n    card = models.ForeignKey(Card, on_delete=models.CASCADE)\n    tutorial = models.ForeignKey(CardFamily, on_delete=models.CASCADE)\n    order = models.IntegerField(default=0)\n\n    class Meta:\n        verbose_name_plural = \"cards\"\n\n\nclass Guide(models.Model):\n    \"\"\"\n    Guide entry grouping SimplePage or link-based content.\n\n    Guides back the sidebar and inline help sections that surface how-to\n    documentation for contributors.\n    \"\"\"\n\n    title = models.CharField(max_length=80)\n    page = models.ForeignKey(\n        SimplePage, on_delete=models.SET_NULL, blank=True, null=True\n    )\n    body = models.TextField(blank=True)\n    order = models.IntegerField(default=1)\n    link_text = models.CharField(max_length=80, blank=True, null=True)\n    link_url = models.CharField(max_length=255, blank=True, null=True)\n\n    def __str__(self):\n        return self.title\n\n\ndef validated_get_or_create(klass, **kwargs):\n    \"\"\"\n    Create or return an object using full model validation.\n\n    This works like ``QuerySet.get_or_create()``, but always constructs the\n    object via attribute assignment and ``full_clean()`` before saving.\n\n    This is helpful for models with validation that is not fully enforced at\n    the database level, or when using integrations like django-model-translation\n    where fields must be set through normal attribute access.\n\n    Args:\n        klass: The model class to query or create.\n        **kwargs: Lookup fields, plus optional ``defaults`` dict, as in\n            ``get_or_create()``.\n\n    Returns:\n        tuple[Model, bool]: A ``(obj, created)`` tuple like\n        ``get_or_create()``.\n    \"\"\"\n    defaults = kwargs.pop(\"defaults\", {})\n\n    try:\n        obj = klass.objects.get(**kwargs)\n        return obj, False\n    except klass.DoesNotExist:\n        obj = klass()\n\n        for k, v in chain(kwargs.items(), defaults.items()):\n            setattr(obj, k, v)\n\n        obj.full_clean()\n        obj.save()\n        return obj, True\n\n\nclass NextAsset(models.Model):\n    \"\"\"\n    Abstract base class for \"next asset\" queues.\n\n    These lightweight records cache the next transcribable or reviewable\n    assets selected for a campaign or topic, so they can be fetched quickly\n    without recomputing complex queries.\n    \"\"\"\n\n    id = models.UUIDField(  # noqa: A003\n        primary_key=True, default=uuid.uuid4, editable=False\n    )\n    item = models.ForeignKey(Item, on_delete=models.CASCADE)\n    item_item_id = models.CharField(max_length=100)\n    project = models.ForeignKey(Project, on_delete=models.CASCADE)\n    project_slug = models.SlugField(max_length=80, allow_unicode=True)\n    sequence = models.PositiveIntegerField(default=1)\n    created_on = models.DateTimeField(editable=False, auto_now_add=True)\n\n    class Meta:\n        abstract = True\n\n    def __str__(self):\n        return self.asset.title\n\n\nclass NextTranscribableAsset(NextAsset):\n    \"\"\"\n    Abstract base for cached transcribable asset queues.\n    \"\"\"\n\n    transcription_status = models.CharField(\n        editable=False,\n        max_length=20,\n        default=TranscriptionStatus.NOT_STARTED,\n        choices=TranscriptionStatus.CHOICES,\n        db_index=True,\n    )\n\n    class Meta:\n        abstract = True\n\n\nclass NextReviewableAsset(NextAsset):\n    \"\"\"\n    Abstract base for cached reviewable asset queues.\n\n    Stores the IDs of prior transcribers to help avoid assigning reviewers\n    to assets they have already worked on.\n    \"\"\"\n\n    transcriber_ids = ArrayField(\n        base_field=models.IntegerField(),\n        blank=True,\n        default=list,\n    )\n\n    class Meta:\n        abstract = True\n\n\nclass NextCampaignAssetManager(models.Manager):\n    \"\"\"\n    Base manager for \"next asset\" campaign queues.\n\n    Subclasses should set ``target_count`` to control how many entries\n    should be prepopulated per campaign.\n    \"\"\"\n\n    target_count = None  # Override in subclass\n\n    def needed_for_campaign(self, campaign_id, target_count=None):\n        \"\"\"\n        Return how many additional entries are needed for a campaign.\n\n        Args:\n            campaign_id: The campaign primary key.\n            target_count: Optional override for the per-campaign queue size.\n                If omitted, ``self.target_count`` is used.\n\n        Returns:\n            int: Number of additional entries required to reach the target.\n        \"\"\"\n        if target_count is None:\n            if self.target_count is None:\n                raise NotImplementedError(\n                    \"You must define `target_count` in the subclass \"\n                    \"or pass `target_count` explicitly.\"\n                )\n            target_count = self.target_count\n\n        current_count = self.filter(campaign_id=campaign_id).count()\n        return max(target_count - current_count, 0)\n\n\nclass NextTopicAssetManager(models.Manager):\n    \"\"\"\n    Base manager for \"next asset\" topic queues.\n\n    Subclasses should set ``target_count`` to control how many entries\n    should be prepopulated per topic.\n    \"\"\"\n\n    target_count = None  # Override in subclass\n\n    def needed_for_topic(self, topic_id, target_count=None):\n        \"\"\"\n        Return how many additional entries are needed for a topic.\n\n        Args:\n            topic_id: The topic primary key.\n            target_count: Optional override for the per-topic queue size.\n                If omitted, ``self.target_count`` is used.\n\n        Returns:\n            int: Number of additional entries required to reach the target.\n        \"\"\"\n        if target_count is None:\n            if self.target_count is None:\n                raise NotImplementedError(\n                    \"You must define `target_count` in the subclass \"\n                    \"or pass `target_count` explicitly.\"\n                )\n            target_count = self.target_count\n\n        current_count = self.filter(topic_id=topic_id).count()\n        return max(target_count - current_count, 0)\n\n\nclass NextTranscribableCampaignAssetManager(NextCampaignAssetManager):\n    target_count = getattr(settings, \"NEXT_TRANSCRIBABLE_ASSET_COUNT\", 100)\n\n\nclass NextTranscribableTopicAssetManager(NextTopicAssetManager):\n    target_count = getattr(settings, \"NEXT_TRANSCRIBABLE_ASSET_COUNT\", 100)\n\n\nclass NextReviewableCampaignAssetManager(NextCampaignAssetManager):\n    target_count = getattr(settings, \"NEXT_REVIEWABLE_ASSET_COUNT\", 100)\n\n\nclass NextReviewableTopicAssetManager(NextTopicAssetManager):\n    target_count = getattr(settings, \"NEXT_REVIEWABLE_ASSET_COUNT\", 100)\n\n\nclass NextTranscribableCampaignAsset(NextTranscribableAsset):\n    \"\"\"\n    Cached transcribable asset entry for a campaign-wide queue.\n    \"\"\"\n\n    asset = models.OneToOneField(Asset, on_delete=models.CASCADE)\n    campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE)\n\n    objects = NextTranscribableCampaignAssetManager()\n\n    class Meta:\n        ordering = (\"created_on\",)\n        get_latest_by = \"created_on\"\n\n\nclass NextTranscribableTopicAsset(NextTranscribableAsset):\n    \"\"\"\n    Cached transcribable asset entry for a topic-scoped queue.\n    \"\"\"\n\n    asset = models.ForeignKey(Asset, on_delete=models.CASCADE)\n    topic = models.ForeignKey(Topic, on_delete=models.CASCADE)\n\n    objects = NextTranscribableTopicAssetManager()\n\n    class Meta:\n        ordering = (\"created_on\",)\n        get_latest_by = \"created_on\"\n        unique_together = (\"asset\", \"topic\")\n\n\nclass NextReviewableCampaignAsset(NextReviewableAsset):\n    \"\"\"\n    Cached reviewable asset entry for a campaign-wide queue.\n    \"\"\"\n\n    asset = models.OneToOneField(Asset, on_delete=models.CASCADE)\n    campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE)\n\n    objects = NextReviewableCampaignAssetManager()\n\n    class Meta:\n        ordering = (\"created_on\",)\n        get_latest_by = \"created_on\"\n        indexes = [\n            GinIndex(fields=[\"transcriber_ids\"]),\n        ]\n\n\nclass NextReviewableTopicAsset(NextReviewableAsset):\n    \"\"\"\n    Cached reviewable asset entry for a topic-scoped queue.\n    \"\"\"\n\n    asset = models.ForeignKey(Asset, on_delete=models.CASCADE)\n    topic = models.ForeignKey(Topic, on_delete=models.CASCADE)\n\n    objects = NextReviewableTopicAssetManager()\n\n    class Meta:\n        ordering = (\"created_on\",)\n        get_latest_by = \"created_on\"\n        unique_together = (\"asset\", \"topic\")\n        indexes = [\n            GinIndex(fields=[\"transcriber_ids\"]),\n        ]\n\n\nclass ProjectTopic(models.Model):\n    \"\"\"\n    Link table connecting projects and topics with optional status filtering.\n\n    url_filter can be used to restrict which asset transcription status is\n    shown when browsing the project through a given topic.\n    \"\"\"\n\n    project = models.ForeignKey(\"Project\", on_delete=models.CASCADE)\n    topic = models.ForeignKey(\"Topic\", on_delete=models.CASCADE)\n\n    url_filter = models.CharField(\n        max_length=20,\n        choices=TranscriptionStatus.CHOICES,\n        blank=True,\n        null=True,\n        help_text=\"Optional filter on the status for this project-topic link\",\n    )\n    ordering = models.IntegerField(\n        default=0, help_text=\"Sort order override: lower values will be listed first\"\n    )\n\n    class Meta:\n        db_table = (\n            \"concordia_project_topics\"  # pre-existing table, so we reuse the name\n        )\n        unique_together = (\"project\", \"topic\")\n        ordering = (\"ordering\",)\n        indexes = [\n            models.Index(fields=[\"topic\", \"project\"]),\n            models.Index(fields=[\"topic\", \"ordering\"]),\n            models.Index(fields=[\"topic\", \"url_filter\"]),\n        ]\n"
  },
  {
    "path": "concordia/parser.py",
    "content": "import html\nfrom html.parser import HTMLParser\n\nimport defusedxml.ElementTree as ET\nimport requests\nfrom django.core.cache import cache\n\nfrom concordia.logging import ConcordiaLogger\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\nclass OGImageParser(HTMLParser):\n    def __init__(self):\n        super().__init__()\n        self.og_image = None\n\n    def handle_starttag(self, tag, attrs):\n        if tag.lower() == \"meta\":\n            attr_dict = dict(attrs)\n            print(attr_dict)\n            if attr_dict.get(\"property\") == \"og:image\" and \"content\" in attr_dict:\n                self.og_image = attr_dict[\"content\"]\n\n\ndef extract_og_image(url):\n    \"\"\"Fetch the meta value from the HTML.\"\"\"\n    cache_key = f\"og_image:{url}\"\n\n    try:\n        response = requests.get(url, timeout=5)\n        parser = OGImageParser()\n        parser.feed(html.unescape(response.text))\n        cache.set(cache_key, parser.og_image, timeout=24 * 60 * 60)\n        return parser.og_image\n    except requests.RequestException:\n        structured_logger.warning(\n            \"Failed to fetch image for blog post: %s\",\n            event_code=\"post_image_fetch_failed\",\n            reason=(\n                \"Failed to fetch Open Graph image from the \"\n                \"given URL due to a network or HTTP error\"\n            ),\n            reason_code=\"ogi_req_fail_fetch\",\n        )\n\n\ndef get_og_image(url):\n    \"\"\"Fetch the meta value from the HTML.\"\"\"\n    cache_key = f\"og_image:{url}\"\n    cached_image = cache.get(cache_key)\n    if cached_image is not None:\n        return cached_image\n    else:\n        return extract_og_image(url)\n\n\ndef fetch_blog_posts():\n    \"\"\"get and parse The Signal's RSS feed\"\"\"\n    try:\n        response = requests.get(\n            \"https://blogs.loc.gov/thesignal/category/by-the-people-transcription-program/feed/\",\n            timeout=60,\n        )\n        response.raise_for_status()\n        root = ET.fromstring(response.content)\n    except requests.exceptions.HTTPError:\n        structured_logger.warning(\n            \"HTTP error when fetching blog posts, but handled: %s\",\n            event_code=\"handled_post_fetch_http_error\",\n            reason=\"The RSS feed returned an HTTP error response (e.g. 4xx or 5xx)\",\n            reason_code=\"blog_http_error\",\n        )\n        return []\n    except requests.exceptions.ConnectionError:\n        structured_logger.warning(\n            \"Connection error when fetching blog posts: %s\",\n            event_code=\"blog_post_fetch_connection_error\",\n            reason=\"Network connection failed while trying to reach the RSS feed.\",\n            reason_code=\"blog_conn_error\",\n        )\n        return []\n    except requests.exceptions.Timeout:\n        structured_logger.warning(\n            \"Timeout when fetching blog posts: %s\",\n            event_code=\"blog_post_fetch_timeout\",\n            reason=\"The request to fetch RSS feed exceeded the timeout threshold.\",\n            reason_code=\"blog_timeout\",\n        )\n        return []\n    except requests.exceptions.RequestException:\n        structured_logger.warning(\n            \"Request exception when fetching blog posts: %s\",\n            event_code=\"blog_post_fetch_request_exception\",\n            reason=\"General request failure when fetching or parsing RSS feed content.\",\n            reason_code=\"blog_req_error\",\n        )\n        return []\n\n    return root.find(\"channel\").findall(\"item\")\n\n\ndef paginate_blog_posts():\n    feed_items = []\n    items = fetch_blog_posts()\n    for item in items[:6]:\n        feed_item = {\n            \"title\": item.find(\"title\").text,\n        }\n        link = item.find(\"link\")\n        if link is not None:\n            feed_item[\"link\"] = link.text\n            og_image = get_og_image(link.text)\n            if og_image is not None:\n                feed_item[\"og_image\"] = og_image\n        feed_items.append(feed_item)\n    segmented_items = [feed_items[:3]]\n    if len(feed_items) > 3:\n        segmented_items.append(feed_items[3:6])\n\n    return segmented_items\n"
  },
  {
    "path": "concordia/passwords/LICENSE",
    "content": "Copyright (c) 2014, Donald Stufft\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n  list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n  this list of conditions and the following disclaimer in the documentation\n  and/or other materials provided with the distribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "concordia/passwords/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/passwords/validators.py",
    "content": "\"\"\"\nPassword complexity validator.\n\nThis module provides a `ComplexityValidator` compatible with Django’s validation\npipeline. It checks a password against configurable complexity requirements,\ncounting unique characters of several categories and optionally unique words.\n\nSettings:\n    PASSWORD_COMPLEXITY (dict[str, int] | None):\n        Mapping of requirement names to minimum counts. Any missing keys default\n        to 0. If the setting is falsy or not provided, no complexity checks run.\n\n        Supported keys:\n            - 'UPPER'   : unique uppercase letters\n            - 'LOWER'   : unique lowercase letters\n            - 'LETTERS' : unique letters (upper or lower)\n            - 'DIGITS'  : unique digits\n            - 'SPECIAL' : unique non-space, non-alnum characters\n            - 'WORDS'   : unique word tokens (\\\\b\\\\w+ with re.UNICODE)\n\nUsage:\n    In your Django settings:\n\n        AUTH_PASSWORD_VALIDATORS = [\n            {\n                \"NAME\":\n                \"concordia.passwords.validators.ComplexityValidator\",\n                \"OPTIONS\": {\n                    \"complexities\": {\n                        \"UPPER\": 1,\n                        \"LOWER\": 1,\n                        \"DIGITS\": 1,\n                        \"SPECIAL\": 1,\n                        \"LETTERS\": 4,\n                        \"WORDS\": 2,\n                    }\n                },\n            },\n        ]\n\n\"\"\"\n\n# Originally from\n# https://github.com/dstufft/django-passwords/blob/master/passwords/validators.py\nimport re\nfrom typing import Mapping, Set\n\nfrom django.conf import settings\nfrom django.core.exceptions import ValidationError\nfrom django.utils.translation import gettext_lazy as _\n\n# Settings\nPASSWORD_COMPLEXITY = getattr(settings, \"PASSWORD_COMPLEXITY\", None)\n\n\nclass ComplexityValidator(object):\n    \"\"\"\n    Validate password complexity against configured unique-count thresholds.\n\n    The validator counts unique characters in several categories (uppercase,\n    lowercase, letters, digits, special) and unique words, then compares those\n    counts to thresholds provided at construction time.\n\n    Attributes:\n        message (str):\n            Base error message template. Interpolated with a comma-separated\n            list of failed requirements.\n        code (str):\n            Error code used in `ValidationError`.\n        complexities (dict[str, int] | None):\n            Thresholds for each category. When `None`, the validator is\n            effectively disabled.\n    \"\"\"\n\n    message = _(\"Must be more complex (%s)\")\n    code = \"complexity\"\n\n    def __init__(self, complexities: Mapping[str, int] | None):\n        \"\"\"\n        Initialize the validator.\n\n        Args:\n            complexities (Mapping[str, int] | None):\n                Per-category minimum unique counts. If `None`, no checks are\n                enforced. Missing keys default to 0.\n        \"\"\"\n        self.complexities = complexities\n\n    def __call__(self, value: str) -> None:\n        \"\"\"\n        Validate a password string.\n\n        The method tallies unique characters by category using `str.isupper`,\n        `str.islower`, `str.isdigit`, and a fallback for non-space, non-alnum\n        characters, and counts unique words via `re.findall(r\"\\\\b\\\\w+\", ...)`.\n\n        Args:\n            value (str): The candidate password.\n\n        Raises:\n            ValidationError:\n                If one or more configured thresholds are not met. The error\n                message lists each failed requirement.\n        \"\"\"\n        if self.complexities is None:\n            return\n\n        uppercase: Set[str] = set()\n        lowercase: Set[str] = set()\n        letters: Set[str] = set()\n        digits: Set[str] = set()\n        special: Set[str] = set()\n\n        for character in value:\n            if character.isupper():\n                uppercase.add(character)\n                letters.add(character)\n            elif character.islower():\n                lowercase.add(character)\n                letters.add(character)\n            elif character.isdigit():\n                digits.add(character)\n            elif not character.isspace():\n                special.add(character)\n\n        words = set(re.findall(r\"\\b\\w+\", value, re.UNICODE))\n\n        errors = []\n        if len(uppercase) < self.complexities.get(\"UPPER\", 0):\n            errors.append(\n                _(\"%(UPPER)s or more unique uppercase characters\") % self.complexities\n            )\n        if len(lowercase) < self.complexities.get(\"LOWER\", 0):\n            errors.append(\n                _(\"%(LOWER)s or more unique lowercase characters\") % self.complexities\n            )\n        if len(letters) < self.complexities.get(\"LETTERS\", 0):\n            errors.append(_(\"%(LETTERS)s or more unique letters\") % self.complexities)\n        if len(digits) < self.complexities.get(\"DIGITS\", 0):\n            errors.append(_(\"%(DIGITS)s or more unique digits\") % self.complexities)\n        if len(special) < self.complexities.get(\"SPECIAL\", 0):\n            errors.append(\n                _(\"%(SPECIAL)s or more non unique special characters\")\n                % self.complexities\n            )\n        if len(words) < self.complexities.get(\"WORDS\", 0):\n            errors.append(_(\"%(WORDS)s or more unique words\") % self.complexities)\n\n        if errors:\n            raise ValidationError(\n                self.message % (_(\"must contain \") + \", \".join(errors),),\n                code=self.code,\n            )\n"
  },
  {
    "path": "concordia/routing.py",
    "content": "import os\n\nfrom channels.auth import AuthMiddlewareStack\nfrom channels.routing import ProtocolTypeRouter, URLRouter\nfrom django.core.asgi import get_asgi_application\nfrom django.urls import path\n\nfrom . import consumers\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"concordia.settings\")\ndjango_asgi_app = get_asgi_application()\n\napplication = ProtocolTypeRouter(\n    {\n        \"http\": django_asgi_app,\n        \"websocket\": AuthMiddlewareStack(\n            URLRouter([path(\"ws/asset/asset_updates/\", consumers.AssetConsumer)])\n        ),\n    }\n)\n"
  },
  {
    "path": "concordia/secrets.py",
    "content": "import os\n\nimport boto3\nfrom botocore.exceptions import ClientError\n\nAWS_DEFAULT_REGION = os.getenv(\"AWS_DEFAULT_REGION\", \"us-east-1\")\n\n\ndef get_secret(secret_name):\n    endpoint_url = \"https://secretsmanager.%s.amazonaws.com\" % AWS_DEFAULT_REGION\n    region_name = AWS_DEFAULT_REGION\n    secret = None\n\n    session = boto3.session.Session()\n    client = session.client(\n        service_name=\"secretsmanager\",\n        region_name=region_name,\n        endpoint_url=endpoint_url,\n    )\n\n    try:\n        get_secret_value_response = client.get_secret_value(SecretId=secret_name)\n    except ClientError as e:\n        if e.response[\"Error\"][\"Code\"] == \"ResourceNotFoundException\":\n            raise Exception(\n                \"The requested secret \" + secret_name + \" was not found\"\n            ) from e\n        elif e.response[\"Error\"][\"Code\"] == \"InvalidRequestException\":\n            raise Exception(\n                \"The request for \" + secret_name + \"was invalid due to:\", e\n            ) from e\n        elif e.response[\"Error\"][\"Code\"] == \"InvalidParameterException\":\n            raise Exception(\n                \"The request for \" + secret_name + \"had invalid params:\", e\n            ) from e\n        elif e.response[\"Error\"][\"Code\"] == \"DecryptionFailure\":\n            raise Exception(\n                \"The request failed to decrypt the value for \" + secret_name + \":\", e\n            ) from e\n        else:\n            raise Exception(\"Unknown exception:\", e) from e\n    else:\n        # Decrypted secret using the associated KMS CMK Depending on whether the\n        # secret was a string or binary, one of these fields will be populated\n        if \"SecretString\" in get_secret_value_response:\n            secret = get_secret_value_response[\"SecretString\"]\n        else:\n            secret = get_secret_value_response[\"SecretBinary\"]\n\n    return secret\n"
  },
  {
    "path": "concordia/settings_dev.py",
    "content": "import os\n\nfrom .settings_template import *  # NOQA ignore=F405\nfrom .settings_template import DJANGO_VITE, INSTALLED_APPS, LOGGING, MIDDLEWARE\n\nLOGGING[\"handlers\"][\"stream\"][\"level\"] = \"DEBUG\"\nLOGGING[\"handlers\"][\"file\"][\"level\"] = \"DEBUG\"\nLOGGING[\"handlers\"][\"celery\"][\"level\"] = \"DEBUG\"\nLOGGING[\"handlers\"][\"structlog\"][\"level\"] = \"DEBUG\"\nLOGGING[\"handlers\"][\"django_structlog\"][\"level\"] = \"DEBUG\"\nLOGGING[\"loggers\"] = {\n    \"django\": {\"handlers\": [\"file\", \"stream\"], \"level\": \"DEBUG\"},\n    \"celery\": {\"handlers\": [\"celery\", \"stream\"], \"level\": \"DEBUG\"},\n    \"concordia\": {\"handlers\": [\"file\", \"stream\"], \"level\": \"DEBUG\"},\n    \"django.utils.autoreload\": {\"level\": \"INFO\"},\n    \"django.template\": {\"level\": \"INFO\"},\n    \"aws_xray_sdk\": {\n        \"handlers\": [\"file\", \"stream\"],\n        \"level\": \"DEBUG\",\n        \"propagate\": True,\n    },\n    \"structlog\": {\n        \"handlers\": [\"structlog_file\", \"structlog_console\"],\n        \"level\": \"INFO\",\n    },\n    \"django_structlog\": {\n        \"handlers\": [\"structlog_file\", \"structlog_console\"],\n        \"level\": \"INFO\",\n    },\n}\n\nDEBUG = True\n\n# Toggle this to True only when you run 'npm run dev' - vite dev server\n# Otherwise, it will look for the manifest.json in /dist/\nUSE_VITE_DEV_SERVER = os.getenv(\"USE_VITE_DEV_SERVER\", \"false\").lower() == \"true\"\n\nDJANGO_VITE[\"default\"][\"dev_mode\"] = USE_VITE_DEV_SERVER\nDJANGO_VITE[\"default\"][\"dev_server_port\"] = 5173\n\nALLOWED_HOSTS = [\"127.0.0.1\", \"0.0.0.0\", \"*\"]  # nosec\n\nEMAIL_BACKEND = \"django.core.mail.backends.console.EmailBackend\"\nEMAIL_FILE_PATH = (\n    \"/tmp/concordia-messages\"  # nosec — change this to a proper location for deployment\n)\nDEFAULT_FROM_EMAIL = os.environ.get(\"DEFAULT_FROM_EMAIL\", \"\")\nDEFAULT_TO_EMAIL = DEFAULT_FROM_EMAIL\nCONCORDIA_DEVS = [\n    \"rsar@loc.gov\",\n]\n\nINSTALLED_APPS += [\"django_opensearch_dsl\"]\n\n# Globally disable auto-syncing. Automatically update the index when a model is\n# created / saved / deleted.\nOPENSEARCH_DSL_AUTOSYNC = False\n\nOPENSEARCH_DSL = {\n    \"default\": {\"hosts\": \"localhost:9200\"},\n    \"secure\": {\n        \"hosts\": [{\"scheme\": \"https\", \"host\": \"192.30.255.112\", \"port\": 9201}],\n        \"http_auth\": (\"admin\", os.environ.get(\"OPENSEARCH_INITIAL_ADMIN_PASSWORD\", \"\")),\n        \"timeout\": 120,\n    },\n}\n\nREGISTRATION_SALT = \"django_registration\"  # doesn't need to be secret\n\nINSTALLED_APPS += [\"debug_toolbar\"]\nMIDDLEWARE += [\"debug_toolbar.middleware.DebugToolbarMiddleware\"]\nINTERNAL_IPS = (\"127.0.0.1\",)\n\nINSTALLED_APPS += (\"django_extensions\",)\nSHELL_PLUS_PRE_IMPORTS = [\n    (\"concordia.utils\", \"get_anonymous_user\"),\n    (\"concordia.models\", \"TranscriptionStatus\"),\n]\n\n# X-Ray configuration for local development\nif os.environ.get(\"AWS_XRAY_SDK_ENABLED\", \"false\").lower() == \"true\":\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    logger.info(\"ECS X-Ray auto-instrumentation starting\")\n\n    # Add X-Ray to INSTALLED_APPS\n    INSTALLED_APPS = INSTALLED_APPS + [\"aws_xray_sdk.ext.django\"]\n\n    # Add middleware - MUST be first in the list\n    MIDDLEWARE = [\"aws_xray_sdk.ext.django.middleware.XRayMiddleware\"] + MIDDLEWARE\n\n    logger.info(\"ECS X-Ray auto-instrumentation completed\")\n    logger.info(\"X-Ray middleware added at position 0: %s\", MIDDLEWARE[0])\n    logger.info(\"Current MIDDLEWARE[0]: %s\", MIDDLEWARE[0])\n\n    XRAY_RECORDER = {\n        \"AWS_XRAY_DAEMON_ADDRESS\": os.environ.get(\n            \"AWS_XRAY_DAEMON_ADDRESS\", \"127.0.0.1:2000\"\n        ),\n        \"AUTO_INSTRUMENT\": True,\n        \"AWS_XRAY_CONTEXT_MISSING\": os.environ.get(\n            \"AWS_XRAY_CONTEXT_MISSING\", \"LOG_ERROR\"\n        ),\n        \"PLUGINS\": (),\n        \"AWS_XRAY_TRACING_NAME\": os.environ.get(\n            \"AWS_XRAY_TRACING_NAME\",\n            os.environ.get(\"CONCORDIA_ENVIRONMENT\", \"development\"),\n        ),\n        \"PATCH_MODULES\": [\"boto3\", \"botocore\", \"requests\", \"httplib\", \"psycopg2\"],\n        \"SAMPLING\": False,\n        \"IGNORE_MODULE_PATTERNS\": [\n            r\"^debug_toolbar\\.\",\n            r\"^django\\.contrib\\.admin\\.views\\.decorators\\.cache\",\n            r\"^django\\.contrib\\.admin\\.options\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdmin\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdmin\",\n            r\"^django\\.contrib\\.admin\\.options\\.BaseModelAdmin\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminMixin\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminMixin\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminMixinBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminMixinBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminDecorator\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminDecorator\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminDecoratorMixin\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminDecoratorMixin\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminDecoratorBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminDecoratorBase\",\n        ],\n    }\n"
  },
  {
    "path": "concordia/settings_docker.py",
    "content": "import os\n\nfrom .settings_template import *  # NOQA ignore=F405\nfrom .settings_template import INSTALLED_APPS, STATIC_URL\n\nDEBUG = os.getenv(\"DEBUG\", \"\").lower() == \"true\"\n\nEMAIL_BACKEND = \"django.core.mail.backends.dummy.EmailBackend\"\n\nINSTALLED_APPS += [\"django_opensearch_dsl\"]\n\n\ndef whitenoise_immutable_file_test(static_url):\n    \"\"\"\n    Determine if a file is immutable based on its URL.\n    Vite assets in the 'dist/' directory are hashed and safe to cache forever.\n    \"\"\"\n    return static_url.startswith(f\"{STATIC_URL}dist/\")\n\n\nWHITENOISE_IMMUTABLE_FILE_TEST = whitenoise_immutable_file_test\n\n# Globally disable auto-syncing\nOPENSEARCH_DSL_AUTOSYNC = os.getenv(\"OPENSEARCH_DSL_AUTOSYNC\", False)\n\nOPENSEARCH_DSL = {\n    \"default\": {\"hosts\": os.getenv(\"OPENSEARCH_ENDPOINT\", \"9200:9200\")},\n    \"secure\": {\n        \"hosts\": [\n            {\"scheme\": \"https\", \"host\": os.getenv(\"OPENSEARCH_ENDPOINT\"), \"port\": 9201}\n        ],\n        \"http_auth\": (\"admin\", os.environ.get(\"OPENSEARCH_INITIAL_ADMIN_PASSWORD\", \"\")),\n        \"timeout\": 120,\n    },\n}\n\n# X-Ray configuration for local development\nif os.environ.get(\"AWS_XRAY_SDK_ENABLED\", \"false\").lower() == \"true\":\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    logger.info(\"ECS X-Ray auto-instrumentation starting\")\n\n    # Add X-Ray to INSTALLED_APPS\n    INSTALLED_APPS = INSTALLED_APPS + [\"aws_xray_sdk.ext.django\"]\n\n    # Add middleware - MUST be first in the list\n    MIDDLEWARE = [\n        \"aws_xray_sdk.ext.django.middleware.XRayMiddleware\"\n    ] + MIDDLEWARE  # noqa F405\n\n    logger.info(\"ECS X-Ray auto-instrumentation completed\")\n    logger.info(\"X-Ray middleware added at position 0: %s\", MIDDLEWARE[0])\n    logger.info(\"Current MIDDLEWARE[0]: %s\", MIDDLEWARE[0])\n\n    XRAY_RECORDER = {\n        \"AWS_XRAY_DAEMON_ADDRESS\": os.environ.get(\n            \"AWS_XRAY_DAEMON_ADDRESS\", \"127.0.0.1:2000\"\n        ),\n        \"AUTO_INSTRUMENT\": True,\n        \"AWS_XRAY_CONTEXT_MISSING\": os.environ.get(\n            \"AWS_XRAY_CONTEXT_MISSING\", \"LOG_ERROR\"\n        ),\n        \"PLUGINS\": (),\n        \"AWS_XRAY_TRACING_NAME\": os.environ.get(\n            \"AWS_XRAY_TRACING_NAME\",\n            os.environ.get(\"CONCORDIA_ENVIRONMENT\", \"development\"),\n        ),\n        \"PATCH_MODULES\": [\"boto3\", \"botocore\", \"requests\", \"httplib\", \"psycopg2\"],\n        \"SAMPLING\": False,\n        \"IGNORE_MODULE_PATTERNS\": [\n            r\"^debug_toolbar\\.\",\n            r\"^django\\.contrib\\.admin\\.views\\.decorators\\.cache\",\n            r\"^django\\.contrib\\.admin\\.options\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdmin\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdmin\",\n            r\"^django\\.contrib\\.admin\\.options\\.BaseModelAdmin\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminMixin\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminMixin\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminMixinBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminMixinBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminDecorator\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminDecorator\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminDecoratorMixin\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminDecoratorMixin\",\n            r\"^django\\.contrib\\.admin\\.options\\.ModelAdminDecoratorBase\",\n            r\"^django\\.contrib\\.admin\\.options\\.InlineModelAdminDecoratorBase\",\n        ],\n    }\n\n\n# HMAC activation flow provide the two-step registration process,\n# the user signs up and then completes activation via email instructions.\n\n# This is *not* a secret for the HMAC activation workflow — see:\n# https://django-registration.readthedocs.io/en/2.0.4/hmac.html#security-considerations\nREGISTRATION_SALT = \"django_registration\"\n\nRATELIMIT_BLOCK = os.getenv(\"RATELIMIT_BLOCK\", \"\").lower() not in (\"false\", \"0\")\n"
  },
  {
    "path": "concordia/settings_ecs.py",
    "content": "import json\nimport os\n\nfrom .secrets import get_secret\nfrom .settings_template import *  # NOQA ignore=F405\nfrom .settings_template import (\n    CONCORDIA_ENVIRONMENT,\n    DATABASES,\n    INSTALLED_APPS,\n    MIDDLEWARE,\n    STORAGES,\n)\n\nif os.getenv(\"AWS\"):\n    ENV_NAME = os.getenv(\"ENV_NAME\")\n\n    django_secret_json = get_secret(\"crowd/%s/Django/SecretKey\" % ENV_NAME)\n    django_secret = json.loads(django_secret_json)\n    SECRET_KEY = django_secret[\"DjangoSecretKey\"]\n\n    postgres_secret_json = get_secret(\"crowd/%s/DB/MasterUserPassword\" % ENV_NAME)\n    postgres_secret = json.loads(postgres_secret_json)\n\n    DATABASES[\"default\"].update({\"PASSWORD\": postgres_secret[\"password\"]})\n\n    cf_turnstile_secret_json = get_secret(\"crowd/%s/Turnstile\" % ENV_NAME)\n    cf_turnstile_secret = json.loads(cf_turnstile_secret_json)\n    TURNSTILE_SITEKEY = cf_turnstile_secret[\"TurnstileSiteKey\"]\n    TURNSTILE_SECRET = cf_turnstile_secret[\"TurnstileSecret\"]\n\n    smtp_secret_json = get_secret(\"concordia/SMTP\")\n    smtp_secret = json.loads(smtp_secret_json)\n    EMAIL_HOST = smtp_secret[\"Hostname\"]\n    EMAIL_HOST_USER = smtp_secret[\"Username\"]\n    EMAIL_HOST_PASSWORD = smtp_secret[\"Password\"]\n\nelse:\n    EMAIL_HOST = os.environ.get(\"EMAIL_HOST\", \"localhost\")\n    EMAIL_HOST_USER = os.environ.get(\"EMAIL_HOST_USER\", \"\")\n    EMAIL_HOST_PASSWORD = os.environ.get(\"EMAIL_HOST_PASSWORD\", \"\")\n\nSECURE_PROXY_SSL_HEADER = (\"HTTP_X_FORWARDED_PROTO\", \"https\")\n\nEMAIL_USE_TLS = True\nEMAIL_BACKEND = \"django.core.mail.backends.smtp.EmailBackend\"\nEMAIL_PORT = 587\nDEFAULT_FROM_EMAIL = os.environ.get(\"DEFAULT_FROM_EMAIL\", \"crowd@loc.gov\")\nDEFAULT_TO_EMAIL = DEFAULT_FROM_EMAIL\nCONCORDIA_DEVS = [\n    \"jkue@loc.gov\",\n    \"jstegmaier@loc.gov\",\n    \"rsar@loc.gov\",\n]\n\nCSRF_COOKIE_SECURE = True\n\nCELERY_BROKER_URL = os.getenv(\"CELERY_BROKER_URL\")\nCELERY_RESULT_BACKEND = CELERY_BROKER_URL\n\nS3_BUCKET_NAME = os.getenv(\"S3_BUCKET_NAME\")\nEXPORT_S3_BUCKET_NAME = os.getenv(\"EXPORT_S3_BUCKET_NAME\")\n\nSTORAGES = {\n    **STORAGES,\n    \"default\": {\n        \"BACKEND\": \"storages.backends.s3boto3.S3Boto3Storage\",\n    },\n    \"assets\": {\n        \"BACKEND\": \"storages.backends.s3boto3.S3Boto3Storage\",\n        \"OPTIONS\": {\n            \"querystring_auth\": False,\n        },\n    },\n    \"visualizations\": {\n        \"BACKEND\": \"concordia.storage_backends.OverwriteS3Boto3Storage\",\n        \"OPTIONS\": {\n            \"querystring_auth\": False,\n            \"bucket_name\": EXPORT_S3_BUCKET_NAME,\n        },\n    },\n}\nAWS_STORAGE_BUCKET_NAME = S3_BUCKET_NAME\nAWS_DEFAULT_ACL = None  # Don't set an ACL on the files, inherit the bucket ACLs\n\nif CONCORDIA_ENVIRONMENT == \"production\":\n    MEDIA_URL = \"https://crowd-media.loc.gov/\"\nelse:\n    MEDIA_URL = \"https://%s.s3.amazonaws.com/\" % S3_BUCKET_NAME\n\nINSTALLED_APPS += [\"django_opensearch_dsl\"]\n\n# Globally disable auto-syncing\nOPENSEARCH_DSL_AUTOSYNC = os.getenv(\"OPENSEARCH_DSL_AUTOSYNC\", False)\n\nOPENSEARCH_DSL = {\n    \"default\": {\"hosts\": os.getenv(\"OPENSEARCH_ENDPOINT\", \"opensearch-node:9200\")}\n}\n\n# HMAC activation flow provide the two-step registration process,\n# the user signs up and then completes activation via email instructions.\n\nREGISTRATION_SALT = \"django_registration\"  # doesn't need to be secret\n\nRATELIMIT_BLOCK = os.getenv(\"RATELIMIT_BLOCK\", \"\").lower() not in (\"false\", \"0\")\n\nif os.getenv(\"USE_PERSISTENT_DATABASE_CONNECTIONS\"):\n    DATABASES[\"default\"].update({\"CONN_MAX_AGE\": 15 * 60})\n\n# ECS-specific X-Ray auto-instrumentation (minimal Django config)\nif os.environ.get(\"AWS_XRAY_SDK_ENABLED\", \"false\").lower() == \"true\":\n    import logging\n\n    logger = logging.getLogger(__name__)\n\n    logger.info(\"ECS X-Ray auto-instrumentation starting\")\n\n    # Add X-Ray to INSTALLED_APPS\n    INSTALLED_APPS = INSTALLED_APPS + [\"aws_xray_sdk.ext.django\"]\n\n    # Add middleware\n    MIDDLEWARE = [\n        \"aws_xray_sdk.ext.django.middleware.XRayMiddleware\"\n    ] + MIDDLEWARE  # noqa F405\n\n    logger.info(\"ECS X-Ray auto-instrumentation completed\")\n    logger.info(\"X-Ray middleware added at position 0: %s\", MIDDLEWARE[0])\n    logger.info(\"aws_xray_sdk.ext.django added to INSTALLED_APPS\")\n    logger.info(\"All X-Ray configuration handled via environment variables\")\n"
  },
  {
    "path": "concordia/settings_loadtest.py",
    "content": "import os\nimport sys\n\nfrom .settings_template import *  # NOQA ignore=F405\nfrom .settings_template import DATABASES, LOGGING, STORAGES\n\nDEBUG = False\nRATELIMIT_ENABLE = False\n\n# Load testing DB name standard. If you need a different DB name, create a\n# personal settings file (eg settings_loadtest_<username>.py) and override it\n# there.\nDATABASES[\"default\"][\"NAME\"] = \"concordia_lt\"\n\n# Ensure Turnstile does not block Locust. Default to Cloudflare's test keys that\n# always pass, but allow env vars to override.\nTURNSTILE_SITEKEY = os.environ.get(\n    \"TURNSTILE_SITEKEY\",\n    \"1x00000000000000000000BB\",  # always pass, invisible\n)\nTURNSTILE_SECRET = os.environ.get(\n    \"TURNSTILE_SECRET\",\n    \"1x0000000000000000000000000000000AA\",  # always pass\n)\n\nLOGGING[\"handlers\"][\"stream\"][\"level\"] = \"INFO\"\nLOGGING[\"handlers\"][\"file\"][\"level\"] = \"INFO\"\nLOGGING[\"handlers\"][\"celery\"][\"level\"] = \"INFO\"\nLOGGING[\"handlers\"][\"console\"] = {\n    \"level\": \"INFO\",\n    \"class\": \"logging.StreamHandler\",\n    \"stream\": sys.stdout,\n}\nLOGGING[\"handlers\"][\"celery_console\"] = {\n    \"level\": \"INFO\",\n    \"class\": \"logging.StreamHandler\",\n    \"stream\": sys.stdout,\n    \"formatter\": \"long\",\n}\nLOGGING[\"handlers\"][\"structlog_file\"][\"level\"] = \"INFO\"\nLOGGING[\"handlers\"][\"structlog_console\"][\"level\"] = \"INFO\"\n\nLOGGING[\"loggers\"][\"django\"][\"handlers\"] = [\"file\", \"stream\", \"console\"]\nLOGGING[\"loggers\"][\"celery\"][\"handlers\"] = [\"celery\", \"celery_console\"]\nLOGGING[\"loggers\"][\"concordia\"][\"handlers\"] = [\"file\", \"stream\", \"console\"]\nLOGGING[\"loggers\"][\"concordia\"][\"level\"] = \"INFO\"\nLOGGING[\"loggers\"][\"django.utils.autoreload\"] = {\"level\": \"INFO\"}\nLOGGING[\"loggers\"][\"django.template\"] = {\"level\": \"INFO\"}\nLOGGING[\"loggers\"][\"structlog\"][\"handlers\"] = [\"structlog_file\", \"structlog_console\"]\nLOGGING[\"loggers\"][\"django_structlog\"][\"handlers\"] = [\n    \"structlog_file\",\n    \"structlog_console\",\n]\n\nALLOWED_HOSTS = [\"127.0.0.1\", \"0.0.0.0\", \"*\"]  # nosec\n\nMAIL_BACKEND = \"django.core.mail.backends.console.EmailBackend\"\nEMAIL_FILE_PATH = \"/tmp/concordia-messages\"  # nosec\nDEFAULT_FROM_EMAIL = os.environ.get(\"DEFAULT_FROM_EMAIL\", \"test@example.test\")\nDEFAULT_TO_EMAIL = DEFAULT_FROM_EMAIL\n\nREGISTRATION_SALT = \"django_registration\"  # doesn't need to be secret\n\nS3_BUCKET_NAME = \"crowd-staging-content\"\nEXPORT_S3_BUCKET_NAME = \"crowd-staging-export\"\nSTORAGES = {\n    **STORAGES,\n    \"default\": {\n        \"BACKEND\": \"storages.backends.s3boto3.S3Boto3Storage\",\n    },\n    \"assets\": {\n        \"BACKEND\": \"storages.backends.s3boto3.S3Boto3Storage\",\n        \"OPTIONS\": {\n            \"querystring_auth\": False,\n        },\n    },\n    \"visualizations\": {\n        \"BACKEND\": \"concordia.storage_backends.OverwriteS3Boto3Storage\",\n        \"OPTIONS\": {\n            \"querystring_auth\": False,\n        },\n    },\n}\n\nAWS_STORAGE_BUCKET_NAME = S3_BUCKET_NAME\nAWS_DEFAULT_ACL = None  # Don't set an ACL on the files, inherit the bucket ACLs\nMEDIA_URL = \"https://%s.s3.amazonaws.com/\" % S3_BUCKET_NAME\n\nSECURE_CROSS_ORIGIN_OPENER_POLICY = None\n"
  },
  {
    "path": "concordia/settings_local_test.py",
    "content": "import logging\nimport os\n\nimport structlog\n\nfrom .settings_template import *  # NOQA ignore=F405\nfrom .settings_template import DATABASES\n\nDEBUG = False\n\nDATABASES[\"default\"][\"PORT\"] = \"5432\"\n\nCHANNEL_LAYERS = {\n    \"default\": {\n        \"BACKEND\": \"channels.layers.InMemoryChannelLayer\",\n    }\n}\n\nCACHES = {\n    \"default\": {\n        \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        \"LOCATION\": \"default-location\",\n    },\n    \"view_cache\": {\n        \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        \"LOCATION\": \"view-location\",\n    },\n    \"configuration_cache\": {\n        \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        \"LOCATION\": \"configuration-location\",\n    },\n    \"visualization_cache\": {\n        \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        \"LOCATION\": \"visualization-location\",\n    },\n}\n\nstructlog.configure(\n    processors=[],\n    wrapper_class=structlog.make_filtering_bound_logger(logging.CRITICAL),\n    context_class=dict,\n    logger_factory=structlog.stdlib.LoggerFactory(),\n)\n\n# These cause Celery to run tasks locally, synchronously and immediately\nCELERY_TASK_ALWAYS_EAGER = True\nCELERY_TASK_EAGER_PROPAGATES = True\n\nDEFAULT_TO_EMAIL = \"rsar@loc.gov\"\nCONCORDIA_DEVS = [\n    \"rsar@loc.gov\",\n]\n\nALLOWED_HOSTS = [\"127.0.0.1\", \"0.0.0.0\"]  # nosec\n\nEMAIL_BACKEND = \"django.core.mail.backends.dummy.EmailBackend\"\n\nSESSION_ENGINE = \"django.contrib.sessions.backends.cache\"\n\nRATELIMIT_ENABLE = False\n\n# Turnstile settings\nTURNSTILE_JS_API_URL = os.environ.get(\n    \"TURNSTILE_JS_API_URL\", \"https://challenges.cloudflare.com/turnstile/v0/api.js\"\n)\nTURNSTILE_VERIFY_URL = os.environ.get(\n    \"TURNSTILE_VERIFY_URL\", \"https://challenges.cloudflare.com/turnstile/v0/siteverify\"\n)\nTURNSTILE_SITEKEY = os.environ.get(\n    \"TURNSTILE_SITEKEY\", \"1x00000000000000000000BB\"\n)  # Always pass, invisible\nTURNSTILE_SECRET = os.environ.get(\n    \"TURNSTILE_SECRET\", \"1x0000000000000000000000000000000AA\"\n)  # Always pass\n"
  },
  {
    "path": "concordia/settings_template.py",
    "content": "import os\n\nimport sentry_sdk\nimport structlog\nfrom django.contrib import messages\nfrom django.core.management.utils import get_random_secret_key\nfrom sentry_sdk.integrations.django import DjangoIntegration\n\nfrom concordia.version import get_concordia_version\n\n# New in 3.2, if no field in a model is defined with primary_key=True an implicit\n# primary key is added. This can now be controlled by changing the value below\n# 3.2 default value is BigAutoField. But migrations does not support M2M PK\nDEFAULT_AUTO_FIELD = \"django.db.models.AutoField\"\n\n# Build paths inside the project like this: os.path.join(SITE_ROOT_DIR, ...)\nCONCORDIA_APP_DIR = os.path.abspath(os.path.dirname(__file__))\nSITE_ROOT_DIR = os.path.dirname(CONCORDIA_APP_DIR)\n\nSECRET_KEY = os.getenv(\"DJANGO_SECRET_KEY\", get_random_secret_key())\n\nCONCORDIA_ENVIRONMENT = os.environ.get(\"CONCORDIA_ENVIRONMENT\", \"development\")\nDATA_UPLOAD_MAX_MEMORY_SIZE = 10485760\n# Optional SMTP authentication information for EMAIL_HOST.\nEMAIL_HOST_USER = \"\"\nEMAIL_HOST_PASSWORD = \"\"  # nosec\nEMAIL_USE_TLS = False\nDEFAULT_FROM_EMAIL = \"crowd@loc.gov\"\n\nALLOWED_HOSTS = [\"*\"]\n\nDEBUG = False\nCSRF_COOKIE_SECURE = False\n\nAUTH_PASSWORD_VALIDATORS = []\nEMAIL_BACKEND = \"django.core.mail.backends.filebased.EmailBackend\"\nEMAIL_HOST = \"localhost\"\nEMAIL_PORT = 25\nLANGUAGE_CODE = \"en-us\"\nLOGIN_REDIRECT_URL = \"/\"\nLOGOUT_REDIRECT_URL = \"/\"\nROOT_URLCONF = \"concordia.urls\"\nSTATIC_ROOT = \"static-files\"\nSTATIC_URL = \"/static/\"\n\nSTATICFILES_FINDERS = [\n    # We let the filesystem override the app directories so Gulp can pre-process\n    # files if needed:\n    \"django.contrib.staticfiles.finders.FileSystemFinder\",\n    \"django.contrib.staticfiles.finders.AppDirectoriesFinder\",\n    # See https://github.com/kevin1024/django-npm\n    \"npm.finders.NpmFinder\",\n]\n\nSTATICFILES_DIRS = [\n    # Vite's new home (JS/Manifest)\n    os.path.join(SITE_ROOT_DIR, \"concordia\", \"static\", \"dist\"),\n    # Gulp's home (where base.css lives), based on gulpfile .dest('static/')\n    os.path.join(SITE_ROOT_DIR, \"static\"),\n    # Standard Admin assets\n    os.path.join(SITE_ROOT_DIR, \"concordia\", \"static\", \"admin\"),\n]\n\nNPM_FILE_PATTERNS = {\n    \"redom\": [\"dist/*\"],\n    \"split.js\": [\"dist/*\"],\n    \"urijs\": [\"src/*\"],\n    \"openseadragon\": [\"build/*\"],\n    \"openseadragon-filters\": [\"dist/*\", \"index.js\"],\n    \"codemirror\": [\"lib/*\", \"addon/*\", \"mode/*\"],\n    \"prettier\": [\"*.js\"],\n    \"remarkable\": [\"dist/*\"],\n    \"jquery\": [\"dist/*\"],\n    \"js-cookie\": [\"dist/*\"],\n    \"@popperjs/core\": [\"dist/*\"],\n    \"bootstrap\": [\"dist/*\"],\n    \"screenfull\": [\"*\"],\n    \"@duetds/date-picker/\": [\"dist/*\"],\n    \"@fortawesome/fontawesome-free/\": [\n        \"css/*\",\n        \"js/*\",\n        \"sprites/*\",\n        \"svgs/*\",\n        \"webfonts/*\",\n    ],\n    \"chart.js\": [\"auto/*\", \"dist/*\"],\n    \"@kurkle/color\": [\"dist/*\"],\n    \"chroma-js\": [\"dist/*\"],\n    \"@sentry\": [\"*\"],\n    \"@sentry-internal\": [\"*\"],\n}\n\nTEMPLATE_DEBUG = False\nTIME_ZONE = \"America/New_York\"\nUSE_I18N = True\nUSE_TZ = True\nWSGI_APPLICATION = \"concordia.wsgi.application\"\n\nDATABASES = {\n    \"default\": {\n        \"ENGINE\": \"django.db.backends.postgresql\",\n        \"NAME\": \"concordia\",\n        \"USER\": \"concordia\",\n        \"PASSWORD\": os.getenv(\"POSTGRESQL_PW\"),\n        \"HOST\": os.getenv(\"POSTGRESQL_HOST\", \"localhost\"),\n        \"PORT\": os.getenv(\"POSTGRESQL_PORT\", \"5432\"),\n        # Change this back to 15 minutes (15*60) once celery regression\n        # is fixed  see https://github.com/celery/celery/issues/4878\n        \"CONN_MAX_AGE\": 0,  # 15 minutes\n    }\n}\n\nINSTALLED_APPS = [\n    \"concordia.apps.ConcordiaAdminConfig\",  # Replaces 'django.contrib.admin'\n    \"django.contrib.auth\",\n    \"django.contrib.contenttypes\",\n    \"django.contrib.humanize\",\n    \"django.contrib.sessions\",\n    \"django.contrib.messages\",\n    \"django.contrib.sites\",\n    # Replaces \"django.contrib.staticfiles\",\n    \"concordia.apps.ConcordiaStaticFilesConfig\",\n    \"django_structlog\",\n    \"django_bootstrap5\",\n    \"maintenance_mode\",\n    \"concordia.apps.ConcordiaAppConfig\",\n    \"exporter\",\n    \"importer\",\n    \"configuration\",\n    \"prometheus_metrics.apps.PrometheusMetricsConfig\",\n    \"robots\",\n    \"django_celery_beat\",\n    \"flags\",\n    \"channels\",\n    \"django_admin_multiple_choice_list_filter\",\n    \"tinymce\",\n    \"django_vite\",\n]\n\nMIDDLEWARE = [\n    \"prometheus_metrics.middleware.PrometheusBeforeMiddleware\",\n    \"django.middleware.security.SecurityMiddleware\",\n    # WhiteNoise serves static files efficiently:\n    \"whitenoise.middleware.WhiteNoiseMiddleware\",\n    \"django.contrib.sessions.middleware.SessionMiddleware\",\n    \"django.middleware.common.CommonMiddleware\",\n    \"django.middleware.csrf.CsrfViewMiddleware\",\n    \"django.contrib.auth.middleware.AuthenticationMiddleware\",\n    \"django.contrib.messages.middleware.MessageMiddleware\",\n    \"django.middleware.clickjacking.XFrameOptionsMiddleware\",\n    \"django_structlog.middlewares.RequestMiddleware\",\n    \"django_ratelimit.middleware.RatelimitMiddleware\",\n    \"concordia.middleware.MaintenanceModeMiddleware\",\n]\n\nRATELIMIT_VIEW = \"concordia.views.rate_limit.ratelimit_view\"\nRATELIMIT_BLOCK = False\n\nTEMPLATES = [\n    {\n        \"BACKEND\": \"django.template.backends.django.DjangoTemplates\",\n        \"DIRS\": [\n            os.path.join(SITE_ROOT_DIR, \"templates\"),\n            os.path.join(CONCORDIA_APP_DIR, \"templates\"),\n        ],\n        \"OPTIONS\": {\n            \"context_processors\": [\n                \"django.template.context_processors.debug\",\n                \"django.template.context_processors.request\",\n                \"django.contrib.auth.context_processors.auth\",\n                \"django.contrib.messages.context_processors.messages\",\n                \"django.template.context_processors.media\",\n                \"maintenance_mode.context_processors.maintenance_mode\",\n                # Concordia\n                \"concordia.context_processors.system_configuration\",\n                \"concordia.context_processors.site_navigation\",\n                \"concordia.context_processors.maintenance_mode_frontend_available\",\n                \"concordia.context_processors.request_id_context\",\n                \"concordia.turnstile.context_processors.turnstile_default_settings\",\n            ],\n            \"libraries\": {\n                \"staticfiles\": \"django.templatetags.static\",\n                \"django_vite\": \"django_vite.templatetags.django_vite\",\n            },\n            \"loaders\": [\n                \"django.template.loaders.filesystem.Loader\",\n                \"django.template.loaders.app_directories.Loader\",\n            ],\n            \"builtins\": [\n                \"configuration.templatetags.configuration_tags\",\n                \"concordia.templatetags.reject_filter\",\n            ],\n        },\n    }\n]\n\nHAYSTACK_CONNECTIONS = {\n    \"default\": {\n        \"ENGINE\": \"haystack.backends.whoosh_backend.WhooshEngine\",\n        \"PATH\": os.path.join(os.path.dirname(__file__), \"whoosh_index\"),\n    }\n}\n\nREDIS_ADDRESS = os.environ.get(\"REDIS_ADDRESS\", \"localhost\")\nREDIS_PORT = os.environ.get(\"REDIS_PORT\", \"\")\nif REDIS_PORT.isdigit():\n    REDIS_PORT = int(REDIS_PORT)\nelse:\n    REDIS_PORT = 6379\n\nif REDIS_ADDRESS and REDIS_PORT:\n    CACHES = {\n        \"default\": {\n            \"BACKEND\": \"django_redis.cache.RedisCache\",\n            \"LOCATION\": f\"redis://{REDIS_ADDRESS}:{REDIS_PORT}/1\",\n            \"OPTIONS\": {\n                \"CLIENT_CLASS\": \"django_redis.client.DefaultClient\",\n            },\n        },\n        \"view_cache\": {\n            \"BACKEND\": \"django_redis.cache.RedisCache\",\n            \"LOCATION\": f\"redis://{REDIS_ADDRESS}:{REDIS_PORT}/2\",\n            \"OPTIONS\": {\n                \"CLIENT_CLASS\": \"django_redis.client.DefaultClient\",\n            },\n        },\n        \"configuration_cache\": {\n            \"BACKEND\": \"django_redis.cache.RedisCache\",\n            \"LOCATION\": f\"redis://{REDIS_ADDRESS}:{REDIS_PORT}/3\",\n            \"OPTIONS\": {\n                \"CLIENT_CLASS\": \"django_redis.client.DefaultClient\",\n            },\n        },\n        \"visualization_cache\": {\n            \"BACKEND\": \"django_redis.cache.RedisCache\",\n            \"LOCATION\": f\"redis://{REDIS_ADDRESS}:{REDIS_PORT}/4\",\n            \"OPTIONS\": {\n                \"CLIENT_CLASS\": \"django_redis.client.DefaultClient\",\n            },\n        },\n    }\nelse:\n    CACHES = {\n        \"default\": {\n            \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        },\n        \"view_cache\": {\"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\"},\n        \"configuration_cache\": {\n            \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\"\n        },\n        \"visualization_cache\": {\n            \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\"\n        },\n    }\n\nSESSION_ENGINE = \"django.contrib.sessions.backends.db\"\n\nCELERY_BROKER_URL = f\"redis://{REDIS_ADDRESS}:{REDIS_PORT}/0\"\nCELERY_RESULT_BACKEND = f\"redis://{REDIS_ADDRESS}:{REDIS_PORT}/0\"\n\nCELERY_ACCEPT_CONTENT = [\"json\"]\nCELERY_TASK_SERIALIZER = \"json\"\nCELERY_IMPORTS = (\"importer.tasks\",)\n\nCELERY_BROKER_HEARTBEAT = 0\nCELERY_BROKER_CONNECTION_RETRY = True\nCELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True\nCELERY_BROKER_TRANSPORT_OPTIONS = {\n    \"confirm_publish\": True,\n    \"max_retries\": 3,\n    \"interval_start\": 0,\n    \"interval_step\": 0.2,\n    \"interval_max\": 0.5,\n}\n\nLOGGING = {\n    \"version\": 1,\n    \"disable_existing_loggers\": False,\n    \"formatters\": {\n        \"long\": {\n            \"format\": \"[{asctime} {levelname} {name}:{lineno}] {message}\",\n            \"datefmt\": \"%Y-%m-%dT%H:%M:%S\",\n            \"style\": \"{\",\n        },\n        \"short\": {\n            \"format\": \"[{levelname} {name}] {message}\",\n            \"datefmt\": \"%Y-%m-%dT%H:%M:%S\",\n            \"style\": \"{\",\n        },\n        \"structlog_json\": {\n            \"()\": structlog.stdlib.ProcessorFormatter,\n            \"processor\": structlog.processors.JSONRenderer(),\n        },\n        \"structlog_console\": {\n            \"()\": structlog.stdlib.ProcessorFormatter,\n            \"processor\": structlog.dev.ConsoleRenderer(),\n        },\n    },\n    \"handlers\": {\n        \"stream\": {\n            \"class\": \"logging.StreamHandler\",\n            \"level\": \"INFO\",\n            \"formatter\": \"long\",\n        },\n        \"null\": {\"level\": \"INFO\", \"class\": \"logging.NullHandler\"},\n        \"file\": {\n            \"class\": \"logging.handlers.TimedRotatingFileHandler\",\n            \"level\": \"INFO\",\n            \"formatter\": \"long\",\n            \"filename\": f\"{SITE_ROOT_DIR}/logs/concordia.log\",\n            \"when\": \"H\",\n            \"interval\": 3,\n            \"backupCount\": 16,\n        },\n        \"celery\": {\n            \"level\": \"INFO\",\n            \"class\": \"logging.handlers.RotatingFileHandler\",\n            \"filename\": f\"{SITE_ROOT_DIR}/logs/celery.log\",\n            \"formatter\": \"long\",\n            \"maxBytes\": 1024 * 1024 * 100,  # 100 mb\n        },\n        \"structlog_file\": {\n            \"class\": \"logging.handlers.TimedRotatingFileHandler\",\n            \"level\": \"INFO\",\n            \"formatter\": \"structlog_json\",\n            \"filename\": f\"{SITE_ROOT_DIR}/logs/concordia-json.log\",\n            \"when\": \"H\",\n            \"interval\": 3,\n            \"backupCount\": 16,\n        },\n        \"structlog_console\": {\n            \"class\": \"logging.StreamHandler\",\n            \"level\": \"INFO\",\n            \"formatter\": \"structlog_console\",\n        },\n    },\n    \"loggers\": {\n        \"django\": {\"handlers\": [\"file\"], \"level\": \"INFO\"},\n        \"celery\": {\"handlers\": [\"celery\"], \"level\": \"INFO\"},\n        \"concordia\": {\"handlers\": [\"file\"], \"level\": \"INFO\"},\n        \"aws_xray_sdk\": {\"handlers\": [\"file\"], \"level\": \"INFO\", \"propagate\": True},\n        \"structlog\": {\n            \"handlers\": [\"structlog_file\"],\n            \"level\": \"INFO\",\n            \"propagate\": True,\n        },\n        \"django_structlog\": {\n            \"handlers\": [\"structlog_file\"],\n            \"level\": \"INFO\",\n            \"propagate\": False,\n        },\n    },\n}\n\nstructlog.configure(\n    processors=[\n        structlog.contextvars.merge_contextvars,\n        structlog.stdlib.filter_by_level,\n        structlog.processors.TimeStamper(fmt=\"iso\"),\n        structlog.stdlib.add_logger_name,\n        structlog.stdlib.add_log_level,\n        structlog.stdlib.PositionalArgumentsFormatter(),\n        structlog.processors.StackInfoRenderer(),\n        structlog.processors.format_exc_info,\n        structlog.processors.UnicodeDecoder(),\n        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,\n    ],\n    logger_factory=structlog.stdlib.LoggerFactory(),\n    cache_logger_on_first_use=True,\n)\n\n\n################################################################################\n# Django-specific settings above\n################################################################################\n\nMEDIA_URL = \"/media/\"\nMEDIA_ROOT = os.path.join(SITE_ROOT_DIR, \"media\")\n\nLOGIN_URL = \"login\"\n\nPASSWORD_VALIDATOR = (\n    \"django.contrib.auth.password_validation.UserAttributeSimilarityValidator\"  # nosec\n)\n\nAUTH_PASSWORD_VALIDATORS = [\n    {\"NAME\": PASSWORD_VALIDATOR},\n    {\n        \"NAME\": \"django.contrib.auth.password_validation.MinimumLengthValidator\",\n        \"OPTIONS\": {\"min_length\": 8},\n    },\n    {\"NAME\": \"django.contrib.auth.password_validation.CommonPasswordValidator\"},\n    {\"NAME\": \"django.contrib.auth.password_validation.NumericPasswordValidator\"},\n    {\"NAME\": \"concordia.validators.DjangoPasswordsValidator\"},\n]\n\n# See https://github.com/dstufft/django-passwords#settings\nPASSWORD_COMPLEXITY = {\n    \"UPPER\": 1,\n    \"LOWER\": 1,\n    \"LETTERS\": 1,\n    \"DIGITS\": 1,\n    \"SPECIAL\": 1,\n    \"WORDS\": 1,\n}\n\nAUTHENTICATION_BACKENDS = [\n    \"concordia.authentication_backends.EmailOrUsernameModelBackend\"\n]\n\n# Turnstile settings\nTURNSTILE_JS_API_URL = os.environ.get(\n    \"TURNSTILE_JS_API_URL\", \"https://challenges.cloudflare.com/turnstile/v0/api.js\"\n)\nTURNSTILE_VERIFY_URL = os.environ.get(\n    \"TURNSTILE_VERIFY_URL\", \"https://challenges.cloudflare.com/turnstile/v0/siteverify\"\n)\nTURNSTILE_SITEKEY = os.environ.get(\"TURNSTILE_SITEKEY\", \"\")\nTURNSTILE_SECRET = os.environ.get(\"TURNSTILE_SECRET\", \"\")\nTURNSTILE_TIMEOUT = os.environ.get(\"TURNSTILE_TIMEOUT\", 5)\nTURNSTILE_DEFAULT_CONFIG = os.environ.get(\n    \"TURNSTILE_DEFAULT_CONFIG\", {\"appearance\": \"interaction-only\"}\n)\nTURNSTILE_PROXIES = os.environ.get(\"TURNSTILE_PROXIES\", {})\nANONYMOUS_USER_VALIDATION_INTERVAL = 86400\n\nSTORAGES = {\n    \"default\": {\n        \"BACKEND\": \"django.core.files.storage.FileSystemStorage\",\n    },\n    \"staticfiles\": {\n        # Use the basic Compressed backend because Vite handled hashing and compression.\n        \"BACKEND\": \"whitenoise.storage.CompressedStaticFilesStorage\",\n    },\n    \"assets\": {\n        \"BACKEND\": \"django.core.files.storage.FileSystemStorage\",\n    },\n    \"visualizations\": {\n        \"BACKEND\": \"django.core.files.storage.FileSystemStorage\",\n    },\n}\n\nDJANGO_VITE = {\n    \"default\": {\n        \"dev_mode\": DEBUG,\n        \"manifest_path\": os.path.join(\n            SITE_ROOT_DIR, \"concordia\", \"static\", \"dist\", \"manifest.json\"\n        ),\n        \"static_url_prefix\": \"\",\n    }\n}\n\nPASSWORD_RESET_TIMEOUT = 604800\nACCOUNT_ACTIVATION_DAYS = 7\nREGISTRATION_OPEN = True  # set to false to temporarily disable registrations\n\nREQUIRE_EMAIL_RECONFIRMATION = True\nEMAIL_RECONFIRMATION_KEY = \"EMAIL_CONFIRMATION_{id}\"\nEMAIL_RECONFIRMATION_DAYS = 7\nEMAIL_RECONFIRMATION_TIMEOUT = 60 * 60 * 24 * EMAIL_RECONFIRMATION_DAYS\n\nMESSAGE_STORAGE = \"django.contrib.messages.storage.session.SessionStorage\"\n\nMESSAGE_TAGS = {messages.ERROR: \"danger\"}\n\nSENTRY_BACKEND_DSN = os.environ.get(\"SENTRY_BACKEND_DSN\", \"\")\nSENTRY_FRONTEND_DSN = os.environ.get(\"SENTRY_FRONTEND_DSN\", \"\")\n\nAPPLICATION_VERSION = get_concordia_version()\n\nsentry_sdk.init(\n    dsn=SENTRY_BACKEND_DSN,\n    environment=CONCORDIA_ENVIRONMENT,\n    release=APPLICATION_VERSION,\n    integrations=[DjangoIntegration()],\n)\n\n# Names of special django.auth Groups\nCOMMUNITY_MANAGER_GROUP_NAME = \"Community Managers\"\nNEWSLETTER_GROUP_NAME = \"Newsletter\"\n\n# Django sites framework setting\nSITE_ID = 1\nROBOTS_USE_SITEMAP = False\nROBOTS_USE_HOST = False\n\n# django-bootstrap4 customization:\nBOOTSTRAP4 = {\"required_css_class\": \"form-group-required\", \"set_placeholder\": False}\n\n# Transcription-related settings\n\n#: Number of seconds an asset reservation is valid for\nTRANSCRIPTION_RESERVATION_SECONDS = 15 * 60\n\n#: Number of hours until an asset reservation is tombstoned\nTRANSCRIPTION_RESERVATION_TOMBSTONE_HOURS = 24\n\n#: Number of hours until a tombstoned reservation is deleted\nTRANSCRIPTION_RESERVATION_TOMBSTONE_LENGTH_HOURS = 24\n\n#: Web cache policy settings\nDEFAULT_PAGE_TTL = 5 * 60\n\n# Feature flags\nFLAGS = {\n    \"ADVERTISE_ACTIVITY_UI\": [],\n    \"CAROUSEL_CMS\": [],\n    \"SEND_WELCOME_EMAIL\": [],\n    \"SHOW_BANNER\": [],\n    \"DISPLAY_ITEM_DESCRIPTION\": [],\n    \"IMPORT_IMAGE_CHECKSUM\": [],\n}\n\nASGI_APPLICATION = \"concordia.routing.application\"\n\nCHANNEL_LAYERS = {\n    \"default\": {\n        \"BACKEND\": \"channels_redis.core.RedisChannelLayer\",\n        \"CONFIG\": {\n            \"hosts\": [(REDIS_ADDRESS, REDIS_PORT)],\n            \"capacity\": 1500,\n            \"expiry\": 10,\n        },\n    }\n}\n\nSECURE_REFERRER_POLICY = \"origin\"\nTINYMCE_COMPRESSOR = False\nTINYMCE_DEFAULT_CONFIG = {\n    \"selector\": \"textarea.tinymce\",\n    \"referrer_policy\": \"origin\",\n    \"skin\": \"oxide-dark\",\n    \"content_css\": \"dark\",\n    \"plugins\": \"link lists searchreplace wordcount\",\n    \"browser_spellcheck\": \"true\",\n    \"newline_behavior\": \"linebreak\",\n    \"toolbar1\": \"bold italic | numlist bullist | link | searchreplace wordcount\",\n    \"width\": 624,\n}\nTINYMCE_JS_URL = \"https://cdn.tiny.cloud/1/rf486i5f1ww9m8191oolczn7f0ry61mzdtfwbu7maiiiv2kv/tinymce/6/tinymce.min.js\"\n\nLANGUAGE_CODES = {\n    \"eng\": \"English (default)\",\n    \"afr\": \"Afrikaans\",\n    \"sqi\": \"Albanian\",\n    \"amh\": \"Amharic\",\n    \"ara\": \"Arabic\",\n    \"asm\": \"Assamese\",\n    \"aze\": \"Azerbaijani\",\n    \"aze_cyrl\": \"Azerbaijani - Cyrillic\",\n    \"eus\": \"Basque\",\n    \"bel\": \"Belarusian\",\n    \"ben\": \"Bengali\",\n    \"bos\": \"Bosnian\",\n    \"bul\": \"Bulgarian\",\n    \"mya\": \"Burmese\",\n    \"cat\": \"Catalan; Valencian\",\n    \"ceb\": \"Cebuano\",\n    \"khm\": \"Central Khmer\",\n    \"chr\": \"Cherokee\",\n    \"chi_sim\": \"Chinese - Simplified\",\n    \"chi_tra\": \"Chinese - Traditional\",\n    \"hrv\": \"Croatian\",\n    \"ces\": \"Czech\",\n    \"dan\": \"Danish\",\n    \"nld\": \"Dutch; Flemish\",\n    \"dzo\": \"Dzongkha\",\n    \"enm\": \"English, Middle (1100-1500)\",\n    \"epo\": \"Esperanto\",\n    \"est\": \"Estonian\",\n    \"kat\": \"Georgian\",\n    \"kat_old\": \"Georgian - Old\",\n    \"deu\": \"German\",\n    \"ell\": \"Greek, Modern (1453-)\",\n    \"fin\": \"Finnish\",\n    \"fra\": \"French\",\n    \"frm\": \"French, Middle (ca. 1400-1600)\",\n    \"glg\": \"Galician\",\n    \"frk\": \"German Fraktur\",\n    \"grc\": \"Greek, Ancient (-1453)\",\n    \"guj\": \"Gujarati\",\n    \"hat\": \"Haitian; Haitian Creole\",\n    \"heb\": \"Hebrew\",\n    \"hin\": \"Hindi\",\n    \"hun\": \"Hungarian\",\n    \"isl\": \"Icelandic\",\n    \"ind\": \"Indonesian\",\n    \"iku\": \"Inuktitut\",\n    \"gle\": \"Irish\",\n    \"ita\": \"Italian\",\n    \"ita_old\": \"Italian - Old\",\n    \"jpn\": \"Japanese\",\n    \"jav\": \"Javanese\",\n    \"kan\": \"Kannada\",\n    \"kaz\": \"Kazakh\",\n    \"kir\": \"Kirghiz; Kyrgyz\",\n    \"kor\": \"Korean\",\n    \"kur\": \"Kurdish\",\n    \"lao\": \"Lao\",\n    \"lat\": \"Latin\",\n    \"lav\": \"Latvian\",\n    \"lit\": \"Lithuanian\",\n    \"mkd\": \"Macedonian\",\n    \"mal\": \"Malayalam\",\n    \"mar\": \"Marathi\",\n    \"msa\": \"Malay\",\n    \"mlt\": \"Maltese\",\n    \"nep\": \"Nepali\",\n    \"nor\": \"Norwegian\",\n    \"ori\": \"Oriya\",\n    \"pan\": \"Panjabi; Punjabi\",\n    \"fas\": \"Persian\",\n    \"pol\": \"Polish\",\n    \"por\": \"Portuguese\",\n    \"pus\": \"Pushto; Pashto\",\n    \"ron\": \"Romanian; Moldavian; Moldovan\",\n    \"rus\": \"Russian\",\n    \"san\": \"Sanskrit\",\n    \"srp\": \"Serbian\",\n    \"srp_latn\": \"Serbian - Latin\",\n    \"sin\": \"Sinhala; Sinhalese\",\n    \"slk\": \"Slovak\",\n    \"slv\": \"Slovenian\",\n    \"spa\": \"Spanish; Castilian\",\n    \"spa_old\": \"Spanish; Castilian - Old\",\n    \"swa\": \"Swahili\",\n    \"swe\": \"Swedish\",\n    \"syr\": \"Syriac\",\n    \"tgl\": \"Tagalog\",\n    \"tgk\": \"Tajik\",\n    \"tam\": \"Tamil\",\n    \"tel\": \"Telugu\",\n    \"tha\": \"Thai\",\n    \"bod\": \"Tibetan\",\n    \"tir\": \"Tigrinya\",\n    \"tur\": \"Turkish\",\n    \"uig\": \"Uighur; Uyghur\",\n    \"ukr\": \"Ukrainian\",\n    \"urd\": \"Urdu\",\n    \"uzb\": \"Uzbek\",\n    \"uzb_cyrl\": \"Uzbek - Cyrillic\",\n    \"vie\": \"Vietnamese\",\n    \"cym\": \"Welsh\",\n    \"yid\": \"Yiddish\",\n}\nPYTESSERACT_ALLOWED_LANGUAGES = LANGUAGE_CODES.keys()\n\nPYLENIUM_CONFIG = os.path.join(SITE_ROOT_DIR, \"pylenium.json\")\n\nMAINTENANCE_MODE_STATE_BACKEND = \"maintenance_mode.backends.CacheBackend\"\nMAINTENANCE_MODE_IGNORE_ADMIN_SITE = True\nMAINTENANCE_MODE_IGNORE_URLS = (\"/healthz*\", \"/metrics*\", \"/maintenance-mode*\")\n\nDEFAULT_AXE_SCRIPT = os.path.join(\n    SITE_ROOT_DIR, \"node_modules\", \"axe-core\", \"axe.min.js\"\n)\n\n# Used for tracking accepts for the review rate limit\nTRANSCRIPTION_ACCEPTED_TRACKING_KEY = \"TRANSCRIPTION_ACCEPTED_{user_id}\"\n\nCONFIGURATION_CACHE_TIMEOUT = 3600  # One hour\n\n# The number of assets to store for next_transcribabe/next_reviewable, per campaign\nNEXT_TRANSCRIBABE_ASSET_COUNT = 100\nNEXT_REVIEWABLE_ASSET_COUNT = NEXT_TRANSCRIBABE_ASSET_COUNT\n"
  },
  {
    "path": "concordia/settings_test.py",
    "content": "import logging\nimport os\n\nimport structlog\n\nfrom .settings_template import *  # NOQA ignore=F405\nfrom .settings_template import DATABASES\n\nDEBUG = False\n\nDATABASES[\"default\"].update({\"PASSWORD\": \"\", \"USER\": \"postgres\"})\n\nCHANNEL_LAYERS = {\n    \"default\": {\n        \"BACKEND\": \"channels.layers.InMemoryChannelLayer\",\n    }\n}\n\nCACHES = {\n    \"default\": {\n        \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        \"LOCATION\": \"default-location\",\n    },\n    \"view_cache\": {\n        \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        \"LOCATION\": \"view-location\",\n    },\n    \"configuration_cache\": {\n        \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        \"LOCATION\": \"configuration-location\",\n    },\n    \"visualization_cache\": {\n        \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        \"LOCATION\": \"visualization-location\",\n    },\n}\n\nstructlog.configure(\n    processors=[],\n    wrapper_class=structlog.make_filtering_bound_logger(logging.CRITICAL),\n    context_class=dict,\n    logger_factory=structlog.stdlib.LoggerFactory(),\n)\n\n# These cause Celery to run tasks locally, synchronously and immediately\nCELERY_TASK_ALWAYS_EAGER = True\nCELERY_TASK_EAGER_PROPAGATES = True\n\nDEFAULT_TO_EMAIL = \"rsar@loc.gov\"\nCONCORDIA_DEVS = [\n    \"rsar@loc.gov\",\n]\n\nALLOWED_HOSTS = [\"127.0.0.1\", \"0.0.0.0\"]  # nosec\n\nEMAIL_BACKEND = \"django.core.mail.backends.dummy.EmailBackend\"\n\nSESSION_ENGINE = \"django.contrib.sessions.backends.cache\"\n\nRATELIMIT_ENABLE = False\n\n# Turnstile settings\nTURNSTILE_JS_API_URL = os.environ.get(\n    \"TURNSTILE_JS_API_URL\", \"https://challenges.cloudflare.com/turnstile/v0/api.js\"\n)\nTURNSTILE_VERIFY_URL = os.environ.get(\n    \"TURNSTILE_VERIFY_URL\", \"https://challenges.cloudflare.com/turnstile/v0/siteverify\"\n)\nTURNSTILE_SITEKEY = os.environ.get(\n    \"TURNSTILE_SITEKEY\", \"1x00000000000000000000BB\"\n)  # Always pass, invisible\nTURNSTILE_SECRET = os.environ.get(\n    \"TURNSTILE_SECRET\", \"1x0000000000000000000000000000000AA\"\n)  # Always pass\n\nCONCORDIA_DEVS = []\n"
  },
  {
    "path": "concordia/signals/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/signals/handlers.py",
    "content": "import logging\nfrom time import time\nfrom typing import Any\n\nimport structlog\nfrom asgiref.sync import AsyncToSync\nfrom channels.layers import get_channel_layer\nfrom django.conf import settings\nfrom django.contrib.auth import login as auth_login\nfrom django.contrib.auth.models import Group, User\nfrom django.contrib.auth.signals import user_logged_in, user_login_failed\nfrom django.core.mail import EmailMultiAlternatives\nfrom django.db.models.signals import post_delete, post_save\nfrom django.dispatch import receiver\nfrom django.http import HttpRequest\nfrom django.http.response import HttpResponseBase\nfrom django.template import loader\nfrom django_registration.signals import user_activated, user_registered\nfrom django_structlog import signals\nfrom flags.state import flag_enabled\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    Asset,\n    Transcription,\n    TranscriptionStatus,\n    UserProfile,\n)\nfrom concordia.tasks.assets import calculate_difficulty_values\nfrom concordia.tasks.useractivity import update_useractivity_cache\nfrom concordia.utils.next_asset import remove_next_asset_objects\n\nfrom .signals import reservation_obtained, reservation_released\n\nASSET_CHANNEL_LAYER = get_channel_layer()\n\nlogger = logging.getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@receiver(user_logged_in)\ndef clear_reservation_token(\n    sender: type[User],\n    user: User,\n    request: HttpRequest,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Clear any reservation token from the session on successful login.\n\n    Behavior:\n        If the session contains a key named \"reservation_token\", remove it and\n        persist the session. Emit structured logs describing whether a token\n        was cleared or not. Always log a successful login message.\n\n    Args:\n        sender (type[User]): The User model class that sent the signal.\n        user (User): The authenticated user.\n        request (HttpRequest): The current request containing the session.\n        **kwargs: Additional signal data (ignored).\n\n    Returns:\n        None\n    \"\"\"\n    try:\n        token = request.session[\"reservation_token\"]\n        del request.session[\"reservation_token\"]\n        request.session.save()\n        logger.info(\"Clearing reservation token %s for %s on login\", token, user)\n        structured_logger.info(\n            \"Reservation token cleared on login.\",\n            event_code=\"reservation_token_cleared\",\n            reservation_token=token,\n            user=user,\n        )\n    except KeyError:\n        structured_logger.debug(\n            \"No reservation token found to clear on login.\",\n            event_code=\"reservation_token_absent_on_login\",\n            user=user,\n        )\n\n    logger.info(\"Successful user login with username %s\", user)\n\n\n@receiver(user_login_failed)\ndef handle_user_login_failed(\n    sender: type[User],\n    credentials: dict[str, Any],\n    request: HttpRequest | None,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Log a warning when a user login attempt fails.\n\n    Args:\n        sender (type[User]): The User model class that sent the signal.\n        credentials (dict[str, Any]): Submitted credential data.\n        request (HttpRequest | None): The current request if available.\n        **kwargs: Additional signal data (ignored).\n\n    Returns:\n        None\n    \"\"\"\n    logger.warning(\"Failed user login with username %s\", credentials[\"username\"])\n\n\n@receiver(user_activated)\ndef user_successfully_activated(\n    sender: type[User],\n    user: User,\n    request: HttpRequest | None,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Handle post-activation tasks for a newly activated user.\n\n    Behavior:\n        If this activation is not the result of a password reset (request is\n        present), log the user in. Sends a welcome email when the\n        \"SEND_WELCOME_EMAIL\" flag is enabled.\n\n    Args:\n        sender (type[User]): The User model class that sent the signal.\n        user (User): The activated user.\n        request (HttpRequest | None): The current request or None for password\n            reset activations.\n        **kwargs: Additional signal data (ignored).\n\n    Returns:\n        None\n    \"\"\"\n    logger.info(\"Received user activation signal for %s\", user.username)\n\n    # Log the user in, if this isn't the result of a password reset\n    # The password reset form automatically logs the user in and activates.\n    # But when it does so, it sends None for the request.\n    # So when the user activates without resetting the password, the behavior\n    # should be the same - the user should be automatically logged in.\n    if request:\n        auth_login(request, user)\n\n    if flag_enabled(\"SEND_WELCOME_EMAIL\"):\n        text_body_template = loader.get_template(\"emails/welcome_email_body.txt\")\n        text_body_message = text_body_template.render()\n\n        html_body_template = loader.get_template(\"emails/welcome_email_body.html\")\n        html_body_message = html_body_template.render()\n\n        subject_template = loader.get_template(\"emails/welcome_email_subject.txt\")\n        subject_message = subject_template.render()\n\n        # Send welcome email\n        message = EmailMultiAlternatives(\n            subject=subject_message.rstrip(),\n            body=text_body_message,\n            from_email=settings.DEFAULT_FROM_EMAIL,\n            to=[user.email],\n            reply_to=[settings.DEFAULT_FROM_EMAIL],\n        )\n        message.attach_alternative(html_body_message, \"text/html\")\n        message.send()\n\n\n@receiver(user_registered)\ndef add_user_to_newsletter(\n    sender: type[User],\n    user: User,\n    request: HttpRequest,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Add a newly registered user to the newsletter group if they opted in.\n\n    Args:\n        sender (type[User]): The User model class that sent the signal.\n        user (User): The newly registered user.\n        request (HttpRequest): The registration request containing form data.\n        **kwargs: Additional signal data (ignored).\n\n    Returns:\n        None\n    \"\"\"\n    # If the user checked the newsletter checkbox,\n    # add them to the Newsletter group\n    if (\n        request.POST\n        and \"newsletterOptIn\" in request.POST\n        and request.POST[\"newsletterOptIn\"]\n    ):\n        newsletter_group = Group.objects.get(name=settings.NEWSLETTER_GROUP_NAME)\n        newsletter_group.user_set.add(user)\n        newsletter_group.save()\n\n\n@receiver(post_save, sender=Transcription)\ndef update_asset_status(\n    sender: type[Transcription],\n    *,\n    instance: Transcription,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Update the parent asset's transcription status after a transcription save.\n\n    Behavior:\n        Derive the asset's new status based on the latest transcription flags\n        (accepted, submitted, rejected). Proceed only if the saved instance is\n        the asset's current latest transcription. Persist the new status and\n        trigger downstream tasks and cache cleanup.\n\n    Side Effects:\n        - Saves the `Asset` with an updated `transcription_status`.\n        - Removes next-asset cache entries via `remove_next_asset_objects`.\n        - Triggers difficulty calculation on the saved asset.\n\n    Args:\n        sender (type[Transcription]): The Transcription model class.\n        instance (Transcription): The saved transcription instance.\n\n    Returns:\n        None\n    \"\"\"\n    logger.info(\"update_asset_status for %s\", instance.id)\n\n    asset = instance.asset\n\n    new_status = TranscriptionStatus.IN_PROGRESS\n\n    if instance.rejected:\n        new_status = TranscriptionStatus.IN_PROGRESS\n    elif instance.accepted:\n        new_status = TranscriptionStatus.COMPLETED\n    elif instance.submitted:\n        new_status = TranscriptionStatus.SUBMITTED\n\n    # Before we do anything, we need to make sure this\n    # is the latest transcription for the asset.\n    current_latest_transcription = asset.latest_transcription()\n    if instance != current_latest_transcription:\n        # A transcription lower down in the asset's history has been updated.\n        # This shouldn't happen outside of extraordinary circumstances.\n        # We'll log this occurrence then skip the rest of the signal because\n        # we don't want to change the asset's status since changing an older\n        # transcription doesn't logically affect the status or anything else\n        logger.warning(\n            \"An older transcription (%s) was updated for asset %s (%s). This \"\n            \"would have updated the status to %s, but this was prevented and \"\n            \"the status remained %s. The current latest_transcription is %s. \"\n            \"The sender was %s.\",\n            instance.id,\n            asset,\n            asset.id,\n            new_status,\n            asset.transcription_status,\n            current_latest_transcription,\n            sender,\n        )\n        return\n\n    logger.info(\n        \"Updating asset status for %s (%s) from %s to %s\",\n        asset,\n        asset.id,\n        asset.transcription_status,\n        new_status,\n    )\n\n    asset.transcription_status = new_status\n    asset.full_clean()\n    asset.save()\n\n    logger.info(\"Status for %s (%s) updated\", asset, asset.id)\n\n    remove_next_asset_objects(asset.id)\n\n    calculate_difficulty_values(Asset.objects.filter(pk=asset.pk))\n\n\n@receiver(post_save, sender=Asset)\ndef send_asset_update(\n    *,\n    instance: Asset,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Broadcast an asset update message to the channel layer.\n\n    Behavior:\n        Include the asset's current status, difficulty and the most recent\n        transcription details if present.\n\n    Args:\n        instance (Asset): The saved asset.\n\n    Returns:\n        None\n    \"\"\"\n    latest_trans = None\n\n    latest_transcription = instance.transcription_set.order_by(\"-pk\").first()\n    if latest_transcription:\n        latest_trans = {\n            \"text\": latest_transcription.text,\n            \"id\": latest_transcription.pk,\n            \"submitted_by\": latest_transcription.user.pk,\n        }\n\n    AsyncToSync(ASSET_CHANNEL_LAYER.group_send)(\n        \"asset_updates\",\n        {\n            \"type\": \"asset_update\",\n            \"asset_pk\": instance.pk,\n            \"status\": instance.transcription_status,\n            \"difficulty\": instance.difficulty,\n            \"latest_transcription\": latest_trans,\n        },\n    )\n\n\n@receiver(reservation_obtained)\ndef send_asset_reservation_obtained(sender: Any, **kwargs: Any) -> None:\n    \"\"\"\n    Broadcast an \"asset reservation obtained\" message and log the event.\n\n    Args:\n        sender (Any): The caller that obtained the reservation.\n        **kwargs: Expected keys are \"asset_pk\" and \"reservation_token\".\n\n    Returns:\n        None\n    \"\"\"\n    logger.info(\n        \"Reservation obtained by %s for asset %s with token %s\",\n        sender,\n        kwargs[\"asset_pk\"],\n        kwargs[\"reservation_token\"],\n    )\n\n    structured_logger.info(\n        \"Asset reservation obtained.\",\n        event_code=\"asset_reservation_obtained\",\n        asset_pk=kwargs[\"asset_pk\"],\n        reservation_token=kwargs[\"reservation_token\"],\n        sender=sender,\n    )\n\n    send_asset_reservation_message(\n        sender=sender,\n        message_type=\"asset_reservation_obtained\",\n        asset_pk=kwargs[\"asset_pk\"],\n        reservation_token=kwargs[\"reservation_token\"],\n    )\n\n\n@receiver(reservation_released)\ndef send_asset_reservation_released(sender: Any, **kwargs: Any) -> None:\n    \"\"\"\n    Broadcast an \"asset reservation released\" message and log the event.\n\n    Args:\n        sender (Any): The caller that released the reservation.\n        **kwargs: Expected keys are \"asset_pk\" and \"reservation_token\".\n\n    Returns:\n        None\n    \"\"\"\n    logger.info(\n        \"Reservation released by %s for asset %s with token %s\",\n        sender,\n        kwargs[\"asset_pk\"],\n        kwargs[\"reservation_token\"],\n    )\n    structured_logger.info(\n        \"Asset reservation released.\",\n        event_code=\"asset_reservation_released\",\n        asset_pk=kwargs[\"asset_pk\"],\n        reservation_token=kwargs[\"reservation_token\"],\n        sender=sender,\n    )\n    send_asset_reservation_message(\n        sender=sender,\n        message_type=\"asset_reservation_released\",\n        asset_pk=kwargs[\"asset_pk\"],\n        reservation_token=kwargs[\"reservation_token\"],\n    )\n\n\ndef send_asset_reservation_message(\n    *,\n    sender: Any,\n    message_type: str,\n    asset_pk: int,\n    reservation_token: str,\n) -> None:\n    \"\"\"\n    Send a structured reservation message over the \"asset_updates\" channel group.\n\n    Args:\n        sender (Any): The caller dispatching the message.\n        message_type (str): The channel message type to emit.\n        asset_pk (int): The asset primary key.\n        reservation_token (str): The reservation token value.\n\n    Returns:\n        None\n    \"\"\"\n    structured_logger.debug(\n        \"Dispatching reservation message to channel layer.\",\n        event_code=\"asset_reservation_channel_dispatch\",\n        message_type=message_type,\n        asset_pk=asset_pk,\n        reservation_token=reservation_token,\n        sender=sender,\n    )\n    AsyncToSync(ASSET_CHANNEL_LAYER.group_send)(\n        \"asset_updates\",\n        {\n            \"type\": message_type,\n            \"asset_pk\": asset_pk,\n            \"reservation_token\": reservation_token,\n            \"sent\": time(),\n        },\n    )\n\n\n@receiver(post_delete, sender=Asset)\ndef remove_file_from_s3(\n    sender: type[Asset],\n    instance: Asset,\n    using: str,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Delete the asset's stored image file from S3 after the asset is removed.\n\n    Args:\n        sender (type[Asset]): The Asset model class.\n        instance (Asset): The asset being deleted.\n        using (str): The database alias used for the operation.\n        **kwargs: Additional signal data (ignored).\n\n    Returns:\n        None\n    \"\"\"\n    instance.storage_image.delete(save=False)\n\n\n@receiver(post_save, sender=settings.AUTH_USER_MODEL)\ndef create_user_profile(\n    sender: Any,\n    instance: Any,\n    *args: Any,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Ensure a UserProfile exists for a newly saved user instance.\n\n    Behavior:\n        If the user instance does not have a related `profile`, create one.\n\n    Args:\n        sender (Any): The user model class.\n        instance (Any): The saved user instance.\n        *args: Unused positional signal arguments.\n        **kwargs: Unused keyword signal arguments.\n\n    Returns:\n        None\n    \"\"\"\n    if not hasattr(instance, \"profile\"):\n        UserProfile.objects.create(user=instance)\n\n\n@receiver(post_save, sender=Transcription)\ndef on_transcription_save(\n    sender: type[Transcription],\n    instance: Transcription,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Update user activity metrics when a transcription is created or reviewed.\n\n    Behavior:\n        - If the transcription was newly created, record a \"transcribe\" action.\n        - Else if it was reviewed, record a \"review\" action.\n        - Skip anonymous user activity.\n        - Dispatch `update_useractivity_cache` asynchronously.\n\n    Args:\n        sender (type[Transcription]): The Transcription model class.\n        instance (Transcription): The saved transcription.\n        **kwargs: Signal kwargs; \"created\" indicates a newly created instance.\n\n    Returns:\n        None\n    \"\"\"\n    if kwargs.get(\"created\", False):\n        user = instance.user\n        attr_name = \"transcribe\"\n    elif instance.reviewed_by:\n        user = instance.reviewed_by\n        attr_name = \"review\"\n    else:\n        user = None\n        attr_name = None\n\n    if user is not None and attr_name is not None and user.username != \"anonymous\":\n        structured_logger.info(\n            \"Transcription saved; updating user activity cache.\",\n            event_code=\"transcription_useractivity_triggered\",\n            transcription=instance,\n            user=user,\n            activity_type=attr_name,\n            campaign=instance.asset.item.project.campaign,\n        )\n        update_useractivity_cache.delay(\n            user.id,\n            instance.asset.item.project.campaign.id,\n            attr_name,\n        )\n\n\n@receiver(signals.update_failure_response)\n@receiver(signals.bind_extra_request_finished_metadata)\ndef add_request_id_to_response(\n    response: HttpResponseBase,\n    logger: ConcordiaLogger,\n    **kwargs: Any,\n) -> None:\n    \"\"\"\n    Add an `X-Request-ID` header to non-cacheable responses for traceability.\n\n    Behavior:\n        If the response is publicly cacheable, do nothing to avoid storing a\n        stale or incorrect request identifier. Otherwise, extract the current\n        request identifier from structlog context and attach it to the response.\n\n    Args:\n        response (HttpResponseBase): The response object to modify.\n        logger (Any): A ConcordiaLogger logger object with bound context.\n        **kwargs: Additional data (unused).\n\n    Returns:\n        None\n    \"\"\"\n    cache_control = response.get(\"Cache-Control\", \"\").lower()\n\n    is_public = \"public\" in cache_control or \"max-age\" in cache_control\n    is_private = (\n        \"private\" in cache_control\n        or \"no-store\" in cache_control\n        or \"no-cache\" in cache_control\n    )\n\n    if is_public and not is_private:\n        # Don't add header to potentially cacheable responses\n        # to avoid the cache storing a bad request_id\n        return\n\n    context = structlog.contextvars.get_merged_contextvars(logger)\n    response[\"X-Request-ID\"] = context[\"request_id\"]\n"
  },
  {
    "path": "concordia/signals/signals.py",
    "content": "\"\"\"\nSignals emitted by Concordia to announce reservation lifecycle events.\n\nSignals:\n    reservation_obtained (Signal): Emitted when an asset reservation is created.\n        Sender:\n            The actor that initiated the reservation (for example, a view).\n        Keyword arguments:\n            asset_pk (int): Primary key of the reserved asset.\n            reservation_token (str): Reservation token.\n\n    reservation_released (Signal): Emitted when an asset reservation is released.\n        Sender:\n            The actor that released the reservation.\n        Keyword arguments:\n            asset_pk (int): Primary key of the asset whose reservation was released.\n            reservation_token (str): The reservation token that was released.\n\"\"\"\n\nfrom django.dispatch import Signal\n\nreservation_obtained: Signal = Signal()\n\nreservation_released: Signal = Signal()\n"
  },
  {
    "path": "concordia/static/admin/custom-inline.js",
    "content": "/* global jQuery */\n\n(function ($) {\n    function triggerChangeOnField(win, chosenId) {\n        var element = document.getElementById(win.name);\n\n        $.ajax({\n            url: '/admin/serialized_object/',\n            data: {\n                model_name: 'Card',\n                object_id: chosenId,\n                field_name: 'title',\n            },\n            dataType: 'json',\n            success: function (data) {\n                const newContent = document.createTextNode(data.title);\n                var a = document.createElement('a');\n                a.href = '/admin/card/' + chosenId + '/change/';\n                a.append(newContent);\n                var newStrong = document.createElement('strong');\n                newStrong.append(a);\n                var strong = element.parentNode.querySelector('strong');\n                if (strong) {\n                    strong.replaceWith(newStrong);\n                } else {\n                    element.parentNode.append(newStrong);\n                }\n            },\n        });\n    }\n\n    // Vite\n    window.triggerChangeOnField = triggerChangeOnField;\n\n    $(document).ready(function () {\n        // https://stackoverflow.com/a/33937138/10320488\n        window.ORIGINAL_dismissRelatedLookupPopup =\n            window.dismissRelatedLookupPopup;\n        window.dismissRelatedLookupPopup = function (win, chosenId) {\n            window.ORIGINAL_dismissRelatedLookupPopup(win, chosenId);\n            triggerChangeOnField(win, chosenId);\n        };\n    });\n})(jQuery);\n"
  },
  {
    "path": "concordia/static/admin/editor-preview.js",
    "content": "/* global CodeMirror prettier prettierPlugins django */\n\n(function ($) {\n    /**\n     * Initializes CodeMirror with a side-by-side preview pane and Prettier support.\n     */\n    var setupCodeMirror = function (textarea, flavor) {\n        var converter;\n        switch (flavor) {\n            case 'html': {\n                converter = (input) => input;\n                break;\n            }\n            case 'markdown': {\n                var md = new window.remarkable.Remarkable({html: true});\n                converter = (input) => md.render(input);\n                break;\n            }\n            default: {\n                throw 'Unknown code flavor: ' + flavor;\n            }\n        }\n\n        var $formRow = $(textarea).parents('.form-row').first();\n        $formRow.addClass('codemirror-with-preview');\n\n        var preview = $('<iframe>')\n            // Firefox and, reportedly, Safari have a quirk where the <iframe> body\n            // is not correctly available until it “loads” the blank page:\n            .on('load', function () {\n                var frameDocument = this.contentDocument;\n                frameDocument.open();\n                frameDocument.write(\n                    '<html><body><main>Loading…</main></body></html>',\n                );\n                frameDocument.close();\n\n                var previewTemplate = document.querySelector(\n                    'template#preview-head',\n                ).content;\n\n                for (const node of previewTemplate.childNodes) {\n                    frameDocument.head.append(\n                        frameDocument.importNode(node, true),\n                    );\n                }\n\n                queueUpdate();\n            })\n            .insertAfter(textarea)\n            .get(0);\n\n        function updatePreview() {\n            var main = preview.contentDocument.body.querySelector('main');\n            if (main) {\n                main.innerHTML = converter(editor.getValue());\n            }\n        }\n\n        var editorMode = flavor;\n        if (flavor == 'html') {\n            // CodeMirror actually treats HTML as a subset of XML:\n            editorMode = {\n                name: 'xml',\n                htmlMode: true,\n            };\n        }\n\n        var editor = CodeMirror.fromTextArea(textarea, {\n            mode: editorMode,\n            lineNumbers: true,\n            highlightFormatting: true,\n            indentUnit: 4,\n            lineWrapping: true,\n        });\n\n        var editorLineWidgets = [];\n\n        var queuedUpdate;\n\n        editor.on('change', queueUpdate);\n\n        function queueUpdate() {\n            if (queuedUpdate) {\n                window.cancelAnimationFrame(queuedUpdate);\n            }\n            queuedUpdate = window.requestAnimationFrame(updatePreview);\n        }\n\n        $('<button class=\"button\">Run Prettier</button>')\n            .prependTo($formRow)\n            .on('click', function (event) {\n                event.preventDefault();\n\n                $formRow.find('.errornote').remove();\n\n                for (const widget of editorLineWidgets) {\n                    editor.removeLineWidget(widget);\n                }\n\n                try {\n                    var pretty = prettier.format(editor.getValue(), {\n                        parser: flavor,\n                        plugins: prettierPlugins,\n                        printWidth: 120,\n                        tabWidth: 4,\n                    });\n\n                    editor.setValue(pretty);\n                    queueUpdate();\n                } catch (error) {\n                    $('<p class=\"errornote\">').text(error).appendTo($formRow);\n\n                    var lineWarning = document.createElement('div');\n                    lineWarning.style.whiteSpace = 'nowrap';\n                    lineWarning.style.overflow = 'hidden';\n\n                    var icon = lineWarning.append(\n                        document.createElement('span'),\n                    );\n                    icon.style.marginRight = '1rem';\n                    icon.innerHTML = '⚠️';\n                    lineWarning.append(document.createTextNode(error.message));\n\n                    editorLineWidgets.push(\n                        editor.addLineWidget(\n                            error.loc.start.line - 1,\n                            lineWarning,\n                            {coverGutter: false, noHScroll: true},\n                        ),\n                    );\n                }\n            });\n    };\n\n    // Auto-initialize specifically for the SimplePage 'body' field\n    $(document).ready(function () {\n        var textArea = document.getElementById('id_body');\n\n        if (textArea) {\n            setupCodeMirror(textArea, 'markdown');\n        } else {\n            console.warn(\n                'CodeMirror: Element #id_content not found on this page.',\n            );\n        }\n    });\n})(django.jQuery);\n"
  },
  {
    "path": "concordia/static/js/src/about-accordions.js",
    "content": "import $ from 'jquery';\n\n$(function () {\n    $('.toggle-blog-posts').click(function (event) {\n        $('.accordion-icon', event.delegateTarget).toggleClass(\n            'fa-plus-square fa-minus-square',\n        );\n        $('.blog-content').toggle();\n    });\n    $('.toggle-publications').click(function (event) {\n        $('.accordion-icon', event.delegateTarget).toggleClass(\n            'fa-plus-square fa-minus-square',\n        );\n        $('.publications-content').toggle();\n    });\n    $('.toggle-press').click(function (event) {\n        $('.accordion-icon', event.delegateTarget).toggleClass(\n            'fa-plus-square fa-minus-square',\n        );\n        $('.press-content').toggle();\n    });\n    $('.toggle-program-history').click(function (event) {\n        $('.accordion-icon', event.delegateTarget).toggleClass(\n            'fa-plus-square fa-minus-square',\n        );\n        $('.program-history').toggle();\n    });\n});\n"
  },
  {
    "path": "concordia/static/js/src/asset-reservation.js",
    "content": "import $ from 'jquery';\nimport {Modal} from 'bootstrap';\nimport {buildErrorMessage, displayHtmlMessage, displayMessage} from './base.js';\nimport * as Sentry from '@sentry/browser';\n\nconst assetReservationElement = document.getElementById(\n    'asset-reservation-data',\n);\nconst assetReservationData = assetReservationElement\n    ? assetReservationElement.dataset\n    : {};\n\nfunction attemptToReserveAsset(reservationURL, findANewPageURL, actionType) {\n    let $transcriptionEditor = $('#transcription-editor');\n    // We need to do this because BS5 does not automatically initialize modals when you\n    // try to show them; without new boostrap.Modal, it doesn't recognize it as a modal\n    // at all (it's treated as ordinary HTML), so BS controls do not work\n    var reservationModalElement = document.getElementById(\n        'asset-reservation-failure-modal',\n    );\n    // This tries to get the modal if it exists, otherwise it initializes it\n    var reservationModal =\n        Modal.getInstance(reservationModalElement) ||\n        new Modal(reservationModalElement);\n\n    $.ajax({\n        url: reservationURL,\n        type: 'POST',\n        dataType: 'json',\n    })\n        .done(function () {\n            $transcriptionEditor\n                .data('hasReservation', true)\n                .trigger('update-ui-state');\n\n            // If the asset was successfully reserved, continue reserving it\n            window.setTimeout(\n                attemptToReserveAsset,\n                60_000,\n                reservationURL,\n                findANewPageURL,\n                actionType,\n            );\n        })\n        .fail(function (jqXHR, textStatus, errorThrown) {\n            if (jqXHR.status == 409) {\n                if (actionType == 'transcribe') {\n                    $transcriptionEditor\n                        .data('hasReservation', false)\n                        .trigger('update-ui-state');\n                    reservationModal.show();\n                } else {\n                    displayHtmlMessage(\n                        'warning',\n                        'There are other reviewers on this page.' +\n                            ' <a href=\"' +\n                            findANewPageURL +\n                            '\">Find a new page to review</a>',\n                        'transcription-reservation',\n                    );\n                    Sentry.captureException(errorThrown, function (scope) {\n                        scope.setTransactionName(\n                            '409 error when attempting to reserve asset at ' +\n                                reservationURL,\n                        );\n                    });\n                }\n            } else if (jqXHR.status == 408) {\n                $transcriptionEditor\n                    .data('hasReservation', false)\n                    .trigger('update-ui-state');\n                reservationModal.show();\n                Sentry.captureException(errorThrown, function (scope) {\n                    scope.setTransactionName(\n                        '408 error when attempting to reserve asset at ' +\n                            reservationURL,\n                    );\n                });\n            } else {\n                displayMessage(\n                    'error',\n                    'Unable to reserve this page: ' +\n                        buildErrorMessage(jqXHR, textStatus, errorThrown),\n                    'transcription-reservation',\n                );\n                Sentry.captureException(errorThrown, function (scope) {\n                    scope.setTransactionName(\n                        'Error when attempting to reserve asset at ' +\n                            reservationURL,\n                    );\n                });\n            }\n        });\n}\n\nif (!window._assetReservationUnloadBound) {\n    window.addEventListener('beforeunload', function () {\n        if (assetReservationData.reserveAssetUrl) {\n            let payload = {\n                release: true,\n                csrfmiddlewaretoken: $(\n                    'input[name=\"csrfmiddlewaretoken\"]',\n                ).val(),\n            };\n\n            // We'll try Beacon since that's reliable but until we can drop support for IE11 we need a fallback:\n            if ('sendBeacon' in navigator) {\n                navigator.sendBeacon(\n                    assetReservationData.reserveAssetUrl,\n                    new Blob([$.param(payload)], {\n                        type: 'application/x-www-form-urlencoded',\n                    }),\n                );\n            } else {\n                $.ajax({\n                    url: assetReservationData.reserveAssetUrl,\n                    type: 'POST',\n                    data: payload,\n                });\n            }\n        }\n    });\n    window._assetReservationUnloadBound = true;\n}\n\nfunction reserveAssetForEditing() {\n    if (assetReservationData.reserveAssetUrl) {\n        attemptToReserveAsset(\n            assetReservationData.reserveAssetUrl,\n            '',\n            'transcribe',\n        );\n    }\n}\n\n$(function () {\n    if (assetReservationData.reserveForEditing) {\n        reserveAssetForEditing();\n    }\n});\n\nexport {reserveAssetForEditing};\n"
  },
  {
    "path": "concordia/static/js/src/banner.js",
    "content": "var storage = window.localStorage;\nvar storageAvailable;\ntry {\n    const x = '__storage_test__';\n    storage.setItem(x, x);\n    storage.removeItem(x);\n    storageAvailable = true;\n} catch {\n    storageAvailable = false;\n}\nif (storageAvailable) {\n    for (var key in storage) {\n        if (key.startsWith('banner-')) {\n            const banner = document.getElementById(key);\n            if (banner && banner.classList.contains('alert')) {\n                banner.setAttribute('hidden', 'hidden');\n            }\n        }\n    }\n}\nconst noInterfaceBanner = document.getElementById('no-interface-banner');\nif (noInterfaceBanner) {\n    noInterfaceBanner.addEventListener('click', (event) => {\n        var banner = event.target.parentElement.parentElement;\n        if (banner.hasAttribute('id')) {\n            storage.setItem(banner.id, 'true');\n            banner.classList.remove('d-flex');\n            banner.setAttribute('hidden', 'hidden');\n        }\n    });\n}\n"
  },
  {
    "path": "concordia/static/js/src/base.js",
    "content": "import 'bootstrap';\nimport Cookies from 'js-cookie';\nimport $ from 'jquery';\nimport screenfull from 'screenfull';\nimport {Popover} from 'bootstrap';\nimport * as Sentry from '@sentry/browser';\n\n(function () {\n    /*\n        Configure jQuery to use CSRF tokens automatically — see\n        https://docs.djangoproject.com/en/2.1/ref/csrf/#setting-the-token-on-the-ajax-request\n    */\n\n    var CSRFCookie = Cookies.get('csrftoken');\n\n    if (!CSRFCookie) {\n        return;\n    }\n\n    function csrfSafeMethod(method) {\n        // these HTTP methods do not require CSRF protection\n        return /^(GET|HEAD|OPTIONS|TRACE)$/.test(method);\n    }\n\n    $.ajaxSetup({\n        beforeSend: function (xhr, settings) {\n            if (!csrfSafeMethod(settings.type) && !this.crossDomain) {\n                xhr.setRequestHeader('X-CSRFToken', CSRFCookie);\n            }\n        },\n    });\n})();\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    const popoverTriggerList = document.querySelectorAll(\n        '[data-bs-toggle=\"popover\"]',\n    );\n    for (const popoverTriggerElement of popoverTriggerList) {\n        new Popover(popoverTriggerElement);\n    }\n});\n\n// eslint-disable-next-line no-unused-vars\nexport function buildErrorMessage(jqXHR, textStatus, errorThrown) {\n    /* Construct a nice error message using optional JSON response context */\n    var errorMessage;\n    // eslint-disable-next-line unicorn/prefer-ternary\n    if (jqXHR.responseJSON && jqXHR.responseJSON.error) {\n        errorMessage = jqXHR.responseJSON.error;\n    } else {\n        errorMessage = textStatus + ' ' + errorThrown;\n    }\n    return errorMessage;\n}\n\nexport function displayHtmlMessage(level, message, uniqueId) {\n    /*\n        Display a dismissable message at a level which will match one of the\n        Bootstrap alert classes\n        (https://getbootstrap.com/docs/5.3/components/alerts/)\n\n        If provided, uniqueId will be used to remove any existing elements which\n        have that ID, allowing old messages to be replaced automatically.\n    */\n    let $messages = $('#messages');\n    $messages.removeAttr('hidden');\n\n    let $newMessage = $messages\n        .find('#message-template .alert')\n        .clone()\n        .removeAttr('hidden')\n        .removeAttr('id');\n\n    if (level == 'error') {\n        // Class for red background\n        level = 'danger';\n    }\n\n    $newMessage.addClass('alert-' + level);\n\n    if (uniqueId) {\n        $('#' + uniqueId).remove();\n        $newMessage.attr('id', uniqueId);\n    }\n\n    // Add a span to the message to ensure justified\n    // styles don't end up splitting the text\n    // message might be a Text node, so we need to get\n    // the actual text if so\n    if (message instanceof Text) {\n        message = message.textContent;\n    }\n    $newMessage.prepend('<span>' + message + '</span>');\n\n    $messages.append($newMessage);\n\n    return $newMessage;\n}\n\nexport function displayMessage(level, message, uniqueId) {\n    return displayHtmlMessage(\n        level,\n        document.createTextNode(message),\n        uniqueId,\n    );\n}\n\nfunction isOutdatedBrowser() {\n    /*\n        See https://caniuse.com/#feat=css-supports-api for the full matrix but\n        by now this is effectively the same as testing for IE11 vs. all of the\n        evergreen browsers:\n    */\n    return typeof CSS == 'undefined' || !CSS.supports;\n}\n\nfunction loadLegacyPolyfill(scriptUrl, callback) {\n    var script = document.createElement('script');\n    script.type = 'text/javascript';\n    script.async = false;\n    // eslint-disable-next-line unicorn/prefer-add-event-listener\n    script.onload = callback;\n    // eslint-disable-next-line unicorn/prevent-abbreviations\n    script.src = scriptUrl;\n    document.body.append(script);\n}\n\ndocument.addEventListener('DOMContent', () => {\n    if (isOutdatedBrowser()) {\n        var theMessage =\n            'You are using an outdated browser. This website fully supports the current ' +\n            'version of every major browser ' +\n            '(Microsoft Edge, Google Chrome, Mozilla Firefox, and Apple Safari). See ' +\n            'our <a href=\"/help-center/#browserSupport\">browser support policy</a> ' +\n            'for more information.';\n\n        var warningCookie = 'outdated-browser-message-hidden';\n        var warningLastShown = 0;\n        try {\n            var cookie = Cookies.get(warningCookie);\n            if (cookie) {\n                warningLastShown = Number.parseInt(cookie, 10);\n            }\n        } catch (error) {\n            Sentry.captureException(error);\n        }\n\n        if (Date.now() - warningLastShown > 7 * 86_400) {\n            displayHtmlMessage('danger', theMessage).on(\n                'closed.bs.alert',\n                function () {\n                    Cookies.set(warningCookie, Date.now());\n                },\n            );\n        }\n\n        /*\n            CSS variables are supported by everything except IE11:\n            https://caniuse.com/#feat=css-variables\n        */\n        loadLegacyPolyfill(\n            'https://cdn.jsdelivr.net/npm/css-vars-ponyfill@2.0.2/dist/css-vars-ponyfill.min.js',\n            function () {\n                /* global cssVars */\n                cssVars({\n                    legacyOnly: true,\n                    preserveStatic: true,\n                    include: 'link[rel=\"stylesheet\"][href^=\"/static/\"]',\n                });\n            },\n        );\n    }\n});\n\nif (screenfull.isEnabled) {\n    $('#go-fullscreen')\n        .removeAttr('hidden')\n        .on('click', function (event) {\n            event.preventDefault();\n            var targetElement = document.getElementById(this.dataset.bsTarget);\n\n            if (screenfull.isFullscreen) {\n                screenfull.exit();\n            } else {\n                screenfull.request(targetElement);\n            }\n        });\n}\n\nfunction appendAccountItem(link, $menu) {\n    if (link.type !== 'post') {\n        $('<a>')\n            .addClass('dropdown-item')\n            .attr('href', link.url)\n            .text(link.title)\n            .appendTo($menu);\n        return;\n    }\n\n    const csrfToken = Cookies.get('csrftoken');\n    const formId =\n        'nav-post-' + link.title.toLowerCase().replaceAll(/[^\\da-z]+/g, '-');\n\n    const $form = $('<form>')\n        .attr({id: formId, method: 'post', action: link.url})\n        .css('display', 'none')\n        .appendTo(document.body);\n\n    // Django expects the hidden field name \"csrfmiddlewaretoken\"\n    $('<input>')\n        .attr({type: 'hidden', name: 'csrfmiddlewaretoken', value: csrfToken})\n        .appendTo($form);\n\n    if (link.fields) {\n        for (const [name, value] of Object.entries(link.fields)) {\n            $('<input>')\n                .attr({type: 'hidden', name: name, value: value})\n                .appendTo($form);\n        }\n    }\n\n    $('<button>')\n        .addClass('dropdown-item')\n        .attr({type: 'submit', form: formId})\n        .text(link.title)\n        .appendTo($menu);\n}\n\n$.ajax({\n    url: '/account/ajax-status/',\n    method: 'GET',\n    dataType: 'json',\n    cache: true,\n}).done(function (data) {\n    if (!data.username) {\n        $('.anonymous-only').removeClass('d-none');\n        $('.anonymous-only').addClass('d-lg-flex');\n        $('.authenticated-only').addClass('d-none');\n        return;\n    }\n\n    $('.anonymous-only').addClass('d-none');\n    $('.anonymous-only').removeClass('d-lg-flex');\n    $('.authenticated-only').removeClass('d-none');\n\n    var $toggle = $('#topnav-account-dropdown-toggle');\n    var $accountDropdownMenu = $('#topnav-account-dropdown-menu');\n    if (data.username) {\n        $toggle.empty().text(data.username + ' ');\n        $('<span>')\n            .addClass('fa fa-chevron-down text-primary')\n            .appendTo($toggle);\n    }\n\n    if (data.links && $accountDropdownMenu.length > 0) {\n        $accountDropdownMenu.empty();\n        for (const link of data.links) {\n            appendAccountItem(link, $accountDropdownMenu);\n        }\n    }\n});\n\n$.ajax({url: '/account/ajax-messages/', method: 'GET', dataType: 'json'}).done(\n    function (data) {\n        if (data.messages) {\n            for (const message of data.messages) {\n                displayMessage(message.level, message.message);\n            }\n        }\n    },\n);\n\n// eslint-disable-next-line no-unused-vars\nexport function debounce(function_, timeout = 300) {\n    // Based on https://www.freecodecamp.org/news/javascript-debounce-example/\n    let timer;\n    return (...arguments_) => {\n        clearTimeout(timer);\n        timer = setTimeout(() => {\n            function_.apply(this, arguments_);\n        }, timeout);\n    };\n}\n\n/* Social share stuff */\n\nvar hideTooltip = function (tooltipButton) {\n    return function () {\n        tooltipButton.tooltip('hide');\n    };\n};\n\nvar hideTooltipCallback = function () {\n    // wait a couple seconds and then hide the tooltip.\n    setTimeout(hideTooltip($(this)), 3000);\n};\n\nfunction trackShareInteraction($element, interactionType) {\n    // Adobe analytics user interaction tracking\n    if ('loc_ux_tracking' in window) {\n        let loc_ux_tracking = window['loc_ux_tracking'];\n        loc_ux_tracking.trackUserInteractionEvent(\n            $element,\n            'Share Tool',\n            'click',\n            interactionType,\n        );\n    }\n}\n\nvar $copyUrlButton = $('.copy-url-button');\nvar $facebookShareButton = $('.facebook-share-button');\nvar $twitterShareButton = $('.twitter-share-button');\n\nconst copyUrlButton = document.querySelector('.copy-url-button');\nif (copyUrlButton) {\n    copyUrlButton.addEventListener('click', function (event) {\n        event.preventDefault();\n\n        // The asynchronous Clipboard API is not supported by Microsoft Edge or Internet Explorer:\n        // https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText#Browser_compatibility\n        // We'll use the older document.execCommand(\"copy\") interface which requires a text input:\n        var $clipboardInput = $('<input type=\"text\">')\n            .val($copyUrlButton.attr('href'))\n            .insertAfter($copyUrlButton);\n        $clipboardInput.get(0).select();\n\n        var tooltipMessage = '';\n\n        trackShareInteraction($copyUrlButton, 'Link copy');\n\n        try {\n            document.execCommand('copy');\n            // Show the tooltip with a success message\n            tooltipMessage = 'This link has been copied to your clipboard';\n            $copyUrlButton\n                .tooltip('dispose')\n                .tooltip({title: tooltipMessage})\n                .tooltip('show')\n                .on('shown.bs.tooltip', hideTooltipCallback);\n        } catch (error) {\n            if (Sentry !== 'undefined') {\n                Sentry.captureException(error);\n            }\n\n            // Display an error message in the tooltip\n            tooltipMessage =\n                '<p>Could not access your clipboard.</p><button class=\"btn btn-light btn-sm\" id=\"dismiss-tooltip-button\">Close</button>';\n            $copyUrlButton\n                .tooltip('dispose')\n                .tooltip({title: tooltipMessage, html: true})\n                .tooltip('show');\n            document\n                .querySelector('#dismiss-tooltip-button')\n                .addEventListener('click', function () {\n                    $copyUrlButton.tooltip('hide');\n                });\n        } finally {\n            $clipboardInput.remove();\n        }\n\n        return false;\n    });\n}\n\nconst fbShareButton = document.querySelector('.copy-url-button');\nif (fbShareButton) {\n    fbShareButton.addEventListener('click', function () {\n        trackShareInteraction($facebookShareButton, 'Facebook Share');\n        return true;\n    });\n}\n\nconst xShareButton = document.querySelector('.twitter-share-button');\nif (xShareButton) {\n    xShareButton.addEventListener('click', function () {\n        trackShareInteraction($twitterShareButton, 'Twitter Share');\n        return true;\n    });\n}\n\n// eslint-disable-next-line no-unused-vars\nexport function trackUIInteraction(element, category, action, label) {\n    if ('loc_ux_tracking' in window) {\n        let loc_ux_tracking = window['loc_ux_tracking'];\n        let data = [element, category, action, label];\n        loc_ux_tracking.trackUserInteractionEvent(...data);\n    }\n}\n"
  },
  {
    "path": "concordia/static/js/src/campaign-selection.js",
    "content": "import $ from 'jquery';\n\n$(function () {\n    const queryString = window.location.search;\n    const urlParameters = new URLSearchParams(queryString);\n\n    $('#tblTranscription tbody tr').each(function () {\n        var rowID = $(this).find('.campaign').attr('id');\n\n        if (rowID == urlParameters.get('campaign_slug')) {\n            $(this).find('.campaign').css('font-weight', 'bold');\n        } else {\n            $(this).find('.campaign').attr('font-weight', 'normal');\n        }\n    });\n\n    $('input[type=\"checkbox\"]').change(function () {\n        if (this.checked) {\n            $('.' + this.id).fadeIn('slow');\n        } else $('.' + this.id).fadeOut('slow');\n    });\n});\n"
  },
  {
    "path": "concordia/static/js/src/contribute.js",
    "content": "import 'bootstrap/dist/css/bootstrap.min.css';\nimport {Modal} from 'bootstrap';\nimport {selectLanguage} from './ocr.js';\nimport {reserveAssetForEditing} from './asset-reservation.js';\nimport $ from 'jquery';\nimport {buildErrorMessage, displayMessage} from './base.js';\n\nfunction lockControls($container) {\n    if (!$container) {\n        return;\n    }\n    // Locks all of the controls in the provided jQuery element\n    $container.find('input, textarea').attr('readonly', 'readonly');\n    $container.find('input:checkbox').attr('disabled', 'disabled');\n    $container.find('button:not(#open-guide)').attr('disabled', 'disabled');\n}\n\nfunction unlockControls($container) {\n    if (!$container) {\n        return;\n    }\n    // Unlocks all of the controls except buttons in the provided jQuery element\n    $container.find('input, textarea').removeAttr('readonly');\n    $container.find('input:checkbox').removeAttr('disabled');\n\n    // Though we lock all buttons in lockControls, we don't automatically\n    // unlock most of them. Which buttons should be locked or unlocked\n    // is more complicated logic handled by the update-ui-state\n    // listener on the transcription form and the form\n    // results handlers.\n    // The only buttons unlocked here are ones that should always be unlocked.\n    $container.find('button#open-guide').removeAttr('disabled');\n    $container.find('button#ocr-transcription-button').removeAttr('disabled');\n    $container.find('button#close-guide').removeAttr('disabled');\n    $container.find('button#new-tag-button').removeAttr('disabled');\n}\n\n$(document).on('keydown', function (event) {\n    /*\n        Global keyboard event handlers\n\n        * F1 and ? open help\n        * Control-I focuses on the image viewer\n        * Control-T focuses on the transcription text field\n\n        n.b. jQuery interferes with setting the focus so our handlers use the\n        DOM directly\n    */\n\n    if (\n        (event.which == 112 || event.which == 191) &&\n        !event.target.tagName.match(/(INPUT|TEXTAREA)/i) // eslint-disable-line  unicorn/prefer-regexp-test, unicorn/better-regex\n    ) {\n        // Either the F1 or ? keys were pressed outside of a text field so we'll show help:\n        Modal.getOrCreateInstance(\n            document.getElementById('keyboard-help-modal'),\n        ).show();\n        return false;\n    } else if (event.which == 73 && event.ctrlKey) {\n        // Control-I == switch to the image viewer\n        document.querySelector('#asset-image .openseadragon-canvas').focus();\n        return false;\n    } else if (event.which == 84 && event.ctrlKey) {\n        // Control-T == switch to the transcription field\n        document.getElementById('transcription-input').focus();\n        return false;\n    }\n});\n\nfunction resetTurnstile() {\n    if (window.turnstile) {\n        window.turnstile.reset('.cf-turnstile');\n    }\n}\n\nfunction setupPage() {\n    $('form.ajax-submission').each(function (index, formElement) {\n        /*\n        Generic AJAX submission logic which takes a form and POSTs its data to the\n        configured action URL, locking the controls until it gets a response either\n        way.\n\n        If the AJAX request is successful, the form-submit-success custom event will\n        be triggered. On failures, form-submit-failure will be triggered after\n        unlocking the controls.\n\n        Because there's no standard way to get the value of the submit button\n        clicked, and forms may be submitted without using a button at all, the\n        <form> element may have optional data-submit-name and data-submit-value\n        attributes for the default values and a click handler will be used to\n        update those values based on user interaction.\n\n        The optional data-lock-element attribute can be set to lock additional\n        elements in the same way the form is locked once its submitted.\n        */\n\n        var $form = $(formElement);\n\n        $form.on('submit', function (event) {\n            event.preventDefault();\n\n            var eventData = $form.data();\n\n            lockControls($form);\n            if (eventData.lockElement) {\n                lockControls($(eventData.lockElement));\n            }\n\n            var formData = $form.serializeArray();\n\n            $.ajax({\n                url: $form.attr('action'),\n                method: 'POST',\n                dataType: 'json',\n                data: $.param(formData),\n            })\n                .done(function (data, textStatus) {\n                    $form.trigger('form-submit-success', {\n                        textStatus: textStatus,\n                        requestData: formData,\n                        responseData: data,\n                        $form: $form,\n                    });\n                    unlockControls($form);\n                    if (eventData.lockElement) {\n                        unlockControls($(eventData.lockElement));\n                    }\n                })\n                .fail(function (jqXHR, textStatus, errorThrown) {\n                    $form.trigger('form-submit-failure', {\n                        textStatus: textStatus,\n                        errorThrown: errorThrown,\n                        requestData: formData,\n                        $form: $form,\n                        jqXHR: jqXHR,\n                    });\n                    unlockControls($form);\n                    if (eventData.lockElement) {\n                        unlockControls($(eventData.lockElement));\n                    }\n                });\n\n            return false;\n        });\n    });\n\n    var $transcriptionEditor = $('#transcription-editor');\n    var $saveButton = $transcriptionEditor\n        .find('#save-transcription-button')\n        .first();\n    var $submitButton = $transcriptionEditor\n        .find('#submit-transcription-button')\n        .first();\n    var $nothingToTranscribeCheckbox = $transcriptionEditor\n        .find('#nothing-to-transcribe')\n        .on('change', function () {\n            var $textarea = $transcriptionEditor.find('textarea');\n            if (this.checked) {\n                const nothingToTranscribeElement = document.getElementById(\n                    'nothing-to-transcribe-modal',\n                );\n                if (nothingToTranscribeElement) {\n                    const nothingToTranscribeModal =\n                        Modal.getInstance(nothingToTranscribeElement) ||\n                        new Modal(nothingToTranscribeElement);\n                    var nothingToTranscribeTitle =\n                        nothingToTranscribeElement.querySelector(\n                            '.modal-title',\n                        );\n                    var nothingToTranscribeBody =\n                        nothingToTranscribeElement.querySelector('.modal-body');\n                    if ($textarea.val()) {\n                        nothingToTranscribeTitle.textContent =\n                            'Text will be deleted';\n                        nothingToTranscribeBody.innerHTML =\n                            '<p>Text in the transcription box is removed when “Nothing to transcribe” is checked. Do you want to discard that text?</p>';\n                    } else {\n                        nothingToTranscribeTitle.textContent =\n                            'Nothing to transcribe';\n                        nothingToTranscribeBody.innerHTML =\n                            '<p>Are you sure?</p>';\n                    }\n                    nothingToTranscribeModal.show();\n\n                    const okButton = document.getElementById('confirmDiscard');\n                    okButton.addEventListener('click', function () {\n                        $textarea.val('');\n                        nothingToTranscribeModal.hide();\n                    });\n                    const cancelButton =\n                        document.getElementById('cancelDiscard');\n                    cancelButton.addEventListener('click', function () {\n                        $('#nothing-to-transcribe').prop('checked', false);\n                        nothingToTranscribeModal.hide();\n                    });\n                }\n            }\n            $transcriptionEditor.trigger('update-ui-state');\n        });\n    var $ocrSection = $('#ocr-section');\n    var $ocrForm = $('#ocr-transcription-form');\n    var $ocrModal = $('#ocr-transcription-modal');\n    var languageModalElement = document.getElementById(\n        'language-selection-modal',\n    );\n    var languageModal;\n    if (languageModalElement) {\n        languageModal = Modal.getOrCreateInstance(languageModalElement);\n    }\n    var $ocrLoading = $('#ocr-loading');\n    var rollbackButton = document.getElementById(\n        'rollback-transcription-button',\n    );\n    var rollforwardButton = document.getElementById(\n        'rollforward-transcription-button',\n    );\n    // We need to do this because BS5 does not automatically initialize modals when you\n    // try to show them; without new boostrap.Modal, it doesn't recognize it as a modal\n    // at all (it's treated as ordinary HTML), so BS controls do not work\n    // We try to get Modal.getInstance in case the modal is already initialized\n    var errorModalElement = document.getElementById('error-modal');\n    if (errorModalElement) {\n        var errorModal =\n            Modal.getInstance(errorModalElement) ||\n            new Modal(errorModalElement);\n    }\n    var submissionModalElement = document.getElementById(\n        'successful-submission-modal',\n    );\n    if (submissionModalElement) {\n        var submissionModal =\n            Modal.getInstance(submissionModalElement) ||\n            new Modal(submissionModalElement);\n    }\n    var reviewModalElement = document.getElementById('review-accepted-modal');\n    if (reviewModalElement) {\n        var reviewModal =\n            Modal.getInstance(reviewModalElement) ||\n            new Modal(reviewModalElement);\n    }\n\n    let firstEditorUpdate = true;\n    let editorPlaceholderText = $transcriptionEditor\n        .find('textarea')\n        .attr('placeholder');\n    let editorNothingToTranscribePlaceholderText = 'Nothing to transcribe';\n\n    $transcriptionEditor\n        .on('update-ui-state', function () {\n            /*\n             * All controls are locked when the user does not have the write lock\n             *\n             * The Save button is enabled when the user has changed the text from\n             * what it was when the page was loaded or last saved\n             *\n             * The Submit button is enabled when the user has either made no changes\n             * or has saved the transcription and not changed the text\n             */\n\n            var data = $transcriptionEditor.data();\n\n            if (\n                !data.hasReservation ||\n                (data.transcriptionStatus != 'in_progress' &&\n                    data.transcriptionStatus != 'not_started' &&\n                    data.transcriptionStatus != 'submitted')\n            ) {\n                // If the status is completed OR if the user doesn't have the reservation\n                lockControls($transcriptionEditor);\n                lockControls($ocrSection);\n                lockControls($ocrForm);\n            } else {\n                // Either in transcribe or review mode OR the user has the reservation\n                if (data.hasReservation) {\n                    unlockControls($ocrSection);\n                    unlockControls($ocrForm);\n                }\n                var $textarea = $transcriptionEditor.find('textarea');\n\n                if (\n                    $nothingToTranscribeCheckbox.prop('checked') ||\n                    data.transcriptionStatus == 'submitted'\n                ) {\n                    $textarea.attr('readonly', 'readonly');\n                    if ($nothingToTranscribeCheckbox.prop('checked')) {\n                        $textarea.attr(\n                            'placeholder',\n                            editorNothingToTranscribePlaceholderText,\n                        );\n                    }\n                } else {\n                    $textarea.removeAttr('readonly');\n                    $textarea.attr('placeholder', editorPlaceholderText);\n                }\n\n                if (data.transcriptionId && !data.unsavedChanges) {\n                    // We have a transcription ID and it's not stale,\n                    // so we can submit the transcription for review and disable the save button:\n                    $saveButton.attr('disabled', 'disabled');\n                    $submitButton.removeAttr('disabled');\n                    // We only want to do this the first time the editor ui is updated (i.e., on first load)\n                    // because otherwise it's impossible to uncheck the 'Nothing to transcribe' checkbox\n                    // since this code would just immediately mark it checked again.\n                    if (!$textarea.val() && firstEditorUpdate) {\n                        $nothingToTranscribeCheckbox.prop('checked', true);\n                        $textarea.attr('readonly', 'readonly');\n                        $textarea.attr(\n                            'placeholder',\n                            editorNothingToTranscribePlaceholderText,\n                        );\n                    }\n                } else {\n                    // Unsaved changes are in the textarea and we're in transcribe mode\n                    $submitButton.attr('disabled', 'disabled');\n\n                    if (\n                        $textarea.val() ||\n                        $nothingToTranscribeCheckbox.prop('checked')\n                    ) {\n                        $saveButton.removeAttr('disabled');\n                    } else {\n                        $saveButton.attr('disabled', 'disabled');\n                    }\n                }\n            }\n\n            if (\n                !data.hasReservation &&\n                (data.transcriptionStatus == 'in_progress' ||\n                    data.transcriptionStatus == 'not_started')\n            ) {\n                // If we're in transcribe mode and we don't have the reservation\n                $('.transcription-status-display')\n                    .children()\n                    .attr('hidden', 'hidden')\n                    .filter('#display-conflict')\n                    .removeAttr('hidden');\n            }\n            firstEditorUpdate = false;\n        })\n        .on('form-submit-success', function (event, extra) {\n            let responseData = extra.responseData;\n            displayMessage(\n                'info',\n                \"Successfully saved your work. Submit it for review when you're done\",\n                'transcription-save-result',\n            );\n            $transcriptionEditor.data({\n                transcriptionId: responseData.id,\n                unsavedChanges: false,\n            });\n            $transcriptionEditor\n                .find('input[name=\"supersedes\"]')\n                .val(responseData.id);\n            $transcriptionEditor\n                .find('textarea[name=\"text\"]')\n                .val(responseData.text);\n            $transcriptionEditor.data('submitUrl', responseData.submissionUrl);\n            $ocrForm.find('input[name=\"supersedes\"]').val(responseData.id);\n            $('#transcription-status-display')\n                .children()\n                .attr('hidden', 'hidden')\n                .filter('#display-inprogress')\n                .removeAttr('hidden');\n            if (responseData.undo_available) {\n                $('#rollback-transcription-button').removeAttr('disabled');\n            }\n            if (responseData.redo_available) {\n                $('#rollforward-transcription-button').removeAttr('disabled');\n            }\n            resetTurnstile();\n            let messageChildren = $('#transcription-status-message').children();\n            messageChildren\n                .attr('hidden', 'hidden')\n                .filter('#message-inprogress')\n                .removeAttr('hidden');\n            $('#transcription-status-display').removeAttr('hidden');\n            $('#message-contributors')\n                .removeAttr('hidden')\n                .find('#message-contributors-num')\n                .html(responseData.asset.contributors);\n            $transcriptionEditor.trigger('update-ui-state');\n        })\n        .on('form-submit-failure', function (event, info) {\n            displayMessage(\n                'error',\n                'Unable to save your work: ' +\n                    buildErrorMessage(\n                        info.jqXHR,\n                        info.textStatus,\n                        info.errorThrown,\n                    ),\n                'transcription-save-result',\n            );\n            resetTurnstile();\n            $transcriptionEditor.trigger('update-ui-state');\n        });\n\n    $submitButton.on('click', function (event) {\n        event.preventDefault();\n\n        $.ajax({\n            url: $transcriptionEditor.data('submitUrl'),\n            method: 'POST',\n            dataType: 'json',\n        })\n            .done(function (data) {\n                $('#transcription-status-display')\n                    .children()\n                    .attr('hidden', 'hidden');\n                let messageChildren = $(\n                    '#transcription-status-display',\n                ).children();\n                messageChildren\n                    .attr('hidden', 'hidden')\n                    .filter('#message-submitted')\n                    .removeAttr('hidden');\n                $('#display-submitted').removeAttr('hidden');\n                messageChildren\n                    .filter('#message-contributors')\n                    .removeAttr('hidden')\n                    .find('#message-contributors-num')\n                    .html(data.asset.contributors);\n                submissionModal.show();\n                submissionModalElement.addEventListener(\n                    'hidden.bs.modal',\n                    function () {\n                        window.location.reload(true);\n                    },\n                );\n            })\n            .fail(function (jqXHR, textStatus, errorThrown) {\n                displayMessage(\n                    'error',\n                    'Unable to save your work: ' +\n                        buildErrorMessage(jqXHR, textStatus, errorThrown),\n                    'transcription-submit-result',\n                );\n            });\n    });\n\n    $transcriptionEditor\n        .find('textarea')\n        .each(function (index, textarea) {\n            textarea.value = $.trim(textarea.value);\n        })\n        .on('change input', function () {\n            $transcriptionEditor.data('unsavedChanges', true);\n            $transcriptionEditor.trigger('update-ui-state');\n        });\n\n    function submitReview(status) {\n        var reviewUrl = $transcriptionEditor.data('reviewUrl');\n        $.ajax({\n            url: reviewUrl,\n            method: 'POST',\n            dataType: 'json',\n            data: {\n                action: status,\n            },\n        })\n            .done(function (data) {\n                if (status == 'reject') {\n                    $.ajax({\n                        url: window.location,\n                        method: 'GET',\n                        dataType: 'html',\n                    })\n                        .done(function (data) {\n                            $('#editor-column').html(\n                                $(data).find('#editor-column').html(),\n                            );\n                            $('#ocr-section').html(\n                                $(data).find('#ocr-section').html(),\n                            );\n                            $('#help-container').html(\n                                $(data).find('#help-container').html(),\n                            );\n                            $ocrModal.html(\n                                $(data).find('#ocr-transcription-modal').html(),\n                            );\n                            $('#select-language-button').on(\n                                'click',\n                                selectLanguage,\n                            );\n                            reserveAssetForEditing();\n                            setupPage();\n                        })\n                        .fail(function (jqXHR, textStatus, errorThrown) {\n                            displayMessage(\n                                'error',\n                                'Unable to save your review: ' +\n                                    buildErrorMessage(\n                                        jqXHR,\n                                        textStatus,\n                                        errorThrown,\n                                    ),\n                                'transcription-review-result',\n                            );\n                        });\n                } else {\n                    $('#transcription-status-display')\n                        .children()\n                        .attr('hidden', 'hidden');\n                    $('#display-completed').removeAttr('hidden');\n                    let messageChildren = $(\n                        '#transcription-status-message',\n                    ).children();\n                    messageChildren\n                        .attr('hidden', 'hidden')\n                        .filter('#message-completed')\n                        .removeAttr('hidden');\n                    $('#transcription-status-display').removeAttr('hidden');\n                    messageChildren\n                        .filter('#message-contributors')\n                        .removeAttr('hidden')\n                        .find('#message-contributors-num')\n                        .html(data.asset.contributors);\n                    reviewModal.show();\n                    reviewModalElement.addEventListener(\n                        'hidden.bs.modal',\n                        function () {\n                            window.location.reload(true);\n                        },\n                    );\n                }\n            })\n            .fail(function (jqXHR, textStatus, errorThrown) {\n                displayMessage(\n                    'error',\n                    'Unable to save your review: ' +\n                        buildErrorMessage(jqXHR, textStatus, errorThrown),\n                    'transcription-review-result',\n                );\n                if (jqXHR.responseJSON && jqXHR.responseJSON.popupError) {\n                    let popupErrorMessage = jqXHR.responseJSON.popupError;\n                    let popupTitle;\n                    if (jqXHR.responseJSON.popupTitle) {\n                        popupTitle = jqXHR.responseJSON.popupTitle;\n                    } else {\n                        popupTitle = 'An error occurred with your review';\n                    }\n                    $('#error-modal')\n                        .find('#error-modal-title')\n                        .first()\n                        .html(popupTitle);\n                    $('#error-modal')\n                        .find('#error-modal-message')\n                        .first()\n                        .html(popupErrorMessage);\n                    errorModal.show();\n                }\n            });\n    }\n\n    $('#accept-transcription-button')\n        .removeAttr('disabled')\n        .on('click', function (event) {\n            event.preventDefault();\n            submitReview('accept');\n        });\n\n    $('#reject-transcription-button')\n        .removeAttr('disabled')\n        .on('click', function (event) {\n            event.preventDefault();\n            submitReview('reject');\n        });\n\n    function rollTranscription(url) {\n        lockControls($transcriptionEditor);\n        $.ajax({\n            url: url,\n            method: 'POST',\n            dataType: 'json',\n            data: {\n                'cf-turnstile-response': $transcriptionEditor\n                    .find('input[name=\"cf-turnstile-response\"]')\n                    .val(),\n            },\n        })\n            .done(function (responseData) {\n                displayMessage(\n                    'info',\n                    responseData.message,\n                    'transcription-save-result',\n                );\n                $transcriptionEditor.data({\n                    transcriptionId: responseData.id,\n                    unsavedChanges: false,\n                });\n                $transcriptionEditor\n                    .find('input[name=\"supersedes\"]')\n                    .val(responseData.id);\n                $transcriptionEditor.data(\n                    'submitUrl',\n                    responseData.submissionUrl,\n                );\n                $ocrForm.find('input[name=\"supersedes\"]').val(responseData.id);\n                $transcriptionEditor\n                    .find('textarea[name=\"text\"]')\n                    .val(responseData.text);\n                $('#transcription-status-display')\n                    .children()\n                    .attr('hidden', 'hidden')\n                    .filter('#display-inprogress')\n                    .removeAttr('hidden');\n                if (responseData.undo_available) {\n                    $('#rollback-transcription-button').removeAttr('disabled');\n                }\n                if (responseData.redo_available) {\n                    $('#rollforward-transcription-button').removeAttr(\n                        'disabled',\n                    );\n                }\n                let messageChildren = $(\n                    '#transcription-status-display',\n                ).children();\n                messageChildren\n                    .attr('hidden', 'hidden')\n                    .filter('#display-inprogress')\n                    .removeAttr('hidden');\n                messageChildren\n                    .filter('#message-contributors')\n                    .removeAttr('hidden')\n                    .find('#message-contributors-num')\n                    .html(responseData.asset.contributors);\n                unlockControls($transcriptionEditor);\n                $transcriptionEditor.trigger('update-ui-state');\n            })\n            .fail(function (jqXHR, textStatus, errorThrown) {\n                displayMessage(\n                    'error',\n                    'Unable to save your work: ' +\n                        buildErrorMessage(jqXHR, textStatus, errorThrown),\n                    'transcription-save-result',\n                );\n                unlockControls($transcriptionEditor);\n                $transcriptionEditor.trigger('update-ui-state');\n            });\n    }\n\n    if (rollbackButton) {\n        rollbackButton.addEventListener('click', function () {\n            rollTranscription(this.dataset.url);\n        });\n    }\n\n    if (rollforwardButton) {\n        rollforwardButton.addEventListener('click', function () {\n            rollTranscription(this.dataset.url);\n        });\n    }\n\n    var $tagEditor = $('#tag-editor'),\n        $tagForm = $('#tag-form'),\n        $currentTagList = $tagEditor.find('#current-tags'),\n        $newTagInput = $('#new-tag-input');\n\n    const characterError =\n        'Tags must be between 1-50 characters and may contain only letters, numbers, dashes, underscores, apostrophes, and spaces';\n    const duplicateError =\n        'That tag has already been added. Each tag can only be added once.';\n\n    function addNewTag() {\n        $newTagInput.get(0).setCustomValidity(''); // Resets custom validation\n        const $form = $newTagInput.closest('form');\n        $form.removeClass('was-validated');\n        $newTagInput.val(\n            $newTagInput.val().replace('‘', \"'\").replace('’', \"'\"),\n        );\n        if (!$newTagInput.get(0).checkValidity()) {\n            $form.find('.invalid-feedback').html(characterError);\n            $form.addClass('was-validated');\n            return;\n        }\n\n        var value = $.trim($newTagInput.val());\n        if (value) {\n            // Prevent adding tags which are already present:\n            var dupeCount = $currentTagList\n                .find('input[name=\"tags\"]')\n                .filter(function (index, input) {\n                    return (\n                        input.value.toLocaleLowerCase() ==\n                        value.toLocaleLowerCase()\n                    );\n                }).length;\n\n            if (dupeCount == 0) {\n                var $newTag = $(\n                    '\\\n                            <li class=\"btn btn-outline-dark btn-sm\"> \\\n                                <label class=\"m-0\"> \\\n                                    <input type=\"hidden\" name=\"tags\" value=\"' +\n                        value +\n                        '\" /> \\\n                                </label> \\\n                                <input type=\"hidden\" name=\"tags\" value=\"' +\n                        value +\n                        '\" /> \\\n                                <a class=\"close\" data-bs-dismiss=\"alert\" aria-label=\"Remove previous tag\"> \\\n                                    <span aria-hidden=\"true\" class=\"fas fa-times\"></span> \\\n                                </a> \\\n                            </li> \\\n                ',\n                );\n                $newTag.find('label').append(document.createTextNode(value));\n                $currentTagList.append($newTag);\n                $newTagInput.val('');\n                $tagForm.submit();\n            } else {\n                $newTagInput.get(0).setCustomValidity(duplicateError);\n                $form.find('.invalid-feedback').html(duplicateError);\n                $newTagInput.closest('form').addClass('was-validated');\n                return;\n            }\n        }\n    }\n\n    $tagEditor.find('#new-tag-button').on('click', addNewTag);\n    $newTagInput.on('change', addNewTag);\n    $newTagInput.on('keydown', function (event) {\n        // See https://github.com/LibraryOfCongress/concordia/issues/159 for the source of these values:\n        if (event.which == '13' || event.which == '188') {\n            // Either the enter or comma keys will add the tag and reset the input field:\n            event.preventDefault();\n            addNewTag();\n        }\n    });\n\n    $currentTagList.on('click', '.close', function () {\n        $(this).parents('li').remove();\n        $tagForm.submit();\n    });\n\n    $tagEditor\n        .on('form-submit-success', function (event, info) {\n            $('#tag-count').html(info.responseData['all_tags'].length);\n            unlockControls($tagEditor);\n            displayMessage(\n                'info',\n                'Your tags have been saved',\n                'tags-save-result',\n            );\n        })\n        .on('form-submit-failure', function (event, info) {\n            unlockControls($tagEditor);\n\n            var message = 'Unable to save your tags: ';\n            message += buildErrorMessage(\n                info.jqXHR,\n                info.textStatus,\n                info.errorThrown,\n            );\n\n            displayMessage('error', message, 'tags-save-result');\n        });\n\n    if ($ocrForm) {\n        $ocrForm\n            .on('submit', function () {\n                languageModal.hide();\n                $ocrLoading.removeAttr('hidden');\n            })\n            .on('form-submit-success', function (event, extra) {\n                let responseData = extra.responseData;\n                $transcriptionEditor.data({\n                    transcriptionId: responseData.id,\n                    unsavedChanges: false,\n                });\n                $transcriptionEditor\n                    .find('input[name=\"supersedes\"]')\n                    .val(responseData.id);\n                $transcriptionEditor.data(\n                    'submitUrl',\n                    responseData.submissionUrl,\n                );\n                $transcriptionEditor\n                    .find('textarea[name=\"text\"]')\n                    .val(responseData.text);\n                $ocrLoading.attr('hidden', 'hidden');\n                $('#transcription-status-display')\n                    .children()\n                    .attr('hidden', 'hidden');\n                $('#display-inprogress').removeAttr('hidden');\n                let messageChildren = $(\n                    '#transcription-status-message',\n                ).children();\n                if (responseData.undo_available) {\n                    $('#rollback-transcription-button').removeAttr('disabled');\n                }\n                if (responseData.redo_available) {\n                    $('#rollforward-transcription-button').removeAttr(\n                        'disabled',\n                    );\n                }\n                messageChildren\n                    .attr('hidden', 'hidden')\n                    .filter('#message-inprogress')\n                    .removeAttr('hidden');\n                messageChildren\n                    .filter('#message-contributors')\n                    .removeAttr('hidden')\n                    .find('#message-contributors-num')\n                    .html(responseData.asset.contributors);\n                $('#transcription-status-display').removeAttr('hidden');\n                $transcriptionEditor.trigger('update-ui-state');\n                $ocrForm.find('input[name=\"supersedes\"]').val(responseData.id);\n            })\n            .on('form-submit-failure', function (event, info) {\n                let errorMessage;\n                if (info.jqXHR.status == 429) {\n                    errorMessage =\n                        'OCR is only available once per minute. Please try again later and review all OCR text closely before submitting.';\n                } else {\n                    errorMessage = buildErrorMessage(\n                        info.jqXHR,\n                        info.textStatus,\n                        info.errorThrown,\n                    );\n                }\n                displayMessage(\n                    'error',\n                    'Unable to save your work: ' + errorMessage,\n                    'transcription-save-result',\n                );\n                $ocrLoading.attr('hidden', 'hidden');\n                $transcriptionEditor.trigger('update-ui-state');\n            });\n    }\n}\n\nlet transcriptionForm = document.getElementById('transcription-editor');\nlet ocrForm = document.getElementById('ocr-transcription-form');\n\nlet formChanged = false;\nif (transcriptionForm) {\n    transcriptionForm.addEventListener('change', function () {\n        formChanged = true;\n    });\n    transcriptionForm.addEventListener('submit', function () {\n        formChanged = false;\n    });\n}\nif (ocrForm) {\n    ocrForm.addEventListener('submit', function () {\n        formChanged = false;\n    });\n}\nwindow.addEventListener('beforeunload', function (event) {\n    if (formChanged) {\n        // Some browsers ignore this value and always display a built-in message instead\n        return (event.returnValue =\n            \"The transcription you've started has not been saved.\");\n    }\n});\n$('#asset-reservation-failure-modal').click(function () {\n    document.getElementById('transcription-input').placeholder =\n        \"Someone else is already transcribing this page.\\n\\nYou can help by transcribing a new page, adding tags to this page, or coming back later to review this page's transcription.\";\n});\n\nsetupPage();\n"
  },
  {
    "path": "concordia/static/js/src/filter-assets.js",
    "content": "function filterAssets(doFilter, url) {\n    const button = doFilter\n        ? document.getElementById('show-all')\n        : document.getElementById('filter-assets');\n\n    button.checked = false;\n    window.location = url;\n}\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    document.addEventListener('change', function (event) {\n        if (event.target.name === 'radioButtons') {\n            filterAssets(\n                event.target.dataset.filter === 'true',\n                event.target.dataset.url,\n            );\n        }\n    });\n});\n\nwindow.filterAssets = filterAssets;\n"
  },
  {
    "path": "concordia/static/js/src/guide.js",
    "content": "/* global */\n\nimport $ from 'jquery';\nimport {Carousel} from 'bootstrap';\nimport {trackUIInteraction} from './base.js';\n\nfunction openOffcanvas() {\n    let guide = document.getElementById('guide-sidebar');\n    if (guide.classList.contains('offscreen')) {\n        guide.classList.remove('offscreen');\n        guide.style.borderWidth = '0 0 thick thick';\n        guide.style.borderStyle = 'solid';\n        guide.style.borderColor = '#0076ad';\n        document.addEventListener('keydown', function (event) {\n            if (event.key == 'Escape') {\n                closeOffcanvas();\n            }\n        });\n        document.getElementById('open-guide').style.background = '#002347';\n    } else {\n        closeOffcanvas();\n    }\n}\n\nfunction closeOffcanvas() {\n    let guide = document.getElementById('guide-sidebar');\n    guide.classList.add('offscreen');\n    guide.style.border = 'none';\n\n    let openGuide = document.getElementById('open-guide');\n    if (openGuide) {\n        openGuide.style.background = '#0076AD';\n    }\n}\n\ndocument.getElementById('open-guide')?.addEventListener('click', openOffcanvas);\n\ndocument\n    .getElementById('close-guide')\n    ?.addEventListener('click', closeOffcanvas);\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    const guideCarouselElement = document.getElementById('guide-carousel');\n    if (guideCarouselElement) {\n        new Carousel(guideCarouselElement, {\n            interval: false,\n            wrap: false,\n        });\n\n        guideCarouselElement.addEventListener('slide.bs.carousel', (event) => {\n            const barsCol = document.getElementById('guide-bars-col');\n            if (!barsCol) return;\n\n            if (event.to === 0) {\n                barsCol.classList.add('d-none');\n            } else {\n                barsCol.classList.remove('d-none');\n            }\n        });\n    }\n});\n\n$('#previous-card').hide();\n\n$('#card-carousel').on('slid.bs.carousel', function () {\n    if ($('#card-carousel .carousel-item:first').hasClass('active')) {\n        $('#previous-card').hide();\n        $('#next-card').show();\n    } else if ($('#card-carousel .carousel-item:last').hasClass('active')) {\n        $('#previous-card').show();\n        $('#next-card').hide();\n    } else {\n        $('#previous-card').show();\n        $('#next-card').show();\n    }\n});\n\nfunction trackHowToInteraction(element, label) {\n    trackUIInteraction(element, 'How To Guide', 'click', label);\n}\n\nif ($('#open-guide').length > 0) {\n    $('#open-guide').on('click', function () {\n        trackHowToInteraction($(this), 'Open');\n    });\n}\nif ($('#close-guide').length > 0) {\n    $('#close-guide').on('click', function () {\n        trackHowToInteraction($(this), 'Close');\n    });\n}\nif ($('#previous-guide').length > 0) {\n    $('#previous-guide').on('click', function () {\n        trackHowToInteraction($(this), 'Back');\n    });\n}\nif ($('#next-guide').length > 0) {\n    $('#next-guide').on('click', function () {\n        trackHowToInteraction($(this), 'Next');\n    });\n}\nif ($('#guide-bars').length > 0) {\n    $('#guide-bars').on('click', function () {\n        trackHowToInteraction($(this), 'Hamburger Menu');\n    });\n}\n$('#guide-sidebar .nav-link').on('click', function () {\n    let label = $(this).text().trim();\n    trackHowToInteraction($(this), label);\n});\n\nexport {openOffcanvas, closeOffcanvas};\n"
  },
  {
    "path": "concordia/static/js/src/homepage-carousel.js",
    "content": "import $ from 'jquery';\nimport {Carousel} from 'bootstrap';\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    const carouselElement = document.getElementById('homepage-carousel');\n    if (!carouselElement) return; // exit if not on homepage\n\n    // avoid double init\n    const carousel = Carousel.getOrCreateInstance(carouselElement, {\n        interval: 5000,\n        pause: false,\n        ride: 'carousel',\n    });\n\n    const playPauseButton = document.getElementById('play-pause-button');\n    if (!playPauseButton) return;\n\n    playPauseButton.addEventListener('click', function () {\n        if ($(this).hasClass('paused')) {\n            carousel.cycle();\n        } else {\n            carousel.pause();\n        }\n        $(this).children('.fa').toggleClass('fa-pause').toggleClass('fa-play');\n        $(this).toggleClass('paused');\n    });\n\n    carouselElement.addEventListener('mouseover', () => {\n        carousel.pause();\n    });\n\n    carouselElement.addEventListener('mouseleave', () => {\n        if (!playPauseButton.classList.contains('paused')) {\n            carousel.cycle();\n        }\n    });\n});\n"
  },
  {
    "path": "concordia/static/js/src/modules/accessible-colors.js",
    "content": "import chroma from 'chroma-js';\n\n/**\n * Adjust a color’s lightness so it meets at least `minContrast` vs. `background`.\n * @param {string} colorString - Input color (any CSS‐parsable string, e.g. '#f66' or 'rgb(255,0,0)')\n * @param {string} [background='#fff'] - Background color to contrast against\n * @param {number} [minContrast=4.5] - Minimum WCAG contrast ratio\n * @returns {string} A colorString string of the adjusted color\n */\nexport function adjustColorForContrast(\n    colorString,\n    background = '#fff',\n    minContrast = 4.5,\n) {\n    let color = chroma(colorString);\n    const backgroundLum = chroma(background).luminance();\n    // if background is light, we darken; if background is dark, we brighten\n    let step = 0.05;\n    if (backgroundLum > 0.5) {\n        step *= -1;\n    }\n\n    // We adjust the color's lightness by `step` until it reaches a constract of minConstrast\n    // We limit it to 20 iterations to avoid an infinite loop. 20 because at 20\n    // iterations, we've definitely traversed the entire possible range\n    // (from 0 to 1 or from 1 to 0)\n    for (\n        let index = 0;\n        index < 20 && chroma.contrast(color, background) < minContrast;\n        index++\n    ) {\n        color = color.set('hsl.l', color.get('hsl.l') + step);\n    }\n    return color.hex();\n}\n\n/**\n * Generate a `count`-color palette that all meet `minContrast` vs. `background`.\n * Uses an LCh‐spaced base palette from chroma.js, then adjusts each hue.\n * @param {number} count - Number of colors to generate\n * @param {string} [background='#fff'] - Background color to contrast against\n * @param {number} [minContrast=4.5] - Minimum WCAG contrast ratio\n * @param {string} [scaleName='Spectral']\n *   - Any valid chroma.js scale name (e.g. 'Spectral', 'Rainbow', etc.)\n * @returns {string[]} Array of colorString color strings\n */\nexport function generateAccessibleColors(\n    count,\n    background = '#fff',\n    minContrast = 4.5,\n    scaleName = 'Spectral',\n) {\n    // build a base LCh (Lightness-Color-hue) palette\n    const raw = chroma.scale(scaleName).mode('lch').colors(count);\n\n    // adjust each color for contrast\n    return raw.map((colorString) =>\n        adjustColorForContrast(colorString, background, minContrast),\n    );\n}\n"
  },
  {
    "path": "concordia/static/js/src/modules/chroma-esm.js",
    "content": "// This is a shim to allow chroma-js to be used as an ES modules\n// TODO Consider removing the shim and vite config alias and input\n//      concordia-visualizations directly\nimport 'chroma-js'; // Vite resolves this to node_modules/chroma-js - loads the UMD build onto window.chroma\nexport default window.chroma; // re-export as the module’s default\n"
  },
  {
    "path": "concordia/static/js/src/modules/concordia-visualization.js",
    "content": "import Chart from 'chart.js/auto';\nimport {renderEmptyChart, renderErrorOverlay} from './visualization-errors.js';\nimport {generateAccessibleColors} from './accessible-colors.js';\n\nconst defaultAspectRatios = {\n    pie: '1 / 1',\n    doughnut: '1 / 1',\n    radar: '1 / 1',\n    bar: '2 / 1',\n    line: '2 / 1',\n};\n\nexport class ConcordiaVisualization {\n    /**\n     * @param {Object} config\n     * @param {string} config.name\n     *   The slug used to fetch `/api/visualization/<name>/`.\n     * @param {string} config.canvasId\n     *   The ID of the <canvas> element where the chart will be drawn.\n     * @param {string} [config.chartType=\"bar\"]\n     *   The Chart.js chart type (e.g. \"bar\", \"line\", \"pie\", etc.).\n     * @param {string} config.title\n     *   The title to show on top of the chart (used both for real data and error case).\n     * @param {string} [config.xLabel]\n     *   The x-axis title (optional-if omitted, no x-axis label is shown).\n     * @param {string} [config.yLabel]\n     *   The y-axis title (optional-if omitted, no y-axis label is shown).\n     * @param {Function} config.buildDataset\n     *   A callback `(payload) => { data, [options] }` which receives the raw JSON payload\n     *   and must return an object containing:\n     *     - `data`: a valid Chart.js `data` object (`{ labels: [...], datasets: [...] }`), and\n     *     - (optionally) `options`: partial Chart.js `options` you want to merge on top of the default.\n     * @param {Object} [config.chartOptions]\n     *   Any additional Chart.js options to merge into the final `options` object\n     *   (will be deep-merged after `buildDataset(...).options`).\n     * @param {string} [config.pageBackgroundColor=\"#fff\"]\n     *   The color of the page's background. Used to create contrasting colors\n     * @param {number} [config.minContrast] - Minimum contrast between colors on the chart\n     * @param {string} [config.aspectRatio]\n     *   CSS aspect ratio. Default is based on chartType, as defined in defaultAspectRatios\n     */\n    constructor({\n        name,\n        canvasId,\n        chartType = 'bar',\n        title,\n        xLabel = '',\n        yLabel = '',\n        buildDataset,\n        chartOptions = {},\n        pageBackgroundColor = '#fff',\n        minContrast = 4.5,\n        aspectRatio,\n    }) {\n        if (\n            !name ||\n            !canvasId ||\n            !title ||\n            typeof buildDataset !== 'function'\n        ) {\n            throw new Error(\n                'ConcordiaVisualization requires: name, canvasId, title, and buildDataset()',\n            );\n        }\n\n        this.name = name;\n        this.canvasId = canvasId;\n        this.chartType = chartType;\n        this.title = title;\n        this.xLabel = xLabel;\n        this.yLabel = yLabel;\n        this.buildDataset = buildDataset;\n        this.chartOptions = chartOptions;\n        this.pageBackgroundColor = pageBackgroundColor;\n        this.minContrast = minContrast;\n\n        if (aspectRatio) {\n            this._cssAspectRatio = aspectRatio;\n        } else {\n            // Use the default if none provided, or failback to 2-to-1\n            this._cssAspectRatio =\n                defaultAspectRatios[this.chartType] ?? '2 / 1';\n        }\n    }\n\n    /**\n     * Fetches `/api/visualization/<name>/`, handles errors, and renders the chart.\n     * Call this once the DOM is ready.\n     */\n    async render() {\n        const canvas = document.getElementById(this.canvasId);\n        if (!canvas) {\n            console.error(\n                `ConcordiaVisualization: Canvas ID '${this.canvasId}' not found.`,\n            );\n            return;\n        }\n\n        // Set accessibility attributes\n        canvas.tabIndex = 0;\n        canvas.setAttribute('role', 'img');\n        canvas.setAttribute('aria-label', this.title);\n\n        // Set aspectRatio on wrapper and make sure canvas fills it\n        const wrapper = canvas.parentNode;\n        wrapper.style.aspectRatio = this._cssAspectRatio;\n        canvas.style.width = '100%';\n        canvas.style.height = '100%';\n\n        const context = canvas.getContext('2d');\n\n        let resp;\n        try {\n            resp = await fetch(`/api/visualization/${this.name}/`);\n        } catch (error) {\n            console.error(\n                `ConcordiaVisualization: Network error fetching '${this.name}':`,\n                error,\n            );\n            this._handleError(context, 'No data available');\n            return;\n        }\n\n        if (!resp.ok) {\n            console.error(\n                `ConcordiaVisualization: HTTP ${resp.status} for '${this.name}'.`,\n            );\n            this._handleError(context, 'No data available');\n            return;\n        }\n\n        // If a chart already exists on this canvas, destroy it\n        Chart.getChart(canvas)?.destroy();\n\n        let payload;\n        try {\n            payload = await resp.json();\n        } catch (error) {\n            console.error(\n                `ConcordiaVisualization: Failed to parse JSON for '${this.name}':`,\n                error,\n            );\n            this._handleError(context, 'No data available');\n            return;\n        }\n\n        let data,\n            userOptions = {};\n        try {\n            // Let user-supplied buildDataset transform payload into { data, [options] }\n            const result = this.buildDataset(payload);\n            data = result.data;\n            userOptions = result.options || {};\n        } catch (error) {\n            console.error(\n                `ConcordiaVisualization: buildDataset threw for '${this.name}':`,\n                error,\n            );\n            this._handleError(context, 'No data available');\n            return;\n        }\n\n        if (!data || typeof data !== 'object') {\n            console.error(\n                `ConcordiaVisualization: buildDataset must return an object with a 'data' property for '${this.name}'.`,\n            );\n            this._handleError(context, 'No data available');\n            return;\n        }\n\n        // Auto-generate accessible colors only if none provided\n        const originalDatasets = data.datasets || [];\n        if (originalDatasets.length > 0) {\n            const hasExplicit = originalDatasets.some(\n                (ds) =>\n                    ds.backgroundColor !== undefined ||\n                    ds.borderColor !== undefined,\n            );\n            if (!hasExplicit) {\n                if (originalDatasets.length > 1) {\n                    const colors = generateAccessibleColors(\n                        originalDatasets.length,\n                        this.pageBackgroundColor,\n                        this.minContrast,\n                    );\n                    data.datasets = originalDatasets.map((ds, index) => ({\n                        ...ds,\n                        backgroundColor: colors[index],\n                        borderColor: colors[index],\n                        borderWidth: ds.borderWidth ?? 1,\n                    }));\n                } else {\n                    const count = data.labels?.length || 0;\n                    const colors = generateAccessibleColors(\n                        count,\n                        this.pageBackgroundColor,\n                        this.minContrast,\n                    );\n                    data.datasets = [\n                        {\n                            ...originalDatasets[0],\n                            backgroundColor: colors,\n                            borderColor: colors,\n                            borderWidth: originalDatasets[0].borderWidth ?? 1,\n                        },\n                    ];\n                }\n            }\n        }\n\n        // Merge options: default -> userOptions -> this.chartOptions\n        const finalOptions = ConcordiaVisualization._deepMerge(\n            {},\n            ConcordiaVisualization._defaultOptions(\n                this.title,\n                this.xLabel,\n                this.yLabel,\n            ),\n            userOptions,\n            this.chartOptions,\n        );\n\n        // Create the Chart.js chart\n        let chart = new Chart(context, {\n            type: this.chartType,\n            data: data,\n            options: finalOptions,\n        });\n\n        // If CSV URL exists in payload, create a link below the canvas\n        if (payload.csv_url) {\n            // wrapper is the <section>, container is the <div>\n            // Insert link after the wrapper, but within the outer container\n            const container = wrapper.parentNode;\n            const link = document.createElement('a');\n            link.href = payload.csv_url;\n            link.textContent = 'Download data as CSV';\n            link.classList.add('visualization-data-link');\n            link.setAttribute('target', '_blank');\n            link.setAttribute('rel', 'noopener noreferrer');\n            container.append(link);\n        }\n\n        // Create a hidden live region for announcing the current slice/bar\n        const live = document.createElement('div');\n        live.id = `${this.canvasId}-live`;\n        live.setAttribute('aria-live', 'polite');\n        Object.assign(live.style, {\n            position: 'absolute',\n            width: '1px',\n            height: '1px',\n            margin: '-1px',\n            padding: 0,\n            border: 0,\n            clip: 'rect(0 0 0 0)',\n        });\n        canvas.parentNode.insertBefore(live, canvas.nextSibling);\n\n        // Wire up keyboard navigation\n        const meta = chart.getDatasetMeta(0).data; // first dataset's elements\n        let elementIndex = 0;\n\n        // helper to update tooltip and live text\n        function highlight(index) {\n            // build an array of every datasetIndex at this index\n            const elements = chart.data.datasets\n                .map((_unusedValue, datasetIndex) => ({datasetIndex, index}))\n                .filter(({datasetIndex}) => {\n                    // skip if that dataset doesn't actually have a bar at this index\n                    return !!chart.getDatasetMeta(datasetIndex).data[index];\n                });\n\n            // get a tooltip-friendly position from one of the elements\n            const {x, y} = chart\n                .getDatasetMeta(elements[0].datasetIndex)\n                .data[index].tooltipPosition();\n\n            // activate them all\n            chart.setActiveElements(elements);\n            chart.tooltip.setActiveElements(elements, {x, y});\n            chart.update();\n\n            // update the live region:\n            live.textContent =\n                `${chart.data.labels[index]} - ` +\n                elements\n                    .map(({datasetIndex}) => {\n                        const ds = chart.data.datasets[datasetIndex];\n                        return `${ds.label}: ${ds.data[index]}`;\n                    })\n                    .join(', ');\n        }\n\n        // initialize on focus\n        canvas.addEventListener('focus', () => {\n            elementIndex = 0;\n            highlight(elementIndex);\n        });\n\n        // arrow-key handling\n        canvas.addEventListener('keydown', (event) => {\n            if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {\n                elementIndex = (elementIndex + 1) % meta.length;\n            } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {\n                elementIndex = (elementIndex - 1 + meta.length) % meta.length;\n            } else {\n                return; // ignore other keys\n            }\n            event.preventDefault();\n            highlight(elementIndex);\n        });\n    }\n\n    /**\n     * Instance-private helper: destroy any existing chart on this canvas,\n     * draw a blank chart with title + axes, and overlay an error message.\n     */\n    _handleError(context, message) {\n        renderEmptyChart(context, {\n            title: this.title,\n            xLabel: this.xLabel,\n            yLabel: this.yLabel,\n            chartType: this.chartType,\n        });\n        renderErrorOverlay(context, message);\n\n        // insert a visible error message under the canvas, for UAs (such as screenreaders)\n        // that can't handle the canvas\n        const canvas = context.canvas;\n        const container = canvas.parentNode;\n        const alert = document.createElement('div');\n        alert.setAttribute('role', 'alert');\n        alert.classList.add('visually-hidden');\n        alert.textContent = message;\n        container.insertBefore(alert, canvas.nextSibling);\n    }\n\n    /**\n     * Default Chart.js options (title + axes) for a \"real\" chart.\n     * Individual visualizations can override or extend these via userOptions.\n     */\n    static _defaultOptions(title, xLabel, yLabel) {\n        return {\n            responsive: true,\n            maintainAspectRatio: false,\n            plugins: {\n                title: {\n                    display: true,\n                    text: title,\n                },\n                tooltip: {\n                    mode: 'index',\n                    intersect: false,\n                },\n                legend: {\n                    position: 'top',\n                    labels: {\n                        boxWidth: 12,\n                        padding: 8,\n                    },\n                },\n            },\n            interaction: {\n                mode: 'index',\n                intersect: false,\n            },\n            scales: {\n                x: {\n                    title: {\n                        display: !!xLabel,\n                        text: xLabel,\n                    },\n                },\n                y: {\n                    beginAtZero: true,\n                    title: {\n                        display: !!yLabel,\n                        text: yLabel,\n                    },\n                },\n            },\n        };\n    }\n\n    /**\n     * Simple deep-merge of multiple objects.\n     * Later sources overwrite earlier keys.\n     */\n    static _deepMerge(target, ...sources) {\n        for (const source of sources) {\n            if (source && typeof source === 'object') {\n                for (const [key, value] of Object.entries(source)) {\n                    // Skip any attempt to assign \"__proto__\" or \"constructor\"\n                    if (key === '__proto__' || key === 'constructor') {\n                        continue;\n                    }\n\n                    if (\n                        value &&\n                        typeof value === 'object' &&\n                        !Array.isArray(value) &&\n                        !(value instanceof HTMLElement)\n                    ) {\n                        if (!target[key] || typeof target[key] !== 'object') {\n                            target[key] = {};\n                        }\n                        ConcordiaVisualization._deepMerge(target[key], value);\n                    } else {\n                        target[key] = value;\n                    }\n                }\n            }\n        }\n        return target;\n    }\n}\n"
  },
  {
    "path": "concordia/static/js/src/modules/quick-tips.js",
    "content": "import $ from 'jquery';\nimport {Modal} from 'bootstrap';\n\nfunction clearCache() {\n    const keys = Object.keys(localStorage);\n    for (const key of keys) {\n        if (key.startsWith('campaign-')) {\n            localStorage.removeItem(key);\n        }\n    }\n}\n\nfunction initCampaignTutorial() {\n    const campaignData = document.getElementById('campaign-data');\n    if (campaignData) {\n        if (typeof Storage === 'undefined') return;\n\n        const campaignSlug = campaignData.dataset.campaignSlug;\n        const isAuthenticated =\n            campaignData.dataset.userAuthenticated === 'true';\n        const hasAsset = campaignData.dataset.hasAsset === 'true';\n        if (campaignSlug) {\n            const keyName = `campaign-${campaignSlug}`;\n            const seen = localStorage.getItem(keyName);\n\n            if (!seen) {\n                if (!isAuthenticated) {\n                    clearCache();\n                }\n\n                if (hasAsset) {\n                    if (typeof window.setTutorialHeight === 'function') {\n                        window.setTutorialHeight();\n                    }\n\n                    $(function () {\n                        const modalElement =\n                            document.getElementById('tutorial-popup');\n                        const modal = new Modal(modalElement);\n                        modal.show();\n                    });\n\n                    localStorage.setItem(keyName, 'true');\n                }\n            }\n        } else if (!isAuthenticated) {\n            clearCache();\n        }\n    }\n}\n\ndocument.addEventListener('DOMContentLoaded', initCampaignTutorial);\n\nfunction setTutorialHeight() {\n    let $carouselItems = $('#card-carousel .carousel-item');\n    let heights = $carouselItems.map(function () {\n        let height = $(this).height();\n        if (height <= 0) {\n            let firstChild = $(this).children[0];\n            if (firstChild) {\n                height = firstChild.offsetHeight + 48;\n            } else {\n                return 517.195;\n            }\n        }\n        return height;\n    });\n    let maxHeight = Math.max.apply(this, heights);\n    $carouselItems.height(maxHeight);\n}\n\nexport {setTutorialHeight};\n\n// Expose globally so inline HTML can see it\nwindow.setTutorialHeight = setTutorialHeight;\n"
  },
  {
    "path": "concordia/static/js/src/modules/turnstile.js",
    "content": "/* global turnstile */\n\nfunction resetTurnstile(widgetId) {\n    // widgetId is optional. If not provided, the latest\n    // turnstile widget is used automatically\n    if (\n        typeof turnstile !== 'undefined' &&\n        turnstile !== null &&\n        typeof turnstile.reset === 'function'\n    ) {\n        turnstile.reset(widgetId);\n    } else {\n        console.error(\n            'Unable to reset turnstile. Turnstile.reset is not a function.',\n        );\n    }\n}\n\nexport {resetTurnstile};\n"
  },
  {
    "path": "concordia/static/js/src/modules/visualization-errors.js",
    "content": "import Chart from 'chart.js/auto';\n\n/**\n * Destroys any existing chart on this canvas and draws a “blank” chart that\n * only renders the title and axes (no data). Returns the new Chart instance.\n *\n * @param {CanvasRenderingContext2D} context\n * @param {Object} options\n * @param {string} options.title - the chart’s title text\n * @param {string} options.xLabel - x-axis title\n * @param {string} options.yLabel - y-axis title\n * @param {string} [options.chartType] - Chart.js type (default: 'bar')\n */\nexport function renderEmptyChart(\n    context,\n    {title, xLabel, yLabel, chartType = 'bar'},\n) {\n    // If there’s already a chart on this canvas, destroy it:\n    const existing = Chart.getChart(context.canvas);\n    if (existing) {\n        existing.destroy();\n    }\n\n    // Create a new empty chart\n    return new Chart(context, {\n        type: chartType,\n        data: {\n            labels: [], // no x-axis labels\n            datasets: [], // no data\n        },\n        options: {\n            responsive: true,\n            plugins: {\n                title: {\n                    display: true,\n                    text: title,\n                },\n                tooltip: {\n                    enabled: false,\n                },\n                legend: {\n                    display: false,\n                },\n            },\n            scales: {\n                x: {\n                    title: {\n                        display: !!xLabel,\n                        text: xLabel,\n                    },\n                },\n                y: {\n                    beginAtZero: true,\n                    title: {\n                        display: !!yLabel,\n                        text: yLabel,\n                    },\n                },\n            },\n        },\n    });\n}\n\n/**\n * Draws a centered error message overlay on top of whatever’s already been\n * rendered on the chart canvas. This does *not* destroy or modify the chart;\n * it simply paints a translucent rectangle and places text in the middle.\n *\n * @param {CanvasRenderingContext2D} context\n * @param {string} message\n * @param {Object} [options]\n * @param {string} [options.backgroundColor] - CSS color for overlay (default: \"rgba(255,255,255,0.6)\")\n * @param {string} [options.textColor] - CSS color for text (default: \"#a00\")\n * @param {string} [options.font] - CSS font for text (default: \"bold 16px sans-serif\")\n */\nexport function renderErrorOverlay(\n    context,\n    message,\n    {\n        backgroundColor = 'rgba(255, 255, 255, 0.6)',\n        textColor = '#a00',\n        font = 'bold 16px sans-serif',\n    } = {},\n) {\n    const {width, height} = context.canvas;\n\n    // Draw a semi‐transparent rectangle\n    context.save();\n    context.fillStyle = backgroundColor;\n    context.fillRect(0, 0, width, height);\n    context.restore();\n\n    // Draw the error text centered\n    context.save();\n    context.fillStyle = textColor;\n    context.font = font;\n    context.textAlign = 'center';\n    context.textBaseline = 'middle';\n    context.fillText(message, width / 2, height / 2);\n    context.restore();\n}\n"
  },
  {
    "path": "concordia/static/js/src/ocr.js",
    "content": "import 'bootstrap/dist/css/bootstrap.min.css';\nimport {Modal} from 'bootstrap';\n\ndocument.addEventListener('DOMContentLoaded', function () {\n    const link = document.getElementById('ocr-transcription-link');\n    if (link) {\n        if (link.dataset.authenticated === 'true') {\n            // Enable the button\n            link.classList.remove('disabled');\n            link.removeAttribute('aria-disabled');\n            link.removeAttribute('tabindex');\n\n            link.dataset.bsToggle = 'modal';\n            link.dataset.bsTarget = '#ocr-transcription-modal';\n            link.setAttribute('title', 'Transcribe with OCR');\n        } else {\n            link.classList.add('disabled');\n            link.setAttribute('aria-disabled', 'true');\n\n            link.setAttribute(\n                'href',\n                '/accounts/login/?next=' +\n                    encodeURIComponent(window.location.pathname),\n            );\n            link.setAttribute('title', 'Log in to use \"Transcribe with OCR\"');\n\n            delete link.dataset.bsToggle;\n            delete link.dataset.bsTarget;\n        }\n    }\n});\n\nfunction selectLanguage() {\n    const ocrModalElement = document.getElementById('ocr-transcription-modal');\n    const langModalElement = document.getElementById(\n        'language-selection-modal',\n    );\n\n    const ocrModal = Modal.getOrCreateInstance(ocrModalElement);\n    const langModal = Modal.getOrCreateInstance(langModalElement);\n\n    ocrModal.hide();\n    langModal.show();\n}\n\nconst selectLanguageButton = document.getElementById('select-language-button');\nif (selectLanguageButton) {\n    selectLanguageButton.addEventListener('click', selectLanguage);\n}\n\nexport {selectLanguage};\n"
  },
  {
    "path": "concordia/static/js/src/password-validation.js",
    "content": "import $ from 'jquery';\n\n$(function () {\n    var requirements = [\n        {\n            id: 'pw-length',\n            text: 'At least 8 characters long',\n            test: function (index) {\n                return index.length >= 8;\n            },\n        },\n        {\n            id: 'pw-uppercase',\n            text: '1 or more uppercase characters',\n            test: function (index) {\n                return index.match(/[A-Z]/);\n            },\n        },\n        {\n            id: 'pw-digits',\n            text: '1 or more digits',\n            test: function (index) {\n                return index.match(/\\d/);\n            },\n        },\n        {\n            id: 'pw-special',\n            text: '1 or more special characters',\n            test: function (index) {\n                return index.match(/[^\\d\\sa-z]/i);\n            },\n        },\n    ];\n    var $password1 = $('#id_password1,#id_new_password1').removeAttr('title');\n    var $requirementsList = $password1\n        .siblings('.form-text')\n        .find('ul')\n        .addClass('list-unstyled')\n        .empty();\n\n    for (const request of requirements) {\n        $('<li>')\n            .attr('id', request.id)\n            .text(request.text)\n            .appendTo($requirementsList);\n    }\n\n    $password1.on('input change', function () {\n        var currentValue = this.value;\n        var validity = true;\n\n        for (const request of requirements) {\n            var li = document.getElementById(request.id);\n\n            if (request.test(currentValue)) {\n                li.className = 'text-success';\n            } else {\n                li.className = 'text-warning';\n                validity = false;\n            }\n        }\n\n        if (validity) {\n            this.removeAttribute('aria-invalid');\n            this.setCustomValidity('');\n        } else {\n            this.setAttribute('aria-invalid', 'true');\n            this.setCustomValidity(\n                'Your password does not meet the requirements',\n            );\n        }\n    });\n});\n"
  },
  {
    "path": "concordia/static/js/src/profile-fields.js",
    "content": "import $ from 'jquery';\nimport {getPages} from './recent-pages.js';\n\nwindow.sortDateAscending = function () {\n    var urlParameters = new URLSearchParams(window.location.search);\n    urlParameters.set('order_by', 'date-ascending');\n    getPages('?' + urlParameters.toString());\n};\nwindow.sortDateDescending = function () {\n    var urlParameters = new URLSearchParams(window.location.search);\n    urlParameters.set('order_by', 'date-descending');\n    getPages('?' + urlParameters.toString());\n};\n\nif (!window._profileFieldsInitialized) {\n    window._profileFieldsInitialized = true;\n\n    $(document).ready(function () {\n        let profilePage = document.getElementById('profile-page');\n        let activeTab = profilePage?.dataset.activeTab;\n        if (activeTab === 'recent' || window.location.hash === '#recent') {\n            getPages();\n        }\n    });\n\n    // Disable form submissions, if there are invalid fields\n    window.addEventListener(\n        'load',\n        function () {\n            // Fetch all the forms we want to apply custom Bootstrap validation styles to\n            var forms = document.querySelectorAll('.needs-validation');\n            for (const form of forms) {\n                form.addEventListener('submit', (event) => {\n                    $('#validation-confirmation').hide();\n                    if (!form.checkValidity()) {\n                        event.preventDefault();\n                        event.stopPropagation();\n                    }\n                    form.classList.add('was-validated');\n                });\n            }\n        },\n        false,\n    );\n}\n"
  },
  {
    "path": "concordia/static/js/src/quick-tips-setup.js",
    "content": "import $ from 'jquery';\n\nimport {setTutorialHeight} from './modules/quick-tips.js';\nimport {trackUIInteraction} from './base.js';\n\n$('#tutorial-popup').on('shown.bs.modal', function () {\n    setTutorialHeight();\n});\n\nfunction trackQuickTipsInteraction(element, label) {\n    trackUIInteraction(element, 'Quick Tips', 'click', label);\n}\n\n$('#quick-tips').on('click', function () {\n    trackQuickTipsInteraction($(this), 'Open');\n});\n\n$('#previous-card').on('click', function () {\n    trackQuickTipsInteraction($(this), 'Back');\n});\n\n$('#next-card').on('click', function () {\n    trackQuickTipsInteraction($(this), 'Next');\n});\n\n$('.carousel-indicators li').on('click', function () {\n    let index = [...this.parentElement.children].indexOf(this);\n    trackQuickTipsInteraction($(this), `Carousel ${index}`);\n});\n\n$('#tutorial-popup').on('hidden.bs.modal', function () {\n    // We're tracking whenever the popup closes, so we don't separately track the close button being clicked\n    trackUIInteraction($(this), 'Quick Tips', 'click', 'Close');\n});\n\n$('#tutorial-popup').on('shown-on-load', function () {\n    // We set a timeout to make sure the analytics code is loaded before trying to track\n    setTimeout(function () {\n        trackUIInteraction($(this), 'Quick Tips', 'load', 'Open');\n    }, 1000);\n});\n"
  },
  {
    "path": "concordia/static/js/src/recent-pages.js",
    "content": "import $ from 'jquery';\n\nlet currentRequest;\n\nexport function getPages(queryString = window.location.search) {\n    // Show indicator\n    $('#recent-pages').html(\n        '<p class=\"text-center py-3\"><span class=\"spinner-border spinner-border-sm\"></span>Loading...</p>',\n    );\n\n    if (currentRequest) {\n        // Cancel previous before starting a new one\n        currentRequest.abort();\n    }\n    currentRequest = $.ajax({\n        type: 'GET',\n        url: '/account/get_pages' + queryString,\n        dataType: 'json',\n        success: function (data) {\n            // Clean up old elements\n            const dropdownElements = document.querySelectorAll(\n                '[data-bs-toggle=\"dropdown\"]',\n            );\n            for (const dropdownElement of dropdownElements) {\n                const instance = dropdownElement._bs_dropdown;\n                if (instance && typeof instance.dispose === 'function') {\n                    instance.dispose();\n                }\n            }\n\n            var recentPages = document.createElement('div');\n            recentPages.className = 'col-md';\n            recentPages.innerHTML = data.content; // render data into the DOM\n            $('#recent-pages').fadeOut(100, function () {\n                $(this).html(recentPages).fadeIn(150);\n            });\n        },\n        error: function () {\n            $('#recent-pages').html('<p>Failed to load pages.</p>');\n        },\n        complete: function () {\n            // clear the reference\n            currentRequest = undefined;\n        },\n    });\n}\n\nif (!window._recentPagesHandlersInitialized) {\n    window._recentPagesHandlersInitialized = true;\n\n    $(document).on('click', '#recent-tab', function () {\n        if (!this.dataset.loaded) {\n            this.dataset.loaded = 'true';\n            getPages(window.location.search);\n        }\n    });\n\n    $(document).on('submit', '.date-filter', function (event) {\n        event.preventDefault();\n\n        const parameters = new URLSearchParams(new FormData(this));\n\n        getPages('?' + parameters.toString());\n    });\n\n    $(document).on('click', '#current-filters a', function (event) {\n        event.preventDefault();\n\n        const href = $(this).attr('href'); // e.g. \"?tab=recent\"\n\n        getPages(href);\n    });\n\n    $(document).on('click', '.dropdown-menu a.filter-link', function (event) {\n        event.preventDefault();\n\n        const href = $(this).attr('href') || '';\n        const qsFromLink = href.startsWith('?') ? href.slice(1) : href;\n        const linkParameters = new URLSearchParams(qsFromLink);\n\n        const currentParameters = new URLSearchParams(window.location.search);\n\n        for (const [key, value] of linkParameters.entries()) {\n            if (key.startsWith('delete:')) {\n                currentParameters.delete(key.replace('delete:', ''));\n            } else {\n                currentParameters.set(key, value);\n            }\n        }\n        finalizePageUpdate(currentParameters);\n    });\n\n    // Intercept clicks and load via AJAX instead of full page reload\n    $(document).on('click', '.pagination a.page-link', function (event) {\n        event.preventDefault();\n\n        const href = $(this).attr('href') || '';\n        const qs = href.startsWith('?') ? href : '?' + href;\n\n        getPages(qs);\n\n        // Update the URL in the address bar\n        history.replaceState(undefined, '', qs + window.location.hash);\n    });\n\n    $(document).on(\n        'submit',\n        'nav[aria-label=\"Page Jump\"] form',\n        function (event) {\n            event.preventDefault();\n\n            const pageNumber = $(this).find('select[name=\"page\"]').val();\n\n            const currentParameters = new URLSearchParams(\n                window.location.search,\n            );\n\n            currentParameters.set('page', pageNumber);\n\n            // Preserve other filters\n            $(this)\n                .find('input[type=\"hidden\"]')\n                .each(function () {\n                    currentParameters.set(this.name, this.value);\n                });\n            finalizePageUpdate(currentParameters);\n        },\n    );\n}\n\nfunction finalizePageUpdate(currentParameters) {\n    if (!currentParameters.has('tab')) currentParameters.set('tab', 'recent');\n\n    const newQuery = '?' + currentParameters.toString();\n    // Call AJAX loader\n    getPages(newQuery);\n    // Update the URL in the address bar without reloading\n    history.replaceState(undefined, '', newQuery + window.location.hash);\n}\n"
  },
  {
    "path": "concordia/static/js/src/viewer-split.js",
    "content": "import {seadragonViewer} from './viewer.js';\nimport Split from 'split.js';\n\nlet pageSplit;\nlet contributeContainer = document.getElementById('contribute-container');\nlet ocrSection = document.getElementById('ocr-section');\nlet editorColumn = document.getElementById('editor-column');\nlet viewerColumn = document.getElementById('viewer-column');\nlet layoutColumns = ['#viewer-column', '#editor-column'];\nlet verticalKey = 'transcription-split-sizes-vertical';\nlet horizontalKey = 'transcription-split-sizes-horizontal';\n\nlet sizesVertical = localStorage.getItem(verticalKey);\n\nif (sizesVertical) {\n    sizesVertical = JSON.parse(sizesVertical);\n} else {\n    sizesVertical = [50, 50];\n}\n\nlet sizesHorizontal = localStorage.getItem(horizontalKey);\n\nif (sizesHorizontal) {\n    sizesHorizontal = JSON.parse(sizesHorizontal);\n} else {\n    sizesHorizontal = [50, 50];\n}\n\nlet splitDirection = localStorage.getItem('transcription-split-direction');\n\nif (splitDirection) {\n    splitDirection = JSON.parse(splitDirection);\n} else {\n    splitDirection = 'h';\n}\n\nfunction saveSizes(sizes) {\n    let sizeKey;\n    if (splitDirection == 'h') {\n        sizeKey = horizontalKey;\n        sizesHorizontal = sizes;\n    } else {\n        sizeKey = verticalKey;\n        sizesVertical = sizes;\n    }\n    localStorage.setItem(sizeKey, JSON.stringify(sizes));\n}\n\nfunction saveDirection(direction) {\n    localStorage.setItem(\n        'transcription-split-direction',\n        JSON.stringify(direction),\n    );\n}\n\nfunction verticalSplit() {\n    splitDirection = 'v';\n    saveDirection(splitDirection);\n    if (contributeContainer) {\n        contributeContainer.classList.remove('flex-row');\n        contributeContainer.classList.add('flex-column');\n    }\n    viewerColumn.classList.remove('h-100');\n    if (ocrSection != undefined) {\n        editorColumn.prepend(ocrSection);\n    }\n\n    return Split(layoutColumns, {\n        sizes: sizesVertical,\n        minSize: 100,\n        gutterSize: 8,\n        direction: 'vertical',\n        elementStyle: function (dimension, size, gutterSize) {\n            return {\n                'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)',\n            };\n        },\n        gutterStyle: function (dimension, gutterSize) {\n            return {\n                'flex-basis': gutterSize + 'px',\n            };\n        },\n        onDragEnd: saveSizes,\n    });\n}\nfunction horizontalSplit() {\n    splitDirection = 'h';\n    saveDirection(splitDirection);\n    if (contributeContainer) {\n        contributeContainer.classList.remove('flex-column');\n        contributeContainer.classList.add('flex-row');\n    }\n    viewerColumn.classList.add('h-100');\n    if (ocrSection != undefined) {\n        viewerColumn.append(ocrSection);\n    }\n    return Split(layoutColumns, {\n        sizes: sizesHorizontal,\n        minSize: 100,\n        gutterSize: 8,\n        elementStyle: function (dimension, size, gutterSize) {\n            return {\n                'flex-basis': 'calc(' + size + '% - ' + gutterSize + 'px)',\n            };\n        },\n        gutterStyle: function (dimension, gutterSize) {\n            return {\n                'flex-basis': gutterSize + 'px',\n            };\n        },\n        onDragEnd: saveSizes,\n    });\n}\n\nif (contributeContainer && seadragonViewer) {\n    if (splitDirection == 'v') {\n        pageSplit = verticalSplit();\n    } else {\n        pageSplit = horizontalSplit();\n    }\n\n    document\n        .getElementById('viewer-layout-horizontal')\n        .addEventListener('click', function () {\n            if (splitDirection != 'h') {\n                if (pageSplit != undefined) {\n                    pageSplit.destroy();\n                }\n                pageSplit = horizontalSplit();\n                setTimeout(function () {\n                    // Some quirk in the viewer makes this\n                    // sometimes not work depending on\n                    // the rotation, unless it's delayed.\n                    // Less than 10ms didn't reliable work.\n                    // Adding ', null, true' ensures the zoom happens immediately\n                    // and doesn't conflict with the CSS flexbox resizing of the container.\n                    seadragonViewer.viewport.zoomTo(1, undefined, true);\n                }, 10);\n            }\n        });\n\n    document\n        .getElementById('viewer-layout-vertical')\n        .addEventListener('click', function () {\n            if (splitDirection != 'v') {\n                if (pageSplit != undefined) {\n                    pageSplit.destroy();\n                }\n                pageSplit = verticalSplit();\n                setTimeout(function () {\n                    seadragonViewer.viewport.zoomTo(1, undefined, true);\n                }, 10);\n            }\n        });\n}\n"
  },
  {
    "path": "concordia/static/js/src/viewer.js",
    "content": "import {debounce, displayHtmlMessage} from './base.js';\nimport screenfull from 'screenfull';\nimport OpenSeadragon from 'openseadragon';\nimport {\n    initializeFiltering,\n    GAMMA,\n    INVERT,\n    THRESHOLDING,\n} from 'openseadragon-filters';\n\nconst viewerElement = document.getElementById('viewer-data');\n\nlet viewerData;\nlet seadragonViewer;\nlet filterPlugin;\n\nif (viewerElement) {\n    viewerData = viewerElement.dataset;\n\n    seadragonViewer = OpenSeadragon({\n        id: 'asset-image',\n        prefixUrl: viewerData.prefixUrl,\n        tileSources: {\n            type: 'image',\n            url: viewerData.tileSourceUrl,\n        },\n        gestureSettingsTouch: {\n            pinchRotate: true,\n        },\n        showNavigator: true,\n        showRotationControl: true,\n        showFlipControl: true,\n        toolbar: 'viewer-controls',\n        zoomInButton: 'viewer-zoom-in',\n        zoomOutButton: 'viewer-zoom-out',\n        homeButton: 'viewer-home',\n        rotateLeftButton: 'viewer-rotate-left',\n        rotateRightButton: 'viewer-rotate-right',\n        flipButton: 'viewer-flip',\n        crossOriginPolicy: 'Anonymous',\n        drawer: 'canvas',\n        defaultZoomLevel: 0,\n        homeFillsView: false,\n    });\n\n    // Initialize the filtering plugin\n    filterPlugin = initializeFiltering(seadragonViewer);\n\n    // We need to define our own fullscreen function rather than using OpenSeadragon's\n    // because the built-in fullscreen function overwrites the DOM with the viewer,\n    // breaking our extra controls, such as the image filters.\n    if (screenfull.isEnabled) {\n        let fullscreenButton = document.querySelector('#viewer-fullscreen');\n        fullscreenButton.addEventListener('click', function (event) {\n            event.preventDefault();\n            let targetElement = document.querySelector(\n                fullscreenButton.dataset.target,\n            );\n            if (screenfull.isFullscreen) {\n                screenfull.exit();\n            } else {\n                screenfull.request(targetElement);\n            }\n        });\n        // Listen for fullscreen changes for proper flex container alinment\n        screenfull.on('change', () => {\n            let targetElement = document.querySelector(\n                fullscreenButton.dataset.target,\n            );\n            if (screenfull.isFullscreen) {\n                // Ensure the flex container takes full width of the screen\n                targetElement.style.width = '100vw';\n                targetElement.style.display = 'flex';\n            } else {\n                targetElement.style.width = '';\n                targetElement.style.display = '';\n            }\n        });\n    }\n\n    // The buttons configured as controls for the viewer don't properly get focus\n    // when clicked. This mostly isn't a problem, but causes odd-looking behavior\n    // when one of the extra buttons in the control bar is clicked (and therefore\n    // focused) first--clicking the control button leaves the focus on the extra\n    // button.\n    // TODO: Attempting to add focus to the clicked button here doesn't consistently\n    // work for unknown reasons, so it just removes focus from the extra buttons\n    // for now\n    let viewerControlButtons = document.querySelectorAll(\n        '.viewer-control-button',\n    );\n    for (const node of viewerControlButtons) {\n        node.addEventListener('click', function () {\n            let focusedButton = document.querySelector(\n                '.extra-control-button:focus',\n            );\n            if (focusedButton) {\n                focusedButton.blur();\n            }\n        });\n    }\n}\n\n/*\n * Image filter handling\n */\n\nlet availableFilters = [\n    {\n        formId: 'gamma-form',\n        inputId: 'gamma',\n        getFilter: function () {\n            let value = document.getElementById(this.inputId).value;\n            if (\n                !Number.isNaN(value) &&\n                value != 1 &&\n                value >= 0 &&\n                value <= 5\n            ) {\n                return GAMMA(Number.parseFloat(value));\n            }\n        },\n    },\n    {\n        formId: 'invert-form',\n        inputId: 'invert',\n        getFilter: function () {\n            let value = document.getElementById(this.inputId).checked;\n            if (value) {\n                return INVERT();\n            }\n        },\n    },\n    {\n        formId: 'threshold-form',\n        inputId: 'threshold',\n        getFilter: function () {\n            let value = document.getElementById(this.inputId).value;\n            if (!Number.isNaN(value) && value > 0 && value <= 255) {\n                return THRESHOLDING(Number.parseInt(value));\n            }\n        },\n    },\n];\n\nfunction updateFilters() {\n    let filters = [];\n    for (const filterData of availableFilters) {\n        let filter = filterData.getFilter();\n        if (filter) {\n            filters.push(filter);\n        }\n    }\n\n    //  Call setFilterOptions on the plugin instance instead of the viewer\n\n    if (filterPlugin) {\n        filterPlugin.setFilterOptions({\n            filters: {\n                processors: filters,\n            },\n        });\n    }\n}\n\nif (viewerElement) {\n    for (const filterData of availableFilters) {\n        let form = document.getElementById(filterData.formId);\n        if (form) {\n            form.addEventListener('change', updateFilters);\n            form.addEventListener('reset', function () {\n                // We use setTimeout to push the updateFilters\n                // call to the next event cycle in order to\n                // call it after the form is reset, instead\n                // of before, which is when this listener\n                // triggers\n                setTimeout(updateFilters);\n            });\n        }\n\n        let input = document.getElementById(filterData.inputId);\n        if (input) {\n            // We use debounce here so that updateFilters is only called once,\n            // after the user stops typing or scrolling with their mousewheel\n            input.addEventListener(\n                'keyup',\n                debounce(() => updateFilters()),\n            );\n            input.addEventListener(\n                'wheel',\n                debounce(() => updateFilters()),\n            );\n        }\n    }\n}\n\n/*\n * Image filter form handling\n */\nfunction stepUp(id) {\n    let input = document.getElementById(id);\n    input.stepUp();\n    input.dispatchEvent(new Event('input', {bubbles: true}));\n    input.dispatchEvent(new Event('change', {bubbles: true}));\n    return false;\n}\n\nfunction stepDown(id) {\n    let input = document.getElementById(id);\n    input.stepDown();\n    input.dispatchEvent(new Event('input', {bubbles: true}));\n    input.dispatchEvent(new Event('change', {bubbles: true}));\n    return false;\n}\n\nfunction resetImageFilterForms() {\n    for (const filterData of availableFilters) {\n        let form = document.getElementById(filterData.formId);\n        form.reset();\n    }\n}\n\nif (seadragonViewer) {\n    let gammaNumber = document.getElementById('gamma');\n    let gammaRange = document.getElementById('gamma-range');\n\n    gammaNumber.addEventListener('input', function () {\n        gammaRange.value = gammaNumber.value;\n    });\n\n    gammaRange.addEventListener('input', function () {\n        gammaNumber.value = gammaRange.value;\n    });\n\n    let gammaUp = document.getElementById('gamma-up');\n    gammaUp.addEventListener('click', function () {\n        stepUp('gamma');\n    });\n\n    let gammaDown = document.getElementById('gamma-down');\n    gammaDown.addEventListener('click', function () {\n        stepDown('gamma');\n    });\n\n    let thresholdNumber = document.getElementById('threshold');\n    let thresholdRange = document.getElementById('threshold-range');\n\n    thresholdNumber.addEventListener('input', function () {\n        thresholdRange.value = thresholdNumber.value;\n    });\n\n    thresholdRange.addEventListener('input', function () {\n        thresholdNumber.value = thresholdRange.value;\n    });\n\n    let thresholdUp = document.getElementById('threshold-up');\n    thresholdUp.addEventListener('click', function () {\n        stepUp('threshold');\n    });\n\n    let thresholdDown = document.getElementById('threshold-down');\n    thresholdDown.addEventListener('click', function () {\n        stepDown('threshold');\n    });\n\n    let reset = document.getElementById('viewer-reset');\n    reset.addEventListener('click', resetImageFilterForms);\n\n    // After the viewer has opened, set it to the home\n    // view, which insures the entire image is displayed\n    // (Workaround for change in behavior introduced during\n    // the upgrade to OpenSeadragon 5.0.1)\n    seadragonViewer.addHandler('open', function () {\n        // We use setTimeout to make sure everything is\n        // fully loaded so the viewport is ready calculate\n        // the bounds and zoom correctly.\n        setTimeout(() => {\n            seadragonViewer.viewport.goHome(true);\n        }, 0);\n    });\n\n    seadragonViewer.addHandler('open-failed', function () {\n        // We don't use the eventData or error message\n        // because it contains the image URL, which we don't\n        // want to display\n        let contactUs =\n            '<strong><a class=\"alert-link\" href=\"' +\n            viewerData.contactUrl +\n            '\" target=\"_blank\">Contact us</a></strong>';\n        displayHtmlMessage(\n            'error',\n            'Unable to display image - ' + contactUs,\n            'openseadragon-open-failed',\n        );\n    });\n}\n\nexport {seadragonViewer};\n"
  },
  {
    "path": "concordia/static/js/src/visualizations/asset-status-by-campaign.js",
    "content": "import {ConcordiaVisualization} from 'concordia-visualization';\n\nconst colors = ['#FFFFFF', '#002347', '#E0F6FF', '#257DB1'];\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    const assetStatusByCampaignChart = new ConcordiaVisualization({\n        name: 'asset-status-by-campaign',\n        canvasId: 'asset-status-by-campaign',\n        chartType: 'bar',\n        title: 'Page Status by Campaign (Active Campaigns)',\n        xLabel: 'Campaign',\n        yLabel: 'Page Count',\n        buildDataset: (payload) => {\n            const fullNames = payload.campaign_names;\n            const shortLabels = fullNames.map((name) =>\n                name.length > 20 ? name.slice(0, 20) + '…' : name,\n            );\n\n            const statusKeys = Object.keys(payload.per_campaign_counts);\n            const statusLabels = payload.status_labels;\n\n            const datasets = statusKeys.map((key, index) => ({\n                label: statusLabels[index],\n                data: payload.per_campaign_counts[key],\n                backgroundColor: colors[index],\n                borderColor: 'black',\n                borderWidth: 2,\n            }));\n\n            return {\n                data: {\n                    labels: shortLabels, // truncated names on the axis\n                    datasets: datasets,\n                },\n                options: {\n                    scales: {\n                        x: {stacked: true},\n                        y: {stacked: true, beginAtZero: true},\n                    },\n                    plugins: {\n                        tooltip: {\n                            // We want the full names on hover\n                            callbacks: {\n                                title: (tooltipItems) => {\n                                    const campaignIndex =\n                                        tooltipItems[0].dataIndex;\n                                    return fullNames[campaignIndex];\n                                },\n                                label: (tooltipItem) => {\n                                    const status = tooltipItem.dataset.label;\n                                    const value = tooltipItem.parsed.y;\n                                    return `${status}: ${value}`;\n                                },\n                            },\n                        },\n                    },\n                },\n            };\n        },\n    });\n\n    assetStatusByCampaignChart.render();\n});\n"
  },
  {
    "path": "concordia/static/js/src/visualizations/asset-status-overview.js",
    "content": "import {ConcordiaVisualization} from '../modules/concordia-visualization.js';\n\nconst colors = ['#FFFFFF', '#002347', '#E0F6FF', '#257DB1'];\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    const assetStatusOverviewChart = new ConcordiaVisualization({\n        name: 'asset-status-overview',\n        canvasId: 'asset-status-overview',\n        chartType: 'pie',\n        title: 'Page Status (Active Campaigns)',\n        xLabel: '',\n        yLabel: '',\n        buildDataset: (payload) => {\n            return {\n                data: {\n                    labels: payload.status_labels,\n                    datasets: [\n                        {\n                            data: payload.total_counts,\n                            backgroundColor: colors,\n                            borderColor: 'black',\n                            borderWidth: 2,\n                        },\n                    ],\n                },\n                options: {\n                    scales: {\n                        // We don't want scales on a pie chart\n                        x: {display: false},\n                        y: {display: false},\n                    },\n                },\n            };\n        },\n    });\n\n    assetStatusOverviewChart.render();\n});\n"
  },
  {
    "path": "concordia/static/js/src/visualizations/daily-activity.js",
    "content": "import {ConcordiaVisualization} from '../modules/concordia-visualization.js';\n\ndocument.addEventListener('DOMContentLoaded', () => {\n    const dailyActivityChart = new ConcordiaVisualization({\n        name: 'daily-transcription-activity-last-28-days',\n        canvasId: 'daily-activity',\n        chartType: 'bar',\n        title: 'Daily Transcription Activity (Last 28 Days)',\n        xLabel: 'Date',\n        yLabel: 'Transcriptions + Reviews',\n        buildDataset: (payload) => {\n            const colors = ['#911C42', '#BFBBDD'];\n\n            const datasets = payload.transcription_datasets.map(\n                (ds, index) => ({\n                    ...ds,\n                    backgroundColor: colors[index],\n                    borderColor: '#000',\n                    borderWidth: 1.5,\n                }),\n            );\n\n            return {\n                data: {\n                    labels: payload.labels,\n                    datasets: datasets,\n                },\n                options: {\n                    scales: {\n                        x: {\n                            stacked: true,\n                            ticks: {\n                                callback: function (value, index) {\n                                    // Show only every 4th tick starting at index 3 (i.e., the 4th day)\n                                    return (index - 3) % 4 === 0\n                                        ? this.getLabelForValue(index)\n                                        : '';\n                                },\n                                autoSkip: false,\n                            },\n                        },\n                        y: {\n                            stacked: true,\n                        },\n                    },\n                    plugins: {\n                        legend: {\n                            display: false,\n                        },\n                    },\n                },\n            };\n        },\n    });\n\n    dailyActivityChart.render();\n});\n"
  },
  {
    "path": "concordia/static/scss/_variables.scss",
    "content": "// global variables\n$blue: #00618e;\n$blue-light: #beeaff;\n$orange: #f05129;\n$accent: $orange;\n$default: $gray-300;\n$primary: $blue;\n\n$theme-colors: (\n    'accent': $orange,\n    'default': $gray-300,\n    'primary': $primary,\n);\n\n$link-color: map.get($theme-colors, 'primary');\n$link-hover-color: color.adjust($link-color, $lightness: 15%);\n\n$kbd-color: $gray-900;\n$kbd-bg: $gray-200;\n\n$headings-font-weight: 700;\n\n$sizes: (\n    60: 60%,\n    65: 65%,\n);\n\n// typography\n$concordia-app-font-size-xxs: $font-size-base * 0.75;\n$concordia-app-font-size-xs: $font-size-base * 0.8125;\n$concordia-app-font-size-xl: $font-size-base * 1.5;\n$concordia-app-line-height-xs: 1.4;\n$concordia-app-line-height-xxs: 1.3;\n\n// toolbar\n$concordia-app-toolbar-border: $gray-600;\n$concordia-app-toolbar-background: $gray-300;\n$concordia-app-active-color: $white;\n\n// thumbnail\n$concordia-app-asset-list-thumbnail-width: 140px;\n$concordia-app-asset-list-thumbnail-gap: 10px;\n$concordia-app-asset-list-text-padding: 8px;\n\n// asset unavailable\n$concordia-app-asset-unavailable-background: $blue-light;\n$concordia-app-asset-unavailable-padding-x: $concordia-app-asset-list-text-padding;\n$concordia-app-asset-unavailable-padding-y: $concordia-app-asset-list-text-padding;\n$concordia-app-asset-unavailable-icon-width: 24px;\n\n// footer\n$concordia-app-footer-height: $line-height-lg * 1rem;\n"
  },
  {
    "path": "concordia/static/scss/base.scss",
    "content": "@use 'sass:math';\n\n$gray-100: #f6f6f6;\n$gray-200: #efefef;\n$gray-300: $gray-200;\n$gray-400: #bfbfbf;\n$gray-500: $gray-400;\n$gray-600: #808080;\n$gray-700: #545454;\n$gray-800: #242424;\n$gray-900: $gray-800;\n\n$blue: #0076ad;\n$orange: #f05129;\n$green: #218739;\n$red: #d1332e;\n$navy: #002347;\n$white: #fff;\n\n$dark: $gray-900;\n$accent: $orange;\n$error: $red;\n$light: $gray-100;\n$secondary: $gray-700;\n\n$theme-colors: (\n    'accent': $orange,\n    'error': $red,\n    'info': $blue,\n    'warning': $secondary,\n);\n\n$mark-bg: #caeea4;\n\n// additional sizes\n$sizes: (\n    1: 1%,\n    30: 30%,\n    35: 35%,\n    40: 40%,\n    60: 60%,\n    65: 65%,\n);\n\n// typography\n$font-family-sans-serif: 'Open Sans', arial, helvetica, sans-serif;\n$font-family-serif: 'Roboto Slab', arial, helvetica, serif;\n$headings-font-weight: 700;\n\n// Breadcrumbs\n$breadcrumb-padding-y: 7px;\n$breadcrumb-padding-x: 0;\n$breadcrumb-item-padding: 0.25rem;\n$breadcrumb-margin-bottom: 0;\n$breadcrumb-bg: transparent;\n$breadcrumb-active-color: $dark;\n\n// carousel\n$carousel-control-color: black;\n$carousel-control-width: 8%;\n$carousel-control-opacity: 1;\n$carousel-indicator-width: 12px;\n$carousel-indicator-height: 12px;\n$carousel-control-icon-height: 3.5rem;\n$carousel-control-icon-width: 3.5rem;\n\n// dropdown\n$dropdown-padding-y: 0.4rem;\n$dropdown-item-padding-y: 3px;\n$dropdown-item-padding-x: 1rem;\n$dropdown-divider-margin-y: 5px;\n\n// small font size\n$small-font-size: 87.5%;\n\n// inline list\n$list-inline-padding: 12px;\n\n// variables that are placed below dependent on bootstrap variables\n@import '../../../node_modules/bootstrap/scss/functions';\n@import '../../../node_modules/bootstrap/scss/variables';\n@import '../../../node_modules/bootstrap/scss/mixins';\n@import '../../../node_modules/bootstrap/scss/maps';\n@import '../../../node_modules/bootstrap/scss/utilities';\n@import '../../../node_modules/bootstrap/scss/utilities/api';\n\n// navbar\n$navbar-toggler-font-size: 32px;\n$navbar-toggler-padding-y: 0;\n$navbar-toggler-padding-x: 0;\n$navbar-light-color: $dark;\n$navbar-light-active-color: $dark;\n$navbar-light-disabled-color: rgba($black, 0.55);\n\n// forms\n$input-border-color: $gray-500;\n$label-margin-bottom: 0.4rem;\n$form-group-margin-bottom: 1.5rem;\n\n// buttons\n$btn-font-weight: $font-weight-bold;\n$btn-border-radius: 0.375rem;\n\n// headings\n$h1-font-size: $font-size-base * 2.5;\n$h2-font-size: $font-size-base * 1.75;\n$h3-font-size: $font-size-base * 1.25;\n$h4-font-size: $font-size-base * 1.125;\n$h5-font-size: $font-size-base;\n$h6-font-size: $font-size-base;\n\n// border\n$border-color: $gray-500;\n$hr-border-color: $gray-500;\n\n// alert\n$alert-bg-level: 0;\n$alert-border-level: 0;\n$alert-color-level: -12;\n$alert-padding-y: 9px;\n$alert-padding-x: 1rem;\n\n@import '../../../node_modules/bootstrap/scss/root';\n@import '../../../node_modules/bootstrap/scss/reboot';\n@import '../../../node_modules/bootstrap/scss/type';\n@import '../../../node_modules/bootstrap/scss/containers';\n@import '../../../node_modules/bootstrap/scss/grid';\n@import '../../../node_modules/bootstrap/scss/forms';\n@import '../../../node_modules/bootstrap/scss/buttons';\n@import '../../../node_modules/bootstrap/scss/dropdown';\n@import '../../../node_modules/bootstrap/scss/button-group';\n@import '../../../node_modules/bootstrap/scss/nav';\n@import '../../../node_modules/bootstrap/scss/navbar';\n@import '../../../node_modules/bootstrap/scss/breadcrumb';\n@import '../../../node_modules/bootstrap/scss/alert';\n@import '../../../node_modules/bootstrap/scss/close';\n@import '../../../node_modules/bootstrap/scss/carousel';\n\n// progress\n$bg-completed: $blue;\n$bg-submitted: #e0f6ff;\n/* stylelint-disable-next-line scss/no-global-function-names */\n$bg-in-progress: $navy;\n$bg-not-started: #fff;\n\n// shadow\n/* stylelint-disable-next-line color-function-notation */\n$shadow-color: rgba(51, 51, 51, 80%); /* #333333 */\n\nhtml,\n#contribute-main-content {\n    background-color: $white;\n    color: $dark;\n    overflow-x: hidden;\n}\n\nbody {\n    color: $dark;\n    border-top: 4px solid $accent;\n    display: flex;\n    flex-direction: column;\n    min-height: 100vh;\n}\n\n#body {\n    font-family: 'Open Sans', arial, helvetica, sans-serif;\n}\n\nmain {\n    flex: 1 1 0%;\n}\n\nbody a {\n    color: $blue;\n    text-decoration: none;\n}\n\nbody a:hover {\n    color: #004261;\n}\n\n.simple-page a {\n    text-decoration: underline;\n}\n\nbody .container,\n.col-md-2,\n.col-md-3,\n.row .col-12,\n.row .col-lg,\n.row .col-lg-auto {\n    padding-left: 15px;\n    padding-right: 15px;\n}\n\ndiv.row {\n    margin-left: -15px;\n    margin-right: -15px;\n}\n\nsmall {\n    font-weight: 400;\n}\n\nheader.border-bottom {\n    border-bottom: 1px solid $border-color !important;\n}\n\n.close {\n    opacity: 1;\n}\n\n.alert {\n    color: #fff;\n\n    &.alert-danger {\n        background-color: $red;\n        color: #fff;\n\n        .alert-link {\n            color: #fff;\n            font-weight: bolder;\n        }\n    }\n\n    &.alert-dismissible {\n        padding: 9px 1rem;\n    }\n\n    &.alert-info {\n        background-color: $blue;\n        color: #fff;\n    }\n\n    .alert-link {\n        color: #fff;\n        text-decoration: underline;\n    }\n\n    &.alert-light {\n        color: $dark;\n\n        .alert-link {\n            color: $dark;\n        }\n    }\n\n    &.alert-success {\n        background-color: $green;\n    }\n\n    &.alert-warning {\n        background-color: $gray-700;\n    }\n}\n\ndiv .btn:first-child:active,\na:not(.btn-check) + .btn:active,\ndiv :not(.btn-check) + .btn:active {\n    background-color: #005c87;\n    border-color: #005c87;\n}\n\n.alert a[type='button'] {\n    color: inherit;\n    font-size: 1.5rem;\n}\n\n.font-serif {\n    font-family: $font-family-serif;\n}\n\nbody h1,\n.h1 {\n    font-family: $font-family-serif;\n    font-weight: bold;\n}\n\nbody h2 {\n    font-size: $h2-font-size;\n    font-weight: $headings-font-weight;\n}\n\nbody .h3,\nbody h3 {\n    font-size: $h3-font-size;\n    font-weight: $headings-font-weight;\n}\n\nbody h4 {\n    font-size: $h4-font-size;\n    font-weight: $headings-font-weight;\n}\n\nbody h5 {\n    font-size: $h5-font-size;\n    font-weight: $headings-font-weight;\n}\n\ndiv h6 {\n    font-weight: $headings-font-weight;\n}\n\nli a {\n    text-decoration: none;\n}\n\nli a:hover {\n    text-decoration: underline;\n}\n\n.text-dark {\n    color: $gray-800;\n}\n\np a,\n.simple-page a {\n    text-decoration: underline;\n}\n\np a:hover,\n.simple-page a:hover,\n.btn:hover {\n    text-decoration: none;\n}\n\n.row > * {\n    padding-left: 0;\n    padding-right: 0;\n}\n\n.input-group-prepend {\n    margin-right: -1px;\n}\n\n.input-group-append {\n    margin-left: -1px;\n}\n\nbody .btn {\n    font-weight: $btn-font-weight;\n}\n\n.btn-block {\n    width: 100%;\n}\n\n.input-group-sm > .input-group-prepend > .input-group-text {\n    font-size: 0.875rem;\n}\n\n.input-group-prepend .input-group-text {\n    color: $gray-700;\n}\n\n.input-group-sm > .input-group-append > .btn {\n    font-size: 87.5%;\n    padding: 0.25rem 0.5rem;\n}\n\n.input-group > .input-group-append > .btn {\n    border-top-left-radius: 0;\n    border-bottom-left-radius: 0;\n}\n\nbody .input-group-sm > .form-select {\n    padding-right: 1.75rem;\n}\n\nbody .input-group-sm > .form-select,\nbody .input-group > .form-select-sm {\n    padding-top: 0.25rem;\n    padding-bottom: 0.25rem;\n    padding-left: 0.5rem;\n    font-size: 0.875rem;\n}\n\nbutton.btn-close-blue {\n    background: transparent\n        url(\"data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%230076ad'%3e%3cpath d='M.293.293a1 1 0 011.414 0L8 6.586 14.293.293a1 1 0 111.414 1.414L9.414 8l6.293 6.293a1 1 0 01-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 01-1.414-1.414L6.586 8 .293 1.707a1 1 0 010-1.414z'/%3e%3c/svg%3e\")\n        center/1em auto no-repeat;\n    opacity: 1;\n}\n\nbody .btn-link,\nli .nav-link {\n    color: $blue;\n}\n\nbody .btn-link:hover,\nbody .nav-link:hover {\n    color: #004261;\n}\n\nbody .btn-primary:hover {\n    background-color: #004261;\n}\n\nbody .btn:disabled,\nbody .btn-primary.disabled,\nbody .btn-primary {\n    border-color: $blue;\n}\n\nbody .btn-primary.disabled {\n    background-color: $blue;\n}\n\nbody .btn-primary:disabled,\nbody .btn-primary {\n    background-color: $blue;\n    color: #fff;\n}\n\nbody .btn-outline-primary:disabled {\n    background-color: transparent;\n    color: $blue;\n    opacity: 0.2;\n}\n\na.btn-info:hover {\n    background-color: #005c87;\n    border-color: #005c87;\n    color: $white;\n}\n\nbutton.btn-primary:hover {\n    background-color: #005c87;\n}\n\n.btn {\n    &.btn-danger {\n        background-color: $red;\n        color: #fff;\n    }\n\n    &.btn-info {\n        background-color: $blue;\n        border-color: $blue;\n        color: #fff;\n    }\n\n    &.btn-warning {\n        background-color: $gray-700;\n        border-color: $gray-700;\n    }\n}\n\na.btn-dark {\n    color: #fff;\n}\n\na.btn-outline-dark {\n    color: $gray-800;\n\n    .transcription-status-key-lg {\n        border: 1px solid $white;\n    }\n}\n\na.btn-outline-dark.active {\n    color: #fff;\n}\n\na.btn-outline-dark:hover {\n    color: #fff;\n}\n\n.btn-light {\n    color: $gray-800;\n}\n\nbody .form-check-input {\n    border-color: $gray-800;\n}\n\n.form-check .form-check-input:disabled ~ .form-check-label,\n.form-check-input[disabled] ~ .form-check-label {\n    opacity: 1;\n}\n\n#invert-form div {\n    margin-left: 10px;\n    margin-right: 0;\n}\n\n#invert:checked {\n    background-color: $blue;\n    border-color: $blue;\n}\n\n.flex-1 {\n    flex: 1;\n}\n\na .campaign-title:hover,\n.hero-text a:hover {\n    color: $dark;\n    text-decoration: underline;\n}\n\np.hero-secondary {\n    font-family: $font-family-sans-serif;\n    font-size: 1.125rem;\n    color: $dark;\n}\n\np.hero-secondary.offwhite-text {\n    font-family: $font-family-sans-serif;\n    font-size: 1.125rem;\n    color: #fff;\n}\n\nbody .text-primary {\n    color: $blue !important;\n}\n\nbody .bg-primary {\n    background-color: $blue !important;\n}\n\n.row .bg-completed {\n    background-color: $bg-completed;\n}\n\n.row .bg-submitted {\n    background-color: $bg-submitted;\n    border: 1px solid $dark;\n}\n\n/* stylelint-disable-next-line selector-class-pattern */\n.row .bg-in_progress,\n.row .bg-in-progress {\n    background-color: $bg-in-progress;\n}\n\n/* stylelint-disable-next-line selector-class-pattern */\n.row .bg-not_started,\n.row .bg-not-started {\n    background-color: $bg-not-started;\n    background-image: repeating-linear-gradient(\n        45deg,\n        #242424 0,\n        #242424 1px,\n        #fff 0,\n        #fff 50%\n    );\n    background-size: 10px 10px;\n    border: 1px solid $dark;\n    opacity: 0.8;\n}\n\n.completed-bar {\n    background-color: $navy;\n    color: $white;\n}\n\n.completed-text {\n    padding: 0.3em;\n}\n\n.retired-bar {\n    background-color: $light;\n    color: $blue;\n    border: 2px solid $blue;\n}\n\n.retired-text {\n    padding: 0.3em;\n}\n\ninput {\n    color: $dark;\n    background-color: #fff;\n}\n\nhr {\n    color: $secondary;\n}\n\n.row .campaign-thumbnail {\n    flex: 0 0 auto;\n\n    @include media-breakpoint-up(lg) {\n        width: 16.6667%;\n    }\n\n    @include media-breakpoint-down(lg) {\n        width: 25%;\n    }\n\n    @include media-breakpoint-down(md) {\n        width: 33.3333%;\n    }\n}\n\n.row .campaign-text {\n    flex: 0 0 auto;\n\n    @include media-breakpoint-up(lg) {\n        width: 83.3333%;\n    }\n\n    @include media-breakpoint-down(lg) {\n        width: 75%;\n    }\n\n    @include media-breakpoint-down(md) {\n        width: 66.6667%;\n    }\n}\n\n.navbar-brand {\n    @include media-breakpoint-down(lg) {\n        max-width: 350px;\n    }\n\n    @include media-breakpoint-down(sm) {\n        max-width: 250px;\n    }\n}\n\nul.nav-secondary {\n    margin: 0 -10px;\n\n    .nav-link {\n        padding: 0 10px;\n    }\n\n    li {\n        line-height: 1;\n\n        &:first-of-type {\n            border-right: 1px solid $border-color;\n        }\n    }\n}\n\n.flex-initial {\n    flex: initial !important;\n}\n\n#transcription-input-container {\n    width: 99% !important;\n}\n\n/*\n * vertical and horizontal dividers\n */\n\n.navbar-brand .vl {\n    height: 6rem;\n    background-color: $secondary;\n    width: 0.0675rem;\n}\n\n.border-login-register {\n    border-left: solid 0.0675rem $dark;\n}\n\n.border-left-home-contribute {\n    border-left: solid 0.0675rem $gray-100;\n}\n\n.navbar-nav {\n    margin-right: -0.5rem;\n\n    @include media-breakpoint-down(lg) {\n        margin: 0;\n        padding: 0.5rem 1rem;\n        background-color: $light;\n    }\n}\n\n.navbar-nav .nav-link {\n    text-align: left;\n    padding-top: 5px;\n    padding-bottom: 5px;\n\n    @include media-breakpoint-up(lg) {\n        padding-top: 0;\n        padding-bottom: 0;\n    }\n\n    &.active {\n        font-weight: bold;\n    }\n}\n\n.navbar-light .navbar-nav .nav-link {\n    color: $gray-800;\n}\n\n.navbar-light .navbar-nav #topnav-account-dropdown-toggle {\n    color: $blue;\n    cursor: pointer;\n}\n\n.logo-loc {\n    border-right: 1px solid $border-color;\n    margin: 0 15px 0 0 !important;\n    padding-right: 15px;\n}\n\n.landing-divider {\n    margin: 0.25em;\n}\n\n/* Forms */\n\n.form-group-required label {\n    font-weight: bold;\n}\n\n/* Cards */\n\n$card-img-height: 14.8rem;\n$card-btn-height: 31px;\n$card-progress-height: 12px;\n\n.card-header {\n    min-height: 65px;\n}\n\n.card-body {\n    padding: 1rem 0.5rem;\n}\n\n.card-img-campaign {\n    object-position: center top;\n    object-fit: cover;\n    height: $card-img-height;\n}\n\n.card-img-overlay {\n    left: 0;\n    right: 0;\n    bottom: 0;\n    margin-bottom: 3.0675rem;\n    padding: 0.5rem 1.25rem;\n}\n\n.card-img-overlay.img-campaign {\n    padding: 0;\n}\n\n.card-title {\n    margin-bottom: 0.5rem;\n}\n\na.card-title {\n    color: $blue;\n}\n\na.card-title.text-dark {\n    color: $gray-800;\n}\n\n.img-project {\n    min-height: 13.125rem;\n}\n\n.round-corners-bottom {\n    border-radius: 0 0 0.5rem 0.5rem;\n}\n\n.img-fluid.rounded-circle {\n    max-height: 15rem;\n    width: auto;\n}\n\n.shadow-regular {\n    box-shadow: 0 0 0.25rem $shadow-color;\n}\n\n/*\n * Common navigational elements\n */\n\n.breadcrumb-wrapper {\n    font-size: 13px;\n}\n\n.breadcrumb-wrapper .breadcrumb {\n    margin-bottom: 0;\n    padding: 7px 0;\n}\n\n.breadcrumb-item {\n    max-width: 20em;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n    overflow: hidden;\n}\n\nli.breadcrumb-item.active {\n    color: $gray-800;\n}\n\n.breadcrumb .breadcrumb-item + .breadcrumb-item {\n    padding-left: 0.25rem;\n}\n\n.breadcrumb .breadcrumb-item + .breadcrumb-item::before {\n    padding-right: 0.25rem;\n}\n\n.section-link {\n    text-decoration: underline;\n    padding-top: 1em;\n    padding-bottom: 0.25em;\n}\n\n.underline-link {\n    text-decoration: underline;\n}\n\n.underline-link:hover {\n    color: $blue;\n}\n\n/*\n * List-like displays for items and assets\n */\n\n.concordia-object-card-row {\n    margin: 0 -6px;\n}\n\n.row .concordia-object-card-container {\n    width: 90%;\n}\n\n.concordia-object-card-container {\n    display: flex;\n    flex-wrap: wrap;\n    padding-top: 0.25em;\n}\n\n.concordia-object-card-col {\n    padding: 6px;\n\n    @include media-breakpoint-up(lg) {\n        flex: 0 0 25%;\n        max-width: 25%;\n    }\n}\n\n.concordia-object-card {\n    background-color: $gray-100;\n    overflow: hidden;\n}\n\n.concordia-object-card-title {\n    padding: 12px 10px;\n}\n\n.concordia-object-card .card-title,\n.concordia-object-card .card-actions {\n    position: absolute;\n    left: 0;\n    right: 0;\n}\n\n.concordia-object-card[data-transcription-status='completed']:not(:hover)\n    .card-img {\n    opacity: 0.4;\n}\n\n.concordia-object-card .card-img {\n    transition: 0.3s ease-in-out;\n    border-bottom-right-radius: 0;\n    border-bottom-left-radius: 0;\n}\n\n.concordia-object-card:hover .card-img,\n.concordia-object-card:focus .card-img {\n    transform: scale(1.05);\n}\n\n.concordia-object-card .card-title {\n    bottom: 0;\n    padding: 2px;\n    margin-bottom: 0;\n    font-weight: bold;\n    font-size: 0.875rem; // add this line\n    position: static; // add this line\n}\n\n.concordia-object-card .card-actions {\n    top: calc(\n        #{$card-img-height} - #{$card-btn-height - $card-progress-height}\n    );\n    z-index: 3;\n\n    /* stylelint-disable selector-class-pattern */\n    .view-transcriptions--item-detail &,\n    .view-transcriptions--filtered-item-detail & {\n        top: 0;\n    }\n    /* stylelint-enable selector-class-pattern */\n}\n\n.concordia-object-card .card-actions .btn {\n    height: $card-btn-height;\n    border-radius: 0;\n}\n\n.concordia-object-card .card-actions .btn-default:not(:hover) {\n    background-color: $shadow-color; // add this line\n    border-color: $shadow-color; // add this line\n    color: #fff; // add this line\n}\n\n.concordia-object-card .card-img-container {\n    display: block;\n    height: 100%;\n    overflow: hidden;\n    width: 100%;\n}\n\n.card.concordia-object-card .progress {\n    height: $card-progress-height;\n    border-radius: 0;\n    position: relative;\n    z-index: 2;\n}\n\n.progress {\n    background-color: #e5f7ff;\n\n    &::after {\n        content: '';\n        background-color: #fff;\n        background-size: 10px 10px;\n        flex: 1 1 0%;\n        opacity: 0.8;\n    }\n\n    &.campaign-progress::after,\n    &.w-100::after {\n        background-image: repeating-linear-gradient(\n            45deg,\n            #242424 0,\n            #242424 1px,\n            #fff 0,\n            #fff 50%\n        );\n        border: 1px solid $dark;\n    }\n}\n\nbody .campaign-page-progress {\n    height: 2.5rem;\n}\n\n.campaign-page-progress .progress-bar:last-child {\n    border-top-right-radius: 0.375rem;\n    border-bottom-right-radius: 0.375rem;\n}\n\ndiv.campaign-progress {\n    height: 35px;\n    margin-top: 20px;\n}\n\n#campaign-list li h3 {\n    margin-left: -3.5px;\n}\n\n.page-link:hover {\n    text-decoration: none;\n}\n\na.page-link {\n    color: $blue;\n}\n\nbody .disabled > .page-link {\n    background-color: #fff;\n}\n\n.page-item.disabled .page-link {\n    color: black;\n}\n\n.page-item.active .page-link {\n    background-color: #fff;\n    border-color: #efefef;\n    color: $gray-800;\n}\n\n/*\n * Progress displays\n */\n\n#progress-bar {\n    height: 1rem;\n    border-radius: 0;\n}\n\n.progress-bar-label {\n    font-size: 14px;\n    margin-top: 5px;\n    margin-right: 5px;\n}\n\n.progress-bar-label span:nth-child(2) {\n    margin-left: 5px;\n}\n\n#progress-stats {\n    font-size: smaller;\n}\n\n#progress-stats th {\n    font-size: inherit;\n    font-weight: inherit;\n}\n\n.progress-bar-labels li {\n    float: left;\n}\n\n.progress-bar-labels li + li::before {\n    content: '|';\n    padding: 0 0.5em;\n}\n\n.transcription-status-key-lg {\n    display: inline-block;\n    height: 1em;\n    width: 1em;\n    vertical-align: top;\n    margin: 0.25rem !important;\n}\n\n.transcription-status-key {\n    display: inline-block;\n    height: 0.6em;\n    width: 0.6em;\n    vertical-align: baseline;\n}\n\n#transcription-status-message {\n    max-width: 270px;\n}\n\n// Campaign small blocks\n\n.small-campaign-title {\n    min-height: 6ex;\n}\n\n.small-campaign-description {\n    display: -webkit-box;\n    overflow: hidden;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: 4;\n}\n\n/*\n * Homepage customizations\n */\n\n.play-pause-button {\n    position: absolute;\n    top: 5px;\n    right: 20px;\n    height: 40px;\n    width: 40px;\n    z-index: 2;\n\n    i {\n        color: $white;\n    }\n}\n\n.carousel-item {\n    img {\n        background-color: $light;\n    }\n}\n\n.carousel-overlay {\n    padding: 10px 3rem 1rem;\n\n    @include media-breakpoint-up(md) {\n        padding: 25px;\n        background-color: white;\n        position: absolute;\n        top: 50px;\n        left: 100px;\n        width: 420px;\n\n        [data-overlay-position='top-right'] & {\n            left: auto;\n            right: 100px;\n        }\n    }\n\n    @include media-breakpoint-down(md) {\n        position: absolute;\n        top: 19px;\n    }\n\n    .title {\n        font-size: 2.25rem;\n    }\n}\n\n.carousel {\n    .carousel-control-next-icon {\n        background-image: url('/static/img/slick-right-arrow.svg');\n    }\n\n    .carousel-control-prev-icon {\n        background-image: url('/static/img/slick-left-arrow.svg');\n    }\n\n    .carousel-control-next-icon,\n    .carousel-control-prev-icon {\n        height: $carousel-control-icon-height;\n        width: $carousel-control-icon-width;\n    }\n\n    .carousel-indicators {\n        > button {\n            border-radius: 50%;\n            border: 1px solid $blue;\n            height: 12px;\n            opacity: 1;\n            width: 12px;\n\n            &.active {\n                background-color: $blue;\n                border-color: $blue;\n            }\n        }\n\n        left: 50%;\n        width: 60%;\n        margin-left: -30%;\n        text-align: center;\n    }\n}\n\n#card-carousel .carousel-indicators > button {\n    border: 1px solid $gray-800;\n    border-radius: 100%;\n    height: 14px;\n    width: 14px;\n}\n\n#previous-guide,\n#next-guide {\n    cursor: pointer;\n}\n\n#campaign-options {\n    margin-left: 1px;\n\n    label {\n        font-size: 14px;\n    }\n\n    select {\n        background-color: $white;\n        border-color: $blue;\n        font-size: 14px;\n        height: 2.155rem;\n        padding: 0.3rem;\n    }\n\n    .btn {\n        border-radius: 0;\n        font-size: 14px;\n        height: 2.155rem;\n    }\n}\n\n.aspect-ratio-box {\n    height: 0;\n    overflow: hidden;\n    /* stylelint-disable-next-line scss/no-global-function-names */\n    padding-top: percentage(math.div(9, 16));\n    position: relative;\n\n    @include media-breakpoint-up(sm) {\n        /* stylelint-disable-next-line scss/no-global-function-names */\n        padding-top: percentage(math.div(240, 320));\n    }\n}\n\n.list-view li {\n    padding-top: 24px;\n}\n\n.list-view .aspect-ratio-box {\n    height: 150px;\n    padding-top: 0;\n    width: 150px;\n}\n\n.aspect-ratio-box-inner-wrapper {\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n\n    img {\n        height: 100%;\n        width: 100%;\n        object-fit: cover;\n    }\n}\n\n#banner-inner {\n    margin-right: 0.75rem;\n}\n\n#no-interface-banner {\n    margin-left: 0.75rem;\n}\n\n#homepage-contribute-activities {\n    img {\n        @include media-breakpoint-down(sm) {\n            max-width: 100px;\n        }\n    }\n}\n\n#homepage-carousel {\n    @include media-breakpoint-up(xl) {\n        max-width: 1140px;\n    }\n}\n\n#homepage-next-transcribable-links {\n    position: relative;\n\n    &::before {\n        content: '';\n        width: calc(100% - 2rem);\n        position: absolute;\n        border-top: 1px solid #000;\n\n        @include media-breakpoint-up(md) {\n            width: 725px;\n            left: 50%;\n            -webkit-transform: translateX(-50%);\n            transform: translateX(-50%);\n        }\n    }\n}\n\n/*\n * Asset detail page\n *\n * This page wants to display as much as possible and so the entire page is a flex column\n */\n\n#contribute-main-content {\n    overflow: hidden;\n    position: relative;\n}\n\n#contribute-main-content h2 {\n    font-size: inherit;\n}\n\n#asset-navigation {\n    margin: 0 -0.25rem;\n    padding-top: 3px;\n    padding-bottom: 3px;\n}\n\n#contribute-container {\n    @include media-breakpoint-up(md) {\n        height: calc(100vh - 53px);\n    }\n}\n\n@include media-breakpoint-down(sm) {\n    #contribute-container {\n        flex-direction: column !important;\n    }\n\n    #asset-image {\n        height: 50vh !important;\n    }\n\n    #transcription-editor textarea {\n        height: 30vh;\n    }\n\n    .gutter.gutter-horizontal {\n        display: none !important;\n    }\n\n    .gutter.gutter-vertical {\n        display: none !important;\n    }\n}\n\n#viewer-controls .btn-dark {\n    &:hover,\n    &:focus {\n        border-color: $light;\n    }\n}\n\n.gutter.gutter-horizontal {\n    display: block;\n    position: relative;\n    cursor: ew-resize;\n    background-color: $gray-400;\n}\n\n.gutter.gutter-horizontal::after {\n    display: block;\n    position: absolute;\n    top: calc(50% - 40px);\n    left: -4px;\n    content: '';\n    height: 40px;\n    width: 16px;\n    background: url('../img/handle.svg') no-repeat;\n}\n\n.gutter.gutter-vertical {\n    display: block;\n    position: relative;\n    cursor: ns-resize;\n    background-color: $gray-400;\n    width: 100%;\n}\n\n.gutter.gutter-vertical::after {\n    display: block;\n    position: absolute;\n    top: -4px;\n    left: calc(50% - 60px);\n    content: '';\n    height: 16px;\n    width: 60px;\n    background: url('../img/handle-vertical.svg') no-repeat;\n}\n\n#transcription-editor textarea {\n    resize: none;\n}\n\na.btn-outline-primary,\nbutton.btn-outline-primary {\n    border-color: $blue;\n    color: $blue;\n}\n\na.btn-outline-primary:hover,\nbutton.btn-outline-primary:hover {\n    background-color: $blue;\n    border-color: $blue;\n}\n\n.btn-outline-primary.disabled,\n.btn-outline-primary:disabled {\n    opacity: 0.2;\n}\n\n/* Help Center */\n\n.help-center-card {\n    color: #fff;\n    height: 225px;\n    background-color: $accent;\n    margin-bottom: 1rem;\n}\n\n.help-center-card a {\n    color: #fff;\n}\n\n.help-center .nav-link {\n    padding: 0.5rem;\n}\n\n.help-center a.nav-link {\n    color: #0076ad;\n    font-weight: bold;\n}\n\n.help-center a.nav-link.active {\n    color: #242424;\n}\n\n/*\n * Image filter controls\n*/\n\n#image-filters .btn-dark {\n    color: $light;\n    text-decoration: underline;\n    border-radius: 0;\n\n    &:hover,\n    &:focus,\n    &.active {\n        color: $dark;\n        background-color: $light;\n        text-decoration: none;\n        font-weight: 400;\n        border-color: $light;\n        outline: none;\n        box-shadow: none;\n    }\n}\n\n#filter-tabs {\n    background-color: $light;\n    height: 3em;\n\n    & .custom-control-label::before {\n        height: 1.5rem;\n        width: 2.75rem;\n        border-radius: 4rem;\n    }\n\n    & .custom-control-label::after {\n        width: calc(1.5rem - 4px);\n        height: calc(1.5rem - 4px);\n        border-radius: 1.25rem;\n    }\n\n    & .custom-control-input:checked ~ .custom-control-label::after {\n        transform: translateX(1.25rem);\n    }\n\n    & .custom-control-input ~ .custom-control-label::before {\n        border-color: $dark !important;\n    }\n\n    & .custom-control-input ~ .custom-control-label::after {\n        background-color: $white !important;\n        border: solid 1px $dark !important;\n        left: calc(-2.25rem + 4px);\n    }\n\n    & .custom-control-input:focus ~ .custom-control-label::before {\n        border-color: $dark !important;\n    }\n\n    & .custom-control-input:checked ~ .custom-control-label::before {\n        border-color: $dark !important;\n        background-color: $dark !important;\n    }\n\n    & .custom-control-input:active ~ .custom-control-label::before {\n        background-color: $dark !important;\n        border-color: $dark !important;\n    }\n\n    &\n        .custom-control-input:focus:not(:checked)\n        ~ .custom-control-label::before {\n        border-color: $dark !important;\n    }\n}\n\n#viewer-reset {\n    background-color: $primary;\n    border-color: $primary;\n    color: $white;\n    border-radius: 0;\n\n    &.btn {\n        border-radius: $btn-border-radius;\n    }\n}\n\n.filter-slider {\n    appearance: none;\n    border: solid 1px $dark;\n    height: 0.5em;\n    background: $white;\n}\n\n.filter-slider::-webkit-slider-thumb {\n    -webkit-appearance: none;\n    appearance: none;\n    width: 1.1em;\n    height: 1.5em;\n    background: $primary;\n    cursor: pointer;\n    border-radius: 20%;\n}\n\n.filter-slider::-moz-range-thumb {\n    appearance: none;\n    width: 1.1em;\n    height: 1.5em;\n    background: $primary;\n    cursor: pointer;\n    border-radius: 20%;\n}\n\n.number-input {\n    border: solid 1px $primary;\n    background-color: $white;\n\n    & input[type='number'] {\n        border: none;\n        appearance: textfield;\n        -webkit-appearance: textfield;\n        -moz-appearance: textfield;\n        height: 100%;\n        width: 100%;\n    }\n\n    & input[type='number']::-webkit-inner-spin-button,\n    input[type='number']::-webkit-outer-spin-button {\n        -webkit-appearance: none;\n    }\n\n    & input[type='number']:focus {\n        outline: none;\n    }\n\n    & .arrow-button {\n        background-color: $primary;\n        color: $white;\n        font-size: 0.8em;\n        width: 100%;\n        border: none;\n        padding: 0;\n    }\n}\n\n.row > .filter-buttons {\n    width: 1.8rem;\n}\n\n#ocr-transcription-link {\n    pointer-events: auto;\n}\n\n#ocr-transcription-link.disabled {\n    cursor: default;\n}\n\n#ocr-transcription-modal .modal-dialog {\n    max-width: 419px;\n}\n\n#language-selection-modal .modal-header a,\n#ocr-transcription-modal .modal-header a {\n    cursor: pointer;\n    font-size: 1.5rem;\n}\n\n#language-selection-modal {\n    display: none;\n    margin-left: 40px;\n}\n\n#language-selection-modal .modal-dialog {\n    max-width: 428px;\n}\n\n#language-selection-modal .modal-footer {\n    justify-content: center;\n}\n\n/*\n * Tag input on the asset detail page\n */\n#tag-label {\n    [data-toggle='collapse'][aria-expanded='true'] .fas::before {\n        content: '\\f146';\n    }\n\n    [data-toggle='collapse'].collapsed .fas::before {\n        content: '\\f0fe';\n    }\n}\n\n#current-tags {\n    margin: 5px -2px 0;\n\n    > li {\n        margin: 2px;\n\n        > button {\n            color: inherit;\n            font-size: inherit;\n            padding: 0;\n            margin: 0 0 0 5px;\n            float: none;\n            line-height: inherit;\n            opacity: 1;\n            text-shadow: none;\n        }\n    }\n}\n\n/*\n * Tutorial popup and cards navigation\n */\n#tutorial-popup .modal-header {\n    padding-bottom: 0.25rem;\n}\n\n#tutorial-popup .modal-body {\n    padding-top: 0.25rem;\n}\n\n#close-tutorial a {\n    position: absolute;\n    right: 1rem;\n    top: 0.375rem;\n    color: $blue;\n    cursor: pointer;\n    font-size: 1.5rem;\n    font-weight: 700;\n}\n\n#card-carousel .carousel-item img {\n    background-color: #fff;\n    border-top: 1px solid #efefef;\n    padding-bottom: 1rem;\n}\n\n#card-carousel .carousel-item h5 {\n    margin-bottom: 0;\n}\n\n#card-carousel .carousel-item p {\n    margin-bottom: 0.75rem;\n}\n\n#card-carousel ul {\n    padding-left: 1.5rem;\n}\n\n#card-carousel .carousel-indicators .active {\n    background-color: $blue;\n    border-color: $blue;\n}\n\n#previous-card {\n    position: absolute;\n    bottom: 10px;\n    left: 0;\n}\n\n#next-card {\n    position: absolute;\n    bottom: 10px;\n    right: 0;\n}\n\n/* How to Guide */\n@media (width >= 965px) {\n    #instruction-buttons {\n        justify-content: flex-end;\n    }\n}\n\n#open-guide {\n    border-radius: $btn-border-radius;\n    margin: 0 5px 0 9px;\n    max-width: 137px;\n    white-space: nowrap;\n}\n\n#close-guide {\n    position: absolute;\n    right: -8px;\n    color: #fff;\n    cursor: pointer;\n    font-size: 0.875rem;\n    height: 30.5px;\n    margin: 0 1rem -1rem auto;\n}\n\n.sidebar {\n    max-height: calc(100vh - 107px);\n    width: 450px;\n    position: absolute; /* Stay in place */\n    z-index: 1; /* Stay on top */\n    right: 0;\n    overflow: hidden auto; /* Disable horizontal scrolling */\n    transition: 0.3s;\n    background-color: $white;\n}\n\n.sidebar.offscreen {\n    transform: translateX(100%);\n}\n\n.sidebar h3 {\n    padding-top: 0.5rem;\n}\n\n.sidebar li {\n    border-bottom: thin solid $gray-400;\n}\n\n.sidebar .nav-item a {\n    font-size: $font-size-base * 1.25;\n    text-decoration: underline;\n}\n\n.guide-body {\n    max-height: calc(100vh - 249px);\n    overflow-x: hidden;\n}\n\ndiv.row.guide-header {\n    justify-content: center;\n    margin-right: -4px;\n    margin-bottom: 1rem;\n    padding-bottom: 0.25rem;\n    padding-top: 0.25rem;\n}\n\n.guide-body h3 {\n    font-size: 1rem;\n    font-weight: bold;\n}\n\n#title-bar {\n    font-weight: 700;\n}\n\n.toc-title {\n    font-weight: 600;\n}\n\n#guide-bars {\n    color: #fff;\n    padding-top: 0.25rem;\n}\n\n.sidebar .close {\n    font-size: $font-size-base * 0.875;\n}\n\n#guide-carousel .container {\n    padding-right: 0;\n}\n\n/*\n * Campaign Helpful Links Panel\n */\n\ndiv.related-links {\n    background-color: #ddd;\n    border: #000 1px solid;\n}\n\n.list-inline .list-inline-item:not(:last-child) {\n    margin-right: $list-inline-padding;\n}\n\n.related-links .list-group-item {\n    background-color: #ddd;\n    border: 0;\n    padding-left: unset;\n}\n\n/*\n* Footer\n*/\n\n.footer {\n    background-color: $gray-100;\n    margin-top: 1.5rem;\n\n    .footer-links {\n        border-top: 1px solid $border-color;\n        border-bottom: 1px solid $border-color;\n        padding-top: 1rem;\n        padding-bottom: 1rem;\n        margin-top: 1rem;\n        margin-bottom: 1rem;\n\n        @include media-breakpoint-up(lg) {\n            border-top: none;\n            border-bottom: none;\n            border-left: 1px solid $border-color;\n            border-right: 1px solid $border-color;\n            padding-top: 0;\n            padding-bottom: 0;\n            margin-top: 0;\n            margin-bottom: 0;\n\n            > ul > li {\n                display: block;\n            }\n        }\n    }\n\n    .intersites-link-congress a {\n        display: block;\n        width: 121px;\n        height: 24px;\n        background: url('../img/congress-gov.svg') no-repeat;\n        background-size: 121px 24px;\n    }\n\n    .intersites-link-copyright a {\n        display: block;\n        width: 176px;\n        height: 24px;\n        background: url('../img/copyright-gov.svg') no-repeat;\n        background-size: 176px 24px;\n    }\n}\n\n.bitmap-icon {\n    display: inline-block;\n    width: 24px;\n    height: 24px;\n    vertical-align: text-top;\n    background-image: url('../img/social-icons-sprite.png');\n    background-position: -200px 0;\n    background-repeat: no-repeat;\n\n    &.github-icon {\n        background-position: 0 0;\n    }\n\n    &.twitter-icon {\n        background-position: -40px 0;\n    }\n\n    &.email-icon {\n        background-position: -80px 0;\n    }\n\n    &.facebook-icon {\n        background-position: -40px -36px;\n    }\n\n    &.copy-link-icon {\n        background-position: -80px -36px;\n    }\n}\n\n/* Registration page */\n#registration-form-container > .col-md-6 {\n    max-width: 30rem;\n}\n\n/* This is here to fix\n    https://github.com/LibraryOfCongress/concordia/issues/1127\n    until https://github.com/twbs/bootstrap/issues/29439 is fixed */\n\n#registration-form-container .invalid-feedback {\n    display: block;\n}\n\n/* Profile page */\n.contribution-highlight {\n    background-color: #f6f6f6;\n    border: 1px solid black;\n    flex: 1;\n    margin: 0.5rem;\n    padding-bottom: 10px;\n    padding-top: 10px;\n    text-align: center;\n}\n\n.contribution-highlight .value {\n    color: $blue;\n    font-size: xxx-large;\n    font-weight: bold;\n}\n\n.contribution-highlight .label {\n    font-size: large;\n    font-weight: bold;\n    margin-bottom: 0;\n}\n\n.contribution-table a {\n    text-decoration: underline;\n}\n\n.recent-page a {\n    text-decoration: underline;\n}\n\n.all-campaigns {\n    text-decoration: underline;\n}\n\n/* stylelint-disable-next-line selector-class-pattern */\ninput.duet-date__input {\n    border-radius: 0;\n}\n\ntable.table thead.border-y {\n    th,\n    td {\n        border-bottom: 1px solid #000;\n        border-top: 1px solid #000;\n    }\n}\n\n#current-filters {\n    margin-bottom: 0.75rem;\n    padding: 0.375rem 0;\n}\n\n#current-filters ul {\n    padding-left: 0.5rem;\n}\n\n#current-filters .btn {\n    border-color: $blue;\n    color: $blue;\n}\n\n#current-filters li {\n    background-color: $white;\n}\n\n#current-filters label {\n    padding-left: 5px;\n    padding-top: 1px;\n}\n\n.btn-xs {\n    font-size: 12px;\n    padding: 0 0.12rem;\n}\n\n.btn-xs .btn {\n    font-size: 14px;\n    padding: 0 0.12rem;\n}\n\n.btn-group-sm > .btn {\n    border-radius: $btn-border-radius;\n}\n\n#contact-us {\n    border-top-left-radius: $btn-border-radius;\n    border-bottom-left-radius: $btn-border-radius;\n}\n\n#current-filters li .btn {\n    border: none;\n    padding-right: 4px;\n}\n\n/* stylelint-disable-next-line selector-id-pattern */\n#nav-tabContent .dropdown-menu a:hover {\n    background-color: $navy;\n    color: $light;\n}\n\n.dropdown-menu .dropdown-item:active {\n    background-color: $blue;\n}\n\n.change-options {\n    background-color: $gray-200;\n}\n\n.tab-pane th {\n    cursor: pointer;\n}\n\n.tab-pane th.date-header {\n    cursor: default;\n}\n\n.user-fields {\n    margin-bottom: ($spacer * 1.5) !important;\n    max-width: 450px;\n}\n\n#validation-confirmation {\n    font-size: 87.5%;\n}\n\n/* print */\n@media print {\n    @page {\n        margin: 0.75in;\n    }\n\n    body {\n        border-style: none;\n        background-color: transparent;\n        font-size: 14pt;\n    }\n\n    header .navbar {\n        padding: 0;\n        background-color: transparent;\n    }\n\n    #navigation-container {\n        border-style: none;\n        padding: 0;\n    }\n\n    #contribute-container {\n        border-style: none;\n    }\n\n    .print-transcription-image {\n        page-break-before: always;\n    }\n\n    #contribute-container .gutter-horizontal {\n        display: none;\n    }\n\n    #contribute-container .gutter-vertical {\n        display: none;\n    }\n\n    #contribute-container #editor-column .tx-status-display {\n        font-size: 24px;\n        margin: 2rem 0;\n    }\n\n    #contribute-container #editor-column .print-transcription-text {\n        display: block !important;\n        white-space: pre-wrap;\n        color: #000;\n    }\n\n    #current-tags {\n        max-height: none;\n    }\n\n    #current-tags li {\n        background-color: transparent;\n        color: #000;\n        display: inline-block;\n    }\n}\n\n/* Profile page */\n.view-user-profile .nav-tabs,\n.view-email-reconfirmation .nav-tabs {\n    border-bottom: 1px solid #000;\n}\n\n.view-user-profile .nav-tabs .nav-link.active,\n.view-user-profile .nav-tabs .nav-item.show .nav-link,\n.view-email-reconfirmation .nav-tabs .nav-link.active,\n.view-email-reconfirmation .nav-tabs .nav-item.show .nav-link {\n    border-color: #000 #000 #fff;\n}\n\n/* Accessibility */\n.visually-hidden {\n    position: absolute !important;\n    width: 1px !important;\n    height: 1px !important;\n    padding: 0 !important;\n    margin: -1px !important;\n    overflow: hidden !important;\n    clip: rect(0, 0, 0, 0) !important;\n    white-space: nowrap !important;\n    border: 0 !important;\n}\n\n/* Error pages */\nfigure.error-figure {\n    img {\n        max-width: 75%;\n    }\n\n    figcaption {\n        padding-top: 5px;\n        font-size: 12px;\n    }\n}\n\n/*\n * About page\n */\n\n#blog-carousel .carousel-control-prev {\n    top: 2rem;\n    left: 9rem;\n    opacity: 1;\n}\n\n#blog-carousel .carousel-control-next {\n    top: 2rem;\n    right: 7rem;\n    opacity: 1;\n}\n\n#blog-carousel .carousel-control-icon {\n    background-color: $blue;\n    -webkit-mask-repeat: no-repeat;\n    mask-repeat: no-repeat;\n    mask-size: 25% 50%;\n    height: 3.5rem;\n    width: 3.5rem;\n}\n\n#blog-carousel .carousel-control-icon.prev {\n    -webkit-mask-image: url('/static/img/slick-left-arrow.svg');\n    mask-image: url('/static/img/slick-left-arrow.svg');\n}\n\n#blog-carousel .carousel-control-icon.next {\n    -webkit-mask-image: url('/static/img/slick-right-arrow.svg');\n    mask-image: url('/static/img/slick-right-arrow.svg');\n}\n\n.blog-chunk {\n    width: 684px;\n}\n\n.blog-chunk a {\n    text-decoration: none;\n}\n\n.blog-chunk h5 {\n    min-height: 115.2px;\n}\n\n#blog-carousel .card {\n    border-width: 0;\n}\n\n.about-accordion {\n    background-color: $gray-100;\n    padding-top: 12px;\n    padding-left: 28px;\n    padding-bottom: 8px;\n    margin-top: 5px;\n    margin-bottom: 5px;\n}\n\n.accordion-icon {\n    color: $blue;\n    font-style: normal;\n}\n\n.icon-plus-square::before {\n    content: '\\f0fe';\n}\n\n.icon-minus-square::before {\n    content: '\\f146';\n}\n\n.blog-content,\n.press-content,\n.publications-content,\n.program-history {\n    display: none;\n}\n\n.visualization-container {\n    display: flex-basis;\n    width: 100%;\n}\n\n.visualization-container section {\n    width: 100%;\n}\n\n.visualization-data-link {\n    display: block;\n    margin-top: 0.25rem;\n    font-size: 0.5rem;\n}\n"
  },
  {
    "path": "concordia/static/vendor/jquery.cookie.js",
    "content": "/*!\n * jQuery Cookie Plugin v1.4.1\n * https://github.com/carhartl/jquery-cookie\n *\n * Copyright 2013 Klaus Hartl\n * Released under the MIT license\n */\n(function (factory) {\n\tif (typeof define === 'function' && define.amd) {\n\t\t// AMD\n\t\tdefine(['jquery'], factory);\n\t} else if (typeof exports === 'object') {\n\t\t// CommonJS\n\t\tfactory(require('jquery'));\n\t} else {\n\t\t// Browser globals\n\t\tfactory(jQuery);\n\t}\n}(function ($) {\n\n\tvar pluses = /\\+/g;\n\n\tfunction encode(s) {\n\t\treturn config.raw ? s : encodeURIComponent(s);\n\t}\n\n\tfunction decode(s) {\n\t\treturn config.raw ? s : decodeURIComponent(s);\n\t}\n\n\tfunction stringifyCookieValue(value) {\n\t\treturn encode(config.json ? JSON.stringify(value) : String(value));\n\t}\n\n\tfunction parseCookieValue(s) {\n\t\tif (s.indexOf('\"') === 0) {\n\t\t\t// This is a quoted cookie as according to RFC2068, unescape...\n\t\t\ts = s.slice(1, -1).replace(/\\\\\"/g, '\"').replace(/\\\\\\\\/g, '\\\\');\n\t\t}\n\n\t\ttry {\n\t\t\t// Replace server-side written pluses with spaces.\n\t\t\t// If we can't decode the cookie, ignore it, it's unusable.\n\t\t\t// If we can't parse the cookie, ignore it, it's unusable.\n\t\t\ts = decodeURIComponent(s.replace(pluses, ' '));\n\t\t\treturn config.json ? JSON.parse(s) : s;\n\t\t} catch(e) {}\n\t}\n\n\tfunction read(s, converter) {\n\t\tvar value = config.raw ? s : parseCookieValue(s);\n\t\treturn $.isFunction(converter) ? converter(value) : value;\n\t}\n\n\tvar config = $.cookie = function (key, value, options) {\n\n\t\t// Write\n\n\t\tif (value !== undefined && !$.isFunction(value)) {\n\t\t\toptions = $.extend({}, config.defaults, options);\n\n\t\t\tif (typeof options.expires === 'number') {\n\t\t\t\tvar days = options.expires, t = options.expires = new Date();\n\t\t\t\tt.setTime(+t + days * 864e+5);\n\t\t\t}\n\n\t\t\treturn (document.cookie = [\n\t\t\t\tencode(key), '=', stringifyCookieValue(value),\n\t\t\t\toptions.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE\n\t\t\t\toptions.path    ? '; path=' + options.path : '',\n\t\t\t\toptions.domain  ? '; domain=' + options.domain : '',\n\t\t\t\toptions.secure  ? '; secure' : ''\n\t\t\t].join(''));\n\t\t}\n\n\t\t// Read\n\n\t\tvar result = key ? undefined : {};\n\n\t\t// To prevent the for loop in the first place assign an empty array\n\t\t// in case there are no cookies at all. Also prevents odd result when\n\t\t// calling $.cookie().\n\t\tvar cookies = document.cookie ? document.cookie.split('; ') : [];\n\n\t\tfor (var i = 0, l = cookies.length; i < l; i++) {\n\t\t\tvar parts = cookies[i].split('=');\n\t\t\tvar name = decode(parts.shift());\n\t\t\tvar cookie = parts.join('=');\n\n\t\t\tif (key && key === name) {\n\t\t\t\t// If second argument (value) is a function it's a converter...\n\t\t\t\tresult = read(cookie, value);\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\t// Prevent storing a cookie that we couldn't decode.\n\t\t\tif (!key && (cookie = read(cookie)) !== undefined) {\n\t\t\t\tresult[name] = cookie;\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t};\n\n\tconfig.defaults = {};\n\n\t$.removeCookie = function (key, options) {\n\t\tif ($.cookie(key) === undefined) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Must not alter options, thus extending a fresh object...\n\t\t$.cookie(key, '', $.extend({}, options, { expires: -1 }));\n\t\treturn !$.cookie(key);\n\t};\n\n}));\n"
  },
  {
    "path": "concordia/storage.py",
    "content": "from django.core.files.storage import storages\nfrom django.utils.functional import LazyObject\n\n\nclass LazyAssetStorage(LazyObject):\n    def _setup(self):\n        self._wrapped = storages[\"assets\"]\n\n\nclass LazyVisualizationStorage(LazyObject):\n    def _setup(self):\n        self._wrapped = storages[\"visualizations\"]\n\n\n# This is an intentional alias so we can change this value in the future\n# if we need to split storage across multiple buckets\n# We use a LazyObject so the value isn't evaluated when the code is loaded,\n# which is needed to override the setting during tests\n\nASSET_STORAGE = LazyAssetStorage()\n\nVISUALIZATION_STORAGE = LazyVisualizationStorage()\n"
  },
  {
    "path": "concordia/storage_backends.py",
    "content": "from storages.backends.s3boto3 import S3Boto3Storage\n\n\nclass OverwriteS3Boto3Storage(S3Boto3Storage):\n    def get_available_name(self, name, max_length=None):\n        return name  # Forces overwriting by always returning the given name\n"
  },
  {
    "path": "concordia/tasks/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/tasks/assets.py",
    "content": "import os.path\nfrom logging import getLogger\nfrom tempfile import NamedTemporaryFile\n\nimport requests\nfrom more_itertools.more import chunked\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import Asset\nfrom concordia.storage import ASSET_STORAGE\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task\ndef calculate_difficulty_values(asset_qs=None):\n    \"\"\"\n    Calculate difficulty scores for assets and update their stored values.\n\n    This Celery task walks a queryset of Asset rows in 500-row chunks, computes\n    a difficulty score based on transcription activity, and bulk-updates only\n    those assets whose difficulty value has changed.\n\n    Args:\n        asset_qs: Optional queryset of Asset instances to process. If omitted,\n            all published assets are fetched via Asset.objects.published().\n\n    Returns:\n        int: The number of Asset records whose difficulty field was updated.\n    \"\"\"\n\n    if asset_qs is None:\n        asset_qs = Asset.objects.published()\n\n    asset_qs = asset_qs.add_contribution_counts()\n\n    updated_count = 0\n\n    # We'll process assets in chunks using an iterator to avoid saving objects\n    # which will never be used again in memory. We will find assets which have a\n    # difficulty value which is not the same as the value stored in the database\n    # and pass them to bulk_update() to be saved in a single query.\n    for asset_chunk in chunked(asset_qs.iterator(), 500):\n        changed_assets = []\n\n        for asset in asset_chunk:\n            difficulty = asset.transcription_count * (\n                asset.transcriber_count + asset.reviewer_count\n            )\n            if difficulty != asset.difficulty:\n                asset.difficulty = difficulty\n                changed_assets.append(asset)\n\n        if changed_assets:\n            # We will only save the new difficulty score both for performance\n            # and to avoid any possibility of race conditions causing stale data\n            # to be saved:\n            Asset.objects.bulk_update(changed_assets, [\"difficulty\"])\n            updated_count += len(changed_assets)\n\n    return updated_count\n\n\n@celery_app.task\ndef populate_asset_years():\n    \"\"\"\n    Populate the Asset.year field using dates from related Item metadata.\n\n    This Celery task iterates over assets in 500-row chunks, inspects each\n    asset's Item.metadata[\"item\"][\"dates\"] structure, and assigns the final\n    year key encountered to the Asset.year field. Only assets whose year value\n    changes are persisted with bulk_update().\n\n    Returns:\n        int: The number of Asset records whose year field was updated.\n    \"\"\"\n\n    asset_qs = Asset.objects.prefetch_related(\"item\")\n\n    updated_count = 0\n\n    for asset_chunk in chunked(asset_qs, 500):\n        changed_assets = []\n\n        for asset in asset_chunk:\n            metadata = asset.item.metadata\n\n            year = None\n            for date_outer in metadata[\"item\"][\"dates\"]:\n                for date_inner in date_outer.keys():\n                    year = date_inner\n                    break  # We don't support multiple values\n\n            if asset.year != year:\n                asset.year = year\n                changed_assets.append(asset)\n\n        if changed_assets:\n            Asset.objects.bulk_update(changed_assets, [\"year\"])\n            updated_count += len(changed_assets)\n\n    return updated_count\n\n\n@celery_app.task(ignore_result=True)\ndef fix_storage_images(campaign_slug=None, asset_start_id=None):\n    \"\"\"\n    Ensure that each Asset has a backing file in the asset storage backend.\n\n    For each matching asset, this Celery task checks whether the file referenced\n    by Asset.storage_image exists in ASSET_STORAGE. If it is missing, the task\n    downloads the image from Asset.download_url and saves it into storage using\n    the expected campaign/project/item/sequence-based filename.\n\n    Args:\n        campaign_slug: Optional campaign slug used to restrict the assets that\n            are checked. If omitted, all assets are examined.\n        asset_start_id: Optional numeric Asset primary key. If provided, only\n            assets with id >= this value are processed.\n\n    Raises:\n        requests.RequestException: Propagated if the remote download fails.\n        Exception: Any other exception encountered during download or save is\n            logged and re-raised.\n    \"\"\"\n\n    if campaign_slug:\n        from concordia.models import Campaign\n\n        campaign = Campaign.objects.get(slug=campaign_slug)\n        asset_queryset = Asset.objects.filter(item__project__campaign=campaign)\n    else:\n        asset_queryset = Asset.objects.all()\n\n    if asset_start_id:\n        asset_queryset = asset_queryset.filter(id__gte=asset_start_id)\n\n    count = 0\n    full_count = asset_queryset.count()\n    logger.debug(\"Checking storage image on %s assets\", full_count)\n    for asset in asset_queryset.order_by(\"id\"):\n        count += 1\n        if asset.storage_image:\n            if not asset.storage_image.storage.exists(asset.storage_image.name):\n                logger.info(\"Storage image does not exist for %s (%s)\", asset, asset.id)\n                item = asset.item\n                download_url = asset.download_url\n                asset_filename = os.path.join(\n                    item.project.campaign.slug,\n                    item.project.slug,\n                    item.item_id,\n                    \"%d.jpg\" % asset.sequence,\n                )\n                try:\n                    with NamedTemporaryFile(mode=\"x+b\") as temp_file:\n                        resp = requests.get(download_url, stream=True, timeout=30)\n                        resp.raise_for_status()\n\n                        for chunk in resp.iter_content(chunk_size=256 * 1024):\n                            temp_file.write(chunk)\n\n                        # Rewind the tempfile back to the first byte so we can\n                        temp_file.flush()\n                        temp_file.seek(0)\n\n                        ASSET_STORAGE.save(asset_filename, temp_file)\n\n                except Exception:\n                    logger.exception(\n                        \"Unable to download %s to %s\", download_url, asset_filename\n                    )\n                    raise\n                logger.info(\"Storage image downloaded for  %s (%s)\", asset, asset.id)\n        logger.debug(\"Storage image checked for %s (%s)\", asset, asset.id)\n        logger.debug(\"%s / %s (%s%%)\", count, full_count, str(count / full_count * 100))\n"
  },
  {
    "path": "concordia/tasks/blog.py",
    "content": "from logging import getLogger\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.parser import extract_og_image, fetch_blog_posts\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(bind=True, ignore_result=True)\ndef fetch_and_cache_blog_images(self):\n    \"\"\"\n    Fetch blog posts and cache their Open Graph images.\n\n    This Celery task iterates over entries returned by ``fetch_blog_posts()``,\n    finds each entry's ``<link>`` element, and passes the URL to\n    ``extract_og_image()`` so the Open Graph image can be fetched and cached\n    for later use.\n    \"\"\"\n    for item in fetch_blog_posts():\n        link = item.find(\"link\")\n        if link is not None:\n            extract_og_image(link.text)\n"
  },
  {
    "path": "concordia/tasks/housekeeping.py",
    "content": "from logging import getLogger\n\nfrom django.core.management import call_command\n\nfrom concordia.logging import ConcordiaLogger\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(ignore_result=True)\ndef clear_sessions():\n    \"\"\"\n    Clear expired Django session records.\n\n    This Celery task runs Django's ``clearsessions`` management command to\n    remove expired rows from the session store. It is typically invoked on a\n    schedule to prevent the session table from growing without bounds.\n    \"\"\"\n    call_command(\"clearsessions\")\n"
  },
  {
    "path": "concordia/tasks/next_asset/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/tasks/next_asset/renew.py",
    "content": "from logging import getLogger\n\nfrom concordia.decorators import locked_task\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import Campaign, Topic\n\nfrom ...celery import app as celery_app\nfrom .reviewable import (\n    clean_next_reviewable_for_campaign,\n    clean_next_reviewable_for_topic,\n)\nfrom .transcribable import (\n    clean_next_transcribable_for_campaign,\n    clean_next_transcribable_for_topic,\n)\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef renew_next_asset_cache(self):\n    \"\"\"\n    Triggers cache cleaning and repopulation for all active campaigns and published\n    topics.\n\n    This runs cleaning tasks for both transcribable and reviewable assets across all\n    campaigns and topics. Each cleaning task ensures that the next asset cache remains\n    accurate and up to date by removing invalid entries and restoring the desired count.\n    \"\"\"\n\n    for campaign in Campaign.objects.active():\n        logger.info(\"Spawning clean_next_transcribable_for_campaign for %s\", campaign)\n        clean_next_transcribable_for_campaign.delay(campaign_id=campaign.id)\n        logger.info(\"Spawning clean_next_reviewable_for_campaign for %s\", campaign)\n        clean_next_reviewable_for_campaign.delay(campaign_id=campaign.id)\n\n    for topic in Topic.objects.published():\n        logger.info(\"Spawning clean_next_transcribable_for_topic for %s\", topic)\n        clean_next_transcribable_for_topic.delay(topic_id=topic.id)\n        logger.info(\"Spawning clean_next_reviewable_for_topic for %s\", topic)\n        clean_next_reviewable_for_topic.delay(topic_id=topic.id)\n"
  },
  {
    "path": "concordia/tasks/next_asset/reviewable.py",
    "content": "from itertools import chain\nfrom logging import getLogger\n\nfrom concordia.decorators import locked_task\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    Campaign,\n    NextReviewableCampaignAsset,\n    NextReviewableTopicAsset,\n    Topic,\n)\nfrom concordia.utils import get_anonymous_user\nfrom concordia.utils.next_asset import (\n    find_invalid_next_reviewable_campaign_assets,\n    find_invalid_next_reviewable_topic_assets,\n    find_new_reviewable_campaign_assets,\n    find_new_reviewable_topic_assets,\n)\n\nfrom ...celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef populate_next_reviewable_for_campaign(self, campaign_id):\n    \"\"\"\n    Populate the next reviewable cache for a campaign.\n\n    This task checks how many reviewable assets are still needed for the\n    campaign, finds eligible assets and inserts them into the\n    NextReviewableCampaignAsset table up to the target count.\n\n    The task prefers assets whose transcribers are not already represented in\n    the cache to avoid review bottlenecks.\n\n    Only a single instance of this task runs at a time for a given campaign,\n    using the cache locking system to avoid duplication. This can be\n    overridden with the ``force`` keyword argument, which is stripped by the\n    decorator and not passed to the task itself. See the ``locked_task``\n    documentation for details.\n\n    Args:\n        campaign_id: Primary key of the campaign to process.\n    \"\"\"\n    try:\n        campaign = Campaign.objects.get(id=campaign_id)\n    except Campaign.DoesNotExist:\n        logger.error(\"Campaign %s not found\", campaign_id)\n        return\n    anonymous_user = get_anonymous_user()\n    excluded_user_ids = (\n        NextReviewableCampaignAsset.objects.filter(campaign=campaign)\n        .exclude(transcriber_ids__contains=[anonymous_user.id])\n        .values_list(\"transcriber_ids\", flat=True)\n        .distinct()\n    )\n    # Flatten the list and deduplicate\n    excluded_user_ids = set(chain.from_iterable(excluded_user_ids))\n\n    needed_asset_count = NextReviewableCampaignAsset.objects.needed_for_campaign(\n        campaign_id\n    )\n    if needed_asset_count:\n        assets_qs = find_new_reviewable_campaign_assets(campaign).only(\n            \"id\",\n            \"item_id\",\n            \"item__project_id\",\n            \"item__project__slug\",\n            \"campaign_id\",\n            \"transcription__user\",\n        )\n        # We prefer to not use transcribers that already exist, to avoid\n        # the situation where all possible reviewable assets have the same\n        # transcriber (since that would mean that user would miss the cache\n        # table when they try to review).\n        # If that's impossible, we just take whatever assets we can; that means\n        # only these transcribers have reviewable assets in the campaign\n        excluded_assets_qs = assets_qs.exclude(\n            transcription__user_id__in=excluded_user_ids\n        )\n        if excluded_assets_qs.exists():\n            assets_qs = excluded_assets_qs\n        assets = assets_qs[:needed_asset_count]\n    else:\n        logger.info(\n            \"Campaign %s already has %s next reviewable assets\",\n            campaign,\n            NextReviewableCampaignAsset.objects.target_count,\n        )\n        return\n\n    if assets:\n        objs = NextReviewableCampaignAsset.objects.bulk_create(\n            [\n                NextReviewableCampaignAsset(\n                    asset_id=asset.id,\n                    item_id=asset.item_id,\n                    item_item_id=asset.item.item_id,\n                    project_id=asset.item.project_id,\n                    project_slug=asset.item.project.slug,\n                    campaign_id=asset.campaign_id,\n                    transcriber_ids=list(\n                        asset.transcription_set.exclude(user=anonymous_user)\n                        .values_list(\"user_id\", flat=True)\n                        .distinct()\n                    ),\n                    sequence=asset.sequence,\n                )\n                for asset in assets\n            ]\n        )\n        logger.info(\n            \"Added %d next reviewable assets for campaign %s\", len(objs), campaign\n        )\n    else:\n        logger.info(\"No reviewable assets found in campaign %s\", campaign)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef populate_next_reviewable_for_topic(self, topic_id):\n    \"\"\"\n    Populate the next reviewable cache for a topic.\n\n    This task checks how many reviewable assets are still needed for the topic,\n    finds eligible assets and inserts them into the NextReviewableTopicAsset\n    table up to the target count.\n\n    The task prefers assets whose transcribers are not already represented in\n    the cache to avoid review bottlenecks.\n\n    Only a single instance of this task runs at a time for a given topic,\n    using the cache locking system to avoid duplication. This can be\n    overridden with the ``force`` keyword argument, which is stripped by the\n    decorator and not passed to the task itself. See the ``locked_task``\n    documentation for details.\n\n    Args:\n        topic_id: Primary key of the topic to process.\n    \"\"\"\n    try:\n        topic = Topic.objects.get(id=topic_id)\n    except Topic.DoesNotExist:\n        logger.error(\"Topic %s not found\", topic_id)\n        return\n    anonymous_user = get_anonymous_user()\n    excluded_user_ids = (\n        NextReviewableTopicAsset.objects.filter(topic=topic)\n        .exclude(transcriber_ids__contains=[anonymous_user.id])\n        .values_list(\"transcriber_ids\", flat=True)\n        .distinct()\n    )\n    # Flatten the list and deduplicate\n    excluded_user_ids = set(chain.from_iterable(excluded_user_ids))\n\n    needed_asset_count = NextReviewableTopicAsset.objects.needed_for_topic(topic_id)\n    if needed_asset_count:\n        assets_qs = find_new_reviewable_topic_assets(topic).only(\n            \"id\",\n            \"item_id\",\n            \"item__project_id\",\n            \"item__project__slug\",\n            \"transcription__user\",\n        )\n        # We prefer to not use transcribers that already exist, to avoid\n        # the situation where all possible reviewable assets have the same\n        # transcriber (since that would mean that user would miss the cache\n        # table when they try to review).\n        # If that's impossible, we just take whatever assets we can; that means\n        # only these transcribers have reviewable assets in the campaign\n        excluded_assets_qs = assets_qs.exclude(\n            transcription__user_id__in=excluded_user_ids\n        )\n        if excluded_assets_qs.exists():\n            assets_qs = excluded_assets_qs\n        assets = assets_qs[:needed_asset_count]\n    else:\n        logger.info(\n            \"Topic %s already has %s next reviewable assets\",\n            topic,\n            NextReviewableTopicAsset.objects.target_count,\n        )\n        return\n\n    if assets:\n        objs = NextReviewableTopicAsset.objects.bulk_create(\n            [\n                NextReviewableTopicAsset(\n                    asset_id=asset.id,\n                    item_id=asset.item_id,\n                    item_item_id=asset.item.item_id,\n                    project_id=asset.item.project_id,\n                    project_slug=asset.item.project.slug,\n                    topic_id=topic.id,\n                    transcriber_ids=list(\n                        asset.transcription_set.exclude(user=anonymous_user)\n                        .values_list(\"user_id\", flat=True)\n                        .distinct()\n                    ),\n                    sequence=asset.sequence,\n                )\n                for asset in assets\n            ]\n        )\n        logger.info(\"Added %d next reviewable assets for topic %s\", len(objs), topic)\n    else:\n        logger.info(\"No reviewable assets found in topic %s\", topic)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef clean_next_reviewable_for_campaign(self, campaign_id):\n    \"\"\"\n    Clean cached reviewable assets for a campaign then repopulate the cache.\n\n    Invalid entries are those whose assets no longer have transcription status\n    ``SUBMITTED`` and are no longer eligible for review. After cleaning, the\n    corresponding populate task is queued to restore the cache to the target\n    count.\n\n    Args:\n        campaign_id: Primary key of the campaign to clean.\n    \"\"\"\n\n    for next_asset in find_invalid_next_reviewable_campaign_assets(campaign_id):\n        try:\n            next_asset.delete()\n        except Exception:\n            logger.exception(\"Error deleting cached asset %s\", next_asset.id)\n    logger.info(\n        \"Spawning populate_next_reviewable_for_campaign for campgin %s\", campaign_id\n    )\n    populate_next_reviewable_for_campaign.delay(campaign_id)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef clean_next_reviewable_for_topic(self, topic_id):\n    \"\"\"\n    Clean cached reviewable assets for a topic then repopulate the cache.\n\n    Invalid entries are those whose assets no longer have transcription status\n    ``SUBMITTED`` and are no longer eligible for review. After cleaning, the\n    corresponding populate task is queued to restore the cache to the target\n    count.\n\n    Args:\n        topic_id: Primary key of the topic to clean.\n    \"\"\"\n\n    for next_asset in find_invalid_next_reviewable_topic_assets(topic_id):\n        try:\n            next_asset.delete()\n        except Exception:\n            logger.exception(\"Error deleting cached asset %s\", next_asset.id)\n    logger.info(\"Spawning populate_next_reviewable_for_topic for topic %s\", topic_id)\n    populate_next_reviewable_for_topic.delay(topic_id)\n"
  },
  {
    "path": "concordia/tasks/next_asset/transcribable.py",
    "content": "from logging import getLogger\n\nfrom concordia.decorators import locked_task\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    Campaign,\n    NextTranscribableCampaignAsset,\n    NextTranscribableTopicAsset,\n    Topic,\n)\nfrom concordia.utils.next_asset import (\n    find_invalid_next_transcribable_campaign_assets,\n    find_invalid_next_transcribable_topic_assets,\n    find_new_transcribable_campaign_assets,\n    find_new_transcribable_topic_assets,\n)\n\nfrom ...celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef populate_next_transcribable_for_campaign(self, campaign_id):\n    \"\"\"\n    Populate the cache of next transcribable assets for a campaign.\n\n    This task checks how many transcribable assets are still needed for the\n    campaign, finds eligible assets and inserts them into the\n    NextTranscribableCampaignAsset table up to the target count.\n\n    Only a single instance of the task runs at a time for a particular\n    campaign_id by using the cache locking system to avoid duplication. This\n    can be overridden with the `force` kwarg, which is stripped out by the\n    decorator and not passed to the task itself. See the `locked_task`\n    documentation for more information.\n\n    Args:\n        campaign_id: Primary key of the campaign to process.\n    \"\"\"\n    try:\n        campaign = Campaign.objects.get(id=campaign_id)\n    except Campaign.DoesNotExist:\n        logger.error(\"Campaign %s not found\", campaign_id)\n        return\n\n    needed_asset_count = NextTranscribableCampaignAsset.objects.needed_for_campaign(\n        campaign_id\n    )\n    if needed_asset_count:\n        assets_qs = find_new_transcribable_campaign_assets(campaign).only(\n            \"id\",\n            \"item_id\",\n            \"item__project_id\",\n            \"item__project__slug\",\n            \"campaign_id\",\n            \"transcription_status\",\n        )\n        assets = assets_qs[:needed_asset_count]\n    else:\n        logger.info(\n            \"Campaign %s already has %s next transcribable assets\",\n            campaign,\n            NextTranscribableCampaignAsset.objects.target_count,\n        )\n        return\n\n    if assets:\n        objs = NextTranscribableCampaignAsset.objects.bulk_create(\n            [\n                NextTranscribableCampaignAsset(\n                    asset_id=asset.id,\n                    item_id=asset.item_id,\n                    item_item_id=asset.item.item_id,\n                    project_id=asset.item.project_id,\n                    project_slug=asset.item.project.slug,\n                    campaign_id=asset.campaign_id,\n                    transcription_status=asset.transcription_status,\n                    sequence=asset.sequence,\n                )\n                for asset in assets\n            ]\n        )\n        logger.info(\n            \"Added %d next transcribable assets for campaign %s\", len(objs), campaign\n        )\n    else:\n        logger.info(\"No transcribable assets found in campaign %s\", campaign)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef populate_next_transcribable_for_topic(self, topic_id):\n    \"\"\"\n    Populate the cache of next transcribable assets for a topic.\n\n    This task checks how many transcribable assets are still needed for the\n    topic, finds eligible assets and inserts them into the\n    NextTranscribableTopicAsset table up to the target count.\n\n    Only a single instance of the task runs at a time for a particular topic_id\n    by using the cache locking system to avoid duplication. This can be\n    overridden with the `force` kwarg, which is stripped out by the decorator\n    and not passed to the task itself. See the `locked_task` documentation for\n    more information.\n\n    Args:\n        topic_id: Primary key of the topic to process.\n    \"\"\"\n    try:\n        topic = Topic.objects.get(id=topic_id)\n    except Topic.DoesNotExist:\n        logger.error(\"Topic %s not found\", topic_id)\n        return\n\n    needed_asset_count = NextTranscribableTopicAsset.objects.needed_for_topic(topic_id)\n    if needed_asset_count:\n        assets_qs = find_new_transcribable_topic_assets(topic).only(\n            \"id\",\n            \"item_id\",\n            \"item__project_id\",\n            \"item__project__slug\",\n            \"transcription_status\",\n        )\n        assets = assets_qs[:needed_asset_count]\n    else:\n        logger.info(\n            \"Topic %s already has %s next transcribable assets\",\n            topic,\n            NextTranscribableTopicAsset.objects.target_count,\n        )\n        return\n\n    if assets:\n        objs = NextTranscribableTopicAsset.objects.bulk_create(\n            [\n                NextTranscribableTopicAsset(\n                    asset_id=asset.id,\n                    item_id=asset.item_id,\n                    item_item_id=asset.item.item_id,\n                    project_id=asset.item.project_id,\n                    project_slug=asset.item.project.slug,\n                    topic_id=topic.id,\n                    transcription_status=asset.transcription_status,\n                    sequence=asset.sequence,\n                )\n                for asset in assets\n            ]\n        )\n        logger.info(\"Added %d next transcribable assets for topic %s\", len(objs), topic)\n    else:\n        logger.info(\"No transcribable assets found in topic %s\", topic)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef clean_next_transcribable_for_campaign(self, campaign_id):\n    \"\"\"\n    Remove invalid cached transcribable assets for a campaign then repopulate\n    the cache.\n\n    Invalid assets include those that are reserved or no longer eligible for\n    transcription based on their transcription status. After cleaning, the\n    corresponding populate task is queued to restore the cache to the target\n    count.\n\n    Args:\n        campaign_id: Primary key of the campaign to clean.\n    \"\"\"\n\n    for next_asset in find_invalid_next_transcribable_campaign_assets(campaign_id):\n        try:\n            next_asset.delete()\n        except Exception:\n            logger.exception(\"Error deleting cached asset %s\", next_asset.id)\n    logger.info(\n        \"Spawning populate_next_transcribable_for_campaign for campgin %s\", campaign_id\n    )\n    populate_next_transcribable_for_campaign.delay(campaign_id)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef clean_next_transcribable_for_topic(self, topic_id):\n    \"\"\"\n    Remove invalid cached transcribable assets for a topic then repopulate the\n    cache.\n\n    Invalid assets include those that are reserved or no longer eligible for\n    transcription based on their transcription status. After cleaning, the\n    corresponding populate task is queued to restore the cache to the target\n    count.\n\n    Args:\n        topic_id: Primary key of the topic to clean.\n    \"\"\"\n\n    for next_asset in find_invalid_next_transcribable_topic_assets(topic_id):\n        try:\n            next_asset.delete()\n        except Exception:\n            logger.exception(\"Error deleting cached asset %s\", next_asset.id)\n    logger.info(\"Spawning populate_next_transcribable_for_topic for topic %s\", topic_id)\n    populate_next_transcribable_for_topic.delay(topic_id)\n"
  },
  {
    "path": "concordia/tasks/reports/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/tasks/reports/backfill.py",
    "content": "import datetime\nimport time\nfrom logging import getLogger\nfrom typing import Iterable, Optional\n\nfrom django.db.models import Sum\n\nfrom concordia.decorators import locked_task\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import SiteReport\n\nfrom ...celery import app as celery_app\n\n# Heartbeat / streaming tuning\nHEARTBEAT_EVERY_ROWS = 1000\nHEARTBEAT_EVERY_SECONDS = 10.0\nITERATOR_CHUNK_SIZE = 2000\n\n# Matching window for associating campaign reports with a site-wide TOTAL\n# snapshot. Reports in a single daily run are created within minutes of\n# one another, but we use a wider band to make backfill resilient.\nTOTAL_ROLLUP_WINDOW_HOURS = 6\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task(lock_by_args=False)\ndef backfill_assets_started_for_site_reports(self, skip_existing: bool = True) -> int:\n    \"\"\"\n    Backfill the ``assets_started`` field for existing site-report series.\n\n    This one-off task computes and persists ``assets_started`` values for all\n    relevant ``SiteReport`` rows. It should be removed after it has been run in\n    production and the backfill is no longer needed.\n\n    Series processed:\n\n    * Site-wide TOTAL (``report_name=TOTAL``)\n    * Site-wide RETIRED_TOTAL (``report_name=RETIRED_TOTAL``)\n    * Per-campaign (``campaign`` is not null)\n    * Per-topic (``topic`` is not null)\n\n    Rules:\n\n    * The first snapshot in each series assumes ``assets_started = 0``. This\n      represents the launch of the site or the time before the first report\n      when no earlier data is available.\n    * Per-campaign and per-topic values are derived from ``assets_total`` and\n      ``assets_not_started``; publish/unpublish changes alone do not affect\n      ``assets_started`` as long as the total and not-started counts remain\n      consistent.\n    * The site-wide TOTAL series is backfilled by rolling up per-campaign\n      ``assets_started`` values from the same daily reporting run. This avoids\n      undercounting caused by retirements changing site-wide totals between\n      snapshots.\n    * For rollup series whose membership can change over time (for example,\n      ``RETIRED_TOTAL``), the delta-based ``assets_started`` calculation is not\n      meaningful. We backfill a consistent zero value.\n    * All results are floored at 0, since negative values indicate data\n      removal and should not be treated as negative activity.\n\n    Resumability:\n\n    * By default, rows that already have a non-null ``assets_started`` value\n      are skipped (``skip_existing=True``), so the task can be re-run to\n      resume where it left off. In this mode, only series that still contain\n      at least one snapshot with ``assets_started`` set to ``NULL`` are\n      processed.\n    * To recompute all rows, for example after changing the formula, call the\n      task with ``skip_existing=False``. In this mode, any series that has at\n      least one snapshot is processed, even if all snapshots already have\n      non-null ``assets_started`` values.\n\n    Args:\n        skip_existing: If true, skip rows where ``assets_started`` is already\n            populated.\n\n    Returns:\n        The number of ``SiteReport`` rows updated across all series.\n    \"\"\"\n    structured_logger.info(\n        \"Starting backfill for assets_started across all series.\",\n        event_code=\"assets_started_backfill_start\",\n        skip_existing=skip_existing,\n        task_id=getattr(self.request, \"id\", None),\n    )\n\n    updated_count = 0\n\n    def process_series_queryset(\n        qs: Iterable[SiteReport],\n        *,\n        series_label: str,\n        force_zero_assets_started: bool = False,\n        rollup_total_from_campaigns: bool = False,\n    ) -> int:\n        \"\"\"\n        Process a single series in chronological order and backfill values.\n\n        This helper walks one site-report series and computes\n        ``assets_started`` for each row. It saves updated rows and logs progress,\n        including periodic heartbeat messages for monitoring long-running scans.\n\n        For rollup series whose membership can change over time (for example,\n        ``RETIRED_TOTAL``), the delta-based ``assets_started`` calculation is\n        not meaningful. In those cases, callers should set\n        ``force_zero_assets_started=True`` to backfill a consistent zero value.\n\n        For the site-wide TOTAL series, callers should set\n        ``rollup_total_from_campaigns=True`` to derive values by summing\n        per-campaign ``assets_started`` from the same daily reporting run.\n\n        Args:\n            qs: Queryset or iterable of ``SiteReport`` objects ordered by\n                ``created_on`` and primary key.\n            series_label: Short label for logging, such as ``\"TOTAL\"`` or\n                ``\"CAMPAIGN:<id>\"``.\n            force_zero_assets_started: If True, set ``assets_started`` to 0 for\n                every row in the series instead of computing deltas between\n                snapshots.\n            rollup_total_from_campaigns: If True, compute ``assets_started`` for\n                each row by rolling up per-campaign values within a time window\n                around the row's ``created_on``.\n\n        Returns:\n            The number of rows in the series that were updated.\n        \"\"\"\n        changed = 0\n        scanned = 0\n        previous: Optional[SiteReport] = None\n\n        series_start_t = time.monotonic()\n        last_hb_t = series_start_t\n        last_hb_rows = 0\n\n        structured_logger.info(\n            \"Starting series scan.\",\n            event_code=\"assets_started_backfill_series_start\",\n            series=series_label,\n        )\n\n        window = datetime.timedelta(hours=TOTAL_ROLLUP_WINDOW_HOURS)\n\n        for current in qs.iterator(chunk_size=ITERATOR_CHUNK_SIZE):\n            scanned += 1\n\n            if force_zero_assets_started:\n                calculated = 0\n            elif rollup_total_from_campaigns:\n                window_start = current.created_on - window\n                window_end = current.created_on + window\n                agg = SiteReport.objects.filter(\n                    campaign__isnull=False,\n                    created_on__gte=window_start,\n                    created_on__lte=window_end,\n                ).aggregate(total=Sum(\"assets_started\"))\n                calculated = int(agg[\"total\"] or 0)\n            elif previous is None:\n                calculated = 0\n            else:\n                calculated = SiteReport.calculate_assets_started(\n                    previous_assets_total=previous.assets_total,\n                    previous_assets_not_started=previous.assets_not_started,\n                    current_assets_total=current.assets_total,\n                    current_assets_not_started=current.assets_not_started,\n                )\n\n            # Resume behavior: optionally skip already-populated rows.\n            if skip_existing and current.assets_started is not None:\n                previous = current\n                now_t = time.monotonic()\n                if (\n                    scanned - last_hb_rows >= HEARTBEAT_EVERY_ROWS\n                    or (now_t - last_hb_t) >= HEARTBEAT_EVERY_SECONDS\n                ):\n                    structured_logger.info(\n                        \"Scanning series...\",\n                        event_code=\"assets_started_backfill_series_heartbeat\",\n                        series=series_label,\n                        scanned_rows=scanned,\n                        updated_rows=changed,\n                        last_seen_site_report_id=current.id,\n                    )\n                    last_hb_rows = scanned\n                    last_hb_t = now_t\n                continue\n\n            if current.assets_started != calculated:\n                current.assets_started = calculated\n                current.save(update_fields=[\"assets_started\"])\n                changed += 1\n\n                structured_logger.info(\n                    \"Backfilled assets_started for SiteReport.\",\n                    event_code=\"assets_started_backfill_row\",\n                    site_report_id=current.id,\n                    created_on=current.created_on.isoformat(),\n                    series=series_label,\n                    assets_started=calculated,\n                    previous_site_report_id=(previous.id if previous else None),\n                    campaign_id=current.campaign_id,\n                    topic_id=current.topic_id,\n                )\n\n            previous = current\n\n            now_t = time.monotonic()\n            if (\n                scanned - last_hb_rows >= HEARTBEAT_EVERY_ROWS\n                or (now_t - last_hb_t) >= HEARTBEAT_EVERY_SECONDS\n            ):\n                structured_logger.info(\n                    \"Scanning series...\",\n                    event_code=\"assets_started_backfill_series_heartbeat\",\n                    series=series_label,\n                    scanned_rows=scanned,\n                    updated_rows=changed,\n                    last_seen_site_report_id=current.id,\n                )\n                last_hb_rows = scanned\n                last_hb_t = now_t\n\n        structured_logger.info(\n            \"Finished series scan.\",\n            event_code=\"assets_started_backfill_series_done\",\n            series=series_label,\n            scanned_rows=scanned,\n            updated_rows=changed,\n            elapsed_seconds=round(time.monotonic() - series_start_t, 3),\n        )\n        return changed\n\n    # Per-campaign (includes retired campaigns; their historical reports remain)\n    campaign_base_qs = SiteReport.objects.filter(campaign__isnull=False)\n    if skip_existing:\n        campaign_ids_source = campaign_base_qs.filter(assets_started__isnull=True)\n    else:\n        campaign_ids_source = campaign_base_qs\n\n    campaign_ids = campaign_ids_source.values_list(\"campaign_id\", flat=True).distinct()\n    for campaign_id in campaign_ids.iterator():\n        campaign_series = campaign_base_qs.filter(campaign_id=campaign_id).order_by(\n            \"created_on\", \"pk\"\n        )\n        updated_count += process_series_queryset(\n            campaign_series, series_label=f\"CAMPAIGN:{campaign_id}\"\n        )\n\n    # Per-topic\n    topic_base_qs = SiteReport.objects.filter(topic__isnull=False)\n    if skip_existing:\n        topic_ids_source = topic_base_qs.filter(assets_started__isnull=True)\n    else:\n        topic_ids_source = topic_base_qs\n\n    topic_ids = topic_ids_source.values_list(\"topic_id\", flat=True).distinct()\n    for topic_id in topic_ids.iterator():\n        topic_series = topic_base_qs.filter(topic_id=topic_id).order_by(\n            \"created_on\", \"pk\"\n        )\n        updated_count += process_series_queryset(\n            topic_series, series_label=f\"TOPIC:{topic_id}\"\n        )\n\n    # Site-wide TOTAL (roll up per-campaign assets_started)\n    total_base_qs = SiteReport.objects.filter(\n        report_name=SiteReport.ReportName.TOTAL,\n        campaign__isnull=True,\n        topic__isnull=True,\n    )\n    total_exists_qs = total_base_qs\n    if skip_existing:\n        total_exists_qs = total_exists_qs.filter(assets_started__isnull=True)\n\n    if total_exists_qs.exists():\n        total_qs = total_base_qs.order_by(\"created_on\", \"pk\")\n        updated_count += process_series_queryset(\n            total_qs,\n            series_label=\"TOTAL\",\n            rollup_total_from_campaigns=True,\n        )\n\n    # Site-wide RETIRED_TOTAL\n    retired_base_qs = SiteReport.objects.filter(\n        report_name=SiteReport.ReportName.RETIRED_TOTAL\n    )\n    retired_exists_qs = retired_base_qs\n    if skip_existing:\n        retired_exists_qs = retired_exists_qs.filter(assets_started__isnull=True)\n\n    if retired_exists_qs.exists():\n        retired_total_qs = retired_base_qs.order_by(\"created_on\", \"pk\")\n        updated_count += process_series_queryset(\n            retired_total_qs,\n            series_label=\"RETIRED_TOTAL\",\n            force_zero_assets_started=True,\n        )\n\n    structured_logger.info(\n        \"Completed backfill for assets_started.\",\n        event_code=\"assets_started_backfill_complete\",\n        updated_rows=updated_count,\n        task_id=getattr(self.request, \"id\", None),\n    )\n    return updated_count\n"
  },
  {
    "path": "concordia/tasks/reports/key_metrics.py",
    "content": "import datetime\nfrom logging import getLogger\n\nfrom django.db.models import Max\nfrom django.utils import timezone\n\nfrom concordia.decorators import locked_task\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import KeyMetricsReport, SiteReport\n\nfrom ...celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task(lock_by_args=False)\ndef build_key_metrics_reports(self, recompute_all: bool = False) -> int:\n    \"\"\"\n    Build or refresh KeyMetricsReport rows (monthly, quarterly and fiscal year).\n\n    The task operates in two modes, controlled by ``recompute_all``:\n\n    - If ``recompute_all`` is True:\n        - Recompute every monthly period that can be derived from\n          SiteReport data.\n        - Recompute all quarters that have at least one monthly row.\n        - Recompute all fiscal years that have at least one quarterly\n          row.\n    - If ``recompute_all`` is False (incremental mode):\n        - Create any missing monthly rows.\n        - Refresh a monthly row if any SiteReport in that month has\n          ``created_on`` later than the row's ``updated_on`` value.\n        - Create any missing quarter rows that have at least one\n          monthly row.\n        - Refresh a quarter row if any of its monthly inputs have\n          ``updated_on`` later than the quarter's ``updated_on`` value.\n        - Create any missing fiscal year rows that have at least one\n          quarter row.\n        - Refresh a fiscal year row if any of its quarterly inputs have\n          ``updated_on`` later than the fiscal year's ``updated_on``\n          value.\n\n    Args:\n        recompute_all: If True, recompute all monthly, quarterly and\n            fiscal-year rows from scratch based on SiteReport data. If\n            False, only create missing rows and refresh rows that are\n            stale.\n\n    Returns:\n        int: Count of KeyMetricsReport rows created or updated.\n    \"\"\"\n    task_id = getattr(self.request, \"id\", None)\n    structured_logger.info(\n        \"Starting KeyMetricsReport build.\",\n        event_code=\"key_metrics_build_start\",\n        task_id=task_id,\n        recompute_all=recompute_all,\n    )\n\n    rows_changed = 0\n\n    # Determine month range we can evaluate\n    earliest_site_report = SiteReport.objects.order_by(\"created_on\", \"pk\").first()\n    earliest_date = earliest_site_report.created_on.date()\n    first_month_start = earliest_date.replace(day=1)\n\n    # Use local date for boundary logic\n    today_local = timezone.localdate()\n    # Evaluate up to the month containing \"yesterday\"\n    # so we never rely on future EOM snapshots\n    yesterday_local = today_local - datetime.timedelta(days=1)\n    _, latest_evaluated_end_of_month = KeyMetricsReport.month_bounds(yesterday_local)\n\n    def has_any_snapshot_by_end_of_month(month_start: datetime.date) -> bool:\n        _, end_of_month = KeyMetricsReport.month_bounds(month_start)\n        return SiteReport.objects.filter(created_on__date__lte=end_of_month).exists()\n\n    last_month_start = latest_evaluated_end_of_month.replace(day=1)\n    # Step back if the very latest month has no SiteReport by its EOM\n    while (\n        last_month_start >= first_month_start\n        and not has_any_snapshot_by_end_of_month(last_month_start)\n    ):\n        if last_month_start.month == 1:\n            last_month_start = last_month_start.replace(\n                year=last_month_start.year - 1, month=12, day=1\n            )\n        else:\n            last_month_start = last_month_start.replace(\n                month=last_month_start.month - 1, day=1\n            )\n\n    if last_month_start < first_month_start:\n        structured_logger.info(\n            \"No computable monthly periods found.\",\n            event_code=\"key_metrics_build_no_months\",\n            task_id=task_id,\n        )\n        return 0\n\n    # Monthly\n\n    months_processed: list[datetime.date] = []\n    current_month_start = first_month_start\n    while current_month_start <= last_month_start:\n        year = current_month_start.year\n        month = current_month_start.month\n        _, current_month_end = KeyMetricsReport.month_bounds(current_month_start)\n\n        if recompute_all:\n            report = KeyMetricsReport.upsert_month(year=year, month=month)\n            if report is not None:\n                rows_changed += 1\n                months_processed.append(current_month_start)\n                structured_logger.info(\n                    \"Upserted monthly KeyMetricsReport.\",\n                    event_code=\"key_metrics_month_upserted\",\n                    year=year,\n                    month=month,\n                    period_start=str(report.period_start),\n                    period_end=str(report.period_end),\n                    task_id=task_id,\n                )\n        else:\n            # Incremental mode: create missing, or refresh if stale\n            existing_monthly_report = KeyMetricsReport.objects.filter(\n                period_type=KeyMetricsReport.PeriodType.MONTHLY,\n                fiscal_year=KeyMetricsReport.get_fiscal_year_for_date(\n                    current_month_start\n                ),\n                month=month,\n            ).first()\n\n            if existing_monthly_report is None:\n                report = KeyMetricsReport.upsert_month(year=year, month=month)\n                if report is not None:\n                    rows_changed += 1\n                    months_processed.append(current_month_start)\n                    structured_logger.info(\n                        \"Created missing monthly KeyMetricsReport.\",\n                        event_code=\"key_metrics_month_created\",\n                        year=year,\n                        month=month,\n                        period_start=str(report.period_start),\n                        period_end=str(report.period_end),\n                        task_id=task_id,\n                    )\n            else:\n                # Refresh if any SiteReport within this month (TOTAL\n                # or RETIRED_TOTAL, site-wide) has been created after\n                # the monthly report was last updated.\n                site_report_newer_exists = SiteReport.objects.filter(\n                    report_name__in=(\n                        SiteReport.ReportName.TOTAL,\n                        SiteReport.ReportName.RETIRED_TOTAL,\n                    ),\n                    campaign__isnull=True,\n                    topic__isnull=True,\n                    created_on__date__gte=current_month_start,\n                    created_on__date__lte=current_month_end,\n                    created_on__gt=existing_monthly_report.updated_on,\n                ).exists()\n\n                if site_report_newer_exists:\n                    report = KeyMetricsReport.upsert_month(year=year, month=month)\n                    if report is not None:\n                        rows_changed += 1\n                        months_processed.append(current_month_start)\n                        structured_logger.info(\n                            (\n                                \"Refreshed monthly KeyMetricsReport \"\n                                \"due to newer SiteReports.\"\n                            ),\n                            event_code=\"key_metrics_month_refreshed\",\n                            year=year,\n                            month=month,\n                            period_start=str(report.period_start),\n                            period_end=str(report.period_end),\n                            task_id=task_id,\n                        )\n\n        # Next month\n        if month == 12:\n            current_month_start = current_month_start.replace(\n                year=year + 1, month=1, day=1\n            )\n        else:\n            current_month_start = current_month_start.replace(month=month + 1, day=1)\n\n    # Quarterly\n\n    # Ensure we know which quarters exist (or should exist) given MONTHLY rows\n    monthly_rows = (\n        KeyMetricsReport.objects.filter(period_type=KeyMetricsReport.PeriodType.MONTHLY)\n        .values(\"fiscal_year\")\n        .annotate(max_month=Max(\"month\"))\n    )\n    # We will iterate over all fiscal_years that have at least one monthly row\n    fiscal_years_with_monthlies = {row[\"fiscal_year\"] for row in monthly_rows}\n\n    # Create missing quarters and refresh stale ones\n    for fiscal_year in sorted(fiscal_years_with_monthlies):\n        for fiscal_quarter in (1, 2, 3, 4):\n            quarter_exists = KeyMetricsReport.objects.filter(\n                period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n                fiscal_year=fiscal_year,\n                fiscal_quarter=fiscal_quarter,\n            ).first()\n\n            if recompute_all:\n                quarter_report = KeyMetricsReport.upsert_quarter(\n                    fiscal_year=fiscal_year, fiscal_quarter=fiscal_quarter\n                )\n                if quarter_report is not None:\n                    rows_changed += 1\n                    structured_logger.info(\n                        \"Upserted quarterly KeyMetricsReport.\",\n                        event_code=\"key_metrics_quarter_upserted\",\n                        fiscal_year=fiscal_year,\n                        fiscal_quarter=fiscal_quarter,\n                        period_start=str(quarter_report.period_start),\n                        period_end=str(quarter_report.period_end),\n                        task_id=task_id,\n                    )\n                continue\n\n            # Incremental mode\n            if quarter_exists is None:\n                quarter_report = KeyMetricsReport.upsert_quarter(\n                    fiscal_year=fiscal_year, fiscal_quarter=fiscal_quarter\n                )\n                if quarter_report is not None:\n                    rows_changed += 1\n                    structured_logger.info(\n                        \"Created missing quarterly KeyMetricsReport.\",\n                        event_code=\"key_metrics_quarter_created\",\n                        fiscal_year=fiscal_year,\n                        fiscal_quarter=fiscal_quarter,\n                        period_start=str(quarter_report.period_start),\n                        period_end=str(quarter_report.period_end),\n                        task_id=task_id,\n                    )\n            else:\n                # Refresh if any constituent MONTHLY rows are newer than the quarter row\n                if fiscal_quarter == 1:\n                    month_list = [10, 11, 12]\n                    monthly_fiscal_year = fiscal_year\n                elif fiscal_quarter == 2:\n                    month_list = [1, 2, 3]\n                    monthly_fiscal_year = fiscal_year\n                elif fiscal_quarter == 3:\n                    month_list = [4, 5, 6]\n                    monthly_fiscal_year = fiscal_year\n                else:\n                    month_list = [7, 8, 9]\n                    monthly_fiscal_year = fiscal_year\n\n                monthly_newer_exists = KeyMetricsReport.objects.filter(\n                    period_type=KeyMetricsReport.PeriodType.MONTHLY,\n                    fiscal_year=monthly_fiscal_year,\n                    month__in=month_list,\n                    updated_on__gt=quarter_exists.updated_on,\n                ).exists()\n\n                if monthly_newer_exists:\n                    quarter_report = KeyMetricsReport.upsert_quarter(\n                        fiscal_year=fiscal_year, fiscal_quarter=fiscal_quarter\n                    )\n                    if quarter_report is not None:\n                        rows_changed += 1\n                        structured_logger.info(\n                            (\n                                \"Refreshed quarterly KeyMetricsReport \"\n                                \"due to newer monthly inputs.\"\n                            ),\n                            event_code=\"key_metrics_quarter_refreshed\",\n                            fiscal_year=fiscal_year,\n                            fiscal_quarter=fiscal_quarter,\n                            period_start=str(quarter_report.period_start),\n                            period_end=str(quarter_report.period_end),\n                            task_id=task_id,\n                        )\n\n    # Fiscal year\n\n    # Any fiscal year that has at least one quarter row should have a FY rollup\n    fiscal_years_with_quarters = set(\n        KeyMetricsReport.objects.filter(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY\n        ).values_list(\"fiscal_year\", flat=True)\n    )\n\n    for fiscal_year in sorted(fiscal_years_with_quarters):\n        fiscal_year_report = KeyMetricsReport.objects.filter(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            fiscal_year=fiscal_year,\n        ).first()\n\n        if recompute_all:\n            year_report = KeyMetricsReport.upsert_fiscal_year(fiscal_year=fiscal_year)\n            if year_report is not None:\n                rows_changed += 1\n                structured_logger.info(\n                    \"Upserted fiscal-year KeyMetricsReport.\",\n                    event_code=\"key_metrics_year_upserted\",\n                    fiscal_year=fiscal_year,\n                    period_start=str(year_report.period_start),\n                    period_end=str(year_report.period_end),\n                    task_id=task_id,\n                )\n            continue\n\n        if fiscal_year_report is None:\n            year_report = KeyMetricsReport.upsert_fiscal_year(fiscal_year=fiscal_year)\n            if year_report is not None:\n                rows_changed += 1\n                structured_logger.info(\n                    \"Created missing fiscal-year KeyMetricsReport.\",\n                    event_code=\"key_metrics_year_created\",\n                    fiscal_year=fiscal_year,\n                    period_start=str(year_report.period_start),\n                    period_end=str(year_report.period_end),\n                    task_id=task_id,\n                )\n        else:\n            # Refresh if any constituent QUARTER rows are newer than the FY row\n            quarter_newer_exists = KeyMetricsReport.objects.filter(\n                period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n                fiscal_year=fiscal_year,\n                updated_on__gt=fiscal_year_report.updated_on,\n            ).exists()\n\n            if quarter_newer_exists:\n                year_report = KeyMetricsReport.upsert_fiscal_year(\n                    fiscal_year=fiscal_year\n                )\n                if year_report is not None:\n                    rows_changed += 1\n                    structured_logger.info(\n                        (\n                            \"Refreshed fiscal-year KeyMetricsReport \"\n                            \"due to newer quarterly inputs.\"\n                        ),\n                        event_code=\"key_metrics_year_refreshed\",\n                        fiscal_year=fiscal_year,\n                        period_start=str(year_report.period_start),\n                        period_end=str(year_report.period_end),\n                        task_id=task_id,\n                    )\n\n    structured_logger.info(\n        \"Completed KeyMetricsReport build.\",\n        event_code=\"key_metrics_build_complete\",\n        rows_changed=rows_changed,\n        task_id=task_id,\n        recompute_all=recompute_all,\n    )\n    return rows_changed\n"
  },
  {
    "path": "concordia/tasks/reports/sitereport.py",
    "content": "from logging import getLogger\n\nfrom django.contrib.auth.models import User\nfrom django.db.models import Count, Q, QuerySet\nfrom django.utils import timezone\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    ONE_DAY_AGO,\n    Asset,\n    Campaign,\n    Item,\n    Project,\n    SiteReport,\n    Tag,\n    Topic,\n    Transcription,\n    UserAssetTagCollection,\n)\nfrom concordia.utils import get_anonymous_user\n\nfrom ...celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef _recent_transcriptions() -> QuerySet[Transcription]:\n    \"\"\"\n    Return transcriptions with activity in the last day.\n\n    \"Recent\" activity is defined as any transcription whose accepted, created,\n    rejected, submitted, or updated timestamp is greater than or equal to\n    ONE_DAY_AGO. This queryset is used as the basis for daily activity and DAU\n    calculations.\n\n    Returns:\n        QuerySet[Transcription]: Django queryset of recent transcriptions.\n    \"\"\"\n    qs = Transcription.objects.filter(\n        Q(accepted__gte=ONE_DAY_AGO)\n        | Q(created_on__gte=ONE_DAY_AGO)\n        | Q(rejected__gte=ONE_DAY_AGO)\n        | Q(submitted__gte=ONE_DAY_AGO)\n        | Q(updated_on__gte=ONE_DAY_AGO)\n    )\n    structured_logger.info(\n        \"Fetched recent transcriptions for DAU calculation.\",\n        event_code=\"recent_transcriptions_fetched\",\n        transcription_count=qs.count(),\n    )\n    return qs\n\n\ndef _daily_active_users() -> int:\n    \"\"\"\n    Calculate the daily active user count based on recent transcriptions.\n\n    A daily active user is any account that either created or updated a\n    transcription, or performed a review, within the last day.\n\n    Returns:\n        int: The number of unique users who were active in the last day.\n    \"\"\"\n    transcriptions = _recent_transcriptions()\n    transcriber_ids = transcriptions.values_list(\"user\", flat=True).distinct()\n    reviewer_ids = (\n        transcriptions.exclude(reviewed_by__isnull=True)\n        .values_list(\"reviewed_by\", flat=True)\n        .distinct()\n    )\n    transcriber_count = transcriber_ids.count()\n    reviewer_count = reviewer_ids.count()\n    daily_active_users = len(set(list(reviewer_ids) + list(transcriber_ids)))\n\n    structured_logger.info(\n        \"Calculated daily active users from recent transcriptions.\",\n        event_code=\"daily_active_users_calculated\",\n        transcriber_count=transcriber_count,\n        reviewer_count=reviewer_count,\n        daily_active_users=daily_active_users,\n    )\n    return daily_active_users\n\n\n@celery_app.task\ndef site_report() -> None:\n    \"\"\"\n    Generate site-wide, per-campaign, per-topic, and retired rollup SiteReports.\n\n    This task snapshots current counts for assets, items, projects, campaigns,\n    tags, users, and transcription activity into SiteReport rows. It creates a\n    site-wide TOTAL report, then per-campaign and per-topic reports, and\n    finally a RETIRED_TOTAL rollup for retired campaigns.\n\n    For per-campaign and per-topic reports, the ``assets_started`` metric is\n    derived from ``assets_total`` and ``assets_not_started``, so\n    publish/unpublish changes alone do not affect the calculated starts as\n    long as total and not-started counts remain consistent.\n\n    For the site-wide TOTAL report, ``assets_started`` is calculated by rolling\n    up the per-campaign ``assets_started`` values generated in the same daily\n    reporting run. This avoids confounding changes to the site-wide series\n    caused by campaign retirements.\n\n    The RETIRED_TOTAL rollup does not calculate ``assets_started``; it is set\n    to zero because membership changes when campaigns retire, and the daily\n    delta is not meaningful for that rollup.\n    \"\"\"\n    structured_logger.debug(\n        \"Starting site report generation task.\",\n        event_code=\"site_report_task_start\",\n    )\n    report = {\n        \"assets_not_started\": 0,\n        \"assets_in_progress\": 0,\n        \"assets_submitted\": 0,\n        \"assets_completed\": 0,\n    }\n\n    asset_count_qs = Asset.objects.values_list(\"transcription_status\").annotate(\n        Count(\"transcription_status\")\n    )\n    for status, count in asset_count_qs:\n        logger.debug(\"Assets %s: %d\", status, count)\n        report[f\"assets_{status}\"] = count\n\n    assets_total = Asset.objects.count()\n    assets_published = Asset.objects.published().count()\n    assets_unpublished = Asset.objects.unpublished().count()\n\n    items_published = Item.objects.published().count()\n    items_unpublished = Item.objects.unpublished().count()\n\n    projects_published = Project.objects.published().count()\n    projects_unpublished = Project.objects.unpublished().count()\n\n    campaigns_published = Campaign.objects.published().count()\n    campaigns_unpublished = Campaign.objects.unpublished().count()\n\n    users_registered = User.objects.all().count()\n    users_activated = User.objects.filter(is_active=True).count()\n\n    anonymous_transcriptions = Transcription.objects.filter(\n        user=get_anonymous_user()\n    ).count()\n    transcriptions_saved = Transcription.objects.all().count()\n\n    daily_review_actions = Transcription.objects.recent_review_actions().count()\n\n    stats = UserAssetTagCollection.objects.aggregate(Count(\"tags\"))\n    tag_count = stats[\"tags__count\"]\n\n    distinct_tag_count = Tag.objects.all().count()\n\n    site_report = SiteReport()\n    site_report.report_name = SiteReport.ReportName.TOTAL\n    site_report.assets_total = assets_total\n    site_report.assets_published = assets_published\n    site_report.assets_not_started = report[\"assets_not_started\"]\n    site_report.assets_in_progress = report[\"assets_in_progress\"]\n    site_report.assets_waiting_review = report[\"assets_submitted\"]\n    site_report.assets_completed = report[\"assets_completed\"]\n    site_report.assets_unpublished = assets_unpublished\n    site_report.assets_started = 0\n    site_report.items_published = items_published\n    site_report.items_unpublished = items_unpublished\n    site_report.projects_published = projects_published\n    site_report.projects_unpublished = projects_unpublished\n    site_report.anonymous_transcriptions = anonymous_transcriptions\n    site_report.transcriptions_saved = transcriptions_saved\n    site_report.daily_review_actions = daily_review_actions\n    site_report.distinct_tags = distinct_tag_count\n    site_report.tag_uses = tag_count\n    site_report.campaigns_published = campaigns_published\n    site_report.campaigns_unpublished = campaigns_unpublished\n    site_report.users_registered = users_registered\n    site_report.users_activated = users_activated\n    site_report.daily_active_users = _daily_active_users()\n\n    structured_logger.debug(\n        \"Site-wide counts calculated for report generation.\",\n        event_code=\"site_report_counts_calculated\",\n        assets_total=assets_total,\n        assets_published=assets_published,\n        assets_unpublished=assets_unpublished,\n        assets_started=site_report.assets_started,\n        items_published=items_published,\n        items_unpublished=items_unpublished,\n        projects_published=projects_published,\n        projects_unpublished=projects_unpublished,\n        campaigns_published=campaigns_published,\n        campaigns_unpublished=campaigns_unpublished,\n        users_registered=users_registered,\n        users_activated=users_activated,\n        anonymous_transcriptions=anonymous_transcriptions,\n        transcriptions_saved=transcriptions_saved,\n        daily_review_actions=daily_review_actions,\n        distinct_tags=distinct_tag_count,\n        tag_uses=tag_count,\n        daily_active_users=site_report.daily_active_users,\n    )\n\n    site_report.save()\n\n    structured_logger.debug(\n        \"Site-wide report saved successfully.\",\n        event_code=\"site_report_saved\",\n        site_report_id=site_report.id,\n        created_on=site_report.created_on.isoformat(),\n    )\n\n    campaigns = Campaign.objects.exclude(status=Campaign.Status.RETIRED)\n    structured_logger.debug(\n        \"Generating campaign reports.\",\n        event_code=\"campaign_reports_generation_start\",\n        campaign_count=campaigns.count(),\n    )\n    campaign_reports = []\n    for campaign in campaigns:\n        campaign_reports.append(campaign_report(campaign))\n    structured_logger.debug(\n        \"Campaign reports generation completed.\",\n        event_code=\"campaign_reports_generation_complete\",\n    )\n\n    total_assets_started = sum(\n        (campaign_site_report.assets_started or 0)\n        for campaign_site_report in campaign_reports\n        if campaign_site_report is not None\n    )\n    if site_report.assets_started != total_assets_started:\n        site_report.assets_started = total_assets_started\n        site_report.save(update_fields=[\"assets_started\"])\n\n        structured_logger.debug(\n            \"Site-wide assets_started rolled up from campaign reports.\",\n            event_code=\"site_report_assets_started_rolled_up\",\n            site_report_id=site_report.id,\n            created_on=site_report.created_on.isoformat(),\n            assets_started=total_assets_started,\n            campaign_report_count=len(campaign_reports),\n        )\n\n    topics = Topic.objects.all()\n    structured_logger.debug(\n        \"Generating topic reports.\",\n        event_code=\"topic_reports_generation_start\",\n        topic_count=topics.count(),\n    )\n    for topic in topics:\n        topic_report(topic)\n    structured_logger.debug(\n        \"Topic reports generation completed.\",\n        event_code=\"topic_reports_generation_complete\",\n    )\n\n    retired_total_report()\n    structured_logger.debug(\n        \"Retired total report generation completed.\",\n        event_code=\"retired_total_report_complete\",\n    )\n\n    structured_logger.debug(\n        \"Site report generation task completed successfully.\",\n        event_code=\"site_report_task_complete\",\n    )\n\n\ndef topic_report(topic: Topic) -> None:\n    \"\"\"\n    Generate and save a SiteReport snapshot for a single topic.\n\n    The report aggregates asset, item, project, tag, and review activity counts\n    for the topic and stores them as a new SiteReport row.\n\n    Args:\n        topic: Topic instance to generate a report for.\n    \"\"\"\n    structured_logger.debug(\n        \"Starting topic report generation.\",\n        event_code=\"topic_report_generation_start\",\n        topic_slug=topic,\n    )\n    report = {\n        \"assets_not_started\": 0,\n        \"assets_in_progress\": 0,\n        \"assets_submitted\": 0,\n        \"assets_completed\": 0,\n    }\n\n    asset_count_qs = (\n        Asset.objects.filter(item__project__topics=topic)\n        .values_list(\"transcription_status\")\n        .annotate(Count(\"transcription_status\"))\n    )\n\n    for status, count in asset_count_qs:\n        logger.debug(\"Topic %s assets %s: %d\", topic.slug, status, count)\n        report[f\"assets_{status}\"] = count\n\n    assets_total = Asset.objects.filter(item__project__topics=topic).count()\n    if assets_total == 0:\n        structured_logger.warning(\n            \"Topic report generated with zero total assets.\",\n            event_code=\"topic_report_zero_assets\",\n            reason=\"Topic has no associated assets\",\n            reason_code=\"no_assets\",\n            topic=topic,\n        )\n    assets_published = (\n        Asset.objects.published().filter(item__project__topics=topic).count()\n    )\n    assets_unpublished = (\n        Asset.objects.unpublished().filter(item__project__topics=topic).count()\n    )\n\n    items_published = Item.objects.published().filter(project__topics=topic).count()\n    items_unpublished = Item.objects.unpublished().filter(project__topics=topic).count()\n\n    projects_published = Project.objects.published().filter(topics=topic).count()\n    projects_unpublished = Project.objects.unpublished().filter(topics=topic).count()\n\n    anonymous_transcriptions = Transcription.objects.filter(\n        asset__item__project__topics=topic, user=get_anonymous_user()\n    ).count()\n    transcriptions_saved = Transcription.objects.filter(\n        asset__item__project__topics=topic\n    ).count()\n\n    daily_review_actions = (\n        Transcription.objects.recent_review_actions()\n        .filter(asset__item__project__topics__in=(topic,))\n        .count()\n    )\n\n    asset_tag_collections = UserAssetTagCollection.objects.filter(\n        asset__item__project__topics=topic\n    )\n\n    stats = asset_tag_collections.order_by().aggregate(tag_count=Count(\"tags\"))\n    tag_count = stats[\"tag_count\"]\n\n    distinct_tag_list = set()\n\n    for tag_collection in asset_tag_collections:\n        distinct_tag_list.update(tag_collection.tags.values_list(\"pk\", flat=True))\n\n    distinct_tag_count = len(distinct_tag_list)\n\n    previous = SiteReport.objects.previous_in_series(topic=topic, before=timezone.now())\n    assets_started = SiteReport.calculate_assets_started(\n        previous_assets_total=getattr(previous, \"assets_total\", 0),\n        previous_assets_not_started=getattr(previous, \"assets_not_started\", 0),\n        current_assets_total=assets_total,\n        current_assets_not_started=report[\"assets_not_started\"],\n    )\n\n    structured_logger.debug(\n        \"Topic counts calculated for report generation.\",\n        event_code=\"topic_report_counts_calculated\",\n        topic=topic,\n        assets_total=assets_total,\n        assets_published=assets_published,\n        assets_unpublished=assets_unpublished,\n        assets_started=assets_started,\n        items_published=items_published,\n        items_unpublished=items_unpublished,\n        projects_published=projects_published,\n        projects_unpublished=projects_unpublished,\n        anonymous_transcriptions=anonymous_transcriptions,\n        transcriptions_saved=transcriptions_saved,\n        daily_review_actions=daily_review_actions,\n        distinct_tags=distinct_tag_count,\n        tag_uses=tag_count,\n    )\n    site_report = SiteReport()\n    site_report.topic = topic\n    site_report.assets_total = assets_total\n    site_report.assets_published = assets_published\n    site_report.assets_not_started = report[\"assets_not_started\"]\n    site_report.assets_in_progress = report[\"assets_in_progress\"]\n    site_report.assets_waiting_review = report[\"assets_submitted\"]\n    site_report.assets_completed = report[\"assets_completed\"]\n    site_report.assets_unpublished = assets_unpublished\n    site_report.items_published = items_published\n    site_report.items_unpublished = items_unpublished\n    site_report.projects_published = projects_published\n    site_report.projects_unpublished = projects_unpublished\n    site_report.anonymous_transcriptions = anonymous_transcriptions\n    site_report.transcriptions_saved = transcriptions_saved\n    site_report.daily_review_actions = daily_review_actions\n    site_report.distinct_tags = distinct_tag_count\n    site_report.tag_uses = tag_count\n    site_report.assets_started = assets_started\n    site_report.save()\n    structured_logger.debug(\n        \"Topic report saved successfully.\",\n        event_code=\"topic_report_saved\",\n        topic=topic,\n        site_report_id=site_report.id,\n        created_on=site_report.created_on.isoformat(),\n    )\n\n\ndef campaign_report(campaign: Campaign) -> SiteReport:\n    \"\"\"\n    Generate and save a SiteReport snapshot for a single campaign.\n\n    The report aggregates asset, item, project, contributor, tag, and review\n    counts for the campaign and stores them as a new SiteReport row.\n\n    The ``assets_started`` metric is derived from ``assets_total`` and\n    ``assets_not_started``, so publish/unpublish changes alone do not affect\n    the calculated starts as long as total and not-started counts remain\n    consistent.\n\n    Args:\n        campaign: Campaign instance to generate a report for.\n\n    Returns:\n        SiteReport: The newly created campaign SiteReport.\n    \"\"\"\n    structured_logger.debug(\n        \"Starting campaign report generation.\",\n        event_code=\"campaign_report_generation_start\",\n        campaign=campaign,\n    )\n    report = {\n        \"assets_not_started\": 0,\n        \"assets_in_progress\": 0,\n        \"assets_submitted\": 0,\n        \"assets_completed\": 0,\n    }\n\n    asset_count_qs = (\n        Asset.objects.filter(item__project__campaign=campaign)\n        .values_list(\"transcription_status\")\n        .annotate(Count(\"transcription_status\"))\n    )\n\n    for status, count in asset_count_qs:\n        logger.debug(\"Campaign %s assets %s: %d\", campaign.slug, status, count)\n        report[f\"assets_{status}\"] = count\n\n    assets_total = Asset.objects.filter(item__project__campaign=campaign).count()\n    if assets_total == 0:\n        structured_logger.warning(\n            \"Campaign report generated with zero total assets.\",\n            event_code=\"campaign_report_zero_assets\",\n            reason=\"Campaign has no associated assets\",\n            reason_code=\"no_assets\",\n            campaign=campaign,\n        )\n    assets_published = (\n        Asset.objects.published().filter(item__project__campaign=campaign).count()\n    )\n    assets_unpublished = (\n        Asset.objects.unpublished().filter(item__project__campaign=campaign).count()\n    )\n\n    items_published = (\n        Item.objects.published().filter(project__campaign=campaign).count()\n    )\n    items_unpublished = (\n        Item.objects.unpublished().filter(project__campaign=campaign).count()\n    )\n\n    projects_published = Project.objects.published().filter(campaign=campaign).count()\n    projects_unpublished = (\n        Project.objects.unpublished().filter(campaign=campaign).count()\n    )\n\n    anonymous_transcriptions = Transcription.objects.filter(\n        asset__item__project__campaign=campaign, user=get_anonymous_user()\n    ).count()\n    transcriptions_saved = Transcription.objects.filter(\n        asset__item__project__campaign=campaign\n    ).count()\n\n    daily_review_actions = (\n        Transcription.objects.recent_review_actions()\n        .filter(asset__item__project__campaign=campaign)\n        .count()\n    )\n\n    asset_tag_collections = UserAssetTagCollection.objects.filter(\n        asset__item__project__campaign=campaign\n    )\n\n    stats = asset_tag_collections.order_by().aggregate(tag_count=Count(\"tags\"))\n    tag_count = stats[\"tag_count\"]\n\n    distinct_tag_list = set()\n\n    for tag_collection in asset_tag_collections:\n        distinct_tag_list.update(tag_collection.tags.values_list(\"pk\", flat=True))\n\n    distinct_tag_count = len(distinct_tag_list)\n\n    campaign_assets = Asset.objects.filter(\n        item__project__campaign=campaign,\n        item__project__published=True,\n        item__published=True,\n        published=True,\n    )\n    asset_transcriptions = Transcription.objects.filter(\n        asset__in=campaign_assets\n    ).values_list(\"user_id\", \"reviewed_by\")\n    user_ids = {\n        user_id\n        for transcription in asset_transcriptions\n        for user_id in transcription\n        if user_id\n    }\n    registered_contributor_count = len(user_ids)\n\n    previous = SiteReport.objects.previous_in_series(\n        campaign=campaign, before=timezone.now()\n    )\n    assets_started = SiteReport.calculate_assets_started(\n        previous_assets_total=getattr(previous, \"assets_total\", 0),\n        previous_assets_not_started=getattr(previous, \"assets_not_started\", 0),\n        current_assets_total=assets_total,\n        current_assets_not_started=report[\"assets_not_started\"],\n    )\n\n    structured_logger.debug(\n        \"Campaign counts calculated for report generation.\",\n        event_code=\"campaign_report_counts_calculated\",\n        campaign=campaign,\n        assets_total=assets_total,\n        assets_published=assets_published,\n        assets_unpublished=assets_unpublished,\n        assets_started=assets_started,\n        items_published=items_published,\n        items_unpublished=items_unpublished,\n        projects_published=projects_published,\n        projects_unpublished=projects_unpublished,\n        anonymous_transcriptions=anonymous_transcriptions,\n        transcriptions_saved=transcriptions_saved,\n        daily_review_actions=daily_review_actions,\n        distinct_tags=distinct_tag_count,\n        tag_uses=tag_count,\n        registered_contributors=registered_contributor_count,\n    )\n    site_report = SiteReport()\n    site_report.campaign = campaign\n    site_report.assets_total = assets_total\n    site_report.assets_published = assets_published\n    site_report.assets_not_started = report[\"assets_not_started\"]\n    site_report.assets_in_progress = report[\"assets_in_progress\"]\n    site_report.assets_waiting_review = report[\"assets_submitted\"]\n    site_report.assets_completed = report[\"assets_completed\"]\n    site_report.assets_unpublished = assets_unpublished\n    site_report.items_published = items_published\n    site_report.items_unpublished = items_unpublished\n    site_report.projects_published = projects_published\n    site_report.projects_unpublished = projects_unpublished\n    site_report.anonymous_transcriptions = anonymous_transcriptions\n    site_report.transcriptions_saved = transcriptions_saved\n    site_report.daily_review_actions = daily_review_actions\n    site_report.distinct_tags = distinct_tag_count\n    site_report.tag_uses = tag_count\n    site_report.registered_contributors = registered_contributor_count\n    site_report.assets_started = assets_started\n    site_report.save()\n    structured_logger.debug(\n        \"Campaign report saved successfully.\",\n        event_code=\"campaign_report_saved\",\n        campaign=campaign,\n        site_report_id=site_report.id,\n        created_on=site_report.created_on.isoformat(),\n    )\n    return site_report\n\n\ndef retired_total_report() -> None:\n    \"\"\"\n    Generate and save the RETIRED_TOTAL SiteReport rollup.\n\n    This aggregates the most recent SiteReport for each retired campaign into a\n    single rollup row, summing most fields directly.\n\n    assets_started is a daily-delta metric and is not meaningful for this\n    rollup because the rollup membership changes when campaigns retire, and\n    that causes every asset in a newly-retired campaign being counted\n    as having started on the day of the retirement.\n    \"\"\"\n    structured_logger.debug(\n        \"Starting retired total report generation.\",\n        event_code=\"retired_total_report_generation_start\",\n    )\n    site_reports = (\n        SiteReport.objects.filter(campaign__status=Campaign.Status.RETIRED)\n        .order_by(\"campaign_id\", \"-created_on\")\n        .distinct(\"campaign_id\")\n    )\n\n    FIELDS = [\n        \"assets_total\",\n        \"assets_published\",\n        \"assets_not_started\",\n        \"assets_in_progress\",\n        \"assets_waiting_review\",\n        \"assets_completed\",\n        \"assets_unpublished\",\n        \"items_published\",\n        \"items_unpublished\",\n        \"projects_published\",\n        \"projects_unpublished\",\n        \"anonymous_transcriptions\",\n        \"transcriptions_saved\",\n        \"daily_review_actions\",\n        \"distinct_tags\",\n        \"tag_uses\",\n        \"registered_contributors\",\n    ]\n\n    total_site_report = SiteReport()\n    total_site_report.report_name = SiteReport.ReportName.RETIRED_TOTAL\n\n    for field in FIELDS:\n        setattr(\n            total_site_report,\n            field,\n            sum(getattr(sr, field) or 0 for sr in site_reports),\n        )\n\n    # assets_started will always be zero for retired campaigns,\n    # since no assets could ever be started once a campaign is\n    # retired. Trying to calculate it like we do for other reports\n    # results in every single asset from a newly retired campaign\n    # being counted as having started\n    total_site_report.assets_started = 0\n\n    total_site_report.save()\n    structured_logger.debug(\n        \"Retired total report saved successfully.\",\n        event_code=\"retired_total_report_saved\",\n        site_report_id=total_site_report.id,\n        created_on=total_site_report.created_on.isoformat(),\n    )\n"
  },
  {
    "path": "concordia/tasks/reservations.py",
    "content": "import datetime\nfrom logging import getLogger\n\nfrom django.conf import settings\nfrom django.utils import timezone\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import AssetTranscriptionReservation\nfrom concordia.signals.signals import reservation_released\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task\ndef expire_inactive_asset_reservations():\n    \"\"\"\n    Release and delete stale asset transcription reservations.\n\n    This task identifies reservations which have not been updated within a grace\n    period defined as twice ``TRANSCRIPTION_RESERVATION_SECONDS`` and:\n\n    * Emits the ``reservation_released`` signal for each expired reservation so\n      any listeners can react (for example, by making the asset available again).\n    * Deletes the expired reservation records from the database.\n\n    This is intended to be run periodically (for example via Celery beat) to\n    ensure that abandoned reservations do not block other users from working on\n    assets.\n    \"\"\"\n    timestamp = timezone.now()\n\n    # Clear old reservations, with a grace period:\n    cutoff = timestamp - (\n        datetime.timedelta(seconds=2 * settings.TRANSCRIPTION_RESERVATION_SECONDS)\n    )\n\n    logger.debug(\"Clearing reservations with last reserve time older than %s\", cutoff)\n    expired_reservations = AssetTranscriptionReservation.objects.filter(\n        updated_on__lt=cutoff, tombstoned__in=(None, False)\n    )\n\n    for reservation in expired_reservations:\n        logger.debug(\"Expired reservation with token %s\", reservation.reservation_token)\n        reservation_released.send(\n            sender=\"reserve_asset\",\n            asset_pk=reservation.asset.pk,\n            reservation_token=reservation.reservation_token,\n        )\n        reservation.delete()\n\n\n@celery_app.task\ndef tombstone_old_active_asset_reservations():\n    \"\"\"\n    Mark very old active reservations as tombstoned.\n\n    This task finds asset transcription reservations whose ``created_on`` is\n    older than ``TRANSCRIPTION_RESERVATION_TOMBSTONE_HOURS`` and that are not\n    already tombstoned. Each matching reservation is marked with\n    ``tombstoned=True`` and saved.\n\n    Tombstoning is a soft-deactivation step that prevents further use of\n    obsolete reservations while still retaining a short history for debugging\n    or analytics before final deletion.\n    \"\"\"\n    timestamp = timezone.now()\n\n    cutoff = timestamp - (\n        datetime.timedelta(hours=settings.TRANSCRIPTION_RESERVATION_TOMBSTONE_HOURS)\n    )\n\n    old_reservations = AssetTranscriptionReservation.objects.filter(\n        created_on__lt=cutoff, tombstoned__in=(None, False)\n    )\n    for reservation in old_reservations:\n        logger.debug(\"Tombstoning reservation %s \", reservation.reservation_token)\n        reservation.tombstoned = True\n        reservation.save()\n\n\n@celery_app.task\ndef delete_old_tombstoned_reservations():\n    \"\"\"\n    Permanently delete tombstoned reservations after a retention period.\n\n    This task finds asset transcription reservations which:\n\n    * Have ``tombstoned=True``, and\n    * Have not been updated within\n      ``TRANSCRIPTION_RESERVATION_TOMBSTONE_LENGTH_HOURS``.\n\n    Each matching reservation is deleted from the database. This provides a\n    final cleanup step after tombstoning so reservation records do not linger\n    indefinitely.\n    \"\"\"\n    timestamp = timezone.now()\n\n    cutoff = timestamp - (\n        datetime.timedelta(\n            hours=settings.TRANSCRIPTION_RESERVATION_TOMBSTONE_LENGTH_HOURS\n        )\n    )\n\n    old_reservations = AssetTranscriptionReservation.objects.filter(\n        tombstoned__exact=True, updated_on__lt=cutoff\n    )\n    for reservation in old_reservations:\n        logger.debug(\n            \"Deleting old tombstoned reservation %s\", reservation.reservation_token\n        )\n        reservation.delete()\n"
  },
  {
    "path": "concordia/tasks/retirement.py",
    "content": "from logging import getLogger\n\nfrom celery import chord\nfrom django.db import transaction\nfrom django.db.models import F\nfrom django.utils import timezone\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import Asset, Campaign, CampaignRetirementProgress, Item, Project\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(ignore_result=True)\ndef retire_campaign(campaign_id):\n    \"\"\"\n    Start the retirement workflow for a campaign.\n\n    This task:\n\n    * Loads the `Campaign` for ``campaign_id``.\n    * Creates or retrieves the related `CampaignRetirementProgress` row.\n    * For a new progress row, calculates and stores total counts of projects,\n      items and assets.\n    * Marks the campaign status as ``RETIRED`` if it is not already.\n    * Enqueues `remove_next_project` to begin cascading removal of projects,\n      items and assets.\n\n    Args:\n        campaign_id: Primary key of the `Campaign` to retire.\n\n    Returns:\n        CampaignRetirementProgress: The progress object tracking this\n            retirement run.\n    \"\"\"\n    # Entry point to the retirement process\n    campaign = Campaign.objects.get(id=campaign_id)\n    logger.debug(\"Retiring %s (%s)\", campaign, campaign.id)\n    progress, created = CampaignRetirementProgress.objects.get_or_create(\n        campaign=campaign\n    )\n    if created:\n        # We want to set totals on a newly created progress object\n        # but not on one that already exists. This allows us to keep proper\n        # track of the full progress if the process is stopped and resumed\n        projects = campaign.project_set.values_list(\"id\", flat=True)\n        items = Item.objects.filter(project__id__in=projects).values_list(\n            \"id\", flat=True\n        )\n        assets = Asset.objects.filter(item__id__in=items).values_list(\"id\", flat=True)\n        progress.project_total = len(projects)\n        progress.item_total = len(items)\n        progress.asset_total = len(assets)\n        progress.save()\n    if campaign.status != Campaign.Status.RETIRED:\n        logger.debug(\"Setting campaign status to retired\")\n        # We want to make sure the status is set to Retired before\n        # we start removing information so the front-end is pulling\n        # from archived data rather than live\n        campaign.status = Campaign.Status.RETIRED\n        campaign.save()\n    remove_next_project.delay(campaign.id)\n    return progress\n\n\n@celery_app.task(ignore_result=True)\ndef project_removal_success(project_id, campaign_id):\n    \"\"\"\n    Record successful removal of a project and queue the next project.\n\n    This task updates the associated `CampaignRetirementProgress` row by:\n\n    * Incrementing ``projects_removed``.\n    * Appending a project entry to ``removal_log``.\n    * Enqueuing `remove_next_project` to continue campaign retirement.\n\n    Args:\n        project_id: Primary key of the project that was just deleted.\n        campaign_id: Primary key of the parent `Campaign`.\n    \"\"\"\n    logger.debug(\"Updating progress for campaign %s\", campaign_id)\n    logger.debug(\"Project id %s\", project_id)\n    with transaction.atomic():\n        progress = CampaignRetirementProgress.objects.select_for_update().get(\n            campaign__id=campaign_id\n        )\n        progress.projects_removed = F(\"projects_removed\") + 1\n        progress.removal_log.append(\n            {\n                \"type\": \"project\",\n                \"id\": project_id,\n            }\n        )\n        progress.save()\n        logger.debug(\"Progress updated for %s\", campaign_id)\n    remove_next_project.delay(campaign_id)\n\n\n@celery_app.task(ignore_result=True)\ndef remove_next_project(campaign_id):\n    \"\"\"\n    Remove the next project in a campaign or mark retirement complete.\n\n    This task attempts to fetch the first remaining project in the campaign.\n    If a project exists, it enqueues `remove_next_item` to begin removing that\n    project's items. If no projects remain, it marks the related\n    `CampaignRetirementProgress` as complete and sets ``completed_on``.\n\n    Args:\n        campaign_id: Primary key of the `Campaign` whose projects are being\n            retired.\n    \"\"\"\n    campaign = Campaign.objects.get(id=campaign_id)\n    logger.debug(\"Removing projects for %s (%s)\", campaign, campaign.id)\n    try:\n        project = campaign.project_set.all()[0]\n        remove_next_item.delay(project.id)\n    except IndexError:\n        # This means all projects are deleted, which means the\n        # campaign is fully retired.\n        logger.debug(\"Updating progress for campaign %s\", campaign_id)\n        logger.debug(\"Retirement complete for campaign %s\", campaign_id)\n        with transaction.atomic():\n            progress = CampaignRetirementProgress.objects.select_for_update().get(\n                campaign__id=campaign_id\n            )\n            progress.complete = True\n            progress.completed_on = timezone.now()\n            progress.save()\n        logger.debug(\"Progress updated for %s\", campaign_id)\n\n\n@celery_app.task(ignore_result=True)\ndef item_removal_success(item_id, campaign_id, project_id):\n    \"\"\"\n    Record successful removal of an item and queue the next item.\n\n    This task updates the associated `CampaignRetirementProgress` row by:\n\n    * Incrementing ``items_removed``.\n    * Appending an item entry to ``removal_log``.\n    * Enqueuing `remove_next_item` to continue removing items from the project.\n\n    Args:\n        item_id: Primary key of the item that was just deleted.\n        campaign_id: Primary key of the parent `Campaign`.\n        project_id: Primary key of the parent `Project`.\n    \"\"\"\n    logger.debug(\"Updating progress for campaign %s\", campaign_id)\n    logger.debug(\"Item id %s\", item_id)\n    with transaction.atomic():\n        progress = CampaignRetirementProgress.objects.select_for_update().get(\n            campaign__id=campaign_id\n        )\n        progress.items_removed = F(\"items_removed\") + 1\n        progress.removal_log.append(\n            {\n                \"type\": \"item\",\n                \"id\": item_id,\n            }\n        )\n        progress.save()\n    logger.debug(\"Progress updated for %s\", campaign_id)\n    remove_next_item.delay(project_id)\n\n\n@celery_app.task(ignore_result=True)\ndef remove_next_item(project_id):\n    \"\"\"\n    Remove the next item in a project or delete the project if empty.\n\n    This task attempts to fetch the first remaining item for the given\n    project. If an item exists, it enqueues `remove_next_assets` to delete\n    that item's assets. If no items remain, it deletes the project and\n    enqueues `project_removal_success`.\n\n    Args:\n        project_id: Primary key of the `Project` whose items are being\n            removed.\n    \"\"\"\n    project = Project.objects.get(id=project_id)\n    logger.debug(\"Removing items for %s (%s)\", project, project.id)\n    try:\n        item = project.item_set.all()[0]\n        remove_next_assets.delay(item.id)\n    except IndexError:\n        # No more items remain for this project, so we can now delete\n        # the project\n        logger.debug(\"All items remoed for %s (%s)\", project, project.id)\n        campaign_id = project.campaign.id\n        project_id = project.id\n        project.delete()\n        project_removal_success.delay(project_id, campaign_id)\n\n\n@celery_app.task(ignore_result=True)\ndef assets_removal_success(asset_ids, campaign_id, item_id):\n    \"\"\"\n    Record successful removal of a batch of assets and queue the next batch.\n\n    This task updates the associated `CampaignRetirementProgress` row by:\n\n    * Incrementing ``assets_removed`` by the number of asset IDs.\n    * Appending an entry for each asset to ``removal_log``.\n    * Enqueuing `remove_next_assets` to continue deleting assets for the item.\n\n    Args:\n        asset_ids: Iterable of primary keys for assets just deleted.\n        campaign_id: Primary key of the parent `Campaign`.\n        item_id: Primary key of the parent `Item`.\n    \"\"\"\n    logger.debug(\"Updating progress for campaign %s\", campaign_id)\n    logger.debug(\"Asset ids %s\", asset_ids)\n    with transaction.atomic():\n        progress = CampaignRetirementProgress.objects.select_for_update().get(\n            campaign__id=campaign_id\n        )\n        progress.assets_removed = F(\"assets_removed\") + len(asset_ids)\n        for asset_id in asset_ids:\n            progress.removal_log.append(\n                {\n                    \"type\": \"asset\",\n                    \"id\": asset_id,\n                }\n            )\n        progress.save()\n    logger.debug(\"Progress updated for %s\", campaign_id)\n    remove_next_assets.delay(item_id)\n\n\n@celery_app.task(ignore_result=True)\ndef remove_next_assets(item_id):\n    \"\"\"\n    Remove assets for an item in small batches or delete the item.\n\n    This task fetches all remaining assets for the given item. If no assets\n    remain, it deletes the item and enqueues `item_removal_success`.\n    Otherwise, it deletes up to ten assets with a Celery chord of\n    `delete_asset` tasks, using `assets_removal_success` as the callback.\n\n    Args:\n        item_id: Primary key of the `Item` whose assets are being removed.\n    \"\"\"\n    item = Item.objects.get(id=item_id)\n    campaign_id = item.project.campaign.id\n    logger.debug(\"Removing assets for %s (%s)\", item, item.id)\n    assets = item.asset_set.all()\n    if not assets:\n        # No assets remain for this item, so we can safely delete it\n        logger.debug(\"All assets removed for %s (%s)\", item, item.id)\n        item_id = item.id\n        project_id = item.project.id\n        item.delete()\n        item_removal_success.delay(item_id, campaign_id, project_id)\n    else:\n        # We delete assets in chunks of 10 in order to not lock up the database\n        # for a long period of time.\n        chord(delete_asset.s(asset.id) for asset in assets[:10])(\n            assets_removal_success.s(campaign_id, item.id)\n        )\n\n\n@celery_app.task\ndef delete_asset(asset_id):\n    \"\"\"\n    Delete a single asset and its storage image.\n\n    This task:\n\n    * Loads the `Asset` for the given primary key.\n    * Deletes the associated ``storage_image`` file from storage.\n    * Deletes the asset record itself.\n\n    It returns the ID of the deleted asset so callers such as Celery chords\n    can record which assets were removed.\n\n    Args:\n        asset_id: Primary key of the `Asset` to delete.\n\n    Returns:\n        int: The ID of the deleted asset.\n    \"\"\"\n    asset = Asset.objects.get(id=asset_id)\n    asset_id = asset.id\n    logger.debug(\"Deleting asset %s (%s)\", asset, asset_id)\n    # We explicitly delete the storage image, though\n    # this should be removed anyway when the asset is deleted\n    asset.storage_image.delete(save=False)\n    asset.delete()\n    logger.debug(\"Asset %s (%s) deleted\", asset, asset_id)\n\n    return asset_id\n"
  },
  {
    "path": "concordia/tasks/search_index.py",
    "content": "from logging import getLogger\n\nfrom django.core.management import call_command\n\nfrom concordia.logging import ConcordiaLogger\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task\ndef create_opensearch_indices():\n    \"\"\"\n    Create OpenSearch indices if they do not already exist.\n\n    This task invokes the ``opensearch index create`` management command with\n    ``verbosity=2``, ``force=True`` and ``ignore_error=True``.\n    \"\"\"\n    call_command(\n        \"opensearch\", \"index\", \"create\", verbosity=2, force=True, ignore_error=True\n    )\n\n\n@celery_app.task\ndef delete_opensearch_indices():\n    \"\"\"\n    Delete OpenSearch indices and their stored documents.\n\n    This task invokes the ``opensearch index delete`` management command with\n    ``force=True`` and ``ignore_error=True``.\n    \"\"\"\n    call_command(\"opensearch\", \"index\", \"delete\", force=True, ignore_error=True)\n\n\n@celery_app.task\ndef rebuild_opensearch_indices():\n    \"\"\"\n    Rebuild all OpenSearch indices.\n\n    This task invokes the ``opensearch index rebuild`` management command with\n    ``verbosity=2``, ``force=True`` and ``ignore_error=True``.\n    \"\"\"\n    call_command(\n        \"opensearch\", \"index\", \"rebuild\", verbosity=2, force=True, ignore_error=True\n    )\n\n\n@celery_app.task\ndef populate_opensearch_users_indices():\n    \"\"\"\n    Populate the ``users`` OpenSearch index.\n\n    This task invokes the ``opensearch document index`` management command for\n    the ``users`` index with ``--force`` and ``--parallel`` so user documents\n    defined by the `UserDocument` mapping are indexed and searchable in\n    OpenSearch Dashboards.\n    \"\"\"\n    call_command(\n        \"opensearch\", \"document\", \"index\", \"--indices\", \"users\", \"--force\", \"--parallel\"\n    )\n\n\n@celery_app.task\ndef populate_opensearch_assets_indices():\n    \"\"\"\n    Populate the ``assets`` OpenSearch index.\n\n    This task invokes the ``opensearch document index`` management command for\n    the ``assets`` index with ``--force`` and ``--parallel`` so asset documents\n    defined by the `AssetDocument` mapping are indexed and searchable in\n    OpenSearch Dashboards.\n    \"\"\"\n    call_command(\n        \"opensearch\",\n        \"document\",\n        \"index\",\n        \"--indices\",\n        \"assets\",\n        \"--force\",\n        \"--parallel\",\n    )\n\n\n@celery_app.task\ndef populate_opensearch_indices():\n    \"\"\"\n    Populate all OpenSearch document indices.\n\n    This task invokes the ``opensearch document index`` management command with\n    ``--force`` to skip interactive confirmation and ``--parallel`` to index\n    documents in parallel.\n    \"\"\"\n    call_command(\"opensearch\", \"document\", \"index\", \"--force\", \"--parallel\")\n"
  },
  {
    "path": "concordia/tasks/thumbnails.py",
    "content": "from logging import getLogger\nfrom typing import Optional\n\nimport requests\nfrom celery import group\nfrom django.db import transaction\nfrom django.db.models import Q\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import Item\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n# TODO: remove download_item_thumbnail_task once `item.thumbnail_url` is removed\n\n\n@celery_app.task(\n    bind=True,\n    autoretry_for=(requests.RequestException,),\n    retry_backoff=5,\n    retry_kwargs={\"max_retries\": 5, \"countdown\": 5},\n)\ndef download_item_thumbnail_task(\n    self,\n    item_id: int,\n    force: bool = False,\n) -> str:\n    \"\"\"\n    Fetch an item and ensure its thumbnail image is populated.\n\n    The item's ``thumbnail_url`` field is used as the source of the download.\n\n    Args:\n        item_id: Primary key of the item to process.\n        force: Overwrite an existing thumbnail if true.\n\n    Returns:\n        Storage path of the saved image or a skip message.\n\n    Raises:\n        ValueError: If ``Item.thumbnail_url`` is unavailable.\n        requests.RequestException: For network errors (auto-retried).\n    \"\"\"\n    from importer.tasks.items import download_and_set_item_thumbnail\n\n    with transaction.atomic():\n        item = (\n            Item.objects.select_for_update(of=(\"self\",))\n            .only(\"id\", \"thumbnail_url\", \"thumbnail_image\", \"item_id\")\n            .get(pk=item_id)\n        )\n\n    src_url = item.thumbnail_url\n    if not src_url:\n        msg = \"No thumbnail URL available.\"\n        logger.info(\"download_item_thumbnail_task: %s item_id=%s\", msg, item_id)\n        return msg\n\n    return download_and_set_item_thumbnail(item, src_url, force=force)\n\n\n# TODO: remove download_missing_thumbnails_task once `item.thumbnail_url` is removed\n\n\n@celery_app.task(bind=True)\ndef download_missing_thumbnails_task(\n    self,\n    project_id: Optional[int] = None,\n    batch_size: int = 10,\n    limit: Optional[int] = None,\n    force: bool = False,\n) -> int:\n    \"\"\"\n    Spawn per-item download tasks for items missing thumbnails in chunks.\n\n    This finds items that have a non-empty ``thumbnail_url`` but no stored\n    ``thumbnail_image``. It then executes per-item tasks in chunks of\n    ``batch_size``, waiting for each chunk to finish before starting the next.\n\n    Args:\n        project_id: Optional project filter.\n        batch_size: Number of parallel tasks per wave.\n        limit: Optional cap on total items processed.\n        force: Overwrite existing thumbnails if true.\n\n    Returns:\n        Count of items scheduled or processed.\n    \"\"\"\n    qs = Item.objects.all()\n\n    if project_id is not None:\n        qs = qs.filter(project_id=project_id)\n\n    qs = qs.filter(\n        Q(thumbnail_url__isnull=False)\n        & ~Q(thumbnail_url=\"\")\n        & (Q(thumbnail_image__isnull=True) | Q(thumbnail_image=\"\"))\n    ).order_by(\"pk\")\n\n    if limit is not None:\n        qs = qs[:limit]\n\n    ids = list(qs.values_list(\"pk\", flat=True))\n    total = len(ids)\n    if total == 0:\n        logger.info(\"download_missing_thumbnails_task: nothing to do.\")\n        return 0\n\n    # Process in waves of `batch_size`, waiting between waves.\n    for i in range(0, total, batch_size):\n        chunk = ids[i : i + batch_size]\n        task_group = group(\n            download_item_thumbnail_task.s(item_id, force=force) for item_id in chunk\n        )\n        result = task_group.apply_async()\n        # Block this task until the chunk finishes; then schedule next.\n        result.get(disable_sync_subtasks=False)\n\n    logger.info(\n        \"download_missing_thumbnails_task: processed %s items in chunks of %s\",\n        total,\n        batch_size,\n    )\n    return total\n"
  },
  {
    "path": "concordia/tasks/unusualactivity.py",
    "content": "import datetime\nfrom logging import getLogger\n\nfrom django.conf import settings\nfrom django.contrib.sites.models import Site\nfrom django.core.mail import EmailMultiAlternatives\nfrom django.template import loader\nfrom django.utils import timezone\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import Transcription\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\nENV_MAPPING = {\"development\": \"DEV\", \"test\": \"TEST\", \"staging\": \"STAGE\"}\n\n\n@celery_app.task(ignore_result=True)\ndef unusual_activity(ignore_env: bool = False) -> None:\n    \"\"\"\n    Send an email report about suspect transcription or review activity.\n\n    By default this task runs only when ``CONCORDIA_ENVIRONMENT`` is\n    set to ``\"production\"``. Setting ``ignore_env`` to true forces the\n    report to be generated in other environments and adds an\n    environment tag to the subject line.\n\n    The report includes:\n\n    * Transcriptions flagged by ``transcribe_incidents`` in the past day\n    * Reviews flagged by ``review_incidents`` in the past day\n\n    Both plain text and HTML versions of the report are rendered from\n    templates and emailed to the monitoring recipients.\n\n    Args:\n        ignore_env: Generate and send the report even if the current\n            environment is not production.\n\n    Returns:\n        None\n    \"\"\"\n    # Don't bother running unless we're in the prod env\n    if settings.CONCORDIA_ENVIRONMENT == \"production\" or ignore_env:\n        site = Site.objects.get_current()\n        display_time = timezone.localtime().strftime(\"%b %d %Y, %I:%M %p\")\n        ONE_DAY_AGO = timezone.now() - datetime.timedelta(days=1)\n        title = \"Unusual User Activity Report for \" + display_time\n        if ignore_env:\n            title += \" [%s]\" % ENV_MAPPING[settings.CONCORDIA_ENVIRONMENT]\n        context = {\n            \"title\": title,\n            \"domain\": \"https://\" + site.domain,\n            \"transcriptions\": Transcription.objects.transcribe_incidents(ONE_DAY_AGO),\n            \"reviews\": Transcription.objects.review_incidents(ONE_DAY_AGO),\n        }\n\n        text_body_template = loader.get_template(\"emails/unusual_activity.txt\")\n        text_body_message = text_body_template.render(context)\n\n        html_body_template = loader.get_template(\"emails/unusual_activity.html\")\n        html_body_message = html_body_template.render(context)\n\n        to_email = [\"rsar@loc.gov\"]\n        if settings.DEFAULT_TO_EMAIL:\n            to_email.append(settings.DEFAULT_TO_EMAIL)\n        message = EmailMultiAlternatives(\n            subject=context[\"title\"],\n            body=text_body_message,\n            from_email=settings.DEFAULT_FROM_EMAIL,\n            to=to_email,\n            reply_to=[settings.DEFAULT_FROM_EMAIL],\n        )\n        message.attach_alternative(html_body_message, \"text/html\")\n        message.send()\n"
  },
  {
    "path": "concordia/tasks/useractivity.py",
    "content": "from logging import getLogger\nfrom typing import Iterable\n\nfrom django.conf import settings\nfrom django.contrib.auth.models import User\nfrom django.core.cache import cache\nfrom django.core.mail import send_mail\nfrom django.db.models import Q\n\nfrom concordia.decorators import locked_task\nfrom concordia.exceptions import CacheLockedError\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    Asset,\n    Campaign,\n    Tag,\n    Transcription,\n    UserAssetTagCollection,\n    UserProfileActivity,\n    _update_useractivity_cache,\n    update_userprofileactivity_table,\n)\nfrom concordia.utils import get_anonymous_user\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef _populate_activity_table(campaigns: Iterable[Campaign]) -> None:\n    \"\"\"\n    Populate UserProfileActivity rows for the given campaigns.\n\n    For each campaign this helper calculates per user counts of assets,\n    tags, transcriptions and reviews and bulk creates rows for all\n    non-anonymous users. It also updates or creates an aggregate row for\n    the anonymous user.\n\n    Args:\n        campaigns: Iterable of Campaign instances to process.\n    \"\"\"\n    anonymous_user = get_anonymous_user()\n    for campaign in campaigns:\n        transcriptions = Transcription.objects.filter(\n            asset__item__project__campaign=campaign\n        )\n        reviewer_ids = (\n            transcriptions.exclude(reviewed_by=anonymous_user)\n            .values_list(\"reviewed_by\", flat=True)\n            .distinct()\n        )\n        transcriber_ids = (\n            transcriptions.exclude(user=anonymous_user)\n            .values_list(\"user\", flat=True)\n            .distinct()\n        )\n        user_ids = list(set(list(reviewer_ids) + list(transcriber_ids)))\n        tag_collections = UserAssetTagCollection.objects.filter(\n            asset__item__project__campaign=campaign\n        )\n        UserProfileActivity.objects.bulk_create(\n            [\n                UserProfileActivity(\n                    user=user,\n                    campaign=campaign,\n                    asset_count=Asset.objects.filter(item__project__campaign=campaign)\n                    .filter(\n                        Q(transcription__reviewed_by=user) | Q(transcription__user=user)\n                    )\n                    .distinct()\n                    .count(),\n                    asset_tag_count=Tag.objects.filter(\n                        userassettagcollection__in=tag_collections.filter(user=user)\n                    )\n                    .distinct()\n                    .count(),\n                    transcribe_count=transcriptions.filter(Q(user=user))\n                    .distinct()\n                    .count(),\n                    review_count=transcriptions.filter(Q(reviewed_by=user))\n                    .distinct()\n                    .count(),\n                )\n                for user in User.objects.filter(id__in=user_ids)\n            ]\n        )\n        assets = Asset.objects.filter(item__project__campaign=campaign)\n        q = Q(transcription__reviewed_by=anonymous_user) | Q(\n            transcription__user=anonymous_user\n        )\n        user_profile_activity, _ = UserProfileActivity.objects.get_or_create(\n            user=anonymous_user,\n            campaign=campaign,\n        )\n        user_profile_activity.asset_count = assets.filter(q).distinct().count()\n        user_profile_activity.asset_tag_count = (\n            Tag.objects.filter(\n                userassettagcollection__in=tag_collections.filter(user=anonymous_user)\n            )\n            .distinct()\n            .count()\n        )\n        user_profile_activity.transcribe_count = (\n            transcriptions.filter(Q(user=anonymous_user)).distinct().count()\n        )\n        user_profile_activity.review_count = (\n            transcriptions.filter(Q(reviewed_by=anonymous_user)).distinct().count()\n        )\n        user_profile_activity.save()\n\n\n@celery_app.task\ndef populate_completed_campaign_counts() -> None:\n    \"\"\"\n    Populate UserProfileActivity for completed and retired campaigns.\n\n    This task should be run after the UserProfileActivity table is\n    created. It processes all campaigns that are not active by\n    delegating to ``_populate_activity_table``.\n    \"\"\"\n    # this task creates records in the UserProfileActivity table for campaigns\n    # that are completed or have status == RETIRED (but have not yet actually\n    # been retired). It should be run once, after the table has initially been\n    # created\n    # in my local env, this task took ~10 minutes to complete\n    campaigns = Campaign.objects.exclude(status=Campaign.Status.ACTIVE)\n    _populate_activity_table(campaigns)\n\n\n@celery_app.task\ndef populate_active_campaign_counts() -> None:\n    \"\"\"\n    Populate UserProfileActivity for active campaigns.\n\n    This task builds or refreshes activity rows for campaigns whose\n    status is ACTIVE by delegating to ``_populate_activity_table``.\n    \"\"\"\n    active_campaigns = Campaign.objects.filter(status=Campaign.Status.ACTIVE)\n    _populate_activity_table(active_campaigns)\n\n\n@celery_app.task(\n    bind=True,\n    autoretry_for=(Exception,),\n    retry_backoff=5,\n    retry_kwargs={\"max_retries\": 5, \"countdown\": 5},\n)\ndef update_useractivity_cache(\n    self,\n    user_id: int,\n    campaign_id: int,\n    attr_name: str,\n    *args,\n    **kwargs,\n) -> None:\n    \"\"\"\n    Update cached user activity counts for a single metric.\n\n    This Celery task acquires a short lived cache based lock to prevent\n    concurrent updates for the same key. On success it calls\n    ``_update_useractivity_cache`` then releases the lock and logs a\n    completion event. If the lock cannot be acquired after the retry\n    budget it logs a warning and sends an email to the developer list.\n\n    Args:\n        user_id: Primary key of the user to update.\n        campaign_id: Primary key of the campaign whose cache is updated.\n        attr_name: Name of the activity attribute being incremented,\n            for example ``\"transcribe_count\"`` or ``\"review_count\"``.\n\n    Raises:\n        CacheLockedError: If the cache lock cannot be acquired before\n            retries are exhausted.\n    \"\"\"\n    structured_logger.info(\n        \"Running update_useractivity_cache task\",\n        event_code=\"useractivity_cache_task_start\",\n        user_id=user_id,\n        campaign_id=campaign_id,\n        activity_type=attr_name,\n        attempt=self.request.retries + 1,\n    )\n    try:\n        lock_key = \"userprofileactivity_cache_lock\"\n\n        # attempt to acquire\n        if not cache.add(lock_key, \"locked\", timeout=10):\n            raise CacheLockedError(f\"Could not acquire lock for {lock_key}\")\n\n        try:\n            _update_useractivity_cache(user_id, campaign_id, attr_name)\n            structured_logger.info(\n                \"Successfully updated user activity cache\",\n                event_code=\"useractivity_cache_task_complete\",\n                user_id=user_id,\n                campaign_id=campaign_id,\n                activity_type=attr_name,\n            )\n        finally:\n            # release\n            cache.delete(lock_key)\n\n    except Exception as e:\n        if self.request.retries >= self.max_retries:\n            structured_logger.warning(\n                \"Could not acquire cache lock\",\n                event_code=\"useractivity_cache_lock_failed\",\n                reason=\"Another task is holding the lock\",\n                reason_code=\"lock_unavailable\",\n                user_id=user_id,\n                campaign_id=campaign_id,\n                activity_type=attr_name,\n            )\n            structured_logger.exception(\n                \"Failed to update user activity cache after retries.\",\n                event_code=\"useractivity_cache_task_failed\",\n                reason=\"Max retries reached while trying to acquire lock.\",\n                reason_code=\"max_retries_exceeded\",\n                user_id=user_id,\n                campaign_id=campaign_id,\n                activity_type=attr_name,\n            )\n            subject = \"Task update_useractivity_cache failed: cache is locked.\"\n            message_body = \"\"\"%s\n                            user: %s\n                            campaign: %s\n                            attribute: %s\n                          \"\"\" % (\n                e,\n                user_id,\n                campaign_id,\n                attr_name,\n            )\n            logger.error(\"%s %s Retrying...\", subject, message_body)\n            send_mail(\n                subject,\n                message_body,\n                settings.DEFAULT_FROM_EMAIL,\n                settings.CONCORDIA_DEVS,\n            )\n        # Let celery handle retries\n        raise e\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef update_userprofileactivity_from_cache(self) -> None:\n    \"\"\"\n    Flush per campaign activity deltas from cache to the database.\n\n    This task is wrapped by the ``locked_task`` decorator so only one\n    instance runs at a time. For each campaign it reads the cached\n    update payload, writes transcribe and review counts with\n    ``update_userprofileactivity_table`` then clears the cache entry.\n    \"\"\"\n    structured_logger.info(\n        \"Starting update_userprofileactivity_from_cache task\",\n        event_code=\"starting_update_userprofileactivity_from_cache_task\",\n    )\n    for campaign in Campaign.objects.all():\n        key = f\"userprofileactivity_{campaign.pk}\"\n        structured_logger.debug(\n            \"Read key\",\n            event_code=\"update_userprofileactivity_from_cache_key_read\",\n            key=key,\n        )\n        updates_by_user = cache.get(key)\n        if updates_by_user is not None:\n            cache.delete(key)\n            for user_id in updates_by_user:\n                user = User.objects.get(id=user_id)\n                update_userprofileactivity_table(\n                    user,\n                    campaign.id,\n                    \"transcribe_count\",\n                    updates_by_user[user_id][0],\n                )\n                update_userprofileactivity_table(\n                    user,\n                    campaign.id,\n                    \"review_count\",\n                    updates_by_user[user_id][1],\n                )\n                structured_logger.debug(\n                    \"Updated activity counts for user\",\n                    event_code=(\"update_userprofileactivity_from_cache_database_write\"),\n                    user=user_id,\n                )\n        else:\n            structured_logger.debug(\n                \"Cache contained no updates for key. Skipping\",\n                event_code=\"update_userprofileactivity_from_cache_no_updates\",\n                key=key,\n            )\n"
  },
  {
    "path": "concordia/tasks/visualizations.py",
    "content": "import csv\nfrom datetime import timedelta\nfrom io import StringIO\nfrom logging import getLogger\n\nfrom django.core.cache import caches\nfrom django.core.files.base import ContentFile\nfrom django.db.models import Count\nfrom django.utils import timezone\n\nfrom concordia.decorators import locked_task\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import Asset, Campaign, SiteReport, TranscriptionStatus\nfrom concordia.storage import VISUALIZATION_STORAGE\n\nfrom ..celery import app as celery_app\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef populate_asset_status_visualization_cache(self) -> None:\n    \"\"\"\n    Build and cache aggregate asset status counts for active campaigns.\n\n    This task queries live Asset rows for all campaigns that are published,\n    listed and active then aggregates counts by ``transcription_status``. It\n    also writes a CSV export to ``VISUALIZATION_STORAGE`` and stores the\n    following payload in the ``\"visualization_cache\"`` under the\n    ``\"asset-status-overview\"`` key:\n\n        - `status_labels`: [\n                \"Not Started\",\n                \"In Progress\",\n                \"Needs Review\",\n                \"Completed\"\n          ]\n        - `total_counts`: [\n                count_not_started,\n                count_in_progress,\n                count_submitted,\n                count_completed\n          ]\n        - `csv_url`: URL to download a CSV of the data\n    \"\"\"\n    visualization_cache = caches[\"visualization_cache\"]\n    cache_key = \"asset-status-overview\"\n    csv_path = \"visualization_exports/page-status-active-campaigns.csv\"\n\n    structured_logger.debug(\n        \"Starting asset status visualization task.\",\n        event_code=\"asset_status_vis_start\",\n    )\n\n    campaign_ids = list(\n        Campaign.objects.published().listed().active().values_list(\"id\", flat=True)\n    )\n\n    status_keys = [key for key, _ in TranscriptionStatus.CHOICES]\n    status_labels = [TranscriptionStatus.CHOICE_MAP[key] for key in status_keys]\n\n    # Aggregate counts across all active campaigns\n    status_counts_qs = (\n        Asset.objects.filter(campaign_id__in=campaign_ids)\n        .values(\"transcription_status\")\n        .annotate(cnt=Count(\"id\"))\n    )\n    counts_map = {row[\"transcription_status\"]: row[\"cnt\"] for row in status_counts_qs}\n    total_counts = [counts_map.get(status, 0) for status in status_keys]\n\n    structured_logger.debug(\n        \"Aggregated asset counts by status.\",\n        event_code=\"asset_status_vis_counts\",\n        active_campaign_count=len(campaign_ids),\n        total_counts=total_counts,\n    )\n\n    # If data unchanged, skip CSV + cache update\n    existing = visualization_cache.get(cache_key)\n    if isinstance(existing, dict) and existing.get(\"total_counts\") == total_counts:\n        structured_logger.info(\n            \"Asset status data unchanged; skipping CSV and cache update.\",\n            event_code=\"asset_status_vis_unchanged\",\n            total_counts=total_counts,\n        )\n        return\n    elif isinstance(existing, dict):\n        # We want the existing URL in case the upload fails later\n        overview_csv_url = existing.get(\"csv_url\")\n    else:\n        overview_csv_url = None\n\n    overview_csv = StringIO(newline=\"\")\n    overview_writer = csv.writer(overview_csv)\n    overview_writer.writerow([\"Status\", \"Count\"])\n    for label, count in zip(status_labels, total_counts, strict=True):\n        overview_writer.writerow([label, count])\n    overview_csv_content = overview_csv.getvalue()\n\n    try:\n        VISUALIZATION_STORAGE.save(csv_path, ContentFile(overview_csv_content))\n        overview_csv_url = VISUALIZATION_STORAGE.url(csv_path)\n        structured_logger.debug(\n            \"CSV saved for asset status visualization.\",\n            event_code=\"asset_status_vis_csv_saved\",\n            csv_path=csv_path,\n            byte_length=len(overview_csv_content.encode(\"utf-8\")),\n            csv_url=overview_csv_url,\n        )\n    except Exception:\n        if overview_csv_url is None:\n            structured_logger.exception(\n                (\n                    \"CSV upload failed for asset status visualization and \"\n                    \"no existing CSV URL could be determined\"\n                ),\n                event_code=\"asset_status_vis_csv_missing_url_error\",\n                csv_path=csv_path,\n            )\n            raise\n        structured_logger.exception(\n            \"CSV upload failed for asset status visualization.\",\n            event_code=\"asset_status_vis_csv_error\",\n            csv_path=csv_path,\n        )\n\n    # Update cache\n    overview_payload = {\n        \"status_labels\": status_labels,\n        \"total_counts\": total_counts,\n        \"csv_url\": overview_csv_url,\n    }\n    visualization_cache.set(cache_key, overview_payload, None)\n\n    structured_logger.debug(\n        \"Asset status visualization cache updated.\",\n        event_code=\"asset_status_vis_cache_set\",\n        cache_key=cache_key,\n        total_counts=total_counts,\n    )\n\n    structured_logger.debug(\n        \"Asset status visualization task completed successfully.\",\n        event_code=\"asset_status_vis_complete\",\n    )\n\n\n@celery_app.task(bind=True, ignore_result=True)\n@locked_task\ndef populate_daily_activity_visualization_cache(self) -> None:\n    \"\"\"\n    Build and cache a 28 day time series of transcription activity.\n\n    This task queries ``SiteReport`` rows with\n    ``report_name=SiteReport.ReportName.TOTAL`` for the last 28 days\n    (excluding today) and derives per day counts of saved transcriptions and\n    review actions. It writes a CSV export to ``VISUALIZATION_STORAGE`` and\n    stores the following payload in the ``\"visualization_cache\"`` under the\n    ``\"daily-transcription-activity-last-28-days\"`` key.\n\n    The dataset contains:\n\n        - `labels`: [ \"YYYY-MM-DD\", ..., ] (28 dates)\n        - `transcription_datasets`: [\n              {\n                  \"label\": \"Transcriptions\",\n                  \"data\": [ daily_total, daily_total, ... ],\n              },\n              {\n                  \"label\": \"Reviews\",\n                  \"data\": [ daily_total, daily_total, ... ],\n              },\n          ]\n        - `csv_url`: URL to download a CSV of the data\n    \"\"\"\n    visualization_cache = caches[\"visualization_cache\"]\n    cache_key = \"daily-transcription-activity-last-28-days\"\n    csv_path = \"visualization_exports/daily-transcription-activity-last-28-days.csv\"\n\n    structured_logger.debug(\n        \"Starting daily activity visualization task.\",\n        event_code=\"daily_activity_vis_start\",\n    )\n\n    yesterday = timezone.localdate() - timedelta(days=1)\n    start_date = yesterday - timedelta(days=27)\n    date_range = [start_date + timedelta(days=i) for i in range(28)]\n    date_strings = [d.strftime(\"%Y-%m-%d\") for d in date_range]\n\n    reports = SiteReport.objects.filter(\n        report_name=SiteReport.ReportName.TOTAL,\n        created_on__date__in=date_range,\n    )\n\n    # Find the most recent SiteReport BEFORE the first of our dates, if any\n    report_lookup = {\n        timezone.localtime(report.created_on).date(): report for report in reports\n    }\n\n    prev_report = (\n        SiteReport.objects.filter(\n            report_name=SiteReport.ReportName.TOTAL,\n            created_on__date__lt=start_date,\n        )\n        .order_by(\"-created_on\")\n        .first()\n    )\n    prev_cumulative = prev_report.transcriptions_saved if prev_report else 0\n    running_prev = prev_cumulative\n\n    transcriptions = []\n    reviews = []\n\n    for report_date in date_range:\n        sitereport = report_lookup.get(report_date)\n        if sitereport:\n            cumulative = sitereport.transcriptions_saved or 0\n            daily_saved = cumulative - running_prev\n            if daily_saved < 0:\n                daily_saved = 0\n            running_prev = cumulative\n            daily_review = sitereport.daily_review_actions or 0\n        else:\n            daily_saved = 0\n            daily_review = 0\n\n        transcriptions.append(daily_saved)\n        reviews.append(daily_review)\n\n    structured_logger.debug(\n        \"Compiled daily activity series.\",\n        event_code=\"daily_activity_vis_series_compiled\",\n        start_date=start_date.isoformat(),\n        end_date=yesterday.isoformat(),\n        transcriptions_total=sum(transcriptions),\n        reviews_total=sum(reviews),\n    )\n\n    # If data unchanged, skip CSV + cache update\n    existing = visualization_cache.get(cache_key)\n    if isinstance(existing, dict):\n        prev_series = existing.get(\"transcription_datasets\") or []\n        prev_transcriptions = next(\n            (\n                ds.get(\"data\")\n                for ds in prev_series\n                if ds.get(\"label\") == \"Transcriptions\"\n            ),\n            None,\n        )\n        prev_reviews = next(\n            (ds.get(\"data\") for ds in prev_series if ds.get(\"label\") == \"Reviews\"),\n            None,\n        )\n        if prev_transcriptions == transcriptions and prev_reviews == reviews:\n            structured_logger.info(\n                \"Daily activity data unchanged; skipping CSV and cache update.\",\n                event_code=\"daily_activity_vis_unchanged\",\n            )\n            return\n        else:\n            csv_url = existing.get(\"csv_url\")\n    else:\n        csv_url = None\n\n    data = {\n        \"labels\": date_strings,\n        \"transcription_datasets\": [\n            {\"label\": \"Transcriptions\", \"data\": transcriptions},\n            {\"label\": \"Reviews\", \"data\": reviews},\n        ],\n    }\n\n    csv_output = StringIO(newline=\"\")\n    writer = csv.writer(csv_output)\n    writer.writerow([\"Date\", \"Transcriptions\", \"Reviews\"])\n    for i in range(28):\n        writer.writerow([date_strings[i], transcriptions[i], reviews[i]])\n    csv_content = csv_output.getvalue()\n\n    try:\n        VISUALIZATION_STORAGE.save(csv_path, ContentFile(csv_content))\n        csv_url = VISUALIZATION_STORAGE.url(csv_path)\n        structured_logger.debug(\n            \"CSV saved for daily activity visualization.\",\n            event_code=\"daily_activity_vis_csv_saved\",\n            csv_path=csv_path,\n            byte_length=len(csv_content.encode(\"utf-8\")),\n            csv_url=csv_url,\n        )\n    except Exception:\n        if csv_url is None:\n            structured_logger.exception(\n                (\n                    \"CSV upload failed for daily activity visualization and \"\n                    \"no existing CSV URL could be determined\"\n                ),\n                event_code=\"daily_activity_vis_csv_missing_url_error\",\n                csv_path=csv_path,\n            )\n            raise\n        structured_logger.exception(\n            \"CSV upload failed for daily activity visualization.\",\n            event_code=\"daily_activity_vis_csv_error\",\n            csv_path=csv_path,\n        )\n\n    data[\"csv_url\"] = csv_url\n    visualization_cache.set(cache_key, data, None)\n\n    structured_logger.debug(\n        \"Daily activity visualization cache updated.\",\n        event_code=\"daily_activity_vis_cache_set\",\n        cache_key=cache_key,\n    )\n\n    structured_logger.debug(\n        \"Daily activity visualization task completed successfully.\",\n        event_code=\"daily_activity_vis_complete\",\n    )\n"
  },
  {
    "path": "concordia/templates/404.html",
    "content": "{% extends \"error.html\" %}\n\n{% block full_title %}404 Error{% endblock full_title %}\n\n{% block error_message %}\n    <h1>HTTP 404 Error</h1>\n\n    <p>\n        The requested page was not found.\n    </p>\n\n    <nav>\n        <ul class=\"nav justify-content-center\">\n            <li class=\"nav-item\">\n                <a class=\"nav-link\" href=\"{{ request.META.HTTP_REFERER }}\">\n                    &laquo; Go Back\n                </a>\n            </li>\n            <li class=\"nav-item\">\n                <a class=\"nav-link\" href=\"/\">Go Home &raquo;</a>\n            </li>\n        </ul>\n    </nav>\n{% endblock error_message %}\n"
  },
  {
    "path": "concordia/templates/429.html",
    "content": "{% extends \"error.html\" %}\n\n{% block full_title %}429 Error{% endblock full_title %}\n\n{% block error_message %}\n    <h1>HTTP 429: Too Many Requests</h1>\n\n    <p>\n        {% if exception %}\n            {{ exception }}\n        {% else %}\n            {{ error|default:'Please wait a bit, then try again.' }}\n        {% endif %}\n\n    </p>\n    <p>\n        Seeing this page a lot? <a href=\"{% url 'contact' %}\">Contact Us</a>\n    </p>\n{% endblock error_message %}\n"
  },
  {
    "path": "concordia/templates/500.html",
    "content": "{% extends \"error.html\" %}\n\n{% block full_title %}500 Error{% endblock full_title %}\n\n{% block error_message %}\n    <h1>HTTP 500 Error</h1>\n    <p>\n        The server encountered an unexpected condition which prevented\n        it from fulfilling the request. Our staff have been notified\n        about the failure.\n    </p>\n    <nav>\n        <ul class=\"nav justify-content-center\">\n            {% if request %}\n                <li class=\"nav-item\">\n                    <a\n                        class=\"nav-link\"\n                        href=\"{{ request.META.HTTP_REFERER }}\"\n                    >\n                        &laquo; Go Back\n                    </a>\n                </li>\n            {% endif %}\n            <li class=\"nav-item\">\n                <a class=\"nav-link\" href=\"/\">Go Home &raquo;</a>\n            </li>\n        </ul>\n    </nav>\n{% endblock error_message %}\n"
  },
  {
    "path": "concordia/templates/503.html",
    "content": "{% extends \"error.html\" %}\n\n{% load staticfiles %}\n\n{% block full_title %}503 Error{% endblock full_title %}\n\n{% block error_message %}\n    <h1>We're experiencing technical difficulties.</h1>\n    <p>\n        But we're working on it and we should be back soon. Please try again later.\n    </p>\n    <figure class=\"error-figure\">\n        <img src=\"{% static 'img/503.jpg' %}\" />\n        <figcaption>\n            <strong>Operating a hand drill at Vultee-Nashville, woman is working on a \"Vengeance\" dive bomber, Tennessee, February 1943.</strong><br />\n            Palmer, Alfred T., photographer. <a target=\"_blank\" href=\"http://hdl.loc.gov/loc.pnp/fsac.1a35371\" rel=noopener>http://hdl.loc.gov/loc.pnp/fsac.1a35371</a></figcaption>\n    </figure>\n\n    <nav>\n        <ul class=\"nav justify-content-center\">\n            {% if request and request.META.HTTP_REFERER %}\n                <li class=\"nav-item\">\n                    <a\n                        class=\"nav-link\"\n                        href=\"{{ request.META.HTTP_REFERER }}\"\n                    >\n                        &laquo; Go Back\n                    </a>\n                </li>\n            {% endif %}\n            <li class=\"nav-item\">\n                <a class=\"nav-link\" href=\"/\">Go Home &raquo;</a>\n            </li>\n        </ul>\n    </nav>\n{% endblock error_message %}\n"
  },
  {
    "path": "concordia/templates/account/account_deletion.html",
    "content": "{% extends \"base.html\" %}\n\n{% load django_bootstrap5 %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <ul class=\"nav nav-tabs mb-4\" id=\"nav-tab\" role=\"tablist\">\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold\" aria-selected=\"false\" id=\"contributions-tab\" type=\"button\" role=\"tab\" href=\"{% url 'user-profile' %}\">My Contributions</a>\n            </li>\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold\" aria-selected=\"false\" id=\"recent-tab\" type=\"button\" role=\"tab\" aria-controls=\"recent\" href=\"{% url 'user-profile' %}#recent\">Recent Pages Worked On</a>\n            </li>\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold active\" aria-selected=\"true\" id=\"account-tab\" type=\"button\" role=\"tab\" href=\"{% url 'user-profile' %}#account\">Account Settings</a>\n            </li>\n        </ul>\n        <div class=\"row\">\n            <div class=\"col-md-8 mx-auto p-3\">\n\n\n\n            </div>\n        </div>\n        <div class=\"col-12 col-md-10 py-3 mt-4 change-options\">\n            <div class=\"d-flex\">\n                <h2>Delete your account?</h2>\n            </div>\n            <div class=\"d-flex\">\n                <p>By clicking the button below, all of your account information will be permanently removed from our system. This cannot be undone!</p>\n            </div>\n            <form class=\"form\" action=\"{% url 'account-deletion' %}\" method=\"POST\" enctype=\"multipart/form-data\">\n                {% csrf_token %}\n                <div class=\"input-group-append\">\n                    {% bootstrap_button \"Delete Account\" button_type=\"submit\" button_class=\"btn btn-primary rounded-0\" name=\"submit_delete\" %}\n                </div>\n            </form>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/account/email_reconfirmation_failed.html",
    "content": "{% extends \"base.html\" %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <ul class=\"nav nav-tabs mb-4\" id=\"nav-tab\" role=\"tablist\">\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold\" aria-selected=\"false\" id=\"contributions-tab\" type=\"button\" role=\"tab\" href=\"{% url 'user-profile' %}\">My Contributions</a>\n            </li>\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold\" aria-selected=\"false\" id=\"recent-tab\" type=\"button\" role=\"tab\" aria-controls=\"recent\" href=\"{% url 'user-profile' %}#recent\">Recent Pages Worked On</a>\n            </li>\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold active\" aria-selected=\"true\" id=\"account-tab\" type=\"button\" role=\"tab\" href=\"{% url 'user-profile' %}#account\">Account Settings</a>\n            </li>\n        </ul>\n        <div class=\"row\">\n            <div class=\"col-md-8 mx-auto p-3\">\n                <h2>Email Reconfirmation</h2>\n                <p class=\"reconfirmation-error-{{ reconfirmation_error.code }}\">\n                    {{ reconfirmation_error.message }}\n                </p>\n                <p><a href=\"{% url 'user-profile' %}\">&#8810; Return to User Profile</a></p>\n            </div>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/account/profile.html",
    "content": "{% extends \"base.html\" %}\n\n{% load humanize %}\n{% load staticfiles django_vite %}\n{% load django_bootstrap5 %}\n\n{% block prefetch %}\n    <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/@duetds/date-picker@1.4.0/dist/duet/themes/default.css\" />\n{% endblock prefetch %}\n\n{% block title %}User Profile{% endblock title %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item active\" aria-current=\"page\">Account</li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container bg-main profile-page\" data-active-tab=\"{{ active_tab }}\">\n        <ul class=\"nav nav-tabs mb-4\" id=\"nav-tab\" role=\"tablist\">\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold{% if active_tab == 'contributions' %} active{% endif %}\" aria-selected=\"{% if active_tab == 'contributions' %}true{% else %}false{% endif %}\" id=\"contributions-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#contributions\" type=\"button\" role=\"tab\">My Contributions</a>\n            </li>\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold{% if active_tab == 'recent' %} active\" aria-selected=\"true\"{% else %} aria-selected=\"false\"{% endif %} id=\"recent-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#recent\" type=\"button\" role=\"tab\" aria-controls=\"recent\" href=\"#recent\">Recent Pages Worked On</a>\n            </li>\n            <li class=\"nav-item\">\n                <a class=\"nav-link fw-bold{% if active_tab == 'account' %} active\" aria-selected=\"true\"{% else %} aria-selected=\"false\"{% endif %} id=\"account-tab\" data-bs-toggle=\"tab\" data-bs-target=\"#account\" type=\"button\" role=\"tab\" href=\"#account\">Account Settings</a>\n            </li>\n        </ul>\n        <div class=\"tab-content\" id=\"nav-tabContent\">\n            <div class=\"tab-pane fade{% if active_tab == 'account' %} show active{% endif %}\" id=\"account\" role=\"tabpanel\" aria-labelledby=\"account-tab\">\n                <div class=\"row justify-content-start\">\n                    <div class=\"col-12 col-md-10\">\n                        <h2>Account Settings</h2>\n                        <div class=\"mb-2\">\n                            <span class=\"fw-bold\">Username</span>: {{ user.username }}\n                        </div>\n                    </div>\n                    <div class=\"col-12 col-md-10 py-3 change-options\">\n                        <div class=\"mt-1 mb-3\">\n                            <span class=\"fw-bold\">Email address</span>: {{ user.email }}\n                        </div>\n                        {% if unconfirmed_email %}\n                            <div class=\"mt-1 mb-3\">\n                                <span class=\"fw-bold\">Unconfirmed email address</span>: {{ unconfirmed_email }}\n                            </div>\n                        {% endif %}\n                        <form class=\"form needs-validation\" action=\"{% url 'user-profile' %}\" method=\"POST\" enctype=\"multipart/form-data\" novalidate>\n                            {% csrf_token %}\n\n                            <div class=\"input-group mb-3 user-fields\">\n                                <label for=\"id_email\"><span class=\"visually-hidden\">Email</span></label>\n                                <input type=\"email\" name=\"email\" placeholder=\"Change your email address\" class=\"form-control fst-italic\" title=\"\" required=\"\" id=\"id_email\" required>\n                                <div class=\"input-group-append\">\n                                    {% bootstrap_button \"Save Change\" button_type=\"submit\" button_class=\"btn btn-primary rounded-0\" name=\"submit_email\" %}\n                                </div>\n                                {% if valid is True %}\n                                    <div class=\"mt-1 text-success w-100\" id=\"validation-confirmation\"><i class=\"fa fa-check-circle\"></i> Email changed successfully; <strong>Check email to confirm address</strong></div>\n                                {% else %}\n                                    <div class=\"invalid-feedback\" {% if valid is not None and valid is False %}style=\"display: block;\" aria-hidden=\"false\"{%endif %}>\n                                        <i class=\"fa fa-exclamation-circle\"></i> Error in email change\n                                    </div>\n                                {% endif %}\n                            </div>\n                        </form>\n                    </div>\n                    <div class=\"col-12 col-md-10 py-3 mt-4 change-options\">\n                        <div class=\"row justify-content-start\">\n                            <div class=\"btn-row col-md-10\">\n                                <a class=\"btn btn-primary rounded-0\" href=\"{% url 'password_change' %}\">Change Password</a>\n                            </div>\n                        </div>\n                    </div>\n                    <div class=\"col-12 col-md-10 py-3 mt-4 change-options\">\n                        <div class=\"mb-2\">Optional: If you want a name to appear on your service letter, enter first and last name and click \"Save\". To remove name data click \"Save\" without entering any text.</div>\n                        <form class=\"form\" action=\"{% url 'user-profile' %}\" method=\"POST\" enctype=\"multipart/form-data\">\n                            {% csrf_token %}\n                            <div class=\"mb-3 user-fields\">\n                                <label class=\"d-flex mb-2\">\n                                    <span class=\"fw-bold\">First Name</span>: {{ user.first_name }}\n                                </label>\n                                <input name=\"first_name\" placeholder=\"Enter your first name\" class=\"form-control fst-italic\">\n                                <label class=\"mt-2\">\n                                    <span class=\"fw-bold\">Last Name</span>: {{ user.last_name }}\n                                </label>\n                                <input name=\"last_name\" placeholder=\"Enter your last name\" class=\"form-control fst-italic\">\n                            </div>\n                            <div class=\"input-group-append\">\n                                {% bootstrap_button \"Save Changes\" button_type=\"submit\" button_class=\"btn btn-primary rounded-0\" name=\"submit_name\" %}\n                            </div>\n                        </form>\n                    </div>\n                    <div class=\"col-12 col-md-10 py-3 mt-4 change-options\">\n                        <div class=\"row justify-content-start\">\n                            <div class=\"btn-row col-md-10\">\n                                <a class=\"btn btn-primary rounded-0\" href=\"{% url 'account-deletion' %}\">Delete Account</a>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            </div>\n            <div class=\"row tab-pane fade{% if active_tab == 'contributions' %} show active{% endif %}\" id=\"contributions\" role=\"tabpanel\">\n                {% if user_profile_activity %}\n                    <div class=\"d-flex justify-content-start\">\n                        <div class=\"col-md\">\n                            <div class=\"d-flex\">\n                                <h2>My Contributions</h2>\n                            </div>\n                            <div>\n                                <label><b>Account created: </b></label> {{ user.date_joined|date:\"SHORT_DATE_FORMAT\" }}\n                            </div>\n                            <div class=\"d-lg-flex\" style=\"margin-right: -0.5rem; margin-left: -0.5rem;\">\n                                <div class=\"contribution-highlight\">\n                                    <div class=\"value\">{{ user_profile_activity.count|intcomma }}</div>\n                                    <p class=\"label\">Campaigns</p>\n                                    <p>Projects you've worked on</p>\n                                </div>\n                                <div class=\"contribution-highlight\">\n                                    <div class=\"value\">{{ pages_worked_on|intcomma }}</div>\n                                    <p class=\"label\">Pages</p>\n                                    <p>Pages you've worked on</p>\n                                </div>\n                                <div class=\"contribution-highlight\">\n                                    <div class=\"value\">{{ totalCount|intcomma }}</div>\n                                    <p class=\"label\">Actions</p>\n                                    <p>Your saves, submits, and reviews</p>\n                                </div>\n                            </div>\n                            <div class=\"d-flex mt-4\">\n                                <table id=\"tblTranscription\" class=\"table table-striped table-sm table-responsive-sm contribution-table\">\n                                    <thead class=\"border-y\">\n                                        <tr>\n                                            <td></td>\n                                            <th>Campaign</th>\n                                            <th>\n                                                <abbr title=\"Total number of times you saved, submitted a transcription\" class=\"text-decoration-none\">Saves & Submits</abbr>\n                                            </th>\n                                            <th>\n                                                <abbr title=\"Total number of times you reviewed a transcription\" class=\"text-decoration-none\">Reviews</abbr>\n                                            </th>\n                                            <th><abbr title=\"Total number of times you saved, submitted, or reviewed a transcription\" class=\"text-decoration-none\">Total Actions</abbr></th>\n                                        </tr>\n                                    </thead>\n                                    <tbody>\n                                        <tr>\n                                            <td class=\"py-2\"></td>\n                                            <td class=\"campaign all-campaigns py-2\" id=\"-1\">\n                                                <b><a href=\"{% url 'campaign-topic-list' %}\">All Campaigns</a></b>\n                                            </td>\n                                            <td class=\"py-2\"><b>{{ totalTranscriptions|intcomma }}</b></td>\n                                            <td class=\"py-2\"><b>{{ totalReviews|intcomma }}</b></td>\n                                            <td class=\"py-2\"><b>{{ totalCount|intcomma }}</b></td>\n                                        </tr>\n                                        {% for user_campaign in user_profile_activity %}\n                                            <tr>\n                                                <td></td>\n                                                <td>\n                                                    <a class=\"campaign py-2\" id={{user_campaign.campaign.id}} href=\"{% url 'transcriptions:campaign-detail' user_campaign.campaign.slug %}\">\n                                                        {{ user_campaign.campaign.title }}\n                                                    </a>\n                                                </td>\n                                                <td class=\"py-2\">{{ user_campaign.transcribe_count|intcomma }}</td>\n                                                <td class=\"py-2\">{{ user_campaign.review_count|intcomma }}</td>\n                                                <td class=\"py-2\">{{ user_campaign.total_actions|intcomma }}</td>\n                                            </tr>\n                                        {% endfor %}\n                                    </tbody>\n                                </table>\n                            </div>\n                            <div class=\"d-flex justify-content-start bg-light\">\n                                <div class=\"col-12 py-3\">\n                                    <h3>Service Letter</h3>\n                                    <div class=\"mb-3\">Download a letter verifying your volunteer contributions, including a list of your transcription and review activity over the past six months.</div>\n                                    <div class=\"row justify-content-start\">\n                                        <div class=\"btn-row col-md-10\">\n                                            <a class=\"btn btn-primary rounded-0\" href=\"/letter\">Download Letter</a>\n                                        </div>\n                                    </div>\n                                </div>\n                            </div>\n                            <div class=\"d-flex justify-content-start bg-light mt-4\">\n                                <div class=\"col-12 py-3\">\n                                    <h3>Volunteer Hours Spreadsheet</h3>\n                                    <div class=\"mb-3\">The <em>By the People</em> website doesn't track the number of hours you spend volunteering. Download a spreadsheet template that makes it easy keep your own record.</div>\n                                    <div class=\"row justify-content-start\">\n                                        <div class=\"btn-row col-md-10\">\n                                            <a class=\"btn btn-primary rounded-0\" href=\"https://crowd-content.s3.amazonaws.com/cm-uploads/resources/2023/btp_volunteer_hours_log_sheet.xlsx\n                                                                                      \">Download Spreadsheet</a>\n                                        </div>\n                                    </div>\n                                </div>\n                            </div>\n                        </div>\n                    </div>\n                {% endif %}\n            </div>\n\n            <div class=\"tab-pane fade{% if active_tab == 'recent' %} show active{% endif %}\" id=\"recent\" role=\"tabpanel\" aria-labelledby=\"recent-tab\">\n                <div class=\"row justify-content-start\" id=\"recent-pages\"></div>\n            </div>\n        </div>\n    </div>\n{% endblock main_content %}\n\n{% block body_scripts %}\n    <script type=\"module\" src=\"https://cdn.jsdelivr.net/npm/@duetds/date-picker@1.4.0/dist/duet/duet.esm.js\"></script>\n    <script nomodule src=\"https://cdn.jsdelivr.net/npm/@duetds/date-picker@1.4.0/dist/duet/duet.js\"></script>\n    {{ block.super }}\n    {% vite_asset 'src/profile.js' %}\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/admin/auth/user/change_form.html",
    "content": "{% extends \"admin/change_form.html\" %}\n\n{% load i18n admin_urls humanize %}\n\n{% block object-tools-items %}\n    {% if original.pk %}\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_transcription_changelist' %}?user__id__exact={{ original.pk }}\">\n                Transcriptions\n            </a>\n        </li>\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_transcription_changelist' %}?reviewed_by__id__exact={{ original.pk }}\">\n                Reviews\n            </a>\n        </li>\n    {% endif %}\n    {{ block.super }}\n{% endblock object-tools-items %}\n"
  },
  {
    "path": "concordia/templates/admin/base_site.html",
    "content": "{% extends \"admin/base.html\" %}\n\n{% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}\n\n{% block branding %}\n    <h1 id=\"site-name\"><a href=\"{% url 'admin:index' %}\">{{ site_header|default:_('Django administration') }}</a></h1>\n{% endblock %}\n\n{% block nav-global %}{% endblock %}\n\n{% block extrahead %}\n    <style>\n        .view-parent-object::after {\n            content: \" ⤴️\";\n        }\n\n        .view-related-objects::after {\n            content: \" 🔎\";\n        }\n        .long-name-filter li {\n            list-style-type: circle !important;\n            list-style-position: inside !important;\n        }\n        .long-name-filter a {\n            display: inline !important;\n            margin-left: -0.5em;\n        }\n    </style>\n{% endblock %}\n\n{% block messages %}\n    {% if messages %}\n        <ul class=\"messagelist\">\n            {% for message in messages %}\n        {# Remove mark-safe from tags since that's for controlling template behavior #}\n                {% with message.tags|reject:\"mark-safe\"|join:\" \" as cleaned_tags %}\n                    <li {% if cleaned_tags %} class=\"{{ cleaned_tags }}\">{% endif %}\n                    {% if \"marked-safe\" in message.tags %}\n                        {{ message|safe|capfirst }}\n                    {% else %}\n                        {{ message|capfirst }}\n                    {% endif %}\n                    </li>\n                {% endwith %}\n            {% endfor %}\n        </ul>\n    {% endif %}\n{% endblock messages %}\n"
  },
  {
    "path": "concordia/templates/admin/bulk_change.html",
    "content": "{% extends \"admin/base_site.html\" %}\n\n{% block messages %}\n    {% comment %} This is displayed elswhere {% endcomment %}\n{% endblock messages %}\n\n{% block extrahead %}\n    {{ block.super }}\n    <style>\n        .message-error, .message-warning {\n            font-weight: bold;\n        }\n\n        .message-error {\n            color: #dc3545;\n        }\n\n        .message-warning {\n            color: #ffc107;\n        }\n    </style>\n{% endblock %}\n\n{% block content %}\n    <div id=\"content\" class=\"colM\">\n        <div>\n            <ol>\n                <li>The spreadsheet should be in xlsx (not xls or csv) format.</li>\n                <li>The spreadsheet should have a header row. One of the columns headers should be \"asset__slug\", and another should be \"New Status\".</li>\n                <li>If the \"user\" column is set, that value will be used as the submitting user. Otherwise, the anonymouse user will be used.</li>\n            </ol>\n            <form method=\"post\" enctype=\"multipart/form-data\">\n                {% csrf_token %}\n                {{ form.as_table }}\n                <div class=\"submit-row\">\n                    <input type=\"submit\" value=\"import!\" class=\"default\" />\n                </div>\n            </form>\n        </div>\n\n        {% if messages %}\n            <h4>Messages</h4>\n            <ul>\n                {% for message in messages %}\n                    <li class=\"message {% if message.level >= DEFAULT_MESSAGE_LEVELS.ERROR %}message-error{% elif message.level >= DEFAULT_MESSAGE_LEVELS.WARNING %}message-warning{% endif %}\">{{ message }}</li>\n                {% endfor %}\n            </ul>\n        {% endif %}\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/bulk_import.html",
    "content": "{% extends \"admin/base.html\" %}\n\n{% block messages %}\n    {% comment %} This is displayed elswhere {% endcomment %}\n{% endblock messages %}\n\n{% block extrahead %}\n    {{ block.super }}\n    <style>\n        .message-error, .message-warning {\n            font-weight: bold;\n        }\n\n        .message-error {\n            color: #dc3545;\n        }\n\n        .message-warning {\n            color: #ffc107;\n        }\n    </style>\n{% endblock %}\n\n{% block content %}\n    <div id=\"content-main\">\n        {% if import_jobs %}\n            <h2>Import Tasks</h2>\n            <ul>\n                {% for import_job in import_jobs %}\n                    <li>\n                        <a target=\"_blank\" rel=noopener href=\"{% url 'admin:importer_importjob_change' object_id=import_job.pk %}\">{{ import_job }}</a>\n                    </li>\n                {% endfor %}\n            </ul>\n        {% else %}\n            <p>\n                The spreadsheet must follow this convention:\n                <ol>\n                    <li>A header row must include the columns Campaign, Campaign Short Description, Campaign Long Description, Campaign Slug, Project Slug, Project, Project Description, and Import URLs</li>\n                    <li>The header names are case sensitive but may occur in any order and other columns will be ignored</li>\n                    <li>Project titles (in the Project column) must be 80 characters or less.</li>\n                    <li>The Campaign, Project, and Import URLs columns must have values or the row will be skipped</li>\n                    <li>\n                        The Import URLs column may contain one or more URLs\n                        separated by spaces or newlines. Do not include commas or\n                        semicolons as those are valid URL characters and will be\n                        treated as part of the URL.\n                    </li>\n                    <li>\n                        Campaigns and Projects will be created if they do not\n                        exist but existing records will not be modified. If you\n                        want to recreate them, delete the old records before\n                        running the importer.\n                    </li>\n                    <li>\n                        Items will be added to projects but items which have\n                        already been imported into that project will be skipped.\n                        (Unless the redownload option is checked below.)\n                        This means that you can add multiple items to a project\n                        both by having the “Import URLs” cell contain multiple\n                        URLs or by duplicating the row with new ”Import URLs”\n                        values.\n                    </li>\n                </ol>\n            </p>\n\n            <div>\n                <form method=\"post\" enctype=\"multipart/form-data\">\n                    {% csrf_token %}\n                    {{ form.as_p }}\n                    <div class=\"submit-row\">\n                        <button type=\"submit\">Import!</button>\n                    </div>\n                </form>\n            </div>\n        {% endif %}\n\n\n        {% if messages %}\n            <h4>Messages</h4>\n            <ul>\n                {% for message in messages %}\n                    <li class=\"message {% if message.level >= DEFAULT_MESSAGE_LEVELS.ERROR %}message-error{% elif message.level >= DEFAULT_MESSAGE_LEVELS.WARNING %}message-warning{% endif %}\">{{ message }}</li>\n                {% endfor %}\n            </ul>\n        {% endif %}\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/bulk_review.html",
    "content": "{% extends \"admin/base.html\" %}\n\n{% block messages %}\n    {% comment %} This is displayed elswhere {% endcomment %}\n{% endblock messages %}\n\n{% block extrahead %}\n    {{ block.super }}\n    <style>\n        .message-error, .message-warning {\n            font-weight: bold;\n        }\n\n        .message-error {\n            color: #dc3545;\n        }\n\n        .message-warning {\n            color: #ffc107;\n        }\n    </style>\n{% endblock %}\n\n{% block content %}\n    <div id=\"content-main\">\n        {% if import_jobs %}\n            <h2>Import Tasks</h2>\n            <ul>\n                {% for import_job in import_jobs %}\n                    <li>\n                        <a target=\"_blank\" rel=noopener href=\"{% url 'admin:importer_importjob_change' object_id=import_job.pk %}\">{{ import_job }}</a>\n                    </li>\n                {% endfor %}\n            </ul>\n        {% else %}\n            <p>\n                The spreadsheet must follow this convention:\n                <ol>\n                    <li>A header row must include the columns Campaign, Campaign Short Description, Campaign Long Description, Campaign Slug, Project Slug, Project, Project Description, and Import URLs</li>\n                    <li>The header names are case sensitive but may occur in any order and other columns will be ignored</li>\n                    <li>Project titles (in the Project column) must be 80 characters or less.</li>\n                    <li>The Campaign, Project, and Import URLs columns must have values or the row will be skipped</li>\n                    <li>\n                        The Import URLs column may contain one or more URLs\n                        separated by spaces or newlines. Do not include commas or\n                        semicolons as those are valid URL characters and will be\n                        treated as part of the URL.\n                    </li>\n                    <li>\n                        Campaigns and Projects will be created if they do not\n                        exist but existing records will not be modified. If you\n                        want to recreate them, delete the old records before\n                        running the importer.\n                    </li>\n                    <li>\n                        Items will be added to projects but items which have\n                        already been imported into that project will be skipped.\n                        This means that you can add multiple items to a project\n                        both by having the “Import URLs” cell contain multiple\n                        URLs or by duplicating the row with new ”Import URLs”\n                        values.\n                    </li>\n                </ol>\n            </p>\n\n            <div>\n                <form method=\"post\" enctype=\"multipart/form-data\">\n                    {% csrf_token %}\n                    {{ form.as_p }}\n                    <div class=\"submit-row\">\n                        <button type=\"submit\">Review!</button>\n                    </div>\n                </form>\n            </div>\n        {% endif %}\n\n\n        {% if messages %}\n            <h4>Messages</h4>\n            <ul>\n                {% for message in messages %}\n                    <li class=\"message {% if message.level >= DEFAULT_MESSAGE_LEVELS.ERROR %}message-error{% elif message.level >= DEFAULT_MESSAGE_LEVELS.WARNING %}message-warning{% endif %}\">{{ message }}</li>\n                {% endfor %}\n            </ul>\n        {% endif %}\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/celery_task.html",
    "content": "{% extends \"admin/base.html\" %}\n\n{% block messages %}\n    {% comment %} This is displayed elswhere {% endcomment %}\n{% endblock messages %}\n\n{% block extrahead %}\n    {{ block.super }}\n    <style>\n        .message-error,\n        .message-warning {\n            font-weight: bold;\n        }\n\n        .message-error {\n            color: #dc3545;\n        }\n\n        .message-warning {\n            color: #ffc107;\n        }\n    </style>\n{% endblock %}\n\n{% block content %}\n    <div id=\"content-main\">\n        {% if campaigns %}\n            <h2>Importer Progress</h2>\n            <ul>\n                <table>\n                    <thead>\n                        <tr>\n                            <th>Campaign Title</th>\n                            <th></th>\n                        </tr>\n                    </thead>\n                    {% for campaign in campaigns %}\n                        <tr>\n                            <td>{{ campaign.title }}</td>\n                            <td><a href=\"?id={{ campaign.id}}\">Check Progress</a></td>\n                        </tr>\n                    {% endfor %}\n                </table>\n            </ul>\n\n        {% else %}\n\n            {% if projects %}\n                <span><a href=\"/admin/celery-review\">All Campaigns</a></span>\n                <h2>Projects</h2>\n                <ul>\n                    <table>\n                        <thead>\n                            <tr>\n                                <th>Project Title</th>\n                                <th>Successful</th>\n                                <th>Started-Incomplete</th>\n                                <th>Started-Failed</th>\n                                <th>Unstarted</th>\n                                <th></th>\n                            </tr>\n                        </thead>\n                        {% for project in projects %}\n                            <tr>\n                                <td>{{ project.title }}</td>\n                                <td>{{ project.successful }}</td>\n                                <td><a\n                                    href=\"/admin/importer/importitemasset/?completed=null&import_item__job__project__campaign__id__exact={{project.campaign_id}}&import_item__job__project__in={{ project.id}}&last_started=not-null\">\n                                    {{ project.incomplete }}</a></td>\n                                <td><a\n                                    href=\"/admin/importer/importitemasset/?failed=not-null&import_item__job__project__campaign__id__exact={{project.campaign_id}}&import_item__job__project__in={{ project.id}}&last_started=not-null\">\n                                    {{ project.failure }}</a></td>\n                                <td><a\n                                    href=\"/admin/importer/importitemasset/?import_item__job__project__campaign__id__exact={{project.campaign_id}}&import_item__job__project__in={{ project.id}}&last_started=null\">\n                                    {{ project.unstarted }}</a></td>\n                            </tr>\n                        {% endfor %}\n                    </table>\n                </ul>\n\n                <span>Total Assets: {{ totalassets }}</span>\n\n            {% endif %}\n        {% endif %}\n        {% if messages %}\n            <h4>Messages</h4>\n            <ul>\n                {% for message in messages %}\n                    <li\n                        class=\"message {% if message.level >= DEFAULT_MESSAGE_LEVELS.ERROR %}message-error{% elif message.level >= DEFAULT_MESSAGE_LEVELS.WARNING %}message-warning{% endif %}\">\n                        {{ message }}</li>\n                {% endfor %}\n            </ul>\n        {% endif %}\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/clear_cache.html",
    "content": "{% extends \"admin/base.html\" %}\n\n{% block extrahead %}\n    {{ block.super }}\n    <style>\n        .message-error, .message-warning {\n            font-weight: bold;\n        }\n\n        .message-error {\n            color: #dc3545;\n        }\n\n        .message-warning {\n            color: #ffc107;\n        }\n    </style>\n{% endblock %}\n\n{% block content %}\n    <div id=\"content-main\">\n        <p>\n            Don't do this if you don't know what you're doing.\n        </p>\n        <p><strong>\n            Don't do this if you don't know what you're doing.\n        </strong>\n        </p>\n        <div>\n            <form method=\"post\">\n                {% csrf_token %}\n                {{ form.as_p }}\n                <div class=\"submit-row\">\n                    <button type=\"submit\">Clear Cache</button>\n                </div>\n            </form>\n        </div>\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/asset/change_form.html",
    "content": "{% extends \"admin/change_form.html\" %}\n\n{% load i18n admin_urls humanize %}\n\n{% block object-tools-items %}\n    {% if original.pk %}\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_campaign_change' original.item.project.campaign_id %}\">\n                Campaign\n            </a>\n        </li>\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_project_change' original.item.project_id %}\">\n                Project\n            </a>\n        </li>\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_item_change' original.item_id %}\">\n                Item\n            </a>\n        </li>\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_transcription_changelist' %}?asset__id__exact={{ original.pk }}\">\n                Transcriptions\n            </a>\n        </li>\n    {% endif %}\n    {{ block.super }}\n{% endblock object-tools-items %}\n\n{% block content %}\n    {% if original %}\n        <form action=\"{% url 'admin:concordia_asset_changelist' %}\" method=\"post\" style=\"display:inline;\">\n            {% csrf_token %}\n\n            {# Manually create the hidden PK field #}\n            <input type=\"hidden\" name=\"_selected_action\" value=\"{{ original.pk }}\"></input>\n\n            {# We use this to send the user back to this page instead of leaving them on the changelist #}\n            <input type=\"hidden\" name=\"next\" value=\"{% url 'admin:concordia_asset_change' original.pk %}\"></input>\n\n            {{ status_action_form.action }}\n            <button type=\"submit\" class=\"button\">Change status</button>\n        </form>\n    {% endif %}\n\n    <h4>Current status: {{ original.get_transcription_status_display }}</h4>\n\n    {{ block.super }}\n\n    <h4>Current status: {{ original.transcription_status }}</h4>\n\n    {% if transcriptions %}\n        <table>\n            <caption>Transcription History</caption>\n            <thead>\n                <tr>\n                    <th>ID</th>\n                    <th>Creator</th>\n                    <th>Created</th>\n                    <th>Updated</th>\n                    <th>Submitted</th>\n                    <th>Review Status</th>\n                </tr>\n            </thead>\n            <tbody>\n                {% for t in transcriptions %}\n                    <tr>\n                        <th><a href=\"{% url 'admin:concordia_transcription_change' t.id %}\">{{ t.id }}</a></th>\n                        <th>{{ t.user }}</th>\n                        <td>{{ t.created_on|naturaltime }}</td>\n                        <td>{{ t.updated_on|naturaltime }}</td>\n                        <td>{{ t.submitted|naturaltime|default:'' }}</td>\n                        <td>\n                            {% if t.rejected %}\n                                Rejected\n                            {% elif t.accepted %}\n                                Accepted\n                            {% endif %}\n                            {% if t.rejected or t.accepted %}\n                                by {{ t.reviewed_by }}\n                            {% endif %}\n                            {% if t.rejected %}\n                                {{ t.rejected|naturaltime }}\n                            {% elif t.accepted %}\n                                {{ t.accepted|naturaltime }}\n                            {% endif %}\n                        </td>\n                    </tr>\n                {% endfor %}\n            </tbody>\n        </table>\n    {% endif %}\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/asset/change_list.html",
    "content": "{% extends \"admin/change_list.html\" %}\n\n{% block result_list %}\n    {% block pagination %} {{ block.super }} {% endblock %}\n    {{ block.super }}\n{% endblock %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/campaign/change_form.html",
    "content": "{% extends \"admin/change_form.html\" %}\n\n{% load i18n admin_urls static %}\n\n{% block object-tools-items %}\n    {% if original.pk %}\n        <li>\n            <a href=\"{% url 'admin:concordia_campaign_export-csv' original.slug %}\" class=\"viewsitelink\">\n                Export CSV\n            </a>\n        </li>\n        <li>\n            <a href=\"{% url 'admin:concordia_campaign_export-bagit' original.slug %}\" class=\"viewsitelink\">\n                Export BagIt\n            </a>\n        </li>\n        <li>\n            <a href=\"{% url 'admin:concordia_campaign_report' original.slug %}\" class=\"viewsitelink\">\n                Report\n            </a>\n        </li>\n        {% if perms.concordia.retire_campaign and original.status != 1 %}\n            {# Hide if campaign is active #}\n            <li>\n                <a href=\"{% url 'admin:concordia_campaign_retire' original.slug %}\" class=\"viewsitelink\">\n                    Retire\n                </a>\n            </li>\n        {% endif %}\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_project_changelist' %}?campaign__id__exact={{ original.pk }}\">\n                Projects\n            </a>\n        </li>\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_item_changelist' %}?project__campaign__id__exact={{ original.pk }}\">\n                Items\n            </a>\n        </li>\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_asset_changelist' %}?item__project__campaign__id__exact={{ original.pk }}\">\n                Assets\n            </a>\n        </li>\n    {% endif %}\n    {{ block.super }}\n{% endblock %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/campaign/retire.html",
    "content": "{% extends \"admin/base_site.html\" %}\n{% load i18n admin_urls static %}\n\n{% block extrahead %}\n    {{ block.super }}\n    {{ media }}\n    <script src=\"{% static 'admin/js/cancel.js' %}\" async></script>\n{% endblock %}\n\n{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} delete-confirmation{% endblock %}\n\n{% block breadcrumbs %}\n    <div class=\"breadcrumbs\">\n        <a href=\"{% url 'admin:index' %}\">Home</a>\n        &rsaquo; <a href=\"{% url 'admin:app_list' app_label=opts.app_label %}\">{{ opts.app_config.verbose_name }}</a>\n        &rsaquo; <a href=\"{% url opts|admin_urlname:'changelist' %}\">{{ opts.verbose_name_plural|capfirst }}</a>\n        &rsaquo; <a href=\"{% url opts|admin_urlname:'change' object.pk|admin_urlquote %}\">{{ object|truncatewords:\"18\" }}</a>\n        &rsaquo; Retire\n    </div>\n{% endblock %}\n\n{% block content %}\n    {% block delete_confirm %}\n        <p>Are you sure you want to retire the {{ object_name }} \"{{ object }}\"? All of the following related items will be deleted:</p>\n        {% include \"admin/includes/object_delete_summary.html\" %}\n        <form method=\"post\">{% csrf_token %}\n            <div>\n                <input type=\"hidden\" name=\"post\" value=\"yes\">\n                <input type=\"submit\" value=\"Yes, I’m sure\">\n                <a href=\"#\" class=\"button cancel-link\">No, take me back</a>\n            </div>\n        </form>\n    {% endblock %}\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/item/change_form.html",
    "content": "{% extends \"admin/change_form.html\" %}\n\n{% load i18n admin_urls static %}\n\n{% block object-tools-items %}\n    {% if original.pk %}\n        <li>\n            <a href=\"{% url 'transcriptions:item-export-bagit' original.project.campaign.slug original.project.slug original.item_id %}\" class=\"viewsitelink\">\n                Export BagIt\n            </a>\n        </li>\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_campaign_change' original.project.campaign_id %}\">\n                Campaign\n            </a>\n        </li>\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_project_change' original.project_id %}\">\n                Project\n            </a>\n        </li>\n\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_asset_changelist' %}?item__id__exact={{ original.pk }}\">\n                Assets\n            </a>\n        </li>\n\n    {% endif %}\n    {{ block.super }}\n{% endblock %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/project/change_form.html",
    "content": "{% extends \"admin/change_form.html\" %}\n\n{% load i18n admin_urls static %}\n\n{% block object-tools-items %}\n    {% if original.pk %}\n        <li>\n            <a href=\"{% url 'admin:concordia_project_export-csv' original.campaign.slug original.slug %}\" class=\"viewsitelink\">\n                Export CSV\n            </a>\n        </li>\n        <li>\n            <a href=\"{% url 'transcriptions:project-export-bagit' original.campaign.slug original.slug %}\" class=\"viewsitelink\">\n                Export BagIt\n            </a>\n        </li>\n        <li>\n            <a href=\"{% url 'admin:concordia_project_item-import' original.pk %}\" class=\"viewsitelink\">\n                Import Items\n            </a>\n        </li>\n\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_campaign_change' original.campaign_id %}\">\n                Campaign\n            </a>\n        </li>\n\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_item_changelist' %}?project__id__exact={{ original.pk }}\">\n                Items\n            </a>\n        </li>\n\n        <li>\n            <a class=\"view-related-objects\" href=\"{% url 'admin:concordia_asset_changelist' %}?item__project__id__exact={{ original.pk }}\">\n                Assets\n            </a>\n        </li>\n    {% endif %}\n    {{ block.super }}\n{% endblock %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/project/item_import.html",
    "content": "{% extends \"admin/change_form.html\" %}\n\n{% load i18n admin_urls %}\n\n\n{% block content %}\n    <div id=\"content-main\">\n        {% if import_job %}\n            <p>\n                Task ID <a target=\"_blank\" rel=noopener href=\"{% url 'admin:importer_importjob_change' object_id=import_job.pk %}\">{{ import_job }}</a>\n                created to import <a target=\"_blank\" rel=noopener href=\"{{ form.cleaned_data.import_url }}\">{{ form.cleaned_data.import_url }}</a>\n            </p>\n            <ul>\n                <li>\n                    <a target=\"_blank\" rel=noopener href=\"{% url 'admin:concordia_item_changelist' %}?project__pk={{ object_id }}\">\n                        View Project Items\n                    </a>\n                </li>\n                <li>\n                    <a target=\"_blank\" rel=noopener href=\"{% url 'admin:concordia_asset_changelist' %}?project__pk={{ object_id }}\">\n                        View Project Assets\n                    </a>\n                </li>\n            </ul>\n        {% else %}\n            <form id=\"import-items\" method=\"post\">\n                {% csrf_token %}\n\n                <input type=\"hidden\" name=\"project-id\" value=\"{{ object_id }}\">\n\n                {{ form.non_field_errors }}\n\n                {% if form.errors %}\n                    <p>Please fix the errors below:</p>\n                {% endif %}\n\n                <div class=\"row\">\n                    <div class=\"fieldBox field-import_url\">\n                        <label for=\"{{ form.import_url.id_for_label }}\">{{ form.import_url.label }}</label>\n                        {{ form.import_url }}\n\n                        <ul class=\"error\">\n                            {% for error in form.import_url.errors %}\n                                <li>{{ error }}</li>\n                            {% endfor %}\n                        </ul>\n\n                        <ul>\n                            <li onclick=\"document.getElementById('{{ form.import_url.id_for_label }}').value = 'https://www.loc.gov/item/mss859430231'\">https://www.loc.gov/item/mss859430231</li>\n                            <li onclick=\"document.getElementById('{{ form.import_url.id_for_label }}').value = 'https://www.loc.gov/collections/branch-rickey-papers/'\">https://www.loc.gov/collections/branch-rickey-papers/</li>\n                            <li onclick=\"document.getElementById('{{ form.import_url.id_for_label }}').value = 'https://www.loc.gov/item/mss859430231'\">https://www.loc.gov/item/mss859430231</li>\n                            <li onclick=\"document.getElementById('{{ form.import_url.id_for_label }}').value = 'https://www.loc.gov/search/?q=group%3Amal&amp;fa=online-format!%3Aonline+text'\">https://www.loc.gov/search/?q=group%3Amal&amp;fa=online-format!%3Aonline+text</li>\n                        </ul>\n                    </div>\n                </div>\n\n                <div class=\"submit-row\">\n                    <input type=\"submit\" value=\"Import\" class=\"default\">\n                </div>\n            </form>\n        {% endif %}\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/simplepage/change_form.html",
    "content": "{% extends \"admin/change_form.html\" %}\n\n{% block extrahead %}\n    {{ block.super }}\n\n    {% include 'fragments/codemirror.html' %}\n{% endblock extrahead %}\n\n{% block content %}\n    {{ block.super }}\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/concordia/transcription/change_form.html",
    "content": "{% extends \"admin/change_form.html\" %}\n\n{% load i18n admin_urls humanize %}\n\n{% block object-tools-items %}\n    {% if original.pk %}\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_campaign_change' original.asset.item.project.campaign_id %}\">\n                Campaign\n            </a>\n        </li>\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_project_change' original.asset.item.project_id %}\">\n                Project\n            </a>\n        </li>\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_item_change' original.asset.item_id %}\">\n                Item\n            </a>\n        </li>\n        <li>\n            <a class=\"view-parent-object\" href=\"{% url 'admin:concordia_asset_change' original.asset.pk %}\">\n                Asset\n            </a>\n        </li>\n        {% if original.supersedes_id %}\n            <li>\n                <a class=\"view-related-object\" href=\"{% url 'admin:concordia_transcription_change' original.supersedes_id %}\">\n                    Previous Version\n                </a>\n            </li>\n        {% endif %}\n        {% with original.superseded_by.first as superseded_by %}\n            {% if superseded_by %}\n                <li>\n                    <a class=\"view-related-object\" href=\"{% url 'admin:concordia_transcription_change' superseded_by.pk %}\">\n                        Next Version\n                    </a>\n                </li>\n            {% endif %}\n        {% endwith %}\n    {% endif %}\n    {{ block.super }}\n{% endblock object-tools-items %}\n"
  },
  {
    "path": "concordia/templates/admin/index.html",
    "content": "{% extends 'admin/index.html' %}\n\n{% load static i18n %}\n\n{% block sidebar %}\n    <div id=\"content-related\">\n        <div class=\"module\" id=\"custom-actions\">\n            <h2>Site Operations</h2>\n            <ul>\n                <li><a href=\"{% url 'admin:bulk-import' %}\">Bulk Import Items</a></li>\n                <li><a href=\"{% url 'admin:celery-review' %}\">Importer Progress</a></li>\n                <li><a href=\"{% url 'admin:site-report' %}\">Site Report</a></li>\n                <li><a href=\"{% url 'admin:retired-site-report' %}\">Retired Site Report</a></li>\n                <li><a href=\"{% url 'admin:project-level-export' %}\">Project Level Export</a></li>\n                {% if user.is_superuser %}\n                    {% if maintenance_mode %}\n                        <li><a href=\"{% url 'maintenance_mode_off' %}\">Disable Maintenance Mode</a></li>\n                    {% else %}\n                        <li><a href=\"{% url 'maintenance_mode_on' %}\">Enable Maintenance Mode</a></li>\n                    {% endif %}\n                    {% if maintenance_mode_frontend_available %}\n                        <li><a href=\"{% url 'maintenance_mode_frontend_unavailable' %}\">Disable Front-end During Maintenance Mode</a></li>\n                    {% else %}\n                        <li><a href=\"{% url 'maintenance_mode_frontend_available' %}\">Enable Front-end During Maintenance Mode</a></li>\n                    {% endif %}\n                    <li><a href=\"{% url 'admin:clear-cache' %}\">Clear Caches</a></li>\n                    <li><a href=\"{% url 'admin:bulk-change' %}\">Bulk Change Status of Assets</a></li>\n                {% endif %}\n            </ul>\n        </div>\n        <div class=\"module\" id=\"recent-actions-module\">\n            <h2>{% trans 'Recent actions' %}</h2>\n            <h3>{% trans 'My actions' %}</h3>\n            {% load log %}\n            {% get_admin_log 10 as admin_log for_user user %}\n            {% if not admin_log %}\n                <p>{% trans 'None available' %}</p>\n            {% else %}\n                <ul class=\"actionlist\">\n                    {% for entry in admin_log %}\n                        <li class=\"{% if entry.is_addition %}addlink{% endif %}{% if entry.is_change %}changelink{% endif %}{% if entry.is_deletion %}deletelink{% endif %}\">\n                            {% if entry.is_deletion or not entry.get_admin_url %}\n                                {{ entry.object_repr }}\n                            {% else %}\n                                <a href=\"{{ entry.get_admin_url }}\">{{ entry.object_repr }}</a>\n                            {% endif %}\n                            <br>\n                            {% if entry.content_type %}\n                                <span class=\"mini quiet\">{% filter capfirst %}{{ entry.content_type }}{% endfilter %}</span>\n                            {% else %}\n                                <span class=\"mini quiet\">{% trans 'Unknown content' %}</span>\n                            {% endif %}\n                        </li>\n                    {% endfor %}\n                </ul>\n            {% endif %}\n        </div>\n        <div class=\"module\" id=\"version-module\">\n            <h2>Application Version</h2>\n            <p><small>{{ APPLICATION_VERSION }}</small></p>\n        </div>\n    </div>\n{% endblock sidebar%}\n"
  },
  {
    "path": "concordia/templates/admin/long_name_filter.html",
    "content": "{% load i18n %}\n<h3>{% blocktranslate with filter_title=title %} By {{ filter_title }} {% endblocktranslate %}</h3>\n<ul class=\"long-name-filter\">\n    {% for choice in choices %}\n        <li{% if choice.selected %} class=\"selected\"{% endif %}>\n            <a href=\"{{ choice.query_string|iriencode }}\">{{ choice.display }}</a></li>\n    {% endfor %}\n</ul>\n"
  },
  {
    "path": "concordia/templates/admin/process_bagit.html",
    "content": "{% extends \"admin/base.html\" %}\n\n{% block messages %}\n    {% comment %} This is displayed elswhere {% endcomment %}\n{% endblock messages %}\n\n{% block extrahead %}\n    {{ block.super }}\n    <style>\n        .message-error, .message-warning {\n            font-weight: bold;\n        }\n\n        .message-error {\n            color: #dc3545;\n        }\n\n        .message-warning {\n            color: #ffc107;\n        }\n    </style>\n{% endblock %}\n\n{% block content %}\n    <div id=\"content-main\">\n\n        <p><strong>\n            This feature will accept a zip file, process and convert to Loc.gov structure and re-zip it back\n        </strong>\n        </p>\n        <div>\n            <form method=\"post\" enctype=\"multipart/form-data\">\n                {% csrf_token %}\n                {{ form.as_p }}\n                <div class=\"submit-row\">\n                    <button type=\"submit\">Process Bagit</button>\n                </div>\n            </form>\n        </div>\n\n\n        {% if messages %}\n            <h4>Messages</h4>\n            <ul>\n                {% for message in messages %}\n                    <li class=\"message {% if message.level >= DEFAULT_MESSAGE_LEVELS.ERROR %}message-error{% elif message.level >= DEFAULT_MESSAGE_LEVELS.WARNING %}message-warning{% endif %}\">{{ message }}</li>\n                {% endfor %}\n            </ul>\n        {% endif %}\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/admin/project_level_export.html",
    "content": "{% extends \"admin/base.html\" %}\n\n{% block messages %}\n    {% comment %} This is displayed elswhere {% endcomment %}\n{% endblock messages %}\n\n{% block extrahead %}\n    {{ block.super }}\n    <style>\n        .message-error,\n        .message-warning {\n            font-weight: bold;\n        }\n\n        .message-error {\n            color: #dc3545;\n        }\n\n        .message-warning {\n            color: #ffc107;\n        }\n    </style>\n{% endblock %}\n\n{% block content %}\n    <div id=\"content-main\">\n        {% if campaigns %}\n            <h2>Campaigns</h2>\n            <ul>\n                <table>\n                    <thead>\n                        <tr>\n                            <th>Campaign Title</th>\n                            <th></th>\n                        </tr>\n                    </thead>\n                    {% for campaign in campaigns %}\n                        <tr>\n                            <td>{{ campaign.title }}</td>\n                            <td><a href=\"?id={{ campaign.id}}&slug={{ campaign.slug }}\">List Projects</a></td>\n                        </tr>\n                    {% endfor %}\n                </table>\n            </ul>\n\n        {% else %}\n\n            {% if projects %}\n                <span><a href=\"/admin/project-level-export\">All Campaigns</a></span>\n                <h2>Projects</h2>\n                <form method=\"post\" id=\"registration-form\" class=\"form-register\">\n                    {% csrf_token %}\n                    <ul>\n                        <table>\n                            <thead>\n                                <tr>\n                                    <th></th>\n                                    <th>Project Title</th>\n                                    <th></th>\n                                </tr>\n                            </thead>\n                            {% for project in projects %}\n                                <tr>\n                                    <td><input type=\"checkbox\" id=\"{{project.id}}\" name=\"project_name\" value=\"{{project.id}}\"></td>\n                                    <td>{{ project.title }}</td>\n\n                                </tr>\n                            {% endfor %}\n                        </table>\n                        <input type=\"submit\" value=\"Submit\">\n                    </ul>\n\n                </form>\n\n            {% endif %}\n        {% endif %}\n        {% if messages %}\n            <h4>Messages</h4>\n            <ul>\n                {% for message in messages %}\n                    <li\n                        class=\"message {% if message.level >= DEFAULT_MESSAGE_LEVELS.ERROR %}message-error{% elif message.level >= DEFAULT_MESSAGE_LEVELS.WARNING %}message-warning{% endif %}\">\n                        {{ message }}</li>\n                {% endfor %}\n            </ul>\n        {% endif %}\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "concordia/templates/base.html",
    "content": "{% spaceless %}\n    {% load static django_vite %}\n{% endspaceless %}<!DOCTYPE html>\n<html lang=\"{{ language_code|default:'en'}}\">\n    <head>\n        <meta charset=\"UTF-8\">\n        <meta name=\"viewport\" content=\"width=device-width, initial-scale=1,\n                                       shrink-to-fit=no\">\n        <title>\n            {% block full_title %}By the People\n                {% block title %}\n                    {% if title %}\n                        {{ title }}{% else %}Untitled\n                    {% endif %}\n                {% endblock title %}\n            {% endblock full_title %}\n        </title>\n        <meta name=\"description\" content=\"Crowdsourcing project By the People invites\n                                          anyone to become a Library of Congress virtual volunteer. Explore, transcribe, review,\n                                          and tag digital collections to improve search and readability and open new avenues of\n                                          research.\">\n        <link rel=\"shortcut icon\" href=\"{% static 'favicon.ico' %}\">\n        {% include \"fragments/common-stylesheets.html\" %}\n        {% block prefetch %}\n            <link href=\"https://fonts.gstatic.com\" rel=\"preconnect dns-prefetch\"\n                  crossorigin>\n            {% if CONCORDIA_ENVIRONMENT == \"production\" %}\n                <link href=\"https://crowd-media.loc.gov\" rel=\"preconnect dns-prefetch\"\n                      crossorigin>\n            {% endif %}\n            <link href=\"https://thelibraryofcongress.tt.omtrdc.net\" rel=\"preconnect\n                                                                         dns-prefetch\" crossorigin>\n            <link href=\"https://smon.loc.gov\" rel=\"preconnect dns-prefetch\" crossorigin>\n        {% endblock prefetch %}\n        <link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\" rel=\"stylesheet\" integrity=\"sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH\" crossorigin=\"anonymous\">\n        {% block head_content %}\n            {% vite_hmr_client %}\n        {% endblock head_content %}\n        {% block extra_scripts %}{% endblock %}\n        {% comment %}\n    Adobe's tag manager requires this script to be placed at the top even though it's bad\nfor performance:\n    {% endcomment %}\n        {% if CONCORDIA_ENVIRONMENT == \"production\" %}\n            <script\n                src=\"https://assets.adobedtm.com/f94f5647937d/624e2240e90d/launch-0610ec681aff.min.js\" async></script>\n        {% else %}\n            <script\n                src=\"https://assets.adobedtm.com/f94f5647937d/624e2240e90d/launch-0610ec681aff.min.js\" async></script>\n        {% endif %}\n    </head>\n    <body id=\"body\"\n          class=\"{% block body_classes %}view-{{ VIEW_NAME_FOR_CSS }}\n                     section-{{ PATH_LEVEL_1|default:'homepage' }}\n                     environment-{{ CONCORDIA_ENVIRONMENT }}\n                     {% block extra_body_classes %}{% endblock %}\n                     d-print-block\n                 {% endblock body_classes %}\">\n        {% block site-header %}\n            <header class=\"border-bottom\" role=\"banner\" aria-label=\"site navigation\">\n                <nav class=\"container navbar navbar-light navbar-expand-lg\n                            align-items-lg-end p-3 d-print-block\">\n                    <div class=\"navbar-brand d-flex align-items-center\">\n                        <a class=\"logo-loc\" href=\"https://www.loc.gov\" title=\"Library of\n                                                                              Congress\">\n                            <img class=\"img-fluid\" src=\"{% static 'img/LoC-logo.svg' %}\"\n                                 width=\"170\" height=\"97\" alt=\"Library of Congress logo\">\n                        </a>\n                        <h1 class=\"logo-by-the-people m-0 -d-flex -align-items-center\">\n                            <a class=\"d-flex\" href=\"/\" title=\"By the People\">\n                                <img class=\"img-fluid\" src=\"{% static 'img/logo-by-the-people.svg' %}\" width=\"260\" height=\"27\" alt=\"\" aria-hidden=\"true\">\n                                <span class=\"visually-hidden\">By The People</span>\n                            </a>\n                        </h1>\n                    </div>\n                    <button class=\"navbar-toggler navbar-light border-0 d-print-none\"\n                            type=\"button\" data-bs-toggle=\"collapse\" data-bs-target=\"#nav-menu\" aria-controls=\"nav-menu\"\n                            aria-expanded=\"false\" aria-label=\"Toggle navigation\">\n                        <i class=\"fas fa-bars\"></i>\n                        <span class=\"visually-hidden\">Menu</span>\n                    </button>\n                    <div class=\"collapse navbar-collapse text-center d-print-none\"\n                         id=\"nav-menu\">\n                        <ul class=\"navbar-nav ms-auto d-print-none small\">\n                            <li class=\"nav-item\">\n                                <a class=\"nav-link {% if PATH_LEVEL_1 == 'about'%}active{% endif %}\" href=\"{% url 'about' %}\">About</a>\n                            </li>\n                            <li class=\"nav-item dropdown nav-dropdown\">\n                                <a id=\"topnav-campaigns-dropdown-toggle\" class=\"nav-link\n                                                                                {% if 'campaigns' in PATH_LEVEL_1 %}active{% endif %}\" href=\"{% url 'campaign-topic-list' %}\" data-bs-toggle=\"dropdown\" aria-haspopup=\"true\"\n                                   aria-expanded=\"false\">Campaigns&nbsp;<span class=\"fa fa-chevron-down text-primary\"></span></a>\n                                <div class=\"dropdown-menu\" aria-labelledby=\"topnav-campaigns-dropdown-toggle\">\n                                    <a class=\"dropdown-item\" href=\"{% url 'campaign-topic-list' %}\">All Campaigns</a>\n                                    <a class=\"dropdown-item\" href=\"{% url 'transcriptions:completed-campaign-list' %}\">Completed Campaigns</a>\n                                </div>\n                            </li>\n                            <li id=\"topnav-help-dropdown\" class=\"nav-item dropdown\n                                                                 nav-dropdown\">\n                                <a id=\"topnav-help-dropdown-toggle\" class=\"nav-link\n                                                                           {% if PATH_LEVEL_1 == 'help-center' or 'get-started' in PATH_LEVEL_1 %}active{% endif %}\"\n                                   href=\"{% url 'help-center' %}\" rel=\"nofollow\" data-bs-toggle=\"dropdown\" aria-haspopup=\"true\"\n                                   aria-expanded=\"false\">How-To&nbsp;<span class=\"fa fa-chevron-down text-primary\"></span></a>\n                                <div class=\"dropdown-menu\"\n                                     aria-labelledby=\"topnav-help-dropdown-toggle\">\n                                    <a class=\"dropdown-item\" href=\"{% url 'welcome-guide' %}\"\n                                       rel=\"nofollow\">Get Started</a>\n                                    <a class=\"dropdown-item\" href=\"{% url 'transcription-basic-rules' %}\" rel=\"nofollow\">Transcribe</a>\n                                    <a class=\"dropdown-item\" href=\"{% url 'how-to-review' %}\"\n                                       rel=\"nofollow\">Review</a>\n                                    <a class=\"dropdown-item\" href=\"{% url 'how-to-tag' %}\"\n                                       rel=\"nofollow\">Tag</a>\n                                </div>\n                            </li>\n                            <li id=\"resources-dropdown\" class=\"nav-item dropdown nav-dropdown\">\n                                <a id=\"resources-dropdown\" class=\"nav-link\n                                                                  {% if PATH_LEVEL_1 == 'resources' %}active{% endif %}\"\n                                   href=\"{% url 'resources' %}\" rel=\"nofollow\" data-bs-toggle=\"dropdown\" aria-haspopup=\"true\"\n                                   aria-expanded=\"false\">Resources <span class=\"fa fa-chevron-down text-primary\"></span></a>\n                                <div class=\"dropdown-menu\"\n                                     aria-labelledby=\"topnav-resources-dropdown-toggle\">\n                                    <a class=\"dropdown-item\" href=\"{% url 'guidelines' %}\"\n                                       rel=\"nofollow\">Community Guidelines</a>\n                                    <a class=\"dropdown-item\" href=\"{% url 'programs' %}\"\n                                       rel=\"nofollow\">Programs</a>\n                                    <a class=\"dropdown-item\" href=\"{% url 'for-educators' %}\"\n                                       rel=\"nofollow\">For Educators</a>\n                                    <a class=\"dropdown-item\" href=\"{% url 'service' %}\"\n                                       rel=\"nofollow\">Documenting Service</a>\n                                </div>\n                            </li>\n                            <li class=\"nav-item\">\n                                <a class=\"nav-link\"\n                                   href=\"https://forum.crowd.loc.gov\" target=\"_blank\">Discuss</a>\n                            </li>\n                            <li id=\"topnav-account-dropdown\" class=\"nav-item dropdown\n                                                                    nav-dropdown authenticated-only\">\n                                <a id=\"topnav-account-dropdown-toggle\" class=\"nav-link fw-bold\" href=\"#\" data-bs-toggle=\"dropdown\" aria-haspopup=\"true\" aria-expanded=\"false\" aria-label=\"{{ user.username }} account menu\">\n                                    {{ user.username }}<span class=\"fa fa-chevron-down text-primary\"></span>\n                                </a>\n                                <div id=\"topnav-account-dropdown-menu\" class=\"dropdown-menu\" aria-labelledby=\"topnav-account-dropdown-toggle\"></div>\n                            </li>\n                        </ul>\n\n                        <ul class=\"nav-secondary anonymous-only list-unstyled d-none d-lg-flex\n                                   d-print-none small ms-4 ps-3\">\n                            <li class=\"nav-item\">\n                                <a class=\"nav-link nav-secondary nav-link-login fw-bold\"\n                                   href=\"{% url 'login' %}?next={{ request.path|urlencode }}\" rel=\"nofollow\">Login</a>\n                            </li>\n                            <li class=\"nav-item\">\n                                <a class=\"nav-link nav-secondary nav-link-register fw-bold\"\n                                   href=\"{% url 'registration_register' %}\" rel=\"nofollow\">Register</a>\n                            </li>\n                        </ul>\n                    </div>\n                </nav>\n            </header>\n        {% endblock site-header %}\n\n        {% block breadcrumbs-container %}\n            <nav class=\"container breadcrumb-wrapper\" aria-label=\"breadcrumb\">\n                <ol class=\"breadcrumb\">\n                    <li class=\"breadcrumb-item\"><a href=\"/\">Home</a></li>\n                    {% block breadcrumbs %}{% endblock breadcrumbs %}\n                </ol>\n            </nav>\n        {% endblock breadcrumbs-container %}\n\n        {% block site-main %}\n            <main class=\"{% block extra_main_classes %}{% endblock %} d-print-block\">\n\n                {% block messages-container %}\n                    <div id=\"messages\" hidden>\n                        <div hidden id=\"message-template\">\n                            {% comment %} This is a hidden <div> rather than <template>\nbecause it's not worth dealing with IE11 compatibility {% endcomment %}\n                            <div class=\"alert alert-dismissible mx-3 my-2 d-flex justify-content-between align-items-center\" role=\"alert\">\n                                <a type=\"button\" data-bs-dismiss=\"alert\"\n                                   aria-label=\"Close\">\n                                    <!--span aria-hidden=\"true\">&times;</span-->\n                                    <span aria-hidden=\"true\" class=\"fas fa-times\"></span>\n                                </a>\n                            </div>\n                        </div>\n                    </div>\n                {% endblock messages-container %}\n\n                {% if maintenance_mode %}\n                    <div class=\"alert mx-3 my-2 text-center\" role=\"alert\" style=\"background-color: red;\">\n                        <h2>Maintenance mode is active!</h2>\n                    </div>\n                {% endif %}\n\n                {% block main_content %}{% endblock main_content %}\n            </main>\n        {% endblock site-main %}\n        {% block site-footer %}\n            <footer class=\"footer border-top py-4 d-print-none\">\n                <div class=\"container\">\n                    <div class=\"row\">\n                        <div class=\"col-lg-auto px-3\">\n\n                            <h2 class=\"h3 fw-normal text-center text-lg-start\">Follow\n                                Us</h2>\n                            <ul class=\"list-unstyled list-inline mb-0 text-center\n                                       text-lg-start\">\n                                <li class=\"list-inline-item link-github\">\n                                    <a href=\"https://github.com/LibraryOfCongress/concordia\"\n                                       title=\"GitHub\" target=\"_blank\">\n                                        <span class=\"bitmap-icon github-icon\"></span>\n                                    </a>\n                                </li>\n                                <li class=\"list-inline-item link-twitter\">\n                                    <a href=\"https://www.twitter.com/Crowd_LOC\"\n                                       title=\"Twitter\" target=\"_blank\">\n                                        <span class=\"bitmap-icon twitter-icon\"></span>\n                                    </a>\n                                </li>\n                                <li class=\"list-inline-item link-email\">\n                                    <a\n                                        href=\"https://updates.loc.gov/accounts/USLOC/subscriber/new?topic_id=USLOC_175\"\n                                        title=\"Newsletter\" target=\"_blank\">\n                                        <span class=\"bitmap-icon email-icon\"></span>\n                                    </a>\n                                </li>\n                            </ul>\n                        </div>\n                        <div class=\"footer-links col-lg\">\n                            <ul class=\"list-unstyled list-inline small fw-bold mb-0\n                                       text-center text-lg-start\">\n                                <li class=\"list-inline-item mb-1\"><a\n                                    href=\"/for-educators/\" target=\"_blank\">For Educators</a></li>\n                                <li class=\"list-inline-item mb-1\"><a\n                                    href=\"https://forum.crowd.loc.gov\" target=\"_blank\">Discuss</a></li>\n                                <li class=\"list-inline-item mb-1\"><a href=\"https://ask.loc.gov/crowd\" target=\"_blank\" rel=noopener>Contact\n                                    Us</a></li>\n                                <li class=\"list-inline-item\"><a\n                                    href=\"{% url 'welcome-guide' %}\" target=\"_blank\">Help</a></li>\n                            </ul>\n                        </div>\n                        <div class=\"col-lg-auto align-self-center\">\n                            <ul class=\"list-unstyled list-inline small text-center\n                                       text-lg-start\">\n                                <li class=\"list-inline-item\"><a\n                                    href=\"https://www.loc.gov/accessibility/\">Accessibility</a></li>\n                                <li class=\"list-inline-item\"><a\n                                    href=\"https://www.loc.gov/legal/\">Legal</a></li>\n                                <li class=\"list-inline-item\"><a\n                                    href=\"https://www.loc.gov/about/office-of-the-inspector-general/\">Inspector\n                                    General</a></li>\n                                <li class=\"list-inline-item\"><a\n                                    href=\"https://www.loc.gov/legal/standard-disclaimer-for-external-links/\">External Link\n                                    Disclaimer</a></li>\n                            </ul>\n                            <ul class=\"list-unstyled list-inline mb-0 text-center\n                                       text-lg-start\">\n                                <li class=\"list-inline-item intersites-link-congress\"><a\n                                    href=\"https://www.congress.gov/\"><span class=\"visually-hidden\">Congress.gov</span></a></li>\n                                <li class=\"list-inline-item intersites-link-copyright\"><a\n                                    href=\"https://copyright.gov\"><span class=\"visually-hidden\">Copyright.gov</span></a></li>\n                            </ul>\n                        </div>\n                    </div>\n                </div>\n            </footer>\n        {% endblock site-footer %}\n\n        {% if SENTRY_FRONTEND_DSN %}\n            <script src=\"https://browser.sentry-cdn.com/5.0.8/bundle.min.js\"\n                    integrity=\"sha384-PKOJCSVL6suo2Qz9Hs4hkrZqX7S6iLwadxXxBEa0h0ycsuoDNZCiAcHlPGHYxU6l\"\n                    crossorigin=\"anonymous\"></script>\n            <script>\n                // Don't load Sentry if this is the \"always online\" version of the page,\n                // which is the version CloudFlare serves if the actual site is down\n                if (navigator.userAgent.indexOf(\"CloudFlare-AlwaysOnline\") < 0) {\n                    Sentry.init({\n                        'dsn': '{{ SENTRY_FRONTEND_DSN }}',\n                        'release': '{{ APPLICATION_VERSION }}',\n                        'environment': '{{ CONCORDIA_ENVIRONMENT }}',\n                        'blacklistUrls': [\n                            /^moz-extension/\n                        ],\n                        // Turnstile 300xxx and 600xxx errors indicate the user failed validation. We don't want those in Sentry\n                        'ignoreErrors': [\"[Cloudflare Turnstile] Error: 600\", \"[Cloudflare Turnstile] Error: 300\"]\n                    });\n                }\n            </script>\n        {% endif %}\n\n        <script>\n            window.STATIC_URL = \"{% get_static_prefix %}\";\n        </script>\n\n        {% vite_asset 'src/main.js' %}\n\n        {% block body_scripts %}{% endblock body_scripts %}\n\n        <script type=\"text/javascript\">\n            if (typeof _satellite == \"undefined\") {\n                if (typeof Sentry != \"undefined\") {\n                    Sentry.captureMessage(\"Adobe Analytics did not load\");\n                }\n            } else {\n                _satellite.pageBottom();\n            }\n        </script>\n        <div id=\"tutorial-data\" aria-hidden=\"true\" hidden data-campaign-slug=\"{{ campaign.slug }}\" data-user-authenticated=\"{{ user.is_authenticated|yesno:\"true,false\" }}\" data-has-asset=\"{{ asset|yesno:\"true,false\" }}\">\n        </div>\n    </body>\n</html>\n"
  },
  {
    "path": "concordia/templates/django_registration/activation_complete.html",
    "content": "{% extends \"base.html\" %}\n{% block title %}Account Activation{% endblock title %}\n{% block extra_main_classes %}container{% endblock %}\n{% block main_content %}\n    <div class=\"row\">\n        <div class=\"col-md-8 mx-auto p-3\">\n            <h2>Account Activation Complete</h2>\n            <p>\n                Welcome back! Your registration is now complete and you are logged in.\n            </p>\n            <p>\n                Visit the <a href=\"{% url 'welcome-guide' %}\">By the People Welcome Guide</a> for instructions and help getting started.\n                Or, <a href=\"{% url 'redirect-to-next-transcribable-topic-asset' 'suffrage-women-fight-for-the-vote' %}\">jump right in</a> to a page that needs your help!\n            </p>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/django_registration/activation_email_body.txt",
    "content": "{% load custom_math %}\nThank you for registering as a Library of Congress virtual volunteer with By the People!\n\nTo complete your activation, please verify your email address in the next {{ expiration_days }} days by clicking the link below:\n\nhttps://{{ site }}{% url \"django_registration_activate\" activation_key %}\n\nOnce your email is verified, your account will be active! As a registered user you can complete pages by reviewing other volunteers' transcriptions, tag pages, and see a history of your activity on your account page.\n\nCheck out our Get Started guide and other instructions by visiting https://crowd.loc.gov/get-started/.\n\nHappy transcribing,\n-- The By the People team\n"
  },
  {
    "path": "concordia/templates/django_registration/activation_email_subject.txt",
    "content": "Start transcribing! Activate your By the People account at {{ site }}\n"
  },
  {
    "path": "concordia/templates/django_registration/activation_failed.html",
    "content": "{% extends \"base.html\" %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <div class=\"row\">\n            <div class=\"col-md-8 mx-auto p-3\">\n                <h2>Account Activation</h2>\n                <p class=\"activation-error-{{ activation_error.code }}\">\n                    {{ activation_error.message }}\n                </p>\n            </div>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/django_registration/registration_closed.html",
    "content": "{% extends \"base.html\" %}\n\n{% block title %}Registration is closed{% endblock title %}\n\n{% block head_content %}\n    <meta name=\"robots\" content=\"noindex\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <div class=\"row\">\n            <div class=\"col-md-8 mx-auto p-3\">\n                <h2>Registration Closed</h2>\n                <p>\n                    Registration is closed at the moment.\n                </p>\n            </div>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/django_registration/registration_complete.html",
    "content": "{% extends \"base.html\" %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <div class=\"row\">\n            <div class=\"col-md-8 mx-auto p-3\">\n                <h2>Thank you for joining By the People!</h2>\n                <p>\n                    We are excited for you to get started! First, complete your registration by checking your email inbox for an activation link. Click the link in the email within 7 days to activate your account.\n                </p>\n                <p>\n                    Activated and ready to jump in? <a href=\"{% url 'welcome-guide' %}\">Read our Welcome Guide</a>.\n                </p>\n            </div>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/django_registration/registration_form.html",
    "content": "{% extends \"base.html\" %}\n\n{% load static %}\n{% load django_bootstrap5 %}\n\n{% block title %}Registration{% endblock title %}\n\n{% block main_content %}\n    <div class=\"container-fluid\">\n        <div id=\"registration-form-container\" class=\"row flex-md-row justify-content-center\">\n            <div class=\"col-md-6 p-3\">\n                <h2 class=\"mb-3 text-center\">Registration</h2>\n                <p>\n                    Register for an account to track your work, add tags and review transcriptions.\n                </p>\n                <p> To sign up, please provide a username, email address and a strong password.\n                    Once you click register, we will send you an email to confirm your address. We may use this email address to communicate with you about account activity,\n                    website updates, and, occasionally, to ask for your feedback.\n                </p>\n                <p>\n                    Learn more about why we ask you to register in the <a href=\"{% url 'about' %}\">FAQ</a>.\n                </p>\n                <hr />\n                <form method=\"post\" id=\"registration-form\" class=\"form-register\">\n                    {% csrf_token %}\n\n                    {% bootstrap_form form %}\n\n                    {% bootstrap_button \"Register\" button_type=\"submit\" button_class=\"btn-primary\" extra_classes=\"btn justify-content-center\" %}\n                    <div class=\"d-flex col mx-auto\">\n                        <a href=\"{% url 'login' %}\" class=\"text-center blue-text\">I already have an account</a>\n                    </div>\n                </form>\n            </div>\n        </div>\n    </div>\n{% endblock main_content %}\n\n{% block body_scripts %}\n    {{ block.super }}\n    {% load django_vite %}\n    {% vite_asset 'concordia/static/js/src/password-validation.js' %}\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/documents/service_letter.html",
    "content": "{% load static staticfiles %}\n{% load humanize %}\n<html lang=\"en-US\">\n    <head>\n        <style>\n            @page {\n                size: A4;\n                margin: 1.1cm;\n                @bottom-right {\n                    content: \"Page \" counter(page) \" of \" counter(pages);\n                    font-family: Arial;\n                    font-size: 11pt;\n                    line-height: 1.4;\n                }\n            }\n            a { text-decoration: none; color: black; }\n            p { font-family: Arial; font-size: 11pt; line-height: 1.4;}\n            img { width: 35%; height: auto; }\n            h1 { font-size: 1.75rem; }\n            h2 { font-size: 1.2rem;}\n            tr { page-break-inside: avoid; page-break-after: auto; }\n            thead { display: table-header-group; }\n            tfoot { display: table-footer-group; }\n            .text-right { text-align: right !important; }\n            .text-left {text-align: left !important; }\n            table {\n                width: 100%;\n                margin-bottom: 1rem;\n                color: #242424;\n                border-collapse: collapse;\n                font-family: Artial;\n                font-size: 11pt;\n                line-height: 1.4;\n                page-break-inside: auto;\n            }\n            table thead th {\n                vertical-align: bottom;\n                border-bottom: 2px solid #efefef;\n            }\n            table th, table td {\n                padding: 0.75rem;\n                vertical-align: top;\n                border-top: 1px solid #efefef;\n            }\n            table thead th, table thead td {\n                border-bottom: 1px solid #000;\n                border-top: 1px solid #000;\n            }\n            table tbody tr:nth-of-type(odd) {\n                background-color: rgba(0, 0, 0, 0.05);\n            }\n        </style>\n        <title>Service Letter</title>\n        <meta name=author content=\"By the People\" />\n        <meta name=generator content=\"Concordia\" />\n        <meta name=description content=\"BTP Service Letter\" />\n        <meta name=keywords content=\"SL\" />\n        <meta name=keywords content=\"Concordia\" />\n        <meta name=keywords content=\"BTP\" />\n        <meta name=dcterms.created content=\"{% now 'c' %}\" />\n        <meta name=dcterms.modified content=\"{% now 'c' %}\" />\n    </head>\n    <body>\n        <p><img src=\"{{ image_url }}\" alt=\"Library Logo\" /><br /><br /><br /></p>\n        <p>\n            Library of Congress<br />\n            101 Independence Avenue SE<br />\n            Washington, DC 20540<br />\n        </p>\n        <p>\n            {% now \"m/d/Y\" %}<br /><br />\n        </p>\n        <p>To whom it may concern,</p>\n        <p>I am writing to confirm {% if user.first_name %}{{ user.first_name }}{% if user.last_name %} {{ user.last_name }}{% endif %}{% else %}this volunteer{% endif %}'s participation in the Library of Congress virtual volunteering program <a href=\"https://crowd.loc.gov\"><em>By the People</em> (https://crowd.loc.gov)</a>. The project invites anyone to help the Library by transcribing, tagging, and reviewing transcriptions of digitized historical documents from the Library's collections. Transcriptions make the content of handwritten and other documents keyword searchable on the <a href=\"https://www.loc.gov\">Library's main website (https://loc.gov)</a>, open new avenues of digital research, and improve accessibility, including for people with visual or cognitive disabilities.</p>\n        <p>They registered as a <em>By the People</em> volunteer on {{ join_date|date:\"m/d/Y\" }} as {{ user.username }}. They made {{ total_transcriptions|intcomma }} edits to transcriptions on the site and reviewed {{ total_reviews|intcomma }} transcriptions by other volunteers. You can find further details on their virtual volunteer activity in the following pages.</p>\n        <p>The <em>By the People</em> site does not track the time that volunteers spend transcribing but volunteers may track their own hours. The following activity pages have time stamps that may also be useful.</p>\n        <p>Best,<br /><br /></p>\n        <p>Lauren Algee</p>\n        <p>\n            Community Manager, <em>By the People</em><br />\n            Library of Congress<br />\n            crowd@ask.loc.gov\n        </p>\n        <div style=\"page-break-after: always;\"></div>\n        <h1>Recent Pages Worked On</h1>\n        <h2>All the pages contributed to in the last 6 months</h2>\n        <table>\n            <thead>\n                <tr class=\"text-start\">\n                    <th>Row</th>\n                    <th>Date</th>\n                    <th>Page</th>\n                    <th>Campaign Items</th>\n                    <th>Your Contribution</th>\n                    <th>Current Status</th>\n                </tr>\n            </thead>\n            <tbody>\n                {% for asset in asset_list %}\n                    <tr>\n                        <td>{{ forloop.counter }}</td>\n                        <td class=\"col-md-3\">{{ asset.latest_activity }}</td>\n                        <td class=\"text-end\"><a href=\"{{ asset.get_absolute_url }}\">{{ asset.sequence }}</a></td>\n                        <td>{{ asset.item.title }}</td>\n                        <td>{% if asset.last_reviewed %}Reviewed{% else %}Transcribed{% endif %}</td>\n                        <td>{{ asset.get_transcription_status_display }}</td>\n                    </tr>\n                {% endfor %}\n            </tbody>\n        </table>\n    </body>\n</html>\n"
  },
  {
    "path": "concordia/templates/emails/delete_account_body.txt",
    "content": "This email is to confirm deletion of your By the People user account. We have permanently removed all of your account information from the system.\n\nThank you for all of your contributions to the Library of Congress. Come back any time!\n\nSorry to see you go,\n-- The By the People team\n"
  },
  {
    "path": "concordia/templates/emails/delete_account_subject.txt",
    "content": "Your By the People account has been deleted\n"
  },
  {
    "path": "concordia/templates/emails/email_reconfirmation_body.txt",
    "content": "{% load custom_math %}\nTo complete your email change, please verify your email address in the next {{ expiration_days }} days by clicking the link below:\n\nhttps://{{ site }}{% url \"email-reconfirmation\" confirmation_key %}\n\nOnce it's verified, your email will be active on your account, and your previous email address will no longer be used.\n\nHappy transcribing,\n-- The By the People team\n"
  },
  {
    "path": "concordia/templates/emails/email_reconfirmation_subject.txt",
    "content": "Confirm your email change for your By the People account at {{ site }}\n"
  },
  {
    "path": "concordia/templates/emails/unusual_activity.html",
    "content": "<style type=\"text/css\">\n\n    table, td { border: 1px solid black; border-collapse: collapse; }\n    th, td { padding: 7px; }\n\n</style>\n<h4>{{ title }}</h4>\nTranscription Incidents: 2 or more transcriptions submitted in 1 minute.\n<table>\n    <tr>\n        <th>User</th>\n        <th>Incidents</th>\n        <th>User transcriptions</th>\n    </tr>\n    {% for row in transcriptions %}\n        <tr>\n            <td>\n                <a href=\"{{ domain }}{% url 'admin:concordia_transcription_changelist' %}?user={{ row.0 }}\">\n                    {{ row.1 }}\n                </a>\n            </td>\n            <td>{{ row.2 }}</td>\n            <td>{{ row.3 }}</td>\n        </tr>\n    {% empty %}\n        <tr><td>No transcriptions fell within the window.</td></tr>\n    {% endfor %}\n</table>\n<br/>\nReview Incidents: 2 or more transcriptions accepted in 1 minute.\n<table>\n    <tr>\n        <th>User</th>\n        <th>Incidents</th>\n        <th>User accepts</th>\n    </tr>\n    {% for row in reviews %}\n        <tr>\n            <td>\n                <a href=\"{{ domain }}{% url 'admin:concordia_transcription_changelist' %}?accepted=not-null&o=-7&reviewed_by__id__exact={{ row.0 }}\">\n                    {{ row.1 }}\n                </a>\n            </td>\n            <td>{{ row.2 }}</td>\n            <td>{{ row.3 }}</td>\n        </tr>\n    {% empty %}\n        <tr><td>No reviews fell within the window.</td></tr>\n    {% endfor %}\n</table>\n"
  },
  {
    "path": "concordia/templates/emails/unusual_activity.txt",
    "content": "{{ title }}\nTranscription Incidents: 2 or more transcriptions submitted in 1 minute.\n{% for row in transcriptions %}\n    * {{ row.1 }} | {{ row.2 }} || {{ row.3 }}\n{% empty %}\n    No transcriptions fell within the window.\n{% endfor %}\nReview Incidents: 2 or more transcriptions accepted in 1 minute.\n{% for row in reviews %}\n    {{ row.1 }} | {{ row.2 }} | {{ row.3 }}\n{% empty %}\n    No reviews fell within the window.\n{% endfor %}\n"
  },
  {
    "path": "concordia/templates/emails/welcome_email_body.html",
    "content": "<p>\n    Thank you for becoming a By the People virtual volunteer for the Library of Congress!\n</p>\n<p>\n    To help you get started, we recommend reading the <a href=\"https://crowd.loc.gov/help-center/welcome-guide/\">Welcome Guide</a>. It includes instructions on transcribing, tagging, and reviewing transcriptions by other volunteers. Reviewing is an area where we especially need your help in completing transcriptions and moving them over the finish line!\n</p>\n<p>\n    Once you have a handle on the transcription guidelines, explore the different collections available under <a href=\"https://crowd.loc.gov/campaigns-topics/\">“Campaigns”</a> using the top navigation on any page.\n</p>\n<p>\n    Let us know what you find once you dig in! Share your experience or questions with the community managers and other volunteers on our <a href=\"https://forum.crowd.loc.gov\">discussion space on Discourse</a>.\n</p>\n<p>\n    Happy transcribing!<br/>\n    The By the People team\n</p>\n"
  },
  {
    "path": "concordia/templates/emails/welcome_email_body.txt",
    "content": "Thank you for becoming a By the People virtual volunteer for the Library of Congress!\n\nTo help you get started, we recommend reading the Welcome Guide <https://crowd.loc.gov/help-center/welcome-guide/>. It includes instructions on transcribing, tagging, and reviewing transcriptions by other volunteers. Reviewing is an area where we especially need your help in completing transcriptions and moving them over the finish line!\n\nOnce you have a handle on the transcription guidelines, explore the different collections available under “Campaigns” <https://crowd.loc.gov/campaigns-topics/> using the top navigation on any page.\n\nLet us know what you find once you dig in! Share your experience or questions with the community managers and other volunteers on our discussion space on Discourse <https://forum.crowd.loc.gov>.\n\nHappy transcribing!\nThe By the People team\n"
  },
  {
    "path": "concordia/templates/emails/welcome_email_subject.txt",
    "content": "Welcome to By The People\n"
  },
  {
    "path": "concordia/templates/error.html",
    "content": "{% extends \"base.html\" %}\n{% load staticfiles %}\n\n{% block head_content %}\n    <style>\n        body {\n            height: 100vh;\n            width: 100vw;\n            margin: 0;\n            padding: 0;\n        }\n\n        #error-message {\n            max-width: 50%;\n        }\n    </style>\n{% endblock head_content %}\n\n{% block body_classes %}d-flex justify-content-center align-items-center text-center{% endblock body_classes %}\n\n{% block site-header %}{% endblock site-header %}\n\n{% block breadcrumbs-container %}{% endblock breadcrumbs-container %}\n\n{% block site-main %}\n    <div id=\"error-message\">\n        {% block error_message %}{% endblock error_message %}\n    </div>\n{% endblock site-main %}\n\n{% block site-footer %} {% endblock site-footer %}\n"
  },
  {
    "path": "concordia/templates/forms/widgets/email.html",
    "content": "<input\n    type=\"{{ widget.type }}\"\n    name=\"{{ widget.name }}\"\n    {% if widget.value != None %}\n        placeholder=\"Change your email address\"\n    {% endif %}\n    class=\"form-control fst-italic\"\n    {% for name, value in widget.attrs.items %}\n        {% if value is not False %}\n            {{ name }}\n            {% if value is not True %}\n                =\"{{ value|stringformat:'s' }}\"\n            {% endif %}\n        {% endif %}\n    {% endfor %}\n>\n"
  },
  {
    "path": "concordia/templates/forms/widgets/turnstile_widget.html",
    "content": "<div class=\"cf-turnstile\" {% include \"django/forms/widgets/attrs.html\" %}></div>\n"
  },
  {
    "path": "concordia/templates/fragments/_filter-buttons.html",
    "content": "{% if user.is_authenticated %}\n    <div>\n        <input name=\"radioButtons\" type=\"radio\" id=\"show-all\" {% if not do_filter %}checked{% endif %} data-url=\"{{ all_url}}{% if sublevel_qs %}?{{ sublevel_qs }}{% endif %}\">\n        <label for=\"show-all\">Show all</label>\n        <input name=\"radioButtons\" type=\"radio\" id=\"filter-assets\" {% if do_filter %}checked{% endif %} class=\"ml-1\" data-url=\"{{ filtered_url}}{% if sublevel_qs %}?{{ sublevel_qs }}{% endif %}\">\n        <label for=\"filter-assets\">Show pages I can review</label>\n    </div>\n{% endif %}\n"
  },
  {
    "path": "concordia/templates/fragments/_modal_footer.html",
    "content": "<div class=\"modal-footer d-flex justify-content-around\">\n    <p>\n        <a class=\"btn btn-primary\" href=\"{{ next_open_asset_url }}\">\n            Transcribe a new page\n        </a>\n    </p>\n    <p>\n        <a class=\"btn btn-primary\" href=\"{{ next_review_asset_url }}\">\n            Review a new page\n        </a>\n    </p>\n</div>\n"
  },
  {
    "path": "concordia/templates/fragments/activity-filter-sort.html",
    "content": "{% load concordia_querystring %}\n<div class=\"col-sm\">\n    <ul>\n        <li><a href=\"/transcribe\">Transcribe</a></li>\n        <li><a href=\"/review\">Review</a></li>\n    </ul>\n</div>\n<div class=\"col-sm\">\n    Filter by campaign:\n    <ul>\n        {% for c in campaigns %}\n            <li><a href=\"?{% qs_alter request.GET campaign_filter=c.pk %}\">{{ c.title }}</a></li>\n        {% endfor %}\n    </ul>\n</div>\n<div class=\"col-sm\">\n    Sort by:\n    <ul>\n        <li><a href=\"?{% qs_alter request.GET order_by=\"pk\" %}\">None</a></li>\n        <li><a href=\"?{% qs_alter request.GET order_by=\"-difficulty\" %}\">Hard to easy</a></li>\n        <li><a href=\"?{% qs_alter request.GET order_by=\"difficulty\" %}\">Easy to hard</a></li>\n    </ul>\n</div>\n"
  },
  {
    "path": "concordia/templates/fragments/codemirror.html",
    "content": "{% load static django_vite %}\n\n<link rel=\"stylesheet\" href=\"{% static 'codemirror/lib/codemirror.css' %}\">\n<link rel=\"stylesheet\" href=\"{% static 'codemirror/addon/lint/lint.css' %}\">\n\n<script src=\"{% static 'codemirror/lib/codemirror.js' %}\"></script>\n<script src=\"{% static 'codemirror/mode/xml/xml.js' %}\"></script>\n<script src=\"{% static 'codemirror/mode/markdown/markdown.js' %}\"></script>\n<script src=\"{% static 'codemirror/addon/lint/html-lint.js' %}\"></script>\n\n<script src=\"{% static 'prettier/standalone.js' %}\"></script>\n<script src=\"{% static 'prettier/parser-html.js' %}\"></script>\n<script src=\"{% static 'prettier/parser-markdown.js' %}\"></script>\n\n<script src=\"{% static 'remarkable/dist/remarkable.min.js' %}\"></script>\n\n{% vite_asset 'concordia/static/admin/editor-preview.js' %}\n\n<style>\n    .form-row.codemirror-with-preview > div {\n        display: flex;\n        width: 100%;\n        flex-wrap: wrap;\n    }\n\n    .form-row.codemirror-with-preview > div > * {\n        overflow-y: auto;\n        box-sizing: border-box;\n        min-height: 500px;\n    }\n\n    .form-row.codemirror-with-preview > div .CodeMirror {\n        min-height: 500px;\n    }\n\n    .form-row.codemirror-with-preview > div > label {\n        flex-basis: 100%;\n        min-height: 0;\n        font-weight: bold;\n        color: #333;\n    }\n\n</style>\n\n<template id=\"preview-head\">{% spaceless %}\n    {% include \"fragments/common-stylesheets.html\" %}\n{% endspaceless %}</template>\n"
  },
  {
    "path": "concordia/templates/fragments/common-stylesheets.html",
    "content": "{% load static django_vite %}\n\n<link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css?family=Open+Sans:400,600,700|Roboto+Slab:400,700\" />\n<link rel=\"stylesheet\" href=\"{% vite_asset_url 'concordia/static/scss/base.scss' %}\" />\n<link rel=\"stylesheet\" href=\"{% static '@fortawesome/fontawesome-free/css/all.min.css' %}\" />\n"
  },
  {
    "path": "concordia/templates/fragments/featured_blog_posts.html",
    "content": "{% load group_list %}\n<h3 class=\"mb-4\">Featured blog posts</h3>\n<div id=\"blog-carousel\" class=\"carousel slide\">\n    <div class=\"carousel-inner\" blog-post-count=\"{{ blog_posts|length }}\" slice-count=\"{{ blog_posts|slice:':3'|length }}\" batch-count=\"{{ blog_posts|slice:':3'|batch:3|length }}\">\n        {% for segment in blog_posts %}\n            <div class=\"carousel-item mb-4{% if forloop.first %} active{% endif %}\">\n                <div class=\"row mx-auto blog-chunk\">\n                    {% for post in segment %}\n                        <div class=\"col-md-4\">\n                            <div class=\"card h-100\">\n                                <div class=\"card-body p-0\">\n                                    <a href=\"{{ post.link }}\">\n                                        <img alt=\"{{ post.title }}\" width=\"197\" height=\"132\" style=\"opacity: 1;\" src=\"{{ post.og_image }}\">\n                                        <h5 class=\"pt-2\">\n                                            {{ post.title }}\n                                        </h5>\n                                    </a>\n                                </div>\n                            </div>\n                        </div>\n                    {% endfor %}\n                </div>\n            </div>\n        {% endfor %}\n    </div>\n    {% if blog_posts|length > 1 %}\n        <button class=\"carousel-control-prev\" type=\"button\" data-bs-target=\"#blog-carousel\" data-bs-slide=\"prev\">\n            <span class=\"carousel-control-icon prev\" aria-hidden=\"true\"></span>\n            <span class=\"visually-hidden\">Previous</span>\n        </button>\n    {% endif %}\n    <div class=\"carousel-indicators\">\n        {% for segment in blog_posts %}\n            <button type=\"button\" data-bs-target=\"#blog-carousel\" data-bs-slide-to=\"{{ forloop.counter0 }}\" {% if forloop.first %}class=\"active\" aria-current=\"true\"{% endif %}>\n            </button>\n        {% endfor %}\n    </div>\n    {% if blog_posts|length > 1 %}\n        <button class=\"carousel-control-next\" type=\"button\" data-bs-target=\"#blog-carousel\" data-bs-slide=\"next\">\n            <span class=\"carousel-control-icon next\" aria-hidden=\"true\"></span>\n            <span class=\"visually-hidden\">Next</span>\n        </button>\n    {% endif %}\n</div>\n<div class=\"text-center\">\n    <a class=\"btn btn-primary text-decoration-none\" href=\"https://blogs.loc.gov/thesignal/category/by-the-people-transcription-program/\" target=\"_blank\">Read more</a>\n</div>\n"
  },
  {
    "path": "concordia/templates/fragments/recent-pages.html",
    "content": "{% load concordia_querystring %}\n\n<h2>Recent Pages Worked On</h2>\n<div>View all the pages you contributed to in the last 6 months.</div>\n<div class=\"row mt-4\">\n    {% include \"fragments/standard-pagination.html\" %}\n</div>\n<form class=\"container form date-filter pb-1 mb-2\" method=\"get\">\n    <div class=\"d-flex flex-row\">\n        <span class=\"fw-bold me-1\">Date: </span>Select Range\n    </div>\n    <div class=\"d-flex flex-row pb-1\">\n        <label for=\"id_start\"><span class=\"visually-hidden\">Start</span></label>\n        <duet-date-picker name=\"start\" identifier=\"id_start\"></duet-date-picker>\n        <div class=\"p-2\">to</div>\n        <label for=\"id_end\"><span class=\"visually-hidden\">End</span></label>\n        <duet-date-picker name=\"end\" identifier=\"id_end\"></duet-date-picker>\n        <button type=\"submit\" class=\"btn btn-primary rounded-0 p-2\">Go</button>\n    </div>\n    <div class=\"d-flex flex-row pt-1\">\n        <div>\n            <input type=\"radio\" name=\"flexRadioDefault\" id=\"flexRadioDefault1\" onclick=\"sortDateDescending();\"{% if order_by != 'date-ascending' %} checked{% endif %}>\n            <label class=\"form-check-label\" for=\"flexRadioDefault1\">Sort by Newest</label>\n        </div>\n        <div style=\"margin-left: 1.6rem;\">\n            <input type=\"radio\" name=\"flexRadioDefault\" id=\"flexRadioDefault2\" onclick=\"sortDateAscending();\"{% if order_by == 'date-ascending'%} checked{% endif %}>\n            <label class=\"form-check-label\" for=\"flexRadioDefault2\">Sort by Oldest</label>\n        </div>\n    </div>\n</form>\n{% if campaign or activity or statuses or start or end %}\n    <div class=\"bg-light flex-row\" id=\"current-filters\">\n        <span class=\"align-middle d-inline fw-bold\">Filtered by: </span>\n        <ul class=\"d-inline\">\n            {% if campaign %}\n                <li class=\"btn btn-xs font-size-sm rounded-pill\">\n                    <label class=\"m-0 fw-normal\">\n                        <input type=\"hidden\" value=\"campaign\">\n                        campaign: {{ campaign.slug }}\n                    </label>\n                    <a href=\"?{% qs_alter request.GET tab='recent' delete:campaign %}\" class=\"btn\" role=\"button\">\n                        <span aria-hidden=\"true\" class=\"fas fa-times\"></span>\n                    </a>\n                </li>\n            {% endif %}\n            {% if activity %}\n                <li class=\"btn btn-xs font-size-sm rounded-pill\">\n                    <label class=\"m-0 fw-normal\">\n                        <input type=\"hidden\" value=\"activity\">\n                        contribution: {{ activity }}\n                    </label>\n                    <a href=\"?{% qs_alter request.GET tab='recent' delete:activity %}\" class=\"btn\" role=\"button\">\n                        <span aria-hidden=\"true\" class=\"fas fa-times\"></span>\n                    </a>\n                </li>\n            {% endif %}\n            {% for status in statuses %}\n                <li class=\"btn btn-xs font-size-sm rounded-pill\">\n                    <label class=\"m-0 fw-normal\">\n                        <input type=\"hidden\" value=\"activity\">\n                        status: {% if status == 'submitted' %}needs review {% else %}{{ status }}{% endif %}\n                    </label>\n                    <a href=\"?{% qs_alter request.GET tab='recent' delete_value:\"status\",status %}\" class=\"btn\" role=\"button\">\n                        <span aria-hidden=\"true\" class=\"fas fa-times\"></span>\n                    </a>\n                </li>\n            {% endfor %}\n            {% if start or end %}\n                <li class=\"btn btn-xs font-size-sm rounded-pill\">\n                    <label class=\"m-0 fw-normal\">\n                        <input type=\"hidden\" value=\"date\">\n                        {% if start and end %}\n                            date: {{ start }} to {{ end }}\n                        {% else %}\n                            date: {% if start %}{{ start }}{% else %}{{ end }}{% endif %}\n                        {% endif %}\n                    </label>\n                    <a href=\"?{% qs_alter request.GET tab='recent' delete:start delete:end %}\" class=\"btn\" role=\"button\">\n                        <span aria-hidden=\"true\" class=\"fas fa-times\"></span>\n                    </a>\n                </li>\n            {% endif %}\n        </ul>\n    </div>\n{% endif %}\n<table class=\"table table-striped table-responsive-sm\">\n    <thead class=\"border-y\">\n        <tr class=\"text-start\">\n            <th class=\"date-header\">Date</th>\n            <th>Page</th>\n            <th>\n                <div data-bs-toggle=\"dropdown\" aria-haspopup=\"true\" aria-expanded=\"false\">Campaign/Item <span class=\"fa fa-chevron-down\"></span></div>\n                <div class=\"dropdown-menu border border-primary py-0 rounded-0\">\n                    {% if campaign %}\n                        <a class=\"dropdown-item border-bottom border-primary filter-link\" href=\"?{% qs_alter request.GET tab='recent' delete:campaign %}\">Show All</a>\n                    {% endif %}\n                    {% for recent_campaign in recent_campaigns %}\n                        <a class=\"dropdown-item{% if not forloop.first %} border-top border-primary{% endif %}{% if campaign.pk == recent_campaign.pk %} fw-bold{% endif %} filter-link\" href=\"?{% qs_alter request.GET campaign=recent_campaign.pk delete:page %}\">\n                            {{ recent_campaign.title }}\n                        </a>\n                    {% endfor %}\n                </div>\n            </th>\n            <th>\n                <div data-bs-toggle=\"dropdown\" aria-haspopup=\"true\" aria-expanded=\"false\">Your Contribution <span class=\"fa fa-chevron-down\"></span></div>\n                <div class=\"dropdown-menu border border-primary py-0 rounded-0\">\n                    {% if activity %}\n                        <a class=\"dropdown-item border-bottom border-primary filter-link\" href=\"?{% qs_alter request.GET tab='recent' delete:activity %}\">Show All</a>\n                    {% endif %}\n                    <a class=\"dropdown-item border-bottom border-primary{% if activity == 'reviewed' %} fw-bold{% endif %} filter-link\" href=\"?{% qs_alter request.GET activity='reviewed' delete:page %}\">Reviewed</a>\n                    <a class=\"dropdown-item{% if activity == 'transcribed' %} fw-bold{% endif %} filter-link\" href=\"?{% qs_alter request.GET activity='transcribed' delete:page %}\">Transcribed</a>\n                </div>\n            </th>\n            <th>\n                <div data-bs-toggle=\"dropdown\" aria-haspopup=\"true\" aria-expanded=\"false\">Current Status <span class=\"fa fa-chevron-down\"></span></div>\n                <div class=\"dropdown-menu border border-primary py-0 rounded-0\">\n                    <a class=\"dropdown-item border-bottom border-primary{% if 'in_progress' in status_list %} fw-bold{% endif %} filter-link\" href=\"?{% qs_alter request.GET status='in_progress' delete:page %}\">In Progress</a>\n                    <a class=\"dropdown-item border-bottom border-primary{% if 'submitted' in status_list %} fw-bold{% endif %} filter-link\" href=\"?{% qs_alter request.GET status='submitted' delete:page %}\">Needs Review</a>\n                    <a class=\"dropdown-item{% if 'completed' in status_list %} fw-bold{% endif %} filter-link\" href=\"?{% qs_alter request.GET status='completed' delete:page %}\">Completed</a>\n                </div>\n            </th>\n        </tr>\n    </thead>\n    <tbody>\n        {% for asset in page_obj %}\n            <tr class=\"{{ asset.item.project.campaign.id }} recent-page\">\n                <td class=\"col-md-3\"><abbr title=\"{{ asset.latest_activity|date:'SHORT_DATE_FORMAT' }}\">{{ asset.latest_activity }}</abbr></td>\n                <td class=\"text-end\"><a href=\"{{ asset.get_absolute_url }}\">{{ asset.sequence }}</a></td>\n                <td><a href=\"{{ asset.item.get_absolute_url }}\">{{ asset.item.title }}</a></td>\n                <td>{% if asset.last_reviewed %}Reviewed{% else %}Transcribed{% endif %}</td>\n                <td>{{ asset.get_transcription_status_display }}</td>\n            </tr>\n        {% endfor %}\n    </tbody>\n</table>\n<div class=\"row\">\n    {% include \"fragments/standard-pagination.html\" %}\n</div>\n"
  },
  {
    "path": "concordia/templates/fragments/sharing-button-group.html",
    "content": "<div class=\"concordia-share-button-group btn-group\" role=\"navigation\" aria-label=\"Links to share this page\">\n    <a class=\"facebook-share-button btn btn-link px-1 py-0\" target=\"_blank\" role=\"button\" title=\"Share this page on Facebook\" href=\"https://www.facebook.com/share.php?u={{ url|urlencode:\"\" }}\"><span class=\"bitmap-icon facebook-icon\"></span></a>\n    <a class=\"twitter-share-button btn btn-link px-1 py-0\" target=\"_blank\" role=\"button\" title=\"Share this page on Twitter\" href=\"https://twitter.com/intent/tweet?text={% filter urlencode %}”{{ title }}” {{ url }} #ByThePeople @Crowd_LOC{% endfilter %}&amp;source=webclient\"><span class=\"bitmap-icon twitter-icon\"></span></a>\n    <a class=\"copy-url-button btn btn-link px-1 py-0\" target=\"_blank\" rel=noopener role=\"button\" data-bs-toggle=\"tooltip\" data-bs-trigger=\"manual\" data-bs-placement=\"bottom\" aria-label=\"Copy this link to your clipboard\" href=\"{{ url }}\"><span class=\"bitmap-icon copy-link-icon\"></span></a>\n</div>\n"
  },
  {
    "path": "concordia/templates/fragments/standard-pagination.html",
    "content": "{% load concordia_querystring %}\n\n{% comment %}\nThis template fragment assumes that you are using Bootstrap's default pagination\nwith a Django ListView CBV or equivalent which has the default is_paginated,\npaginator, and page_obj variables defined.\n{% endcomment %}\n\n{% if is_paginated %}\n    <nav class=\"w-100\" aria-label=\"Pagination\">\n        <ul class=\"pagination mx-auto justify-content-center\">\n            {% if page_obj.has_previous %}\n                <li class=\"page-item\">\n                    <a class=\"page-link\" href=\"?{% qs_alter request.GET page=page_obj.previous_page_number %}\" aria-title=\"Previous Page\">\n                        <span class=\"fas fa-chevron-left\"><span class=\"visually-hidden\">Previous Page</span></span>\n                    </a>\n                </li>\n            {% else %}\n                <li class=\"page-item disabled\" aria-hidden=\"true\">\n                    <span class=\"page-link\">\n                        <span class=\"fas fa-chevron-left\"></span>\n                    </span>\n                </li>\n            {% endif %}\n\n            {% if page_obj.number > 1 %}\n                <li class=\"page-item\">\n                    <a class=\"page-link\" href=\"?{% qs_alter request.GET page=1 %}\">1</a>\n                </li>\n            {% endif %}\n\n            {% if page_obj.previous_page_number > 3 %}\n                <li class=\"page-item disabled\" aria-hidden=\"true\"><span class=\"page-link\">…</span></li>\n            {% endif %}\n\n            {% if page_obj.has_previous %}\n                {% with page_obj.previous_page_number|add:-1 as second_previous_page %}\n                    {% if second_previous_page > 1 %}\n                        <li class=\"page-item\">\n                            <a class=\"page-link\" href=\"?{% qs_alter request.GET page=second_previous_page %}\">{{ second_previous_page }}</a>\n                        </li>\n                    {% endif %}\n                {% endwith %}\n            {% endif %}\n\n            {% if page_obj.previous_page_number > 1 %}\n                <li class=\"page-item\">\n                    <a class=\"page-link\" href=\"?{% qs_alter request.GET page=page_obj.previous_page_number %}\">{{ page_obj.previous_page_number }}</a>\n                </li>\n            {% endif %}\n\n            <li class=\"page-item active\">\n                <a class=\"page-link\" href=\"?{% qs_alter request.GET page=page_obj.number %}\" aria-current=\"page\">\n                    {{ page_obj.number }}\n                </a>\n            </li>\n\n            {% if page_obj.next_page_number < paginator.num_pages %}\n                <li class=\"page-item\">\n                    <a class=\"page-link\" href=\"?{% qs_alter request.GET page=page_obj.next_page_number %}\">{{ page_obj.next_page_number }}</a>\n                </li>\n            {% endif %}\n\n            {% if page_obj.has_next %}\n                {% with page_obj.next_page_number|add:1 as second_next_page %}\n                    {% if second_next_page < paginator.num_pages %}\n                        <li class=\"page-item\">\n                            <a class=\"page-link\" href=\"?{% qs_alter request.GET page=second_next_page %}\">{{ second_next_page }}</a>\n                        </li>\n                    {% endif %}\n                {% endwith %}\n            {% endif %}\n\n            {% if page_obj.next_page_number|add:2 < paginator.num_pages %}\n                <li class=\"page-item disabled\" aria-hidden=\"true\"><span class=\"page-link\">…</span></li>\n            {% endif %}\n\n            {% if page_obj.number < paginator.num_pages %}\n                <li class=\"page-item\">\n                    <a class=\"page-link\" href=\"?{% qs_alter request.GET page=paginator.num_pages %}\">{{ paginator.num_pages }}</a>\n                </li>\n            {% endif %}\n\n            {% if page_obj.has_next %}\n                <li class=\"page-item\">\n                    <a class=\"page-link\" href=\"?{% qs_alter request.GET page=page_obj.next_page_number %}\" aria-title=\"Next Page\">\n                        <span class=\"fas fa-chevron-right\"><span class=\"visually-hidden\">Next Page</span></span>\n                    </a>\n                </li>\n            {% else %}\n                <li class=\"page-item disabled\" aria-hidden=\"true\">\n                    <span class=\"page-link\">\n                        <span class=\"fas fa-chevron-right\"></span>\n                    </span>\n                </li>\n            {% endif %}\n        </ul>\n    </nav>\n    <nav class=\"w-100\" aria-label=\"Page Jump\">\n        <form method=\"get\" class=\"d-flex justify-content-center mt-3\" role=\"form\">\n            <div class=\"input-group input-group-sm\" style=\"max-width: 240px;\">\n                <label class=\"input-group-text\" for=\"page-jump\">Jump to</label>\n                <select class=\"form-select\" id=\"page-jump\" name=\"page\">\n                    {% for i in paginator.page_range %}\n                        <option value=\"{{ i }}\" {% if i == page_obj.number %}selected{% endif %}>\n                            Page {{ i }}{% if i == page_obj.number %} of {{ paginator.num_pages }}{% endif %}\n                        </option>\n                    {% endfor %}\n                </select>\n                <button type=\"submit\" class=\"btn btn-primary\">Go</button>\n            </div>\n\n            {# Preserve other query parameters #}\n            {% for key, value in request.GET.items %}\n                {% if key != 'page' %}\n                    <input type=\"hidden\" name=\"{{ key }}\" value=\"{{ value }}\">\n                {% endif %}\n            {% endfor %}\n        </form>\n    </nav>\n{% endif %}\n"
  },
  {
    "path": "concordia/templates/fragments/transcription-progress-bar.html",
    "content": "{% load humanize %}\n<div id=\"contributor-stats\">\n    {{ contributor_count|intcomma }} registered\n    contributor{{contributor_count|pluralize}}\n</div>\n\n<div id=\"progress-bar\" class=\"progress\">\n    <div\n        title=\"Completed ({{ completed_count|intcomma }} page{{ completed_count|pluralize }})\"\n        class=\"progress-bar bg-completed\"\n        role=\"progressbar\"\n        style=\"width: {{ completed_percent }}%\"\n        aria-valuenow=\"{{ completed_percent }}\"\n        aria-valuemin=\"0\"\n        aria-valuemax=\"100\"\n    ></div>\n    <div\n        title=\"Needs Review ({{ submitted_count|intcomma }} page{{ submitted_count|pluralize }})\"\n        class=\"progress-bar bg-submitted\"\n        role=\"progressbar\"\n        style=\"width: {{ submitted_percent }}%\"\n        aria-valuenow=\"{{ submitted_percent }}\"\n        aria-valuemin=\"0\"\n        aria-valuemax=\"100\"\n    ></div>\n    <div\n        title=\"In Progress ({{ in_progress_count|intcomma }} page{{ in_progress_count|pluralize }})\"\n        class=\"progress-bar bg-in_progress\"\n        role=\"progressbar\"\n        style=\"width: {{ in_progress_percent }}%\"\n        aria-valuenow=\"{{ in_progress_percent }}\"\n        aria-valuemin=\"0\"\n        aria-valuemax=\"100\"\n    ></div>\n</div>\n<div class=\"table-responsive-md\">\n    <table id=\"progress-stats\" class=\"table table-sm fw-light\">\n        <tbody>\n            {% for key, label, value in transcription_status_counts %}\n                <tr\n                    class=\"{% if filters.transcription_status == key %}table-secondary{% endif %}\"\n                >\n                    <th class=\"text-nowrap\">\n                        <a href=\"?transcription_status={{ key|urlencode }}\">\n                            <span\n                                class=\"transcription-status-key bg-{{ key }}\"\n                            ></span>\n                            {{ label }}\n                        </a>\n                    </th>\n                    <td class=\"text-end\">\n                        <a href=\"?transcription_status={{ key|urlencode }}\">\n                            <abbr title=\"{{ value|intcomma }} pages\"\n                            >{{ value|intcomma }}</abbr\n                                >\n                            </a>\n                        </td>\n                    </tr>\n            {% endfor %}\n        </tbody>\n    </table>\n</div>\n"
  },
  {
    "path": "concordia/templates/fragments/transcription-progress-row.html",
    "content": "{% load humanize %}\n\n<div class=\"row\">\n    <div class=\"col-12 col-lg pt-1 pb-1\">\n        <div class=\"progress campaign-page-progress\">\n            <div\n                title=\"Completed ({{ completed_count|intcomma }} page{{ completed_count|pluralize }})\"\n                class=\"progress-bar bg-completed\"\n                role=\"progressbar\"\n                style=\"width: {{ completed_percent }}%\"\n                aria-valuenow=\"{{ completed_percent }}\"\n                aria-valuemin=\"0\"\n                aria-valuemax=\"100\"\n            ></div>\n            <div\n                title=\"Needs Review ({{ submitted_count|intcomma }} page{{ submitted_count|pluralize }})\"\n                class=\"progress-bar bg-submitted\"\n                role=\"progressbar\"\n                style=\"width: {{ submitted_percent }}%\"\n                aria-valuenow=\"{{ submitted_percent }}\"\n                aria-valuemin=\"0\"\n                aria-valuemax=\"100\"\n            ></div>\n            <div\n                title=\"In Progress ({{ in_progress_count|intcomma }} page{{ in_progress_count|pluralize }})\"\n                class=\"progress-bar bg-in_progress bg-in-progress\"\n                role=\"progressbar\"\n                style=\"width: {{ in_progress_percent }}%\"\n                aria-valuenow=\"{{ in_progress_percent }}\"\n                aria-valuemin=\"0\"\n                aria-valuemax=\"100\"\n            ></div>\n            <div\n                title=\"Not Started ({{ not_started_count|intcomma }} page{{ not_started_count|pluralize }})\"\n                class=\"progress-bar bg-not_started bg-not-started\"\n                role=\"progressbar\"\n                style=\"width: {{ not_started_percent }}%\"\n                aria-valuenow=\"{{ not_started_percent }}\"\n                aria-valuemin=\"0\"\n                aria-valuemax=\"100\"\n            ></div>\n        </div>\n    </div>\n</div>\n<div class=\"row\">\n    <div class=\"col-12 col-lg pb-1\">\n        <ul class=\"progress-bar-labels list-unstyled m-0 p-1\">\n            {% if completed_percent %}\n                <li>{{ completed_percent }}% Completed</li>\n            {% endif %}\n            {% if submitted_percent %}\n                <li>{{ submitted_percent }}% Needs Review</li>\n            {% endif %}\n            {% if in_progress_percent %}\n                <li>{{ in_progress_percent }}% In Progress</li>\n            {% endif %}\n            {% if not_started_percent %}\n                <li>{{ not_started_percent }}% Not Started</li>\n            {% endif %}\n        </ul>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/fragments/transcription-status-filters.html",
    "content": "{% load humanize %}\n\n<div class=\"btn-group btn-group-toggle flex-wrap justify-content-center\n            {% if size == \"large\" %}\n                btn-group-xl w-100\n            {% else %}\n                btn-group-sm\n            {% endif %}\n           \">\n    {% for url, classes, key, label, count in status_choices %}\n        <a class=\"btn btn-outline-dark {{ classes }}\" href=\"{{ url|default:\"?\" }}\">\n            {% if key %}\n                <span class=\"bg-{{ key }}\n                             {% if size == \"large\" %}\n                                 transcription-status-key-lg\n                             {% else %}\n                                 transcription-status-key\n                             {% endif %}\n                            \"></span>\n            {% endif %}\n\n            {{ label }} {% if size == \"large\" and count >= 0 %}({{ count|intcomma }}){% endif %}\n        </a>\n    {% endfor %}\n</div>\n"
  },
  {
    "path": "concordia/templates/home.html",
    "content": "{% extends \"base.html\" %}\n{% load staticfiles %}\n{% load feature_flags %}\n\n{% block title %}Home{% endblock title %}\n\n{% block breadcrumbs-container %}{% endblock breadcrumbs-container %}\n\n{% block extra_main_classes %}{% endblock %}\n\n{% block main_content %}\n\n    {% flag_enabled 'SHOW_BANNER' as SHOW_BANNER %}\n\n    {% if SHOW_BANNER and banner %}\n        <div id=\"homepage-contribute-container\" class=\"container my-4\">\n            <div class=\"px-default ms-md-3 mb-5\">\n                <div class=\"alert {{ banner.alert_class }} alert-dismissible w-100 d-flex\" id=\"banner-{{ banner.slug }}\" role=\"alert\">\n                    <div id=\"banner-inner\" class=\"d-flex flex-1 justify-content-center\">\n                        {% if banner.link %}\n                            <a class=\"btn {{ banner.btn_class }}\" href=\"{{ banner.link }}\"{% if banner.open_in_new_window_tab %} target=\"_blank\"{% endif %}>\n                                {{ banner.text }}\n                            </a>\n                        {% else %}\n                            <table class=\"{{ banner.btn_class }} fw-bold\">\n                                <td class=\"align-middle\">{{ banner.text }}</td>\n                            </table>\n                        {% endif %}\n                        <button type=\"button\" class=\"btn btn-dark\" id=\"no-interface-banner\">Don't display this again</button>\n                    </div>\n                    <div class=\"d-flex justify-content-end\">\n                        <a type=\"button\" data-bs-dismiss=\"alert\" aria-label=\"Close\">\n                            <span class=\"fas fa-times text-white\"></span>\n                        </a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    {% endif %}\n\n    <div id=\"homepage-contribute-container\" class=\"container my-4\">\n        <div class=\"px-default ms-md-3 mb-5\">\n            <h2 class=\"text-center font-serif\">Be a virtual volunteer!</h2>\n            <p class=\"text-center px-md-5 mx-md-5\">Help transcribe Library of Congress documents. Volunteers create and review transcriptions to improve search, access, and discovery of these pages from history.</p>\n        </div>\n        <ul id=\"homepage-contribute-activities\" class=\"list-unstyled row text-center\">\n            <li class=\"col-sm px-4 mb-4\">\n                <a class=\"text-dark\" href=\"{% url 'welcome-guide' %}\">\n                    <img class=\"img-fluid\" src=\"{% static 'img/homepage-search.svg' %}\" alt=\"\" width=\"200\" height=\"200\" loading=\"lazy\">\n                    <h3 class=\"mt-3 text-uppercase\">Get started</h3>\n                    <p id=\"homepage-activity-tag-help\" class=\"m-0\">Learn how to volunteer. Anyone can contribute!</p>\n                </a>\n            </li>\n            <li class=\"col-sm px-4 mb-4\">\n                <a class=\"text-dark\" href=\"{% url 'transcription-basic-rules' %}\">\n                    <img class=\"img-fluid\" src=\"{% static 'img/homepage-pencil.svg' %}\" alt=\"\" width=\"200\" height=\"200\" loading=\"lazy\">\n                    <h3 class=\"mt-3 text-uppercase\">Transcribe</h3>\n                    <p id=\"homepage-activity-transcribe-help\" class=\"m-0\">No account needed! Type what you see on the page.</p>\n                </a>\n            </li>\n            <li class=\"col-sm px-4 mb-4\">\n                <a class=\"text-dark\" href=\"{% url 'how-to-review' %}\">\n                    <img class=\"img-fluid\" src=\"{% static 'img/homepage-checkmark.svg' %}\" alt=\"\" width=\"200\" height=\"200\" loading=\"lazy\">\n                    <h3 class=\"mt-3 text-uppercase\">Review</h3>\n                    <p id=\"homepage-activity-review-help\" class=\"m-0\">Review is the crucial final step! Register to edit and complete transcriptions.</p>\n                </a>\n            </li>\n        </ul>\n    </div>\n    {% flag_enabled 'ADVERTISE_ACTIVITY_UI' as ADVERTISE_ACTIVITY_UI %}\n    {% flag_enabled 'NEW_CAROUSEL_SLIDE' as NEW_CAROUSEL_SLIDE %}\n    {% flag_enabled 'CAROUSEL_CMS' as CAROUSEL_CMS %}\n\n    {% if CAROUSEL_CMS %}\n        <div id=\"homepage-carousel\" class=\"carousel slide container\">\n            <button type=\"button\" id=\"play-pause-button\" class=\"btn btn-primary play-pause-button\" aria-label=\"pause\">\n                <i class=\"fa fa-solid fa-pause\"></i>\n            </button>\n            <div class=\"carousel-indicators d-none d-lg-flex\">\n                {% for slide in slides %}\n                    <button type=\"button\" data-bs-target=\"#homepage-carousel\" data-bs-slide-to=\"{{ forloop.counter0 }}\" {% if forloop.first %}class=\"active\" aria-current=\"true\" {% endif %}aria-label=\"Slide {{ forloop.counter }}\"></button>\n                {% endfor %}\n            </div>\n\n            <div class=\"carousel-inner\">\n                {% for slide in slides %}\n                    <div class=\"carousel-item {% if forloop.first %} active {% endif %}\" {% if slide.overlay_position == \"right\" %} data-overlay-position=\"top-right\" {% endif %} data-bs-title=\"{{ slide.headline }}\" data-hero-text=\"{{ slide.body }}\" data-link-url=\"{{ slide.lets_go_url }}\">\n                        <img class=\"d-block img-fluid\" src=\"{{ MEDIA_URL }}{{ slide.carousel_image }}\" alt=\"{{ slide.image_alt_text }}\" width=\"1200\" height=\"480\">\n                        <div class=\"carousel-overlay text-center d-flex flex-column justify-content-around align-items-center\">\n                            <h2 class=\"h1 title mb-1 fw-bold\">{{ slide.headline }}</h2>\n                            <p class=\"hero-text mx-auto\">{{ slide.body }}</p>\n                            <a class=\"btn btn-primary px-4\" href=\"{{ slide.lets_go_url }}\">LET'S GO!</a>\n                        </div>\n                    </div>\n                {% endfor %}\n            </div>\n            <a class=\"carousel-control-prev\" href=\"#homepage-carousel\" role=\"button\" data-bs-slide=\"prev\">\n                <span class=\"carousel-control-prev-icon\" aria-hidden=\"true\"></span>\n                <span class=\"visually-hidden\">Previous</span>\n            </a>\n            <a class=\"carousel-control-next\" href=\"#homepage-carousel\" role=\"button\" data-bs-slide=\"next\">\n                <span class=\"carousel-control-next-icon\" aria-hidden=\"true\"></span>\n                <span class=\"visually-hidden\">Next</span>\n            </a>\n\n        </div>\n    {% else %}\n        <div id=\"homepage-carousel\" class=\"carousel slide container\" data-bs-ride=\"carousel\" data-bs-pause=\"hover\">\n            <ol class=\"carousel-indicators d-none d-lg-flex\">\n                <li data-bs-target=\"#homepage-carousel\" data-bs-slide-to=\"0\" class=\"active\"></li>\n                <li data-bs-target=\"#homepage-carousel\" data-bs-slide-to=\"1\"></li>\n                <li data-bs-target=\"#homepage-carousel\" data-bs-slide-to=\"2\"></li>\n                <li data-bs-target=\"#homepage-carousel\" data-bs-slide-to=\"3\"></li>\n                {% if ADVERTISE_ACTIVITY_UI %}\n                    <li data-bs-target=\"#homepage-carousel\" data-bs-slide-to=\"4\"></li>\n                {% endif %}\n            </ol>\n            <div class=\"carousel-inner\">\n                {% if NEW_CAROUSEL_SLIDE %}\n                    <div class=\"carousel-item active\" data-bs-title=\"Join our cause!\" data-hero-text=\"Women's suffrage review challenge August 12-19. Help complete pages and get to know the women who fought for change 100 years ago\" data-link-url=\"{% url 'redirect-to-next-reviewable-topic-asset' 'suffrage-women-fight-for-the-vote' %}\">\n                        <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/suffrage.jpg' %}\" alt=\"Carrie Chapman Catt stands with protesters advocating for women's right to vote. Some protesters carry banners and shields with state names, including Wyoming, California, Kansas. Some women are dressed in white, wearing crowns and carrying musical instruments, and American flags.\">\n                    </div>\n                    {% if ADVERTISE_ACTIVITY_UI %}\n                        <div class=\"carousel-item\" data-overlay-position=\"top-right\" data-bs-title=\"Find your perfect page\" data-hero-text=\"Log in or register to transcribe and review in our new way of browsing\" data-link-url=\"{% url 'action-app' %}\">\n                            <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/activity-ui.jpg' %}\" alt=\"New crowdsourcing browse interface. Two rows of pages volunteers can choose from to review.\">\n                        </div>\n                    {% endif %}\n                    <div class=\"carousel-item\" data-bs-title=\"Walt Whitman at 200\" data-hero-text=\"Transcribe and review poetry, letters, and writings of Walt Whitman. Discover how he wrote and lived for yourself.\" data-link-url=\"{% url 'transcriptions:redirect-to-next-transcribable-campaign-asset' 'walt-whitman' %}\">\n                        <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/whitman.jpg' %}\" alt=\"Walt Whitman in his younger years, a black and white engraving. Whitman with his right hand on his hip and his left in his pocket wearing a black rimmed hat.\">\n                    </div>\n                    <div class=\"carousel-item\" data-overlay-position=\"top-right\" data-bs-title=\"Review Now\" data-hero-text=\"Approve or correct other volunteers' transcriptions to help them cross the finish line.\" data-link-url=\"{% url 'transcriptions:redirect-to-next-reviewable-campaign-asset' 'mary-church-terrell-advocate-for-african-americans-and-women' %}\">\n                        <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/review.jpg' %}\" alt=\"Close up of hand-written text of Mary Church Terrell's first speech to the NAACP\">\n                    </div>\n                    <div class=\"carousel-item\" data-bs-title=\"Where to start?\" data-hero-text=\"Find instructions and help to get started in our Welcome Guide\" data-link-url=\"{% url 'welcome-guide' %}\">\n                        <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/welcome-guide.jpg' %}\" alt=\"Collection of typed report pages by baseball scout Branch Rickey\">\n                    </div>\n                {% else %}\n                    <div class=\"carousel-item active\" data-bs-title=\"Walt Whitman at 200\" data-hero-text=\"Transcribe and review poetry, letters, and writings of Walt Whitman. Discover how he wrote and lived for yourself.\" data-link-url=\"{% url 'transcriptions:redirect-to-next-transcribable-campaign-asset' 'walt-whitman' %}\">\n                        <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/whitman.jpg' %}\" alt=\"Walt Whitman in his younger years, a black and white engraving. Whitman with his right hand on his hip and his left in his pocket wearing a black rimmed hat.\">\n                    </div>\n                    {% if ADVERTISE_ACTIVITY_UI %}\n                        <div class=\"carousel-item\" data-overlay-position=\"top-right\" data-bs-title=\"Find your perfect page\" data-hero-text=\"Log in or register to transcribe and review in our new way of browsing\" data-link-url=\"{% url 'action-app' %}\">\n                            <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/activity-ui.jpg' %}\" alt=\"New crowdsourcing browse interface. Two rows of pages volunteers can choose from to review.\">\n                        </div>\n                    {% endif %}\n                    <div class=\"carousel-item\" data-overlay-position=\"top-right\" data-bs-title=\"Review Now\" data-hero-text=\"Approve or correct other volunteers' transcriptions to help them cross the finish line.\" data-link-url=\"{% url 'transcriptions:redirect-to-next-reviewable-campaign-asset' 'mary-church-terrell-advocate-for-african-americans-and-women' %}\">\n                        <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/review.jpg' %}\" alt=\"Close up of hand-written text of Mary Church Terrell's first speech to the NAACP\">\n                    </div>\n                    <div class=\"carousel-item\" data-bs-title=\"Where to start?\" data-hero-text=\"Find instructions and help to get started in our Welcome Guide\" data-link-url=\"{% url 'welcome-guide' %}\">\n                        <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/welcome-guide.jpg' %}\" alt=\"Collection of typed report pages by baseball scout Branch Rickey\">\n                    </div>\n                    <div class=\"carousel-item\" data-overlay-position=\"top-right\" data-bs-title=\"Jump in!\" data-hero-text=\"Transcription uncovers our shared history and makes documents more searchable for everyone.\" data-link-url=\"{% url 'transcriptions:redirect-to-next-transcribable-campaign-asset' 'mary-church-terrell-advocate-for-african-americans-and-women' %}\">\n                        <img class=\"d-block w-100\" src=\"{% static 'img/homepage-carousel/crowd-home.jpg' %}\" alt=\"A crowd of young women cheering and waving handkerchiefs\">\n                    </div>\n                {% endif %}\n            </div>\n            <a class=\"carousel-control-prev\" href=\"#homepage-carousel\" role=\"button\" data-bs-slide=\"prev\">\n                <span class=\"carousel-control-prev-icon\" aria-hidden=\"true\"></span>\n                <span class=\"visually-hidden\">Previous</span>\n            </a>\n            <a class=\"carousel-control-next\" href=\"#homepage-carousel\" role=\"button\" data-bs-slide=\"next\">\n                <span class=\"carousel-control-next-icon\" aria-hidden=\"true\"></span>\n                <span class=\"visually-hidden\">Next</span>\n            </a>\n            {% if NEW_CAROUSEL_SLIDE %}\n                <div class=\"carousel-overlay text-center d-flex flex-column justify-content-around align-items-center\">\n                    <h1 class=\"title\">Join our cause!</h1>\n                    <p class=\"hero-text mx-auto\">Women's suffrage review challenge August 12-19. Help complete pages and get to know the women who fought for change 100 years ago</p>\n                    <a class=\"btn btn-primary btn-lg\" href=\"{% url 'redirect-to-next-reviewable-topic-asset' 'suffrage-women-fight-for-the-vote' %}\">LET'S GO!</a>\n                </div>\n            {% else %}\n                <div class=\"carousel-overlay text-center d-flex flex-column justify-content-around align-items-center\">\n                    <h1 class=\"title\">Walt Whitman at 200</h1>\n                    <p class=\"hero-text mx-auto\">Transcribe and review poetry, letters, and writings of Walt Whitman. Discover how he wrote and lived for yourself.</p>\n                    <a class=\"btn btn-primary btn-lg\" href=\"{% url 'transcriptions:redirect-to-next-transcribable-campaign-asset' 'walt-whitman' %}\">LET'S GO!</a>\n                </div>\n            {% endif %}\n        </div>\n    {% endif %}\n    <div id=\"homepage-next-transcribable-links\" class=\"container mt-5\">\n        <div class=\"row align-items-center justify-content-md-center\">\n            <h2 class=\"col-md-auto text-center text-nowrap px-5 py-3 m-0\">Surprise me!</h2>\n            <ul class=\"col-md-auto row list-unstyled m-0\">\n                <li class=\"col text-center text-bold px-5 py-3\">\n                    <a href=\"{% url 'redirect-to-next-transcribable-asset' %}\"><span class=\"d-block text-dark fw-bold\">Jump into</span> <span class=\"d-block text-nowrap h2\">a transcription</span></a>\n                </li>\n                <li class=\"col text-center text-bold px-5 py-3\">\n                    <a href=\"{% url 'redirect-to-next-reviewable-asset' %}\"><span class=\"d-block text-dark fw-bold\">Jump into</span> <span class=\"d-block text-nowrap h2\">a review</span></a>\n                </li>\n            </ul>\n        </div>\n    </div>\n    <div id=\"homepage-campaign-list\" class=\"container pt-2 mt-5 mb-4\">\n        <h2 class=\"text-center font-serif mb-4\">Campaigns: <small>Choose which collections to explore and transcribe</small></h2>\n        <ul class=\"list-unstyled row text-center\">\n            {% for campaign in campaigns|slice:\":3\" %}\n                <li class=\"col-sm mb-4\">\n                    {% url 'transcriptions:campaign-detail' campaign.slug as campaign_url %}\n                    <a class=\"text-dark\" href=\"{{ campaign_url }}\">\n                        <div class=\"aspect-ratio-box\">\n                            <div class=\"aspect-ratio-box-inner-wrapper\">\n                                <img src=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\" class=\"img-fluid\" alt=\"{{ campaign.image_alt_text }}\" loading=\"lazy\">\n                            </div>\n                        </div>\n                        <span class=\"d-block h4 mt-2\">{{ campaign.title }}</span>\n                    </a>\n                </li>\n            {% endfor %}\n        </ul>\n        <div class=\"text-center\">\n            <a class=\"btn btn-primary\" href=\"{% url 'campaign-topic-list' %}\" role=\"button\">Browse All Campaigns</a>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/registration/activate.html",
    "content": "{% extends \"base.html\" %}\n{% block main_content %}\n    <h2>Account Activation Complete</h2>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/registration/login.html",
    "content": "{% extends \"base.html\" %}\n\n{% load django_bootstrap5 %}\n\n{% block head_content %}\n    <meta name=\"robots\" content=\"noindex\">\n    {{ block.super }}\n    <script module src=\"{{ TURNSTILE_JS_API_URL }}\"></script>\n{% endblock head_content %}\n\n{% block title %}Login{% endblock title %}\n\n{% block main_content %}\n    <div class=\"container\" role=\"dialog\" aria-labelledby=\"dialog-title\">\n        <div class=\"row\">\n            <div class=\"col-8 col-md-6 col-lg-4 mx-auto my-3\">\n                <h2 id=\"dialog-title\" class=\"text-center\">Welcome back!</h2>\n\n                <form id=\"login-form\" method=\"post\" action=\"{% url 'login' %}\" class=\"col-10 my-3 mx-auto\">\n                    {% csrf_token %}\n\n                    {% if next %}\n                        <input type=\"hidden\" name=\"next\" value=\"{{ next }}\" />\n                    {% endif %}\n\n                    {% bootstrap_form form %}\n                    <div class=\"w-100 text-center mt-0 mb-3\">{{ turnstile_form.turnstile }}</div>\n                    <p>\n                        By using this system, you agree to comply with\n                        <a href=\"https://www.loc.gov/legal/\" target=\"_blank\">the Library's\n                            security requirements</a>\n                    </p>\n                    {% bootstrap_button \"Login\" button_type=\"submit\" button_class=\"btn-primary\" extra_classes=\"btn\" id=\"login\" %}\n                </form>\n            </div>\n        </div>\n        <div class=\"row\">\n            <div class=\"col-8 col-md-6 col-lg-4 mx-auto my-3 text-center\">\n                <a href=\"{% url 'password_reset' %}\">Forgot my password</a>\n            </div>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/registration/password_change_done.html",
    "content": "{% extends \"base.html\" %}\n\n{% block main_content %}\n    <div class=\"row flex-column align-items-center justify-content-center\">\n        <p>Your password was changed</p>\n\n        <nav>\n            <ul class=\"nav justify-content-center\">\n                <li class=\"nav-item\">\n                    <a class=\"nav-link\" href=\"{% url 'user-profile' %}\">\n                        Return to your account profile\n                    </a>\n                </li>\n                <li class=\"nav-item\">\n                    <a class=\"nav-link\" href=\"/\">Home</a>\n                </li>\n            </ul>\n        </nav>\n\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "concordia/templates/registration/password_change_form.html",
    "content": "{% extends \"base.html\" %}\n\n{% load i18n staticfiles %}\n{% load django_bootstrap5 %}\n\n{% block head_content %}\n    <meta name=\"robots\" content=\"noindex\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <div class=\"row\">\n            <form method=\"post\" class=\"col-md-6 mx-auto\">\n                {% csrf_token %}\n\n                {% bootstrap_form form %}\n\n                {% bootstrap_button \"Save\" button_type=\"submit\" button_class=\"btn-primary\" extra_classes=\"btn\" %}\n            </form>\n        </div>\n    </div>\n{% endblock %}\n\n{% block body_scripts %}\n    {{ block.super }}\n    <script src=\"{% static 'js/password-validation.js' %}\"></script>\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/registration/password_reset_complete.html",
    "content": "{% extends \"base.html\" %}\n{% load i18n %}\n\n{% block title %}{{ title }}{% endblock %}\n{% block content_title %}<h1>{{ title }}</h1>{% endblock %}\n\n{% block main_content %}\n    <div class=\"container p-3\">\n        <div class=\"row\">\n            <div class=\"col-md-6 mx-auto\">\n                <p>\n                    Your password has been reset and you are now logged in. If your account was inactive, it has been activated.\n                </p>\n                <p>\n                    New here? Visit the <a href=\"{% url 'welcome-guide' %}\">By the People Welcome Guide</a> for instructions and help getting started.\n                </p>\n            </div>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "concordia/templates/registration/password_reset_confirm.html",
    "content": "{% extends \"base.html\" %}\n{% load i18n staticfiles %}\n{% load django_bootstrap5 %}\n\n{% block title %}{{ title }}{% endblock %}\n{% block content_title %}<h1>{{ title }}</h1>{% endblock %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <div class=\"row\">\n            <div class=\"col-md-8 mx-auto my-3 p-3\">\n                {% if validlink %}\n                    <form method=\"post\" class=\"col-10 mx-auto\">\n                        {% csrf_token %}\n\n                        {% bootstrap_form form %}\n\n                        {% bootstrap_button \"Change my password\" button_type=\"submit\" button_class=\"btn-primary\" extra_classes=\"btn\" %}\n                    </form>\n                {% else %}\n                    <p>\n                        {% trans \"The password reset link was invalid, possibly because it has already been used. Please request a new password reset.\" %}\n                    </p>\n                {% endif %}\n            </div>\n        </div>\n    </div>\n{% endblock %}\n\n{% block body_scripts %}\n    {{ block.super }}\n    <script src=\"{% static 'js/password-validation.js' %}\"></script>\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/registration/password_reset_done.html",
    "content": "{% extends \"base.html\" %}\n{% load i18n %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item active\" aria-current=\"page\">{% trans 'Password reset' %}</li>\n{% endblock %}\n\n{% block title %}{{ title }}{% endblock %}\n{% block content_title %}<h1>{{ title }}</h1>{% endblock %}\n{% block extra_main_classes %}container{% endblock %}\n{% block main_content %}\n    <div class=\"row\">\n        <div class=\"col-md-8 mx-auto p-3\">\n            <p role=\"alert\">\n                {% trans \"We've emailed you instructions for setting your password, if an account exists with the email you entered. You should receive them shortly.\" %}\n            </p>\n            <p>\n                {% trans \"If you don't receive an email, please make sure you've entered the address you registered with, and check your spam folder.\" %}\n            </p>\n        </div>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "concordia/templates/registration/password_reset_email.html",
    "content": "{% load i18n %}{% autoescape off %}\n    {% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}\n\n    {% trans \"Please go to the following page and choose a new password:\" %}\n    {% block reset_link %}\n        https://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}\n    {% endblock %}\n    {% trans \"Your username, in case you've forgotten:\" %} {{ user.get_username }}\n\n    {% trans \"Thanks for using our site!\" %}\n\n    {% blocktrans %}The {{ site_name }} team{% endblocktrans %}\n\n{% endautoescape %}\n"
  },
  {
    "path": "concordia/templates/registration/password_reset_form.html",
    "content": "{% extends \"base.html\" %}\n\n{% load i18n staticfiles %}\n{% load django_bootstrap5 %}\n\n{% block title %}{{ title }}{% endblock %}\n\n{% block content_title %}<h1>{{ title }}</h1>{% endblock %}\n\n{% block head_content %}\n    <meta name=\"robots\" content=\"noindex\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <div class=\"row\">\n            <div class=\"col-6 mx-auto\" role=\"dialog\">\n                <p>\n                    {% trans \"Forgotten your password? Enter your email address below, and we'll email instructions for setting a new one.\" %}\n                </p>\n\n                <form method=\"post\">\n                    {% csrf_token %}\n\n                    {% bootstrap_form form %}\n\n                    {% bootstrap_button \"Reset my password\" button_type=\"submit\" button_class=\"btn-primary\" extra_classes=\"btn\"%}\n                </form>\n            </div>\n        </div>\n    </div>\n{% endblock %}\n\n{% block body_scripts %}\n    {{ block.super }}\n    <script src=\"{% static 'js/password-validation.js' %}\"></script>\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/registration/password_reset_subject.txt",
    "content": "{{ site_name }}\n"
  },
  {
    "path": "concordia/templates/static-page.html",
    "content": "{% extends \"base.html\" %}\n{% load staticfiles django_vite %}\n\n{% block title %}{{ title }}{% endblock title %}\n\n{% block extra_scripts %}\n    {% if about_page %}\n        {% vite_asset 'src/about.js' %}\n    {% endif %}\n{% endblock %}\n\n{% block breadcrumbs %}\n    {% for link, title in breadcrumbs %}\n        {% if forloop.last %}\n            <li class=\"breadcrumb-item active\" title=\"{{ title }}\">{{ title }}</li>\n        {% else %}\n            <li class=\"breadcrumb-item\"><a class=\"primary-text\" href=\"{{ link }}\" title=\"{{ title }}\">{{ title }}</a></li>\n        {% endif %}\n    {% endfor %}\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <div class=\"row\">\n            <div class=\"col\">\n                <h1 class=\"my-3\">{{ title }}</h1>\n\n                <div class=\"simple-page\">\n                    {% if add_navigation %}\n                        <div class=\"row\">\n                            <div class=\"col-3\">\n                                <div class=\"nav flex-column help-center\">\n                                    <h4>Instructions</h4>\n                                    {% for guide in guides %}\n                                        <a class=\"nav-link{% if guide.page.path == request.path %} active{% endif %}\" href=\"{{ guide.page.path }}\">\n                                            {{ guide.title }}\n                                        </a>\n                                    {% endfor %}\n                                    <span lang=\"es\">\n                                        <a class=\"nav-link\" href=\"/help-center/how-to-transcribe-esp/\">Instrucciones en español</a>\n                                    </span>\n                                </div>\n                            </div>\n                            <div class=\"p-3 col-9\">\n                                {{ body|safe }}\n                            </div>\n                        </div>\n                    {% else %}\n                        {{ body|safe }}\n                    {% endif %}\n                </div>\n            </div>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/asset_reservation_failure_modal.html",
    "content": "<div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n    <div class=\"modal-content\">\n        <div class=\"modal-header\">\n            <h5 class=\"modal-title\">Someone else is already transcribing this page</h5>\n            <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n        </div>\n        <div class=\"modal-body\">\n            <p>You can help by transcribing a new page, adding tags to this page, or coming back later to review this page's transcription.</p>\n        </div>\n        <div class=\"modal-footer\">\n            <a class=\"btn btn-primary\" href=\"{{ next_open_asset_url }}\">\n                Find new page\n            </a>\n            <button type=\"button\" class=\"btn btn-primary\" data-bs-dismiss=\"modal\">Close</button>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/editor.html",
    "content": "<div class=\"flex-grow-1 d-flex d-print-block flex-column\">\n    <form id=\"transcription-editor\" class=\"ajax-submission flex-grow-1 d-flex flex-column d-print-block\" method=\"post\" action=\"{% url 'save-transcription' asset_pk=asset.pk %}\" data-transcription-status=\"{{ transcription_status }}\" {% if transcription %}data-transcription-id=\"{{ transcription.pk|default:'' }}\" {% if transcription.submitted %}data-unsaved-changes=\"true\"{% endif %} data-submit-url=\"{% url 'submit-transcription' pk=transcription.pk %}\" data-review-url=\"{% url 'review-transcription' pk=transcription.pk %}\"{% endif %}>\n        {% csrf_token %}\n        <input type=\"hidden\" name=\"supersedes\" value=\"{{ transcription.pk|default:'' }}\" />\n        <div class=\"row justify-content-sm-between align-items-end mx-0\">\n            <div class=\"col ps-0\">\n                <div id=\"transcription-status-message\">\n                    <div id=\"transcription-status-display\" class=\"row\">\n                        <h2 id=\"display-submitted\" {% if transcription_status != 'submitted' %}hidden{% endif %}>\n                            <span class=\"fas fa-list\"></span>\n                            Needs review\n                        </h2>\n                        <h2 id=\"display-completed\" {% if transcription_status != 'completed' %}hidden{% endif %}>\n                            <span class=\"fas fa-check\"></span>\n                            Completed\n                        </h2>\n                        <h2 id=\"display-notstarted\" {% if transcription_status != \"not_started\" %}hidden{% endif %}>\n                            <span class=\"fas fa-edit\"></span>\n                            Not started\n                        </h2>\n                        <h2 id=\"display-inprogress\" {% if transcription_status != \"in_progress\" %}hidden{% endif %}>\n                            <span class=\"fas fa-edit\"></span>\n                            In progress\n                        </h2>\n                        <span id=\"display-conflict\" hidden>\n                            <span class=\"fas fa-exclamation-triangle\"></span>\n                            Another user is transcribing this page\n                        </span>\n                    </div>\n                </div>\n                <div class=\"w-100\">\n                    <h2 id=\"message-contributors\" {% if transcription_status == 'not_started' %}hidden{% endif %}>\n                        Registered Contributors: <span id=\"message-contributors-num\" class=\"fw-normal\">{{ registered_contributors }}</span>\n                    </h2>\n                    <span id=\"message-notstarted\" {% if transcription_status != 'not_started' %}hidden{% endif %}>\n                        Transcribe this page.\n                    </span>\n                    <span id=\"message-inprogress\" {% if transcription_status != 'in_progress' %}hidden{% endif %}>\n                        Someone started this transcription. Can you finish it?\n                    </span>\n                    <span id=\"message-submitted\" {% if transcription_status != 'submitted' %}hidden{% endif %}>\n                        Check this transcription thoroughly. Accept if correct!\n                    </span>\n                    <span id=\"message-completed\" {% if transcription_status != 'completed' %}hidden{% endif %}>\n                        This transcription is finished! You can read and add tags.\n                    </span>\n                </div>\n            </div>\n            {% if cards %}\n                <div id=\"instruction-buttons\" class=\"d-flex align-items-end col pe-0\">\n                    <a class=\"fw-bold mt-3\" id=\"quick-tips\" data-bs-toggle=\"modal\" data-bs-target=\"#tutorial-popup\" role=\"button\" href=\"#\">\n                        <u>Campaign Tips</u>\n                    </a>\n                    {% if guides %}\n                        <div>\n                            <button id=\"open-guide\" class=\"btn btn-primary\" type=\"button\">How-To Guide</button>\n                        </div>{% endif %}\n                </div>\n            {% endif %}\n        </div>\n\n        {% spaceless %}\n            <div id=\"loading-container\" class=\"pb-2\">\n                <div id=\"ocr-loading\" class=\"spinner-border\" role=\"status\" aria-hidden=\"true\" hidden>\n                    <span class=\"visually-hidden\">Loading...</span>\n                </div>\n            </div>\n            <div class=\"d-flex flex-column flex-grow-1\" id=\"transcription-input-container\">\n                <textarea readonly class=\"form-control rounded flex-grow-1 d-print-none\" name=\"text\" id=\"transcription-input\" placeholder=\"{% if transcription_status == 'not_started' or transcription_status == 'in_progress' %}Go ahead, start typing. You got this!{% else %}Nothing to transcribe{% endif %}\" aria-label=\"Transcription input\">\n                    {{ transcription.text }}\n                </textarea>\n                {% if guides %}\n                    {% include \"transcriptions/asset_detail/guide.html\" %}\n                {% endif %}\n            </div>\n\n            <div class=\"print-transcription-text\" aria-hidden=\"true\" style=\"display: none;\">{{ transcription.text }}</div>\n\n            <div class=\"mt-3 mb-2 d-print-none d-flex flex-wrap justify-content-center align-items-center\">\n                {% if transcription_status == 'not_started' or transcription_status == 'in_progress' %}\n                    <div class=\"form-check mt-0 mb-3 d-flex justify-content-center w-100\">\n                        <input id=\"nothing-to-transcribe\" type=\"checkbox\" class=\"form-check-input\" />\n                        <label class=\"form-check-label ms-1\" for=\"nothing-to-transcribe\">\n                            Nothing to transcribe\n                        </label>\n\n                        <a tabindex=\"0\" class=\"btn btn-link d-inline py-0\" role=\"button\" data-bs-toggle=\"popover\" data-bs-placement=\"top\" data-bs-trigger=\"focus click hover\" title=\"Nothing to transcribe?\" data-bs-html=\"true\" data-bs-content=\"If there is no text to transcribe, check this box and click &quot;Submit&quot;. Learn more about what to transcribe and what to skip in &quot;How To.&quot;\">\n                            <span class=\"fas fa-question-circle\" aria-label=\"Open Help\"></span>\n                        </a>\n                    </div>\n\n                    <div>\n                        <button id=\"save-transcription-button\" disabled type=\"submit\" class=\"btn btn-primary mx-1 mb-2\" title=\"Save the text you entered above\">\n                            Save\n                        </button>\n                        <button id=\"rollback-transcription-button\" {% if not undo_available %}disabled{% endif %} type=\"button\" class=\"btn btn-outline-primary mx-1 mb-2\" title=\"Undo\" data-url=\"{% url 'rollback-transcription' asset_pk=asset.pk %}\">\n                            <span class=\"fas fa-undo\"></span> Undo\n                        </button>\n                        <button id=\"rollforward-transcription-button\" {% if not redo_available %}disabled{% endif %} type=\"button\" class=\"btn btn-outline-primary mx-1 mb-2\" title=\"Redo\" data-url=\"{% url 'rollforward-transcription' asset_pk=asset.pk %}\">\n                            Redo <span class=\"fas fa-redo\"></span>\n                        </button>\n                        <button id=\"submit-transcription-button\" disabled type=\"button\" class=\"btn btn-primary mx-1 mb-2\" title=\"Request another volunteer to review the text you entered above\">\n                            Submit for Review\n                        </button>\n                    </div>\n\n                {% elif transcription_status == 'submitted' %}\n                    {% if not user.is_authenticated %}\n                        <p class=\"help-text\">\n                            <a href=\"{% url 'registration_register' %}\">Register</a>\n                            or\n                            <a href=\"{% url 'login' %}?next={{ request.path|urlencode }}\">login</a>\n                            to help review\n                        </p>\n                    {% else %}\n                        <button id=\"reject-transcription-button\" disabled type=\"button\" class=\"btn btn-primary mx-1\" title=\"Correct errors you see in the text\">Edit</button>\n                        {% if transcription.user.pk == user.pk %}\n                            <p class=\"help-text mt-2\">You submitted this transcription. You can re-open it for editing if you wish to make changes before another volunteer reviews it.</p>\n                        {% else %}\n                            <button id=\"accept-transcription-button\" disabled type=\"button\" class=\"btn btn-primary mx-1\" title=\"Confirm that the text is accurately transcribed\">Accept</button>\n                        {% endif %}\n                    {% endif %}\n                {% endif %}\n                {% if anonymous_user_validation_required %}\n                    {% if transcription_status == 'not_started' or transcription_status == 'in_progress' %}\n                        <div class=\"w-100 text-center mt-1 mb-1\">{{ turnstile_form.turnstile }}</div>\n                    {% endif %}\n                {% endif %}\n            </div>\n        {% endspaceless %}\n    </form>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/error_modal.html",
    "content": "<div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n    <div class=\"modal-content\">\n        <div class=\"modal-header\">\n            <h5 class=\"modal-title\" id=\"error-modal-title\">An error ocurred</h5>\n            <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n        </div>\n        <div class=\"modal-body\" id=\"error-modal-message\">\n            <p>An error occurred.</p>\n        </div>\n        <div class=\"modal-footer\">\n            <button type=\"button\" class=\"btn btn-primary\" data-bs-dismiss=\"modal\">Close</button>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/guide.html",
    "content": "<div id=\"guide-sidebar\" class=\"sidebar offscreen\" tabindex=\"-1\">\n    <div id=\"title-bar\" class=\"bg-primary px-2 py-1 row text-white\">\n        <div id=\"guide-bars-col\" class=\"col-1 d-none pt-2\">\n            <a id=\"guide-bars\" data-bs-target=\"#guide-carousel\" data-bs-slide-to=\"0\" href=\"\" aria-label=\"link\">\n                <i aria-hidden=\"true\" class=\"fas fa-solid fa-bars ps-3 pb-2\"></i>\n            </a>\n        </div>\n        <div class=\"col-10\">\n            <h3 class=\"px-2 my-1 py-1\">How-To Guide</h3>\n        </div>\n        <div class=\"col-1 pt-2\">\n            <a id=\"close-guide\">\n                <span aria-hidden=\"true\" class=\"fas fa-times\"></span>\n            </a>\n        </div>\n    </div>\n    <div id=\"guide-carousel\" class=\"carousel carousel-fade\" data-bs-interval=\"false\">\n        <div class=\"carousel-inner\">\n            <div class=\"carousel-item active\" id=\"guide-nav\">\n                <ul class=\"nav flex-column\">\n                    <li class=\"nav-item toc-title\">\n                        <a class=\"nav-link\" data-bs-target=\"#guide-carousel\" data-bs-slide-to=\"1\" href=\"#\" tabindex=\"-1\">About This Campaign</a>\n                    </li>\n                    {% for guide in guides %}\n                        <li class=\"nav-item toc-title\">\n                            <a data-bs-target=\"#guide-carousel\" data-bs-slide-to=\"{{ forloop.counter|add:1 }}\" class=\"nav-link\" href=\"#pane-{{ forloop.counter }}\" tabindex=\"-1\">\n                                {{ guide.title }}\n                            </a>\n                        </li>\n                    {% endfor %}\n                </ul>\n            </div>\n            <div class=\"carousel-item container\">\n                <div class=\"border-bottom justify-content-end mb-3 py-1 d-flex\">\n                    <div class=\"col-7 justify-self-center me-4\">\n                        <h3>About This Campaign</h3>\n                    </div>\n                    <div class=\"col-1 pt-2\">\n                        <a class=\"fw-bold ms-3\" id=\"next-guide\" data-bs-target=\"#guide-carousel\" data-bs-slide=\"next\">></a>\n                    </div>\n                </div>\n                <div class=\"guide-body\">\n                    {% if campaign.description %}\n                        <h4>About this campaign</h4>\n                        <p>{{ campaign.description|safe }}</p>\n                    {% endif %}\n                    {% if asset.item.project.description %}\n                        <h4>About this project</h4>\n                        <p>{{ asset.item.project.description|safe }}</p>\n                    {% endif %}\n                    {% if campaign.helpfullink_set.related_links %}\n                        <h5 class=\"pt-3\">Helpful Links</h5>\n                        <p>\n                            <ul>\n                                {% for link in campaign.helpfullink_set.related_links %}\n                                    <li class=\"mb-3\">\n                                        <a href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>\n                                            {{ link.title }}{% if 'loc.gov' not in link.link_url %} <i class=\"fa fa-external-link-alt\"></i>{% endif %}\n                                        </a>\n                                    </li>\n                                {% endfor %}\n                            </ul>\n                    {% endif %}\n                </div>\n            </div>\n            {% for guide in guides %}\n                <div class=\"carousel-item container\" id=\"pane-{{ forloop.counter }}\">\n                    <div class=\"border-bottom guide-header row\">\n                        <div class=\"col-1 pt-2 prev-guide\">\n                            <a class=\"fw-bold\" id=\"previous-guide\" data-bs-target=\"#guide-carousel\" data-bs-slide=\"prev\"><</a>\n                        </div>\n                        <div class=\"col text-center ps-2\">\n                            <h3>{{ guide.title }}</h3>\n                        </div>\n                        <div class=\"col-1 pt-2 next-guide\">\n                            {% if not forloop.last %}\n                                <a class=\"fw-bold\" id=\"next-guide\" data-bs-target=\"#guide-carousel\" data-bs-slide=\"next\">></a>\n                            {% endif %}\n                        </div>\n                    </div>\n                    <div class=\"guide-body\">\n                        {{ guide.body|safe }}\n                    </div>\n                </div>\n            {% endfor %}\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/language_selection_modal.html",
    "content": "<div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n    <div class=\"modal-content\">\n        <div class=\"modal-header d-flex justify-content-end\">\n            <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n        </div>\n        <form id=\"ocr-transcription-form\" class=\"ajax-submission\" method=\"post\" action=\"{% url 'generate-ocr-transcription' asset_pk=asset.pk %}\" data-lock-element=\"#transcription-editor\">\n            <div class=\"modal-body\">\n                <div class=\"bg-light p-3\">\n                    <h5 class=\"modal-title mb-3\">Select language</h5>\n                    <p>Select the language the transcription is in from the list below.</p>\n                    <div class=\"text-center pb-1\">\n                        <select id=\"language\" name=\"language\" size=\"7\">\n                            {% for language in languages %}\n                                <option value=\"{{ language.0 }}\"{% if language.0 == \"eng\" %} selected=\"selected\"{% endif %}>\n                                    {{ language.1 }}\n                                </option>\n                            {% endfor %}\n                        </select>\n                    </div>\n                </div>\n            </div>\n            <div class=\"modal-footer\">\n                <button type=\"button\" class=\"btn btn-primary\" data-bs-dismiss=\"modal\">Cancel</button>\n                {% if transcription_status != \"completed\" %}\n                    <input type=\"hidden\" name=\"supersedes\" value=\"{{ transcription.pk|default:'' }}\" />\n                    <button id=\"ocr-transcription-button\" class=\"btn btn-link underline-link fw-bold\" disabled>Replace Text</button>\n                {% endif %}\n            </div>\n        </form>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/navigation.html",
    "content": "<nav id=\"asset-navigation\" class=\"d-flex flex-wrap flex-grow-1 justify-content-sm-between align-items-center d-print-block\" role=\"navigation\">\n    <div class=\"d-flex align-items-center\">\n        <form class=\"p-1\" onsubmit=\"document.location.href = encodeURI(document.getElementById('asset-selection').value); return false\">\n            <div class=\"input-group input-group-sm flex-nowrap\">\n                <div class=\"input-group-prepend\">\n                    <label class=\"input-group-text mt-1 p-0 pe-1 border-0\" for=\"asset-selection\">Page</label>\n                </div>\n                <select id=\"asset-selection\" class=\"form-select form-select-sm\">\n                    {% for sequence, slug in asset_navigation %}\n                        <option {% if sequence == asset.sequence %}selected{% endif %} value=\"{% url 'transcriptions:asset-detail' campaign.slug project.slug item.item_id slug %}\">{{ sequence }}</option>\n                    {% endfor %}\n                </select>\n                <div class=\"input-group-append\">\n                    <button type=\"submit\" class=\"btn btn-primary\">Go</button>\n                </div>\n            </div>\n        </form>\n\n        <div class=\"btn-group btn-group-sm p-1\">\n            <a class=\"btn btn-primary {% if not previous_asset_url %}disabled{% endif %}\" {% if previous_asset_url %}href=\"{{ previous_asset_url }}\"{% else %}aria-disabled=\"true\"{% endif %}>\n                <span class=\"fas fa-chevron-left\"></span>\n                <span class=\"visually-hidden\">Previous Page</span>\n            </a>\n            <a class=\"btn btn-primary {% if not next_asset_url %}disabled{% endif %}\" {% if next_asset_url %}href=\"{{ next_asset_url }}\"{% else %}aria-disabled=\"true\"{% endif %}>\n                <span class=\"fas fa-chevron-right\"></span>\n                <span class=\"visually-hidden\">Next Page</span>\n            </a>\n        </div>\n\n        <div class=\"btn-group btn-group-sm p-1\">\n            <button hidden id=\"go-fullscreen\" class=\"btn btn-primary text-nowrap\" data-bs-target=\"contribute-main-content\">\n                <span class=\"fas fa-arrows-alt\"></span>\n                Fullscreen\n            </button>\n        </div>\n    </div>\n\n    <div class=\"btn-group align-self-end\" style=\"margin-right: -8px\">\n        {% if asset.resource_url %}\n            <div class=\"btn-group-sm p-1\" role=\"navigation\" aria-label=\"Link to the original source for this item\">\n                <a class=\"btn btn-outline-primary text-nowrap\" target=\"_blank\" rel=noopener title=\"View the original source for this item in a new tab\" href=\"{{ asset.resource_url }}{% if 'sp=' not in asset.resource_url %}?sp={{ asset.sequence }}{% endif %}\">View on www.loc.gov <i class=\"fa fa-external-link-alt\"></i></a>\n            </div>\n        {% endif %}\n\n        <div class=\"btn-group-sm p-1\" role=\"navigation\" aria-label=\"Link to the next editable page\">\n            <a class=\"btn btn-outline-primary text-nowrap\" title=\"Move to the next page in this item that needs help\" href=\"{{ next_open_asset_url }}\">Find a new page &rarr;</a>\n        </div>\n    </div>\n</nav>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/nothing_to_transcribe_modal.html",
    "content": "<div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n    <div class=\"modal-content\">\n        <div class=\"modal-header\">\n            <h5 class=\"modal-title\" id=\"error-modal-title\"></h5>\n            <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n        </div>\n        <div class=\"modal-body\"></div>\n        <div class=\"modal-footer d-flex justify-content-center gap-5\">\n            <a class=\"btn btn-primary\" id=\"confirmDiscard\">Yes</a>\n            <a class=\"btn btn-primary\" id=\"cancelDiscard\">Cancel</a>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/ocr_help_modal.html",
    "content": "<div id=\"ocr-help-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n    <div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\">\n                <h5 class=\"modal-title\">About Transcribe with OCR</h5>\n                <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n            </div>\n            <div class=\"modal-body\">\n                <h6 class=\"modal-title\">What is OCR?</h6>\n                <p>OCR stands for Optical Character Recognition. OCR is a software tool that can extract print text from some documents.</p>\n                <h6>When will OCR work well?</h6>\n                <p>OCR does not work on handwriting. It only works for printed or typed text, meaning text created by a typewriter, printing press, or other mechanical means. OCR will do best on consistent and clear images of modern typefaces.</p>\n                <h6>Do I still need to review pages started with OCR?</h6>\n                <p>Yes! OCR is imperfect. It may not work well for some or all parts of a typed page, but it can be a great starting point. If you start a page with OCR, you should read the text closely before submitting. If you are reviewing a OCR-ed page, you also still need to review.</p>\n                <h6>Who can use \"Transcribe with OCR\"?</h6>\n                <p><a href=\"{% url 'registration_register' %}\">Register for an account</a> and <a href=\"{% url 'registration_login' %}?next={{ request.path }}\">log in</a> to use this feature.</p>\n                <h6>Why does <span class=\"fst-italic\">By the People</span> have this feature?</h6>\n                <p>We always want to use volunteer time effectively. When the Library of Congress digitizes a large group of printed pages, it will usually OCR them. The materials in By the People campaigns are not good candidates for applying OCR at scale, either because they are handwritten, a mixed collection of handwritten and print materials, or printed on paper or in a typeface that does not produce accurate OCR results. However, OCR can still be a useful starting point for some typed pages. Use it if it if you like it or skip it if you don’t!</p>\n            </div>\n            <div class=\"modal-footer justify-content-center\">\n                <button type=\"button\" class=\"btn btn-primary\" data-bs-dismiss=\"modal\">Close</button>\n            </div>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/ocr_transcription_modal.html",
    "content": "<div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n    <div class=\"modal-content\">\n        <div class=\"modal-header d-flex justify-content-end\">\n            <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n        </div>\n        <div class=\"modal-body\">\n            <div class=\"bg-light p-3\">\n                <h5 class=\"modal-title mb-3\">Are you sure?</h5>\n                <p>Clicking \"Transcribe with OCR\" will remove all existing transcription text and replace it with automatically generated text. Use the \"Undo\" button to restore previous text.</p>\n            </div>\n        </div>\n        <div class=\"modal-footer\">\n            <button type=\"button\" class=\"btn btn-primary\" data-bs-dismiss=\"modal\">Cancel</button>\n            {% if transcription_status == \"not_started\" or transcription_status == \"in_progress\" %}\n                <input type=\"hidden\" name=\"supersedes\" value=\"{{ transcription.pk|default:'' }}\" />\n                <a tabindex=\"0\" class=\"btn btn-link d-inline p-0\" role=\"button\" data-bs-placement=\"top\" id=\"select-language-button\">\n                    <span class=\"underline-link fw-bold\">Yes, Select Language</span>\n                </a>\n            {% endif %}\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/quick_tips_modal.html",
    "content": "<div id=\"campaign-data\"\n     aria-hidden=\"true\"\n     role=\"none\"\n     hidden\n     data-campaign-slug=\"{{ campaign.slug|default:'' }}\"\n     data-user-authenticated=\"{{ user.is_authenticated|yesno:'true,false' }}\"\n     data-has-asset=\"{{ asset|yesno:'true,false' }}\">\n</div>\n<div id=\"tutorial-popup\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n    <div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header border-bottom-0 row\">\n                <div class=\"col ps-3\">\n                    <h4>Campaign Tips</h4>\n                </div>\n                <div class=\"col-1\" id=\"close-tutorial\">\n                    <a data-bs-dismiss=\"modal\" aria-label=\"Close\">\n                        <span aria-hidden=\"true\">x</span>\n                    </a>\n                </div>\n            </div>\n            <div class=\"modal-body\">\n                <div id=\"card-carousel\" class=\"carousel slide\" data-bs-interval=\"false\">\n                    <div class=\"carousel-inner\">\n                        {% for card in cards %}\n                            <div class=\"carousel-item pb-4 {% if forloop.first %} active {% endif %}\">\n                                <div class=\"position-static d-flex flex-column justify-content-around\">\n                                    {% if card.image %}\n                                        <img src=\"{{ card.image.url }}\"{% if card.image_alt_text %} alt=\"{{ card.image_alt_text }}\"{% endif %}>\n                                    {% endif %}\n                                    {% if card.display_heading %}\n                                        <h5>{{ card.display_heading }}</h5>\n                                    {% endif %}\n                                    <p>{{ card.body_text|safe }}</p>\n                                </div>\n                            </div>\n                        {% endfor %}\n                    </div>\n                    <a id=\"previous-card\" href=\"#card-carousel\" role=\"button\" data-bs-slide=\"prev\">\n                        <strong><u><&nbsp;Back</u></strong>\n                    </a>\n                    <div class=\"carousel-indicators d-none d-lg-flex\">\n                        {% for card in cards %}\n                            <button type=\"button\" data-bs-target=\"#card-carousel\" data-bs-slide-to=\"{{ forloop.counter0 }}\" {% if forloop.first %}class=\"active\" {% endif %}></button>\n                        {% endfor %}\n                    </div>\n                    <a id=\"next-card\" href=\"#card-carousel\" data-bs-slide=\"next\">\n                        <strong><u>Next&nbsp;></u></strong>\n                    </a>\n                </div>\n            </div>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/review_accepted_modal.html",
    "content": "<div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n    <div class=\"modal-content\">\n        <div class=\"modal-header\">\n            <h5 class=\"modal-title\">Nice Job!</h5>\n            <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n        </div>\n        <div class=\"modal-body\">\n            <p>\n                Thanks for helping complete this page!\n            </p>\n            <p>\n                What would you like to do next?\n            </p>\n        </div>\n        {% include \"fragments/_modal_footer.html\" %}\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/successful_submission_modal.html",
    "content": "<div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n    <div class=\"modal-content\">\n        <div class=\"modal-header\">\n            <h5 class=\"modal-title\">Submitted!</h5>\n            <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\"></button>\n        </div>\n        <div class=\"modal-body\">\n            <p>Thanks for helping complete this page!</p>\n            <p>\n                {% if not user.is_authenticated %}<a href=\"{% url 'login' %}?next={{ request.path|urlencode }}\">Login</a> or <a href=\"{% url 'registration_register' %}\">Register</a> to review and track your progress.{% endif %}\n            </p>\n            <p>\n                What would you like to do next?\n            </p>\n        </div>\n        {% include \"fragments/_modal_footer.html\" %}\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/tags.html",
    "content": "<div id=\"tag-editor\" class=\"flex-shrink-1\">\n    <h2 id=\"tag-label\" class=\"border-top pt-3 pb-2\"><a\n        data-bs-toggle=\"collapse\" href=\"#tag-form\" role=\"button\" aria-expanded=\"false\"\n        aria-controls=\"tag-form\"><i class=\"fas fa-plus-square\"></i> <span\n            id=\"tag-count-text\" class=\"text-dark\">Tags (<span id=\"tag-count\">{{ tags|length }}</span>)</span></a></h2>\n    <form id=\"tag-form\" class=\"ajax-submission collapse\" method=\"post\" action=\"{% url 'submit-tags' asset_pk=asset.pk %}\">\n        {% csrf_token %}\n        <div class=\"d-print-none grid\">\n            {% if user.is_authenticated %}\n                <div class=\"row\">\n                    <div class=\"col input-group\">\n                        <input type=\"text\" id=\"new-tag-input\" class=\"form-control\" placeholder=\"Add a new tag…\" aria-label=\"Add a new tag\" pattern=\"[\\- _À-ž'\\w]{1,50}\">\n                        <div class=\"input-group-append\">\n                            <button id=\"new-tag-button\" class=\"btn btn-outline-primary\" type=\"button\" title=\"Add tags to the page\">Add</button>\n                        </div>\n                        <div class=\"invalid-feedback\">\n                            Tags must be between 1-50 characters and may contain only letters, numbers, dashes, underscores, apostrophes, and spaces\n                        </div>\n                    </div>\n                </div>\n            {% else %}\n                <div class=\"d-flex justify-content-center w-100 py-2\">\n                    <p class=\"help-text anonymous-only text-center d-print-none mb-0\">\n                        Want to tag this page?\n\n                        <a href=\"{% url 'registration_register' %}\" class=\"mx-1\">Register</a>\n                        <span class=\"text-muted\">or</span>\n                        <a href=\"{% url 'login' %}?next={{ request.path|urlencode }}\" class=\"mx-1\">login</a>\n                        to add tags.\n                    </p>\n                </div>\n            {% endif %}\n        </div>\n\n        <ul id=\"current-tags\" class=\"d-flex flex-wrap list-unstyled mb-0 d-print-block\">\n            {% for tag in tags %}\n                <li class=\"btn btn-outline-dark btn-sm\">\n                    <label class=\"m-0\">\n                        <input type=\"hidden\" name=\"tags\" value=\"{{ tag }}\" />\n                        {{ tag }}\n                    </label>\n                    <a class=\"close authenticated-only\" data-bs-dismiss=\"alert\" aria-label=\"Remove previous tag\" {% if not user.is_authenticated %}hidden{% endif %}>\n                        <span aria-hidden=\"true\" class=\"fas fa-times\"></span>\n                    </a>\n                </li>\n            {% endfor %}\n        </ul>\n    </form>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/viewer.html",
    "content": "<div id=\"viewer-controls\" class=\"m-1 text-center d-print-none\">\n    <div class=\"d-inline-flex justify-content-between\">\n        <div class=\"d-flex btn-group m-1\">\n            <button id=\"viewer-layout-vertical\" class=\"btn btn-dark viewer-control-button\" title=\"Vertical Layout\">\n                <span class=\"fas fa-grip-lines\"></span>\n            </button>\n            <button id=\"viewer-layout-horizontal\" class=\"btn btn-dark\" title=\"Horizontal Layout\">\n                <span class=\"fas fa-grip-lines-vertical\"></span>\n            </button>\n        </div>\n\n        <div class=\"d-flex btn-group m-1\">\n            <button type=\"button\" id=\"viewer-home\" class=\"btn btn-dark viewer-control-button\" title=\"Fit Image to Viewport\">\n                <span class=\"fas fa-compress\"></span>\n            </button>\n        </div>\n\n        <div class=\"d-flex btn-group m-1\">\n            <button id=\"viewer-zoom-in\" class=\"btn btn-dark viewer-control-button\" title=\"Zoom In\">\n                <span class=\"fas fa-search-plus\"></span>\n            </button>\n            <button id=\"viewer-zoom-out\" class=\"btn btn-dark\" title=\"Zoom Out\">\n                <span class=\"fas fa-search-minus\"></span>\n            </button>\n        </div>\n\n        <div class=\"d-flex btn-group m-1\">\n            <button id=\"viewer-rotate-left\" class=\"btn btn-dark viewer-control-button\" title=\"Rotate Left\">\n                <span class=\"fas fa-undo\"></span>\n            </button>\n            <button id=\"viewer-rotate-right\" class=\"btn btn-dark viewer-control-button\" title=\"Rotate Right\">\n                <span class=\"fas fa-redo\"></span>\n            </button>\n        </div>\n\n        <div class=\"d-flex btn-group m-1\">\n            <button id=\"viewer-flip\" class=\"btn btn-dark viewer-control-button\" title=\"Flip\">\n                <span class=\"fas fa-exchange-alt\"></span>\n            </button>\n        </div>\n\n        <div class=\"d-flex btn-group m-1\">\n            <button type=\"button\" class=\"btn btn-dark extra-control-button\" title=\"Image Filters\" data-bs-toggle=\"collapse\" data-bs-target=\"#image-filters\">\n                <span class=\"fas fa-sliders-h\" aria-label=\"Image Filters\"></span>\n            </button>\n        </div>\n\n        <div class=\"d-flex btn-group m-1\">\n            <button type=\"button\" id=\"viewer-fullscreen\" class=\"btn btn-dark extra-control-button\" title=\"View Full Screen\" data-target=\"#viewer-column\">\n                <span class=\"fas fa-expand\"></span>\n            </button>\n        </div>\n\n        <div class=\"d-flex btn-group m-1\">\n            <button type=\"button\" class=\"btn btn-dark extra-control-button\" title=\"Viewer keyboard shortcuts\" data-bs-toggle=\"modal\" data-bs-target=\"#keyboard-help-modal\">\n                <span class=\"fas fa-question-circle\" aria-label=\"Viewer keyboard shortcuts\"></span>\n            </button>\n        </div>\n    </div>\n</div>\n\n<div id=\"keyboard-help-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n    <div class=\"modal-dialog modal-dialog-centered\" role=\"document\">\n        <div class=\"modal-content\">\n            <div class=\"modal-header\">\n                <h5 class=\"modal-title\">Keyboard Shortcuts</h5>\n                <button type=\"button\" class=\"btn-close\" data-bs-dismiss=\"modal\" aria-label=\"Close\">\n\n                </button>\n            </div>\n            <div class=\"modal-body\">\n                <h6>Viewer Shortcuts</h6>\n                <table class=\"table table-compact table-responsive\">\n                    <tr>\n                        <th><kbd>w</kbd>, up arrow</th>\n                        <td>Scroll the viewport up</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>s</kbd>, down arrow</th>\n                        <td>Scroll the viewport down</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>a</kbd>, left arrow</th>\n                        <td>Scroll the viewport left</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>d</kbd>, right arrow </th>\n                        <td>Scroll the viewport right</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>0</kbd></th>\n                        <td>Fit the entire image to the viewport</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>-</kbd>, <kbd>_</kbd>, Shift+<kbd>W</kbd>, Shift+Up arrow</th>\n                        <td>Zoom the viewport out</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>=</kbd>, <kbd>+</kbd>, Shift+<kbd>S</kbd>, Shift+Down arrow</th>\n                        <td>Scroll the viewport in</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>r</kbd></th>\n                        <td>Rotate the viewport clockwise</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>R</kbd></th>\n                        <td>Rotate the viewport counterclockwise</td>\n                    </tr>\n                    <tr>\n                        <th><kbd>f</kbd></th>\n                        <td>Flip the viewport horizontally</td>\n                    </tr>\n                </table>\n            </div>\n            <div class=\"modal-footer\">\n                <button type=\"button\" class=\"btn btn-primary\" data-bs-dismiss=\"modal\">Close</button>\n            </div>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail/viewer_filters.html",
    "content": "<div id=\"image-filters\" class=\"m-1 text-center d-print-none collapse\">\n    <hr class=\"m-0\" />\n    <ul class=\"d-inline-flex mt-1 btn-group nav nav-tabs\" role=\"tablist\">\n        <li class=\"nav-item\" role=\"presentation\">\n            <button id=\"viewer-gamma\" class=\"btn btn-dark nav-link active\" title=\"Adjust gamma\" data-bs-toggle=\"tab\" data-bs-target=\"#gamma-filter\" role=\"tab\">\n                Brightness\n            </button>\n        </li>\n        <li class=\"nav-item\" role=\"presentation\">\n            <button id=\"viewer-invert\" class=\"btn btn-dark nav-link\" title=\"Invert colors\" data-bs-toggle=\"tab\" data-bs-target=\"#invert-filter\" role=\"tab\">\n                Invert\n            </button>\n        </li>\n        <li class=\"nav-item\" role=\"presentation\">\n            <button id=\"viewer-threshold\" class=\"btn btn-dark nav-link\" title=\"Adjust threshold\" data-bs-toggle=\"tab\" data-bs-target=\"#threshold-filter\" role=\"tab\">\n                Contrast\n            </button>\n        </li>\n    </ul>\n    <div class=\"btn-group m-1\">\n        <button id=\"viewer-reset\" class=\"btn\" title=\"Reset all filters\">\n            Reset All\n        </button>\n    </div>\n    <div id=\"filter-tabs\" class=\"tab-content\">\n        <div id=\"gamma-filter\" class=\"tab-pane pt-1 ps-3 show active\" role=\"tabpanel\">\n            <form id=\"gamma-form\" class=\"d-flex align-items-center\" onsubmit=\"return false;\">\n                <div class=\"row ms-0 me-3 number-input\">\n                    <div class=\"col p-1\">\n                        <input\n                            type=\"number\"\n                            id=\"gamma\"\n                            name=\"gamma\"\n                            min=\"0\"\n                            max=\"5\"\n                            step=\"0.01\"\n                            value=\"1.00\"\n                        />\n                        <label class=\"visually-hidden\" for=\"gamma\">Gamma</label>\n                    </div>\n                    <div class=\"col p-0 filter-buttons\">\n                        <div class=\"row m-0\">\n                            <button id=\"gamma-up\" type=\"button\" class=\"arrow-button\">\n                                <span class=\"fas fa-chevron-up\" />\n                                <span class=\"visually-hidden\">Increase</span>\n                            </button>\n                        </div>\n                        <div class=\"row m-0\">\n                            <button id=\"gamma-down\" type=\"button\" class=\"arrow-button\">\n                                <span class=\"fas fa-chevron-down\" />\n                                <span class=\"visually-hidden\">Decrease</span>\n                            </button>\n                        </div>\n                    </div>\n                </div>\n                <input\n                    type=\"range\"\n                    id=\"gamma-range\"\n                    name=\"gamma-range\"\n                    min=\"0\"\n                    max=\"5\"\n                    step=\"0.01\"\n                    value=\"1.00\"\n                    class=\"filter-slider flex-grow-1\"\n                />\n                <label class=\"visually-hidden\" for=\"gamma-range\">Gamma</label>\n                <input type=\"reset\" class=\"btn btn-link underline-link fw-bold\" value=\"Reset filter\" />\n            </form>\n        </div>\n        <div id=\"invert-filter\" class=\"tab-pane pt-2\" role=\"tabpanel\" style=\"background-color: white;\">\n            <form id=\"invert-form\" onsubmit=\"return false;\" class=\"d-flex justify-content-center\">\n                <label class=\"ms-2 align-middle\">Off</label>\n                <div class=\"form-check form-switch custom-control-inline\">\n                    <input type=\"checkbox\" id=\"invert\" name=\"invert\" class=\"form-check-input\" role=\"switch\" />\n                    <label class=\"form-check-label\" for=\"invert\"><span class=\"visually-hidden\">Invert</span></label>\n                </div>\n                <label class=\"align-middle\">On</label>\n            </form>\n        </div>\n        <div id=\"threshold-filter\" class=\"tab-pane pt-1 ps-3\" role=\"tabpanel\">\n            <form id=\"threshold-form\" class=\"d-flex align-items-center\" onsubmit=\"return false;\">\n                <div class=\"row ms-0 me-3 number-input\">\n                    <div class=\"col p-1\">\n                        <input\n                            type=\"number\"\n                            id=\"threshold\"\n                            name=\"threshold\"\n                            min=\"0\"\n                            max=\"255\"\n                            step=\"1\"\n                            value=\"0\"\n                        />\n                        <label class=\"visually-hidden\" for=\"threshold\">Threshold</label>\n                    </div>\n                    <div class=\"col p-0 filter-buttons\">\n                        <div class=\"row m-0\">\n                            <button id=\"threshold-up\" type=\"button\" class=\"arrow-button\">\n                                <span class=\"fas fa-chevron-up\" />\n                                <span class=\"visually-hidden\">Increase</span>\n                            </button>\n                        </div>\n                        <div class=\"row m-0\">\n                            <button id=\"threshold-down\" type=\"button\" class=\"arrow-button\">\n                                <span class=\"fas fa-chevron-down\" />\n                                <span class=\"visually-hidden\">Decrease</span>\n                            </button>\n                        </div>\n                    </div>\n                </div>\n                <input\n                    type=\"range\"\n                    id=\"threshold-range\"\n                    name=\"threshold-range\"\n                    min=\"0\"\n                    max=\"255\"\n                    step=\"1\"\n                    value=\"0\"\n                    class=\"filter-slider flex-grow-1\"\n                />\n                <label class=\"visually-hidden\" for=\"threshold-range\">Threshold</label>\n                <input type=\"reset\" class=\"btn btn-link underline-link fw-bold\" value=\"Reset filter\" />\n            </form>\n        </div>\n    </div>\n</div>\n"
  },
  {
    "path": "concordia/templates/transcriptions/asset_detail.html",
    "content": "{% extends \"base.html\" %}\n\n{% load staticfiles %}\n\n{% load feature_flags %}\n\n{% load concordia_media_tags %}\n{% load concordia_sharing_tags %}\n\n{% block title %}\n    {{ asset.title }} ({{ asset.item.project.campaign.title }}: {{ asset.item.project.title }})\n{% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\">\n    <meta property=\"og.url\" content=\"https://{{ request.get_host }}{{ request.path }}\" />\n    <meta property=\"og.title\" content=\"{{ asset.item.title }}\" />\n    <meta property=\"og.description\" content=\"{{ asset.item.project.description }}\" />\n    <meta property=\"og.type\" content=\"website\" />\n    <meta property=\"og.image\" content=\"{{ thumbnail_url }}\" />\n\n    <script id=\"asset-reservation-data\"\n            data-reserve-asset-url=\"{% url 'reserve-asset' asset.pk %}\"\n            {% if transcription_status == \"not_started\" or transcription_status == \"in_progress\" or user.is_authenticated%}\n                data-reserve-for-editing=1\n            {% endif %}\n    ></script>\n\n    <script id=\"viewer-data\"\n            data-prefix-url=\"{% static 'openseadragon/build/openseadragon/images/' %}\"\n            data-tile-source-url=\"{% asset_media_url asset %}?canvas\"\n            data-contact-url=\"https://ask.loc.gov/crowd\"\n    ></script>\n\n    {% if anonymous_user_validation_required %}\n        <script module src=\"{{ TURNSTILE_JS_API_URL }}\"></script>\n    {% endif %}\n\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item\"><a class=\"primary-text text-truncate\" href=\"{% url 'transcriptions:campaign-detail' slug=campaign.slug %}\" title=\"{{ campaign.title }}\">{{ campaign.title }}</a></li>\n    <li class=\"breadcrumb-item\"><a class=\"primary-text text-truncate\" href=\"{% url 'transcriptions:project-detail' campaign_slug=campaign.slug slug=project.slug %}\" title=\"{{ project.title }}\">{{ project.title }}</a></li>\n    <li class=\"breadcrumb-item\"><a class=\"primary-text text-truncate\" href=\"{% url 'transcriptions:item-detail' campaign_slug=campaign.slug project_slug=project.slug item_id=item.item_id %}\" title=\"{{ item.title }}\">{{ item.title }}</a></li>\n    <li class=\"breadcrumb-item active\" title=\"{{ asset.title }}\"><span class=\"text-truncate\">{{ asset.title }}</spanclass></li>\n{% endblock breadcrumbs %}\n\n{% block extra_body_classes %}d-flex flex-column{% endblock %}\n{% block extra_main_classes %}flex-grow-1 d-flex flex-column{% endblock %}\n\n{% block main_content %}\n    {% flag_enabled 'ADVERTISE_ACTIVITY_UI' as ADVERTISE_ACTIVITY_UI %}\n    <div id=\"unacceptable-characters-content\"></div>\n\n    <div id=\"contribute-main-content\" class=\"container-fluid flex-grow-1 d-flex flex-column d-print-block\">\n        <div id=\"navigation-container\" class=\"row p-1 px-3 d-print-none bg-light\">\n            {% include \"transcriptions/asset_detail/navigation.html\" %}\n        </div>\n        <div id=\"contribute-container\" class=\"d-flex flex-grow-1 d-print-block border\">\n            <div id=\"viewer-column\" class=\"ps-0 d-flex align-items-stretch bg-dark d-print-block flex-column\">\n                {% include \"transcriptions/asset_detail/viewer.html\" %}\n                {% include \"transcriptions/asset_detail/ocr_help_modal.html\" %}\n                {% include \"transcriptions/asset_detail/viewer_filters.html\" %}\n                <div id=\"asset-image\" class=\"h-100 bg-dark d-print-none w-100\"></div>\n                <div id=\"ocr-section\" class=\"row ps-3 pb-4 bg-white print-none\">\n                    {% if not disable_ocr %}\n                        <div class=\"d-flex flex-row align-items-center justify-content-end mt-1\">\n                            <a tabindex=\"0\" class=\"btn btn-link d-inline p-0\" role=\"button\" data-bs-placement=\"top\" data-bs-trigger=\"focus click hover\" title=\"When to use OCR\"  data-bs-toggle=\"modal\" data-bs-target=\"#ocr-help-modal\">\n                                <span class=\"underline-link fw-bold\">What is OCR</span> <span class=\"fas fa-question-circle\" aria-label=\"When to use OCR\"></span>\n                            </a>\n                            <a role=\"button\" data-bs-placement=\"top\" data-bs-trigger=\"click\" title=\"Transcribe with OCR\" id=\"ocr-transcription-link\" class=\"btn btn-primary mx-1\" aria-disabled=\"true\" href=\"#\" tabindex=\"0\" data-authenticated=\"{{ user.is_authenticated|yesno:'true,false' }}\">Transcribe with OCR</a>\n                        </div>\n                    {% endif %}\n                </div>\n            </div>\n\n            <div id=\"editor-column\" class=\"d-flex justify-content-between p-3 d-print-block flex-column\">\n                {% include \"transcriptions/asset_detail/editor.html\" %}\n                {% include \"transcriptions/asset_detail/tags.html\" %}\n            </div>\n        </div>\n        <div id=\"help-container\" class=\"mt-1 d-print-none\">\n            <div class=\"row p-3 bg-light justify-content-sm-between\">\n                <div class=\"d-flex align-items-center ps-1 col\">Share this item: {% share_buttons current_asset_url asset.item.title %}</div>\n                <div class=\"btn-group align-items-center col\">\n                    <p class=\"ms-auto me-2 my-0\">Need help?</p>\n\n                    <div class=\"d-grid gap-2 d-md-block\">\n                        <a class=\"btn btn-primary mx-1\" href=\"https://ask.loc.gov/crowd\" target=\"_blank\" rel=noopener>\n                            Contact us\n                        </a>\n                    </div>\n                </div>\n            </div>\n        </div>\n        <div id=\"asset-reservation-failure-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n            {% include \"transcriptions/asset_detail/asset_reservation_failure_modal.html\" %}\n        </div>\n        <div id=\"successful-submission-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n            {% include \"transcriptions/asset_detail/successful_submission_modal.html\" %}\n        </div>\n        <div id=\"review-accepted-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n            {% include \"transcriptions/asset_detail/review_accepted_modal.html\" %}\n        </div>\n        <div id=\"ocr-transcription-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n            {% include \"transcriptions/asset_detail/ocr_transcription_modal.html\" %}\n        </div>\n        <div id=\"language-selection-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n            {% include \"transcriptions/asset_detail/language_selection_modal.html\" %}\n        </div>\n        <div id=\"error-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n            {% include \"transcriptions/asset_detail/error_modal.html\" %}\n        </div>\n        <div id=\"nothing-to-transcribe-modal\" class=\"modal\" tabindex=\"-1\" role=\"dialog\">\n            {% include \"transcriptions/asset_detail/nothing_to_transcribe_modal.html\" %}\n        </div>\n    </div>\n    <div class=\"print-transcription-image d-none d-print-block\"><img class=\"img-fluid\" alt=\"Scanned image of the current content page\" src=\"{% asset_media_url asset %}\"></div>\n    {% if cards %}\n        {% include \"transcriptions/asset_detail/quick_tips_modal.html\" %}\n    {% endif %}\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/campaign_detail.html",
    "content": "{% extends \"base.html\" %}\n\n{% load static %}\n{% load staticfiles %}\n{% load humanize %}\n{% load concordia_text_tags %}\n{% load concordia_filtering_tags %}\n\n{% block title %}{{ campaign.title }}{% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\">\n    <meta name=\"description\" content=\"{{ campaign.description|striptags|normalize_whitespace }}\">\n    <meta name=\"thumbnail\" content=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\">\n    <meta property=\"og:image\" content=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item\"><a class=\"primary-text text-truncate\" href=\"{% url 'campaign-topic-list' %}\">Campaigns</a></li>\n    <li class=\"breadcrumb-item active\" aria-current=\"page\" title=\"{{ campaign.title }}\"><span class=\"text-truncate\">{{ campaign.title }}</span></li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <div class=\"row\">\n            <div class=\"col-md-12\">\n                <h1>{{ campaign.title }}</h1>\n            </div>\n        </div>\n        <div class=\"row\">\n            <div class=\"col-12 col-lg pt-1 pb-1\">\n                <div class=\"progress campaign-page-progress\">\n                    <div\n                        title=\"Completed ({{ completed_count|intcomma }} page{{ completed_count|pluralize }})\"\n                        class=\"progress-bar bg-completed\"\n                        role=\"progressbar\"\n                        style=\"width: {{ completed_percent }}%\"\n                        aria-valuenow=\"{{ completed_percent }}\"\n                        aria-valuemin=\"0\"\n                        aria-valuemax=\"100\"\n                    ></div>\n                    <div\n                        title=\"Needs Review ({{ submitted_count|intcomma }} page{{ submitted_count|pluralize }})\"\n                        class=\"progress-bar bg-submitted\"\n                        role=\"progressbar\"\n                        style=\"width: {{ submitted_percent }}%\"\n                        aria-valuenow=\"{{ submitted_percent }}\"\n                        aria-valuemin=\"0\"\n                        aria-valuemax=\"100\"\n                    ></div>\n                    <div\n                        title=\"In Progress ({{ in_progress_count|intcomma }} page{{ in_progress_count|pluralize }})\"\n                        class=\"progress-bar bg-in_progress\"\n                        role=\"progressbar\"\n                        style=\"width: {{ in_progress_percent }}%\"\n                        aria-valuenow=\"{{ in_progress_percent }}\"\n                        aria-valuemin=\"0\"\n                        aria-valuemax=\"100\"\n                    ></div>\n                    <div\n                        title=\"Not Started ({{ not_started_count|intcomma }} page{{ not_started_count|pluralize }})\"\n                        class=\"progress-bar bg-not_started\"\n                        role=\"progressbar\"\n                        style=\"width: {{ not_started_percent }}%\"\n                        aria-valuenow=\"{{ not_started_percent }}\"\n                        aria-valuemin=\"0\"\n                        aria-valuemax=\"100\"\n                    ></div>\n                </div>\n            </div>\n        </div>\n        <div class=\"row\">\n            <div class=\"col-12 col-lg pb-1\">\n                <ul class=\"progress-bar-labels list-unstyled m-0 p-1\">\n                    {% if completed_percent %}\n                        <li>{{ completed_percent }}% Completed</li>\n                    {% endif %}\n                    {% if submitted_percent %}\n                        <li>{{ submitted_percent }}% Needs Review</li>\n                    {% endif %}\n                    {% if in_progress_percent %}\n                        <li>{{ in_progress_percent }}% In Progress</li>\n                    {% endif %}\n                    {% if not_started_percent %}\n                        <li>{{ not_started_percent }}% Not Started</li>\n                    {% endif %}\n                </ul>\n            </div>\n            <div class=\"row\">\n                <div class=\"col-md-12 pt-1 pb-1\">\n                    <hr class=\"landing-divider\" />\n                    <p class=\"mb-1\"><strong>Completed Page{{ completed_count|pluralize }}:</strong> {{ completed_count|intcomma }}</p>\n                    <p class=\"mb-1\"><strong>Registered Contributor{{ contributor_count|pluralize }}:</strong> {{ contributor_count|intcomma }}</p>\n                    {% if campaign.launch_date %}\n                        <p class=\"mb-1\"><strong>Launched {{ campaign.launch_date }}.</strong></p>\n                    {% endif %}\n                    <hr class=\"landing-divider\" />\n                </div>\n            </div>\n            <div class=\"row\">\n                <div class=\"col-md-9\">\n                    <div class=\"hero-text\">{{ campaign.description|safe }}</div>\n                </div>\n                <div class=\"col-md-3\">\n                    {% if campaign.helpfullink_set.related_links %}\n                        <aside class=\"mb-3 mt-4 mt-md-0 p-3 bg-light\">\n                            <h4 class=\"mb-3\">Helpful Links</h4>\n                            <ul class=\"list-unstyled m-0\">\n                                {% for link in campaign.helpfullink_set.related_links %}\n                                    {% if 'loc.gov' in link.link_url   %}\n                                        <li class=\"mb-3\"><a href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>{{ link.title }}</a></li>\n                                    {%else%}\n                                        <li class=\"mb-3\"><a href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>{{ link.title }} <i class=\"fa fa-external-link-alt\"></i></a></li>\n                                    {% endif %}\n                                {% endfor %}\n                            </ul>\n                        </aside>\n                    {% endif %}\n                </div>\n            </div>\n            <div class=\"d-flex justify-content-between mt-4\">\n                <div>\n                    <h3>Filter pages:</h3>\n                </div>\n                {% url 'transcriptions:campaign-detail' campaign.slug as all_assets %}\n                {% url 'transcriptions:filtered-campaign-detail' campaign.slug as filtered_assets %}\n                {% include \"fragments/_filter-buttons.html\" with do_filter=filter_assets all_url=all_assets filtered_url=filtered_assets sublevel_qs=sublevel_querystring %}\n            </div>\n            <div class=\"row\">\n                <div class=\"col-12 col-lg text-center\">\n                    {% transcription_status_filters transcription_status_counts transcription_status \"large\" True all_assets %}\n                </div>\n            </div>\n            <div class=\"row justify-content-center concordia-object-card-row\">\n                <div class=\"concordia-object-card-container justify-content-center\">\n                    {% for project in projects %}\n                        <div class=\"col-6 concordia-object-card-col\">\n                            <div class=\"h-100 concordia-object-card card border\" data-transcription-status=\"{{ project.lowest_transcription_status }}\">\n                                {% if filter_assets %}\n                                    {% url 'transcriptions:filtered-project-detail' campaign.slug project.slug as project_url %}\n                                {% else %}\n                                    {% url 'transcriptions:project-detail' campaign.slug project.slug as project_url %}\n                                {% endif %}\n\n                                <a href=\"{{ project_url }}?{{ sublevel_querystring }}\" aria-hidden=\"true\">\n                                    <img class=\"card-img card-img-campaign\" src=\"{{ MEDIA_URL }}{{ project.thumbnail_image }}\" alt=\"{{ project.title }}\">\n                                </a>\n\n                                <div class=\"progress w-100\">\n                                    <div title=\"Completed\" class=\"progress-bar bg-completed\" role=\"progressbar\" style=\"width: {{ project.completed_percent }}%\" aria-valuenow=\"{{ project.completed_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                                    <div title=\"Needs Review\" class=\"progress-bar bg-submitted\" role=\"progressbar\" style=\"width: {{ project.submitted_percent }}%\" aria-valuenow=\"{{ project.submitted_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                                    <div title=\"In Progress\" class=\"progress-bar bg-in_progress\" role=\"progressbar\" style=\"width: {{ project.in_progress_percent }}%\" aria-valuenow=\"{{ project.in_progress_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                                </div>\n\n                                <h6 class=\"text-center primary-text m-0 concordia-object-card-title\">\n                                    <a{% if project.lowest_transcription_status == 'completed' %} class=\"text-dark\"{% endif %} href=\"{{ project_url }}?{{ sublevel_querystring }}\">{{ project.title }}</a>\n                                </h6>\n\n                                {% if project.lowest_transcription_status == 'completed' %}\n                                    <div class=\"card-actions\">\n                                        <a class=\"btn btn-sm btn-block btn-dark\" href=\"{{ project_url }}?{{ sublevel_querystring }}\">\n                                            <span class=\"fas fa-check tx-completed\"></span>\n                                            Complete\n                                        </a>\n                                    </div>\n                                {% endif %}\n                            </div>\n                        </div>\n                    {% empty %}\n                        {% if filter_assets %}\n                            <div class=\"pt-3\">There are no pages you can review. Select \"Show all\" to see pages you can read or edit.</div>\n                        {% endif %}\n                    {% endfor %}\n                </div>\n            </div>\n{% endblock main_content %}\n{% block body_scripts %}\n    {{ block.super }}\n    <script src=\"{% static 'js/filter-assets.js' %}\"></script>\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/campaign_detail_completed.html",
    "content": "{% extends \"base.html\" %}\n\n{% load staticfiles %}\n{% load humanize %}\n{% load concordia_text_tags %}\n\n{% block title %}{{ campaign.title }}{% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\">\n    <meta name=\"description\" content=\"{{ campaign.description|striptags|normalize_whitespace }}\">\n    <meta name=\"thumbnail\" content=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\">\n    <meta property=\"og:image\" content=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item\"><a class=\"primary-text text-truncate\" href=\"{% url 'campaign-topic-list' %}\">Campaigns</a></li>\n    <li class=\"breadcrumb-item active\" aria-current=\"page\" title=\"{{ campaign.title }}\"><span class=\"text-truncate\">{{ campaign.title }}</span></li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <div class=\"row\">\n            <div class=\"col-md-12\">\n                <h1>{{ campaign.title }}</h1>\n                <div class=\"completed-bar\"><h3 class=\"completed-text\">100% Complete</h3></div>\n                <hr class=\"landing-divider\" />\n                <span><strong>Completed Page{{ completed_count|pluralize }}:</strong> {{ completed_count|intcomma }}</span>\n                <br />\n                <span><strong>Registered Contributor{{ contributor_count|pluralize }}:</strong> {{ contributor_count|intcomma }}</span>\n                {% if campaign.launch_date and campaign.completed_date %}\n                    <br />\n                    <span><strong>Launched {{ campaign.launch_date }} and completed {{ campaign.completed_date }}.</strong></span>\n                {% endif %}\n                <hr class=\"landing-divider\" />\n            </div>\n        </div>\n        {% with campaign.helpfullink_set.completed_transcription_links as links %}\n            {% if links %}\n                <div class=\"row mt-2\">\n                    <div class=\"col-md-12\">\n                        <aside class=\"mb-3 mt-md-0 p-3 bg-light\">\n                            <h3 class=\"mb-3\">Use Completed Transcriptions</h3>\n                            <ul class=\"list-unstyled m-0\">\n                                {% for link in links %}\n                                    {% if forloop.last %}\n                                        <li>\n                                    {% else %}\n                                        <li class=\"mb-3\">\n                                    {% endif %}\n                                    <a class=\"underline-link\" href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>{{ link.title }}</a>\n                                    {% if 'loc.gov' not in link.link_url  %}\n                                        <i class=\"fa fa-external-link-alt\"></i>\n                                    {% endif %}\n                                    </li>\n                                {% endfor %}\n                            </ul>\n                        </aside>\n                    </div>\n                </div>\n            {% endif %}\n        {% endwith %}\n        <div class=\"row\">\n            <div class=\"col-md-12\">\n                <div class=\"hero-text\">{{ campaign.description|safe }}</div>\n            </div>\n        </div>\n        <div class=\"row justify-content-center concordia-object-card-row\">\n            <div class=\"concordia-object-card-container justify-content-center pt-3\">\n                {% for project in projects %}\n                    <div class=\"col-6 col-md-4 col-lg-3 concordia-object-card-col\">\n                        <div class=\"h-100 concordia-object-card card border\" data-transcription-status=\"complete\">\n                            {% url 'transcriptions:project-detail' campaign.slug project.slug as project_url %}\n\n                            <a href=\"{{ project_url }}?{{ sublevel_querystring }}\" aria-hidden=\"true\">\n                                <img class=\"card-img card-img-campaign\" src=\"{{ MEDIA_URL }}{{ project.thumbnail_image }}\" alt=\"{{ project.title }}\">\n                            </a>\n\n                            <div class=\"progress w-100\">\n                                <div title=\"Completed\" class=\"progress-bar bg-completed\" role=\"progressbar\" style=\"width: 100%\" aria-valuenow=\"100\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                            </div>\n\n                            <h6 class=\"text-center primary-text m-0 concordia-object-card-title\">\n                                <a class=\"underline-link\" href=\"{{ project_url }}?{{ sublevel_querystring }}\">{{ project.title }}</a>\n                            </h6>\n                            <div class=\"card-actions\">\n                                <a class=\"btn btn-sm btn-block btn-dark\" href=\"{{ project_url }}?{{ sublevel_querystring }}\">\n                                    <span class=\"fas fa-check tx-completed\"></span>\n                                    Complete\n                                </a>\n                            </div>\n                        </div>\n                    </div>\n                {% endfor %}\n            </div>\n        </div>\n        {% with campaign.helpfullink_set.related_links as links %}\n            {% if links %}\n                <div class=\"row mt-2\">\n                    <div class=\"col-md-12\">\n                        <aside class=\"mb-3 mt-md-0 p-3 bg-light\">\n                            <h3 class=\"mb-3\">Helpful Links</h3>\n                            <ul class=\"list-unstyled m-0\">\n                                {% for link in links %}\n                                    {% if forloop.last %}\n                                        <li>\n                                    {% else %}\n                                        <li class=\"mb-3\">\n                                    {% endif %}\n                                    <a class=\"underline-link\" href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>{{ link.title }}</a>\n                                    {% if 'loc.gov' not in link.link_url  %}\n                                        <i class=\"fa fa-external-link-alt\"></i>\n                                    {% endif %}\n                                    </li>\n                                {% endfor %}\n                            </ul>\n                        </aside>\n                    </div>\n                </div>\n            {% endif %}\n        {% endwith %}\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/campaign_detail_retired.html",
    "content": "{% extends \"base.html\" %}\n\n{% load staticfiles %}\n{% load humanize %}\n{% load concordia_text_tags %}\n\n{% block title %}{{ campaign.title }}{% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\">\n    <meta name=\"description\" content=\"{{ campaign.description|striptags|normalize_whitespace }}\">\n    <meta name=\"thumbnail\" content=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\">\n    <meta property=\"og:image\" content=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item\"><a class=\"primary-text text-truncate\" href=\"{% url 'campaign-topic-list' %}\">Campaigns</a></li>\n    <li class=\"breadcrumb-item active\" aria-current=\"page\" title=\"{{ campaign.title }}\"><span class=\"text-truncate\">{{ campaign.title }}</span></li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <div class=\"row\">\n            <div class=\"col-md-12\">\n                <h1>{{ campaign.title }}</h1>\n                <div class=\"retired-bar mb-2\"><h3 class=\"p-2 mb-0\">Campaign retired. All transcriptions available in LOC.gov</h3></div>\n                <hr class=\"landing-divider\" />\n                <span><strong>Completed Page{{ completed_count|pluralize }}:</strong> {{ completed_count|intcomma }}</span>\n                <br />\n                <span><strong>Registered Contributor{{ contributor_count|pluralize }}:</strong> {{ contributor_count|intcomma }}</span>\n                {% if campaign.launch_date and campaign.completed_date %}\n                    <br />\n                    <span><strong>Launched {{ campaign.launch_date }} and completed {{ campaign.completed_date }}.</strong></span>\n                {% endif %}\n                <hr class=\"landing-divider\" />\n            </div>\n        </div>\n        {% with campaign.helpfullink_set.completed_transcription_links as links %}\n            {% if links %}\n                <div class=\"row mt-2\">\n                    <div class=\"col-md-12\">\n                        <aside class=\"mb-3 mt-md-0 p-3 bg-light\">\n                            <h3 class=\"mb-3\">Use Completed Transcriptions</h3>\n                            <ul class=\"list-unstyled m-0\">\n                                {% for link in links %}\n                                    {% if forloop.last %}\n                                        <li>\n                                    {% else %}\n                                        <li class=\"mb-3\">\n                                    {% endif %}\n                                    <a class=\"underline-link\" href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>{{ link.title }}</a>\n                                    {% if 'loc.gov' not in link.link_url  %}\n                                        <i class=\"fa fa-external-link-alt\"></i>\n                                    {% endif %}\n                                    </li>\n                                {% endfor %}\n                            </ul>\n                        </aside>\n                    </div>\n                </div>\n            {% endif %}\n        {% endwith %}\n        <div class=\"row mb-3\">\n            <div class=\"col-md-12\">\n                <h3>About This Campaign</h3>\n                <div class=\"hero-text\">{{ campaign.description|safe }}</div>\n            </div>\n        </div>\n        {% with campaign.helpfullink_set.related_links as links %}\n            {% if links %}\n                <div class=\"row mt-2\">\n                    <div class=\"col-md-12\">\n                        <aside class=\"mb-3 mt-md-0 p-3 bg-light\">\n                            <h3 class=\"mb-3\">Helpful Links</h3>\n                            <ul class=\"list-unstyled m-0\">\n                                {% for link in links %}\n                                    {% if forloop.last %}\n                                        <li>\n                                    {% else %}\n                                        <li class=\"mb-3\">\n                                    {% endif %}\n                                    <a class=\"underline-link\" href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>{{ link.title }}</a>\n                                    {% if 'loc.gov' not in link.link_url  %}\n                                        <i class=\"fa fa-external-link-alt\"></i>\n                                    {% endif %}\n                                    </li>\n                                {% endfor %}\n                            </ul>\n                        </aside>\n                    </div>\n                </div>\n            {% endif %}\n        {% endwith %}\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/campaign_list.html",
    "content": "{% extends \"base.html\" %}\n\n{% load staticfiles %}\n\n{% block title %}Active Campaigns{% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\">\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item active\" aria-current=\"page\">Active Campaigns</li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <h1>Active Campaigns</h1>\n        {% if topics %}\n            <h3>Explore by topic</h3>\n            <ul class=\"topic-list\">\n                {% for topic in topics %}\n                    <li class=\"page-item\">\n                        <a class=\"page-link\" href=\"{% url 'topic-detail' topic.slug %}\">\n                            {{ topic.title }}\n                        </a>\n                    </li>\n                {% endfor %}\n            </ul>\n        {% endif %}\n        <ul class=\"list-unstyled\">\n            {% for campaign in campaigns %}\n                <li class=\"p-4 mb-1 bg-light\">\n                    <h2 class=\"h1 mb-3\">{{ campaign.title }}</h2>\n                    <div class=\"row\">\n                        <a class=\"col-md-5 order-md-2\" href=\"{% url 'transcriptions:campaign-detail' campaign.slug %}\">\n                            <p class=\"mb-2 text-center\"><img src=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\" class=\"img-fluid\" alt=\"{{ campaign.title }} image\"></p>\n                        </a>\n                        <div class=\"col-md\">\n                            <p>{{ campaign.short_description|safe }}</p>\n                            <a class=\"btn btn-primary\" href=\"{% url 'transcriptions:campaign-detail' campaign.slug %}\">View Projects</a>\n                        </div>\n                    </div>\n                </li>\n            {% endfor %}\n        </ul>\n        {% include \"transcriptions/completed_campaigns_section.html\" %}\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/campaign_list_small_blocks.html",
    "content": "{% extends \"base.html\" %}\n\n{% load staticfiles truncation %}\n\n{% block title %}Completed Campaigns{% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item active\" aria-current=\"page\">Completed Campaigns</li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <h1>Completed Campaigns</h1>\n        <div id=\"campaign-options\" class=\"d-flex flex-wrap my-2\">\n            <div class=\"d-flex align-items-center me-auto mt-2\">Results: {{ result_count }} Campaigns</div>\n            <div class=\"d-flex align-items-center ms-3 mt-2\">\n                <label for=\"view-options\" class=\"pe-1\">View</label>\n                <select id=\"view-options\">\n                    <option value=\"grid\"{% if request.GET.view != 'list' %} selected{% endif %}>Grid</option>\n                    <option value=\"list\"{% if request.GET.view == 'list' %} selected{% endif %}>List</option>\n                </select>\n                <a class=\"btn btn-primary\" onclick=\"toggleCampaignView();\">Go</a>\n            </div>\n            <div class=\"d-flex align-items-center ms-3 mt-2\">\n                <label for=\"campaign-type\" class=\"pe-1\">Campaign Status</label>\n                <select id=\"campaign-type\">\n                    <option value=\"all\"{% if 'type' not in request.GET %} selected{%endif %}>All</option>\n                    <option value=\"completed\"{% if request.GET.type == 'completed' %} selected{% endif %}>Completed</option>\n                    <option value=\"retired\"{% if request.GET.type == 'retired' %} selected{% endif %}>Retired</option>\n                </select>\n                <a class=\"btn btn-primary\" onclick=\"toggleCampaignType();\" type=\"submit\">Go</a>\n            </div>\n            <div class=\"d-flex align-items-center ms-3 mt-2\">\n                <label for=\"research-center\" class=\"pe-1\">Research Center</label>\n                <select id=\"research-center\">\n                    <option value=\"all\"{% if 'research_center' not in request.GET %} selected{% endif %}>All</option>\n                    {% for research_center in research_centers %}\n                        <option value=\"{{ research_center.pk }}\"{% if request.GET.research_center|add:\"0\" == research_center.pk %} selected{% endif %}>\n                            {{ research_center.title }}\n                        </option>\n                    {% endfor %}\n                </select>\n                <a class=\"btn btn-primary\" onclick=\"toggleResearchCenter();\" type=\"submit\">Go</a>\n            </div>\n        </div>\n        {% if request.GET.view == 'list' %}\n            <ul id=\"campaign-list\" class=\"list-unstyled list-view\">\n                {% for campaign in campaigns %}\n                    <li{% if forloop.counter > 10 %} hidden{% endif %}>\n                        <div class=\"row\">\n                            <div class=\"campaign-thumbnail\">\n                                <div class=\"aspect-ratio-box\">\n                                    <div class=\"aspect-ratio-box-inner-wrapper\">\n                                        <a href=\"{% url 'transcriptions:campaign-detail' campaign.slug %}\">\n                                            <img src=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\" class=\"img-fluid\" alt=\"{{ campaign.alt_image_text}}\" loading=\"lazy\" width=\"150\" height=\"150\">\n                                        </a>\n                                    </div>\n                                </div>\n                            </div>\n                            <div class=\"campaign-text\">\n                                <p class=\"mb-2\">\n                                    <a href=\"{% url 'transcriptions:campaign-detail' campaign.slug %}\">\n                                        <span class=\"d-block h4\">\n                                            {{ campaign.title }}\n                                        </span>\n                                    </a>\n                                    {% if campaign.launch_date %}\n                                        <span class=\"fw-bold\">Started: </span>{{ campaign.launch_date|date:\"Y-m-d\" }}\n                                        {% if campaign.completed_date %}</br>{% endif %}\n                                    {% endif %}\n                                    {% if campaign.completed_date %}\n                                        <span class=\"fw-bold\">Completed: </span>{{ campaign.completed_date|date:\"Y-m-d\" }}\n                                    {% endif %}\n                                </p>\n                                <p class=\"mb-2\">\n                                    {{ campaign.short_description|striptags|truncatechars_on_word_break:160 }}\n                                </p>\n                            </div>\n                        </div>\n                    </li>\n                {% endfor %}\n            </ul>\n            {% with campaigns|length as campaigns_count %}\n                {% if campaigns_count > 10 %}\n                    <div class=\"align-items-center justify-content-center d-flex\">\n                        <a id=\"show-more\" class=\"btn btn-primary\">Show More Campaigns ({{ campaigns_count|add:\"-10\" }})</a>\n                    </div>\n                {% endif %}\n            {% endwith %}\n        {% else %}\n            <ul class=\"list-unstyled row mt-4\">\n                {% with show_description=True show_start=True %}\n                    {% for campaign in campaigns %}\n                        {% include \"transcriptions/campaign_small_block.html\" %}\n                    {% endfor %}\n                {% endwith %}\n            </ul>\n        {% endif %}\n    </div>\n{% endblock main_content %}\n\n{% block body_scripts %}\n    <script>\n        var toggleCampaignView = function(form) {\n            let url = new URL(window.location.href);\n            let viewValue = document.getElementById('view-options').value;\n            url.searchParams.set(\"view\", encodeURIComponent(viewValue));\n            window.location.href = url;\n        };\n        let showMoreButton = document.getElementById(\"show-more\");\n        let campaignList = document.getElementById(\"campaign-list\");\n        if (showMoreButton){\n            showMoreButton.addEventListener(\"click\", function(event){\n                for (const child of campaignList.children){\n                    child.hidden = false;\n                }\n                showMoreButton.parentElement.classList.remove(\"d-flex\");\n                showMoreButton.parentElement.hidden = true;\n                event.preventDefault();\n            });\n        }\n\n        var toggleCampaignType = function(form) {\n            let url = new URL(window.location.href);\n            let typeValue = document.getElementById('campaign-type').value;\n            if (typeValue == \"all\") {\n                url.searchParams.delete(\"type\");\n            } else {\n                url.searchParams.set(\"type\", encodeURIComponent(typeValue));\n            }\n            window.location.href = url;\n        }\n\n        var toggleResearchCenter = function(form) {\n            let url = new URL(window.location.href);\n            let researchCenter = document.getElementById('research-center').value;\n            if (researchCenter == \"all\") {\n                url.searchParams.delete(\"research_center\");\n            } else {\n                url.searchParams.set(\"research_center\", encodeURIComponent(researchCenter));\n            }\n            window.location.href = url;\n        }\n    </script>\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/campaign_report.html",
    "content": "{% extends \"base.html\" %}\n\n{% load humanize %}\n{% load staticfiles %}\n\n{% block title %}Campaign Report: {{ title }}{% endblock title %}\n\n{% block main_content %}\n    <div class=\"container\">\n        <div class=\"row\">\n            <h3>Campaign Summary: {{ title }}</h3>\n            <table class=\"table table-bordered table-hover\">\n                <tbody>\n                    <tr>\n                        <th>Total Images:</th>\n                        <td class=\"font-monospace text-end\">{{ total_asset_count|intcomma }}</td>\n                    </tr>\n                    <tr>\n                        <th>Total Projects:</th>\n                        <td class=\"font-monospace text-end\">{{ projects.paginator.count|intcomma }} </td>\n                    </tr>\n                </tbody>\n            </table>\n        </div>\n        <div class=\"row justify-content-center\">\n            {% for project in projects %}\n                <div class=\"card-column col-lg-4 mb-1\">\n                    <div class=\"card h-100\">\n                        <div class=\"card-header\">\n                            <a class=\"card-title\" href=\"{% url 'transcriptions:project-detail' campaign_slug=campaign_slug slug=project.slug %}\">\n                                {{ project.title }}\n                            </a>\n                        </div>\n                        <div class=\"card-body\">\n                            <table class=\"table table-sm table-bordered table-striped\">\n                                <tbody>\n                                    <tr>\n                                        <th>Images in this Project</th>\n                                        <td class=\"font-monospace text-end\">{{ project.asset_count|intcomma }}</td>\n                                    </tr>\n                                    <tr>\n                                        <th>Number of Transcribers</th>\n                                        <td class=\"font-monospace text-end\">{{ project.transcriber_count|intcomma }} </td>\n                                    </tr>\n                                    <tr>\n                                        <th>Number of Reviewers</th>\n                                        <td class=\"font-monospace text-end\">{{ project.reviewer_count|intcomma }} </td>\n                                    </tr>\n                                    <tr>\n                                        <th>Tags</th>\n                                        <td class=\"font-monospace text-end\">{{ project.tag_count|intcomma }} </td>\n                                    </tr>\n                                </tbody>\n                            </table>\n\n                            <table class=\"table table-sm table-bordered table-striped mb-0\">\n                                <caption style=\"caption-side: top\">Transcription Statuses</caption>\n                                <tbody>\n                                    {% for status, count in project.transcription_statuses %}\n                                        <tr>\n                                            <th>{{ status }}</th>\n                                            <td class=\"font-monospace text-end\">{{ count }}</td>\n                                        </tr>\n                                    {% endfor %}\n                                </tbody>\n                            </table>\n                        </div>\n                    </div>\n                </div>\n            {% endfor %}\n        </div>\n\n        <div class=\"row mt-3\">\n            <nav class=\"w-100\" aria-label=\"pagination\">\n                <ul class=\"pagination mx-auto justify-content-center\">\n                    {% if projects.has_previous %}\n                        <li class=\"page-item\">\n                            <a class=\"page-link\" href=\"?page={{ projects.previous_page_number }}\">Previous</a>\n                        </li>\n                    {% else %}\n                        <li class=\"page-item disabled\" aria-hidden=\"true\">\n                            <span class=\"page-link\">Previous</span>\n                        </li>\n                    {% endif %}\n\n                    {% for page_num in paginator.page_range %}\n                        <li class=\"page-item {% if page_num == projects.number %}active{% endif %}\" {% if page_num == projects.number %}aria-current=\"page\"{% endif %}>\n                            <a class=\"page-link\" href=\"?page={{ page_num }}\">{{ page_num }}</a>\n                        </li>\n                    {% endfor %}\n\n                    {% if projects.has_next %}\n                        <li class=\"page-item\">\n                            <a class=\"page-link\" href=\"?page={{ projects.next_page_number }}\">Next</a>\n                        </li>\n                    {% else %}\n                        <li class=\"page-item disabled\" aria-hidden=\"true\">\n                            <span class=\"page-link\">Next</span>\n                        </li>\n                    {% endif %}\n                </ul>\n            </nav>\n        </div>\n    </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/campaign_small_block.html",
    "content": "{% load truncation %}\n\n<li class=\"col-sm-4 mb-4\">\n    <a href=\"{% url 'transcriptions:campaign-detail' campaign.slug %}\">\n        <div class=\"aspect-ratio-box\">\n            <div class=\"aspect-ratio-box-inner-wrapper\">\n                <img src=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\" class=\"img-fluid\" alt=\"{{ campaign.alt_image_text}}\" loading=\"lazy\">\n            </div>\n        </div>\n        <span class=\"d-block h4 mt-2 small-campaign-title\">{{ campaign.title }}</span>\n    </a>\n    {% if campaign.completed_date or show_start and campaign.launch_date %}\n        <p>\n            {% if show_start and campaign.launch_date %}\n                <span class=\"fw-bold\">Started: </span>{{ campaign.launch_date|date:\"Y-m-d\" }}\n                {% if campaign.completed_date %}</br>{% endif %}\n            {% endif %}\n            {% if campaign.completed_date %}\n                <span class=\"fw-bold\">Completed: </span>{{ campaign.completed_date|date:\"Y-m-d\" }}\n            {% endif %}\n        </p>\n    {% endif %}\n    {% if show_description %}\n        <p class=\"small-campaign-description\">{{ campaign.short_description|striptags|truncatechars_on_word_break:160 }}</p>\n    {% endif %}\n</li>\n"
  },
  {
    "path": "concordia/templates/transcriptions/campaign_topic_list.html",
    "content": "{% extends \"base.html\" %}\n\n{% load staticfiles %}\n\n{% block title %}Active Campaigns{% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item active\" aria-current=\"page\">All Campaigns</li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <h1>All Campaigns</h1>\n        {% if topics %}\n            <div>\n                <h2 class=\"h5\">Explore by topic</h2>\n                <ul class=\"list-inline\">\n                    {% for topic in topics %}\n                        <li class=\"list-inline-item my-1 \">\n                            <a class=\"btn btn-outline-primary\" href=\"{% url 'topic-detail' topic.slug %}\">\n                                {{ topic.title }}\n                            </a>\n                        </li>\n                    {% endfor %}\n                </ul>\n            </div>\n        {% endif %}\n        <h2 class=\"p-2\">Active Campaigns</h2>\n        <ul id=\"campaign-list\" class=\"list-unstyled\">\n            {% for campaign in campaigns %}\n                <li class=\"p-4 mb-1 bg-light\" {% if forloop.counter > 10 %}hidden{% endif %}>\n                    <h3 class=\"mb-3\"><a href=\"{{ campaign.get_absolute_url }}\">{{ campaign.title }}</a></h3>\n                    <div class=\"row\">\n                        <a class=\"col-md-5 order-md-2\" href=\"{{ campaign.get_absolute_url }}\">\n                            <p class=\"mb-2 text-center\"><img src=\"{{ MEDIA_URL }}{{ campaign.thumbnail_image }}\" class=\"img-fluid\" alt=\"{% if campaign.image_alt_text %}{{ campaign.image_alt_text }}{% else %}{{ campaign.title }} image{% endif %}\"></p>\n                        </a>\n                        <div class=\"col-md\">\n                            <p>{{ campaign.short_description|safe }}</p>\n                            <a class=\"btn btn-primary\" href=\"{{ campaign.get_absolute_url }}\">View Projects</a>\n                            <div class=\"progress campaign-progress\">\n                                <div\n                                    class=\"progress-bar bg-completed\"\n                                    role=\"progressbar\"\n                                    style=\"width: {{ campaign.completed_percent|floatformat:'0' }}%\"\n                                    aria-valuenow=\"{{ campaign.completed_percent|floatformat:'0' }}\"\n                                ></div>\n                                <div\n                                    class=\"progress-bar bg-submitted\"\n                                    role=\"progressbar\"\n                                    style=\"width: {{ campaign.needs_review_percent|floatformat:'0' }}%\"\n                                    aria-valuenow=\"{{ campaign.needs_review_percent|floatformat:'0' }}\"\n                                ></div>\n                            </div>\n                            <div class=\"progress-bar-label\">\n                                {% if campaign.completed_percent %}\n                                    <span>{{ campaign.completed_percent|floatformat:'0' }}% Completed</span>\n                                    {% if campaign.needs_review_percent %} | {%endif %}\n                                {% endif %}\n                                {% if campaign.needs_review_percent %}\n                                    <span>{{ campaign.needs_review_percent|floatformat:'0' }}% Needs Review</span>\n                                {% endif %}\n                            </div>\n                        </div>\n                    </div>\n                </li>\n            {% endfor %}\n        </ul>\n        {% with campaigns|length as campaigns_count %}\n            {% if campaigns_count > 10 %}\n                <div class=\"align-items-center justify-content-center d-flex mt-3\">\n                    <button id=\"show-more\" class=\"btn btn-primary\" aria-expanded=\"false\">Show More Campaigns ({{ campaigns_count|add:\"-10\" }})</button>\n                </div>\n            {% endif %}\n        {% endwith %}\n        {% include \"transcriptions/completed_campaigns_section.html\" %}\n    </div>\n{% endblock main_content %}\n\n{% block body_scripts %}\n    <script>\n        let showMoreButton = document.getElementById(\"show-more\");\n        let campaignList = document.getElementById(\"campaign-list\");\n        if (showMoreButton && campaignList){\n            const items = Array.from(campaignList.children);\n            const batchSize = 10;\n            let expanded = false; // collapsed by default\n\n            function updateVisibility() {\n                if (expanded) {\n                    // All\n                    items.forEach(item => item.hidden = false);\n                    showMoreButton.textContent = \"Show Fewer Campaigns\";\n                    showMoreButton.setAttribute(\"aria-expanded\", \"true\");\n                } else {\n                    // Only first 10\n                    items.forEach((item, index) => {\n                        item.hidden = index >= batchSize;\n                    });\n                    const remaining = items.length - batchSize;\n                    showMoreButton.textContent = `Show More Campaigns (${remaining})`;\n                    showMoreButton.setAttribute(\"aria-expanded\", \"false\");\n                }\n            }\n\n            // Initial state\n            updateVisibility();\n\n            showMoreButton.addEventListener(\"click\", function(event){\n                expanded = !expanded; // toggle state\n                updateVisibility();\n                event.preventDefault();\n            });\n        }\n    </script>\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/completed_campaigns_section.html",
    "content": "<h2 class=\"pt-2\">Completed Campaigns</h2>\n<h4 class=\"section-link\"><a href=\"{% url 'transcriptions:completed-campaign-list' %}\">See All {{ completed_campaigns|length }} Completed Campaigns &raquo;</a></h4>\n<ul class=\"list-unstyled row\">\n    {% for campaign in completed_campaigns|slice:3 %}\n        {% include \"transcriptions/campaign_small_block.html\" %}\n    {% endfor %}\n</ul>\n"
  },
  {
    "path": "concordia/templates/transcriptions/item_detail.html",
    "content": "{% extends \"base.html\" %}\n\n{% load humanize %}\n{% load static %}\n{% load staticfiles %}\n{% load concordia_filtering_tags %}\n{% load concordia_media_tags %}\n{% load feature_flags %}\n\n{% block title %} {{ item.title }} ({{ campaign.title }}: {{ project.title }}) {% endblock title %}\n\n{% block head_content %}\n    {{ block.super }}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\" />\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item\"><a class=\"primary-text\" href=\"{% url 'campaign-topic-list' %}\">Campaigns</a></li>\n    <li class=\"breadcrumb-item\"><a class=\"primary-text text-truncate\" href=\"{% url 'transcriptions:campaign-detail' campaign.slug %}?{{ sublevel_querystring }}\" title=\"{{ campaign.title }}\">{{ campaign.title }}</a></li>\n    <li class=\"breadcrumb-item\"><a class=\"primary-text text-truncate\" href=\"{% url 'transcriptions:project-detail' campaign.slug project.slug %}?{{ sublevel_querystring }}\" title=\"{{ project.title }}\">{{ project.title }}</a></li>\n    <li class=\"breadcrumb-item active\" aria-current=\"page\" title=\"{{ item.title }}\"><span class=\"text-truncate\">{{ item.title }}</span></li>\n{% endblock breadcrumbs%}\n\n{% block main_content %}\n    {% flag_enabled 'DISPLAY_ITEM_DESCRIPTION' as DISPLAY_ITEM_DESCRIPTION %}\n\n    <div class=\"container py-3\">\n        <div class=\"row\">\n            <div class=\"col-md-10\">\n                <h1>{{ item.title }}</h1>\n                {% if DISPLAY_ITEM_DESCRIPTION %}\n                    <div class=\"m-3 hero-text\">{{ item.description|safe }}</div>\n                {% endif %}\n            </div>\n            <div class=\"col-md-2 align-bottom px-3\">\n                <div>\n                    <a href=\"{{ item.item_url }}\" class=\"btn btn-light\" title=\"View the original source for this item in a new tab\" target=\"_blank\" rel=noopener>View this item on www.loc.gov<i class=\"fa fa-external-link-alt\"></i></a>\n                </div>\n            </div>\n        </div>\n        {% include \"fragments/transcription-progress-row.html\" %}\n        <div class=\"d-flex justify-content-between mt-4\">\n            <div>\n                <h3>Filter pages:</h2>\n            </div>\n            {% url 'transcriptions:item-detail' campaign.slug project.slug item.item_id as all_assets %}\n            {% url 'transcriptions:filtered-item-detail' campaign.slug project.slug item.item_id as filtered_assets %}\n            {% include \"fragments/_filter-buttons.html\" with do_filter=filter_assets all_url=all_assets filtered_url=filtered_assets sublevel_qs=sublevel_querystring %}\n        </div>\n        <div class=\"row\">\n            <div class=\"col-12 col-lg text-center\">\n                {% transcription_status_filters transcription_status_counts transcription_status \"large\" True all_assets %}\n            </div>\n        </div>\n        <div class=\"row justify-content-center concordia-object-card-row\">\n            {% for a in assets %}\n                {% url 'transcriptions:asset-detail' a.item.project.campaign.slug a.item.project.slug a.item.item_id a.slug as asset_detail_url %}\n                <div class=\"col-6 col-md-4 col-lg-3 concordia-object-card-col\">\n                    <div class=\"h-100 card concordia-object-card border\" data-transcription-status=\"{{ a.transcription_status }}\">\n                        <a class=\"card-img-container\" href=\"{{ asset_detail_url }}\">\n                            <img class=\"card-img\" alt=\"{{ a.slug }}\" src=\"{% asset_media_url a %}\" />\n                        </a>\n                        <a class=\"card-title text-center{% if a.transcription_status == 'completed' %} text-dark{% endif %}\" href=\"{{ asset_detail_url }}\">\n                            #{{ a.sequence }}\n                        </a>\n                        <div class=\"card-actions\">\n                            <div class=\"d-grid\">\n                                <a class=\"btn btn-sm btn-block {% if a.transcription_status != 'completed' %}btn-primary{% else %}btn-dark{% endif %}\" href=\"{{ asset_detail_url }}\">\n                                    {% if a.transcription_status == 'submitted' %}\n                                        <span class=\"fas fa-list tx-submitted\"></span>\n                                        Review\n                                    {% elif a.transcription_status == 'completed' %}\n                                        <span class=\"fas fa-check tx-completed\"></span>\n                                        Complete\n                                    {% else %}\n                                        <span class=\"fas fa-edit tx-edit\"></span>\n                                        Transcribe\n                                    {% endif %}\n                                </a>\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            {% empty %}\n                {% if filter_assets %}\n                    <div class=\"pt-3\">There are no pages you can review. Select \"Show all\" to see pages you can read or edit.</div>\n                {% endif %}\n            {% endfor %}\n        </div>\n        <div class=\"row mt-4\">\n            {% include \"fragments/standard-pagination.html\" %}\n        </div>\n    </div>\n{% endblock main_content %}\n{% block body_scripts %}\n    {{ block.super }}\n    <script src=\"{% static 'js/filter-assets.js' %}\"></script>\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/project_detail.html",
    "content": "{% extends \"base.html\" %}\n\n{% load static %}\n{% load staticfiles %}\n{% load concordia_filtering_tags %}\n{% load concordia_text_tags %}\n\n{% block title %}{{ project.title }} ({{ campaign.title }}){% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\" />\n    <meta name=\"description\" content=\"{{ project.description|striptags|normalize_whitespace }}\" />\n    <meta name=\"thumbnail\" content=\"{{ MEDIA_URL }}{{ project.thumbnail_image }}\" />\n    <meta property=\"og:image\" content=\"{{ MEDIA_URL }}{{ project.thumbnail_image }}\" />\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item\"><a class=\"primary-text\" href=\"{% url 'campaign-topic-list' %}\">Campaigns</a></li>\n    <li class=\"breadcrumb-item\">\n        <a class=\"primary-text text-truncate\" href=\"{% url 'transcriptions:campaign-detail' campaign.slug %}?{{ sublevel_querystring }}\" title=\"{{ campaign.title }}\">{{ campaign.title }}</a>\n    </li>\n    <li class=\"breadcrumb-item active\" aria-current=\"page\" title=\"{{ project.title }}\"><span class=\"text-truncate\">{{ project.title }}</span></li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <div class=\"row\">\n            <div class=\"col-12\">\n                <h1>{{ project.title }}</h1>\n            </div>\n        </div>\n        {% include \"fragments/transcription-progress-row.html\" %}\n        <div class=\"row\">\n            <div class=\"col-12 mt-4\">\n                <div class=\"hero-text\">{{ project.description|safe }}</div>\n            </div>\n        </div>\n        <div class=\"d-flex justify-content-between mt-4\">\n            <div>\n                <h3>Filter pages:</h2>\n            </div>\n            {% url 'transcriptions:project-detail' campaign.slug project.slug as all_assets %}\n            {% url 'transcriptions:filtered-project-detail' campaign.slug project.slug as filtered_assets %}\n            {% include \"fragments/_filter-buttons.html\" with do_filter=filter_assets all_url=all_assets filtered_url=filtered_assets sublevel_qs=sublevel_querystring %}\n        </div>\n        <div class=\"row\">\n            <div class=\"col-12 col-lg text-center\">\n                {% transcription_status_filters transcription_status_counts transcription_status \"large\" True all_assets %}\n            </div>\n        </div>\n        <div class=\"row justify-content-center concordia-object-card-row\">\n            {% for item in items %}\n                <div class=\"col-6 col-md-4 col-lg-3 concordia-object-card-col\">\n                    <div class=\"h-100 concordia-object-card card border\" data-transcription-status=\"{{ item.lowest_transcription_status }}\">\n                        {% if filter_assets %}\n                            {% url 'transcriptions:filtered-item-detail' campaign.slug project.slug item.item_id as item_url %}\n                        {% else %}\n                            {% url 'transcriptions:item-detail' campaign.slug project.slug item.item_id as item_url %}\n                        {% endif %}\n\n                        <a href=\"{{ item_url }}?{{ sublevel_querystring }}\">\n                            <img class=\"card-img card-img-campaign\" alt=\"{{ item.title }}\" src=\"{{ item.thumbnail_link }}\" />\n                        </a>\n\n                        <div class=\"progress w-100\">\n                            <div title=\"Completed\" class=\"progress-bar bg-completed\" role=\"progressbar\" style=\"width: {{ item.completed_percent }}%\" aria-valuenow=\"{{ item.completed_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                            <div title=\"Needs Review\" class=\"progress-bar bg-submitted\" role=\"progressbar\" style=\"width: {{ item.submitted_percent }}%\" aria-valuenow=\"{{ item.submitted_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                            <div title=\"In Progress\" class=\"progress-bar bg-in_progress\" role=\"progressbar\" style=\"width: {{ item.in_progress_percent }}%\" aria-valuenow=\"{{ item.in_progress_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                        </div>\n\n                        <h6 class=\"text-center primary-text m-0 concordia-object-card-title\">\n                            <a{% if item.lowest_transcription_status == 'completed' %} class=\"text-dark\"{% endif %} href=\"{{ item_url }}?{{ sublevel_querystring }}\" class=\"campaign-image-link\">\n                                {{ item.title }}\n                            </a>\n                        </h6>\n\n                        {% if item.lowest_transcription_status == 'completed' %}\n                            <div class=\"card-actions\">\n                                <a class=\"btn btn-sm btn-block btn-dark\" href=\"{{ item_url }}?{{ sublevel_querystring }}\">\n                                    <span class=\"fas fa-check tx-completed\"></span>\n                                    Complete\n                                </a>\n                            </div>\n                        {% endif %}\n                    </div>\n                </div>\n            {% empty %}\n                {% if filter_assets %}\n                    <div class=\"pt-3\">There are no pages you can review. Select \"Show all\" to see pages you can read or edit.</div>\n                {% endif %}\n            {% endfor %}\n        </div>\n        <div class=\"row mt-4\">\n            {% include \"fragments/standard-pagination.html\" %}\n        </div>\n    </div>\n{% endblock main_content %}\n{% block body_scripts %}\n    {{ block.super }}\n    <script src=\"{% static 'js/filter-assets.js' %}\"></script>\n{% endblock body_scripts %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/topic_detail.html",
    "content": "{% extends \"base.html\" %}\n\n{% load staticfiles %}\n{% load humanize %}\n{% load concordia_text_tags %}\n{% load concordia_querystring %}\n{% load concordia_filtering_tags %}\n\n{% block title %}{{ topic.title }}{% endblock title %}\n\n{% block head_content %}\n    <link rel=\"canonical\" href=\"https://{{ request.get_host }}{{ request.path }}\">\n    <meta name=\"description\" content=\"{{ topic.description|striptags|normalize_whitespace }}\">\n    <meta name=\"thumbnail\" content=\"{{ MEDIA_URL }}{{ topic.thumbnail_image }}\">\n    <meta property=\"og:image\" content=\"{{ MEDIA_URL }}{{ topic.thumbnail_image }}\">\n    {{ block.super }}\n{% endblock head_content %}\n\n{% block breadcrumbs %}\n    <li class=\"breadcrumb-item\"><a class=\"primary-text\" href=\"{% url 'campaign-topic-list' %}\">Campaigns</a></li>\n    <li class=\"breadcrumb-item active\" aria-current=\"page\" title=\"{{ topic.title }}\"><span class=\"text-truncate\">{{ topic.title }}</span></li>\n{% endblock breadcrumbs %}\n\n{% block main_content %}\n    <div class=\"container py-3\">\n        <div class=\"row\">\n            <div class=\"col-md-12\">\n                <h1>{{ topic.title }}</h1>\n            </div>\n        </div>\n        {% include \"fragments/transcription-progress-row.html\" %}\n        <div class=\"row mt-3\">\n            <div class=\"col-md-9\">\n                <div class=\"hero-text\">{{ topic.description|safe }}</div>\n            </div>\n            <div class=\"col-md-3\">\n                {% if topic.helpfullink_set.all|length %}\n                    <aside class=\"mb-3 mt-4 mt-md-0 p-3 bg-light border\">\n                        <h4 class=\"mb-3\">Helpful Links</h4>\n                        <ul class=\"list-unstyled m-0\">\n                            {% for link in topic.helpfullink_set.all %}\n                                {% if 'loc.gov' in link.link_url   %}\n                                    <li class=\"mb-3\"><a href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>{{ link.title }}</a></li>\n                                {% else %}\n                                    <li class=\"mb-3\"><a href=\"{{ link.link_url }}\" target=\"_blank\" rel=noopener>{{ link.title }} <i class=\"fa fa-external-link-alt\"></i></a></li>\n                                {% endif %}\n                            {% endfor %}\n                        </ul>\n                    </aside>\n                {% endif %}\n            </div>\n            <div class=\"d-flex justify-content-between mt-4\">\n                <div>\n                    <h3>Filter pages:</h3>\n                </div>\n                {% url 'transcriptions:topic-detail' topic.slug as all_assets %}\n            </div>\n            <div class=\"row\">\n                <div class=\"col-12 col-lg text-center\">\n                    {% transcription_status_filters transcription_status_counts transcription_status \"large\" True all_assets %}\n                </div>\n            </div>\n            <div class=\"row justify-content-center concordia-object-card-row mt-3\">\n                {% for project in projects %}\n                    <div class=\"col-6 col-md-4 col-lg-3 concordia-object-card-col\">\n                        <div class=\"h-100 concordia-object-card card border\" data-transcription-status=\"{{ project.lowest_transcription_status }}\">\n                            {% url 'transcriptions:project-detail' project.campaign.slug project.slug as project_url %}\n                            {% if sublevel_querystring %}\n                            {# If a filter has been set, we want to always respect that #}\n                            {# Essentially, this just assigns sublevel_querystring to the temp variable project_querystring for use below #}\n                                {% qs_alter sublevel_querystring as project_querystring %}\n                            {% elif project.topic_url_filter %}\n                            {# No overriding filter has been set, so use the one assigned to the project, if any #}\n                                {% qs_alter sublevel_querystring add_if_missing:transcription_status=project.topic_url_filter as project_querystring %}\n                            {% else %}\n                            {# Explicitly blank the querystring, since none are set; this is needed so the last project's querystring isn't used #}\n                                {% qs_alter \"\" as project_querystring %}\n                            {% endif %}\n\n                            <a href=\"{{ project_url }}{% if project_querystring %}?{{ project_querystring }}{% endif %}\" aria-hidden=\"true\">\n                                <img class=\"card-img card-img-campaign\" src=\"{{ MEDIA_URL }}{{ project.thumbnail_image }}\" alt=\"{{ project.title }}\">\n                            </a>\n\n                            <div class=\"progress w-100\">\n                                <div title=\"Completed\" class=\"progress-bar bg-completed\" role=\"progressbar\" style=\"width: {{ project.completed_percent }}%\" aria-valuenow=\"{{ project.completed_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                                <div title=\"Needs Review\" class=\"progress-bar bg-submitted\" role=\"progressbar\" style=\"width: {{ project.submitted_percent }}%\" aria-valuenow=\"{{ project.submitted_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                                <div title=\"In Progress\" class=\"progress-bar bg-in_progress\" role=\"progressbar\" style=\"width: {{ project.in_progress_percent }}%\" aria-valuenow=\"{{ project.in_progress_percent }}\" aria-valuemin=\"0\" aria-valuemax=\"100\"></div>\n                            </div>\n\n                            <h6 class=\"text-center primary-text m-0 concordia-object-card-title\">\n                                <a{% if project.lowest_transcription_status == 'completed' %} class=\"text-dark\"{% endif %} href=\"{{ project_url }}{% if project_querystring %}?{{ project_querystring }}{% endif %}\">{{ project.campaign.title }} - {{ project.title }}</a>\n                            </h6>\n\n                            {% if project.lowest_transcription_status == 'completed' %}\n                                <div class=\"card-actions\">\n                                    <a class=\"btn btn-sm btn-block btn-dark\" href=\"{{ project_url }}{% if project_querystring %}?{{ project_querystring }}{% endif %}\">\n                                        <span class=\"fas fa-check tx-completed\"></span>\n                                        Complete\n                                    </a>\n                                </div>\n                            {% endif %}\n                        </div>\n                    </div>\n                {% endfor %}\n            </div>\n        </div>\n{% endblock main_content %}\n"
  },
  {
    "path": "concordia/templates/transcriptions/transcription.html",
    "content": "{% extends \"base.html\" %}\n{% load static %}\n\n{% block head_content %}\n    <script id=\"viewer-data\"\n            data-prefix-url=\"{% static 'openseadragon/build/openseadragon/images/' %}\"\n            data-contact-url=\"https://ask.loc.gov/crowd\"\n    ></script>\n    {% include \"fragments/import-map.html\" %}\n{% endblock %}\n\n{% block title %}React Test Page{% endblock title %}\n\n{% block main_content %}\n    <div id=\"app\" class=\"container-fluid flex-grow-1 d-flex flex-column d-print-block\">\n        <script type=\"module\" src=\"{% static 'frontend/js/index.js' %}\"></script>\n    </div>\n{% endblock %}\n"
  },
  {
    "path": "concordia/templatetags/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/templatetags/concordia_filtering_tags.py",
    "content": "from typing import Any, Dict, Iterable, List, Tuple\nfrom urllib.parse import quote as urlquote\n\nfrom django import template\n\nfrom ..models import TranscriptionStatus\n\nregister = template.Library()\n\n\n@register.inclusion_tag(\"fragments/transcription-status-filters.html\")\ndef transcription_status_filters(\n    status_counts: Iterable[Tuple[str, str, int]],\n    active_value: str | None,\n    size: str = \"small\",\n    reversed_order: bool = False,\n    url: str = \"\",\n) -> Dict[str, Any]:\n    \"\"\"\n    Build a context for the transcription status filter UI.\n\n    Behavior:\n        Produces the context expected by the\n        `fragments/transcription-status-filters.html` template. The context\n        includes a `status_choices` list of tuples used to render links and\n        classes for each status, plus an entry representing \"All.\"\n\n        The function keeps the provided `active_value` selected, can reverse\n        the status order, and will prepend the provided `url` when building\n        filter links.\n\n    Usage:\n        Basic usage with counts from the view:\n\n            {% load concordia_filtering_tags %}\n            {% transcription_status_filters status_counts active_value %}\n\n        With optional parameters:\n\n            {% transcription_status_filters status_counts active_value\n               size=\"small\" reversed_order=True url=request.path %}\n\n        Where:\n            - `status_counts` is an iterable of `(key, label, count)`.\n            - `active_value` is the currently selected status key, or empty.\n            - `size` controls sizing classes used by the fragment.\n            - `reversed_order` reverses the order of `TranscriptionStatus.CHOICES`.\n            - `url` is prefixed to each generated link.\n\n    Args:\n        status_counts: Iterable of three-tuples `(key, label, count)` used to\n            display per-status counts.\n        active_value: The currently active status key, or `None`/empty for All.\n        size: Size hint passed through to the template.\n        reversed_order: If True, reverse the status choice order.\n        url: Base URL to which the query string is appended.\n\n    Returns:\n        dict: Template context with keys:\n            - `size` (str)\n            - `status_choices` (list[tuple[str, str, str, str, int | None]])\n              Each tuple is `(href, active_class, css_key, label, count)`.\n    \"\"\"\n    ctx: Dict[str, Any] = {}\n    ctx[\"size\"] = size\n\n    status_choices: List[Tuple[str, str, str, str, int | None]] = [\n        (\"\", \"flex-initial\" + \" active\" if not active_value else \"\", \"\", \"All\", None)\n    ]\n    ctx[\"status_choices\"] = status_choices\n\n    counts = {count[0]: count[2] for count in status_counts}\n    statuses = TranscriptionStatus.CHOICES\n    if reversed_order:\n        statuses = reversed(statuses)\n    for key, label in statuses:\n        status_choices.append(\n            (\n                \"%s?transcription_status=%s\" % (url, urlquote(key)),\n                \"active\" if active_value == key else \"\",\n                key.replace(\"_\", \"-\"),\n                label,\n                counts.get(key),\n            )\n        )\n\n    return ctx\n"
  },
  {
    "path": "concordia/templatetags/concordia_media_tags.py",
    "content": "from typing import Any\n\nfrom django import template\n\nregister = template.Library()\n\n\n@register.simple_tag()\ndef asset_media_url(asset: Any) -> str:\n    \"\"\"\n    Return the media URL for an asset's stored image.\n\n    Behavior:\n        Reads `asset.storage_image.url` and returns the URL string. This tag\n        does not perform existence checks; it assumes the attribute is present\n        on the given object.\n\n    Usage:\n        Inline `src` attribute:\n\n            {% load concordia_media_tags %}\n            <img src=\"{% asset_media_url asset %}\" alt=\"\">\n\n        Store in a variable:\n\n            {% asset_media_url asset as image_url %}\n            <img src=\"{{ image_url }}\" alt=\"\">\n\n    Args:\n        asset: An object that exposes `storage_image.url`.\n\n    Returns:\n        str: The URL of the stored image.\n    \"\"\"\n    return asset.storage_image.url\n"
  },
  {
    "path": "concordia/templatetags/concordia_querystring.py",
    "content": "\"\"\"\nQuery string manipulation template tag.\n\nOriginally from https://github.com/acdha/django-bittersweet\n\"\"\"\n\nfrom typing import Any, Optional\n\nfrom django.http import QueryDict\nfrom django.template import Library, Node, Variable\nfrom django.template.base import Parser, Token\nfrom django.utils.html import escape\n\nregister = Library()\n\n\nclass QueryStringAlterer(Node):\n    \"\"\"\n    Template node that applies alterations to a query string.\n\n    Behavior:\n        Resolves a base query string from the template context (either a raw\n        query string or a `QueryDict` such as `request.GET`) and applies a\n        sequence of alterations provided as tag arguments.\n\n        Supported alterations:\n            - Assignment: `name=value`\n            - Deletion by key: `delete:name`\n            - Deletion by key and value (value from a literal or a variable):\n              `delete_value:\"name\",value` or `delete_value:field_name,value`\n            - Conditional add if missing: `add_if_missing:name=value`\n\n        The result is URL-encoded and HTML-escaped. If the tag is used with an\n        `as variable_name` clause, the encoded string is stored in the context\n        under that name and an empty string is rendered. Otherwise, the encoded\n        string is returned.\n\n    Usage:\n        The tag is registered as `qs_alter`. Provide a base query string\n        (a `QueryDict` like `request.GET` or a string) followed by one or more\n        alterations.\n\n        Query string provided as `QueryDict`:\n\n            {% qs_alter request.GET foo=bar %}\n            {% qs_alter request.GET foo=bar baaz=quux %}\n            {% qs_alter request.GET foo=bar baaz=quux delete:corge %}\n\n        Remove one facet from a list:\n\n            {% qs_alter request.GET foo=bar baaz=quux\n               delete_value:\"facets\",value %}\n\n        Conditionally add a parameter only if missing:\n\n            {% qs_alter request.GET add_if_missing:foo=bar %}\n\n        Query string provided as string:\n\n            {% qs_alter \"foo=baaz\" foo=bar %}\n\n        Store the result in a variable in the template context:\n\n            {% qs_alter request.GET foo=bar baaz=quux delete:corge as new_qs %}\n\n    Args (template usage):\n        base_qs: Either a `QueryDict` (for example, `request.GET`) or a string\n            containing a query string.\n        alterations: One or more alteration arguments in the formats described\n            above.\n        as variable_name: Optional. If provided, the result is saved to the\n            named context variable instead of being rendered.\n\n    Returns:\n        str: The encoded query string when not using `as variable_name`;\n        otherwise an empty string.\n    \"\"\"\n\n    def __init__(self, base_qs: str, as_variable: Optional[str], *args) -> None:\n        self.base_qs = Variable(base_qs)\n        self.args = args\n        # Controls whether the result is returned or stored in the context.\n        self.as_variable = as_variable\n\n    def render(self, context: Any) -> str:\n        \"\"\"\n        Render the altered query string.\n\n        Args:\n            context: Template rendering context.\n\n        Returns:\n            str: The encoded query string, or an empty string when storing the\n            result via `as variable_name`.\n        \"\"\"\n        base_qs = self.base_qs.resolve(context)\n\n        if isinstance(base_qs, QueryDict):\n            qs = base_qs.copy()\n        else:\n            qs = QueryDict(base_qs, mutable=True)\n\n        for arg in self.args:\n            if arg.startswith(\"delete:\"):\n                v = arg[7:]\n                if v in qs:\n                    del qs[v]\n            elif arg.startswith(\"delete_value:\"):\n                field, value = arg[13:].split(\",\", 2)\n                value = Variable(value).resolve(context)\n\n                if not (field[0] == '\"' and field[-1] == '\"'):\n                    field = Variable(field).resolve(context)\n                else:\n                    field = field.strip(\"\\\"'\")\n\n                f_list = qs.getlist(field)\n                if value in f_list:\n                    f_list.remove(value)\n                    qs.setlist(field, f_list)\n            elif arg.startswith(\"add_if_missing:\"):\n                k, v = arg[15:].split(\"=\", 2)\n                if k not in qs:\n                    qs[k] = Variable(v).resolve(context)\n            else:\n                k, v = arg.split(\"=\", 2)\n                qs[k] = Variable(v).resolve(context)\n\n        encoded_qs = escape(qs.urlencode())\n        if self.as_variable:\n            context[self.as_variable] = encoded_qs\n            return \"\"\n        else:\n            return encoded_qs\n\n    @classmethod\n    def qs_alter_tag(cls, parser: Parser, token: Token) -> \"QueryStringAlterer\":\n        \"\"\"\n        Template tag parser for `qs_alter`.\n\n        Args:\n            parser: Django template parser.\n            token: Token containing the tag and its arguments.\n\n        Returns:\n            QueryStringAlterer: A compiled node ready for rendering.\n        \"\"\"\n        bits = token.split_contents()\n\n        if bits[-2] == \"as\":\n            as_variable = bits[-1]\n            bits = bits[0:-2]\n        else:\n            as_variable = None\n\n        return QueryStringAlterer(bits[1], as_variable, *bits[2:])\n\n\nregister.tag(\"qs_alter\", QueryStringAlterer.qs_alter_tag)\n"
  },
  {
    "path": "concordia/templatetags/concordia_sharing_tags.py",
    "content": "from typing import Dict\n\nfrom django import template\n\nregister = template.Library()\n\n\n@register.inclusion_tag(\"fragments/sharing-button-group.html\")\ndef share_buttons(url: str, title: str) -> Dict[str, str]:\n    \"\"\"\n    Build the context for the sharing button fragment and render it.\n\n    Behavior:\n        This is an inclusion tag. Django will render\n        `fragments/sharing-button-group.html` with the returned context and\n        insert the resulting HTML at the call site.\n\n    Usage:\n        Render inline:\n\n            {% load concordia_sharing_buttons %}\n            {% share_buttons request.build_absolute_uri object.title %}\n\n        Capture the rendered HTML, then output it later:\n\n            {% share_buttons page_url page_title as share_html %}\n            {{ share_html|safe }}\n\n        Notes:\n            - The value captured with `as` is rendered HTML, not a context\n              dictionary. Do not pass it to `{% include %}` as context.\n\n    Args:\n        url: Absolute URL to share.\n        title: Display title to accompany the share action.\n\n    Returns:\n        dict: Mapping used by `fragments/sharing-button-group.html` with keys:\n            - `title` (str)\n            - `url` (str)\n    \"\"\"\n    return {\"title\": title, \"url\": url}\n"
  },
  {
    "path": "concordia/templatetags/concordia_text_tags.py",
    "content": "import re\n\nfrom django import template\n\nregister = template.Library()\n\nWHITESPACE_NORMALIZER = re.compile(r\"\\s+\")\n\n\n@register.filter\ndef normalize_whitespace(text: str) -> str:\n    \"\"\"\n    Replace consecutive whitespace in text with a single space.\n\n    Behavior:\n        Collapses runs of whitespace characters (including newlines and tabs)\n        to a single ASCII space.\n\n    Usage:\n        In a template:\n\n            {% load concordia_text_tags %}\n            {{ some_text|normalize_whitespace }}\n\n        In Python:\n\n            normalize_whitespace(\"a\\\\n\\\\n  b\\\\t\\\\t c\")  # -> \"a b c\"\n\n    Args:\n        text: Input text to normalize.\n\n    Returns:\n        str: Text with whitespace collapsed to single spaces.\n    \"\"\"\n    return WHITESPACE_NORMALIZER.sub(\" \", text)\n\n\n@register.filter\ndef reprchar(character: str) -> str:\n    \"\"\"\n    Return a Python-style literal representation of a single character without\n    surrounding quotes, for example \"\\\\\\\\u200b\", \"\\\\\\\\x00\", \"\\\\\\\\n\".\n\n    Behavior:\n        Uses Python's `repr` to obtain an escaped form, then removes the outer\n        quotes so the result is suitable for display in templates.\n\n    Usage:\n        In a template:\n\n            {% load concordia_text_tags %}\n            Invisible char: {{ some_char|reprchar }}\n\n        In Python:\n\n            reprchar(\"\\\\u200b\")  # -> \"\\\\\\\\u200b\"\n\n    Args:\n        character: A single-character string to represent.\n\n    Returns:\n        str: The escaped representation without surrounding quotes.\n    \"\"\"\n    # Strip the outer quotes added by repr\n    return repr(character)[1:-1]\n"
  },
  {
    "path": "concordia/templatetags/custom_math.py",
    "content": "from typing import Any\n\nfrom django import template\n\nregister = template.Library()\n\n\n@register.filter(name=\"multiply\")\ndef multiply(value: Any, arg: Any) -> Any:\n    \"\"\"\n    Multiply two values.\n\n    Behavior:\n        Returns the product of `value` and `arg` using Python's `*` operator.\n\n    Usage:\n        In a template:\n\n            {% load custom_math %}\n            {{ 6|multiply:7 }}            {# 42 #}\n            {{ price|multiply:quantity }} {# product of variables #}\n\n        In Python:\n\n            multiply(3, 5)   # -> 15\n            multiply(\"a\", 3) # -> \"aaa\"\n\n    Args:\n        value: Left operand.\n        arg: Right operand.\n\n    Returns:\n        Any: The result of `value * arg`.\n    \"\"\"\n    return value * arg\n"
  },
  {
    "path": "concordia/templatetags/group_list.py",
    "content": "from typing import Any, Sequence\n\nfrom django import template\n\nregister = template.Library()\n\n\n@register.filter\ndef batch(value: Sequence[Any], size: int) -> list[Sequence[Any]]:\n    \"\"\"\n    Group a sequence into consecutive chunks.\n\n    Behavior:\n        Returns a list of slices from `value`, each of length `size`, except\n        possibly the last slice if there are not enough elements.\n\n    Usage:\n        In a template:\n\n            {% load group_list %}\n            {% for row in items|batch:3 %}\n                <div class=\"row\">\n                    {% for item in row %}\n                        <span>{{ item }}</span>\n                    {% endfor %}\n                </div>\n            {% endfor %}\n\n        In Python:\n\n            batch([1, 2, 3, 4, 5], 2)  # -> [[1, 2], [3, 4], [5]]\n            batch((\"a\", \"b\", \"c\"), 4)  # -> [(\"a\", \"b\", \"c\")]\n\n    Args:\n        value: The sequence to split. Must support `len()` and slicing.\n        size: The maximum size of each chunk. Will be converted to `int`\n            by Django when called from templates.\n\n    Returns:\n        list[Sequence[Any]]: Consecutive slices of `value`, each at most `size`\n        elements long.\n    \"\"\"\n    size = int(size)\n    return [value[i : i + size] for i in range(0, len(value), size)]\n"
  },
  {
    "path": "concordia/templatetags/reject_filter.py",
    "content": "from typing import Any\n\nfrom django import template\n\nregister = template.Library()\n\n\n@register.filter\ndef reject(value: Any, args: str) -> Any:\n    \"\"\"\n    Remove one or more unwanted items from a list or space-separated string.\n\n    Behavior:\n        - If `value` is a string, treat it as space-separated tokens.\n        - If `value` is an iterable of items, convert it to a list.\n        - Remove any tokens present in `args`, which is a comma-separated\n          string of items to reject.\n\n    Usage:\n        In a template:\n\n            {% load reject_filter %}\n            {{ \"error warn marked-safe\"|reject:\"marked-safe\" }}\n            {# -> \"error warn\" #}\n\n            {{ \"error warning marked-safe\"|reject:\"marked-safe,warn\" }}\n            {# -> \"error\" #}\n\n            {{ my_list|reject:\"deprecated,hidden\" }}\n            {# If my_list == [\"ok\", \"deprecated\", \"x\", \"hidden\"] then\n               -> [\"ok\", \"x\"] #}\n\n        In Python:\n\n            reject(\"a b c\", \"b\")            # -> \"a c\"\n            reject([\"a\", \"b\", \"c\"], \"b,c\")  # -> [\"a\"]\n\n    Args:\n        value: Input to filter. A space-separated string or an iterable.\n        args: Comma-separated items to remove.\n\n    Returns:\n        If `value` is a string, a space-joined string of remaining tokens.\n        Otherwise a list of remaining items.\n    \"\"\"\n    if not value:\n        return value\n\n    if isinstance(value, str):\n        value_list = value.split()\n    else:\n        value_list = list(value)\n\n    reject_items = set(args.split(\",\"))\n\n    filtered_list = [item for item in value_list if item not in reject_items]\n\n    return \" \".join(filtered_list) if isinstance(value, str) else filtered_list\n"
  },
  {
    "path": "concordia/templatetags/truncation.py",
    "content": "import unicodedata\n\nfrom django import template\nfrom django.template.defaultfilters import stringfilter\nfrom django.utils.text import Truncator, add_truncation_text\n\nregister = template.Library()\n\n\nclass WordBreakTruncator(Truncator):\n    def word_break(self, num: int, truncate: str | None = None) -> str:\n        \"\"\"\n        Return the text truncated to no longer than the given number of\n        characters, cutting at the most recent word break.\n\n        This method follows the behavior of `django.utils.text.Truncator`, but\n        differs by ensuring the cut occurs on a word boundary when possible.\n        It also counts only non-combining Unicode code points toward the\n        character limit.\n\n        Args:\n            num (int): Maximum length of the resulting string, including any\n                truncation text.\n            truncate (str | None): The text to append when truncation occurs.\n                If not provided, the default from `add_truncation_text` is used.\n\n        Returns:\n            str: The truncation marker\n            appended.\n        \"\"\"\n        self._setup()\n        length = int(num)\n        text = unicodedata.normalize(\"NFC\", self._wrapped)\n\n        # Calculate the length to truncate to (max length - end_text length).\n        truncate_len = length\n        for char in add_truncation_text(\"\", truncate):\n            if not unicodedata.combining(char):\n                truncate_len -= 1\n                if truncate_len == 0:\n                    break\n        return self._text_word_break(length, truncate, text, truncate_len)\n\n    def _text_word_break(\n        self, length: int, truncate: str | None, text: str, truncate_len: int\n    ) -> str:\n        \"\"\"\n        Truncate a string after a given number of characters, cutting at the\n        most recent word break.\n\n        Args:\n            length (int): Maximum length of the resulting string, including any\n                truncation text.\n            truncate (str | None): The text to append when truncation occurs.\n            text (str): The normalized source string.\n            truncate_len (int): The effective content length budget after\n                subtracting the truncation text length.\n        Returns:\n            str: The original string if no truncation is needed; otherwise the\n            truncated string with truncation text appended.\n        \"\"\"\n        s_len = 0\n        end_index = None\n        for i, char in enumerate(text):\n            if unicodedata.combining(char):\n                # Do not count combining characters toward the visible length.\n                continue\n            s_len += 1\n            if end_index is None and s_len > truncate_len:\n                end_index = i\n            if s_len > length:\n                # Return the truncated string at the prior word boundary.\n                return add_truncation_text(\n                    \" \".join(text[: end_index or 0].split()[:-1]), truncate\n                )\n\n        # Return the original string since no truncation was necessary.\n        return text\n\n\n@register.filter(is_safe=True)\n@stringfilter\ndef truncatechars_on_word_break(value: str, arg: int | str) -> str:\n    \"\"\"\n    Truncate a string after a given number of characters, cutting at the most\n    recent word break.\n\n    Behavior:\n        - Counts only non-combining Unicode code points toward the limit.\n        - If truncation occurs, appends a truncation marker.\n        - Preserves whole words by backing up to the nearest word boundary.\n\n    Usage:\n        In a template:\n\n            {% load truncation %}\n            {{ long_text|truncatechars_on_word_break:120 }}\n\n        In Python:\n\n            truncatechars_on_word_break(\"alpha beta gamma\", 8)\n            # returns \"alpha […]\" (truncated at a word boundary)\n\n    Args:\n        value (str): The source text to truncate.\n        arg (int | str): Maximum length. If a string is provided, it is cast\n            to an integer. Invalid values cause the original text to be\n            returned unchanged.\n\n    Returns:\n        str: The truncated string, or the original string if no truncation is\n        needed or the argument is invalid.\n    \"\"\"\n    try:\n        length = int(arg)\n    except ValueError:\n        # Invalid literal for int(); fail silently and return original.\n        return value\n    return WordBreakTruncator(value).word_break(length, \"[…]\")\n"
  },
  {
    "path": "concordia/templatetags/visualization.py",
    "content": "# concordia/templatetags/visualization.py\n\nfrom django import template\nfrom django.utils.html import escape, format_html, format_html_join\nfrom django.utils.safestring import SafeString\n\nregister = template.Library()\n\n\n@register.simple_tag\ndef concordia_visualization(name: str, **attrs) -> SafeString:\n    \"\"\"\n    Render a container with a section and a canvas for a named visualization.\n\n    This tag outputs a `<div>` that always includes the\n    `visualization-container` class, wrapping a `<section>` with a `<canvas>`\n    whose `id` is set to the provided `name`. Any extra attributes passed to\n    the tag are applied to the outer `<div>` after being safely escaped.\n\n    Usage:\n        Load the tag library, then invoke the tag with a name and optional\n        HTML attributes.\n\n        Template:\n\n            {% load visualization %}\n            {% concordia_visualization \"daily-activity\"\n                style=\"float:left;\" class=\"chart\" data-role=\"viz\" %}\n\n        Output:\n\n            <div class=\"visualization-container chart\" style=\"float:left;\"\n                 data-role=\"viz\">\n                <section>\n                    <canvas id=\"daily-activity\"></canvas>\n                </section>\n            </div>\n\n        Notes:\n            - The `class` attribute you pass is appended to\n              `visualization-container`.\n            - All attribute names and values are escaped.\n            - This tag does not include any `<script>` tags. Visualization\n              scripts are included in the site-wide JavaScript rollup.\n\n    Args:\n        name (str): The slug used as the `id` of the `<canvas>` element.\n        **attrs: Any HTML attributes to apply to the outer `<div>` container.\n\n    Returns:\n        SafeString: Escaped HTML for the container, section, and canvas.\n    \"\"\"\n    # Ensure 'visualization-container' is always present in class attribute\n    user_classes = attrs.pop(\"class\", \"\")\n    combined_classes = \"visualization-container\"\n    if user_classes:\n        combined_classes += f\" {user_classes}\"\n    attrs[\"class\"] = combined_classes\n\n    # Build an attribute string like: key1=\"value1\" key2=\"value2\"\n    # Using format_html_join ensures that each key and value is properly escaped.\n    attr_items = ((escape(key), escape(value)) for key, value in attrs.items())\n    # format_html_join(' ', '{}=\"{}\"', attr_items) -> 'key1=\"value1\" key2=\"value2\"'\n    attrs_str = format_html_join(\" \", '{}=\"{}\"', attr_items)\n    # Prepend a space so that when we do '<div {attrs_str}>\n    # we get \"<div key=...>\"\n    attrs_str = format_html(\" {}\", attrs_str)\n\n    # Build the <div> + <section> + <canvas> line\n    # We use the section in order to be able to grow the\n    # canvas's container to fit the entire thing. We need\n    # the outer div to be able to add elements to our display\n    # (e.g., a csv) without resizing the section\n    # Use format_html so that {name} is escaped if necessary.\n    canvas_html = format_html(\n        \"<div{}>\" '<section><canvas id=\"{}\"></canvas></section>' \"</div>\",\n        attrs_str,\n        name,\n    )\n\n    # Because we used format_html, this is already safe.\n    return canvas_html\n"
  },
  {
    "path": "concordia/tests/README.md",
    "content": "# Concordia Tests\n\nThis directory contains tests for the concordia app. It\nuses Django TestCases, which will create a test database\nbefore running each test.\n\n## Pre-requisites\n\n-   Regarding Django TestCases, since these tests create a test database, the docker container with the db must be running — for example:\n\n    ```console\n    $ docker-compose up -d db\n    ```\n\n-   Use the settings module with defaults appropriate for testing:\n\n    ```console\n    $ export DJANGO_SETTINGS_MODULE=concordia.settings_test\n    ```\n\n    or\n\n    ```console\n    $ pipenv run manage.py test --settings=concordia.settings_test\n    ```\n\n## Running the tests\n\n-   To run all tests:\n\n    ```console\n    $ python manage.py test concordia\n    ```\n\n*   To run a single unittest module:\n\n    ```console\n    $ python manage.py test concordia.tests.test_view\n    ```\n\n*   To run a single unittest in a django unittest module:\n    ```console\n    $ python manage.py test\n    concordia.tests.test_view.ViewTest1.test_addition\n    ```\n"
  },
  {
    "path": "concordia/tests/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/tests/axe.py",
    "content": "# Code originally from\n# https://github.com/mozilla-services/axe-selenium-python/blob/3cfbdd67c9b40ab03f37b3ba2521f77c2071827b/axe_selenium_python/axe.py\n# This Source Code Form is subject to the terms of the Mozilla Public\n# License, v. 2.0. If a copy of the MPL was not distributed with this\n# file, You can obtain one at http://mozilla.org/MPL/2.0/.\n\nimport json\nimport os\nfrom io import open\n\nfrom django.conf import settings\n\n_DEFAULT_SCRIPT = settings.DEFAULT_AXE_SCRIPT or os.path.join(\n    os.path.dirname(__file__), \"node_modules\", \"axe-core\", \"axe.min.js\"\n)\n\n\nclass Axe:\n    def __init__(self, py, script_path=_DEFAULT_SCRIPT):\n        self.script_path = script_path\n        self.py = py\n\n    def violations(self, report=None):\n        \"\"\"\n        Injects aXe into the current document then runs it and returns\n        any violations found.\n\n        :param report: Whether to generate a report or not. Can be None,\n                       True or a string. If True, write_results is run with\n                       the default filename, otherwise used as the filename\n                       for write_results. If None or False, write_result is\n                       not called.\n        :type report: bool, str or None\n        :returns: Response from aXe\n        :rtype: Dict\n        \"\"\"\n        self.inject()\n        results = self.run()\n        if report:\n            if report is True:\n                self.write_results(results)\n            else:\n                self.write_results(results, report)\n        return results[\"violations\"]\n\n    def inject(self):\n        \"\"\"\n        Recursively inject aXe into all iframes and the top level document.\n        \"\"\"\n        with open(self.script_path, \"r\", encoding=\"utf8\") as f:\n            self.py.execute_script(f.read())\n\n    def run(self, context=None, options=None):\n        \"\"\"\n        Run axe against the current page.\n\n        :param context: which page part(s) to analyze and/or what to exclude.\n        :param options: dictionary of aXe options.\n        \"\"\"\n        template = (\n            \"var callback = arguments[arguments.length - 1];\"\n            + \"axe.run(%s).then(results => callback(results))\"\n        )\n        args = \"\"\n\n        # If context parameter is passed, add to args\n        if context is not None:\n            args += \"%r\" % context\n        # Add comma delimiter only if both parameters are passed\n        if context is not None and options is not None:\n            args += \",\"\n        # If options parameter is passed, add to args\n        if options is not None:\n            args += \"%s\" % options\n\n        command = template % args\n        response = self.py.execute_async_script(command)\n        return response\n\n    def report(self, violations):\n        \"\"\"\n        Return readable report of accessibility violations found.\n\n        :param violations: Dictionary of violations.\n        :type violations: dict\n        :return report: Readable report of violations.\n        :rtype: string\n        \"\"\"\n        string = \"\"\n        string += \"Found \" + str(len(violations)) + \" accessibility violations:\"\n        for violation in violations:\n            string += (\n                \"\\n\\n\\nRule Violated:\\n\"\n                + violation[\"id\"]\n                + \" - \"\n                + violation[\"description\"]\n                + \"\\n\\tURL: \"\n                + violation[\"helpUrl\"]\n                + \"\\n\\tImpact Level: \"\n                + violation[\"impact\"]\n                + \"\\n\\tTags:\"\n            )\n            for tag in violation[\"tags\"]:\n                string += \" \" + tag\n            string += \"\\n\\tElements Affected:\"\n            i = 1\n            for node in violation[\"nodes\"]:\n                for target in node[\"target\"]:\n                    string += \"\\n\\t\" + str(i) + \") Target: \" + target\n                    i += 1\n                for item in node[\"all\"]:\n                    string += \"\\n\\t\\t\" + item[\"message\"]\n                for item in node[\"any\"]:\n                    string += \"\\n\\t\\t\" + item[\"message\"]\n                for item in node[\"none\"]:\n                    string += \"\\n\\t\\t\" + item[\"message\"]\n            string += \"\\n\\n\\n\"\n        return string\n\n    def write_results(self, data, name=None):\n        \"\"\"\n        Write JSON to file with the specified name.\n\n        :param name: Path to the file to be written to. If no path is passed\n                     a new JSON file \"results.json\" will be created in the\n                     current working directory.\n        :param output: JSON object.\n        \"\"\"\n\n        if name:\n            filepath = os.path.abspath(name)\n        else:\n            filepath = os.path.join(os.path.getcwd(), \"results.json\")\n\n        with open(filepath, \"w\", encoding=\"utf8\") as f:\n            try:\n                f.write(unicode(json.dumps(data, indent=4)))\n            except NameError:\n                f.write(json.dumps(data, indent=4))\n"
  },
  {
    "path": "concordia/tests/data/site_reports.csv",
    "content": "created_on,time,campaign\n05/27/2024,10:13 PM UTC,1\n05/26/2024,10:12 PM UTC,\n"
  },
  {
    "path": "concordia/tests/test_account_views.py",
    "content": "\"\"\"\nTests for user account-related views\n\"\"\"\n\nfrom smtplib import SMTPException\nfrom unittest.mock import patch\n\nfrom django import forms\nfrom django.contrib.messages import get_messages\nfrom django.core import mail, signing\nfrom django.core.cache import cache\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\nfrom django.utils.timezone import now\n\nfrom concordia.models import ConcordiaUser, Transcription, User, UserProfileActivity\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import (\n    CacheControlAssertions,\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n    create_campaign,\n    create_transcription,\n)\n\n\n@override_settings(RATELIMIT_ENABLE=False)\nclass ConcordiaAccountViewTests(\n    CreateTestUsers, JSONAssertMixin, CacheControlAssertions, TestCase\n):\n    \"\"\"\n    This class contains the unit tests for the view in the concordia app.\n    \"\"\"\n\n    def setUp(self):\n        cache.clear()\n\n    def tearDown(self):\n        cache.clear()\n\n    def test_AccountProfileView_get(self):\n        \"\"\"\n        Test the http GET on route account/profile\n        \"\"\"\n\n        self.login_user()\n\n        response = self.client.get(reverse(\"user-profile\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n        self.assertTemplateUsed(response, template_name=\"account/profile.html\")\n        self.assertEqual(response.context[\"user\"], self.user)\n        self.assertContains(response, self.user.username)\n        self.assertContains(response, self.user.email)\n\n        response = self.client.get(reverse(\"user-profile\"), {\"activity\": \"transcribed\"})\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n        self.assertTemplateUsed(response, template_name=\"account/profile.html\")\n        self.assertEqual(response.context[\"user\"], self.user)\n        self.assertEqual(response.context[\"active_tab\"], \"recent\")\n\n        response = self.client.get(reverse(\"user-profile\"), {\"status\": \"submitted\"})\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n        self.assertTemplateUsed(response, template_name=\"account/profile.html\")\n        self.assertEqual(response.context[\"user\"], self.user)\n        self.assertEqual(response.context[\"active_tab\"], \"recent\")\n        self.assertEqual(response.context[\"status_list\"], [\"submitted\"])\n\n        response = self.client.get(\n            reverse(\"user-profile\"), {\"start\": \"1970-01-01\", \"end\": \"1970-01-02\"}\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n        self.assertTemplateUsed(response, template_name=\"account/profile.html\")\n        self.assertEqual(response.context[\"user\"], self.user)\n        self.assertEqual(response.context[\"end\"], \"1970-01-02\")\n        self.assertEqual(response.context[\"start\"], \"1970-01-01\")\n\n        anon = get_anonymous_user()\n        asset = create_asset()\n        t = asset.transcription_set.create(asset=asset, user=anon)\n        t.submitted = now()\n        t.accepted = now()\n        t.reviewed_by = self.user\n        t.save()\n        # when the transcription is saved, the handler should automatically\n        # create or updated the corresponding UserProfileActivity object\n        user_profile_activity, _ = UserProfileActivity.objects.get_or_create(\n            campaign=asset.item.project.campaign, user=self.user\n        )\n        user_profile_activity.review_count = 1\n        user_profile_activity.save()\n        response = self.client.get(reverse(\"user-profile\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n        self.assertTemplateUsed(response, template_name=\"account/profile.html\")\n        self.assertEqual(response.context[\"user\"], self.user)\n        self.assertEqual(response.context[\"totalReviews\"], 1)\n        self.assertEqual(response.context[\"totalCount\"], 1)\n\n    def test_AccountProfileView_post(self):\n        \"\"\"\n        This unit test tests the post entry for the route account/profile\n        :param self:\n        \"\"\"\n        test_email = \"tester2@example.com\"\n\n        self.login_user()\n\n        with self.settings(REQUIRE_EMAIL_RECONFIRMATION=False):\n            # First, test trying to 'update' to the already used email\n            response = self.client.post(\n                reverse(\"user-profile\"),\n                {\"email\": self.user.email, \"username\": \"tester\"},\n            )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"form\", response.context)\n        self.assertFalse(response.context[\"form\"].is_valid())\n\n        with self.settings(REQUIRE_EMAIL_RECONFIRMATION=False):\n            response = self.client.post(\n                reverse(\"user-profile\"), {\"email\": test_email, \"username\": \"tester\"}\n            )\n\n        self.assertEqual(response.status_code, 302)\n        self.assertUncacheable(response)\n        index = response.url.find(\"#\")\n        self.assertEqual(response.url[:index], reverse(\"user-profile\"))\n\n        # Verify the User was correctly updated\n        updated_user = User.objects.get(email=test_email)\n        self.assertEqual(updated_user.email, test_email)\n\n        # Test first/last name can be updated\n        self.assertNotEqual(updated_user.first_name, \"Test\")\n        self.assertNotEqual(updated_user.last_name, \"User\")\n        response = self.client.post(\n            reverse(\"user-profile\"),\n            {\"submit_name\": True, \"first_name\": \"Test\", \"last_name\": \"User\"},\n        )\n\n        self.assertRedirects(response, reverse(\"user-profile\"))\n        self.assertUncacheable(response)\n\n        updated_user = User.objects.get(email=test_email)\n        first_name = updated_user.first_name\n        last_name = updated_user.last_name\n        self.assertEqual(first_name, \"Test\")\n        self.assertEqual(last_name, \"User\")\n\n        # Test name form submission without valid data\n        # First/last names should stay the same after post\n        # The form can't really be invalid since even blank\n        # values just set the names to empty strings,\n        # so we need to mock an invalid response\n        with patch(\"concordia.forms.UserNameForm.is_valid\") as mock:\n            mock.return_value = False\n            response = self.client.post(reverse(\"user-profile\"), {\"submit_name\": True})\n        updated_user = User.objects.get(email=test_email)\n        self.assertEqual(updated_user.first_name, first_name)\n        self.assertEqual(updated_user.last_name, last_name)\n\n    def test_AccountProfileView_post_invalid_form(self):\n        \"\"\"\n        This unit test tests the post entry for the route account/profile but\n        submits an invalid form\n        \"\"\"\n        self.login_user()\n\n        response = self.client.post(reverse(\"user-profile\"), {\"first_name\": \"Jimmy\"})\n\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n\n        # Verify the User was not changed\n        updated_user = User.objects.get(id=self.user.id)\n        self.assertEqual(updated_user.first_name, \"\")\n\n    def test_ajax_session_status_anon(self):\n        response = self.client.get(reverse(\"ajax-session-status\"))\n        self.assertCachePrivate(response)\n        data = self.assertValidJSON(response)\n        self.assertEqual(data, {})\n\n    def test_ajax_session_status(self):\n        self.login_user()\n\n        response = self.client.get(reverse(\"ajax-session-status\"))\n        self.assertCachePrivate(response)\n        data = self.assertValidJSON(response)\n\n        self.assertIn(\"links\", data)\n        self.assertIn(\"username\", data)\n\n        self.assertEqual(data[\"username\"], self.user.username)\n\n        self.assertFalse(any(link[\"title\"] == \"Admin Area\" for link in data[\"links\"]))\n\n    def test_ajax_session_status_staff(self):\n        self.login_user(is_staff=True, is_superuser=True)\n\n        response = self.client.get(reverse(\"ajax-session-status\"))\n        self.assertCachePrivate(response)\n        data = self.assertValidJSON(response)\n\n        self.assertIn(\"links\", data)\n        self.assertIn(\"username\", data)\n\n        self.assertEqual(data[\"username\"], self.user.username)\n\n        self.assertTrue(any(link[\"title\"] == \"Admin Area\" for link in data[\"links\"]))\n\n    def test_ajax_messages(self):\n        self.login_user()\n\n        response = self.client.get(reverse(\"ajax-messages\"))\n        data = self.assertValidJSON(response)\n\n        self.assertIn(\"messages\", data)\n\n        # This view cannot be cached because the messages would be displayed\n        # multiple times:\n        self.assertUncacheable(response)\n\n    def test_email_reconfirmation(self):\n        self.login_user()\n        # Confirm the user doesn't have a reconfirmation key\n        concordia_user = ConcordiaUser.objects.get(id=self.user.id)\n        with self.assertRaises(ValueError):\n            concordia_user.get_email_reconfirmation_key()\n\n        with self.settings(REQUIRE_EMAIL_RECONFIRMATION=True):\n            email_data = {\"email\": \"change@example.com\"}\n            with patch(\"django.core.mail.EmailMultiAlternatives.send\") as mock:\n                mock.side_effect = SMTPException()\n                response = self.client.post(reverse(\"user-profile\"), email_data)\n                self.assertRedirects(\n                    response, \"{}#account\".format(reverse(\"user-profile\"))\n                )\n                messages = [\n                    str(message) for message in get_messages(response.wsgi_request)\n                ]\n                self.assertIn(\n                    \"Email confirmation could not be sent.\",\n                    messages,\n                )\n                self.assertEqual(len(mail.outbox), 0)\n\n            response = self.client.post(reverse(\"user-profile\"), email_data)\n            self.assertRedirects(response, \"{}#account\".format(reverse(\"user-profile\")))\n            self.assertTemplateUsed(response, \"emails/email_reconfirmation_subject.txt\")\n            self.assertTemplateUsed(response, \"emails/email_reconfirmation_body.txt\")\n            self.assertEqual(len(mail.outbox), 1)\n            mail.outbox = []\n\n            updated_user = User.objects.get(id=self.user.id)\n            self.assertNotEqual(updated_user.email, email_data[\"email\"])\n\n            concordia_user = ConcordiaUser.objects.get(id=self.user.id)\n\n            self.assertEqual(\n                concordia_user.get_email_for_reconfirmation(), email_data[\"email\"]\n            )\n            confirmation_key = concordia_user.get_email_reconfirmation_key()\n\n            # Check if user failing validation is handled\n            with patch(\"concordia.models.ConcordiaUser.full_clean\") as mock:\n                mock.side_effect = forms.ValidationError(\"Testing error\")\n                error_response = self.client.get(\n                    reverse(\n                        \"email-reconfirmation\",\n                        kwargs={\"confirmation_key\": confirmation_key},\n                    )\n                )\n                self.assertEqual(error_response.status_code, 403)\n                self.assertTemplateUsed(\n                    error_response, \"account/email_reconfirmation_failed.html\"\n                )\n\n            # Check if invalid data from confirmation key is handled\n            with patch(\"django.core.signing.loads\") as mock:\n                mock.return_value = {\n                    \"username\": \"bad-username\",\n                    \"email\": \"bad-email-address\",\n                }\n                error_response = self.client.get(\n                    reverse(\n                        \"email-reconfirmation\",\n                        kwargs={\"confirmation_key\": confirmation_key},\n                    )\n                )\n                self.assertEqual(error_response.status_code, 403)\n                self.assertTemplateUsed(\n                    error_response, \"account/email_reconfirmation_failed.html\"\n                )\n\n            # Check if signing errors are handled\n            with patch(\"django.core.signing.loads\") as mock:\n                mock.side_effect = signing.BadSignature()\n                error_response = self.client.get(\n                    reverse(\n                        \"email-reconfirmation\",\n                        kwargs={\"confirmation_key\": confirmation_key},\n                    )\n                )\n                self.assertEqual(error_response.status_code, 403)\n                self.assertTemplateUsed(\n                    error_response, \"account/email_reconfirmation_failed.html\"\n                )\n\n                mock.side_effect = signing.SignatureExpired()\n                error_response = self.client.get(\n                    reverse(\n                        \"email-reconfirmation\",\n                        kwargs={\"confirmation_key\": confirmation_key},\n                    )\n                )\n                self.assertEqual(error_response.status_code, 403)\n                self.assertTemplateUsed(\n                    error_response, \"account/email_reconfirmation_failed.html\"\n                )\n\n            confirmation_response = self.client.get(\n                reverse(\n                    \"email-reconfirmation\",\n                    kwargs={\"confirmation_key\": confirmation_key},\n                )\n            )\n            self.assertRedirects(\n                confirmation_response, \"{}#account\".format(reverse(\"user-profile\"))\n            )\n            updated_user = User.objects.get(id=self.user.id)\n            self.assertEqual(updated_user.email, email_data[\"email\"])\n\n            error_response = self.client.get(\n                reverse(\n                    \"email-reconfirmation\",\n                    kwargs={\"confirmation_key\": confirmation_key},\n                )\n            )\n            self.assertEqual(error_response.status_code, 403)\n            self.assertTemplateUsed(\n                error_response, \"account/email_reconfirmation_failed.html\"\n            )\n\n        with self.settings(REQUIRE_EMAIL_RECONFIRMATION=False):\n            email_data = {\"email\": \"change2@example.com\"}\n            response = self.client.post(reverse(\"user-profile\"), email_data)\n            self.assertRedirects(response, \"{}#account\".format(reverse(\"user-profile\")))\n            self.assertEqual(len(mail.outbox), 0)\n            updated_user = User.objects.get(id=self.user.id)\n            self.assertEqual(updated_user.email, email_data[\"email\"])\n\n    def test_account_letter(self):\n        self.login_user()\n\n        response = self.client.get(reverse(\"user-letter\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(\n            response[\"Content-Disposition\"], \"attachment; filename=letter.pdf\"\n        )\n        self.assertEqual(response[\"Content-Type\"], \"application/pdf\")\n\n    def test_get_pages(self):\n        self.login_user()\n        campaign = create_campaign()\n        url = reverse(\"get_pages\")\n\n        response = self.client.get(url, {\"activity\": \"transcribed\"})\n        self.assertEqual(response.status_code, 200)\n\n        response = self.client.get(\n            url, {\"activity\": \"reviewed\", \"order_by\": \"date-ascending\"}\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n\n        response = self.client.get(\n            url, {\"status\": [\"completed\"], \"campaign\": campaign.id}\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n\n        response = self.client.get(url, {\"status\": [\"in_progress\", \"submitted\"]})\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n\n        response = self.client.get(\n            url, kwargs={\"start\": \"1900-01-01\", \"end\": \"1999-12-31\"}\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n\n        response = self.client.get(url, {\"end\": \"1999-12-31\"})\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n\n        response = self.client.get(url, {\"start\": \"1900-01-01\", \"end\": \"1999-12-31\"})\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n\n    def test_AccountDeletionView(self):\n        self.login_user()\n\n        response = self.client.get(reverse(\"account-deletion\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertUncacheable(response)\n        self.assertTemplateUsed(response, template_name=\"account/account_deletion.html\")\n        self.assertEqual(response.context[\"user\"], self.user)\n\n        response = self.client.post(reverse(\"account-deletion\"))\n        self.assertRedirects(response, reverse(\"homepage\"))\n        with self.assertRaises(User.DoesNotExist):\n            User.objects.get(id=self.user.id)\n        self.assertEqual(len(mail.outbox), 1)\n\n        mail.outbox = []\n        self.user = None\n        self.login_user()\n        with patch(\"django.core.mail.EmailMultiAlternatives.send\") as mock:\n            mock.side_effect = SMTPException()\n            response = self.client.post(reverse(\"account-deletion\"))\n            self.assertRedirects(response, reverse(\"homepage\"))\n            messages = [str(message) for message in get_messages(response.wsgi_request)]\n            self.assertIn(\n                \"Email confirmation of deletion could not be sent.\",\n                messages,\n            )\n            self.assertEqual(len(mail.outbox), 0)\n\n        mail.outbox = []\n        self.user = None\n        self.login_user()\n        transcription = create_transcription(user=self.user)\n        response = self.client.post(reverse(\"account-deletion\"))\n        self.assertRedirects(response, reverse(\"homepage\"))\n        user = User.objects.get(id=self.user.id)\n        transcription = Transcription.objects.get(id=transcription.id)\n        self.assertEqual(transcription.user, user)\n        self.assertIn(\"Anonymized\", user.username)\n        self.assertEqual(user.first_name, \"\")\n        self.assertEqual(user.last_name, \"\")\n        self.assertEqual(user.email, \"\")\n        self.assertFalse(user.has_usable_password())\n        self.assertFalse(user.is_staff)\n        self.assertFalse(user.is_superuser)\n        self.assertFalse(user.is_active)\n        self.assertEqual(len(mail.outbox), 1)\n"
  },
  {
    "path": "concordia/tests/test_admin.py",
    "content": "import io\nimport zipfile\nfrom datetime import date, datetime\nfrom html import escape\nfrom unittest import mock\n\nfrom django.contrib import admin\nfrom django.contrib.admin.sites import AdminSite\nfrom django.contrib.auth.models import User\nfrom django.http import HttpResponse, HttpResponseRedirect\nfrom django.test import RequestFactory, TestCase\nfrom django.urls import reverse\nfrom django.utils import timezone\nfrom django.utils.safestring import SafeString\nfrom faker import Faker\n\nfrom concordia.admin import (\n    AssetAdmin,\n    CampaignAdmin,\n    CampaignRetirementProgressAdmin,\n    ConcordiaFileAdmin,\n    ConcordiaUserAdmin,\n    ItemAdmin,\n    KeyMetricsReportAdmin,\n    ProjectAdmin,\n    SiteReportAdmin,\n    TagAdmin,\n    TranscriptionAdmin,\n)\nfrom concordia.models import (\n    Asset,\n    Campaign,\n    CampaignRetirementProgress,\n    ConcordiaFile,\n    Item,\n    KeyMetricsReport,\n    Project,\n    SiteReport,\n    Tag,\n    Transcription,\n)\nfrom concordia.tests.utils import (\n    CreateTestUsers,\n    StreamingTestMixin,\n    create_asset,\n    create_project,\n    create_site_report,\n    create_tag_collection,\n    create_topic,\n    create_transcription,\n)\n\n\nclass ConcordiaUserAdminTest(TestCase, CreateTestUsers, StreamingTestMixin):\n    def setUp(self):\n        self.site = AdminSite()\n        self.user = self.create_test_user()\n        self.super_user = self.create_super_user()\n        self.asset = create_asset()\n        self.user_admin = ConcordiaUserAdmin(model=User, admin_site=self.site)\n        self.request_factory = RequestFactory()\n\n    def test_transcription_count(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n        users = self.user_admin.get_queryset(request)\n        user = users.get(username=self.user.username)\n        transcription_count = self.user_admin.transcription_count(user)\n        self.assertEqual(transcription_count, 0)\n\n        create_transcription(asset=self.asset, user=user)\n        user = users.get(username=self.user.username)\n        user.profile.transcribe_count = 1\n        user.profile.save()\n        transcription_count = self.user_admin.transcription_count(user)\n        self.assertEqual(transcription_count, 1)\n\n    def test_review_count(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n        users = self.user_admin.get_queryset(request)\n        user = users.get(username=self.user.username)\n        review_count = self.user_admin.review_count(user)\n        self.assertEqual(review_count, 0)\n\n        transcription = create_transcription(\n            asset=self.asset, user=self.super_user, submitted=timezone.now()\n        )\n        transcription.accepted = timezone.now()\n        transcription.reviewed_by = self.user\n        transcription.save()\n        user = users.get(username=self.user.username)\n        user.profile.review_count = 1\n        user.profile.save()\n        review_count = self.user_admin.review_count(user)\n        self.assertEqual(review_count, 1)\n\n    def test_csv_export(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n        # TODO: Fix this to mock date_joined rather than removing it\n        self.user_admin.EXPORT_FIELDS = [\n            field for field in self.user_admin.EXPORT_FIELDS if field != \"date_joined\"\n        ]\n        response = self.user_admin.export_users_as_csv(\n            request, self.user_admin.get_queryset(request)\n        )\n        content = self.get_streaming_content(response).split(b\"\\r\\n\")\n        self.assertEqual(len(content), 4)  # Includes empty line at the end of the file\n        test_data = [\n            b\"username,email address,first name,last name,active,staff status,\"\n            + b\"superuser status,last login,transcription count,review count\",\n            b\"testsuperuser,testsuperuser@example.com,,,True,True,True,,0,0\",\n            b\"testuser,testuser@example.com,,,True,False,False,,0,0\",\n            b\"\",\n        ]\n        self.assertEqual(content, test_data)\n\n    def test_excel_export(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n        response = self.user_admin.export_users_as_excel(\n            request, self.user_admin.get_queryset(request)\n        )\n        # TODO: Test contents of file (requires a library to read xlsx files)\n        self.assertNotEqual(len(response.content), 0)\n\n\nclass CampaignAdminTest(TestCase, CreateTestUsers, StreamingTestMixin):\n    def setUp(self):\n        self.site = AdminSite()\n        self.user = self.create_test_user()\n        self.staff_user = self.create_staff_user()\n        self.super_user = self.create_super_user()\n        self.asset = create_asset()\n        self.campaign = self.asset.item.project.campaign\n        self.campaign_admin = CampaignAdmin(model=Campaign, admin_site=self.site)\n        self.fake = Faker()\n        self.request_factory = RequestFactory()\n\n    def test_truncated_description(self):\n        self.campaign.description = \"\"\n        self.assertEqual(self.campaign_admin.truncated_description(self.campaign), \"\")\n        self.campaign.description = self.fake.text()\n        truncated_description = self.campaign_admin.truncated_metadata(self.campaign)\n        self.assertIn(truncated_description, self.campaign.description)\n\n    def test_truncated_metadata(self):\n        self.campaign.metadata = {}\n        self.assertEqual(self.campaign_admin.truncated_metadata(self.campaign), \"\")\n        self.campaign.metadata[self.fake.unique.word()] = self.fake.text()\n        truncated_metadata = self.campaign_admin.truncated_metadata(self.campaign)\n        self.assertIs(type(truncated_metadata), SafeString)\n        self.assertRegex(truncated_metadata, r\"<code>.*</code>\")\n\n    def test_retire(self):\n        self.client.force_login(self.staff_user)\n        response = self.client.get(\n            reverse(\n                \"admin:concordia_campaign_retire\",\n                args=[\n                    self.campaign.slug,\n                ],\n            )\n        )\n        self.assertEqual(response.status_code, 403)\n\n        self.client.logout()\n        self.client.force_login(self.super_user)\n        response = self.client.get(\n            reverse(\n                \"admin:concordia_campaign_retire\", args=[self.campaign.slug + \"bad\"]\n            )\n        )\n        self.assertEqual(response.status_code, 302)\n\n        response = self.client.get(\n            reverse(\"admin:concordia_campaign_retire\", args=[self.campaign.slug])\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"admin/concordia/campaign/retire.html\"\n        )\n        self.assertContains(response, \"Are you sure?\")\n\n        response = self.client.post(\n            reverse(\"admin:concordia_campaign_retire\", args=[self.campaign.slug]),\n            {\"post\": \"yes\"},\n        )\n        self.assertEqual(response.status_code, 302)\n        campaign = Campaign.objects.get(pk=self.campaign.pk)\n        self.assertEqual(campaign.status, Campaign.Status.RETIRED)\n\n    def test_campaign_admin(self):\n        self.client.force_login(self.super_user)\n        response = self.client.get(reverse(\"admin:concordia_campaign_add\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"form\")\n        self.assertContains(response, \"Display on homepage\")\n        self.assertContains(response, \"Next transcription campaign\")\n        self.assertContains(response, \"Next review campaign\")\n\n\nclass HelpfulLinkAdminTest(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.super_user = self.create_super_user()\n\n    def test_helpfullink_admin(self):\n        self.client.force_login(self.super_user)\n        response = self.client.get(reverse(\"admin:concordia_helpfullink_add\"))\n        self.assertEqual(response.status_code, 200)\n\n\nclass ConcordiaFileAdminTest(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.site = AdminSite()\n        self.staff_user = self.create_staff_user()\n        self.super_user = self.create_super_user()\n        self.concordia_file_admin = ConcordiaFileAdmin(\n            model=ConcordiaFile, admin_site=self.site\n        )\n        self.request_factory = RequestFactory()\n\n    def test_link_url(self):\n        class MockFile:\n            url = \"http://example.com?arg=true\"\n\n        class MockConcordiaFile:\n            uploaded_file = MockFile()\n\n        result = self.concordia_file_admin.file_url(MockConcordiaFile())\n        self.assertEqual(result, \"http://example.com\")\n\n    def test_get_fields(self):\n        request = self.request_factory.get(\"/\")\n        result = self.concordia_file_admin.get_fields(request)\n        self.assertNotIn(\"path\", result)\n        self.assertNotIn(\"file_url\", result)\n\n        result = self.concordia_file_admin.get_fields(request, object())\n        self.assertNotIn(\"path\", result)\n        self.assertIn(\"file_url\", result)\n\n\nclass ProjectAdminTest(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.site = AdminSite()\n        self.super_user = self.create_super_user()\n        self.staff_user = self.create_staff_user()\n        self.project_admin = ProjectAdmin(model=Project, admin_site=self.site)\n        self.project = create_project()\n        self.url_lookup = \"admin:concordia_project_item-import\"\n\n    def test_lookup_allowed(self):\n        self.assertTrue(self.project_admin.lookup_allowed(\"campaign__id__exact\", 0))\n        self.assertTrue(self.project_admin.lookup_allowed(\"campaign\", 0))\n        self.assertFalse(self.project_admin.lookup_allowed(\"campaign__slug__exact\", 0))\n\n    def test_item_import_view(self):\n        self.client.force_login(self.staff_user)\n        response = self.client.get(reverse(self.url_lookup, args=[self.project.id]))\n        self.assertEqual(response.status_code, 403)\n        self.client.logout()\n\n        self.client.force_login(self.super_user)\n        response = self.client.get(reverse(self.url_lookup, args=[self.project.id + 1]))\n        self.assertEqual(response.status_code, 404)\n\n        response = self.client.get(reverse(self.url_lookup, args=[self.project.id]))\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"admin/concordia/project/item_import.html\"\n        )\n\n        self.client.post(\n            reverse(self.url_lookup, args=[self.project.id]),\n            {\"bad_param\": \"https://example.com\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"admin/concordia/project/item_import.html\"\n        )\n\n        with self.assertRaises(ValueError):\n            self.client.post(\n                reverse(self.url_lookup, args=[self.project.id]),\n                {\"import_url\": \"https://example.com\"},\n            )\n\n        with mock.patch(\n            \"importer.tasks.items.create_item_import_task.delay\"\n        ) as task_mock:\n            response = self.client.post(\n                reverse(self.url_lookup, args=[self.project.id]),\n                {\"import_url\": \"https://www.loc.gov/item/example\"},\n            )\n            self.assertTrue(task_mock.called)\n\n        with mock.patch(\n            \"importer.tasks.collections.import_collection_task.delay\"\n        ) as task_mock:\n            response = self.client.post(\n                reverse(self.url_lookup, args=[self.project.id]),\n                {\"import_url\": \"https://www.loc.gov/collections/example/\"},\n            )\n            self.assertTrue(task_mock.called)\n\n\nclass ItemAdminTest(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.site = AdminSite()\n        self.super_user = self.create_super_user()\n        self.staff_user = self.create_staff_user()\n        self.user = self.create_test_user()\n        self.admin = ItemAdmin(model=Item, admin_site=self.site)\n        self.asset = create_asset()\n        self.item = self.asset.item\n        create_transcription(asset=self.asset, user=self.user)\n        self.request_factory = RequestFactory()\n\n    def test_lookup_allowed(self):\n        self.assertTrue(self.admin.lookup_allowed(\"project__campaign__id__exact\", 0))\n        self.assertFalse(self.admin.lookup_allowed(\"project__campaign\", 0))\n        self.assertFalse(self.admin.lookup_allowed(\"project__campaign__slug__exact\", 0))\n\n    def test_get_deleted_objects(self):\n        mock_objs = range(0, 50)\n        request = self.request_factory.get(\"/\")\n\n        request.user = self.staff_user\n        deleted_objects, model_count, perms_needed, protected = (\n            self.admin.get_deleted_objects(mock_objs, request)\n        )\n        self.assertEqual(len(deleted_objects), 4)\n        self.assertEqual(model_count, {\"items\": 50, \"assets\": 1, \"transcriptions\": 1})\n        self.assertNotEqual(perms_needed, set())\n        self.assertEqual(protected, [])\n\n        request.user = self.super_user\n        deleted_objects, model_count, perms_needed, protected = (\n            self.admin.get_deleted_objects(mock_objs, request)\n        )\n        self.assertEqual(len(deleted_objects), 4)\n        self.assertEqual(model_count, {\"items\": 50, \"assets\": 1, \"transcriptions\": 1})\n        self.assertEqual(perms_needed, set())\n        self.assertEqual(protected, [])\n\n        deleted_objects, model_count, perms_needed, protected = (\n            self.admin.get_deleted_objects([self.item], request)\n        )\n        self.assertEqual(len(deleted_objects), 1)\n        self.assertEqual(model_count, {\"items\": 1, \"assets\": 1, \"transcriptions\": 1})\n        self.assertEqual(perms_needed, set())\n        self.assertEqual(protected, [])\n\n    def test_get_queryset(self):\n        request = self.request_factory.get(\"/\")\n        qs = self.admin.get_queryset(request)\n        self.assertEqual(qs.count(), 1)\n\n    def test_campaign_title(self):\n        self.assertEqual(\n            self.item.project.campaign.title, self.admin.campaign_title(self.item)\n        )\n\n\nclass AssetAdminTest(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.site = AdminSite()\n        self.super_user = self.create_super_user()\n        self.staff_user = self.create_staff_user()\n        self.user = self.create_test_user()\n        self.admin = AssetAdmin(model=Asset, admin_site=self.site)\n        self.asset = create_asset()\n        create_transcription(asset=self.asset, user=self.user)\n        self.request_factory = RequestFactory()\n\n    def test_get_queryset(self):\n        request = self.request_factory.get(\"/\")\n        qs = self.admin.get_queryset(request)\n        self.assertEqual(qs.count(), 1)\n\n    def test_lookup_allowed(self):\n        self.assertTrue(self.admin.lookup_allowed(\"item__project__id__exact\", 0))\n        self.assertTrue(\n            self.admin.lookup_allowed(\"item__project__campaign__id__exact\", 0)\n        )\n        self.assertFalse(self.admin.lookup_allowed(\"item__project\", 0))\n\n    def test_item_id(self):\n        self.assertEqual(self.asset.item.item_id, self.admin.item_id(self.asset))\n\n    def test_truncated_storage_image(self):\n        truncated_url = self.admin.truncated_storage_image(self.asset)\n        filename = self.asset.get_existing_storage_image_filename()\n        self.assertEqual(truncated_url.count(filename), 2)\n\n        self.asset.storage_image.name = \"\".join([str(i) for i in range(200)])\n        truncated_url = self.admin.truncated_storage_image(self.asset)\n        filename = self.asset.get_existing_storage_image_filename()\n        self.assertEqual(truncated_url.count(filename), 1)\n        self.assertEqual(truncated_url.count(filename[:99]), 2)\n\n    def test_get_readonly_fields(self):\n        request = self.request_factory.get(\"/\")\n        self.assertNotIn(\"item\", self.admin.get_readonly_fields(request))\n        self.assertIn(\"item\", self.admin.get_readonly_fields(request, self.asset))\n\n    def test_change_view(self):\n        self.client.force_login(self.super_user)\n        response = self.client.get(\n            reverse(\"admin:concordia_asset_change\", args=[self.asset.id])\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"admin/concordia/asset/change_form.html\"\n        )\n\n    def test_has_reopen_permission(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n        self.admin.has_reopen_permission(request)\n\n        request.user = self.staff_user\n        self.admin.has_reopen_permission(request)\n\n    def test_response_action_redirects_with_valid_next(self):\n        request = self.request_factory.post(\n            reverse(\"admin:concordia_asset_changelist\"),\n            data={\"next\": \"/admin/\"},\n        )\n        request._messages = mock.MagicMock()\n        request.user = self.super_user\n\n        queryset = Asset.objects.all()\n        admin_instance = AssetAdmin(Asset, self.site)\n        admin_instance.get_actions = mock.MagicMock(return_value={})\n        response = admin_instance.response_action(request, queryset)\n\n        self.assertIsInstance(response, HttpResponseRedirect)\n        self.assertEqual(response.url, \"/admin/\")\n\n    def test_response_action_falls_back_to_default_without_valid_next(self):\n        request = self.request_factory.post(\n            reverse(\"admin:concordia_asset_changelist\"),\n            data={\"next\": \"https://example.com/malicious\"},\n        )\n        request._messages = mock.MagicMock()\n        request.user = self.super_user\n\n        queryset = Asset.objects.all()\n        admin_instance = AssetAdmin(Asset, self.site)\n\n        fallback_response = HttpResponseRedirect(\"/default/\")\n        with mock.patch.object(\n            admin.ModelAdmin, \"response_action\", return_value=fallback_response\n        ):\n            response = admin_instance.response_action(request, queryset)\n\n        self.assertEqual(response.url, \"/default/\")\n\n    def test_change_view_skips_asset_logic_when_no_object_id(self):\n        request = self.request_factory.get(\"/admin/concordia/asset/add/\")\n        request.user = self.super_user\n\n        admin_instance = AssetAdmin(Asset, self.site)\n\n        with mock.patch.object(\n            admin.ModelAdmin, \"change_view\", return_value=HttpResponse(\"OK\")\n        ) as mock_super_change_view:\n            response = admin_instance.change_view(request, object_id=None)\n\n        self.assertEqual(response.status_code, 200)\n        mock_super_change_view.assert_called_once()\n\n    def test_change_view_handles_submitted_status_as_needs_review(self):\n        asset = create_asset(\n            item=self.asset.item, slug=\"test-asset-2\", transcription_status=\"submitted\"\n        )\n        request = self.request_factory.get(\n            reverse(\"admin:concordia_asset_change\", args=[asset.pk])\n        )\n        request.user = self.super_user\n\n        admin_instance = AssetAdmin(Asset, self.site)\n\n        with mock.patch.object(admin_instance, \"get_actions\") as mock_get_actions:\n            mock_get_actions.return_value = {\n                \"change_status_to_completed\": (\n                    \"func\",\n                    None,\n                    \"Change status to Completed\",\n                ),\n                \"change_status_to_needs_review\": (\n                    \"func\",\n                    None,\n                    \"Change status to Needs Review\",\n                ),\n                \"change_status_to_in_progress\": (\n                    \"func\",\n                    None,\n                    \"Change status to In Progress\",\n                ),\n            }\n\n            with mock.patch.object(\n                admin.ModelAdmin, \"change_view\", return_value=HttpResponse(\"OK\")\n            ) as mock_super_change_view:\n                response = admin_instance.change_view(request, str(asset.pk))\n\n        self.assertEqual(response.status_code, 200)\n        mock_super_change_view.assert_called_once()\n\n    def test_response_action_returns_default_when_no_next_url(self):\n        request = self.request_factory.post(\n            reverse(\"admin:concordia_asset_changelist\"),\n            data={},\n        )\n        request._messages = mock.MagicMock()\n        request.user = self.super_user\n\n        queryset = Asset.objects.all()\n        admin_instance = AssetAdmin(Asset, self.site)\n\n        default_response = HttpResponseRedirect(\"/default/\")\n        with mock.patch.object(\n            admin.ModelAdmin, \"response_action\", return_value=default_response\n        ) as mock_super_response_action:\n            response = admin_instance.response_action(request, queryset)\n\n        mock_super_response_action.assert_called_once_with(request, queryset)\n        self.assertEqual(response, default_response)\n        self.assertEqual(response.url, \"/default/\")\n\n\nclass TagAdminTest(TestCase, CreateTestUsers, StreamingTestMixin):\n    def setUp(self):\n        self.site = AdminSite()\n        self.super_user = self.create_super_user()\n        self.user = self.create_test_user()\n        self.admin = TagAdmin(model=Tag, admin_site=self.site)\n        self.request_factory = RequestFactory()\n\n    def test_lookup_allowed(self):\n        self.assertTrue(\n            self.admin.lookup_allowed(\n                \"userassettagcollection__asset__item__project__campaign__id__exact\", 0\n            )\n        )\n        self.assertTrue(self.admin.lookup_allowed(\"id\", 0))\n        self.assertFalse(self.admin.lookup_allowed(\"userassettagcollection__asset\", 0))\n\n    def test_export_tags_as_csv(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n        mocked_datetime = timezone.now()\n        with mock.patch(\"django.utils.timezone.now\") as now_mocked:\n            now_mocked.return_value = mocked_datetime\n            self.collection = create_tag_collection(user=self.user)\n\n        response = self.admin.export_tags_as_csv(\n            request, self.admin.get_queryset(request)\n        )\n        content = self.get_streaming_content(response).split(b\"\\r\\n\")\n        self.assertEqual(len(content), 3)  # Includes empty line at the end of the file\n        test_data = [\n            b\"tag value,user asset tag collection date created,\"\n            + b\"user asset tag collection user_id,asset id,asset title,\"\n            + b\"asset download url,asset resource url,campaign slug\",\n            b\"tag-value,%s,%i,%i,Test Asset,,,test-campaign\"\n            % (\n                str.encode(mocked_datetime.isoformat()),\n                self.user.id,\n                self.collection.asset.id,\n            ),\n            b\"\",\n        ]\n        self.assertEqual(content, test_data)\n\n\nclass TranscriptionAdminTest(TestCase, CreateTestUsers, StreamingTestMixin):\n    def setUp(self):\n        self.site = AdminSite()\n        self.super_user = self.create_super_user()\n        self.user = self.create_test_user()\n        self.asset = create_asset()\n        self.mocked_datetime = timezone.now()\n        self.mocked_datetime_formatted = self.mocked_datetime.isoformat()\n        with mock.patch(\"django.utils.timezone.now\") as now_mocked:\n            now_mocked.return_value = self.mocked_datetime\n            self.transcription = create_transcription(asset=self.asset, user=self.user)\n        self.admin = TranscriptionAdmin(model=Transcription, admin_site=self.site)\n        self.request_factory = RequestFactory()\n        self.fake = Faker()\n\n    def test_lookup_allowed(self):\n        self.assertTrue(\n            self.admin.lookup_allowed(\"asset__item__project__campaign__id__exact\", 0)\n        )\n        self.assertTrue(self.admin.lookup_allowed(\"id\", 0))\n        self.assertFalse(\n            self.admin.lookup_allowed(\"asset__item__project__id__exact\", 0)\n        )\n\n    def test_truncated_text(self):\n        self.transcription.text = self.fake.text(50)\n        result = self.admin.truncated_text(self.transcription)\n        self.assertEqual(result, self.transcription.text)\n\n        self.transcription.text = self.fake.text(500)\n        result = self.admin.truncated_text(self.transcription)\n        self.assertNotEqual(result, self.transcription.text)\n        self.assertIn(result[:-1], self.transcription.text)\n\n    def test_export_to_csv(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n\n        response = self.admin.export_to_csv(request, self.admin.get_queryset(request))\n        content = self.get_streaming_content(response).split(b\"\\r\\n\")\n        self.assertEqual(len(content), 3)\n        test_data = [\n            b\"ID,asset__id,asset__slug,user,created on,updated on,supersedes,\"\n            + b\"submitted,accepted,rejected,reviewed by,text,ocr generated,\"\n            + b\"ocr originated\",\n            b\"%i,%i,%s,%i,%s,%s,,,,,,,False,False\"\n            % (\n                self.transcription.id,\n                self.transcription.asset.id,\n                str.encode(self.transcription.asset.slug),\n                self.user.id,\n                str.encode(self.mocked_datetime_formatted),\n                str.encode(self.mocked_datetime_formatted),\n            ),\n            b\"\",\n        ]\n        self.assertEqual(content, test_data)\n\n    def test_export_to_excel(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n        response = self.admin.export_to_excel(request, self.admin.get_queryset(request))\n        # TODO: Test contents of file (requires a library to read xlsx files)\n        self.assertNotEqual(len(response.content), 0)\n\n    def test_show_full_result_count_is_disabled(self):\n        self.assertFalse(self.admin.show_full_result_count)\n\n    def test_list_display_includes_superseded(self):\n        self.assertIn(\"superseded\", self.admin.list_display)\n\n    def test_list_filter_includes_superseded_param(self):\n        params = {\n            getattr(f, \"parameter_name\", None)\n            for f in self.admin.list_filter\n            if hasattr(f, \"parameter_name\")\n        }\n        self.assertIn(\"superseded\", params)\n\n    def test_get_queryset_adds_is_superseded_annotation(self):\n        base = create_transcription(asset=self.asset, user=self.user, text=\"base\")\n        superseding = create_transcription(\n            asset=self.asset, user=self.user, supersedes=base, text=\"superseding\"\n        )\n        request = self.request_factory.get(\"/\")\n        qs = self.admin.get_queryset(request).filter(pk__in=[base.pk, superseding.pk])\n        by_id = {t.pk: t for t in qs}\n        self.assertIn(base.pk, by_id)\n        self.assertIn(superseding.pk, by_id)\n        self.assertTrue(hasattr(by_id[base.pk], \"is_superseded\"))\n        self.assertTrue(by_id[base.pk].is_superseded)\n        self.assertFalse(by_id[superseding.pk].is_superseded)\n\n    def test_superseded_column_uses_annotation_boolean(self):\n        base = create_transcription(asset=self.asset, user=self.user, text=\"base2\")\n        superseding = create_transcription(\n            asset=self.asset, user=self.user, supersedes=base, text=\"superseding2\"\n        )\n        request = self.request_factory.get(\"/\")\n        qs = self.admin.get_queryset(request).filter(pk__in=[base.pk, superseding.pk])\n        by_id = {t.pk: t for t in qs}\n        self.assertTrue(self.admin.superseded(by_id[base.pk]))\n        self.assertFalse(self.admin.superseded(by_id[superseding.pk]))\n\n\nclass SiteReportAdminTest(TestCase, CreateTestUsers, StreamingTestMixin):\n    def setUp(self):\n        self.site = AdminSite()\n        self.super_user = self.create_super_user()\n        self.mocked_datetime = timezone.now()\n        self.mocked_datetime_formatted = self.mocked_datetime.isoformat()\n        with mock.patch(\"django.utils.timezone.now\") as now_mocked:\n            now_mocked.return_value = self.mocked_datetime\n            self.site_report = create_site_report()\n        self.topic = create_topic()\n        self.campaign = self.topic.project_set.all()[0].campaign\n        self.admin = SiteReportAdmin(model=SiteReport, admin_site=self.site)\n        self.request_factory = RequestFactory()\n        self.fake = Faker()\n\n    def test_report_type(self):\n        self.site_report.report_name = \"Test name\"\n        self.site_report.campaign = self.campaign\n        self.site_report.topic = self.topic\n\n        response = self.admin.report_type(self.site_report)\n        self.assertIn(\"Report name\", response)\n\n        self.site_report.report_name = \"\"\n        response = self.admin.report_type(self.site_report)\n        self.assertIn(\"Campaign\", response)\n\n        self.site_report.campaign = None\n        response = self.admin.report_type(self.site_report)\n        self.assertIn(\"Topic\", response)\n\n        self.site_report.topic = None\n        response = self.admin.report_type(self.site_report)\n        self.assertIn(\"SiteReport\", response)\n\n    def test_export_to_csv(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n\n        response = self.admin.export_to_csv(request, self.admin.get_queryset(request))\n        content = self.get_streaming_content(response).split(b\"\\r\\n\")\n        self.assertEqual(len(content), 3)  # Includes empty line at the end of the file\n\n        test_data = [\n            b\"created on,report name,campaign__title,topic__title,assets total,\"\n            + b\"assets published,assets not started,assets in progress,\"\n            + b\"assets waiting review,assets completed,assets unpublished,\"\n            + b\"assets started,items published,items unpublished,projects published,\"\n            + b\"projects unpublished,anonymous transcriptions,transcriptions saved,\"\n            + b\"daily review actions,distinct tags,tag uses,campaigns published,\"\n            + b\"campaigns unpublished,users registered,users activated,\"\n            + b\"registered contributors,daily active users\",\n            b\"%s,,,,,,,,,,,,,,,,,,,,,,,,,,\"\n            % str.encode(self.mocked_datetime_formatted),\n            b\"\",\n        ]\n        self.assertEqual(content, test_data)\n\n    def test_export_to_excel(self):\n        request = self.request_factory.get(\"/\")\n        request.user = self.super_user\n        response = self.admin.export_to_excel(request, self.admin.get_queryset(request))\n        # TODO: Test contents of file (requires a library to read xlsx files)\n        self.assertNotEqual(len(response.content), 0)\n\n    def test_report_type_variants(self):\n        # Report name present\n        s1 = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        text = self.admin.report_type(s1)\n        self.assertIn(\"Report name\", text)\n\n        # Campaign present, no report name\n        s2 = SiteReport.objects.create(campaign=self.campaign, report_name=\"\")\n        text = self.admin.report_type(s2)\n        self.assertIn(\"Campaign\", text)\n        self.assertIn(self.campaign.title, text)\n\n        # Topic present, no report name or campaign\n        s3 = SiteReport.objects.create(topic=self.topic, report_name=\"\", campaign=None)\n        text = self.admin.report_type(s3)\n        self.assertIn(\"Topic\", text)\n        self.assertIn(self.topic.title, text)\n\n        # None of the above\n        s4 = SiteReport.objects.create(report_name=\"\", campaign=None, topic=None)\n        text = self.admin.report_type(s4)\n        self.assertIn(\"SiteReport:\", text)\n        self.assertIn(str(s4.id), text)\n\n    def test_report_json_pretty_wrap(self):\n        s = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        with mock.patch.object(SiteReport, \"to_debug_json\", return_value='{\"a\":1}'):\n            html = self.admin.report_json(s)\n        self.assertIn(\"<pre\", html)\n        self.assertIn(\"</pre>\", html)\n        self.assertIn(escape('{\"a\":1}'), html)\n\n    def test_previous_and_next_in_series_links(self):\n        # Build a small series of TOTAL snapshots with known timestamps.\n        tz = timezone.get_current_timezone()\n        t1 = timezone.make_aware(datetime(2024, 1, 1, 10, 0, 0), tz)\n        t2 = timezone.make_aware(datetime(2024, 1, 1, 11, 0, 0), tz)\n        t3 = timezone.make_aware(datetime(2024, 1, 1, 12, 0, 0), tz)\n\n        a = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        b = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        c = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n\n        # Set exact created_on values\n        SiteReport.objects.filter(pk=a.pk).update(created_on=t1)\n        SiteReport.objects.filter(pk=b.pk).update(created_on=t2)\n        SiteReport.objects.filter(pk=c.pk).update(created_on=t3)\n\n        # Refresh from DB to get updated created_on\n        a = SiteReport.objects.get(pk=a.pk)\n        b = SiteReport.objects.get(pk=b.pk)\n        c = SiteReport.objects.get(pk=c.pk)\n\n        # Middle record should link back to 'a' and forward to 'c'\n        prev_html = self.admin.previous_in_series_link(b)\n        next_html = self.admin.next_in_series_link(b)\n\n        expected_prev_url = reverse(\n            f\"admin:{a._meta.app_label}_{a._meta.model_name}_change\", args=[a.pk]\n        )\n        expected_prev_label = f\"{a.created_on:%Y-%m-%d %H:%M:%S} (id {a.pk})\"\n        self.assertIn(expected_prev_url, prev_html)\n        self.assertIn(expected_prev_label, prev_html)\n\n        expected_next_url = reverse(\n            f\"admin:{c._meta.app_label}_{c._meta.model_name}_change\", args=[c.pk]\n        )\n        expected_next_label = f\"{c.created_on:%Y-%m-%d %H:%M:%S} (id {c.pk})\"\n        self.assertIn(expected_next_url, next_html)\n        self.assertIn(expected_next_label, next_html)\n\n        # Edge cases: first has no previous, last has no next\n        self.assertEqual(self.admin.previous_in_series_link(a), \"—\")\n        self.assertEqual(self.admin.next_in_series_link(c), \"—\")\n\n\nclass CampaignRetirementProgressAdminTest(TestCase):\n    def setUp(self):\n        class MockCompletion:\n            complete = False\n\n            project_total = 0\n            item_total = 0\n            asset_total = 0\n\n            projects_removed = 0\n            items_removed = 0\n            assets_removed = 0\n\n        self.completion_obj = MockCompletion()\n\n        self.site = AdminSite()\n        self.admin = CampaignRetirementProgressAdmin(\n            model=CampaignRetirementProgress, admin_site=self.site\n        )\n\n    def test_completion(self):\n        self.completion_obj.complete = True\n        self.assertEqual(self.admin.completion(self.completion_obj), \"100%\")\n        self.completion_obj.complete = False\n\n        self.completion_obj.project_total = 10\n        self.completion_obj.item_total = 100\n        self.completion_obj.asset_total = 1000\n        self.assertEqual(self.admin.completion(self.completion_obj), \"0.0%\")\n\n        self.completion_obj.projects_removed = 1\n        self.assertEqual(self.admin.completion(self.completion_obj), \"0.09%\")\n\n        self.completion_obj.items_removed = 10\n        self.completion_obj.assets_removed = 100\n        self.assertEqual(self.admin.completion(self.completion_obj), \"10.0%\")\n\n\nclass KeyMetricsReportAdminTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.site = AdminSite()\n        self.admin = KeyMetricsReportAdmin(model=KeyMetricsReport, admin_site=self.site)\n        self.request_factory = RequestFactory()\n        self.super_user = self.create_super_user()\n\n    def _make_monthly(self):\n        return KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 1, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=1,\n        )\n\n    def test_download_csv_link_builds_expected_anchor(self):\n        obj = self._make_monthly()\n        html = self.admin.download_csv_link(obj)\n        url = reverse(\"admin:concordia_keymetricsreport_download_csv\", args=[obj.pk])\n        self.assertIn('class=\"button\"', html)\n        self.assertIn(\"Download CSV\", html)\n        self.assertIn(url, html)\n\n    def test_get_urls_registers_named_view(self):\n        urls = self.admin.get_urls()\n        names = [p.name for p in urls if hasattr(p, \"name\")]\n        self.assertIn(\"concordia_keymetricsreport_download_csv\", names)\n\n    def test_download_csv_view_success(self):\n        # Ensure monthly stage is computable in admin URLconf context\n        SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n\n        obj = self._make_monthly()\n\n        with (\n            mock.patch.object(\n                KeyMetricsReport, \"render_csv\", return_value=b\"a,b\\n1,2\\n\"\n            ),\n            mock.patch.object(\n                KeyMetricsReport, \"csv_filename\", return_value=\"report.csv\"\n            ),\n        ):\n            self.client.force_login(self.super_user)\n            url = reverse(\n                \"admin:concordia_keymetricsreport_download_csv\", args=[obj.pk]\n            )\n            resp = self.client.get(url)\n\n        self.assertEqual(resp.status_code, 200)\n        self.assertEqual(resp[\"Content-Type\"], \"text/csv\")\n        self.assertIn('attachment; filename=\"report.csv\"', resp[\"Content-Disposition\"])\n        self.assertEqual(resp.content, b\"a,b\\n1,2\\n\")\n\n    def test_download_csv_view_404_when_missing(self):\n        # Login so admin view runs permission checks normally\n        self.client.force_login(self.super_user)\n        url = reverse(\n            \"admin:concordia_keymetricsreport_download_csv\", args=[\"99999999\"]\n        )\n        resp = self.client.get(url)\n        self.assertEqual(resp.status_code, 404)\n\n    def test_download_selected_as_zip_streams_zip_with_csvs(self):\n        r1 = self._make_monthly()\n        r2 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 2, 1),\n            period_end=date(2024, 2, 29),  # 2024 is leap year\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=2,\n        )\n\n        def fname_side_effect(self_obj):\n            return f\"kmr-{self_obj.pk}.csv\"\n\n        def csv_side_effect(self_obj):\n            return f\"id,{self_obj.pk}\\n\".encode(\"utf-8\")\n\n        with (\n            mock.patch.object(\n                KeyMetricsReport, \"csv_filename\", autospec=True\n            ) as mock_fname,\n            mock.patch.object(\n                KeyMetricsReport, \"render_csv\", autospec=True\n            ) as mock_csv,\n        ):\n            mock_fname.side_effect = fname_side_effect\n            mock_csv.side_effect = csv_side_effect\n\n            req = self.request_factory.post(\"/\")\n            req.user = self.super_user\n            qs = KeyMetricsReport.objects.filter(pk__in=[r1.pk, r2.pk])\n\n            resp = self.admin.download_selected_as_zip(req, qs)\n\n        self.assertEqual(resp.status_code, 200)\n        self.assertEqual(resp[\"Content-Type\"], \"application/zip\")\n        self.assertIn(\n            'attachment; filename=\"key_metrics_reports.zip\"',\n            resp[\"Content-Disposition\"],\n        )\n\n        with zipfile.ZipFile(io.BytesIO(resp.content), \"r\") as zf:\n            names = set(zf.namelist())\n            self.assertIn(f\"kmr-{r1.pk}.csv\", names)\n            self.assertIn(f\"kmr-{r2.pk}.csv\", names)\n            self.assertEqual(\n                zf.read(f\"kmr-{r1.pk}.csv\"), f\"id,{r1.pk}\\n\".encode(\"utf-8\")\n            )\n            self.assertEqual(\n                zf.read(f\"kmr-{r2.pk}.csv\"), f\"id,{r2.pk}\\n\".encode(\"utf-8\")\n            )\n"
  },
  {
    "path": "concordia/tests/test_admin_actions.py",
    "content": "import uuid\nfrom unittest import mock\n\nfrom django.contrib.auth.models import User\nfrom django.http import HttpRequest\nfrom django.test import TestCase\n\nfrom concordia.admin.actions import (\n    anonymize_action,\n    change_status_to_completed,\n    change_status_to_in_progress,\n    change_status_to_needs_review,\n    publish_action,\n    publish_item_action,\n    unpublish_action,\n    unpublish_item_action,\n    verify_assets_action,\n)\nfrom concordia.models import (\n    Asset,\n    Campaign,\n    Item,\n    Project,\n    TranscriptionStatus,\n)\nfrom concordia.tests.utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_transcription,\n)\nfrom concordia.utils import get_anonymous_user\n\n\nclass MockModelAdmin:\n    pass\n\n\nrequest = HttpRequest()\nmodeladmin = MockModelAdmin()\n\n\nclass UserAdminActionTest(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.user1 = self.create_user(\"user1\")\n        self.user2 = self.create_user(\"user2\")\n        self.user3 = self.create_user(\"user3\")\n\n    def test_anonymize_action(self):\n        queryset = User.objects.filter(pk__in=(self.user1.pk, self.user3.pk))\n        anonymize_action(modeladmin, request, queryset)\n        user1 = User.objects.get(pk=self.user1.pk)\n        user2 = User.objects.get(pk=self.user2.pk)\n        user3 = User.objects.get(pk=self.user3.pk)\n\n        self.assertIn(\"Anonymized\", user1.username)\n        self.assertEqual(self.user2.username, user2.username)\n        self.assertIn(\"Anonymized\", user3.username)\n\n        self.assertEqual(\"\", user1.email)\n        self.assertEqual(self.user2.email, user2.email)\n        self.assertEqual(\"\", user3.email)\n\n        self.assertFalse(user1.has_usable_password())\n        self.assertTrue(user2.has_usable_password())\n        self.assertFalse(user3.has_usable_password())\n\n        self.assertFalse(user1.check_password(self.user1._password))\n        self.assertTrue(user2.check_password(self.user2._password))\n        self.assertFalse(user3.check_password(self.user3._password))\n\n        self.assertFalse(user1.is_active)\n        self.assertTrue(user2.is_active)\n        self.assertFalse(user3.is_active)\n\n\nclass ItemAdminActionTest(TestCase):\n    def _setUp(self, published=True):\n        self.asset1 = create_asset(published=published)\n        self.item1 = self.asset1.item\n        self.project = self.item1.project\n\n        self.item2 = create_item(project=self.project, item_id=\"2\", published=published)\n        self.asset2 = create_asset(\n            item=self.item2, slug=\"test-asset-slug-2\", published=published\n        )\n\n        self.item3 = create_item(project=self.project, item_id=\"3\", published=published)\n        self.asset3 = create_asset(\n            item=self.item3, slug=\"test-asset-slug-3\", published=published\n        )\n        self.asset4 = create_asset(\n            item=self.item3, slug=\"test-asset-slug-4\", published=published\n        )\n\n    def test_publish_item_action(self):\n        self._setUp(False)\n        queryset = Item.objects.filter(pk__in=[self.item1.pk, self.item3.pk])\n        publish_item_action(modeladmin, request, queryset)\n        item1 = Item.objects.get(pk=self.item1.pk)\n        asset1 = Asset.objects.get(pk=self.asset1.pk)\n        item2 = Item.objects.get(pk=self.item2.pk)\n        asset2 = Asset.objects.get(pk=self.asset2.pk)\n        item3 = Item.objects.get(pk=self.item3.pk)\n        asset3 = Asset.objects.get(pk=self.asset3.pk)\n        asset4 = Asset.objects.get(pk=self.asset4.pk)\n\n        self.assertTrue(item1.published)\n        self.assertTrue(asset1.published)\n        self.assertFalse(item2.published)\n        self.assertFalse(asset2.published)\n        self.assertTrue(item3.published)\n        self.assertTrue(asset3.published)\n        self.assertTrue(asset4.published)\n\n    def test_unpublish_item_action(self):\n        self._setUp(True)\n        queryset = Item.objects.filter(pk__in=[self.item1.pk, self.item3.pk])\n        unpublish_item_action(modeladmin, request, queryset)\n        item1 = Item.objects.get(pk=self.item1.pk)\n        asset1 = Asset.objects.get(pk=self.asset1.pk)\n        item2 = Item.objects.get(pk=self.item2.pk)\n        asset2 = Asset.objects.get(pk=self.asset2.pk)\n        item3 = Item.objects.get(pk=self.item3.pk)\n        asset3 = Asset.objects.get(pk=self.asset3.pk)\n        asset4 = Asset.objects.get(pk=self.asset4.pk)\n\n        self.assertFalse(item1.published)\n        self.assertFalse(asset1.published)\n        self.assertTrue(item2.published)\n        self.assertTrue(asset2.published)\n        self.assertFalse(item3.published)\n        self.assertFalse(asset3.published)\n        self.assertFalse(asset4.published)\n\n\nclass AssetAdminActionTest(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.user = self.create_user(\"testuser\")\n        self.reviewed_asset = create_asset()\n        self.unreviewed_asset = create_asset(\n            item=self.reviewed_asset.item, slug=\"unreviewed-asset\"\n        )\n        self.untranscribed_asset = create_asset(\n            item=self.reviewed_asset.item, slug=\"untranscribed-asset\"\n        )\n        self.asset_pks = [\n            self.reviewed_asset.pk,\n            self.unreviewed_asset.pk,\n            self.untranscribed_asset.pk,\n        ]\n        self.anon_user = get_anonymous_user()\n        self.request = HttpRequest()\n        self.request.user = self.user\n        create_transcription(asset=self.reviewed_asset, user=self.anon_user)\n        create_transcription(asset=self.unreviewed_asset, user=self.anon_user)\n        create_transcription(\n            asset=self.reviewed_asset,\n            user=self.anon_user,\n            reviewed_by=self.user,\n        )\n\n    def test_change_status_to_completed(self):\n        queryset = Asset.objects.filter(pk__in=self.asset_pks)\n        change_status_to_completed(modeladmin, self.request, queryset)\n\n        reviewed_asset = Asset.objects.get(pk=self.reviewed_asset.pk)\n        unreviewed_asset = Asset.objects.get(pk=self.unreviewed_asset.pk)\n        untranscribed_asset = Asset.objects.get(pk=self.untranscribed_asset.pk)\n\n        self.assertEqual(\n            reviewed_asset.transcription_status, TranscriptionStatus.COMPLETED\n        )\n        self.assertEqual(\n            unreviewed_asset.transcription_status, TranscriptionStatus.COMPLETED\n        )\n        self.assertEqual(\n            untranscribed_asset.transcription_status, TranscriptionStatus.COMPLETED\n        )\n\n    def test_change_status_to_needs_review(self):\n        queryset = Asset.objects.filter(pk__in=self.asset_pks)\n        change_status_to_needs_review(modeladmin, self.request, queryset)\n\n        reviewed_asset = Asset.objects.get(pk=self.reviewed_asset.pk)\n        unreviewed_asset = Asset.objects.get(pk=self.unreviewed_asset.pk)\n        untranscribed_asset = Asset.objects.get(pk=self.untranscribed_asset.pk)\n\n        self.assertEqual(\n            reviewed_asset.transcription_status, TranscriptionStatus.SUBMITTED\n        )\n        self.assertEqual(\n            unreviewed_asset.transcription_status, TranscriptionStatus.SUBMITTED\n        )\n        self.assertEqual(\n            untranscribed_asset.transcription_status, TranscriptionStatus.SUBMITTED\n        )\n\n    def test_change_status_to_in_progress(self):\n        queryset = Asset.objects.filter(pk__in=self.asset_pks)\n        change_status_to_in_progress(modeladmin, self.request, queryset)\n\n        reviewed_asset = Asset.objects.get(pk=self.reviewed_asset.pk)\n        unreviewed_asset = Asset.objects.get(pk=self.unreviewed_asset.pk)\n        untranscribed_asset = Asset.objects.get(pk=self.untranscribed_asset.pk)\n\n        self.assertEqual(\n            reviewed_asset.transcription_status, TranscriptionStatus.IN_PROGRESS\n        )\n        self.assertEqual(\n            unreviewed_asset.transcription_status, TranscriptionStatus.IN_PROGRESS\n        )\n        self.assertEqual(\n            untranscribed_asset.transcription_status, TranscriptionStatus.IN_PROGRESS\n        )\n\n    def test_change_status_to_completed_message_single(self):\n        queryset = Asset.objects.filter(pk__in=[self.untranscribed_asset.pk])\n        with mock.patch(\"concordia.admin.actions.messages.info\") as mock_info:\n            change_status_to_completed(modeladmin, self.request, queryset)\n\n        self.assertTrue(mock_info.called)\n        args, kwargs = mock_info.call_args\n        self.assertIs(args[0], self.request)\n        self.assertIn(\"Changed status of\", args[1])\n        self.assertIn(self.untranscribed_asset.title, args[1])\n        self.assertIn(\"to Complete\", args[1])\n\n    def test_change_status_to_completed_message_multiple(self):\n        queryset = Asset.objects.filter(\n            pk__in=[self.unreviewed_asset.pk, self.untranscribed_asset.pk]\n        )\n        with mock.patch(\"concordia.admin.actions.messages.info\") as mock_info:\n            change_status_to_completed(modeladmin, self.request, queryset)\n\n        self.assertTrue(mock_info.called)\n        args, kwargs = mock_info.call_args\n        self.assertIs(args[0], self.request)\n        self.assertIn(\"Changed status of 2 assets to Complete\", args[1])\n\n    def test_change_status_to_needs_review_message_single(self):\n        queryset = Asset.objects.filter(pk__in=[self.untranscribed_asset.pk])\n        with mock.patch(\"concordia.admin.actions.messages.info\") as mock_info:\n            change_status_to_needs_review(modeladmin, self.request, queryset)\n\n        self.assertTrue(mock_info.called)\n        args, kwargs = mock_info.call_args\n        self.assertIs(args[0], self.request)\n        self.assertIn(\"Changed status of\", args[1])\n        self.assertIn(self.untranscribed_asset.title, args[1])\n        self.assertIn(\"to Needs Review\", args[1])\n\n    def test_change_status_to_in_progress_message_multiple(self):\n        extra_asset = create_asset(\n            item=self.reviewed_asset.item, slug=\"extra-no-tx-for-in-progress\"\n        )\n\n        queryset = Asset.objects.filter(\n            pk__in=[self.untranscribed_asset.pk, extra_asset.pk]\n        )\n\n        with mock.patch(\"concordia.admin.actions.messages.info\") as mock_info:\n            change_status_to_in_progress(modeladmin, self.request, queryset)\n\n        self.assertTrue(mock_info.called)\n        args, kwargs = mock_info.call_args\n        self.assertIs(args[0], self.request)\n        self.assertIn(\"Changed status of 2 assets to In Progress\", args[1])\n\n\nclass AdminActionTest(TestCase):\n    def _setUp(self, published=True):\n        self.asset1 = create_asset(published=published)\n        self.item1 = self.asset1.item\n        self.project1 = self.item1.project\n        self.campaign1 = self.project1.campaign\n\n        self.campaign2 = create_campaign(\n            slug=\"test-campaign-slug-2\", published=published\n        )\n        self.project2 = create_project(\n            campaign=self.campaign2, slug=\"test-project-slug-2\", published=published\n        )\n        self.item2 = create_item(\n            project=self.project2, item_id=\"2\", published=published\n        )\n        self.asset2 = create_asset(\n            item=self.item2, slug=\"test-asset-slug-2\", published=published\n        )\n\n        self.campaign3 = create_campaign(\n            slug=\"test-campaign-slug-3\", published=published\n        )\n        self.project3 = create_project(\n            campaign=self.campaign3, slug=\"test-project-slug-3\", published=published\n        )\n        self.item3 = create_item(\n            project=self.project3, item_id=\"3\", published=published\n        )\n        self.asset3 = create_asset(\n            item=self.item3, slug=\"test-asset-slug-3\", published=published\n        )\n        self.asset4 = create_asset(\n            item=self.item3, slug=\"test-asset-slug-4\", published=published\n        )\n\n    def test_publish_action(self):\n        self._setUp(False)\n        queryset = Campaign.objects.filter(\n            pk__in=[self.campaign1.pk, self.campaign3.pk]\n        )\n        publish_action(modeladmin, request, queryset)\n        campaign1 = Campaign.objects.get(pk=self.campaign1.pk)\n        campaign2 = Campaign.objects.get(pk=self.campaign2.pk)\n        campaign3 = Campaign.objects.get(pk=self.campaign3.pk)\n        project1 = Project.objects.get(pk=self.project1.pk)\n\n        self.assertTrue(campaign1.published)\n        self.assertFalse(campaign2.published)\n        self.assertTrue(campaign3.published)\n        self.assertFalse(project1.published)\n\n        queryset = Project.objects.filter(pk__in=[self.project2.pk])\n        publish_action(modeladmin, request, queryset)\n        project1 = Project.objects.get(pk=self.project1.pk)\n        project2 = Project.objects.get(pk=self.project2.pk)\n        project3 = Project.objects.get(pk=self.project3.pk)\n        item2 = Item.objects.get(pk=self.item2.pk)\n\n        self.assertFalse(project1.published)\n        self.assertTrue(project2.published)\n        self.assertFalse(project3.published)\n        self.assertFalse(item2.published)\n\n        queryset = Asset.objects.filter(\n            pk__in=[self.asset1.pk, self.asset2.pk, self.asset3.pk]\n        )\n        publish_action(modeladmin, request, queryset)\n        asset1 = Asset.objects.get(pk=self.asset1.pk)\n        asset2 = Asset.objects.get(pk=self.asset2.pk)\n        asset3 = Asset.objects.get(pk=self.asset3.pk)\n        asset4 = Asset.objects.get(pk=self.asset4.pk)\n\n        self.assertTrue(asset1.published)\n        self.assertTrue(asset2.published)\n        self.assertTrue(asset3.published)\n        self.assertFalse(asset4.published)\n\n    def test_unpublish_action(self):\n        self._setUp(True)\n        queryset = Campaign.objects.filter(\n            pk__in=[self.campaign1.pk, self.campaign3.pk]\n        )\n        unpublish_action(modeladmin, request, queryset)\n        campaign1 = Campaign.objects.get(pk=self.campaign1.pk)\n        campaign2 = Campaign.objects.get(pk=self.campaign2.pk)\n        campaign3 = Campaign.objects.get(pk=self.campaign3.pk)\n        project1 = Project.objects.get(pk=self.project1.pk)\n\n        self.assertFalse(campaign1.published)\n        self.assertTrue(campaign2.published)\n        self.assertFalse(campaign3.published)\n        self.assertTrue(project1.published)\n\n        queryset = Project.objects.filter(pk__in=[self.project2.pk])\n        unpublish_action(modeladmin, request, queryset)\n        project1 = Project.objects.get(pk=self.project1.pk)\n        project2 = Project.objects.get(pk=self.project2.pk)\n        project3 = Project.objects.get(pk=self.project3.pk)\n        item2 = Item.objects.get(pk=self.item2.pk)\n\n        self.assertTrue(project1.published)\n        self.assertFalse(project2.published)\n        self.assertTrue(project3.published)\n        self.assertTrue(item2.published)\n\n        queryset = Asset.objects.filter(\n            pk__in=[self.asset1.pk, self.asset2.pk, self.asset3.pk]\n        )\n        unpublish_action(modeladmin, request, queryset)\n        asset1 = Asset.objects.get(pk=self.asset1.pk)\n        asset2 = Asset.objects.get(pk=self.asset2.pk)\n        asset3 = Asset.objects.get(pk=self.asset3.pk)\n        asset4 = Asset.objects.get(pk=self.asset4.pk)\n\n        self.assertFalse(asset1.published)\n        self.assertFalse(asset2.published)\n        self.assertFalse(asset3.published)\n        self.assertTrue(asset4.published)\n\n\nclass VerifyAssetsActionTest(TestCase):\n    def setUp(self):\n        # Campaign A with two assets\n        self.asset_a1 = create_asset()\n        self.item_a2 = create_item(\n            project=self.asset_a1.item.project, item_id=\"a2\", published=True\n        )\n        self.asset_a2 = create_asset(item=self.item_a2, slug=\"asset-a2\", published=True)\n\n        # Campaign B with one asset\n        self.campaign_b = create_campaign(slug=\"camp-b\")\n        self.project_b = create_project(campaign=self.campaign_b, slug=\"proj-b\")\n        self.item_b1 = create_item(project=self.project_b, item_id=\"b1\")\n        self.asset_b1 = create_asset(item=self.item_b1, slug=\"asset-b1\")\n\n        self.request = HttpRequest()\n\n        class DummyAdmin:\n            def __init__(self, model):\n                self.model = model\n                self.messages = []\n\n            def message_user(self, request, msg, **kwargs):\n                self.messages.append((request, msg, kwargs))\n\n        self.DummyAdmin = DummyAdmin\n\n    def test_verify_assets_action_for_campaign(self):\n        admin_obj = self.DummyAdmin(model=Campaign)\n        queryset = Campaign.objects.filter(\n            pk__in=[self.asset_a1.item.project.campaign.pk, self.campaign_b.pk]\n        )\n\n        with (\n            mock.patch(\n                \"concordia.admin.actions.uuid.uuid4\",\n                return_value=uuid.UUID(\"12345678-1234-1234-1234-1234567890ab\"),\n            ),\n            mock.patch(\n                \"concordia.admin.actions.create_verify_asset_image_job_batch\",\n                return_value=(3, \"http://example/jobs\"),\n            ) as mock_batch,\n        ):\n            verify_assets_action(admin_obj, self.request, queryset)\n\n        # Assert the selected asset IDs were passed through\n        passed_ids = list(mock_batch.call_args[0][0])\n        self.assertCountEqual(\n            passed_ids, [self.asset_a1.pk, self.asset_a2.pk, self.asset_b1.pk]\n        )\n\n        # Assert the message content\n        self.assertEqual(len(admin_obj.messages), 1)\n        _req, msg, _kwargs = admin_obj.messages[0]\n        self.assertIn(\n            \"Created 3 VerifyAssetImageJobs as part of batch \"\n            \"12345678-1234-1234-1234-1234567890ab\",\n            msg,\n        )\n        self.assertIn('href=\"http://example/jobs\"', msg)\n\n    def test_verify_assets_action_for_project(self):\n        admin_obj = self.DummyAdmin(model=Project)\n        queryset = Project.objects.filter(pk__in=[self.asset_a1.item.project.pk])\n\n        with mock.patch(\n            \"concordia.admin.actions.create_verify_asset_image_job_batch\",\n            return_value=(2, \"http://example/proj\"),\n        ) as mock_batch:\n            verify_assets_action(admin_obj, self.request, queryset)\n\n        passed_ids = list(mock_batch.call_args[0][0])\n        self.assertCountEqual(passed_ids, [self.asset_a1.pk, self.asset_a2.pk])\n\n        self.assertEqual(len(admin_obj.messages), 1)\n        _req, msg, _kwargs = admin_obj.messages[0]\n        self.assertIn(\"Created 2 VerifyAssetImageJobs\", msg)\n\n    def test_verify_assets_action_for_item(self):\n        admin_obj = self.DummyAdmin(model=Item)\n        queryset = Item.objects.filter(pk__in=[self.asset_a1.item.pk, self.item_b1.pk])\n\n        with mock.patch(\n            \"concordia.admin.actions.create_verify_asset_image_job_batch\",\n            return_value=(2, \"http://example/item\"),\n        ) as mock_batch:\n            verify_assets_action(admin_obj, self.request, queryset)\n\n        passed_ids = list(mock_batch.call_args[0][0])\n        self.assertCountEqual(passed_ids, [self.asset_a1.pk, self.asset_b1.pk])\n\n        self.assertEqual(len(admin_obj.messages), 1)\n        _req, msg, _kwargs = admin_obj.messages[0]\n        self.assertIn(\"Created 2 VerifyAssetImageJobs\", msg)\n\n    def test_verify_assets_action_for_asset(self):\n        admin_obj = self.DummyAdmin(model=Asset)\n        queryset = Asset.objects.filter(pk__in=[self.asset_a2.pk, self.asset_b1.pk])\n\n        with mock.patch(\n            \"concordia.admin.actions.create_verify_asset_image_job_batch\",\n            return_value=(2, \"http://example/asset\"),\n        ) as mock_batch:\n            verify_assets_action(admin_obj, self.request, queryset)\n\n        passed_ids = list(mock_batch.call_args[0][0])\n        self.assertCountEqual(passed_ids, [self.asset_a2.pk, self.asset_b1.pk])\n\n        self.assertEqual(len(admin_obj.messages), 1)\n        _req, msg, _kwargs = admin_obj.messages[0]\n        self.assertIn(\"Created 2 VerifyAssetImageJobs\", msg)\n\n    def test_verify_assets_action_for_unsupported_model(self):\n        admin_obj = self.DummyAdmin(model=User)  # unsupported branch\n        queryset = User.objects.none()\n\n        with mock.patch(\n            \"concordia.admin.actions.create_verify_asset_image_job_batch\"\n        ) as mock_batch:\n            verify_assets_action(admin_obj, self.request, queryset)\n\n        # No batch call for unsupported model\n        self.assertFalse(mock_batch.called)\n\n        # Error message sent\n        self.assertEqual(len(admin_obj.messages), 1)\n        _req, msg, kwargs = admin_obj.messages[0]\n        self.assertIn(\"This action is not available for this model.\", msg)\n        self.assertEqual(kwargs.get(\"level\"), \"error\")\n"
  },
  {
    "path": "concordia/tests/test_admin_filters.py",
    "content": "from django.contrib.admin import ModelAdmin\nfrom django.test import RequestFactory, TestCase\nfrom django.utils import timezone\n\nfrom concordia.admin import (\n    CardAdmin,\n    HelpfulLinkAdmin,\n    ItemAdmin,\n    ProjectAdmin,\n    SiteReportAdmin,\n    TranscriptionAdmin,\n)\nfrom concordia.admin.filters import (\n    CardCampaignListFilter,\n    ItemProjectListFilter,\n    NextAssetCampaignListFilter,\n    OcrGeneratedFilter,\n    ProjectCampaignListFilter,\n    ProjectCampaignStatusListFilter,\n    SiteReportCampaignListFilter,\n    SubmittedFilter,\n    SupersededListFilter,\n    TopicListFilter,\n)\nfrom concordia.admin_site import ConcordiaAdminSite\nfrom concordia.models import (\n    Campaign,\n    Card,\n    HelpfulLink,\n    Item,\n    NextTranscribableCampaignAsset,\n    Project,\n    SiteReport,\n    Transcription,\n)\nfrom concordia.tests.utils import (\n    CreateTestUsers,\n    create_asset,\n    create_card,\n    create_card_family,\n    create_helpful_link,\n    create_item,\n    create_project,\n    create_site_report,\n    create_topic,\n    create_transcription,\n)\n\n\nclass NullableTimestampFilterTest(CreateTestUsers, TestCase):\n    def setUp(self):\n        user = self.create_user(username=\"tester\")\n        create_transcription(user=user, submitted=timezone.now())\n\n    def test_lookups(self):\n        f = SubmittedFilter(\n            None, {\"submitted\": (\"null\",)}, Transcription, TranscriptionAdmin\n        )\n        transcriptions = f.queryset(None, Transcription.objects.all())\n        self.assertEqual(transcriptions.count(), 0)\n\n        f = SubmittedFilter(\n            None, {\"submitted\": (\"not-null\",)}, Transcription, TranscriptionAdmin\n        )\n        transcriptions = f.queryset(None, Transcription.objects.all())\n        self.assertEqual(transcriptions.count(), 1)\n\n        f = SubmittedFilter(\n            None, {\"submitted\": (timezone.now(),)}, Transcription, TranscriptionAdmin\n        )\n        transcriptions = f.queryset(None, Transcription.objects.all())\n        self.assertEqual(transcriptions.count(), 1)\n\n\nclass CampaignListFilterTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.campaign = create_project().campaign\n\n    def test_card_filter(self):\n        request = RequestFactory().get(\"/admin/concordia/card/?campaign=\")\n        f = CardCampaignListFilter(request, {}, Card, CardAdmin)\n        cards = f.queryset(None, Card.objects.all())\n        self.assertEqual(cards.count(), 0)\n\n        request = RequestFactory().get(\n            \"/admin/concordia/card/?campaign=%s\" % self.campaign.id\n        )\n        f = CardCampaignListFilter(\n            request, {\"campaign\": (self.campaign.id,)}, Card, CardAdmin\n        )\n        cards = f.queryset(None, Card.objects.all())\n        self.assertEqual(cards.count(), 0)\n\n        self.campaign.card_family = create_card_family()\n        self.campaign.card_family.cards.add(create_card())\n        self.campaign.save()\n        cards = f.queryset(None, Card.objects.all())\n        self.assertEqual(cards.count(), 1)\n\n    def test_project_filter(self):\n        request = RequestFactory().get(\n            \"/admin/concordia/project/?campaign__id__exact=%s\" % self.campaign.id\n        )\n        f = ProjectCampaignListFilter(\n            request,\n            {\"campaign__id__exact\": (self.campaign.id,)},\n            Project,\n            ProjectAdmin,\n        )\n        projects = f.queryset(None, Project.objects.all())\n        self.assertEqual(projects.count(), 1)\n\n        request = RequestFactory().get(\"/admin/concordia/project/?campaign__status=1\")\n        f = ProjectCampaignListFilter(\n            request,\n            {\"campaign__status\": (Campaign.Status.ACTIVE,)},\n            Project,\n            ProjectAdmin,\n        )\n        projects = f.queryset(None, Project.objects.all())\n        self.assertEqual(projects.count(), 1)\n\n    def test_site_report_filter(self):\n        create_site_report(campaign=self.campaign)\n        param = \"campaign__id__exact\"\n        request = RequestFactory().get(\n            \"/admin/concordia/sitereport/?%s=%s\" % (param, self.campaign.id)\n        )\n        site_report_admin = SiteReportAdmin(SiteReport, ConcordiaAdminSite())\n        f = SiteReportCampaignListFilter(\n            request,\n            {param: (self.campaign.id,)},\n            SiteReport,\n            site_report_admin,\n        )\n        self.assertTrue(f.has_output())\n\n        self.assertIn(param, f.expected_parameters())\n\n        self.login_user()\n        request.user = self.user\n        changelist = site_report_admin.get_changelist_instance(request)\n        choices = list(f.choices(changelist))\n        self.assertEqual(choices[0][\"display\"], \"All\")\n\n        self.assertEqual(choices[1][\"display\"], \"Test Campaign\")\n\n        self.assertEqual(choices[-1][\"display\"], \"-\")\n\n        f.include_empty_choice = False\n        self.assertFalse(f.has_output())\n\n        choices = list(f.choices(changelist))\n        self.assertEqual(choices[-1][\"display\"], \"Test Campaign\")\n\n\nclass ItemFilterTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.project = create_item().project\n\n    def test_project_filter(self):\n        request = RequestFactory().get(\n            \"/admin/concordia/item/?project__in=%s\" % self.project.pk\n        )\n        f = ItemProjectListFilter(\n            request, {\"project__in\": (self.project.id,)}, Item, ItemAdmin\n        )\n        items = f.queryset(None, Item.objects.all())\n        self.assertEqual(items.count(), 1)\n\n        request = RequestFactory().get(\n            \"/admin/concordia/item/?project__campaign__id__exact=%s\"\n            % self.project.campaign.pk\n        )\n        f = ItemProjectListFilter(\n            request,\n            {\"project__campaign__id__exact\": (self.project.campaign.pk,)},\n            Item,\n            ItemAdmin,\n        )\n        items = f.queryset(None, Item.objects.all())\n        self.assertEqual(items.count(), 1)\n\n\nclass ProjectFilterTests(TestCase):\n    def setUp(self):\n        self.project = create_item().project\n\n    def test_project_campaign_status_list_filter(self):\n        f = ProjectCampaignStatusListFilter(None, {}, Project, ProjectAdmin)\n        projects = f.queryset(None, Project.objects.all())\n        self.assertEqual(projects.count(), 1)\n\n        f = ProjectCampaignStatusListFilter(\n            None, {\"campaign__status\": (Campaign.Status.ACTIVE,)}, Project, ProjectAdmin\n        )\n        projects = f.queryset(None, Project.objects.all())\n        self.assertEqual(projects.count(), 1)\n\n\nclass TranscriptionFilterTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        user = self.create_user(username=\"tester\")\n        create_transcription(user=user)\n\n    def test_ocr_filter(self):\n        f = OcrGeneratedFilter(\"No\", {}, Transcription, TranscriptionAdmin)\n        transcriptions = f.queryset(None, Transcription.objects.all())\n        self.assertEqual(transcriptions.count(), 1)\n\n        f = OcrGeneratedFilter(\n            \"No\", {\"ocr_generated\": (False,)}, Transcription, TranscriptionAdmin\n        )\n        transcriptions = f.queryset(None, Transcription.objects.all())\n        self.assertEqual(transcriptions.count(), 1)\n\n\nclass TopicListFilterTests(TestCase):\n    def setUp(self):\n        self.topic = create_topic()\n        self.helpful_link_1 = create_helpful_link(topic=self.topic)\n        self.helpful_link_2 = create_helpful_link()\n\n    def test_helpfullink_topic_list_filter(self):\n        topic_filter = TopicListFilter(None, {}, HelpfulLink, HelpfulLinkAdmin)\n        helpful_links = topic_filter.queryset(None, HelpfulLink.objects.all())\n        self.assertEqual(helpful_links.count(), 2)\n\n        topic_filter = TopicListFilter(\n            None, {\"topic__id__exact\": (self.topic.id,)}, HelpfulLink, HelpfulLinkAdmin\n        )\n        helpful_links = topic_filter.queryset(None, HelpfulLink.objects.all())\n        self.assertEqual(helpful_links.count(), 1)\n\n\nclass NextAssetCampaignListFilterTests(TestCase):\n    def setUp(self):\n        asset = create_asset()\n        NextTranscribableCampaignAsset.objects.create(\n            asset=asset,\n            campaign=asset.campaign,\n            item=asset.item,\n            item_item_id=asset.item.item_id,\n            project=asset.item.project,\n            project_slug=asset.item.project.slug,\n            sequence=asset.sequence,\n            transcription_status=asset.transcription_status,\n        )\n        self.campaign = asset.campaign\n\n    def test_lookups_only_includes_used_campaigns(self):\n        class DummyAdmin(ModelAdmin):\n            model = NextTranscribableCampaignAsset\n\n        request = RequestFactory().get(\n            \"/admin/concordia/nexttranscribablecampaignasset/\"\n        )\n        dummy_admin = DummyAdmin(NextTranscribableCampaignAsset, None)\n        fil = NextAssetCampaignListFilter(\n            request, {}, NextTranscribableCampaignAsset, dummy_admin\n        )\n\n        lookups = list(fil.lookups(request, dummy_admin))\n        self.assertEqual(len(lookups), 1)\n        self.assertEqual(lookups[0][0], self.campaign.id)\n        self.assertEqual(lookups[0][1], self.campaign.title)\n\n\nclass SupersededListFilterTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.user = self.create_user(username=\"tester\")\n        self.base = create_transcription(user=self.user, text=\"base\")\n        self.superseding = create_transcription(\n            user=self.user,\n            supersedes=self.base,\n            text=\"superseding\",\n            asset=self.base.asset,\n        )\n        asset2 = create_asset(item=self.base.asset.item, slug=\"asset-2\")\n        self.independent = create_transcription(\n            user=self.user, text=\"independent\", asset=asset2\n        )\n\n    def test_lookups(self):\n        request = RequestFactory().get(\"/admin/concordia/transcription/\")\n        f = SupersededListFilter(request, {}, Transcription, TranscriptionAdmin)\n        lookups = dict(f.lookups(request, TranscriptionAdmin(Transcription, None)))\n        self.assertIn(\"yes\", lookups)\n        self.assertIn(\"no\", lookups)\n        self.assertEqual(lookups[\"yes\"], \"Superseded\")\n        self.assertEqual(lookups[\"no\"], \"Not superseded\")\n\n    def test_queryset_superseded_yes(self):\n        f = SupersededListFilter(\n            None, {\"superseded\": (\"yes\",)}, Transcription, TranscriptionAdmin\n        )\n        qs = f.queryset(None, Transcription.objects.all())\n        self.assertQuerySetEqual(\n            qs.order_by(\"id\").values_list(\"id\", flat=True),\n            [self.base.id],\n            transform=lambda x: x,\n        )\n\n    def test_queryset_superseded_no(self):\n        f = SupersededListFilter(\n            None, {\"superseded\": (\"no\",)}, Transcription, TranscriptionAdmin\n        )\n        qs = f.queryset(None, Transcription.objects.all())\n        ids = set(qs.values_list(\"id\", flat=True))\n        self.assertEqual(ids, {self.superseding.id, self.independent.id})\n\n    def test_queryset_no_param_returns_all(self):\n        f = SupersededListFilter(None, {}, Transcription, TranscriptionAdmin)\n        qs = f.queryset(None, Transcription.objects.all())\n        ids = set(qs.values_list(\"id\", flat=True))\n        self.assertEqual(ids, {self.base.id, self.superseding.id, self.independent.id})\n\n    def test_queryset_ignores_unknown_value(self):\n        f = SupersededListFilter(\n            None, {\"superseded\": (\"maybe\",)}, Transcription, TranscriptionAdmin\n        )\n        qs = f.queryset(None, Transcription.objects.all())\n        ids = set(qs.values_list(\"id\", flat=True))\n        self.assertEqual(ids, {self.base.id, self.superseding.id, self.independent.id})\n"
  },
  {
    "path": "concordia/tests/test_admin_forms.py",
    "content": "from django.test import TestCase, override_settings\n\nfrom concordia.admin.forms import SanitizedDescriptionAdminForm, get_cache_name_choices\nfrom concordia.models import Campaign\n\n\nclass SanitizedDescriptionAdminFormTests(TestCase):\n    def test_clean(self):\n        short_description = \"<p>Arm</p>\"\n        data = {\n            \"slug\": \"test\",\n            \"title\": \"Test\",\n            \"status\": Campaign.Status.ACTIVE,\n            \"ordering\": 0,\n            \"short_description\": \"<div>%s</<div>\" % short_description,\n            \"description\": \"<script src=example.com/evil.js></script>\",\n        }\n        data[\"description\"] += \"<strong>Arm</strong>\"\n        form = SanitizedDescriptionAdminForm(data)\n        self.assertTrue(form.is_valid())\n        self.assertEqual(form.clean_short_description(), short_description)\n        self.assertEqual(form.clean_description(), \"<strong>Arm</strong>\")\n\n\nclass ClearCacheFormTests(TestCase):\n    @override_settings(\n        CACHES={\n            \"default\": {\n                \"BACKEND\": \"django.core.cache.backends.dummy.DummyCache\",\n            },\n            \"view_cache\": {\n                \"BACKEND\": \"django.core.cache.backends.dummy.DummyCache\",\n            },\n        }\n    )\n    def test_cache_name_choices(self):\n        choices = get_cache_name_choices()\n        choice_names = [name for name, description in choices]\n        self.assertNotIn(\"default\", choice_names)\n        self.assertIn(\"view_cache\", choice_names)\n"
  },
  {
    "path": "concordia/tests/test_admin_views.py",
    "content": "import copy\nimport json\nfrom functools import wraps\nfrom http import HTTPStatus\nfrom io import BytesIO\nfrom unittest import mock\n\nfrom django.contrib.messages import get_messages\nfrom django.core.files.uploadedfile import SimpleUploadedFile\nfrom django.test import RequestFactory, TestCase\nfrom django.urls import reverse\nfrom django.utils import timezone\nfrom django.utils.text import slugify\nfrom django.utils.timezone import now\n\nfrom concordia.admin.views import SerializedObjectView\nfrom concordia.models import Campaign, Project, TranscriptionStatus\nfrom concordia.tests.utils import (\n    CreateTestUsers,\n    StreamingTestMixin,\n    create_asset,\n    create_campaign,\n    create_card,\n    create_item,\n    create_project,\n    create_site_report,\n    create_transcription,\n)\nfrom concordia.utils import get_anonymous_user\nfrom importer.tests.utils import create_import_asset\n\n\nclass TestProjectLevelExportView(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.login_user(is_staff=True, is_superuser=True)\n        self.url = reverse(\"admin:project-level-export\")\n        self.asset = create_asset(download_url=\"http://example.com/1234.jpg\")\n        self.asset2 = create_asset(\n            slug=\"asset-2\",\n            item=self.asset.item,\n            download_url=\"http://example.com/5678.jpg\",\n        )\n        self.asset3 = create_asset(\n            slug=\"asset-3\",\n            item=self.asset.item,\n            download_url=\"http://example.com/9012.jpg\",\n        )\n\n    def test_get(self):\n        response = self.client.get(self.url)\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(\n            response, f\"<td>{self.asset.item.project.campaign.title}</td>\", html=True\n        )\n\n    def test_get_campaign(self):\n        response = self.client.get(\n            self.url, {\"id\": self.asset.item.project.campaign.id}\n        )\n        self.assertContains(\n            response, f\"<td>{self.asset.item.project.title}</td>\", html=True\n        )\n\n    def test_post(self):\n        with mock.patch(\"exporter.views.boto3.resource\", autospec=True) as bucket_mock:\n            # The parameter is 'project_name', but it actually expects the project id.\n            response = self.client.post(\n                f\"{self.url}?slug={self.asset.item.project.campaign.slug}\",\n                {\"project_name\": f\"{self.asset.item.project.id}\"},\n            )\n            self.assertEqual(response.status_code, 200)\n            self.assertEqual(response[\"Content-Type\"], \"application/zip\")\n            self.assertFalse(bucket_mock.called)\n\n\nclass TestFunctionBasedViews(CreateTestUsers, TestCase, StreamingTestMixin):\n    def test_admin_bulk_import_review(self):\n        self.login_user(is_staff=True, is_superuser=True)\n        self.assertTrue(self.user.is_active)\n        self.assertTrue(self.user.is_staff)\n        self.assertTrue(self.user.is_superuser)\n        path = reverse(\"admin:bulk-review\")\n        response = self.client.get(path)\n        self.assertEqual(response.status_code, 200)\n\n        data = {}\n        response = self.client.post(path, data=data)\n        self.assertEqual(response.status_code, 200)\n\n    def test_admin_site_report_view(self):\n        self.login_user(is_staff=True, is_superuser=True)\n        mocked_datetime = timezone.now()\n        mocked_datetime_formatted = mocked_datetime.isoformat()\n        with mock.patch(\"django.utils.timezone.now\") as now_mocked:\n            now_mocked.return_value = mocked_datetime\n            create_site_report()\n\n        response = self.client.get(reverse(\"admin:site-report\"))\n        self.assertEqual(response.status_code, 200)\n        content = self.get_streaming_content(response).split(b\"\\r\\n\")\n        self.assertEqual(len(content), 3)  # Includes empty line at the end of the file\n        test_data = [\n            b\"Date,report name,Campaign,topic__title,assets total,assets published,\"\n            b\"assets not started,assets in progress,assets waiting review,\"\n            b\"assets completed,assets unpublished,assets started,items published,\"\n            b\"items unpublished,projects published,projects unpublished,\"\n            b\"anonymous transcriptions,transcriptions saved,daily review actions,\"\n            b\"distinct tags,tag uses,campaigns published,campaigns unpublished,\"\n            b\"users registered,users activated,registered contributors,\"\n            b\"daily active users\",\n            b\"%s,,,,,,,,,,,,,,,,,,,,,,,,,,\" % str.encode(mocked_datetime_formatted),\n            b\"\",\n        ]\n        self.assertEqual(content, test_data)\n\n    def test_admin_retired_site_report_view(self):\n        self.login_user(is_staff=True, is_superuser=True)\n\n        response = self.client.get(reverse(\"admin:retired-site-report\"))\n        self.assertEqual(response.status_code, 200)\n        content = self.get_streaming_content(response).split(b\"\\r\\n\")\n        self.assertEqual(len(content), 3)  # Includes empty line at the end of the file\n        test_data = [\n            b\"Date,report name,Campaign,topic__title,assets total,assets published,\"\n            b\"assets not started,assets in progress,assets waiting review,\"\n            b\"assets completed,assets unpublished,assets started,items published,\"\n            b\"items unpublished,projects published,projects unpublished,\"\n            b\"anonymous transcriptions,transcriptions saved,daily review actions,\"\n            b\"distinct tags,tag uses,campaigns published,campaigns unpublished,\"\n            b\"users registered,users activated,registered contributors,\"\n            b\"daily active users\",\n            b\",RETIRED TOTAL,,,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0\",\n            b\"\",\n        ]\n        self.assertEqual(content, test_data)\n\n\nclass TestAdminBulkImportView(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.login_user(is_staff=True, is_superuser=True)\n        self.path = reverse(\"admin:bulk-import\")\n        self.campaign_title = \"Test Campaign\"\n        self.campaign_short_description = \"Short description\"\n        self.campaign_long_description = \"Long description\"\n        self.campaign_slug = \"test-campaign\"\n        self.project_slug = \"test-project\"\n        self.project_title = \"Test Project\"\n        self.project_description = \"Project description\"\n        self.url = \"http://example.com\"\n        self.spreadsheet_data = {\n            \"Campaign\": self.campaign_title,\n            \"Campaign Short Description\": self.campaign_short_description,\n            \"Campaign Long Description\": self.campaign_long_description,\n            \"Campaign Slug\": self.campaign_slug,\n            \"Project Slug\": self.project_slug,\n            \"Project\": self.project_title,\n            \"Project Description\": self.project_description,\n            \"Import URLs\": self.url,\n        }\n        self.post_data = {\"spreadsheet_file\": BytesIO()}\n\n    def test_get(self):\n        response = self.client.get(self.path)\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"form\", response.context)\n\n    def test_invalid_form(self):\n        response = self.client.post(self.path)\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"form\", response.context)\n\n    def test_fully_valid_form(self):\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [self.spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n            self.assertEqual(response.status_code, 200)\n            messages = [str(message) for message in get_messages(response.wsgi_request)]\n            self.assertEqual(len(messages), 3)\n            self.assertEqual(messages[0], f\"Created new campaign {self.campaign_title}\")\n            self.assertEqual(messages[1], f\"Created new project {self.project_title}\")\n            self.assertEqual(\n                messages[2],\n                f\"Queued {self.campaign_title} {self.project_title} \"\n                f\"import for {self.url}\",\n            )\n\n            campaign = Campaign.objects.get()\n            self.assertEqual(campaign.title, self.campaign_title)\n            self.assertEqual(campaign.slug, self.campaign_slug)\n            self.assertEqual(campaign.description, self.campaign_long_description)\n            self.assertEqual(\n                campaign.short_description, self.campaign_short_description\n            )\n\n            project = Project.objects.get()\n            self.assertEqual(project.title, self.project_title)\n            self.assertEqual(project.slug, self.project_slug)\n            self.assertEqual(project.description, self.project_description)\n\n            # Submit it again to test that it doesn't re-create the campaign or project\n            response = self.client.post(self.path, data=self.post_data)\n            self.assertEqual(response.status_code, 200)\n            messages = [str(message) for message in get_messages(response.wsgi_request)]\n            self.assertEqual(len(messages), 3)\n            self.assertEqual(\n                messages[0],\n                f\"Reusing campaign {self.campaign_title} without modification\",\n            )\n            self.assertEqual(\n                messages[1],\n                f\"Reusing project {self.project_title} without modification\",\n            )\n            self.assertEqual(\n                messages[2],\n                f\"Queued {self.campaign_title} {self.project_title} \"\n                f\"import for {self.url}\",\n            )\n            self.assertEqual(1, Campaign.objects.count())\n            self.assertEqual(1, Project.objects.count())\n\n    def test_missing_field(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        del spreadsheet_data[\"Campaign\"]\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 1)\n        self.assertEqual(\n            str(messages[0]), \"Skipping row 0: missing fields ['Campaign']\"\n        )\n\n    def test_empty_field(self):\n        # Only three fields require values: Campaign, Projet and Import URLs.\n        # Other fields must be present but can be empty.\n        # This tests that blank value check\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n\n            # Test one empty field\n            spreadsheet_data[\"Campaign\"] = \"\"\n            slurp_mock.return_value = [spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n            self.assertEqual(response.status_code, 200)\n            messages = [str(message) for message in get_messages(response.wsgi_request)]\n            self.assertEqual(len(messages), 1)\n            self.assertEqual(\n                messages[0],\n                \"Skipping row 0: at least one required field \"\n                \"(Campaign, Project, Import URLs) is empty\",\n            )\n\n    def test_all_empty_fields(self):\n        # If all values in a spreadsheet row are empty, the row is skipped silently\n        spreadsheet_data = {key: \"\" for key in self.spreadsheet_data.keys()}\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 0)\n\n    def test_empty_campaign_slug(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Campaign Slug\"] = \"\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 3)\n        self.assertEqual(messages[0], f\"Created new campaign {self.campaign_title}\")\n        self.assertEqual(messages[1], f\"Created new project {self.project_title}\")\n        self.assertEqual(\n            messages[2],\n            f\"Queued {self.campaign_title} {self.project_title} import for {self.url}\",\n        )\n\n        # Since the provided campaign slug was blank, it should slugify the Campaign\n        # field instead\n        campaign = Campaign.objects.get()\n        self.assertEqual(\n            campaign.slug, slugify(self.campaign_title, allow_unicode=True)\n        )\n\n    def test_bad_campaign_slug(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Campaign Slug\"] = \"bad#slug@\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 2)\n        self.assertEqual(messages[0], \"Campaign slug doesn't match pattern.\")\n        self.assertEqual(\n            messages[1],\n            \"Unable to create campaign Test Campaign: {'slug': \"\n            \"['Enter a valid “slug” consisting of Unicode letters, \"\n            \"numbers, underscores, or hyphens.']}\",\n        )\n\n    def test_empty_project_slug(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Project Slug\"] = \"\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 3)\n        self.assertEqual(messages[0], f\"Created new campaign {self.campaign_title}\")\n        self.assertEqual(messages[1], f\"Created new project {self.project_title}\")\n        self.assertEqual(\n            messages[2],\n            f\"Queued {self.campaign_title} {self.project_title} import for {self.url}\",\n        )\n\n        # Since the provided project slug was blank, it should slugify the project\n        # field instead\n        project = Project.objects.get()\n        self.assertEqual(project.slug, slugify(self.project_title, allow_unicode=True))\n\n    def test_bad_project_slug(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Project Slug\"] = \"bad#slug@\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 3)\n        self.assertEqual(messages[0], f\"Created new campaign {self.campaign_title}\")\n        self.assertEqual(messages[1], \"Project slug doesn't match pattern.\")\n        self.assertEqual(\n            messages[2],\n            \"Unable to create project Test Project: {'slug': \"\n            \"['Enter a valid “slug” consisting of Unicode letters, \"\n            \"numbers, underscores, or hyphens.']}\",\n        )\n\n    def test_bad_url(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Import URLs\"] = bad_url = \"ftp://example.com\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ),\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 3)\n        self.assertEqual(messages[0], f\"Created new campaign {self.campaign_title}\")\n        self.assertEqual(messages[1], f\"Created new project {self.project_title}\")\n        self.assertEqual(messages[2], f\"Skipping unrecognized URL value: {bad_url}\")\n\n    def test_import_task_exception(self):\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.import_items_into_project_from_url\",\n                autospec=True,\n            ) as import_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [self.spreadsheet_data]\n            import_mock.side_effect = Exception(\"Test Exception\")\n\n            response = self.client.post(self.path, data=self.post_data)\n\n            self.assertEqual(response.status_code, 200)\n            messages = [str(message) for message in get_messages(response.wsgi_request)]\n            self.assertEqual(len(messages), 3)\n            self.assertEqual(messages[0], f\"Created new campaign {self.campaign_title}\")\n            self.assertEqual(messages[1], f\"Created new project {self.project_title}\")\n            self.assertEqual(\n                messages[2],\n                f\"Unhandled error attempting to import {self.url}: Test Exception\",\n            )\n\n\nclass TestAdminBulkChangeAssetStatus(CreateTestUsers, TestCase):\n    def setUp(self):\n        item = create_item()\n        self.assets = [\n            create_asset(item=item),\n            create_asset(item=item, slug=\"test-asset-2\"),\n            create_asset(item=item, slug=\"test-asset-3\"),\n            create_asset(item=item, slug=\"test-asset-4\"),\n        ]\n        # Seed with existing transcriptions\n        self.accepted_transcription = create_transcription(\n            asset=self.assets[0], accepted=now()\n        )\n        self.rejected_transcription = create_transcription(\n            asset=self.assets[1], rejected=now()\n        )\n        self.submitted_transcription = create_transcription(asset=self.assets[2])\n        self.accepted_transcription2 = create_transcription(\n            asset=self.assets[3], accepted=now()\n        )\n        anon = get_anonymous_user()\n        self.spreadsheet_data = [\n            {\n                \"asset__slug\": self.assets[0].slug,\n                \"New Status\": \"submitted\",\n                \"user\": anon.id,\n            },\n            {\n                \"asset__slug\": self.assets[1].slug,\n                \"New Status\": \"completed\",\n                \"user\": anon.id,\n            },\n            {\n                \"asset__slug\": self.assets[2].slug,\n                \"New Status\": \"completed\",\n                \"user\": anon.id,\n            },\n            {\n                \"asset__slug\": self.assets[3].slug,\n                \"New Status\": \"in_progress\",\n                \"user\": anon.id,\n            },\n        ]\n\n    def test_admin_bulk_change_asset_status(self):\n        self.login_user(is_staff=True, is_superuser=True)\n\n        fake_file = SimpleUploadedFile(\n            \"test.xlsx\", b\"x\", content_type=\"application/vnd.ms-excel\"\n        )\n        post_data = {\"spreadsheet_file\": fake_file}\n\n        with mock.patch(\n            \"concordia.admin.views.slurp_excel\", autospec=True\n        ) as slurp_mock:\n            slurp_mock.return_value = self.spreadsheet_data\n\n            path = reverse(\"admin:bulk-change\")\n            response = self.client.post(path, data=post_data)\n            for asset in self.assets:\n                asset.refresh_from_db()\n            self.assertEqual(response.status_code, 200)\n            slurp_mock.assert_called()\n            self.assertEqual(\n                self.assets[0].transcription_status,\n                TranscriptionStatus.SUBMITTED,\n            )\n            self.assertEqual(\n                self.assets[1].transcription_status,\n                TranscriptionStatus.COMPLETED,\n            )\n            self.assertEqual(\n                self.assets[2].transcription_status,\n                TranscriptionStatus.COMPLETED,\n            )\n            self.assertEqual(\n                self.assets[3].transcription_status,\n                TranscriptionStatus.IN_PROGRESS,\n            )\n\n\nclass TestAdminBulkImportReview(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.login_user(is_staff=True, is_superuser=True)\n        self.path = reverse(\"admin:bulk-review\")\n        self.campaign_title = \"Test Campaign\"\n        self.campaign_short_description = \"Short description\"\n        self.campaign_long_description = \"Long description\"\n        self.campaign_slug = \"test-campaign\"\n        self.project_slug = \"test-project\"\n        self.project_title = \"Test Project\"\n        self.project_description = \"Project description\"\n        self.url = \"http://example.com\"\n        self.spreadsheet_data = {\n            \"Campaign\": self.campaign_title,\n            \"Campaign Short Description\": self.campaign_short_description,\n            \"Campaign Long Description\": self.campaign_long_description,\n            \"Campaign Slug\": self.campaign_slug,\n            \"Project Slug\": self.project_slug,\n            \"Project\": self.project_title,\n            \"Project Description\": self.project_description,\n            \"Import URLs\": self.url,\n        }\n        self.post_data = {\"spreadsheet_file\": BytesIO()}\n\n    def test_get(self):\n        response = self.client.get(self.path)\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"form\", response.context)\n\n    def test_invalid_form(self):\n        response = self.client.post(self.path)\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"form\", response.context)\n\n    def test_fully_valid_form(self):\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [self.spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n            self.assertEqual(response.status_code, 200)\n            messages = [str(message) for message in get_messages(response.wsgi_request)]\n            self.assertEqual(len(messages), 3)\n            self.assertEqual(messages[0], \"Fetch test message\")\n            self.assertEqual(messages[1], \"Total Asset Count:1\")\n            self.assertEqual(messages[2], \"All Processes Completed\")\n\n    def test_missing_field(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        del spreadsheet_data[\"Campaign\"]\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 4)\n        self.assertEqual(messages[0], \"Skipping row 0: missing fields ['Campaign']\")\n        self.assertEqual(messages[1], \"Fetch test message\")\n        self.assertEqual(messages[2], \"Total Asset Count:1\")\n        self.assertEqual(messages[3], \"All Processes Completed\")\n\n    def test_empty_field(self):\n        # Only three fields require values: Campaign, Projet and Import URLs.\n        # Other fields must be present but can be empty.\n        # This tests that blank value check\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            # Test one empty field\n            spreadsheet_data[\"Campaign\"] = \"\"\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n            self.assertEqual(response.status_code, 200)\n            messages = [str(message) for message in get_messages(response.wsgi_request)]\n            self.assertEqual(len(messages), 4)\n            self.assertEqual(\n                messages[0],\n                \"Skipping row 0: at least one required field \"\n                \"(Campaign, Project, Import URLs) is empty\",\n            )\n            self.assertEqual(messages[1], \"Fetch test message\")\n            self.assertEqual(messages[2], \"Total Asset Count:1\")\n            self.assertEqual(messages[3], \"All Processes Completed\")\n\n    def test_all_empty_fields(self):\n        # If all values in a spreadsheet row are empty, the row is skipped silently\n        spreadsheet_data = {key: \"\" for key in self.spreadsheet_data.keys()}\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 3)\n        self.assertEqual(messages[0], \"Fetch test message\")\n        self.assertEqual(messages[1], \"Total Asset Count:1\")\n        self.assertEqual(messages[2], \"All Processes Completed\")\n\n    def test_empty_campaign_slug(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Campaign Slug\"] = \"\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 3)\n        self.assertEqual(messages[0], \"Fetch test message\")\n        self.assertEqual(messages[1], \"Total Asset Count:1\")\n        self.assertEqual(messages[2], \"All Processes Completed\")\n\n    def test_bad_campaign_slug(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Campaign Slug\"] = \"bad#slug@\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 4)\n        self.assertEqual(messages[0], \"Campaign slug doesn't match pattern.\")\n        self.assertEqual(messages[1], \"Fetch test message\")\n        self.assertEqual(messages[2], \"Total Asset Count:1\")\n        self.assertEqual(messages[3], \"All Processes Completed\")\n\n    def test_empty_project_slug(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Project Slug\"] = \"\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 3)\n        self.assertEqual(messages[0], \"Fetch test message\")\n        self.assertEqual(messages[1], \"Total Asset Count:1\")\n        self.assertEqual(messages[2], \"All Processes Completed\")\n\n    def test_bad_project_slug(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Project Slug\"] = \"bad#slug@\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 4)\n        self.assertEqual(messages[0], \"Project slug doesn't match pattern.\")\n        self.assertEqual(messages[1], \"Fetch test message\")\n        self.assertEqual(messages[2], \"Total Asset Count:1\")\n        self.assertEqual(messages[3], \"All Processes Completed\")\n\n    def test_bad_url(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Import URLs\"] = bad_url = \"ftp://example.com\"\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 4)\n        self.assertEqual(messages[0], f\"Skipping unrecognized URL value: {bad_url}\")\n        self.assertEqual(messages[1], \"Fetch test message\")\n        self.assertEqual(messages[2], \"Total Asset Count:1\")\n        self.assertEqual(messages[3], \"All Processes Completed\")\n\n    def test_large_number_urls(self):\n        spreadsheet_data = copy.copy(self.spreadsheet_data)\n        spreadsheet_data[\"Import URLs\"] = \" \".join([self.url for i in range(51)])\n\n        with (\n            mock.patch(\n                \"concordia.admin.views.AdminProjectBulkImportForm\", autospec=True\n            ) as form_mock,\n            mock.patch(\n                \"concordia.admin.views.slurp_excel\", autospec=True\n            ) as slurp_mock,\n            mock.patch(\n                \"concordia.admin.views.fetch_all_urls\",\n                autospec=True,\n            ) as fetch_mock,\n        ):\n            form_mock.return_value.is_valid.return_value = True\n            form_mock.return_value.cleaned_data = {}\n            slurp_mock.return_value = [spreadsheet_data]\n            fetch_mock.return_value = [[\"Fetch test message\"], 1]\n\n            response = self.client.post(self.path, data=self.post_data)\n\n        self.assertEqual(response.status_code, 200)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(len(messages), 4)\n        self.assertEqual(messages[0], \"Fetch test message\")\n        self.assertEqual(messages[1], \"Fetch test message\")\n        # This count is weird because we mock the fetch_all_urls function\n        self.assertEqual(messages[2], \"Total Asset Count:2\")\n        self.assertEqual(messages[3], \"All Processes Completed\")\n\n\nclass TestCeleryTaskReview(CreateTestUsers, TestCase):\n    def setUp(self):\n        # We don't set up our data here because we want to test\n        # both with and without data\n        self.login_user(is_staff=True, is_superuser=True)\n        self.path = reverse(\"admin:celery-review\")\n\n    def add_campaigns(self):\n        self.add_active_campaigns()\n        self.add_completed_campaigns()\n        self.add_retired_campaigns()\n\n    def add_active_campaigns(self):\n        self.campaign1 = create_campaign(\n            slug=\"test-active-campaign-1\", title=\"Test Active Campaign 1\"\n        )\n        self.campaign2 = create_campaign(\n            slug=\"test-active-campaign-2\", title=\"Test Active Campaign 2\"\n        )\n\n    def add_completed_campaigns(self):\n        self.completed_campaign1 = create_campaign(\n            slug=\"test-completed-campaign-1\",\n            title=\"Test Completed Campaign 1\",\n            status=Campaign.Status.COMPLETED,\n        )\n        self.completed_campaign2 = create_campaign(\n            slug=\"test-completed-campaign-2\",\n            title=\"Test Completed Campaign 1\",\n            status=Campaign.Status.COMPLETED,\n        )\n\n    def add_retired_campaigns(self):\n        self.retired_campaign1 = create_campaign(\n            slug=\"test-retired-campaign-1\",\n            title=\"Test Retired Campaign 1\",\n            status=Campaign.Status.RETIRED,\n        )\n        self.retired_campaign2 = create_campaign(\n            slug=\"test-retired-campaign-2\",\n            title=\"Test Retired Campaign 1\",\n            status=Campaign.Status.RETIRED,\n        )\n\n    def add_projects(self):\n        # Active campaign 1, three projects\n        create_project(\n            campaign=self.campaign1,\n            slug=\"campaign1-project-1\",\n            title=\"Campaign 1 Project 1\",\n        )\n        create_project(\n            campaign=self.campaign1,\n            slug=\"campaign1-project-2\",\n            title=\"Campaign 1 Project 2\",\n        )\n        create_project(\n            campaign=self.campaign1,\n            slug=\"campaign1-project-3\",\n            title=\"Campaign 1 Project 3\",\n        )\n\n        # Active campaign 2, two projects\n        create_project(\n            campaign=self.campaign2,\n            slug=\"campaign1-project-1\",\n            title=\"Campaign 2 Project 1\",\n        )\n        create_project(\n            campaign=self.campaign2,\n            slug=\"campaign1-project-2\",\n            title=\"Campaign 2 Project 2\",\n        )\n\n        # Completed campaign 1, two projects\n        create_project(\n            campaign=self.completed_campaign1,\n            slug=\"completed-campaign1-project-1\",\n            title=\"Completed Campaign 1 Project 1\",\n        )\n        create_project(\n            campaign=self.completed_campaign1,\n            slug=\"completed-campaign1-project-2\",\n            title=\"Completed Campaign 1 Project 2\",\n        )\n\n        # Completed campaign 2, one project\n        create_project(\n            campaign=self.completed_campaign2,\n            slug=\"completed-campaign2-project-1\",\n            title=\"Completed Campaign 1 Project 1\",\n        )\n\n        # We don't create any for retired campaigns since the campaigns\n        # are only created to make sure the view ignores them\n\n    def add_tasks(self, campaign):\n        data = []\n        for project in campaign.project_set.all():\n            import_asset = create_import_asset(1, project=project)\n            item = import_asset.import_item\n            import_job = item.job\n            create_import_asset(\n                2,\n                import_item=item,\n                import_job=import_job,\n                project=project,\n                last_started=timezone.now(),\n            )\n            create_import_asset(\n                3,\n                import_item=item,\n                import_job=import_job,\n                project=project,\n                failed=timezone.now(),\n                last_started=timezone.now(),\n            )\n            create_import_asset(\n                4,\n                import_item=item,\n                import_job=import_job,\n                project=project,\n                completed=timezone.now(),\n                last_started=timezone.now(),\n            )\n            data.append(\n                {\n                    \"title\": project.title,\n                    \"id\": project.id,\n                    \"campaign_id\": str(campaign.id),\n                    \"successful\": 1,\n                    \"incomplete\": 1,\n                    \"unstarted\": 1,\n                    \"failure\": 1,\n                }\n            )\n        return data\n\n    def test_empty_dashboard(self):\n        response = self.client.get(self.path)\n        context = response.context\n\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"campaigns\", context)\n        campaigns = list(context[\"campaigns\"])\n        self.assertEqual(campaigns, [])\n\n    def test_dashboard(self):\n        self.add_active_campaigns()\n        response = self.client.get(self.path)\n        context = response.context\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"campaigns\", context)\n        self.assertIn(self.campaign1, context[\"campaigns\"])\n        self.assertIn(self.campaign2, context[\"campaigns\"])\n\n        self.add_completed_campaigns()\n        response = self.client.get(self.path)\n        context = response.context\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"campaigns\", context)\n        campaigns = list(context[\"campaigns\"])\n        self.assertIn(self.campaign1, campaigns)\n        self.assertIn(self.campaign2, campaigns)\n        self.assertIn(self.completed_campaign1, campaigns)\n        self.assertIn(self.completed_campaign2, campaigns)\n\n        self.add_retired_campaigns()\n        response = self.client.get(self.path)\n        context = response.context\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"campaigns\", context)\n        campaigns = list(context[\"campaigns\"])\n        self.assertIn(self.campaign1, campaigns)\n        self.assertIn(self.campaign2, campaigns)\n        self.assertIn(self.completed_campaign1, campaigns)\n        self.assertIn(self.completed_campaign2, campaigns)\n        self.assertNotIn(self.retired_campaign1, campaigns)\n        self.assertNotIn(self.retired_campaign2, campaigns)\n\n    def test_campaign_dashboard(self):\n        self.add_campaigns()\n        self.add_projects()\n\n        data = self.add_tasks(self.campaign1)\n        response = self.client.get(self.path, {\"id\": self.campaign1.id})\n        context = response.context\n        self.assertIn(\"campaigns\", context)\n        self.assertEqual(context[\"campaigns\"], [])\n        self.assertIn(\"totalassets\", context)\n        self.assertEqual(context[\"totalassets\"], 12)\n        self.assertIn(\"projects\", context)\n        self.assertEqual(context[\"projects\"], data)\n\n        data = self.add_tasks(self.campaign2)\n        response = self.client.get(self.path, {\"id\": self.campaign2.id})\n        context = response.context\n        self.assertIn(\"campaigns\", context)\n        self.assertEqual(context[\"campaigns\"], [])\n        self.assertIn(\"totalassets\", context)\n        self.assertEqual(context[\"totalassets\"], 8)\n        self.assertIn(\"projects\", context)\n        self.assertEqual(context[\"projects\"], data)\n\n        data = self.add_tasks(self.completed_campaign1)\n        response = self.client.get(self.path, {\"id\": self.completed_campaign1.id})\n        context = response.context\n        self.assertIn(\"campaigns\", context)\n        self.assertEqual(context[\"campaigns\"], [])\n        self.assertIn(\"totalassets\", context)\n        self.assertEqual(context[\"totalassets\"], 8)\n        self.assertIn(\"projects\", context)\n        self.assertEqual(context[\"projects\"], data)\n\n        data = self.add_tasks(self.completed_campaign2)\n        response = self.client.get(self.path, {\"id\": self.completed_campaign2.id})\n        context = response.context\n        self.assertIn(\"campaigns\", context)\n        self.assertEqual(context[\"campaigns\"], [])\n        self.assertIn(\"totalassets\", context)\n        self.assertEqual(context[\"totalassets\"], 4)\n        self.assertIn(\"projects\", context)\n        self.assertEqual(context[\"projects\"], data)\n\n\nclass TestSerializedObjectView(TestCase):\n    def setUp(self):\n        self.card = create_card()\n        # Every test needs access to the request factory.\n        self.factory = RequestFactory()\n\n    def test_exists(self):\n        request = self.factory.get(\n            \"/admin/card/\",\n            {\"model_name\": \"Card\", \"object_id\": self.card.id, \"field_name\": \"title\"},\n        )\n        response = SerializedObjectView.as_view()(request)\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(json.loads(response.content)[\"title\"], self.card.title)\n\n    def test_dne(self):\n        request = self.factory.get(\n            \"/admin/card/\",\n            {\"model_name\": \"Card\", \"object_id\": 3, \"field_name\": \"title\"},\n        )\n        response = SerializedObjectView.as_view()(request)\n        self.assertEqual(response.status_code, HTTPStatus.NOT_FOUND)\n        self.assertJSONEqual(response.content, {\"status\": \"false\"})\n\n\ndef mock_cache(object_to_patch):\n    def decorator(cls):\n        # Decorator to mock `django.core.cache.caches`.\n        # Passes the mock cache and caches_mock to each\n        # test method as additional arguments.\n        # We have to write this as a custom decorator\n        # in order to not have to create these mocks in\n        # each invidivual test method, since we need to override\n        # __getitem__ on the caches mock\n\n        # We need to create a helper function so each method\n        # gets a unique wrapper and mocks\n        def create_wrapper(attr):\n            @wraps(attr)\n            def wrapper(self, *args, **kwargs):\n                with mock.patch(object_to_patch) as caches_mock:\n                    cache_mock = mock.MagicMock()\n                    caches_mock.__getitem__.return_value = cache_mock\n                    return attr(self, caches_mock, cache_mock, *args, **kwargs)\n\n            return wrapper\n\n        # Wrap each test method to include the mocks as arguments\n        for attr_name in dir(cls):\n            attr = getattr(cls, attr_name)\n            if callable(attr) and attr_name.startswith(\"test_\"):\n                setattr(cls, attr_name, create_wrapper(attr))\n\n        return cls\n\n    return decorator\n\n\n@mock_cache(\"concordia.admin.views.caches\")\nclass TestClearCacheView(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.login_user(is_staff=True, is_superuser=True)\n        self.path = reverse(\"admin:clear-cache\")\n\n    def test_get(self, caches_mock, cache_mock):\n        response = self.client.get(self.path)\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"form\", response.context)\n        self.assertFalse(caches_mock.__getitem__.called)\n        self.assertFalse(cache_mock.clear.called)\n\n    def test_invalid_form(self, caches_mock, cache_mock):\n        response = self.client.post(self.path)\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"form\", response.context)\n        self.assertFalse(caches_mock.__getitem__.called)\n        self.assertFalse(cache_mock.clear.called)\n\n    def test_valid_form(self, caches_mock, cache_mock):\n        response = self.client.post(self.path, {\"cache_name\": \"view_cache\"})\n        self.assertEqual(response.status_code, 302)\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(messages[0], \"Successfully cleared 'view_cache' cache\")\n        self.assertTrue(caches_mock.__getitem__.called)\n        self.assertTrue(cache_mock.clear.called)\n\n    def test_form_with_invalid_data(self, caches_mock, cache_mock):\n        response = self.client.post(self.path, {\"cache_name\": \"default\"})\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"form\", response.context)\n        self.assertFalse(caches_mock.__getitem__.called)\n        self.assertFalse(cache_mock.clear.called)\n\n    def test_exception(self, caches_mock, cache_mock):\n        caches_mock.__getitem__.side_effect = Exception(\"Test Exception\")\n        response = self.client.post(self.path, {\"cache_name\": \"view_cache\"})\n        messages = [str(message) for message in get_messages(response.wsgi_request)]\n        self.assertEqual(\n            messages[0],\n            \"Couldn't clear cache 'view_cache', something went wrong. \"\n            \"Received error: Test Exception\",\n        )\n"
  },
  {
    "path": "concordia/tests/test_api_views.py",
    "content": "from datetime import date\nfrom unittest import mock\nfrom urllib.parse import urlparse\n\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\nfrom django.utils.timezone import now\n\nfrom concordia import api_views\nfrom concordia.models import (\n    Asset,\n    Campaign,\n    Item,\n    Project,\n    Topic,\n    Transcription,\n    User,\n)\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import (\n    JSONAssertMixin,\n    create_asset,\n    create_item,\n    create_project,\n    create_topic,\n)\n\n\nclass URLAwareEncoderTest(TestCase):\n    def test_default(self):\n        encoder = api_views.URLAwareEncoder()\n        self.assertEqual(encoder.default(None), None)\n\n        obj = mock.Mock(spec=[\"url\"])\n        self.assertEqual(encoder.default(obj), obj.url)\n\n        obj = mock.Mock(spec=[\"get_absolute_url\"])\n        self.assertEqual(encoder.default(obj), obj.get_absolute_url())\n\n        # Test non-model object\n        obj = date.today()\n        self.assertEqual(encoder.default(obj), date.today().isoformat())\n\n\nclass APIViewMixinTest(TestCase):\n    def setUp(self):\n        self.mixin = api_views.APIViewMixin()\n\n    def test_serialize_conctext(self):\n        context = {\"test-key\": \"test-value\"}\n        self.assertEqual(self.mixin.serialize_context(context), context)\n\n    @mock.patch(\"concordia.api_views.model_to_dict\")\n    def test_serialize_object(self, mtd_mock):\n        return_data = {\"test-key\": \"test-value\"}\n        mtd_mock.return_value = return_data\n\n        obj = mock.Mock(spec=[\"get_absolute_url\"])\n        data = self.mixin.serialize_object(obj)\n\n        self.assertEqual(data, return_data | {\"url\": obj.get_absolute_url()})\n\n        obj = mock.Mock(spec=[])\n        data = self.mixin.serialize_object(obj)\n\n        self.assertEqual(data, return_data)\n\n\n@mock.patch(\"concordia.api_views.time\")\nclass APIListViewTest(TestCase):\n    def test_serialize_context(self, time_mock):\n        time_mock.return_value = \"test-time\"\n        view = api_views.APIListView()\n        context = {\"object_list\": []}\n\n        data = view.serialize_context(context)\n        self.assertEqual(data, {\"objects\": [], \"sent\": \"test-time\"})\n\n\n@override_settings(RATELIMIT_ENABLE=False)\nclass ConcordiaViewTests(JSONAssertMixin, TestCase):\n    @classmethod\n    def setUpTestData(cls):\n        cls.anon_user = get_anonymous_user()\n\n        cls.reviewer = User.objects.create_user(\n            username=\"reviewer\", email=\"tester@example.com\"\n        )\n\n        # clear data from other tests\n        Project.objects.all().delete()\n        Topic.objects.all().delete()\n\n        cls.test_project = create_project()\n\n        cls.test_topic = create_topic(project=cls.test_project)\n\n        cls.items = [\n            create_item(\n                item_id=f\"item_{i}\",\n                title=f\"Item {i}\",\n                project=cls.test_project,\n                do_save=False,\n            )\n            for i in range(0, 3)\n        ]\n        Item.objects.bulk_create(cls.items)\n\n        cls.assets = []\n        for item in cls.items:\n            cls.assets.append(\n                create_asset(\n                    title=f\"Thumbnail URL test for {item.id}\",\n                    item=item,\n                    download_url=\"http://tile.loc.gov/image-services/iiif/\"\n                    \"service:music:mussuffrage:mussuffrage-100183:mussuffrage-100183.0001/\"\n                    \"full/pct:100/0/default.jpg\",\n                    do_save=False,\n                )\n            )\n            for i in range(0, 15):\n                cls.assets.append(\n                    create_asset(title=f\"{item.id} — {i}\", item=item, do_save=False)\n                )\n        Asset.objects.bulk_create(cls.assets)\n\n        cls.transcriptions = []\n        for asset in cls.assets:\n            last_t = None\n\n            for n in range(0, 3):\n                cls.transcriptions.append(\n                    Transcription(\n                        asset=asset,\n                        supersedes=last_t,\n                        text=f\"{asset} — {n}\",\n                        user=cls.anon_user,\n                    )\n                )\n\n        Transcription.objects.bulk_create(cls.transcriptions)\n\n        submitted_t = cls.transcriptions[-1]\n        submitted_t.submitted = now()\n        submitted_t.full_clean()\n        submitted_t.save()\n\n    def get_api_response(self, url, **request_args):\n        \"\"\"\n        This issues a call to one of our API views and confirms that the\n        response follows our basic conventions of returning a valid JSON\n        response\n        \"\"\"\n\n        qs = {\"format\": \"json\"}\n        if request_args is not None:\n            qs.update(request_args)\n\n        resp = self.client.get(url, qs)\n        data = self.assertValidJSON(resp)\n        return resp, data\n\n    def get_api_list_response(self, url, page_size=10, **request_args):\n        \"\"\"\n        This issues a call to one of our API views and confirms that the\n        response follows our basic conventions of returning a top level object\n        with members“objects” (list) and “pagination” (object).\n        \"\"\"\n\n        qs = {\"per_page\": page_size}\n        if request_args is not None:\n            qs.update(request_args)\n\n        resp, data = self.get_api_response(url, **qs)\n\n        self.assertIn(\"objects\", data)\n        self.assertIn(\"pagination\", data)\n\n        object_count = len(data[\"objects\"])\n        self.assertLessEqual(object_count, page_size)\n\n        self.assertAbsoluteURLs(data[\"objects\"])\n        self.assertAbsoluteURLs(data[\"pagination\"])\n\n        return resp, data\n\n    def assertAbsoluteUrl(self, url, allow_none=True):\n        \"\"\"Require a URL to either be None or an absolute URL\"\"\"\n\n        if url is None and allow_none:\n            return\n\n        parsed = urlparse(url)\n        self.assertIn(\n            parsed.scheme, [\"http\", \"https\"], msg=f\"Expected {url} to have HTTP scheme\"\n        )\n\n        self.assertTrue(parsed.netloc)\n\n    def assertAbsoluteURLs(self, data):\n        if isinstance(data, dict):\n            for k, v in data.items():\n                if k.endswith(\"url\"):\n                    self.assertAbsoluteUrl(v)\n                elif isinstance(v, (dict, list)):\n                    self.assertAbsoluteURLs(v)\n        elif isinstance(data, list):\n            for i in data:\n                self.assertAbsoluteURLs(i)\n        else:\n            raise TypeError(\n                \"assertAbsoluteURLs must be called with a dictionary or list\"\n            )\n\n    def assertAssetStatuses(self, asset_list, expected_statuses):\n        asset_pks = [i[\"id\"] for i in asset_list]\n\n        self.assertQuerySetEqual(\n            Asset.objects.filter(pk__in=asset_pks).exclude(\n                transcription_status__in=expected_statuses\n            ),\n            [],\n        )\n\n    def assertAssetsHaveLatestTranscriptions(self, asset_list):\n        asset_pks = {i[\"id\"]: i for i in asset_list}\n\n        for asset in Asset.objects.filter(pk__in=asset_pks.keys()):\n            latest_trans = asset.transcription_set.latest(\"pk\")\n\n            if latest_trans is None:\n                self.assertIsNone(asset_pks[asset.id][\"latest_transcription\"])\n            else:\n                self.assertDictEqual(\n                    asset_pks[asset.id][\"latest_transcription\"],\n                    {\n                        \"id\": latest_trans.pk,\n                        \"text\": latest_trans.text,\n                        \"submitted_by\": latest_trans.user_id,\n                    },\n                )\n\n    def test_topic_detail(self):\n        resp, data = self.get_api_response(\n            reverse(\"topic-detail\", kwargs={\"slug\": self.test_topic.slug})\n        )\n\n        self.assertIn(\"object\", data)\n        self.assertNotIn(\"objects\", data)\n\n        serialized_project = data[\"object\"]\n\n        self.assertIn(\"id\", serialized_project)\n        self.assertIn(\"url\", serialized_project)\n        topic = self.test_topic\n        self.assertEqual(\n            serialized_project,\n            serialized_project\n            | {\n                \"id\": topic.id,\n                \"title\": topic.title,\n                \"description\": topic.description,\n                \"slug\": topic.slug,\n                \"thumbnail_image\": topic.thumbnail_image,\n            },\n        )\n        self.assertURLEqual(\n            serialized_project[\"url\"], f\"http://testserver{topic.get_absolute_url()}\"\n        )\n\n    def test_campaign_list(self):\n        resp, data = self.get_api_list_response(reverse(\"transcriptions:campaign-list\"))\n\n        self.assertGreater(len(data[\"objects\"]), 0)\n\n        test_campaigns = {\n            i[\"id\"]: i\n            for i in Campaign.objects.published().values(\n                \"id\", \"title\", \"description\", \"short_description\", \"slug\"\n            )\n        }\n\n        for obj in data[\"objects\"]:\n            self.assertIn(\"id\", obj)\n            self.assertIn(\"url\", obj)\n            self.assertEqual(obj, obj | test_campaigns[obj[\"id\"]])\n\n    def test_campaign_detail(self):\n        resp, data = self.get_api_response(\n            reverse(\n                \"transcriptions:campaign-detail\",\n                kwargs={\"slug\": self.test_project.campaign.slug},\n            )\n        )\n\n        self.assertIn(\"object\", data)\n        self.assertNotIn(\"objects\", data)\n\n        serialized_project = data[\"object\"]\n\n        self.assertIn(\"id\", serialized_project)\n        self.assertIn(\"url\", serialized_project)\n        campaign = self.test_project.campaign\n        self.assertEqual(\n            serialized_project,\n            serialized_project\n            | {\n                \"id\": campaign.id,\n                \"title\": campaign.title,\n                \"description\": campaign.description,\n                \"slug\": campaign.slug,\n                \"metadata\": campaign.metadata,\n                \"thumbnail_image\": campaign.thumbnail_image,\n            },\n        )\n        self.assertURLEqual(\n            serialized_project[\"url\"], f\"http://testserver{campaign.get_absolute_url()}\"\n        )\n\n    def test_project_detail(self):\n        project = self.test_project\n\n        resp, data = self.get_api_list_response(project.get_absolute_url())\n\n        # Until we clean up the project view code, projects have two key\n        # elements: objects lists the children (i.e. items) and the project\n        # itself is in a second top-level “project” object:\n        self.assertIn(\"objects\", data)\n        self.assertIn(\"project\", data)\n        self.assertNotIn(\"object\", data)\n\n        serialized_project = data[\"project\"]\n\n        self.assertIn(\"id\", serialized_project)\n        self.assertIn(\"url\", serialized_project)\n\n        self.assertURLEqual(\n            serialized_project[\"url\"], f\"http://testserver{project.get_absolute_url()}\"\n        )\n        self.assertEqual(\n            serialized_project,\n            serialized_project\n            | {\n                \"description\": project.description,\n                \"id\": project.id,\n                \"metadata\": project.metadata,\n                \"slug\": project.slug,\n                \"thumbnail_image\": project.thumbnail_image,\n                \"title\": project.title,\n            },\n        )\n\n        for obj in data[\"objects\"]:\n            self.assertIn(\"description\", obj)\n            self.assertIn(\"item_id\", obj)\n            self.assertIn(\"item_url\", obj)\n            self.assertIn(\"metadata\", obj)\n            self.assertIn(\"thumbnail_url\", obj)\n            self.assertIn(\"title\", obj)\n            self.assertIn(\"url\", obj)\n\n    def test_item_detail(self):\n        item = self.test_project.item_set.first()\n        resp, data = self.get_api_list_response(item.get_absolute_url())\n\n        # Until we clean up the project view code, projects have two key\n        # elements: objects lists the children (i.e. items) and the project\n        # itself is in a second top-level “project” object:\n        self.assertIn(\"objects\", data)\n        self.assertIn(\"item\", data)\n        self.assertNotIn(\"object\", data)\n\n        serialized_item = data[\"item\"]\n\n        self.assertIn(\"id\", serialized_item)\n        self.assertIn(\"url\", serialized_item)\n        self.assertIn(\"thumbnail_url\", serialized_item)\n\n        self.assertURLEqual(\n            serialized_item[\"url\"], f\"http://testserver{item.get_absolute_url()}\"\n        )\n        self.assertEqual(\n            serialized_item,\n            serialized_item\n            | {\n                \"description\": item.description,\n                \"id\": item.id,\n                \"item_id\": item.item_id,\n                \"metadata\": item.metadata,\n                \"title\": item.title,\n            },\n        )\n\n        for obj in data[\"objects\"]:\n            self.assertIn(\"description\", obj)\n            self.assertIn(\"difficulty\", obj)\n            self.assertIn(\"metadata\", obj)\n            self.assertIn(\"image_url\", obj)\n            self.assertIn(\"thumbnail_url\", obj)\n            self.assertIn(\"resource_url\", obj)\n            self.assertIn(\"title\", obj)\n            self.assertIn(\"slug\", obj)\n            self.assertIn(\"url\", obj)\n            self.assertIn(\"year\", obj)\n            if \"Thumbnail test\" in obj[\"title\"]:\n                self.assertIn(\"https\", obj[\"thumbnail_url\"])\n"
  },
  {
    "path": "concordia/tests/test_authentication.py",
    "content": "from django.test import RequestFactory, TestCase\n\nfrom concordia.authentication_backends import EmailOrUsernameModelBackend\n\nfrom .utils import CreateTestUsers\n\n\nclass AuthenticationBackendTests(TestCase, CreateTestUsers):\n    def test_EmailOrUsernameModelBackend(self):\n        backend = EmailOrUsernameModelBackend()\n        request_factory = RequestFactory()\n        test_user = self.create_user(\"tester\")\n        request = request_factory.get(\"/\")\n\n        # Fail to authenticate with no information\n        user = backend.authenticate(request)\n        self.assertEqual(user, None)\n\n        # Fail to authenticate with no password, using username\n        user = backend.authenticate(request, test_user.username)\n        self.assertEqual(user, None)\n\n        # Authenticate with correct password, using username\n        user = backend.authenticate(request, test_user.username, test_user._password)\n        self.assertEqual(user, test_user)\n\n        # Fail to authenticate with no password, using email\n        user = backend.authenticate(request, test_user.email)\n        self.assertEqual(user, None)\n\n        # Authenticate with correct password, using email\n        user = backend.authenticate(request, test_user.email, test_user._password)\n        self.assertEqual(user, test_user)\n\n        # Fail to authenticate with incorrect password, using username\n        user = backend.authenticate(request, test_user.username, \"bad-password\")\n        self.assertEqual(user, None)\n\n        # Fail to authenticate with incorrect password, using email\n        user = backend.authenticate(request, test_user.email, \"bad-password\")\n        self.assertEqual(user, None)\n\n        # Same tests, with user with a username\n        # the same as the first user's email address\n        test_user2 = self.create_user(test_user.email)\n\n        # Fail to authenticate with no password, using username\n        user = backend.authenticate(request, test_user2.username)\n        self.assertEqual(user, None)\n\n        # Authenticate with correct password, using username\n        user = backend.authenticate(request, test_user2.username, test_user2._password)\n        self.assertEqual(user, test_user2)\n\n        # Fail to authenticate with no password, using email\n        user = backend.authenticate(request, test_user2.email)\n        self.assertEqual(user, None)\n\n        # Authenticate with correct password, using email\n        user = backend.authenticate(request, test_user2.email, test_user2._password)\n        self.assertEqual(user, test_user2)\n\n        # Fail to authenticate with incorrect password, using username\n        user = backend.authenticate(request, test_user2.username, \"bad-password\")\n        self.assertEqual(user, None)\n\n        # Fail to authenticate with incorrect password, using email\n        user = backend.authenticate(request, test_user2.email, \"bad-password\")\n        self.assertEqual(user, None)\n"
  },
  {
    "path": "concordia/tests/test_celery.py",
    "content": "import tempfile\nfrom types import SimpleNamespace\nfrom unittest import mock\n\nfrom django.test import TestCase\n\nimport concordia.celery as celery_mod\nfrom concordia.celery import import_all_submodules\n\n\nclass ConcordiaCeleryTests(TestCase):\n    def test_returns_early_for_non_package(self):\n        mock_pkg = SimpleNamespace(__name__=\"not_a_pkg\")  # no __path__\n\n        with (\n            mock.patch.object(\n                celery_mod.importlib, \"import_module\", return_value=mock_pkg\n            ) as mock_import,\n            mock.patch.object(celery_mod.pkgutil, \"walk_packages\") as mock_walk,\n        ):\n            import_all_submodules(\"not_a_pkg\")\n\n        mock_import.assert_called_once_with(\"not_a_pkg\")\n        mock_walk.assert_not_called()\n\n    def test_imports_all_submodules_for_package(self):\n        sub1 = SimpleNamespace(name=\"dummy_pkg.sub1\")\n        sub2 = SimpleNamespace(name=\"dummy_pkg.sub2\")\n\n        with tempfile.TemporaryDirectory() as td:\n            mock_pkg = SimpleNamespace(__name__=\"dummy_pkg\", __path__=[td])\n\n            with (\n                mock.patch.object(celery_mod.importlib, \"import_module\") as mock_import,\n                mock.patch.object(\n                    celery_mod.pkgutil, \"walk_packages\", return_value=[sub1, sub2]\n                ) as mock_walk,\n            ):\n\n                def side_effect(name):\n                    if name == \"dummy_pkg\":\n                        return mock_pkg\n                    return SimpleNamespace(__name__=name)\n\n                mock_import.side_effect = side_effect\n                import_all_submodules(\"dummy_pkg\")\n\n        mock_walk.assert_called_once()\n        args, _kwargs = mock_walk.call_args\n        self.assertEqual(args[0], mock_pkg.__path__)\n        self.assertEqual(args[1], mock_pkg.__name__ + \".\")\n\n        self.assertIn(mock.call(\"dummy_pkg\"), mock_import.mock_calls)\n        self.assertIn(mock.call(\"dummy_pkg.sub1\"), mock_import.mock_calls)\n        self.assertIn(mock.call(\"dummy_pkg.sub2\"), mock_import.mock_calls)\n\n    def test_package_with_no_submodules(self):\n        with tempfile.TemporaryDirectory() as td:\n            mock_pkg = SimpleNamespace(__name__=\"empty_pkg\", __path__=[td])\n\n            with (\n                mock.patch.object(celery_mod.importlib, \"import_module\") as mock_import,\n                mock.patch.object(\n                    celery_mod.pkgutil, \"walk_packages\", return_value=[]\n                ) as mock_walk,\n            ):\n\n                mock_import.side_effect = lambda name: (\n                    mock_pkg if name == \"empty_pkg\" else SimpleNamespace(__name__=name)\n                )\n                import_all_submodules(\"empty_pkg\")\n\n        mock_walk.assert_called_once()\n        mock_import.assert_called_once_with(\"empty_pkg\")\n\n    def test__load_all_task_modules_invokes_imports(self):\n        with mock.patch.object(celery_mod, \"import_all_submodules\") as mock_import_all:\n            celery_mod._load_all_task_modules(sender=celery_mod.app)\n\n        mock_import_all.assert_has_calls(\n            [\n                mock.call(\"concordia.tasks\"),\n                mock.call(\"importer.tasks\"),\n            ],\n            any_order=False,\n        )\n\n    def test_on_after_finalize_signal_triggers_handler(self):\n        with mock.patch.object(celery_mod, \"import_all_submodules\") as mock_import_all:\n            celery_mod.app.on_after_finalize.send(sender=celery_mod.app)\n\n        mock_import_all.assert_has_calls(\n            [mock.call(\"concordia.tasks\"), mock.call(\"importer.tasks\")],\n            any_order=False,\n        )\n        self.assertEqual(mock_import_all.call_count, 2)\n"
  },
  {
    "path": "concordia/tests/test_consumers.py",
    "content": "from asgiref.sync import sync_to_async\nfrom channels.testing import WebsocketCommunicator\nfrom django.test import RequestFactory, TransactionTestCase\nfrom django.urls import reverse\n\nfrom concordia.consumers import AssetConsumer\nfrom concordia.utils import get_or_create_reservation_token\nfrom concordia.views.ajax import obtain_reservation\n\nfrom .utils import CreateTestUsers, create_asset, create_item, create_transcription\n\n\nclass TestAssetConsumer(CreateTestUsers, TransactionTestCase):\n    \"\"\"\n    Normally defining communicator would be in setUp\n    and communicator.disconnect would be called in tearDown\n    Asynchronous code doesn't seem to work well with those methods\n    so those lines are in each test.\n    \"\"\"\n\n    async def test_asset_update(self):\n        communicator = WebsocketCommunicator(\n            AssetConsumer.as_asgi(), \"ws/asset/asset_updates/\"\n        )\n        connected, subprotocol = await communicator.connect()\n        self.assertTrue(connected)\n\n        asset = await sync_to_async(create_asset)()\n        response = await communicator.receive_json_from()\n        message = response[\"message\"]\n        self.assertEqual(message[\"type\"], \"asset_update\")\n        self.assertEqual(message[\"asset_pk\"], asset.pk)\n        self.assertEqual(message[\"status\"], \"not_started\")\n        self.assertEqual(message[\"latest_transcription\"], None)\n\n        await sync_to_async(create_item)(item_id=\"item-2\", project=asset.item.project)\n        response = await communicator.receive_nothing()\n        self.assertTrue(response)\n\n        user = await sync_to_async(self.create_test_user)()\n        transcription = await sync_to_async(create_transcription)(\n            asset=asset, user=user\n        )\n        response = await communicator.receive_json_from()\n        message = response[\"message\"]\n        self.assertEqual(message[\"type\"], \"asset_update\")\n        self.assertEqual(message[\"asset_pk\"], asset.pk)\n        self.assertEqual(message[\"status\"], \"in_progress\")\n        self.assertEqual(message[\"latest_transcription\"][\"id\"], transcription.pk)\n\n        await communicator.disconnect()\n\n    async def test_asset_reservation_obtained(self):\n        asset = await sync_to_async(create_asset)()\n        communicator = WebsocketCommunicator(\n            AssetConsumer.as_asgi(), \"ws/asset/asset_updates/\"\n        )\n        connected, subprotocol = await communicator.connect()\n        self.assertTrue(connected)\n\n        request_factory = RequestFactory()\n        request = request_factory.get(\"/\")\n        request.session = {}\n        token = get_or_create_reservation_token(request)\n        await sync_to_async(obtain_reservation)(asset.pk, token)\n\n        response = await communicator.receive_json_from()\n        message = response[\"message\"]\n        self.assertEqual(message[\"type\"], \"asset_reservation_obtained\")\n        self.assertEqual(message[\"asset_pk\"], asset.pk)\n\n        await communicator.disconnect()\n\n    async def test_asset_reservation_released(self):\n        asset = await sync_to_async(create_asset)()\n        await self.async_client.get(\n            reverse(\"reserve-asset\", kwargs={\"asset_pk\": asset.pk})\n        )\n\n        communicator = WebsocketCommunicator(\n            AssetConsumer.as_asgi(), \"ws/asset/asset_updates/\"\n        )\n        connected, subprotocol = await communicator.connect()\n        self.assertTrue(connected)\n\n        await self.async_client.post(\n            reverse(\"reserve-asset\", kwargs={\"asset_pk\": asset.pk}),\n            {\"release\": \"release\"},\n        )\n        response = await communicator.receive_json_from()\n        message = response[\"message\"]\n        self.assertEqual(message[\"type\"], \"asset_reservation_released\")\n        self.assertEqual(message[\"asset_pk\"], asset.pk)\n        await communicator.disconnect()\n"
  },
  {
    "path": "concordia/tests/test_contextmanagers.py",
    "content": "from unittest import TestCase\nfrom unittest.mock import patch\n\nfrom concordia.contextmanagers import DEFAULT_LOCK_DURATION, cache_lock\n\n\nclass CacheLockTests(TestCase):\n    def setUp(self):\n        self.lock_id = \"test-lock\"\n        self.oid = \"worker-1\"\n\n        self.cache_patch = patch(\"concordia.contextmanagers.cache\")\n        self.mock_cache = self.cache_patch.start()\n        self.addCleanup(self.cache_patch.stop)\n\n        self.time_patch = patch(\"concordia.contextmanagers.time.monotonic\")\n        self.mock_monotonic = self.time_patch.start()\n        self.addCleanup(self.time_patch.stop)\n\n        self.start_time = 100.0\n        self.mock_monotonic.return_value = self.start_time\n\n    def test_acquires_and_releases_lock(self):\n        self.mock_cache.add.return_value = True\n\n        with cache_lock(self.lock_id, self.oid) as acquired:\n            self.assertTrue(acquired)\n            self.mock_cache.add.assert_called_once_with(\n                self.lock_id, self.oid, DEFAULT_LOCK_DURATION\n            )\n\n        self.mock_cache.delete.assert_called_once_with(self.lock_id)\n\n    def test_does_not_release_if_lock_not_acquired(self):\n        self.mock_cache.add.return_value = False\n\n        with cache_lock(self.lock_id, self.oid) as acquired:\n            self.assertFalse(acquired)\n\n        self.mock_cache.delete.assert_not_called()\n\n    def test_does_not_release_if_expired(self):\n        self.mock_cache.add.return_value = True\n\n        # Simulate expiration: time has passed beyond timeout\n        def advance_time():\n            return self.start_time + DEFAULT_LOCK_DURATION + 1\n\n        self.mock_monotonic.side_effect = [self.start_time, advance_time()]\n\n        with cache_lock(self.lock_id, self.oid) as acquired:\n            self.assertTrue(acquired)\n\n        self.mock_cache.delete.assert_not_called()\n"
  },
  {
    "path": "concordia/tests/test_decorators.py",
    "content": "from unittest import TestCase\nfrom unittest.mock import MagicMock, patch\n\nfrom celery import Task\n\nfrom concordia.decorators import locked_task\n\n\nclass LockedTaskDecoratorTests(TestCase):\n    def setUp(self):\n        self.hostname = \"test-worker\"\n        self.logger_patch = patch(\"concordia.decorators.logger\")\n        self.logger = self.logger_patch.start()\n        self.addCleanup(self.logger_patch.stop)\n\n        self.cache_lock_patch = patch(\"concordia.decorators.cache_lock\")\n        self.mock_cache_lock = self.cache_lock_patch.start()\n        self.addCleanup(self.cache_lock_patch.stop)\n\n    def make_task_instance(self, name=\"test-task\"):\n        task = MagicMock(spec=Task)\n        task.name = name\n        task.request.hostname = self.hostname\n        return task\n\n    def test_lock_by_args_allows_only_one_execution(self):\n        task = self.make_task_instance()\n\n        calls = []\n\n        @locked_task\n        def dummy(self, arg):\n            calls.append(arg)\n            return f\"Ran with {arg}\"\n\n        dummy_task = dummy.__get__(task)\n\n        self.mock_cache_lock.return_value.__enter__.return_value = True\n        result = dummy_task(\"foo\")\n        self.assertEqual(result, \"Ran with foo\")\n        self.assertEqual(calls, [\"foo\"])\n\n        self.mock_cache_lock.return_value.__enter__.return_value = False\n        result = dummy_task(\"foo\")\n        self.assertIsNone(result)\n        self.logger.info.assert_called_once()\n\n    def test_lock_by_task_name(self):\n        task = self.make_task_instance()\n\n        calls = []\n\n        @locked_task(lock_by_args=False)\n        def dummy(self, arg):\n            calls.append(arg)\n            return f\"Ran with {arg}\"\n\n        dummy_task = dummy.__get__(task)\n\n        self.mock_cache_lock.return_value.__enter__.return_value = True\n        result = dummy_task(\"foo\")\n        self.assertEqual(result, \"Ran with foo\")\n        self.assertEqual(calls, [\"foo\"])\n\n    def test_force_runs_even_if_lock_not_acquired(self):\n        task = self.make_task_instance()\n\n        calls = []\n\n        @locked_task\n        def dummy(self, arg):\n            calls.append(arg)\n            return f\"Forced {arg}\"\n\n        dummy_task = dummy.__get__(task)\n\n        self.mock_cache_lock.return_value.__enter__.return_value = False\n        result = dummy_task(\"bar\", force=True)\n        self.assertEqual(result, \"Forced bar\")\n        self.logger.warning.assert_called_once()\n\n    def test_error_in_key_generation_logs_and_raises(self):\n        task = self.make_task_instance()\n\n        @locked_task\n        def dummy(self, arg):\n            return \"This shouldn't run\"\n\n        dummy_task = dummy.__get__(task)\n\n        # Use a non-repr-able object to simulate key generation failure\n        class Unreprable:\n            def __repr__(self):\n                raise ValueError(\"Can't repr\")\n\n        with self.assertRaises(ValueError):\n            dummy_task(Unreprable())\n\n        self.logger.exception.assert_called_once_with(\n            \"Unable to create cache key from arguments for %s.\", task.name\n        )\n"
  },
  {
    "path": "concordia/tests/test_fields.py",
    "content": "from unittest import mock\nfrom urllib.error import HTTPError\n\nfrom django.forms import ValidationError\nfrom django.test import TestCase, override_settings\n\nfrom concordia.turnstile.fields import TurnstileField\n\n\nclass TestFields(TestCase):\n    @override_settings(\n        TURNSTILE_PROXIES={},\n        TURNSTILE_SECRET=\"test-secret\",  # nosec B106: test-only dummy secret\n        TURNSTILE_VERIFY_URL=\"http://example.com\",\n        TURNSTILE_TIMEOUT=0,\n    )\n    def test_TurnstileField(self):\n        with (\n            override_settings(\n                TURNSTILE_DEFAULT_CONFIG={\"appearance\": \"interaction-only\"}\n            ),\n            mock.patch(\"concordia.turnstile.fields.Request\"),\n            mock.patch(\"concordia.turnstile.fields.build_opener\") as opener_mock,\n        ):\n            open_mock = opener_mock.return_value.open\n            read_mock = open_mock.return_value.read\n\n            field = TurnstileField(required=False)\n\n            self.assertEqual(\n                field.widget_attrs(field.widget),\n                {\"data-appearance\": \"interaction-only\"},\n            )\n\n            # Successful validation from Turnstile\n            read_mock.return_value = '{\"success\" : true}'.encode()\n            self.assertEqual(field.validate(\"test-value\"), None)\n\n            # Unsuccessful validation from Turnstile\n            read_mock.return_value = '{\"test-key\" : \"test-value\"}'.encode()\n            self.assertRaises(ValidationError, field.validate, \"test-value\")\n\n            # Error trying to contact Turnstile\n            open_mock.side_effect = HTTPError(\n                \"http://example.com\", 404, \"Test message%\", \"\", mock.MagicMock()\n            )\n            self.assertRaises(ValidationError, field.validate, \"test-value\")\n\n            # Testing special parameters on the widget\n            field = TurnstileField(\n                onload=\"testOnload()\",\n                render=\"test-render\",\n                hl=\"test-hl\",\n                test_parameter=\"test-data\",\n            )\n            self.assertEqual(\n                field.widget_attrs(field.widget),\n                {\n                    \"data-appearance\": \"interaction-only\",\n                    \"data-test_parameter\": \"test-data\",\n                },\n            )\n            self.assertEqual(\n                field.widget.extra_url,\n                {\n                    \"onload\": \"testOnload()\",\n                    \"render\": \"test-render\",\n                    \"hl\": \"test-hl\",\n                },\n            )\n"
  },
  {
    "path": "concordia/tests/test_logging.py",
    "content": "import warnings\nfrom types import SimpleNamespace\nfrom unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase\n\nfrom concordia.logging import ConcordiaLogger\n\n\nclass ConcordiaLoggerTests(TestCase):\n    def setUp(self):\n        self.mock_structlog_logger = MagicMock()\n        self.logger = ConcordiaLogger(self.mock_structlog_logger)\n\n    def test_debug_logs_with_event(self):\n        self.logger.debug(\"debug msg\", event_code=\"debug_event\", key1=\"value1\")\n        self.mock_structlog_logger.debug.assert_called_once()\n        args, kwargs = self.mock_structlog_logger.debug.call_args\n        self.assertEqual(args[0], \"debug msg\")\n        self.assertEqual(kwargs[\"event_code\"], \"debug_event\")\n        self.assertEqual(kwargs[\"key1\"], \"value1\")\n\n    def test_info_logs_with_event(self):\n        self.logger.info(\"info msg\", event_code=\"info_event\", key2=\"value2\")\n        self.mock_structlog_logger.info.assert_called_once()\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        self.assertEqual(args[0], \"info msg\")\n        self.assertEqual(kwargs[\"event_code\"], \"info_event\")\n        self.assertEqual(kwargs[\"key2\"], \"value2\")\n\n    def test_warning_requires_reason_and_reason_code(self):\n        with self.assertRaises(TypeError):\n            self.logger.warning(\n                \"warning msg\", event_code=\"warn_event\", reason=\"only_reason\"\n            )\n\n        self.logger.warning(\n            \"warning msg\",\n            event_code=\"warn_event\",\n            reason=\"test reason\",\n            reason_code=\"warn_code\",\n            key3=\"value3\",\n        )\n        self.mock_structlog_logger.warning.assert_called_once()\n        args, kwargs = self.mock_structlog_logger.warning.call_args\n        self.assertEqual(kwargs[\"event_code\"], \"warn_event\")\n        self.assertEqual(kwargs[\"reason\"], \"test reason\")\n        self.assertEqual(kwargs[\"reason_code\"], \"warn_code\")\n        self.assertEqual(kwargs[\"key3\"], \"value3\")\n\n    def test_error_requires_reason_and_reason_code(self):\n        with self.assertRaises(TypeError):\n            self.logger.error(\n                \"error msg\", event_code=\"error_event\", reason_code=\"only_code\"\n            )\n\n        self.logger.error(\n            \"error msg\",\n            event_code=\"error_event\",\n            reason=\"error reason\",\n            reason_code=\"error_code\",\n        )\n        self.mock_structlog_logger.error.assert_called_once()\n        args, kwargs = self.mock_structlog_logger.error.call_args\n        self.assertEqual(kwargs[\"event_code\"], \"error_event\")\n        self.assertEqual(kwargs[\"reason\"], \"error reason\")\n        self.assertEqual(kwargs[\"reason_code\"], \"error_code\")\n\n    def test_missing_event_raises(self):\n        with self.assertRaises(ValueError):\n            self.logger.info(\"msg\", event_code=None)\n\n    def test_log_merges_context_correctly(self):\n        mock_obj = SimpleNamespace(id=42)\n        self.logger.register_extractor(\"thing\", lambda o: {\"thing_id\": o.id})\n        self.logger.info(\"msg\", event_code=\"test_event\", thing=mock_obj)\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        self.assertEqual(kwargs[\"thing_id\"], 42)\n\n    def test_log_explicit_key_overrides_extracted(self):\n        mock_obj = SimpleNamespace(id=42)\n        self.logger.register_extractor(\"thing\", lambda o: {\"thing_id\": 123})\n        self.logger.info(\"msg\", event_code=\"test_event\", thing=mock_obj, thing_id=999)\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        self.assertEqual(kwargs[\"thing_id\"], 999)\n\n    def test_bind_merges_context(self):\n        bound = self.logger.bind(foo=\"bar\")\n        self.assertIsInstance(bound, ConcordiaLogger)\n        self.assertIn(\"foo\", bound._context)\n        self.assertEqual(bound._context[\"foo\"], \"bar\")\n\n    def test_bind_merges_context_into_logging(self):\n        bound = self.logger.bind(user=\"uval\")\n        bound.register_extractor(\"user\", lambda o: {\"user_id\": o})\n        bound.info(\"msg\", event_code=\"bound_event\")\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        self.assertEqual(kwargs[\"user_id\"], \"uval\")\n\n    def test_unregister_extractor_removes_extractor(self):\n        self.logger.register_extractor(\"foo\", lambda o: {\"foo_id\": 1})\n        self.logger.unregister_extractor(\"foo\")\n        self.assertNotIn(\"foo\", self.logger._extractors)\n\n    def test_register_extractor_warns_on_chained_override(self):\n        def fake_asset_extractor(x):\n            return {\"asset_id\": 1, \"item_id\": 2}\n\n        with warnings.catch_warnings(record=True) as w:\n            warnings.simplefilter(\"always\")\n            self.logger.register_extractor(\"asset\", fake_asset_extractor)\n            self.assertTrue(\n                any(\n                    \"default extractors may still reference\" in str(warn.message)\n                    for warn in w\n                )\n            )\n\n    def test_log_raises_when_message_is_none(self):\n        with self.assertRaises(ValueError):\n            self.logger.info(None, event_code=\"event\")\n\n    def test_log_raises_when_message_is_none_direct(self):\n        with self.assertRaises(ValueError):\n            self.logger.log(\"info\", None, event_code=\"event\")\n\n    def test_log_raises_when_message_is_empty(self):\n        with self.assertRaises(ValueError):\n            self.logger.info(\"\", event_code=\"event\")\n\n    def test_log_raises_when_message_is_empty_direct(self):\n        with self.assertRaises(ValueError):\n            self.logger.log(\"info\", \"\", event_code=\"event\")\n\n    def test_log_skips_none_values_from_extractor(self):\n        class Dummy:\n            def __init__(self):\n                self.id = None\n\n        self.logger.register_extractor(\"thing\", lambda o: {\"thing_id\": o.id})\n        self.logger.info(\"msg\", event_code=\"event\", thing=Dummy())\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        self.assertNotIn(\"thing_id\", kwargs)\n\n    def test_log_includes_nonextractor_bound_context(self):\n        bound = self.logger.bind(extra1=\"foo\", extra2=\"bar\")\n        bound.info(\"msg\", event_code=\"event\")\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        self.assertEqual(kwargs[\"extra1\"], \"foo\")\n        self.assertEqual(kwargs[\"extra2\"], \"bar\")\n\n    def test_log_skips_none_values_in_context(self):\n        self.logger.info(\"msg\", event_code=\"event\", explicit=None)\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        self.assertNotIn(\"explicit\", kwargs)\n\n    def test_log_overrides_bound_and_extracted_context(self):\n        obj = SimpleNamespace(id=123)\n        base = self.logger.bind(thing=obj, value=\"a\")\n        base.register_extractor(\"thing\", lambda o: {\"thing_id\": o.id, \"value\": \"b\"})\n        base.info(\"msg\", event_code=\"event\", value=\"c\")\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        # Extracted value (\"b\") overridden by explicit context (\"c\")\n        self.assertEqual(kwargs[\"value\"], \"c\")\n        self.assertEqual(kwargs[\"thing_id\"], 123)\n\n    def test_extractor_returns_none_value_skipped(self):\n        obj = SimpleNamespace()\n        self.logger.register_extractor(\"thing\", lambda o: {\"thing_id\": None})\n        self.logger.info(\"msg\", event_code=\"event\", thing=obj)\n        args, kwargs = self.mock_structlog_logger.info.call_args\n        self.assertNotIn(\"thing_id\", kwargs)\n\n    def test_get_logger_uses_structlog(self):\n        with patch(\"concordia.logging.structlog.get_logger\") as mock_get_logger:\n            mock_logger_instance = MagicMock()\n            mock_get_logger.return_value = mock_logger_instance\n\n            logger = ConcordiaLogger.get_logger(\"concordia.tests\")\n\n            mock_get_logger.assert_called_once_with(\"structlog.concordia.tests\")\n            self.assertIsInstance(logger, ConcordiaLogger)\n            self.assertEqual(logger._logger, mock_logger_instance)\n\n    def test_log_raises_valueerror_for_empty_reason_and_code(self):\n        with self.assertRaises(ValueError):\n            self.logger.log(\n                \"warning\", \"bad\", event_code=\"something\", reason=\"\", reason_code=\"fail\"\n            )\n\n        with self.assertRaises(ValueError):\n            self.logger.log(\n                \"error\", \"bad\", event_code=\"something\", reason=\"fail\", reason_code=None\n            )\n\n    def test_exception_logs_with_exc_info(self):\n        try:\n            raise ValueError(\"Something went wrong\")\n        except ValueError:\n            self.logger.exception(\n                \"Exception occurred\",\n                event_code=\"test_exception\",\n                reason=\"An error was raised\",\n                reason_code=\"value_error\",\n                extra=\"context\",\n            )\n\n        self.mock_structlog_logger.error.assert_called_once()\n        args, kwargs = self.mock_structlog_logger.error.call_args\n        self.assertEqual(args[0], \"Exception occurred\")\n        self.assertEqual(kwargs[\"event_code\"], \"test_exception\")\n        self.assertEqual(kwargs[\"reason\"], \"An error was raised\")\n        self.assertEqual(kwargs[\"reason_code\"], \"value_error\")\n        self.assertEqual(kwargs[\"extra\"], \"context\")\n        self.assertTrue(kwargs.get(\"exc_info\"))\n"
  },
  {
    "path": "concordia/tests/test_maintenance.py",
    "content": "from django.core.cache import cache\nfrom django.test import RequestFactory, TestCase\nfrom maintenance_mode.core import set_maintenance_mode\n\nfrom concordia.maintenance import need_maintenance_response\n\nfrom .utils import CreateTestUsers\n\n\nclass TestMaintenance(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.request_factory = RequestFactory()\n        cache.clear()\n\n    def tearDown(self):\n        cache.clear()\n\n    def test_need_maintenance_response_maintenance_default(self):\n        request = self.request_factory.get(\"/\")\n        self.assertFalse(need_maintenance_response(request))\n\n    def test_need_maintenance_response_maintenance_off(self):\n        set_maintenance_mode(False)\n        request = self.request_factory.get(\"/\")\n        self.assertFalse(need_maintenance_response(request))\n\n    def test_need_maintenance_response_maintenance_on(self):\n        set_maintenance_mode(True)\n        request = self.request_factory.get(\"/\")\n        self.assertTrue(need_maintenance_response(request))\n\n        request.user = self.create_test_user()\n        request.user.is_staff = True\n\n        # User is set and is staff, but frontend is off\n        # (the default) so they should still get a maintenance\n        # mode response\n        self.assertTrue(need_maintenance_response(request))\n\n    def test_need_maintenance_response_maintenance_frontend(self):\n        set_maintenance_mode(True)\n        request = self.request_factory.get(\"/\")\n        request.user = self.create_test_user()\n        cache.set(\"maintenance_mode_frontend_available\", True)\n\n        # User is set but isn't super user, so they should get\n        # a maintenance mode response\n        self.assertTrue(need_maintenance_response(request))\n\n        request.user.is_staff = True\n        # User is staff, so they shouldn't get a maintenance\n        # mode response\n        self.assertFalse(need_maintenance_response(request))\n"
  },
  {
    "path": "concordia/tests/test_management_commands.py",
    "content": "from io import StringIO\nfrom unittest import mock\n\nfrom django.core.management import call_command\nfrom django.test import TestCase\n\nfrom concordia.tests.utils import create_asset, create_campaign\n\n\nclass EnsureInitialSiteConfigurationTests(TestCase):\n    def test_command_output(self, *args, **kwargs):\n        out = StringIO()\n        call_command(\n            \"ensure_initial_site_configuration\", admin_email=\"admin@loc.gov\", stdout=out\n        )\n        call_command(\n            \"ensure_initial_site_configuration\", site_domain=\"crowd.loc.gov\", stdout=out\n        )\n        with mock.patch(\n            \"django.contrib.sites.models.Site.objects.update\"\n        ) as update_mock:\n            update_mock.return_value = 0\n            call_command(\n                \"ensure_initial_site_configuration\",\n                site_domain=\"crowd.loc.gov\",\n                stdout=out,\n            )\n\n\nclass ImportSiteReportsTests(TestCase):\n    def test_command_output(self, *args, **kwargs):\n        out = StringIO()\n        create_campaign(id=1)\n        call_command(\n            \"import_site_reports\",\n            csv_file=\"concordia/tests/data/site_reports.csv\",\n            stdout=out,\n        )\n\n\nclass PrintFrontendTestUrlsTests(TestCase):\n    def test_command_output(self, *args, **kwargs):\n        out = StringIO()\n        call_command(\"print_frontend_test_urls\", stdout=out)\n        self.assertIn(\"\", out.getvalue())\n\n        create_asset()\n        call_command(\"print_frontend_test_urls\", stdout=out)\n        self.assertIn(\"\", out.getvalue())\n"
  },
  {
    "path": "concordia/tests/test_models.py",
    "content": "import json\nfrom datetime import date, datetime, timedelta\nfrom decimal import Decimal\nfrom secrets import token_hex\nfrom unittest import mock\n\nfrom django.conf import settings\nfrom django.core.exceptions import ObjectDoesNotExist, ValidationError\nfrom django.db.models import signals\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom concordia.models import (\n    Asset,\n    AssetTranscriptionReservation,\n    Campaign,\n    CardFamily,\n    ConcordiaUser,\n    HelpfulLink,\n    KeyMetricsReport,\n    MediaType,\n    NextReviewableCampaignAsset,\n    NextReviewableTopicAsset,\n    NextTranscribableCampaignAsset,\n    NextTranscribableTopicAsset,\n    SiteReport,\n    Topic,\n    Transcription,\n    TranscriptionStatus,\n    UserProfile,\n    UserProfileActivity,\n    _update_useractivity_cache,\n    resource_file_upload_path,\n    update_userprofileactivity_table,\n    validated_get_or_create,\n)\nfrom concordia.signals.handlers import create_user_profile, on_transcription_save\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_banner,\n    create_campaign,\n    create_campaign_retirement_progress,\n    create_card,\n    create_card_family,\n    create_carousel_slide,\n    create_concordia_file,\n    create_guide,\n    create_helpful_link,\n    create_item,\n    create_simple_page,\n    create_tag,\n    create_tag_collection,\n    create_topic,\n    create_transcription,\n    create_user_profile_activity,\n)\n\n\nclass AssetTestCase(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.anon = get_anonymous_user()\n        create_transcription(asset=self.asset, user=self.anon)\n        create_transcription(\n            asset=self.asset,\n            user=self.create_test_user(username=\"tester\"),\n            reviewed_by=self.anon,\n        )\n\n    def test_get_ocr_transcript(self):\n        self.asset.storage_image = \"tests/test-european.jpg\"\n        self.asset.save()\n        phrase = \"marrón rápido salta sobre el perro\"\n        self.assertFalse(phrase in self.asset.get_ocr_transcript())\n        self.assertFalse(\n            phrase in self.asset.get_ocr_transcript(language=\"bad-language-code\")\n        )\n        self.assertTrue(phrase in self.asset.get_ocr_transcript(language=\"spa\"))\n\n    def test_get_contributor_count(self):\n        self.assertEqual(self.asset.get_contributor_count(), 2)\n\n    def test_turn_off_ocr(self):\n        self.assertFalse(self.asset.turn_off_ocr())\n        self.asset.disable_ocr = True\n        self.asset.save()\n        self.assertTrue(self.asset.turn_off_ocr())\n\n        self.assertFalse(self.asset.item.turn_off_ocr())\n        self.asset.item.disable_ocr = True\n        self.asset.item.save()\n        self.assertTrue(self.asset.item.turn_off_ocr())\n\n        self.assertFalse(self.asset.item.project.turn_off_ocr())\n        self.asset.item.project.disable_ocr = True\n        self.asset.item.project.save()\n        self.assertTrue(self.asset.item.project.turn_off_ocr())\n\n    def test_get_storage_path(self):\n        self.assertEqual(\n            self.asset.get_storage_path(filename=self.asset.storage_image.name),\n            \"test-campaign/test-project/testitem.0123456789/1.jpg\",\n        )\n\n    def test_saving_without_campaign(self):\n        try:\n            Asset.objects.create(\n                item=self.asset.item,\n                title=\"No campaign\",\n                slug=\"no-campaign\",\n                media_type=MediaType.IMAGE,\n                storage_image=\"unittest1.jpg\",\n            )\n        except (ValidationError, ObjectDoesNotExist):\n            self.fail(\"Creating an Asset without a campaign failed validation.\")\n\n    def test_rollforward_with_only_rollforward_transcriptions(self):\n        asset = create_asset(slug=\"rollforward-test\", item=self.asset.item)\n        create_transcription(asset=asset, user=self.anon, rolled_forward=True)\n        with self.assertRaisesMessage(\n            ValueError,\n            \"Can not rollforward transcription on an asset with \"\n            \"no non-rollforward transcriptions\",\n        ):\n            asset.rollforward_transcription(self.anon)\n\n    def test_rollforward_with_too_many_rollforward_transcriptions(self):\n        asset = create_asset(slug=\"rollforward-test\", item=self.asset.item)\n        transcription1 = create_transcription(asset=asset, user=self.anon)\n        create_transcription(\n            asset=asset, user=self.anon, supersedes=transcription1, rolled_forward=True\n        )\n        create_transcription(\n            asset=asset, user=self.anon, supersedes=transcription1, rolled_forward=True\n        )\n        with self.assertRaisesMessage(\n            ValueError,\n            \"More rollforward transcription exist than non-roll-forward \"\n            \"transcriptions, which shouldn't be possible. Possibly \"\n            \"incorrectly modified transcriptions for this asset.\",\n        ):\n            asset.rollforward_transcription(self.anon)\n\n    def test_rollforward_with_no_superseded_transcription(self):\n        # This isn't a state that would happen normally, but could be created\n        # accidentally when manually editing transcription history\n        asset = create_asset(slug=\"rollforward-test\", item=self.asset.item)\n        transcription1 = create_transcription(asset=asset, user=self.anon)\n        create_transcription(asset=asset, user=self.anon, supersedes=transcription1)\n        create_transcription(\n            asset=asset, user=self.anon, rolled_back=True, source=transcription1\n        )\n        with self.assertRaisesMessage(\n            ValueError,\n            \"Can not rollforward transcription on an asset if the latest \"\n            \"rollback transcription did not supersede a previous transcription\",\n        ):\n            asset.rollforward_transcription(self.anon)\n\n    def test_get_storage_path_handles_jpeg(self):\n        # Ensure \".jpeg\" is normalized to \".jpg\"\n        expected = self.asset.get_asset_image_filename(\"jpg\")\n        self.assertEqual(self.asset.get_storage_path(\"anything.jpeg\"), expected)\n\n\nclass ItemModelTests(TestCase):\n    def test_thumbnail_link_prefers_image_url_when_present(self):\n        item = create_item()\n\n        class Img:\n            url = \"http://example.test/media/thumb.jpg\"\n\n        item.thumbnail_image = Img()\n        self.assertEqual(item.thumbnail_link, Img.url)\n\n    def test_thumbnail_link_falls_back_when_image_url_raises(self):\n        # If .url access raises ValueError, fall back to thumbnail_url\n        item = create_item()\n\n        class BadImg:\n            @property\n            def url(self):\n                raise ValueError(\"missing from storage\")\n\n        item.thumbnail_image = BadImg()\n        item.thumbnail_url = \"http://example.test/media/fallback.jpg\"\n        self.assertEqual(item.thumbnail_link, item.thumbnail_url)\n\n    def test_thumbnail_link_returns_thumbnail_url_when_no_image(self):\n        item = create_item()\n        item.thumbnail_image = None\n        item.thumbnail_url = \"http://example.test/media/fallback.jpg\"\n        self.assertEqual(item.thumbnail_link, item.thumbnail_url)\n\n    def test_thumbnail_link_returns_none_when_no_image_or_url(self):\n        item = create_item()\n        item.thumbnail_image = None\n        item.thumbnail_url = None\n        self.assertIsNone(item.thumbnail_link)\n\n\nclass TranscriptionManagerTestCase(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.transcription1 = create_transcription(\n            user=self.create_user(username=\"tester1\"),\n            rejected=timezone.now() - timedelta(days=2),\n        )\n        self.transcription2 = create_transcription(\n            asset=self.transcription1.asset, user=get_anonymous_user()\n        )\n\n    def test_recent_review_actions(self):\n        transcriptions = Transcription.objects\n        self.assertEqual(transcriptions.recent_review_actions().count(), 0)\n\n        self.transcription1.accepted = timezone.now()\n        self.transcription1.save()\n        self.assertEqual(transcriptions.recent_review_actions().count(), 1)\n\n        self.transcription2.rejected = timezone.now()\n        self.transcription2.save()\n        self.assertEqual(transcriptions.recent_review_actions().count(), 2)\n\n    def test_review_actions(self):\n        start = timezone.now() - timedelta(days=5)\n        end = timezone.now() - timedelta(days=1)\n        self.assertEqual(Transcription.objects.review_actions(start, end).count(), 1)\n\n    def test_review_incidents(self):\n        self.transcription1.accepted = timezone.now()\n        self.transcription1.reviewed_by = self.create_user(username=\"tester2\")\n        self.transcription1.save()\n        self.transcription2.accepted = self.transcription1.accepted + timedelta(\n            seconds=29\n        )\n        self.transcription2.reviewed_by = self.transcription1.reviewed_by\n        self.transcription2.save()\n        create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=self.transcription1.reviewed_by,\n            rejected=self.transcription2.accepted + timedelta(seconds=29),\n        )\n        create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=self.transcription1.reviewed_by,\n            rejected=self.transcription2.accepted + timedelta(seconds=58),\n        )\n        users = Transcription.objects.review_incidents()\n        self.assertNotIn(self.transcription1.user.id, users)\n\n        transcription3 = create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=self.transcription1.reviewed_by,\n            accepted=self.transcription1.accepted + timedelta(seconds=58),\n        )\n        transcription4 = create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=self.transcription1.reviewed_by,\n            accepted=transcription3.accepted + timedelta(minutes=1, seconds=1),\n        )\n        users = Transcription.objects.review_incidents()\n        self.assertEqual(len(users), 1)\n        self.assertEqual(\n            users[0],\n            (\n                self.transcription1.reviewed_by.id,\n                self.transcription1.reviewed_by.username,\n                2,\n                4,\n            ),\n        )\n\n        create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=self.transcription1.reviewed_by,\n            accepted=transcription4.accepted + timedelta(seconds=29),\n        )\n        create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=self.transcription1.reviewed_by,\n            accepted=transcription4.accepted + timedelta(seconds=58),\n        )\n        users = Transcription.objects.review_incidents()\n        self.assertEqual(len(users), 1)\n        self.assertEqual(\n            users[0],\n            (\n                self.transcription1.reviewed_by.id,\n                self.transcription1.reviewed_by.username,\n                4,\n                6,\n            ),\n        )\n\n    def test_transcribe_incidents(self):\n        self.transcription1.submitted = timezone.now()\n        self.transcription1.save()\n        self.transcription2.submitted = self.transcription1.submitted + timedelta(\n            seconds=29\n        )\n        self.transcription2.user = self.transcription1.user\n        self.transcription2.save()\n        users = Transcription.objects.transcribe_incidents()\n        self.assertEqual(len(users), 0)\n        self.assertNotIn(self.transcription1.user.id, users)\n\n        transcription3 = create_transcription(\n            asset=create_asset(slug=\"asset-two\", item=self.transcription1.asset.item),\n            user=self.transcription1.user,\n            submitted=self.transcription1.submitted + timedelta(seconds=58),\n        )\n        transcription4 = create_transcription(\n            asset=create_asset(slug=\"asset-three\", item=self.transcription1.asset.item),\n            user=self.transcription1.user,\n            submitted=transcription3.submitted + timedelta(minutes=1, seconds=1),\n        )\n        create_transcription(\n            asset=transcription4.asset,\n            user=self.transcription1.user,\n            submitted=transcription4.submitted + timedelta(seconds=59),\n        )\n        users = Transcription.objects.transcribe_incidents()\n        self.assertEqual(len(users), 1)\n        self.assertEqual(\n            users[0],\n            (self.transcription1.user.id, self.transcription1.user.username, 2, 5),\n        )\n\n        create_transcription(\n            asset=create_asset(slug=\"asset-five\", item=self.transcription1.asset.item),\n            user=self.transcription1.user,\n            submitted=self.transcription1.submitted + timedelta(minutes=1, seconds=59),\n        )\n        users = Transcription.objects.transcribe_incidents()\n        self.assertEqual(len(users), 1)\n        self.assertEqual(\n            users[0],\n            (self.transcription1.user.id, self.transcription1.user.username, 3, 6),\n        )\n\n    def test_review_incidents_returns_empty_when_counts_zero(self):\n        reviewer = self.create_user(username=\"rev-zero\")\n        asset = self.transcription1.asset\n\n        t1 = create_transcription(\n            asset=asset,\n            user=self.create_user(username=\"u-a\"),\n            reviewed_by=reviewer,\n            accepted=timezone.now() - timedelta(minutes=5),\n        )\n        create_transcription(\n            asset=asset,\n            user=self.create_user(username=\"u-b\"),\n            reviewed_by=reviewer,\n            accepted=t1.accepted + timedelta(seconds=61),\n        )\n\n        out = Transcription.objects.review_incidents()\n        self.assertEqual(out, [])\n\n    def test_user_review_incidents_no_threshold_hit(self):\n        asset = self.transcription1.asset\n\n        reviewer = self.create_user(\"reviewer-1\")\n        reviewer_proxy = ConcordiaUser.objects.get(pk=reviewer.pk)\n\n        base = timezone.now()\n        create_transcription(\n            asset=asset,\n            user=self.create_user(\"ri_u1\"),\n            reviewed_by=reviewer_proxy,\n            accepted=base,\n        )\n        create_transcription(\n            asset=asset,\n            user=self.create_user(\"ri_u2\"),\n            reviewed_by=reviewer_proxy,\n            accepted=base + timedelta(seconds=61),\n        )\n\n        recent = Transcription.objects.filter(accepted__isnull=False)\n        incidents = reviewer_proxy.review_incidents(recent)\n        self.assertEqual(incidents, 0)\n\n    def test_review_incidents_no_threshold_match_inner_loop_break(self):\n        # Two accepts for same reviewer but >60s apart:\n        a1 = create_asset(slug=\"rev-gap-a1\", item=self.transcription1.asset.item)\n        a2 = create_asset(slug=\"rev-gap-a2\", item=a1.item)\n        reviewer = self.create_user(\"reviewer-1\")\n\n        t0 = timezone.now()\n        create_transcription(\n            asset=a1, user=self.create_user(\"u1\"), reviewed_by=reviewer, accepted=t0\n        )\n        create_transcription(\n            asset=a2,\n            user=self.create_user(\"u2\"),\n            reviewed_by=reviewer,\n            accepted=t0 + timedelta(seconds=61),\n        )\n\n        recent = Transcription.objects.filter(accepted__isnull=False)\n        reviewer_proxy = ConcordiaUser.objects.get(pk=reviewer.pk)\n\n        incidents = reviewer_proxy.review_incidents(recent)\n        self.assertEqual(incidents, 0)\n\n    def test_review_incidents_loops_until_threshold(self):\n        reviewer = self.create_user(username=\"test-reviewer-1\")\n\n        # Three accepts within 60s so threshold=3 will require two inner\n        # iterations (count goes 1->2, not equal to threshold, then 2->3)\n        base = timezone.now()\n        create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=reviewer,\n            accepted=base,\n        )\n        create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=reviewer,\n            accepted=base + timedelta(seconds=20),\n        )\n        create_transcription(\n            asset=self.transcription1.asset,\n            user=self.transcription1.user,\n            reviewed_by=reviewer,\n            accepted=base + timedelta(seconds=40),\n        )\n\n        recent_accepts = Transcription.objects.filter(\n            accepted__gte=base - timedelta(seconds=1)\n        )\n\n        reviewer_proxy = ConcordiaUser.objects.get(id=reviewer.id)\n\n        incidents = reviewer_proxy.review_incidents(\n            recent_accepts=recent_accepts, threshold=3\n        )\n        self.assertEqual(incidents, 1)\n\n\nclass TranscriptionTestCase(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.user = self.create_user(\"test-user-1\")\n        self.user2 = self.create_user(\"test-user-2\")\n        self.asset = create_asset()\n        self.transcription1 = create_transcription(\n            user=self.user,\n            asset=self.asset,\n            rejected=timezone.now() - timedelta(days=2),\n        )\n        self.transcription2 = create_transcription(asset=self.asset, user=self.user2)\n\n    def test_campaign_slug(self):\n        self.assertEqual(\n            self.asset.item.project.campaign.slug, self.transcription1.campaign_slug()\n        )\n\n    def test_clean(self):\n        bad_transcription = Transcription(asset=self.asset, user=self.user)\n        bad_transcription.clean()\n\n        bad_transcription2 = Transcription(\n            asset=self.asset,\n            user=self.user,\n            reviewed_by=self.user,\n            accepted=timezone.now(),\n        )\n        with self.assertRaises(ValidationError):\n            bad_transcription2.clean()\n\n        bad_transcription3 = Transcription(\n            asset=self.asset,\n            user=self.user,\n            reviewed_by=self.user2,\n            accepted=timezone.now(),\n            rejected=timezone.now(),\n        )\n        with self.assertRaises(ValidationError):\n            bad_transcription3.clean()\n\n    @mock.patch(\"concordia.tests.test_models.on_transcription_save\")\n    def test_save(self, mock_handler):\n        signals.post_save.connect(on_transcription_save, sender=Transcription)\n\n        transcription = create_transcription(asset=self.asset)\n        self.assertTrue(mock_handler.called)\n        self.assertEqual(mock_handler.call_count, 1)\n\n        transcription.save()\n        self.assertEqual(mock_handler.call_count, 2)\n\n        signals.post_save.disconnect(on_transcription_save, sender=Transcription)\n\n    def test_status(self):\n        transcription = create_transcription(user=self.user, asset=self.asset)\n        self.assertEqual(\n            transcription.status,\n            TranscriptionStatus.CHOICE_MAP[TranscriptionStatus.IN_PROGRESS],\n        )\n\n        transcription2 = create_transcription(\n            asset=transcription.asset, user=self.user, submitted=timezone.now()\n        )\n        self.assertEqual(\n            transcription2.status,\n            TranscriptionStatus.CHOICE_MAP[TranscriptionStatus.SUBMITTED],\n        )\n\n        transcription3 = create_transcription(\n            asset=transcription.asset,\n            user=self.user,\n            reviewed_by=self.user2,\n            accepted=timezone.now(),\n        )\n        self.assertEqual(\n            transcription3.status,\n            TranscriptionStatus.CHOICE_MAP[TranscriptionStatus.COMPLETED],\n        )\n\n\nclass SignalHandlersTest(CreateTestUsers, TestCase):\n    @mock.patch(\"django.core.cache.cache.get\")\n    @mock.patch(\"django.core.cache.cache.set\")\n    def test_update_useractivity_cache(self, mock_set, mock_get):\n        campaign = create_campaign()\n        user = self.create_test_user()\n        mock_get.return_value = {}\n        _update_useractivity_cache(user.id, campaign.id, \"transcribe\")\n        self.assertEqual(mock_set.call_count, 1)\n        expected_key = f\"userprofileactivity_{campaign.pk}\"\n        expected_value = {user.id: (1, 0)}\n        mock_set.assert_called_with(expected_key, expected_value, timeout=None)\n\n        reviewed_by = self.create_test_user(username=\"testuser2\")\n        mock_get.return_value = {}\n        _update_useractivity_cache(reviewed_by.id, campaign.id, \"review\")\n        self.assertEqual(mock_set.call_count, 2)\n        expected_value = {reviewed_by.id: (0, 1)}\n        mock_set.assert_called_with(expected_key, expected_value, timeout=None)\n\n\nclass AssetTranscriptionReservationTest(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.user = self.create_user(\"test-user\")\n        self.uid = str(self.user.id).zfill(6)\n        self.token = token_hex(22)\n        self.reservation_token = self.token + self.uid\n        self.reservation = AssetTranscriptionReservation.objects.create(\n            asset=self.asset, reservation_token=self.reservation_token\n        )\n\n    def test_get_token(self):\n        self.assertEqual(self.reservation.get_token(), self.token)\n\n    def test_get_user(self):\n        self.assertEqual(self.reservation.get_user(), self.uid)\n\n\nclass UserProfileActivityTestCase(TestCase):\n    def setUp(self):\n        self.user_profile_activity = UserProfileActivity(\n            campaign=Campaign(), transcribe_count=135, review_count=204\n        )\n\n    def test_get_status(self):\n        self.user_profile_activity.campaign.status = Campaign.Status.ACTIVE\n        self.assertEqual(self.user_profile_activity.get_status(), \"Active\")\n        self.user_profile_activity.campaign.status = Campaign.Status.COMPLETED\n        self.assertEqual(self.user_profile_activity.get_status(), \"Completed\")\n        self.user_profile_activity.campaign.status = Campaign.Status.RETIRED\n        self.assertEqual(self.user_profile_activity.get_status(), \"Retired\")\n\n    def test_total_actions(self):\n        self.assertEqual(self.user_profile_activity.total_actions(), 339)\n\n    def test_str(self):\n        activity = create_user_profile_activity()\n        self.assertEqual(f\"{activity.user} - {activity.campaign}\", str(activity))\n\n\nclass UserProfileTestCase(CreateTestUsers, TestCase):\n    def test_update_userprofileactivity_table(self):\n        signals.post_save.disconnect(\n            create_user_profile, sender=settings.AUTH_USER_MODEL\n        )\n\n        user = self.create_test_user()\n        self.assertFalse(hasattr(user, \"profile\"))\n\n        transcription = create_transcription(user=user)\n        update_userprofileactivity_table(\n            user, transcription.asset.item.project.campaign.id, \"transcribe_count\"\n        )\n\n        self.assertTrue(hasattr(user, \"profile\"))\n        self.assertEqual(user.profile.transcribe_count, 1)\n\n        signals.post_save.connect(create_user_profile, sender=settings.AUTH_USER_MODEL)\n\n    def test_update_userprofileactivity_table_updates_existing_and_profile(self):\n        # Avoid auto-profile creation so we control both branches\n        signals.post_save.disconnect(\n            create_user_profile, sender=settings.AUTH_USER_MODEL\n        )\n\n        user = self.create_test_user()\n        UserProfile.objects.create(user=user)\n\n        transcription = create_transcription(user=user)\n        campaign = transcription.asset.item.project.campaign\n        upa, _ = UserProfileActivity.objects.get_or_create(\n            user=user, campaign=campaign, defaults={\"transcribe_count\": 1}\n        )\n\n        update_userprofileactivity_table(user, campaign.id, \"transcribe_count\")\n\n        # F() increments apply on save; refresh to observe DB values\n        upa.refresh_from_db()\n        user.refresh_from_db()\n        user.profile.refresh_from_db()\n\n        self.assertEqual(upa.transcribe_count, 2)\n        self.assertEqual(user.profile.transcribe_count, 1)\n\n        signals.post_save.connect(create_user_profile, sender=settings.AUTH_USER_MODEL)\n\n\nclass CampaignTestCase(TestCase):\n    def test_queryset(self):\n        campaign = create_campaign(unlisted=True)\n        self.assertIn(campaign, Campaign.objects.unlisted())\n\n        campaign.status = Campaign.Status.COMPLETED\n        campaign.save()\n        self.assertIn(campaign, Campaign.objects.completed())\n\n        campaign.status = Campaign.Status.RETIRED\n        campaign.save()\n        self.assertIn(campaign, Campaign.objects.retired())\n\n\nclass CardTestCase(TestCase):\n    def test_str(self):\n        card = create_card()\n        self.assertEqual(card.title, str(card))\n\n\nclass CardFamilyTestCase(TestCase):\n    def setUp(self):\n        self.family1 = create_card_family(default=True)\n\n    def test_str(self):\n        self.assertEqual(self.family1.slug, str(self.family1))\n\n    def test_on_cardfamily_save(self):\n        with mock.patch(\"concordia.models.on_cardfamily_save\") as mocked_handler:\n            signals.post_save.connect(mocked_handler, sender=CardFamily)\n            self.family1.save()\n            self.assertTrue(mocked_handler.called)\n            self.assertEqual(mocked_handler.call_count, 1)\n\n\nclass HelpfulLinkTestCase(TestCase):\n    def setUp(self):\n        self.helpful_link = create_helpful_link()\n\n    def test_str(self):\n        self.assertEqual(self.helpful_link.title, str(self.helpful_link))\n\n    def test_queryset(self):\n        self.assertEqual(HelpfulLink.objects.related_links().count(), 1)\n        create_helpful_link(\n            link_type=HelpfulLink.HelpfulLinkType.COMPLETED_TRANSCRIPTION_LINK\n        )\n        self.assertEqual(HelpfulLink.objects.completed_transcription_links().count(), 1)\n\n\nclass ConcordiaFileTestCase(TestCase):\n    def setUp(self):\n        self.concordia_file = create_concordia_file()\n\n    def test_str(self):\n        self.assertEqual(self.concordia_file.name, str(self.concordia_file))\n\n    def test_delete(self):\n        with (\n            mock.patch.object(\n                self.concordia_file.uploaded_file, \"delete\"\n            ) as delete_mock,\n            mock.patch.object(\n                self.concordia_file.uploaded_file, \"storage\", autospec=True\n            ) as storage_mock,\n        ):\n            storage_mock.exists.return_value = True\n            self.concordia_file.delete()\n            self.assertTrue(delete_mock.called)\n\n        concordia_file2 = create_concordia_file()\n        with (\n            mock.patch.object(concordia_file2.uploaded_file, \"delete\") as delete_mock,\n            mock.patch.object(\n                concordia_file2.uploaded_file, \"storage\", autospec=True\n            ) as storage_mock,\n        ):\n            storage_mock.exists.return_value = False\n            concordia_file2.delete()\n            self.assertFalse(delete_mock.called)\n\n    def test_concordia_file_upload_path(self):\n        current_year = date.today().year\n\n        path = resource_file_upload_path(self.concordia_file, \"SHOULDNTBEUSED.PDF\")\n        self.assertEqual(path, \"file.pdf\")\n\n        self.concordia_file.path = None\n\n        path = resource_file_upload_path(self.concordia_file, \"TEST.PDF\")\n        self.assertEqual(path, f\"cm-uploads/resources/{current_year}/test.pdf\")\n\n        path = resource_file_upload_path(self.concordia_file, \"TEST%%s.PDF\")\n        self.assertEqual(path, f\"cm-uploads/resources/{current_year}/test%s.pdf\")\n\n        path = resource_file_upload_path(self.concordia_file, \"%%YTEST.PDF\")\n        self.assertEqual(path, f\"cm-uploads/resources/{current_year}/%ytest.pdf\")\n\n\nclass TagTestCase(TestCase):\n    def test_str(self):\n        tag = create_tag()\n        self.assertEqual(tag.value, str(tag))\n\n\nclass UserAssetTagCollectionTestCase(TestCase):\n    def test_str(self):\n        tag_collection = create_tag_collection()\n        self.assertEqual(\n            \"{} - {}\".format(tag_collection.asset, tag_collection.user),\n            str(tag_collection),\n        )\n\n\nclass BannerTestCase(TestCase):\n    def setUp(self):\n        self.banner = create_banner()\n\n    def test_str(self):\n        self.assertEqual(f\"Banner: {self.banner.slug}\", str(self.banner))\n\n    def test_alert_class(self):\n        self.assertEqual(\n            self.banner.alert_class(), \"alert-\" + self.banner.alert_status.lower()\n        )\n\n    def test_btn_class(self):\n        self.assertEqual(\n            self.banner.btn_class(), \"btn-\" + self.banner.alert_status.lower()\n        )\n\n\nclass CarouselSlideTestCase(TestCase):\n    def test_str(self):\n        slide = create_carousel_slide()\n        self.assertEqual(f\"CarouselSlide: {slide.headline}\", str(slide))\n\n\nclass CampaignRetirementProgressTestCase(TestCase):\n    def test_str(self):\n        progress = create_campaign_retirement_progress()\n        self.assertEqual(f\"Removal progress for {progress.campaign}\", str(progress))\n\n\nclass GuideTestCase(TestCase):\n    def test_str(self):\n        guide = create_guide()\n        self.assertEqual(guide.title, str(guide))\n\n\nclass SimplePageTestCase(TestCase):\n    def test_str(self):\n        simple_page = create_simple_page()\n        self.assertEqual(f\"SimplePage: {simple_page.path}\", str(simple_page))\n\n\nclass ValidatedGetOrCreateTestCase(TestCase):\n    def test_validated_get_or_create(self):\n        kwargs = {\n            \"title\": \"Test Campaign\",\n            \"slug\": \"test-campaign\",\n        }\n        campaign, created = validated_get_or_create(Campaign, **kwargs)\n        self.assertTrue(created)\n        campaign, created = validated_get_or_create(Campaign, **kwargs)\n        self.assertFalse(created)\n        self.assertEqual(campaign.title, kwargs[\"title\"])\n\n\nclass NextAssetModelTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.topic = create_topic(project=self.asset.item.project)\n        self.campaign = self.asset.campaign\n        self.project = self.asset.item.project\n\n    def test_create_next_transcribable_campaign_asset(self):\n        obj = NextTranscribableCampaignAsset.objects.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.project,\n            project_slug=self.project.slug,\n            sequence=self.asset.sequence,\n            campaign=self.campaign,\n        )\n        self.assertEqual(str(obj), self.asset.title)\n        self.assertEqual(obj.transcription_status, \"not_started\")\n\n    def test_create_next_reviewable_campaign_asset(self):\n        obj = NextReviewableCampaignAsset.objects.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.project,\n            project_slug=self.project.slug,\n            sequence=self.asset.sequence,\n            campaign=self.campaign,\n        )\n        self.assertEqual(str(obj), self.asset.title)\n        self.assertEqual(obj.transcriber_ids, [])\n\n    def test_create_next_transcribable_topic_asset(self):\n        obj = NextTranscribableTopicAsset.objects.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.project,\n            project_slug=self.project.slug,\n            sequence=self.asset.sequence,\n            topic=self.topic,\n        )\n        self.assertEqual(obj.transcription_status, \"not_started\")\n\n    def test_create_next_reviewable_topic_asset(self):\n        obj = NextReviewableTopicAsset.objects.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.project,\n            project_slug=self.project.slug,\n            sequence=self.asset.sequence,\n            topic=self.topic,\n        )\n        self.assertEqual(obj.transcriber_ids, [])\n\n    def test_needed_for_campaign_respects_target_count(self):\n        manager = NextTranscribableCampaignAsset.objects\n        current_needed = manager.needed_for_campaign(self.campaign.id)\n        self.assertEqual(current_needed, settings.NEXT_TRANSCRIBABE_ASSET_COUNT)\n\n        # Add one and check count again\n        manager.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.project,\n            project_slug=self.project.slug,\n            sequence=self.asset.sequence,\n            campaign=self.campaign,\n        )\n        new_needed = manager.needed_for_campaign(self.campaign.id)\n        self.assertEqual(new_needed, settings.NEXT_TRANSCRIBABE_ASSET_COUNT - 1)\n\n    def test_needed_for_topic_respects_target_count(self):\n        manager = NextReviewableTopicAsset.objects\n        current_needed = manager.needed_for_topic(self.topic.id)\n        self.assertEqual(current_needed, settings.NEXT_REVIEWABLE_ASSET_COUNT)\n\n        manager.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.project,\n            project_slug=self.project.slug,\n            sequence=self.asset.sequence,\n            topic=self.topic,\n        )\n        new_needed = manager.needed_for_topic(self.topic.id)\n        self.assertEqual(new_needed, settings.NEXT_REVIEWABLE_ASSET_COUNT - 1)\n\n    def test_needed_for_campaign_raises_without_target(self):\n        from django.db import models\n\n        from concordia.models import NextCampaignAssetManager\n\n        class DummyManager(NextCampaignAssetManager):\n            target_count = None\n\n        class DummyModel(models.Model):\n            campaign = models.ForeignKey(\"concordia.Campaign\", on_delete=models.CASCADE)\n            objects = DummyManager()\n\n            class Meta:\n                app_label = \"concordia\"\n\n        with self.assertRaises(NotImplementedError):\n            DummyModel.objects.needed_for_campaign(self.campaign.id)\n\n    def test_needed_for_topic_raises_without_target(self):\n        from django.db import models\n\n        from concordia.models import NextTopicAssetManager\n\n        class DummyManager(NextTopicAssetManager):\n            target_count = None\n\n        class DummyModel(models.Model):\n            topic = models.ForeignKey(\"concordia.Topic\", on_delete=models.CASCADE)\n            objects = DummyManager()\n\n            class Meta:\n                app_label = \"concordia\"\n\n        with self.assertRaises(NotImplementedError):\n            DummyModel.objects.needed_for_topic(self.topic.id)\n\n    def test_needed_for_campaign_with_explicit_target_count(self):\n        manager = NextTranscribableCampaignAsset.objects\n        # Should return full count when no assets exist yet\n        needed = manager.needed_for_campaign(self.campaign.id, target_count=10)\n        self.assertEqual(needed, 10)\n\n        # Add one asset\n        manager.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.project,\n            project_slug=self.project.slug,\n            sequence=self.asset.sequence,\n            campaign=self.campaign,\n        )\n\n        needed = manager.needed_for_campaign(self.campaign.id, target_count=10)\n        self.assertEqual(needed, 9)\n\n    def test_needed_for_topic_with_explicit_target_count(self):\n        manager = NextReviewableTopicAsset.objects\n        needed = manager.needed_for_topic(self.topic.id, target_count=5)\n        self.assertEqual(needed, 5)\n\n        manager.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.project,\n            project_slug=self.project.slug,\n            sequence=self.asset.sequence,\n            topic=self.topic,\n        )\n\n        needed = manager.needed_for_topic(self.topic.id, target_count=5)\n        self.assertEqual(needed, 4)\n\n\nclass SiteReportAndManagerTestCase(TestCase):\n    def _aware(self, y, m, d, hh=12, mm=0, ss=0):\n        tz = timezone.get_current_timezone()\n        return timezone.make_aware(datetime(y, m, d, hh, mm, ss), tz)\n\n    def _mk_sr(\n        self,\n        *,\n        dt,\n        report_name=None,\n        campaign=None,\n        topic=None,\n        **kwargs,\n    ):\n        sr = SiteReport.objects.create(\n            report_name=report_name or \"\",\n            campaign=campaign,\n            topic=topic,\n            **kwargs,\n        )\n        # Set created_on deterministically for ordering logic\n        SiteReport.objects.filter(pk=sr.pk).update(created_on=dt)\n        return SiteReport.objects.get(pk=sr.pk)\n\n    def test_calculate_assets_started(self):\n        # Uses (assets_total - assets_not_started) deltas; floor at 0.\n        v = SiteReport.calculate_assets_started(\n            previous_assets_total=100,\n            previous_assets_not_started=100,\n            current_assets_total=107,\n            current_assets_not_started=92,\n        )\n        self.assertEqual(v, 15)\n\n        # None treated as 0.\n        v2 = SiteReport.calculate_assets_started(\n            previous_assets_total=None,\n            previous_assets_not_started=None,\n            current_assets_total=200,\n            current_assets_not_started=190,\n        )\n        self.assertEqual(v2, 10)\n\n        # Negative deltas are floored at 0.\n        v3 = SiteReport.calculate_assets_started(\n            previous_assets_total=107,\n            previous_assets_not_started=92,\n            current_assets_total=100,\n            current_assets_not_started=90,\n        )\n        self.assertEqual(v3, 0)\n\n    def test_series_navigation_and_sums(self):\n        # Site-wide TOTAL series snapshots across three days\n        d1 = self._aware(2024, 1, 10)\n        d2 = self._aware(2024, 1, 20)\n        d3 = self._aware(2024, 1, 31)\n\n        r1 = self._mk_sr(\n            dt=d1,\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_started=3,\n        )\n        r2 = self._mk_sr(\n            dt=d2,\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_started=7,\n        )\n        r3 = self._mk_sr(\n            dt=d3,\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_started=10,\n        )\n\n        prev = SiteReport.objects.previous_in_series(\n            report_name=SiteReport.ReportName.TOTAL,\n            before=self._aware(2024, 1, 25),\n        )\n        self.assertEqual(prev.pk, r2.pk)\n\n        # last_on_or_before_date_for_series\n        last = SiteReport.objects.last_on_or_before_date_for_series(\n            report_name=SiteReport.ReportName.TOTAL,\n            on_or_before_date=date(2024, 1, 31),\n        )\n        self.assertEqual(last.pk, r3.pk)\n\n        first = SiteReport.objects.first_on_or_after_date_for_series(\n            report_name=SiteReport.ReportName.TOTAL,\n            on_or_after_date=date(2024, 1, 15),\n            on_or_before_date=date(2024, 1, 29),\n        )\n        self.assertEqual(first.pk, r2.pk)\n\n        self.assertEqual(r2.previous_in_series().pk, r1.pk)\n        self.assertEqual(r2.next_in_series().pk, r3.pk)\n\n        summed = SiteReport.objects.sum_assets_started_for_series_between_dates(\n            report_name=SiteReport.ReportName.TOTAL,\n            start_date=date(2024, 1, 1),\n            end_date=date(2024, 1, 31),\n        )\n        self.assertEqual(summed, 3 + 7 + 10)\n\n    def test_per_campaign_and_topic_series_filters(self):\n        camp = Campaign.objects.create(title=\"C1\", slug=\"c1\")\n        # Per-campaign series\n        d1 = self._aware(2023, 12, 1)\n        d2 = self._aware(2023, 12, 2)\n        s1 = self._mk_sr(dt=d1, campaign=camp, assets_total=1)\n        s2 = self._mk_sr(dt=d2, campaign=camp, assets_total=2)\n\n        prev = SiteReport.objects.previous_for_instance(s2)\n        nxt = SiteReport.objects.next_for_instance(s1)\n        self.assertEqual(prev.pk, s1.pk)\n        self.assertEqual(nxt.pk, s2.pk)\n\n        # Unspecified series (fallback), ensure no crash and no result\n        none_prev = SiteReport.objects.previous_in_series()\n        self.assertIsNone(none_prev)\n\n    def test__series_filter_campaign_branch(self):\n        camp = Campaign.objects.create(title=\"C\", slug=\"c\")\n        # Two rows in same per-campaign series\n        s1 = SiteReport.objects.create(campaign=camp, assets_total=1)\n        s2 = SiteReport.objects.create(campaign=camp, assets_total=2)\n\n        # Force a deterministic order\n        SiteReport.objects.filter(pk=s1.pk).update(created_on=self._aware(2024, 1, 1))\n        SiteReport.objects.filter(pk=s2.pk).update(created_on=self._aware(2024, 1, 2))\n\n        prev = SiteReport.objects.previous_in_series(\n            campaign=camp, before=self._aware(2024, 1, 3)\n        )\n        self.assertEqual(prev.pk, s2.pk)\n\n        # And last_on_or_before path also using campaign filter\n        last = SiteReport.objects.last_on_or_before_date_for_series(\n            campaign=camp, on_or_before_date=date(2024, 1, 2)\n        )\n        self.assertEqual(last.pk, s2.pk)\n\n    def test__series_filter_topic_branch(self):\n        topic = Topic.objects.create(title=\"T\", slug=\"t\")\n        s1 = SiteReport.objects.create(topic=topic, assets_total=1)\n        s2 = SiteReport.objects.create(topic=topic, assets_total=2)\n        SiteReport.objects.filter(pk=s1.pk).update(created_on=self._aware(2024, 2, 1))\n        SiteReport.objects.filter(pk=s2.pk).update(created_on=self._aware(2024, 2, 2))\n\n        first = SiteReport.objects.first_on_or_after_date_for_series(\n            topic=topic,\n            on_or_after_date=date(2024, 2, 1),\n            on_or_before_date=date(2024, 2, 5),\n        )\n        self.assertEqual(first.pk, s1.pk)\n\n    def test_series_filter_for_instance_topic_branch(self):\n        topic = Topic.objects.create(title=\"T2\", slug=\"t2\")\n        a = SiteReport.objects.create(topic=topic, assets_total=10)\n        b = SiteReport.objects.create(topic=topic, assets_total=20)\n\n        SiteReport.objects.filter(pk=a.pk).update(created_on=self._aware(2024, 3, 1))\n        SiteReport.objects.filter(pk=b.pk).update(created_on=self._aware(2024, 3, 2))\n\n        # IMPORTANT: refresh to pick up the updated created_on values\n        a.refresh_from_db()\n        b.refresh_from_db()\n\n        self.assertEqual(b.previous_in_series().pk, a.pk)\n        self.assertEqual(a.next_in_series().pk, b.pk)\n\n    def test_series_filter_for_instance_retired_and_fallback(self):\n        r = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.RETIRED_TOTAL, assets_total=1\n        )\n        # With only a single row, previous/next resolve via the RETIRED series Q()\n        self.assertIsNone(r.previous_in_series())\n        self.assertIsNone(r.next_in_series())\n\n        blank = SiteReport.objects.create(assets_total=3)  # report_name=\"\"\n        self.assertIsNone(blank.previous_in_series())\n        self.assertIsNone(blank.next_in_series())\n\n    def test_to_debug_dict_includes_related_fields_and_counters(self):\n        camp = Campaign.objects.create(title=\"CTitle\", slug=\"cslug\")\n        topic = Topic.objects.create(title=\"TTitle\", slug=\"tslug\")\n\n        sr_campaign = SiteReport.objects.create(\n            campaign=camp, assets_total=9, assets_published=3\n        )\n        sr_topic = SiteReport.objects.create(\n            topic=topic, items_published=4, items_unpublished=1\n        )\n\n        d1 = sr_campaign.to_debug_dict()\n        self.assertIn(\"campaign\", d1)\n        self.assertEqual(d1[\"campaign\"][\"id\"], camp.id)\n        self.assertEqual(d1[\"campaign\"][\"title\"], \"CTitle\")\n        self.assertEqual(d1[\"campaign\"][\"slug\"], \"cslug\")\n        self.assertIn(\"counters\", d1)\n        self.assertEqual(d1[\"counters\"][\"assets_total\"], 9)\n        self.assertEqual(d1[\"counters\"][\"assets_published\"], 3)\n\n        d2 = sr_topic.to_debug_dict()\n        self.assertIn(\"topic\", d2)\n        self.assertEqual(d2[\"topic\"][\"id\"], topic.id)\n        self.assertEqual(d2[\"topic\"][\"title\"], \"TTitle\")\n        self.assertEqual(d2[\"topic\"][\"slug\"], \"tslug\")\n        self.assertEqual(d2[\"counters\"][\"items_published\"], 4)\n        self.assertEqual(d2[\"counters\"][\"items_unpublished\"], 1)\n\n    def test_first_on_or_after_with_upper_bound_campaign(self):\n        camp = Campaign.objects.create(title=\"C-Bound\", slug=\"c-bound\")\n        a = SiteReport.objects.create(campaign=camp)\n        b = SiteReport.objects.create(campaign=camp)\n        SiteReport.objects.filter(pk=a.pk).update(created_on=self._aware(2024, 5, 1))\n        SiteReport.objects.filter(pk=b.pk).update(created_on=self._aware(2024, 5, 2))\n\n        out = SiteReport.objects.first_on_or_after_date_for_series(\n            campaign=camp,\n            on_or_after_date=date(2024, 5, 1),\n            on_or_before_date=date(2024, 5, 1),\n        )\n        self.assertEqual(out.pk, a.pk)\n\n    def test_previous_in_series_defaults_to_now(self):\n        r1 = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        r2 = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=r1.pk).update(created_on=self._aware(2024, 1, 10))\n        SiteReport.objects.filter(pk=r2.pk).update(created_on=self._aware(2024, 1, 20))\n        out = SiteReport.objects.previous_in_series(\n            report_name=SiteReport.ReportName.TOTAL\n        )\n        self.assertEqual(out.pk, r2.pk)\n\n    def test_sum_assets_started_treats_null_as_zero(self):\n        r1 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL, assets_started=None\n        )\n        r2 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL, assets_started=None\n        )\n        SiteReport.objects.filter(pk=r1.pk).update(created_on=self._aware(2024, 2, 1))\n        SiteReport.objects.filter(pk=r2.pk).update(created_on=self._aware(2024, 2, 2))\n        total = SiteReport.objects.sum_assets_started_for_series_between_dates(\n            report_name=SiteReport.ReportName.TOTAL,\n            start_date=date(2024, 2, 1),\n            end_date=date(2024, 2, 28),\n        )\n        self.assertEqual(total, 0)\n\n    def test_to_debug_dict_campaign_status_and_topic_loop(self):\n        camp = Campaign.objects.create(\n            title=\"Camp\", slug=\"camp\"\n        )  # status has a default\n        topic = Topic.objects.create(title=\"Top\", slug=\"top\")\n\n        sr_campaign = SiteReport.objects.create(campaign=camp, assets_total=1)\n        sr_topic = SiteReport.objects.create(topic=topic, assets_total=2)\n\n        d1 = sr_campaign.to_debug_dict()\n        self.assertIn(\"campaign\", d1)\n        # ensure the loop includes all three fields, including status\n        self.assertEqual(d1[\"campaign\"][\"title\"], \"Camp\")\n        self.assertEqual(d1[\"campaign\"][\"slug\"], \"camp\")\n        self.assertIn(\"status\", d1[\"campaign\"])\n\n        d2 = sr_topic.to_debug_dict()\n        self.assertIn(\"topic\", d2)\n        # ensure the loop includes both fields for topic\n        self.assertEqual(d2[\"topic\"][\"title\"], \"Top\")\n        self.assertEqual(d2[\"topic\"][\"slug\"], \"top\")\n\n    def test_to_debug_json_serializes_and_includes_counters(self):\n        camp = Campaign.objects.create(title=\"CJ\", slug=\"cj\")\n        sr = SiteReport.objects.create(\n            campaign=camp, assets_total=4, assets_published=2\n        )\n        out = sr.to_debug_json()\n        parsed = json.loads(out)\n\n        # basic shape checks\n        self.assertIn(\"created_on\", parsed)  # ISO string\n        self.assertEqual(parsed[\"report_name\"], \"\")\n        self.assertEqual(parsed[\"campaign\"][\"id\"], camp.id)\n\n        # counters included and numeric values preserved\n        self.assertEqual(parsed[\"counters\"][\"assets_total\"], 4)\n        self.assertEqual(parsed[\"counters\"][\"assets_published\"], 2)\n\n    def test_first_on_or_after_without_upper_bound_topic(self):\n        # Create two topic reports; query without an upper bound s\n        # hould still return the earliest on/after.\n        topic = Topic.objects.create(title=\"UBT\", slug=\"ubt\")\n        s1 = SiteReport.objects.create(topic=topic)\n        s2 = SiteReport.objects.create(topic=topic)\n        SiteReport.objects.filter(pk=s1.pk).update(created_on=self._aware(2024, 6, 1))\n        SiteReport.objects.filter(pk=s2.pk).update(created_on=self._aware(2024, 6, 2))\n\n        out = SiteReport.objects.first_on_or_after_date_for_series(\n            topic=topic,\n            on_or_after_date=date(2024, 6, 2),\n            # no on_or_before_date here on purpose\n        )\n        self.assertEqual(out.pk, s2.pk)\n\n    def test_to_debug_dict_skips_none_campaign_attrs(self):\n        # Force the related-object cache to a stub that lacks some attrs\n        from types import SimpleNamespace\n\n        camp = Campaign.objects.create(title=\"C\", slug=\"c\")\n        sr = SiteReport.objects.create(campaign=camp, assets_total=1)\n\n        # Populate fields_cache so descriptor returns this stub instead of hitting DB\n        sr._state.fields_cache[\"campaign\"] = SimpleNamespace(title=\"OnlyTitle\")\n        d = sr.to_debug_dict()\n\n        self.assertIn(\"campaign\", d)\n        self.assertEqual(d[\"campaign\"][\"id\"], camp.id)\n        # title present, slug/status omitted because getattr(...) returned None\n        self.assertEqual(d[\"campaign\"][\"title\"], \"OnlyTitle\")\n        self.assertNotIn(\"slug\", d[\"campaign\"])\n        self.assertNotIn(\"status\", d[\"campaign\"])\n\n    def test_to_debug_dict_skips_none_topic_attrs(self):\n        # Force the related-object cache to a stub that lacks one of the looped attrs\n        from types import SimpleNamespace\n\n        t = Topic.objects.create(title=\"TT\", slug=\"tt\")\n        sr = SiteReport.objects.create(topic=t, assets_total=2)\n\n        sr._state.fields_cache[\"topic\"] = SimpleNamespace(slug=\"only-slug\")\n        d = sr.to_debug_dict()\n\n        self.assertIn(\"topic\", d)\n        self.assertEqual(d[\"topic\"][\"id\"], t.id)\n        # slug present, title omitted because getattr(...) returned None\n        self.assertEqual(d[\"topic\"][\"slug\"], \"only-slug\")\n        self.assertNotIn(\"title\", d[\"topic\"])\n\n\nclass KeyMetricsReportTestCase(TestCase):\n    def _aware(self, y, m, d, hh=12, mm=0, ss=0):\n        tz = timezone.get_current_timezone()\n        return timezone.make_aware(datetime(y, m, d, hh, mm, ss), tz)\n\n    def _mk_sr(self, dt, report_name, **counters):\n        sr = SiteReport.objects.create(\n            report_name=report_name,\n            **counters,\n        )\n        SiteReport.objects.filter(pk=sr.pk).update(created_on=dt)\n        return SiteReport.objects.get(pk=sr.pk)\n\n    def test_helpers(self):\n        # FY math\n        self.assertEqual(\n            KeyMetricsReport.get_fiscal_year_for_date(date(2023, 10, 1)),\n            2024,\n        )\n        self.assertEqual(\n            KeyMetricsReport.get_fiscal_year_for_date(date(2024, 9, 30)),\n            2024,\n        )\n        self.assertEqual(\n            KeyMetricsReport.get_fiscal_quarter_for_date(date(2024, 2, 1)),\n            2,\n        )\n        self.assertEqual(\n            KeyMetricsReport.get_fiscal_quarter_for_date(date(2024, 10, 1)),\n            1,\n        )\n        # Month bounds (leap year Feb)\n        first, last = KeyMetricsReport.month_bounds(date(2024, 2, 10))\n        self.assertEqual(first, date(2024, 2, 1))\n        self.assertEqual(last, date(2024, 2, 29))\n\n    def test_upsert_month_from_sitereports(self):\n        # Baselines at 2023-12-31; EOM at 2024-01-31\n        base_dt = self._aware(2023, 12, 31, 9, 0, 0)\n        eom_dt = self._aware(2024, 1, 31, 23, 0, 0)\n\n        # TOTAL baseline + EOM\n        self._mk_sr(\n            base_dt,\n            SiteReport.ReportName.TOTAL,\n            assets_published=100,\n            assets_completed=50,\n            users_activated=10,\n            anonymous_transcriptions=5,\n            transcriptions_saved=20,\n            tag_uses=40,\n        )\n        self._mk_sr(\n            eom_dt,\n            SiteReport.ReportName.TOTAL,\n            assets_published=130,\n            assets_completed=70,\n            users_activated=16,\n            anonymous_transcriptions=8,\n            transcriptions_saved=26,\n            tag_uses=50,\n        )\n\n        # RETIRED_TOTAL baseline + EOM\n        self._mk_sr(\n            base_dt,\n            SiteReport.ReportName.RETIRED_TOTAL,\n            assets_published=10,\n            assets_completed=5,\n            users_activated=1,\n            anonymous_transcriptions=2,\n            transcriptions_saved=3,\n            tag_uses=4,\n        )\n        self._mk_sr(\n            eom_dt,\n            SiteReport.ReportName.RETIRED_TOTAL,\n            assets_published=15,\n            assets_completed=8,\n            users_activated=2,\n            anonymous_transcriptions=3,\n            transcriptions_saved=5,\n            tag_uses=6,\n        )\n\n        # Daily assets_started within the month (sums to 15 + 5 = 20)\n        self._mk_sr(\n            self._aware(2024, 1, 10),\n            SiteReport.ReportName.TOTAL,\n            assets_started=10,\n        )\n        self._mk_sr(\n            self._aware(2024, 1, 20),\n            SiteReport.ReportName.TOTAL,\n            assets_started=5,\n        )\n        self._mk_sr(\n            self._aware(2024, 1, 11),\n            SiteReport.ReportName.RETIRED_TOTAL,\n            assets_started=3,\n        )\n        self._mk_sr(\n            self._aware(2024, 1, 21),\n            SiteReport.ReportName.RETIRED_TOTAL,\n            assets_started=2,\n        )\n\n        # Upsert month\n        m = KeyMetricsReport.upsert_month(year=2024, month=1)\n        self.assertIsNotNone(m)\n        self.assertEqual(m.fiscal_year, 2024)\n        self.assertEqual(m.fiscal_quarter, 2)\n        self.assertEqual(m.month, 1)\n\n        # Deltas: see analysis; expect 35, 23, 7, 4, 8, 12 and started=20\n        self.assertEqual(m.assets_published, 35)\n        self.assertEqual(m.assets_completed, 23)\n        self.assertEqual(m.users_activated, 7)\n        self.assertEqual(m.anonymous_transcriptions, 4)\n        self.assertEqual(m.transcriptions_saved, 8)\n        self.assertEqual(m.tag_uses, 12)\n        self.assertEqual(m.assets_started, 20)\n\n        # __str__ + filenames\n        self.assertIn(\"FY2024M01\", str(m))\n        self.assertTrue(m.csv_filename().startswith(\"key_metrics_monthly_fy2024\"))\n\n    def test_upsert_quarter_and_fiscal_year_rollups(self):\n        # Create monthly rows for FY2024 Q2 (Jan & Feb present)\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 1, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=1,\n            assets_published=10,\n            assets_started=2,\n            assets_completed=3,\n            users_activated=5,\n            anonymous_transcriptions=7,\n            transcriptions_saved=11,\n            tag_uses=13,\n            crowd_visits=None,\n            avg_visit_seconds=Decimal(\"10.50\"),\n        )\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 2, 1),\n            period_end=date(2024, 2, 29),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=2,\n            assets_published=20,\n            assets_started=3,\n            assets_completed=4,\n            users_activated=6,\n            anonymous_transcriptions=8,\n            transcriptions_saved=12,\n            tag_uses=14,\n            # manual present in Feb only\n            crowd_visits=100,\n            avg_visit_seconds=None,\n        )\n\n        # Quarter upsert should sum calc fields; manual sums only when present\n        q2 = KeyMetricsReport.upsert_quarter(fiscal_year=2024, fiscal_quarter=2)\n        self.assertIsNotNone(q2)\n        self.assertEqual(q2.assets_published, 30)\n        self.assertEqual(q2.assets_started, 5)\n        self.assertEqual(q2.assets_completed, 7)\n        self.assertEqual(q2.users_activated, 11)\n        self.assertEqual(q2.anonymous_transcriptions, 15)\n        self.assertEqual(q2.transcriptions_saved, 23)\n        self.assertEqual(q2.tag_uses, 27)\n        # Manual: only Feb had a value, so total=100, avg from Jan only\n        self.assertEqual(q2.crowd_visits, 100)\n        self.assertEqual(q2.avg_visit_seconds, Decimal(\"10.50\"))\n\n        # Fiscal year rollup on FY2024 should equal Jan+Feb (for now)\n        fy = KeyMetricsReport.upsert_fiscal_year(fiscal_year=2024)\n        self.assertIsNotNone(fy)\n        self.assertEqual(fy.assets_published, 30)\n        self.assertEqual(fy.crowd_visits, 100)\n        self.assertEqual(fy.avg_visit_seconds, Decimal(\"10.50\"))\n\n        # String and filenames\n        self.assertIn(\"FY2024 Q2\", str(q2))\n        self.assertTrue(q2.csv_filename().startswith(\"key_metrics_quarterly_fy2024\"))\n        self.assertIn(\"FY2024 Report\", str(fy))\n        self.assertTrue(fy.csv_filename().startswith(\"key_metrics_fiscal_year_fy2024\"))\n\n    def test___str___fallback_when_fields_incomplete(self):\n        # QUARTERLY without fiscal_quarter so fallback label path\n        q = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 4, 1),\n            period_end=date(2024, 6, 30),\n            fiscal_year=2024,\n            fiscal_quarter=None,\n        )\n        s = str(q)\n        self.assertIn(\"KeyMetricsReport QUARTERLY\", s)\n        self.assertIn(\"2024-04-01\", s)\n        self.assertIn(\"2024-06-30\", s)\n\n        # MONTHLY without month so fallback label path\n        m = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 5, 1),\n            period_end=date(2024, 5, 31),\n            fiscal_year=2024,\n            month=None,\n        )\n        s2 = str(m)\n        self.assertIn(\"KeyMetricsReport MONTHLY\", s2)\n\n    def test_quarter_helper_edges(self):\n        # Q3 and Q4 branches\n        self.assertEqual(\n            KeyMetricsReport.get_fiscal_quarter_for_date(date(2024, 4, 1)), 3\n        )\n        self.assertEqual(\n            KeyMetricsReport.get_fiscal_quarter_for_date(date(2024, 7, 1)), 4\n        )\n\n    def test__format_value_for_csv_variants(self):\n        rep = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2023, 10, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n        )\n\n        self.assertEqual(rep._format_value_for_csv(\"crowd_visits\", None), \"\")\n\n        # Manual Decimal (avg_visit_seconds) to string with 2 decimals\n        self.assertEqual(\n            rep._format_value_for_csv(\"avg_visit_seconds\", Decimal(\"10\")),\n            \"10.00\",\n        )\n\n        self.assertEqual(rep._format_value_for_csv(\"crowd_visits\", 0), 0)\n\n        self.assertEqual(rep._format_value_for_csv(\"assets_started\", None), 0)\n\n        # Unknown field fallback: None to \"\", non-None to value passthrough\n        self.assertEqual(rep._format_value_for_csv(\"unknown_field\", None), \"\")\n        self.assertEqual(rep._format_value_for_csv(\"unknown_field\", \"x\"), \"x\")\n\n    def test_upsert_month_returns_none_when_no_snapshots(self):\n        out = KeyMetricsReport.upsert_month(year=2025, month=6)\n        self.assertIsNone(out)\n\n    def test_upsert_quarter_invalid_quarter_raises(self):\n        with self.assertRaises(ValueError):\n            KeyMetricsReport.upsert_quarter(fiscal_year=2024, fiscal_quarter=5)\n\n    def test_quarter_month_specs_all_quarters(self):\n        # Q1\n        q1 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2023, 10, 1),\n            period_end=date(2023, 12, 31),\n            fiscal_year=2024,\n            fiscal_quarter=1,\n        )\n        self.assertEqual(\n            q1._quarter_month_specs(), [(2023, 10), (2023, 11), (2023, 12)]\n        )\n\n        # Q3\n        q3 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 4, 1),\n            period_end=date(2024, 6, 30),\n            fiscal_year=2024,\n            fiscal_quarter=3,\n        )\n        self.assertEqual(q3._quarter_month_specs(), [(2024, 4), (2024, 5), (2024, 6)])\n\n        # Q4\n        q4 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 7, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n            fiscal_quarter=4,\n        )\n        self.assertEqual(q4._quarter_month_specs(), [(2024, 7), (2024, 8), (2024, 9)])\n\n    def test_month_bounds_handles_december(self):\n        first, last = KeyMetricsReport.month_bounds(date(2024, 12, 10))\n        self.assertEqual(first, date(2024, 12, 1))\n        self.assertEqual(last, date(2024, 12, 31))\n\n    def test__monthly_from_sitereports_returns_empty_dict_when_no_eom(self):\n        vals = KeyMetricsReport._monthly_from_sitereports(\n            month_start=date(2030, 5, 1),\n            month_end=date(2030, 5, 31),\n        )\n        self.assertEqual(vals, {})  # no snapshots at all\n\n    def test__monthly_from_sitereports_baseline_fallback_inside_month(self):\n        # No snapshots before month start; first snapshot inside the month\n        start = date(2024, 3, 1)\n        end = date(2024, 3, 31)\n\n        # TOTAL: baseline inside month (10) -> EOM (15)\n        self._mk_sr(\n            self._aware(2024, 3, 5, 9, 0, 0),\n            SiteReport.ReportName.TOTAL,\n            assets_published=10,\n        )\n        self._mk_sr(\n            self._aware(2024, 3, 31, 23, 0, 0),\n            SiteReport.ReportName.TOTAL,\n            assets_published=15,\n        )\n\n        # RETIRED: baseline inside month (4) -> EOM (7)\n        self._mk_sr(\n            self._aware(2024, 3, 10, 9, 0, 0),\n            SiteReport.ReportName.RETIRED_TOTAL,\n            assets_published=4,\n        )\n        self._mk_sr(\n            self._aware(2024, 3, 31, 23, 0, 0),\n            SiteReport.ReportName.RETIRED_TOTAL,\n            assets_published=7,\n        )\n\n        vals = KeyMetricsReport._monthly_from_sitereports(\n            month_start=start, month_end=end\n        )\n        # delta should be (15+7) - (10+4) = 8\n        self.assertEqual(vals[\"assets_published\"], 8)\n\n    def test__monthly_from_sitereports_treats_missing_series_as_zero(self):\n        # Only TOTAL snapshots; RETIRED series absent\n        start = date(2024, 4, 1)\n        end = date(2024, 4, 30)\n\n        # baseline inside month (100) -> EOM (110)\n        self._mk_sr(\n            self._aware(2024, 4, 5, 9, 0, 0),\n            SiteReport.ReportName.TOTAL,\n            assets_published=100,\n        )\n        self._mk_sr(\n            self._aware(2024, 4, 30, 23, 0, 0),\n            SiteReport.ReportName.TOTAL,\n            assets_published=110,\n        )\n\n        vals = KeyMetricsReport._monthly_from_sitereports(\n            month_start=start, month_end=end\n        )\n        # RETIRED contributes 0 via the helper that treats None as 0\n        self.assertEqual(vals[\"assets_published\"], 10)\n\n    def test_upsert_quarter_returns_none_when_no_monthlies_all_quarters(self):\n        # Q1\n        out1 = KeyMetricsReport.upsert_quarter(fiscal_year=2027, fiscal_quarter=1)\n        self.assertIsNone(out1)\n        # Q3\n        out3 = KeyMetricsReport.upsert_quarter(fiscal_year=2027, fiscal_quarter=3)\n        self.assertIsNone(out3)\n        # Q4\n        out4 = KeyMetricsReport.upsert_quarter(fiscal_year=2027, fiscal_quarter=4)\n        self.assertIsNone(out4)\n\n    def test_upsert_fiscal_year_returns_none_when_no_monthlies(self):\n        out = KeyMetricsReport.upsert_fiscal_year(fiscal_year=2029)\n        self.assertIsNone(out)\n\n    def test__calendar_year_for_month_in_fy_helper(self):\n        rep = KeyMetricsReport(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2023, 10, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n        )\n        # Oct in FY should map to previous calendar year\n        self.assertEqual(rep._calendar_year_for_month_in_fy(10, 2024), 2023)\n        # Jun in FY maps to the FY year\n        self.assertEqual(rep._calendar_year_for_month_in_fy(6, 2024), 2024)\n\n    def test_quarter_month_specs_q2(self):\n        q2 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n        self.assertEqual(q2._quarter_month_specs(), [(2024, 1), (2024, 2), (2024, 3)])\n\n\nclass KeyMetricsReportCsvTestCase(TestCase):\n    def setUp(self):\n        # FY2023 FY row (for lifetime math)\n        self.fy23 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2022, 10, 1),\n            period_end=date(2023, 9, 30),\n            fiscal_year=2023,\n            assets_published=50,\n            assets_started=5,\n            assets_completed=7,\n            users_activated=11,\n            anonymous_transcriptions=13,\n            transcriptions_saved=17,\n            tag_uses=19,\n            crowd_visits=30,\n            avg_visit_seconds=Decimal(\"9.00\"),\n        )\n\n        # FY2024 Q1 (for quarterly lifetime math on Q2)\n        self.q1_24 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2023, 10, 1),\n            period_end=date(2023, 12, 31),\n            fiscal_year=2024,\n            fiscal_quarter=1,\n            assets_published=7,\n            assets_started=1,\n            assets_completed=2,\n            users_activated=3,\n            anonymous_transcriptions=4,\n            transcriptions_saved=5,\n            tag_uses=6,\n            crowd_visits=None,\n            avg_visit_seconds=Decimal(\"8.00\"),\n        )\n\n        # FY2024 monthly rows for Q2: Jan, Feb present only\n        self.jan24 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 1, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=1,\n            assets_published=10,\n            assets_started=2,\n            assets_completed=3,\n            users_activated=5,\n            anonymous_transcriptions=7,\n            transcriptions_saved=11,\n            tag_uses=13,\n            crowd_visits=None,\n        )\n        self.feb24 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 2, 1),\n            period_end=date(2024, 2, 29),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=2,\n            assets_published=20,\n            assets_started=3,\n            assets_completed=4,\n            users_activated=6,\n            anonymous_transcriptions=8,\n            transcriptions_saved=12,\n            tag_uses=14,\n            crowd_visits=100,\n        )\n\n        # Upsert Q2 and FY2024 so we can render CSVs with proper totals\n        self.q2_24 = KeyMetricsReport.upsert_quarter(\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n        self.fy24 = KeyMetricsReport.upsert_fiscal_year(fiscal_year=2024)\n\n    def _csv_as_lines(self, rep: KeyMetricsReport) -> list[list[str]]:\n        raw = rep.render_csv().decode(\"utf-8\")\n        return [line.split(\",\") for line in raw.strip().splitlines()]\n\n    def test_monthly_csv_headers_and_values(self):\n        # Build a synthetic single-month report to test header label only\n        m = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 6, 1),\n            period_end=date(2024, 6, 30),\n            fiscal_year=2024,\n            fiscal_quarter=3,\n            month=6,\n            assets_published=1,\n        )\n        lines = self._csv_as_lines(m)\n        # Header: \"Metric\", \"<Month name only>\"\n        self.assertEqual(lines[0][0], \"Metric\")\n        self.assertEqual(lines[0][1], \"June\")\n\n        # One known metric row check\n        labels = [row[0] for row in lines[1:]]\n        vals = [row[1] for row in lines[1:]]\n        pub_idx = labels.index(\"Assets published\")\n        self.assertEqual(int(vals[pub_idx]), 1)\n\n    def test_quarterly_csv_headers_totals_and_lifetime(self):\n        lines = self._csv_as_lines(self.q2_24)\n        header = lines[0]\n\n        # Months present (Jan, Feb), then \"FY24 Q2 totals\", \"FY24 Lifetime totals\"\n        self.assertEqual(header[0], \"Metric\")\n        self.assertIn(\"January\", header)\n        self.assertIn(\"February\", header)\n        self.assertIn(\"FY24 Q2 totals\", header)\n        self.assertIn(\"FY24 Lifetime totals\", header)\n\n        # Assets published row:\n        # Jan(10), Feb(20) => quarter total=30\n        # Lifetime = FY2023 FY(50) + FY2024 Q1(7) = 57\n        labels = [row[0] for row in lines[1:]]\n        ap_idx = labels.index(\"Assets published\")\n        row = lines[1 + ap_idx]\n        # [label, Jan, Feb, Q2 total, Lifetime]\n        self.assertEqual(int(row[1]), 10)\n        self.assertEqual(int(row[2]), 20)\n        self.assertEqual(int(row[3]), 30)\n        self.assertEqual(int(row[4]), 57)\n\n        # Manual example (Crowd.loc.gov visits):\n        # Jan(None), Feb(100) => Q2 total=100 (not blank)\n        # Lifetime = FY2023 FY(30) + Q1(None) => 30\n        cv_idx = labels.index(\"Crowd.loc.gov visits\")\n        row2 = lines[1 + cv_idx]\n        self.assertEqual(row2[1], \"\")  # January empty\n        self.assertEqual(int(row2[2]), 100)\n        self.assertEqual(int(row2[3]), 100)\n        self.assertEqual(int(row2[4]), 30)\n\n    def test_fiscal_year_csv_headers_totals_and_lifetime(self):\n        # Ensure FY rows exist for lifetime (FY2023 and FY2024 already present)\n        lines = self._csv_as_lines(self.fy24)\n        header = lines[0]\n\n        # Header pattern:\n        # Metric | (FY24 Q1 totals if present) | Q2 totals | Q3 totals? | Q4 totals?\n        # | FY24 totals | FY24 Lifetime totals\n        self.assertEqual(header[0], \"Metric\")\n        self.assertIn(\"FY24 Q1 totals\", header)\n        self.assertIn(\"Q2 totals\", header)\n        self.assertIn(\"FY24 totals\", header)\n        self.assertIn(\"FY24 Lifetime totals\", header)\n\n        labels = [row[0] for row in lines[1:]]\n        ap_idx = labels.index(\"Assets published\")\n        row = lines[1 + ap_idx]\n\n        # With our setup:\n        # Q1 assets_published=7 (preset), Q2=30 (from jan+feb),\n        # year total = 37, lifetime = FY2023 FY(50) + FY2024 FY(37) = 87\n        # Header columns could be: Metric, FY24 Q1 totals, Q2 totals,\n        # FY24 totals, FY24 Lifetime totals (Q3/Q4 absent)\n        # Find indices dynamically.\n        h = header\n        q1_i = h.index(\"FY24 Q1 totals\")\n        q2_i = h.index(\"Q2 totals\")\n        yt_i = h.index(\"FY24 totals\")\n        lt_i = h.index(\"FY24 Lifetime totals\")\n\n        self.assertEqual(int(row[q1_i]), 7)\n        self.assertEqual(int(row[q2_i]), 30)\n        self.assertEqual(int(row[yt_i]), 37)\n        expected_lifetime = self.fy23.assets_published + self.fy24.assets_published\n        self.assertEqual(int(row[lt_i]), expected_lifetime)\n\n    def test_str_formats(self):\n        # Monthly string covers Oct (calendar year is fy-1)\n        oct_row = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2023, 10, 1),\n            period_end=date(2023, 10, 31),\n            fiscal_year=2024,\n            fiscal_quarter=1,\n            month=10,\n        )\n        s = str(oct_row)\n        self.assertIn(\"FY2024M10\", s)\n        self.assertIn(\"(October 2023)\", s)\n\n    def test_quarterly_csv_when_no_monthlies_and_no_priors(self):\n        # Create a standalone quarterly row with no monthly rows in that quarter\n        q = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2019, 10, 1),\n            period_end=date(2019, 12, 31),\n            fiscal_year=2020,\n            fiscal_quarter=1,\n        )\n        lines = self._csv_as_lines(q)\n\n        # Header should be: Metric | FY25 Q3 totals | FY25 Lifetime totals\n        self.assertEqual(lines[0][0], \"Metric\")\n        self.assertIn(\"FY20 Q1 totals\", lines[0])\n        self.assertIn(\"FY20 Lifetime totals\", lines[0])\n        self.assertEqual(len(lines[0]), 3)\n\n        labels = [r[0] for r in lines[1:]]\n        # Calculated field: totals are numeric, lifetime is 0\n        ap_i = labels.index(\"Assets published\")\n        ap_row = lines[1 + ap_i]\n        self.assertEqual(int(ap_row[1]), 0)  # quarter total\n        self.assertEqual(int(ap_row[2]), 0)  # lifetime total\n\n        # Manual field: totals should be blank when no values\n        cv_i = labels.index(\"Crowd.loc.gov visits\")\n        cv_row = lines[1 + cv_i]\n        self.assertEqual(cv_row[1], \"\")  # quarter total blank\n        self.assertEqual(cv_row[2], \"\")  # lifetime total blank\n\n    def test_fiscal_year_csv_headers_when_q1_missing(self):\n        # Create an FY row and only Q2 and Q4 quarters for that FY\n        fy = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2025, 10, 1),\n            period_end=date(2026, 9, 30),\n            fiscal_year=2026,\n        )\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2026, 1, 1),\n            period_end=date(2026, 3, 31),\n            fiscal_year=2026,\n            fiscal_quarter=2,\n            assets_published=12,\n        )\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2026, 7, 1),\n            period_end=date(2026, 9, 30),\n            fiscal_year=2026,\n            fiscal_quarter=4,\n            assets_published=8,\n        )\n\n        lines = self._csv_as_lines(fy)\n        header = lines[0]\n\n        self.assertEqual(header[0], \"Metric\")\n        self.assertNotIn(\"FY26 Q1 totals\", header)\n        self.assertIn(\"Q2 totals\", header)\n        self.assertNotIn(\"Q3 totals\", header)\n        self.assertIn(\"Q4 totals\", header)\n        self.assertIn(\"FY26 totals\", header)\n        self.assertIn(\"FY26 Lifetime totals\", header)\n\n        labels = [r[0] for r in lines[1:]]\n        ap_i = labels.index(\"Assets published\")\n        row = lines[1 + ap_i]\n\n        q2_i = header.index(\"Q2 totals\")\n        q4_i = header.index(\"Q4 totals\")\n        yt_i = header.index(\"FY26 totals\")\n        lt_i = header.index(\"FY26 Lifetime totals\")\n\n        self.assertEqual(int(row[q2_i]), 12)\n        self.assertEqual(int(row[q4_i]), 8)\n        self.assertEqual(int(row[yt_i]), 20)\n\n        # Lifetime sums all FY rows <= 2026 (FY2023 + FY2024 + FY2026)\n        expected_lifetime = (\n            self.fy23.assets_published + self.fy24.assets_published + 0\n        )  # FY2026 FY row has no stored value in this test\n        self.assertEqual(int(row[lt_i]), expected_lifetime)\n\n    def test_format_value_for_csv_non_decimal_avg(self):\n        rep = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2025, 10, 1),\n            period_end=date(2026, 9, 30),\n            fiscal_year=2026,\n        )\n        self.assertEqual(\n            rep._format_value_for_csv(\"avg_visit_seconds\", 12.3),\n            \"12.3\",\n        )\n"
  },
  {
    "path": "concordia/tests/test_parser.py",
    "content": "from types import SimpleNamespace\nfrom unittest import mock\n\nimport requests\nfrom django.test import TestCase\nfrom requests.models import Response\n\nimport concordia.parser as parser_mod\nfrom concordia.parser import extract_og_image, fetch_blog_posts, paginate_blog_posts\n\nTITLE = \"What’s New Online at the Library of Congress: May 2025\"\nLINK = \"https://blogs.loc.gov/thesignal/2025/05/new-loc-may-2025/\"\nRSS = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<rss version=\"2.0\">\n  <channel>\n    <title>The Signal</title>\n    <item>\n      <title>%s</title>\n      <link>%s</link>\n      <description><![CDATA[Interested in learning more about what’...]]></description>\n    </item>\n    <item>\n      <title>Volunteers Leverage OCR to Transcribe Library of Congress Digit...</title>\n      <description>\n        <![CDATA[Today’s guest post is from Lauren Algee, a Senior Digital Collec...]]>\n      </description>\n    </item>\n  </channel>\n</rss>\"\"\" % (\n    TITLE,\n    LINK,\n)\nIMAGE = \"https://blogs.loc.gov/thesignal/files/2025/05/loc-2017698702.png\"\nHTML = \"\"\"<html>\n  <head>\n    <meta property=\"og:image\" content=\"%s\"/>\n  </head>\n  <body></body>\n</html>\"\"\" % IMAGE\n\n\nclass ParserTestCase(TestCase):\n    @mock.patch(\"requests.get\")\n    def test_extract_og_image(self, mock_urlopen):\n        mock_response = mock.MagicMock(spec=Response)\n        mock_response.text = HTML\n        mock_response.headers = {\"Content-Type\": \"text/html\"}\n        mock_urlopen.return_value = mock_response\n\n        image = extract_og_image(\"https://example.com/post1\")\n        self.assertEqual(image, IMAGE)\n\n    @mock.patch(\"concordia.parser.extract_og_image\")\n    @mock.patch(\"requests.get\")\n    def test_paginate_blog_posts(self, mock_urlopen, mock_extract_og_image):\n        mock_response = mock.MagicMock(spec=Response)\n        mock_response.content = RSS\n        mock_response.status_code = 200\n        mock_urlopen.return_value = mock_response\n\n        mock_extract_og_image.return_value = IMAGE\n\n        feed_items = paginate_blog_posts()\n\n        self.assertEqual(len(feed_items), 1)\n        self.assertEqual(len(feed_items[0]), 2)\n        feed_item = feed_items[0][0]\n        self.assertEqual(feed_item[\"title\"], TITLE)\n        self.assertEqual(feed_item[\"link\"], LINK)\n        self.assertEqual(feed_item[\"og_image\"], IMAGE)\n\n    @mock.patch(\"concordia.parser.structured_logger.warning\")\n    @mock.patch(\"concordia.parser.requests.get\")\n    def test_get_http_error(self, mock_get, mock_logger):\n        mock_response = mock.Mock()\n        mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(\n            \"500 Server Error\"\n        )\n        mock_get.return_value = mock_response\n        result = fetch_blog_posts()\n        self.assertEqual(result, [])\n        mock_logger.assert_called()\n\n    @mock.patch(\"concordia.parser.structured_logger.warning\")\n    @mock.patch(\"concordia.parser.requests.get\")\n    def test_get_exception_timeout(self, mock_get, mock_logger):\n        mock_get.side_effect = requests.exceptions.Timeout()\n        result = fetch_blog_posts()\n        self.assertEqual(result, [])\n        mock_logger.assert_called()\n\n    @mock.patch(\"concordia.parser.structured_logger.warning\")\n    @mock.patch(\"concordia.parser.requests.get\")\n    def test_get_connection_error(self, mock_get, mock_logger):\n        mock_get.side_effect = requests.exceptions.ConnectionError()\n        result = fetch_blog_posts()\n        self.assertEqual(result, [])\n        mock_logger.assert_called()\n\n    @mock.patch(\"concordia.parser.structured_logger.warning\")\n    @mock.patch(\"concordia.parser.requests.get\")\n    def test_get_request_exception(self, mock_get, mock_logger):\n        mock_get.side_effect = requests.exceptions.RequestException()\n        result = fetch_blog_posts()\n        self.assertEqual(result, [])\n        mock_logger.assert_called()\n        call_args, call_kwargs = mock_logger.call_args\n        self.assertEqual(\"blog_req_error\", call_kwargs[\"reason_code\"])\n\n    def test_ogimageparser_parses_meta_and_sets_og_image(self):\n        parser = parser_mod.OGImageParser()\n        html_document = (\n            \"<html><head>\"\n            '<meta property=\"og:title\" content=\"ignored\"/>'\n            '<meta property=\"og:image\" content=\"http://ex.com/img.png?x=Tom&amp;Jerry\"/>'\n            \"</head><body></body></html>\"\n        )\n        parser.feed(html_document.replace(\"&amp;\", \"&\"))\n        self.assertEqual(parser.og_image, \"http://ex.com/img.png?x=Tom&Jerry\")\n\n    @mock.patch.object(parser_mod.structured_logger, \"warning\")\n    @mock.patch.object(parser_mod.requests, \"get\")\n    def test_extract_og_image_request_exception_logs_and_returns_none(\n        self, requests_get_mock, logger_warning_mock\n    ):\n        requests_get_mock.side_effect = parser_mod.requests.RequestException\n        result = parser_mod.extract_og_image(\"http://ex.com/bad\")\n        self.assertIsNone(result)\n        self.assertEqual(\n            logger_warning_mock.call_args.kwargs.get(\"reason_code\"),\n            \"ogi_req_fail_fetch\",\n        )\n\n    @mock.patch.object(parser_mod, \"extract_og_image\", return_value=\"fetched.png\")\n    @mock.patch.object(parser_mod, \"cache\")\n    def test_get_og_image_calls_extract_on_cache_miss(\n        self, cache_mock, extract_og_image_mock\n    ):\n        cache_mock.get.return_value = None\n        value = parser_mod.get_og_image(\"http://ex.com/post2\")\n        self.assertEqual(value, \"fetched.png\")\n        extract_og_image_mock.assert_called_once_with(\"http://ex.com/post2\")\n\n    @mock.patch.object(parser_mod, \"extract_og_image\")\n    @mock.patch.object(parser_mod, \"cache\")\n    def test_get_og_image_uses_cache_when_present(\n        self, cache_mock, extract_og_image_mock\n    ):\n        cache_mock.get.return_value = \"cached.png\"\n        value = parser_mod.get_og_image(\"http://ex.com/post\")\n        self.assertEqual(value, \"cached.png\")\n        extract_og_image_mock.assert_not_called()\n\n    def _make_item_element(self, title, link):\n        def find(tag):\n            if tag == \"title\":\n                return SimpleNamespace(text=title)\n            if tag == \"link\":\n                return SimpleNamespace(text=link)\n            return None\n\n        return SimpleNamespace(find=find)\n\n    @mock.patch.object(parser_mod, \"get_og_image\")\n    @mock.patch.object(parser_mod, \"fetch_blog_posts\")\n    def test_paginate_blog_posts_segments_and_includes_og_images(\n        self, fetch_blog_posts_mock, get_og_image_mock\n    ):\n        items = [\n            self._make_item_element(f\"T{i}\", f\"http://ex.com/{i}\") for i in range(1, 7)\n        ]\n\n        def get_og_image_side_effect(url):\n            n = int(url.rsplit(\"/\", 1)[-1])\n            return f\"http://img/{n}.png\" if n <= 4 else None\n\n        fetch_blog_posts_mock.return_value = items\n        get_og_image_mock.side_effect = get_og_image_side_effect\n\n        segmented = paginate_blog_posts()\n        self.assertEqual(len(segmented), 2)\n        self.assertEqual(len(segmented[0]), 3)\n        self.assertEqual(len(segmented[1]), 3)\n\n        first = segmented[0][0]\n        self.assertEqual(first[\"title\"], \"T1\")\n        self.assertEqual(first[\"link\"], \"http://ex.com/1\")\n        self.assertEqual(first[\"og_image\"], \"http://img/1.png\")\n\n        last = segmented[1][2]\n        self.assertEqual(last[\"title\"], \"T6\")\n        self.assertEqual(last[\"link\"], \"http://ex.com/6\")\n        self.assertNotIn(\"og_image\", last)\n\n    @mock.patch.object(parser_mod, \"fetch_blog_posts\", return_value=[])\n    def test_paginate_blog_posts_with_no_items_returns_single_empty_segment(\n        self, fetch_blog_posts_mock\n    ):\n        segmented = paginate_blog_posts()\n        self.assertEqual(segmented, [[]])\n"
  },
  {
    "path": "concordia/tests/test_registration_views.py",
    "content": "\"\"\"\nTests for user registration-related views\n\"\"\"\n\nfrom logging import getLogger\nfrom unittest import mock\n\nfrom django.contrib.auth import get_user_model\nfrom django.contrib.auth.tokens import default_token_generator\nfrom django.core import mail\nfrom django.test import TestCase, override_settings, tag\nfrom django.urls import reverse\nfrom django.utils.encoding import force_bytes\nfrom django.utils.http import urlsafe_base64_encode\n\nfrom .utils import CacheControlAssertions, CreateTestUsers, JSONAssertMixin\n\nUser = get_user_model()\n\n\nlogger = getLogger(__name__)\n\n\nINTERNAL_RESET_URL_TOKEN = \"set-password\"  # nosec\nINTERNAL_RESET_SESSION_TOKEN = \"_password_reset_token\"  # nosec\n\n\n@override_settings(RATELIMIT_ENABLE=False)\n@tag(\"registration\")\nclass ConcordiaViewTests(\n    JSONAssertMixin, CacheControlAssertions, TestCase, CreateTestUsers\n):\n    def test_send_activation_email_on_inactive_login(self):\n        self.user = self.create_inactive_user(\"tester\")\n\n        response = self.client.post(\n            reverse(\"registration_login\"),\n            {\"username\": self.user.username, \"password\": self.user._password},\n        )\n\n        self.assertContains(response, \"This account has not yet been activated.\")\n\n        self.assertEqual(len(mail.outbox), 1)\n\n    def test_inactive_user_can_password_reset(self):\n        self.user = self.create_inactive_user(\"tester\")\n\n        self.client.post(reverse(\"password_reset\"), {\"email\": self.user.email})\n\n        self.assertEqual(len(mail.outbox), 1)\n\n    @mock.patch(\"concordia.forms.user_activated.send\")\n    def test_password_reset_will_activate_user(self, signal_mock):\n        self.user = self.create_inactive_user(\"tester2\")\n        fake_pw = \"ASdf12&&\"\n        new_password_data = {\"new_password1\": fake_pw, \"new_password2\": fake_pw}\n        password_reset_token = default_token_generator.make_token(self.user)\n        uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk))\n\n        session = self.client.session\n        session[INTERNAL_RESET_SESSION_TOKEN] = password_reset_token\n        session.save()\n\n        confirm_response = self.client.post(\n            reverse(\n                \"password_reset_confirm\",\n                kwargs={\"uidb64\": uidb64, \"token\": INTERNAL_RESET_URL_TOKEN},\n            ),\n            new_password_data,\n        )\n\n        self.assertRedirects(confirm_response, \"/account/reset/done/\")\n        self.assertUncacheable(confirm_response)\n\n        # Verify the User was correctly activated\n        updated_user = User.objects.get(pk=self.user.pk)\n        self.assertEqual(updated_user.is_active, True)\n\n        # Verify activation signal was sent\n        self.assertTrue(signal_mock.called)\n\n    @mock.patch(\"concordia.forms.user_activated.send\")\n    def test_password_reset_with_activate_user(self, signal_mock):\n        self.user = self.create_user(\"tester\")\n        fake_pw = \"ASdf12&&\"\n        new_password_data = {\"new_password1\": fake_pw, \"new_password2\": fake_pw}\n        password_reset_token = default_token_generator.make_token(self.user)\n        uidb64 = urlsafe_base64_encode(force_bytes(self.user.pk))\n\n        session = self.client.session\n        session[INTERNAL_RESET_SESSION_TOKEN] = password_reset_token\n        session.save()\n\n        confirm_response = self.client.post(\n            reverse(\n                \"password_reset_confirm\",\n                kwargs={\"uidb64\": uidb64, \"token\": INTERNAL_RESET_URL_TOKEN},\n            ),\n            new_password_data,\n        )\n\n        self.assertRedirects(confirm_response, \"/account/reset/done/\")\n        self.assertUncacheable(confirm_response)\n\n        # Verify the User is still activated\n        updated_user = User.objects.get(pk=self.user.pk)\n        self.assertEqual(updated_user.is_active, True)\n\n        # Verify activation signal was not sent\n        self.assertFalse(signal_mock.called)\n"
  },
  {
    "path": "concordia/tests/test_s3.py",
    "content": "import os\nfrom unittest.mock import MagicMock, patch\n\nfrom django.core.files.base import ContentFile\nfrom django.test import TestCase, override_settings\n\nfrom .utils import create_asset\n\n\nclass S3StorageAPITest(TestCase):\n    def setUp(self):\n        super().setUp()\n        # Reset ASSET_STORAGE so it's evaluated with\n        # the new settings\n        from concordia.storage import ASSET_STORAGE\n\n        ASSET_STORAGE._wrapped = None\n\n    def tearDown(self):\n        # Reset ASSET_STORAGE so it doesn't keep\n        # the overriden settings in future tests\n        from concordia.storage import ASSET_STORAGE\n\n        ASSET_STORAGE._wrapped = None\n        ASSET_STORAGE._setup()\n        super().tearDown()\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\n                \"BACKEND\": \"storages.backends.s3boto3.S3Boto3Storage\",\n            },\n            \"assets\": {\n                \"BACKEND\": \"storages.backends.s3boto3.S3Boto3Storage\",\n                \"OPTIONS\": {\n                    \"querystring_auth\": False,\n                },\n            },\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    @patch.dict(\n        os.environ,\n        {\n            # Force static creds so botocore uses the \"env\" provider\n            \"AWS_ACCESS_KEY_ID\": \"test\",\n            \"AWS_SECRET_ACCESS_KEY\": \"test\",\n            \"AWS_SESSION_TOKEN\": \"test\",\n            \"AWS_DEFAULT_REGION\": \"us-east-1\",\n            # Prevent profile/config-based resolution and IMDS\n            \"AWS_SDK_LOAD_CONFIG\": \"0\",\n            \"AWS_EC2_METADATA_DISABLED\": \"true\",\n        },\n        clear=False,\n    )\n    @patch(\"botocore.auth.SigV4Auth.add_auth\")\n    @patch(\"botocore.endpoint.Endpoint._send\")\n    def test_s3_upload_api_layer(self, mock_send, mock_add_auth):\n        # Set up mocked response to prevent real network call\n        mock_response = MagicMock()\n        mock_response.status_code = 200\n        mock_response.headers = {}\n        mock_response.content = b\"\"\n        mock_send.return_value = mock_response\n\n        with patch.dict(\n            os.environ,\n            {\n                # Force the env-credentials provider to win\n                \"AWS_ACCESS_KEY_ID\": \"test\",\n                \"AWS_SECRET_ACCESS_KEY\": \"test\",\n                \"AWS_SESSION_TOKEN\": \"test\",\n                \"AWS_DEFAULT_REGION\": \"us-east-1\",\n                # Make boto ignore shared config and IMDS\n                \"AWS_SDK_LOAD_CONFIG\": \"0\",\n                \"AWS_EC2_METADATA_DISABLED\": \"true\",\n            },\n            clear=False,  # keep PATH, HOME, etc.\n        ):\n            # Ensure AWS_PROFILE is truly absent (not an empty string)\n            # Setting it to an empty string causes an error because\n            # boto tries to use it as a profile name\n            os.environ.pop(\"AWS_PROFILE\", None)\n            os.environ.pop(\"AWS_DEFAULT_PROFILE\", None)\n\n            # We import this here to stop it from being\n            # evaluated before we override the storage settings\n            from concordia.storage import ASSET_STORAGE\n\n            ASSET_STORAGE._setup()\n\n            # Simulate manually saving to the storage backend\n            asset_image_filename = \"test-campaign/test-project/1.jpg\"\n            content = ContentFile(b\"abc123\", name=\"test.jpg\")\n\n            ASSET_STORAGE.save(asset_image_filename, content)\n            asset = create_asset(storage_image=asset_image_filename)\n\n            self.assertTrue(asset.storage_image.name.endswith(\"1.jpg\"))\n            mock_send.assert_called()\n"
  },
  {
    "path": "concordia/tests/test_selenium.py",
    "content": "import json\nfrom logging import getLogger\nfrom secrets import token_hex\n\nfrom django.conf import settings\nfrom django.contrib.staticfiles.testing import StaticLiveServerTestCase\nfrom django.template.loader import render_to_string\nfrom django.test import tag\nfrom django.urls import reverse\nfrom pylenium.config import PyleniumConfig\nfrom pylenium.driver import Pylenium\n\nfrom .axe import Axe\nfrom .utils import CreateTestUsers, create_simple_page\n\nlogger = getLogger(__name__)\n\n\n@tag(\"selenium\", \"axe\")\nclass SeleniumTests(CreateTestUsers, StaticLiveServerTestCase):\n    @classmethod\n    def setUpClass(cls):\n        super().setUpClass()\n        try:\n            with open(settings.PYLENIUM_CONFIG) as file:\n                _json = json.load(file)\n            config = PyleniumConfig(**_json)\n        except FileNotFoundError:\n            logger.warning(\n                \"settings.PYLENIUM_CONFIG (%s) was not found; using defaults.\",\n                settings.PYLENIUM_CONFIG,\n            )\n            config = PyleniumConfig()\n\n        cls.py = Pylenium(config)\n        cls.axe = Axe(cls.py)\n\n    @classmethod\n    def tearDownClass(cls):\n        cls.py.quit()\n        super().tearDownClass()\n\n    def reverse(self, name):\n        return f\"{self.live_server_url}{reverse(name)}\"\n\n    def test_login(self):\n        self.py.viewport(1280, 800)\n        self.py.visit(self.reverse(\"registration_login\"))\n        violations = self.axe.violations()\n        self.assertEqual(len(violations), 0, self.axe.report(violations))\n\n        self.py.get(\"[name='username']\").type(token_hex(8))\n        self.py.get(\"[name='password']\").type(token_hex(24))\n        self.py.get(\"button#login\").click()\n        self.assertTrue(\n            self.py.should().have_url(f\"{self.live_server_url}/account/login/\")\n        )\n\n        violations = self.axe.violations()\n        self.assertEqual(len(violations), 0, self.axe.report(violations))\n\n        self.assertTrue(\n            self.py.get(\"form#login-form\")\n            .should()\n            .contain_text(\"Please enter a correct username and password\")\n        )\n\n        user = self.create_user(\"login-test\")\n        self.py.visit(self.reverse(\"registration_login\"))\n        self.py.get(\"[name='username']\").type(user.username)\n        self.py.get(\"[name='password']\").type(user._password)\n        self.py.get(\"button#login\").click()\n\n        violations = self.axe.violations()\n        self.assertEqual(len(violations), 0, self.axe.report(violations))\n\n    def test_blog_carousel(self):\n        context = {\"blog_posts\": [[{}], [{}]]}\n        html_string = render_to_string(\"fragments/featured_blog_posts.html\", context)\n        create_simple_page(path=\"/about/\", title=\"About\", body=html_string)\n        self.py.visit(self.reverse(\"about\"))\n\n        carousel = self.py.get(\"#blog-carousel\")\n        self.assertTrue(carousel.should().be_visible())\n\n        inner = carousel.get(\".carousel-inner\")\n        items = inner.find(\".carousel-item\")\n        self.assertGreater(len(items), 1, \"No carousel items found\")\n\n        active_items = [\n            item for item in items if \"active\" in item.get_attribute(\"class\")\n        ]\n        self.assertEqual(len(active_items), 1)\n"
  },
  {
    "path": "concordia/tests/test_sentry.py",
    "content": "import importlib\nimport os\nfrom unittest import mock\n\nfrom django.test import TestCase\n\nfrom concordia import celery\n\n\nclass TestSentry(TestCase):\n    @mock.patch.dict(\n        os.environ,\n        {\n            \"SENTRY_BACKEND_DSN\": \"http://example.com\",\n            \"CONCORDIA_ENVIRONMENT\": \"dummy_environment\",\n        },\n    )\n    def test_sentry_config(self):\n        # Because the celery module is imported during start up,\n        # we need to reload it after patching Sentry.\n        # release and integrations aren't tested because they\n        # are impossible to mock due to the how everything is imported\n        # and the functions called are tested elsewhere\n        with mock.patch(\"concordia.celery.sentry_sdk.init\") as sentry_mock:\n            importlib.reload(celery)\n            sentry_mock.assert_called_with(\n                \"http://example.com\",\n                environment=\"dummy_environment\",\n                release=mock.ANY,\n                integrations=mock.ANY,\n            )\n"
  },
  {
    "path": "concordia/tests/test_signals.py",
    "content": "from unittest import mock\n\nfrom django.conf import settings\nfrom django.contrib.auth.models import Group\nfrom django.contrib.auth.signals import user_logged_in\nfrom django.core import mail\nfrom django.http import HttpResponse\nfrom django.test import RequestFactory, TestCase\nfrom django.urls import reverse\nfrom django.utils import timezone\nfrom django_registration.signals import user_activated, user_registered\nfrom structlog.contextvars import bind_contextvars, clear_contextvars\n\nfrom concordia.models import TranscriptionStatus\nfrom concordia.signals.handlers import add_request_id_to_response\n\nfrom .utils import CreateTestUsers, create_asset, create_transcription\n\n\nclass TestSignalHandlers(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.user = self.create_test_user()\n        self.asset = create_asset()\n        self.request_factory = RequestFactory()\n\n    def test_clear_reservation_token(self):\n        self.login_user()\n        response = self.client.get(reverse(\"redirect-to-next-transcribable-asset\"))\n        self.assertIsNotNone(self.client.session.get(\"reservation_token\"))\n        user_logged_in.send(\n            sender=self.__class__, user=self.user, request=response.wsgi_request\n        )\n        self.assertIsNone(self.client.session.get(\"reservation_token\"))\n\n    def test_user_successfully_activated(self):\n        with mock.patch(\"concordia.signals.handlers.flag_enabled\") as flag_mock:\n            flag_mock.return_value = True\n            response = self.client.get(\"/\")\n            request = response.wsgi_request\n            user_activated.send(sender=self.__class__, user=self.user, request=request)\n            self.assertTrue(request.user.is_authenticated)\n            self.assertEqual(len(mail.outbox), 1)\n\n    def test_user_successfully_activated_no_request(self):\n        with mock.patch(\"concordia.signals.handlers.flag_enabled\") as flag_mock:\n            flag_mock.return_value = True\n            user_activated.send(sender=self.__class__, user=self.user, request=None)\n            self.assertEqual(len(mail.outbox), 1)\n\n    def test_user_successfully_activated_no_welcome_email(self):\n        with mock.patch(\"concordia.signals.handlers.flag_enabled\") as flag_mock:\n            flag_mock.return_value = False\n            response = self.client.get(\"/\")\n            request = response.wsgi_request\n            user_activated.send(sender=self.__class__, user=self.user, request=request)\n            self.assertTrue(request.user.is_authenticated)\n            self.assertEqual(len(mail.outbox), 0)\n\n    def test_add_user_to_newsletter(self):\n        self.login_user()\n        response = self.client.post(\"/\")\n        user_registered.send(\n            sender=self.__class__, user=self.user, request=response.wsgi_request\n        )\n        self.assertNotIn(\n            self.user,\n            Group.objects.get(name=settings.NEWSLETTER_GROUP_NAME).user_set.all(),\n        )\n\n        response = self.client.post(\"/\", data={\"newsletterOptIn\": True})\n        user_registered.send(\n            sender=self.__class__, user=self.user, request=response.wsgi_request\n        )\n        self.assertIn(\n            self.user,\n            Group.objects.get(name=settings.NEWSLETTER_GROUP_NAME).user_set.all(),\n        )\n\n\nclass UpdateAssetStatusSignalTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.user1 = self.create_user(\"user-1\")\n        self.user2 = self.create_user(\"user-2\")\n        self.asset = create_asset()\n\n    def test_accepted_transcription_sets_completed_status(self):\n        create_transcription(asset=self.asset, user=self.user1, accepted=timezone.now())\n\n        self.asset.refresh_from_db()\n        self.assertEqual(self.asset.transcription_status, TranscriptionStatus.COMPLETED)\n\n    def test_submitted_transcription_sets_submitted_status(self):\n        create_transcription(\n            asset=self.asset, user=self.user1, submitted=timezone.now()\n        )\n\n        self.asset.refresh_from_db()\n        self.assertEqual(self.asset.transcription_status, TranscriptionStatus.SUBMITTED)\n\n    def test_rejected_transcription_sets_in_progress_status(self):\n        create_transcription(asset=self.asset, user=self.user1, rejected=timezone.now())\n\n        self.asset.refresh_from_db()\n        self.assertEqual(\n            self.asset.transcription_status, TranscriptionStatus.IN_PROGRESS\n        )\n\n    def test_default_transcription_sets_in_progress_status(self):\n        create_transcription(asset=self.asset, user=self.user1)\n\n        self.asset.refresh_from_db()\n        self.assertEqual(\n            self.asset.transcription_status, TranscriptionStatus.IN_PROGRESS\n        )\n\n    def test_outdated_transcription_does_not_update_status(self):\n        t1 = create_transcription(\n            asset=self.asset, user=self.user1, submitted=timezone.now()\n        )\n        create_transcription(asset=self.asset, user=self.user2, accepted=timezone.now())\n\n        # Now \"re-save\" the older one to trigger the signal\n        # Expecting this save to trigger the warning logger since t1 is no longer latest\n        with self.assertLogs(\"concordia.signals.handlers\", level=\"WARNING\") as log_cm:\n            t1.rejected = timezone.now()\n            t1.save()\n\n        self.asset.refresh_from_db()\n        # Status should remain COMPLETED due to latest transcription not being t1\n        self.assertEqual(self.asset.transcription_status, TranscriptionStatus.COMPLETED)\n\n        # Verify that a warning was indeed logged about outdated transcription\n        self.assertTrue(\n            any(\"An older transcription\" in message for message in log_cm.output)\n        )\n        self.assertTrue(any(str(t1.id) in message for message in log_cm.output))\n        self.assertTrue(any(str(self.asset.id) in message for message in log_cm.output))\n\n    @mock.patch(\"concordia.signals.handlers.remove_next_asset_objects\")\n    @mock.patch(\"concordia.signals.handlers.calculate_difficulty_values\")\n    def test_tasks_called_on_latest_transcription(self, mock_calc, mock_remove):\n        create_transcription(asset=self.asset, user=self.user1, accepted=timezone.now())\n\n        mock_remove.assert_called_once_with(self.asset.id)\n        mock_calc.assert_called_once()\n        args, _ = mock_calc.call_args\n        self.assertEqual(list(args[0].values_list(\"pk\", flat=True)), [self.asset.pk])\n\n\nclass RequestIDHeaderTests(TestCase):\n    def setUp(self):\n        self.factory = RequestFactory()\n        clear_contextvars()\n        bind_contextvars(request_id=\"test-id-123\")\n\n    def tearDown(self):\n        clear_contextvars()\n\n    def make_response(self, cache_control_header=None):\n        response = HttpResponse(\"ok\")\n        if cache_control_header:\n            response[\"Cache-Control\"] = cache_control_header\n        return response\n\n    @mock.patch(\n        \"structlog.contextvars.get_merged_contextvars\",\n        return_value={\"request_id\": \"test-id-123\"},\n    )\n    def test_adds_header_when_no_cache_control(self, mock_contextvars):\n        response = self.make_response()\n        add_request_id_to_response(response=response, logger=None)\n        self.assertEqual(response[\"X-Request-ID\"], \"test-id-123\")\n\n    @mock.patch(\n        \"structlog.contextvars.get_merged_contextvars\",\n        return_value={\"request_id\": \"test-id-123\"},\n    )\n    def test_adds_header_when_private(self, mock_contextvars):\n        response = self.make_response(\"private, no-store\")\n        add_request_id_to_response(response=response, logger=None)\n        self.assertEqual(response[\"X-Request-ID\"], \"test-id-123\")\n\n    @mock.patch(\n        \"structlog.contextvars.get_merged_contextvars\",\n        return_value={\"request_id\": \"test-id-123\"},\n    )\n    def test_skips_header_when_public_with_max_age(self, mock_contextvars):\n        response = self.make_response(\"public, max-age=600\")\n        add_request_id_to_response(response=response, logger=None)\n        self.assertNotIn(\"X-Request-ID\", response)\n\n    @mock.patch(\n        \"structlog.contextvars.get_merged_contextvars\",\n        return_value={\"request_id\": \"test-id-123\"},\n    )\n    def test_adds_header_when_no_store_present(self, mock_contextvars):\n        response = self.make_response(\"public, no-store\")\n        add_request_id_to_response(response=response, logger=None)\n        self.assertEqual(response[\"X-Request-ID\"], \"test-id-123\")\n"
  },
  {
    "path": "concordia/tests/test_tasks_assets.py",
    "content": "from unittest import mock\nfrom unittest.mock import PropertyMock\n\nfrom django.test import TestCase\n\nfrom concordia.models import Asset, TranscriptionStatus\nfrom concordia.tasks.assets import (\n    calculate_difficulty_values,\n    fix_storage_images,\n    populate_asset_years,\n)\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_transcription,\n)\n\n\nclass CalculateDifficultyValuesTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.user1 = self.create_test_user(\"cdv-user-1\")\n        self.user2 = self.create_test_user(\"cdv-user-2\")\n        self.reviewer = self.create_test_user(\"cdv-reviewer\")\n        self.campaign = create_campaign(slug=\"cdv-c\")\n        self.project = create_project(campaign=self.campaign, slug=\"cdv-p\")\n        self.item = create_item(project=self.project, item_id=\"cdv-i\")\n\n    def test_no_changes_when_difficulty_matches(self):\n        asset = create_asset(item=self.item, slug=\"cdv-a1\")\n        # Default difficulty is zero and there are no transcriptions\n        updated = calculate_difficulty_values(Asset.objects.filter(pk=asset.pk))\n        self.assertEqual(updated, 0)\n        asset.refresh_from_db()\n        self.assertEqual(asset.difficulty, 0)\n\n    def test_updates_difficulty_for_explicit_queryset(self):\n        asset = create_asset(item=self.item, slug=\"cdv-a2\")\n        with mock.patch(\n            \"concordia.signals.handlers.calculate_difficulty_values\", return_value=None\n        ):\n            # Two transcriptions by two users and one reviewer\n            create_transcription(asset=asset, user=self.user1)\n            create_transcription(\n                asset=asset, user=self.user2, reviewed_by=self.reviewer\n            )\n\n        updated = calculate_difficulty_values(Asset.objects.filter(pk=asset.pk))\n        self.assertEqual(updated, 1)\n\n        asset.refresh_from_db()\n        # transcription_count is 2; transcriber_count is 2; reviewer_count is 1\n        # difficulty is 2 * (2 + 1), so difficulty should be 6\n        self.assertEqual(asset.difficulty, 6)\n\n    def test_default_published_queryset_and_chunking(self):\n        # Build 501 published assets so we traverse more than one chunk\n        first = None\n        last = None\n        for i in range(1, 502):\n            a = create_asset(\n                item=self.item,\n                slug=f\"cdv-bulk-{i}\",\n                sequence=i,\n            )\n            if i == 1:\n                first = a\n            if i == 501:\n                last = a\n\n        with mock.patch(\n            \"concordia.signals.handlers.calculate_difficulty_values\", return_value=None\n        ):\n            # Add one transcription to first and last to force two updates\n            create_transcription(asset=first, user=self.user1)\n            create_transcription(asset=last, user=self.user1)\n        updated = calculate_difficulty_values()\n        self.assertEqual(updated, 2)\n\n        first.refresh_from_db()\n        last.refresh_from_db()\n        self.assertEqual(first.difficulty, 1)\n        self.assertEqual(last.difficulty, 1)\n\n\nclass PopulateAssetYearsTests(TestCase):\n    def setUp(self):\n        self.campaign = create_campaign(slug=\"pay-c\")\n        self.project = create_project(campaign=self.campaign, slug=\"pay-p\")\n\n        self.item1 = create_item(project=self.project, item_id=\"pay-i1\")\n        self.asset1 = create_asset(item=self.item1, slug=\"pay-a1\")\n\n        self.item2 = create_item(project=self.project, item_id=\"pay-i2\")\n        self.asset2 = create_asset(item=self.item2, slug=\"pay-a2\")\n\n        # Ensure both assets have the metadata shape the task expects and that\n        # their current year matches that metadata so we can control which rows\n        # update in individual tests without KeyErrors or unintended updates.\n        self._set_metadata_dates(self.asset1, \"2000\")\n        self._set_metadata_dates(self.asset2, \"2000\")\n        Asset.objects.filter(pk__in=[self.asset1.pk, self.asset2.pk]).update(\n            year=\"2000\"\n        )\n\n    def _set_metadata_dates(self, asset, *years):\n        # Populate minimal metadata structure expected by the task\n        asset.item.metadata = {\n            \"item\": {\"dates\": [{y: {}} for y in years]},\n        }\n        asset.item.save(update_fields=[\"metadata\"])\n\n    def test_updates_year_from_last_date_key(self):\n        # Change asset1’s metadata so it needs an update; asset2 stays matched.\n        self._set_metadata_dates(self.asset1, \"1900\", \"1901\")\n        # Current year differs (2000), so an update should occur for asset1.\n        updated = populate_asset_years()\n        self.assertGreaterEqual(updated, 1)\n\n        self.asset1.refresh_from_db()\n        self.assertEqual(self.asset1.year, \"1901\")\n\n    def test_skips_when_year_unchanged(self):\n        # Keep asset1 year equal to its extracted year; asset2 is already matched\n        self._set_metadata_dates(self.asset1, \"1900\")\n        Asset.objects.filter(pk=self.asset1.pk).update(year=\"1900\")\n\n        updated = populate_asset_years()\n        self.assertEqual(updated, 0)\n\n    def test_multiple_assets_count_returned(self):\n        # Both assets should change\n        self._set_metadata_dates(self.asset1, \"1910\")\n        self._set_metadata_dates(self.asset2, \"1920\")\n\n        Asset.objects.filter(pk=self.asset1.pk).update(year=\"1900\")\n        Asset.objects.filter(pk=self.asset2.pk).update(year=\"1900\")\n\n        updated = populate_asset_years()\n        self.assertEqual(updated, 2)\n\n        self.asset1.refresh_from_db()\n        self.asset2.refresh_from_db()\n        self.assertEqual(self.asset1.year, \"1910\")\n        self.assertEqual(self.asset2.year, \"1920\")\n\n    def test_skips_empty_date_dicts_and_uses_last_year(self):\n        # Use truly empty dicts ({}) so the inner loop over keys is not entered for\n        # those entries; the task should still pick the last non-empty year.\n        self.asset1.item.metadata = {\n            \"item\": {\"dates\": [{}, {\"1955\": {}}, {}, {\"1957\": {}}]}\n        }\n        self.asset1.item.save(update_fields=[\"metadata\"])\n\n        # Ensure an update is needed.\n        Asset.objects.filter(pk=self.asset1.pk).update(year=\"2000\")\n\n        updated = populate_asset_years()\n        self.assertEqual(updated, 1)\n\n        self.asset1.refresh_from_db()\n        self.assertEqual(self.asset1.year, \"1957\")\n\n\nclass FixStorageImagesTests(TestCase):\n    def setUp(self):\n        self.campaign1 = create_campaign(slug=\"fsi-c1\")\n        self.project1 = create_project(campaign=self.campaign1, slug=\"fsi-p1\")\n        self.item1 = create_item(project=self.project1, item_id=\"fsi-i1\")\n\n        self.campaign2 = create_campaign(slug=\"fsi-c2\")\n        self.project2 = create_project(campaign=self.campaign2, slug=\"fsi-p2\")\n        self.item2 = create_item(project=self.project2, item_id=\"fsi-i2\")\n\n        self.asset1 = create_asset(\n            item=self.item1,\n            slug=\"fsi-a1\",\n            sequence=1,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        self.asset2 = create_asset(\n            item=self.item1,\n            slug=\"fsi-a2\",\n            sequence=2,\n            transcription_status=TranscriptionStatus.IN_PROGRESS,\n        )\n        self.asset3 = create_asset(\n            item=self.item2,\n            slug=\"fsi-a3\",\n            sequence=3,\n            transcription_status=TranscriptionStatus.SUBMITTED,\n        )\n\n    def test_skips_when_storage_image_exists(self):\n        with (\n            mock.patch(\n                \"django.core.files.storage.FileSystemStorage.exists\",\n                return_value=True,\n            ),\n            mock.patch(\"concordia.tasks.assets.requests.get\") as mock_get,\n            mock.patch(\"concordia.tasks.assets.ASSET_STORAGE.save\") as mock_save,\n        ):\n            fix_storage_images()\n            mock_get.assert_not_called()\n            mock_save.assert_not_called()\n\n    def test_downloads_and_saves_when_missing_success(self):\n        expected_filename = \"/\".join(\n            [\n                self.campaign1.slug,\n                self.project1.slug,\n                self.item1.item_id,\n                f\"{self.asset1.sequence}.jpg\",\n            ]\n        )\n\n        with (\n            mock.patch(\n                \"django.core.files.storage.FileSystemStorage.exists\",\n                return_value=False,\n            ),\n            mock.patch.object(\n                Asset,\n                \"download_url\",\n                new_callable=PropertyMock,\n                return_value=\"https://example.invalid/img.jpg\",\n            ),\n            mock.patch(\"concordia.tasks.assets.requests.get\") as mock_get,\n            mock.patch(\"concordia.tasks.assets.ASSET_STORAGE.save\") as mock_save,\n        ):\n            fake_response = mock.MagicMock()\n            fake_response.iter_content.return_value = [b\"abc\", b\"def\"]\n            fake_response.raise_for_status.return_value = None\n            mock_get.return_value = fake_response\n\n            fix_storage_images(campaign_slug=self.campaign1.slug)\n\n            mock_get.assert_called()\n            mock_save.assert_any_call(expected_filename, mock.ANY)\n\n    def test_raises_and_logs_when_save_fails(self):\n        with (\n            mock.patch(\n                \"django.core.files.storage.FileSystemStorage.exists\",\n                return_value=False,\n            ),\n            mock.patch.object(\n                Asset,\n                \"download_url\",\n                new_callable=PropertyMock,\n                return_value=\"https://example.invalid/img.jpg\",\n            ),\n            mock.patch(\"concordia.tasks.assets.requests.get\") as mock_get,\n            mock.patch(\n                \"concordia.tasks.assets.ASSET_STORAGE.save\",\n                side_effect=RuntimeError(\"save failed\"),\n            ),\n            mock.patch(\"concordia.tasks.assets.logger\") as mock_logger,\n        ):\n            fake_response = mock.MagicMock()\n            fake_response.iter_content.return_value = [b\"abc\"]\n            fake_response.raise_for_status.return_value = None\n            mock_get.return_value = fake_response\n\n            with self.assertRaises(RuntimeError):\n                fix_storage_images(campaign_slug=self.campaign1.slug)\n\n            self.assertTrue(mock_logger.exception.called)\n\n    def test_filters_by_campaign_and_asset_start_id(self):\n        with (\n            mock.patch(\n                \"django.core.files.storage.FileSystemStorage.exists\",\n                return_value=False,\n            ),\n            mock.patch.object(\n                Asset,\n                \"download_url\",\n                new_callable=PropertyMock,\n                return_value=\"https://example.invalid/img.jpg\",\n            ),\n            mock.patch(\"concordia.tasks.assets.requests.get\") as mock_get,\n            mock.patch(\"concordia.tasks.assets.ASSET_STORAGE.save\") as mock_save,\n        ):\n            fake_response = mock.MagicMock()\n            fake_response.iter_content.return_value = [b\"x\"]\n            fake_response.raise_for_status.return_value = None\n            mock_get.return_value = fake_response\n\n            fix_storage_images(\n                campaign_slug=self.campaign1.slug,\n                asset_start_id=self.asset2.id,\n            )\n\n            self.assertEqual(mock_save.call_count, 1)\n            expected_filename = \"/\".join(\n                [\n                    self.campaign1.slug,\n                    self.project1.slug,\n                    self.item1.item_id,\n                    f\"{self.asset2.sequence}.jpg\",\n                ]\n            )\n            mock_save.assert_called_with(expected_filename, mock.ANY)\n\n    def test_skips_when_storage_image_is_falsy(self):\n        # Make both campaign1 assets have a falsy storage_image, to\n        # ensure we handle that case sanely\n        Asset.objects.filter(pk__in=[self.asset1.pk, self.asset2.pk]).update(\n            storage_image=\"\"\n        )\n\n        with (\n            mock.patch(\n                \"django.core.files.storage.FileSystemStorage.exists\",\n                return_value=True,\n            ) as mock_exists,\n            mock.patch(\"concordia.tasks.assets.requests.get\") as mock_get,\n            mock.patch(\"concordia.tasks.assets.ASSET_STORAGE.save\") as mock_save,\n        ):\n            fix_storage_images(campaign_slug=self.campaign1.slug)\n\n            # Nothing should be fetched or saved when storage_image is falsy.\n            mock_get.assert_not_called()\n            mock_save.assert_not_called()\n            # And we should never even check existence for these assets.\n            mock_exists.assert_not_called()\n"
  },
  {
    "path": "concordia/tests/test_tasks_blog.py",
    "content": "from unittest import mock\n\nfrom django.test import TestCase\nfrom requests.models import Response\n\nfrom concordia.tasks.blog import fetch_and_cache_blog_images\n\n\nclass BlogTaskTestCase(TestCase):\n    @mock.patch(\"concordia.tasks.blog.extract_og_image\")\n    @mock.patch(\"concordia.parser.requests.get\")\n    def test_fetch_and_cache_blog_images(self, mock_get, mock_extract):\n        link1 = \"https://blogs.loc.gov/thesignal/2025/05/volunteers-ocr/\"\n        link2 = \"https://blogs.loc.gov/thesignal/2025/02/douglass-day-2025/\"\n        rss = \"\"\"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n        <rss version=\"2.0\">\n          <channel>\n            <item><link>%s</link></item><item><link>%s</link></item>\n          </channel>\n        </rss>\"\"\" % (\n            link1,\n            link2,\n        )\n        mock_response = mock.MagicMock(spec=Response)\n        mock_response.content = rss\n        mock_response.status_code = 200\n        mock_get.return_value = mock_response\n\n        # run the celery task\n        fetch_and_cache_blog_images()\n\n        mock_extract.assert_any_call(link1)\n        mock_extract.assert_any_call(link2)\n        self.assertEqual(mock_extract.call_count, 2)\n\n    @mock.patch(\"concordia.tasks.blog.extract_og_image\")\n    @mock.patch(\"concordia.tasks.blog.fetch_blog_posts\")\n    def test_skips_items_with_no_link(self, mock_fetch, mock_extract):\n        # Provide one item without a link and one with a link to make\n        # sure we handle no link correctly\n        class DummyLink:\n            def __init__(self, text):\n                self.text = text\n\n        class DummyItem:\n            def __init__(self, link):\n                self._link = link\n\n            def find(self, name):\n                return self._link if name == \"link\" else None\n\n        item_no_link = DummyItem(None)\n        item_with_link = DummyItem(DummyLink(\"https://example.invalid/post\"))\n        mock_fetch.return_value = [item_no_link, item_with_link]\n\n        fetch_and_cache_blog_images()\n\n        mock_extract.assert_called_once_with(\"https://example.invalid/post\")\n"
  },
  {
    "path": "concordia/tests/test_tasks_housekeeping.py",
    "content": "from unittest import mock\n\nfrom django.test import TestCase\n\nfrom concordia.tasks.housekeeping import clear_sessions\n\n\nclass ClearSessionsTaskTests(TestCase):\n    def test_calls_django_clearsessions_command(self):\n        # Verify the task invokes Django's clearsessions management command.\n        with mock.patch(\"concordia.tasks.housekeeping.call_command\") as mock_call:\n            result = clear_sessions()\n            self.assertIsNone(result)\n            mock_call.assert_called_once_with(\"clearsessions\")\n\n    def test_raises_when_call_command_fails(self):\n        # Ensure exceptions from the management command propagate.\n        with mock.patch(\n            \"concordia.tasks.housekeeping.call_command\",\n            side_effect=RuntimeError(\"boom\"),\n        ):\n            with self.assertRaises(RuntimeError):\n                clear_sessions()\n"
  },
  {
    "path": "concordia/tests/test_tasks_next_asset.py",
    "content": "from unittest import mock\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom concordia.models import (\n    NextReviewableCampaignAsset,\n    NextReviewableTopicAsset,\n    NextTranscribableCampaignAsset,\n    NextTranscribableTopicAsset,\n    TranscriptionStatus,\n)\nfrom concordia.tasks.next_asset.renew import renew_next_asset_cache\nfrom concordia.tasks.next_asset.reviewable import (\n    clean_next_reviewable_for_campaign,\n    clean_next_reviewable_for_topic,\n    populate_next_reviewable_for_campaign,\n    populate_next_reviewable_for_topic,\n)\nfrom concordia.tasks.next_asset.transcribable import (\n    clean_next_transcribable_for_campaign,\n    clean_next_transcribable_for_topic,\n    populate_next_transcribable_for_campaign,\n    populate_next_transcribable_for_topic,\n)\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_topic,\n    create_transcription,\n)\n\n\nclass PopulateNextAssetTasksTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(slug=\"test-asset-1\", title=\"Test Asset 1\")\n        self.asset2 = create_asset(\n            item=self.asset1.item, slug=\"test-asset-2\", title=\"Test Asset 2\"\n        )\n        self.topic = create_topic(project=self.asset1.item.project)\n        self.campaign = self.asset1.campaign\n\n    def test_populate_next_transcribable_for_campaign(self):\n        populate_next_transcribable_for_campaign(campaign_id=self.campaign.id)\n        self.assertEqual(\n            NextTranscribableCampaignAsset.objects.filter(\n                campaign=self.campaign\n            ).count(),\n            2,\n        )\n\n    def test_populate_next_transcribable_for_topic(self):\n        populate_next_transcribable_for_topic(topic_id=self.topic.id)\n        self.assertEqual(\n            NextTranscribableTopicAsset.objects.filter(topic=self.topic).count(), 2\n        )\n\n    def test_populate_next_reviewable_for_campaign(self):\n        create_transcription(\n            asset=self.asset1, user=self.anon, submitted=timezone.now()\n        )\n        create_transcription(\n            asset=self.asset2, user=self.user, submitted=timezone.now()\n        )\n        populate_next_reviewable_for_campaign(campaign_id=self.campaign.id)\n        self.assertEqual(\n            NextReviewableCampaignAsset.objects.filter(campaign=self.campaign).count(),\n            2,\n        )\n\n    def test_populate_next_reviewable_for_topic(self):\n        create_transcription(\n            asset=self.asset1, user=self.anon, submitted=timezone.now()\n        )\n        create_transcription(\n            asset=self.asset2, user=self.user, submitted=timezone.now()\n        )\n        populate_next_reviewable_for_topic(topic_id=self.topic.id)\n        self.assertEqual(\n            NextReviewableTopicAsset.objects.filter(topic=self.topic).count(), 2\n        )\n\n    @mock.patch(\"concordia.tasks.next_asset.transcribable.logger\")\n    def test_populate_next_transcribable_for_campaign_missing(self, mock_logger):\n        populate_next_transcribable_for_campaign(campaign_id=9999)\n        mock_logger.error.assert_called_once()\n\n    @mock.patch(\"concordia.tasks.next_asset.transcribable.logger\")\n    def test_populate_next_transcribable_for_topic_missing(self, mock_logger):\n        populate_next_transcribable_for_topic(topic_id=9999)\n        mock_logger.error.assert_called_once()\n\n    @mock.patch(\"concordia.tasks.next_asset.reviewable.logger\")\n    def test_populate_next_reviewable_for_campaign_missing(self, mock_logger):\n        populate_next_reviewable_for_campaign(campaign_id=9999)\n        mock_logger.error.assert_called_once()\n\n    @mock.patch(\"concordia.tasks.next_asset.reviewable.logger\")\n    def test_populate_next_reviewable_for_topic_missing(self, mock_logger):\n        populate_next_reviewable_for_topic(topic_id=9999)\n        mock_logger.error.assert_called_once()\n\n    @mock.patch(\"concordia.tasks.next_asset.transcribable.logger\")\n    def test_populate_next_transcribable_for_campaign_none_needed(self, mock_logger):\n        for i in range(3, 103):\n            asset = create_asset(item=self.asset1.item, slug=f\"dummy-{i}\")\n            NextTranscribableCampaignAsset.objects.create(\n                asset=asset,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                campaign=self.campaign,\n                sequence=asset.sequence,\n                transcription_status=asset.transcription_status,\n            )\n        populate_next_transcribable_for_campaign(campaign_id=self.campaign.id)\n        mock_logger.info.assert_any_call(\n            \"Campaign %s already has %s next transcribable assets\", self.campaign, 100\n        )\n\n    @mock.patch(\"concordia.tasks.next_asset.transcribable.logger\")\n    def test_populate_next_transcribable_for_topic_none_needed(self, mock_logger):\n        for i in range(3, 103):\n            asset = create_asset(item=self.asset1.item, slug=f\"dummy-{i}\")\n            NextTranscribableTopicAsset.objects.create(\n                asset=asset,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                topic=self.topic,\n                sequence=asset.sequence,\n                transcription_status=asset.transcription_status,\n            )\n        populate_next_transcribable_for_topic(topic_id=self.topic.id)\n        mock_logger.info.assert_any_call(\n            \"Topic %s already has %s next transcribable assets\", self.topic, 100\n        )\n\n    @mock.patch(\"concordia.tasks.next_asset.reviewable.logger\")\n    def test_populate_next_reviewable_for_campaign_none_needed(self, mock_logger):\n        create_transcription(\n            asset=self.asset1, user=self.user, submitted=timezone.now()\n        )\n        for i in range(3, 103):\n            asset = create_asset(item=self.asset1.item, slug=f\"r-{i}\")\n            create_transcription(asset=asset, user=self.user, submitted=timezone.now())\n            NextReviewableCampaignAsset.objects.create(\n                asset=asset,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                campaign=self.campaign,\n                sequence=asset.sequence,\n                transcriber_ids=[self.user.id],\n            )\n\n        populate_next_reviewable_for_campaign(campaign_id=self.campaign.id)\n        mock_logger.info.assert_any_call(\n            \"Campaign %s already has %s next reviewable assets\", self.campaign, 100\n        )\n\n    @mock.patch(\"concordia.tasks.next_asset.reviewable.logger\")\n    def test_populate_next_reviewable_for_topic_none_needed(self, mock_logger):\n        create_transcription(\n            asset=self.asset1, user=self.user, submitted=timezone.now()\n        )\n        for i in range(3, 103):\n            asset = create_asset(item=self.asset1.item, slug=f\"t-{i}\")\n            create_transcription(asset=asset, user=self.user, submitted=timezone.now())\n            NextReviewableTopicAsset.objects.create(\n                asset=asset,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                topic=self.topic,\n                sequence=asset.sequence,\n                transcriber_ids=[self.user.id],\n            )\n\n        populate_next_reviewable_for_topic(topic_id=self.topic.id)\n        mock_logger.info.assert_any_call(\n            \"Topic %s already has %s next reviewable assets\", self.topic, 100\n        )\n\n    @mock.patch(\"concordia.tasks.next_asset.reviewable.logger\")\n    def test_populate_next_reviewable_for_campaign_none_found(self, mock_logger):\n        create_transcription(\n            asset=self.asset1, user=self.user, submitted=timezone.now()\n        )\n\n        NextReviewableCampaignAsset.objects.create(\n            asset=self.asset1,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            campaign=self.campaign,\n            sequence=self.asset1.sequence,\n            transcriber_ids=[self.user.id],\n        )\n\n        populate_next_reviewable_for_campaign(campaign_id=self.campaign.id)\n        mock_logger.info.assert_any_call(\n            \"No reviewable assets found in campaign %s\", self.campaign\n        )\n\n    @mock.patch(\"concordia.tasks.next_asset.reviewable.logger\")\n    def test_populate_next_reviewable_for_topic_none_found(self, mock_logger):\n        create_transcription(\n            asset=self.asset1, user=self.user, submitted=timezone.now()\n        )\n\n        NextReviewableTopicAsset.objects.create(\n            asset=self.asset1,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            topic=self.topic,\n            sequence=self.asset1.sequence,\n            transcriber_ids=[self.user.id],\n        )\n\n        populate_next_reviewable_for_topic(topic_id=self.topic.id)\n        mock_logger.info.assert_any_call(\n            \"No reviewable assets found in topic %s\", self.topic\n        )\n\n    @mock.patch(\"concordia.tasks.next_asset.transcribable.logger\")\n    def test_populate_next_transcribable_for_campaign_none_found(self, mock_logger):\n        for asset in (self.asset1, self.asset2):\n            NextTranscribableCampaignAsset.objects.create(\n                asset=asset,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                campaign=self.campaign,\n                sequence=asset.sequence,\n                transcription_status=asset.transcription_status,\n            )\n\n        populate_next_transcribable_for_campaign(campaign_id=self.campaign.id)\n        mock_logger.info.assert_any_call(\n            \"No transcribable assets found in campaign %s\", self.campaign\n        )\n\n    @mock.patch(\"concordia.tasks.next_asset.transcribable.logger\")\n    def test_populate_next_transcribable_for_topic_none_found(self, mock_logger):\n        for asset in (self.asset1, self.asset2):\n            NextTranscribableTopicAsset.objects.create(\n                asset=asset,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                topic=self.topic,\n                sequence=asset.sequence,\n                transcription_status=asset.transcription_status,\n            )\n\n        populate_next_transcribable_for_topic(topic_id=self.topic.id)\n        mock_logger.info.assert_any_call(\n            \"No transcribable assets found in topic %s\", self.topic\n        )\n\n\nclass CleanNextAssetTasksTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.campaign = self.asset.campaign\n        self.topic = create_topic(project=self.asset.item.project)\n        self.campaign_transcribable = NextTranscribableCampaignAsset.objects.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.asset.item.project,\n            project_slug=self.asset.item.project.slug,\n            campaign=self.campaign,\n            sequence=self.asset.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        self.topic_transcribable = NextTranscribableTopicAsset.objects.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.asset.item.project,\n            project_slug=self.asset.item.project.slug,\n            topic=self.topic,\n            sequence=self.asset.sequence,\n            transcription_status=TranscriptionStatus.IN_PROGRESS,\n        )\n        self.campaign_reviewable = NextReviewableCampaignAsset.objects.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.asset.item.project,\n            project_slug=self.asset.item.project.slug,\n            campaign=self.campaign,\n            sequence=self.asset.sequence,\n        )\n        self.topic_reviewable = NextReviewableTopicAsset.objects.create(\n            asset=self.asset,\n            item=self.asset.item,\n            item_item_id=self.asset.item.item_id,\n            project=self.asset.item.project,\n            project_slug=self.asset.item.project.slug,\n            topic=self.topic,\n            sequence=self.asset.sequence,\n        )\n\n    @mock.patch(\n        \"concordia.tasks.next_asset.transcribable.populate_next_transcribable_for_campaign.delay\"\n    )\n    def test_clean_next_transcribable_for_campaign(self, mock_delay):\n        self.asset.transcription_status = TranscriptionStatus.COMPLETED\n        self.asset.save()\n        clean_next_transcribable_for_campaign(self.campaign.id)\n        self.assertFalse(\n            NextTranscribableCampaignAsset.objects.filter(\n                campaign=self.campaign\n            ).exists()\n        )\n        mock_delay.assert_called_once_with(self.campaign.id)\n\n    @mock.patch(\n        \"concordia.tasks.next_asset.transcribable.populate_next_transcribable_for_topic.delay\"\n    )\n    def test_clean_next_transcribable_for_topic(self, mock_delay):\n        self.asset.transcription_status = TranscriptionStatus.COMPLETED\n        self.asset.save()\n        clean_next_transcribable_for_topic(self.topic.id)\n        self.assertFalse(\n            NextTranscribableTopicAsset.objects.filter(topic=self.topic).exists()\n        )\n        mock_delay.assert_called_once_with(self.topic.id)\n\n    @mock.patch(\n        \"concordia.tasks.next_asset.reviewable.populate_next_reviewable_for_campaign.delay\"\n    )\n    def test_clean_next_reviewable_for_campaign(self, mock_delay):\n        self.asset.transcription_status = TranscriptionStatus.IN_PROGRESS\n        self.asset.save()\n        clean_next_reviewable_for_campaign(self.campaign.id)\n        self.assertFalse(\n            NextReviewableCampaignAsset.objects.filter(campaign=self.campaign).exists()\n        )\n        mock_delay.assert_called_once_with(self.campaign.id)\n\n    @mock.patch(\n        \"concordia.tasks.next_asset.reviewable.populate_next_reviewable_for_topic.delay\"\n    )\n    def test_clean_next_reviewable_for_topic(self, mock_delay):\n        self.asset.transcription_status = TranscriptionStatus.NOT_STARTED\n        self.asset.save()\n        clean_next_reviewable_for_topic(self.topic.id)\n        self.assertFalse(\n            NextReviewableTopicAsset.objects.filter(topic=self.topic).exists()\n        )\n        mock_delay.assert_called_once_with(self.topic.id)\n\n    @mock.patch(\n        \"concordia.tasks.next_asset.reviewable.clean_next_reviewable_for_campaign.delay\"\n    )\n    @mock.patch(\n        \"concordia.tasks.next_asset.transcribable.clean_next_transcribable_for_campaign.delay\"\n    )\n    @mock.patch(\n        \"concordia.tasks.next_asset.reviewable.clean_next_reviewable_for_topic.delay\"\n    )\n    @mock.patch(\n        \"concordia.tasks.next_asset.transcribable.clean_next_transcribable_for_topic.delay\"\n    )\n    def test_renew_next_asset_cache(\n        self,\n        mock_clean_trans_topic,\n        mock_clean_rev_topic,\n        mock_clean_trans_campaign,\n        mock_clean_rev_campaign,\n    ):\n        renew_next_asset_cache()\n        mock_clean_trans_campaign.assert_called_once_with(campaign_id=self.campaign.id)\n        mock_clean_rev_campaign.assert_called_once_with(campaign_id=self.campaign.id)\n        mock_clean_trans_topic.assert_called_once_with(topic_id=self.topic.id)\n        mock_clean_rev_topic.assert_called_once_with(topic_id=self.topic.id)\n\n    @mock.patch(\"concordia.tasks.next_asset.transcribable.logger\")\n    def test_clean_next_transcribable_for_campaign_exception(self, mock_logger):\n        with mock.patch.object(\n            self.campaign_transcribable, \"delete\", side_effect=Exception(\"fail\")\n        ):\n            with mock.patch(\n                \"concordia.tasks.next_asset.transcribable.find_invalid_next_transcribable_campaign_assets\",\n                return_value=[self.campaign_transcribable],\n            ):\n                clean_next_transcribable_for_campaign(self.campaign.id)\n        mock_logger.exception.assert_called_once()\n\n    @mock.patch(\"concordia.tasks.next_asset.transcribable.logger\")\n    def test_clean_next_transcribable_for_topic_exception(self, mock_logger):\n        with mock.patch.object(\n            self.topic_transcribable, \"delete\", side_effect=Exception(\"fail\")\n        ):\n            with mock.patch(\n                \"concordia.tasks.next_asset.transcribable.find_invalid_next_transcribable_topic_assets\",\n                return_value=[self.topic_transcribable],\n            ):\n                clean_next_transcribable_for_topic(self.topic.id)\n        mock_logger.exception.assert_called_once()\n\n    @mock.patch(\"concordia.tasks.next_asset.reviewable.logger\")\n    def test_clean_next_reviewable_for_campaign_exception(self, mock_logger):\n        with mock.patch.object(\n            self.campaign_reviewable, \"delete\", side_effect=Exception(\"fail\")\n        ):\n            with mock.patch(\n                \"concordia.tasks.next_asset.reviewable.find_invalid_next_reviewable_campaign_assets\",\n                return_value=[self.campaign_reviewable],\n            ):\n                clean_next_reviewable_for_campaign(self.campaign.id)\n        mock_logger.exception.assert_called_once()\n\n    @mock.patch(\"concordia.tasks.next_asset.reviewable.logger\")\n    def test_clean_next_reviewable_for_topic_exception(self, mock_logger):\n        with mock.patch.object(\n            self.topic_reviewable, \"delete\", side_effect=Exception(\"fail\")\n        ):\n            with mock.patch(\n                \"concordia.tasks.next_asset.reviewable.find_invalid_next_reviewable_topic_assets\",\n                return_value=[self.topic_reviewable],\n            ):\n                clean_next_reviewable_for_topic(self.topic.id)\n        mock_logger.exception.assert_called_once()\n"
  },
  {
    "path": "concordia/tests/test_tasks_reports_backfill.py",
    "content": "from django.test import TestCase\nfrom django.utils import timezone\n\nfrom concordia.models import Campaign, SiteReport, Topic\nfrom concordia.tasks.reports.backfill import (\n    backfill_assets_started_for_site_reports,\n)\n\n\nclass BackfillAssetsStartedTaskTests(TestCase):\n    def _dt(self, days_ago):\n        return timezone.now() - timezone.timedelta(days=days_ago)\n\n    def test_updates_total_and_skips_existing_by_default(self):\n        # Three TOTAL rows in time order. The last is already populated and\n        # should be skipped in default mode.\n        r1 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_total=100,\n            assets_not_started=100,\n            assets_published=10,\n            assets_started=None,\n        )\n        r2 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_total=107,\n            assets_not_started=92,\n            assets_published=17,\n            assets_started=None,\n        )\n        r3 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_total=100,\n            assets_not_started=90,\n            assets_published=20,\n            assets_started=5,\n        )\n\n        dt1 = self._dt(3)\n        dt2 = self._dt(2)\n        dt3 = self._dt(1)\n\n        SiteReport.objects.filter(pk=r1.pk).update(created_on=dt1)\n        SiteReport.objects.filter(pk=r2.pk).update(created_on=dt2)\n        SiteReport.objects.filter(pk=r3.pk).update(created_on=dt3)\n\n        # The TOTAL series assets_started is now expected to be rolled up from\n        # per-campaign series that were generated on the same reporting day.\n        camp1 = Campaign.objects.create(title=\"C1\", slug=\"c1\")\n        camp2 = Campaign.objects.create(title=\"C2\", slug=\"c2\")\n\n        c1_prev = SiteReport.objects.create(\n            campaign=camp1,\n            assets_total=50,\n            assets_not_started=50,\n            assets_published=0,\n            assets_started=None,\n        )\n        c1_curr = SiteReport.objects.create(\n            campaign=camp1,\n            assets_total=50,\n            assets_not_started=40,\n            assets_published=0,\n            assets_started=None,\n        )\n\n        c2_prev = SiteReport.objects.create(\n            campaign=camp2,\n            assets_total=50,\n            assets_not_started=50,\n            assets_published=0,\n            assets_started=None,\n        )\n        c2_curr = SiteReport.objects.create(\n            campaign=camp2,\n            assets_total=52,\n            assets_not_started=47,\n            assets_published=0,\n            assets_started=None,\n        )\n\n        SiteReport.objects.filter(pk=c1_prev.pk).update(created_on=dt1)\n        SiteReport.objects.filter(pk=c1_curr.pk).update(created_on=dt2)\n        SiteReport.objects.filter(pk=c2_prev.pk).update(created_on=dt1)\n        SiteReport.objects.filter(pk=c2_curr.pk).update(created_on=dt2)\n\n        updated = backfill_assets_started_for_site_reports.run()\n        # Campaign series (2 campaigns x 2 rows) + TOTAL series (2 rows).\n        self.assertEqual(updated, 6)\n\n        r1.refresh_from_db()\n        r2.refresh_from_db()\n        r3.refresh_from_db()\n        c1_prev.refresh_from_db()\n        c1_curr.refresh_from_db()\n        c2_prev.refresh_from_db()\n        c2_curr.refresh_from_db()\n\n        self.assertEqual(c1_prev.assets_started, 0)\n        self.assertEqual(c1_curr.assets_started, 10)\n        self.assertEqual(c2_prev.assets_started, 0)\n        self.assertEqual(c2_curr.assets_started, 5)\n\n        self.assertEqual(r1.assets_started, 0)\n        self.assertEqual(r2.assets_started, 15)\n        self.assertEqual(r3.assets_started, 5)\n\n    def test_recompute_when_skip_existing_is_false(self):\n        # Build a TOTAL series with two rows. Make the first row have a wrong,\n        # non-null assets_started so it should be recomputed when\n        # skip_existing is False.\n        #\n        # The TOTAL series assets_started is expected to be rolled up from\n        # per-campaign series generated on the same reporting day.\n        now = timezone.now()\n\n        prev = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_total=100,\n            assets_not_started=100,\n            assets_published=10,\n        )\n        curr = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_total=105,\n            assets_not_started=90,\n            assets_published=15,\n        )\n\n        dt_prev = now - timezone.timedelta(days=2)\n        dt_curr = now - timezone.timedelta(days=1)\n\n        # Enforce chronological order for the iterator\n        SiteReport.objects.filter(pk=prev.pk).update(created_on=dt_prev)\n        SiteReport.objects.filter(pk=curr.pk).update(created_on=dt_curr)\n\n        # Wrong non-null on first row, null on second.\n        SiteReport.objects.filter(pk=prev.pk).update(assets_started=5)\n        SiteReport.objects.filter(pk=curr.pk).update(assets_started=None)\n\n        camp = Campaign.objects.create(title=\"C\", slug=\"c\")\n\n        c_prev = SiteReport.objects.create(\n            campaign=camp,\n            assets_total=100,\n            assets_not_started=100,\n            assets_published=0,\n            assets_started=None,\n        )\n        c_curr = SiteReport.objects.create(\n            campaign=camp,\n            assets_total=105,\n            assets_not_started=90,\n            assets_published=0,\n            assets_started=None,\n        )\n        SiteReport.objects.filter(pk=c_prev.pk).update(created_on=dt_prev)\n        SiteReport.objects.filter(pk=c_curr.pk).update(created_on=dt_curr)\n\n        updated = backfill_assets_started_for_site_reports.run(skip_existing=False)\n        # Campaign series (2 rows) + TOTAL series (2 rows).\n        self.assertEqual(updated, 4)\n\n        prev_refreshed = SiteReport.objects.get(pk=prev.pk)\n        curr_refreshed = SiteReport.objects.get(pk=curr.pk)\n        c_prev.refresh_from_db()\n        c_curr.refresh_from_db()\n\n        self.assertEqual(c_prev.assets_started, 0)\n        self.assertEqual(c_curr.assets_started, 15)\n\n        self.assertEqual(prev_refreshed.assets_started, 0)\n        self.assertEqual(curr_refreshed.assets_started, 15)\n\n    def test_processes_retired_campaign_and_topic_series(self):\n        # One RETIRED_TOTAL row\n        rt = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.RETIRED_TOTAL,\n            assets_not_started=10,\n            assets_published=2,\n            assets_started=None,\n        )\n        SiteReport.objects.filter(pk=rt.pk).update(created_on=self._dt(3))\n\n        # One per-campaign row\n        camp = Campaign.objects.create(title=\"C\", slug=\"c\")\n        cr = SiteReport.objects.create(\n            campaign=camp,\n            assets_not_started=7,\n            assets_published=1,\n            assets_started=None,\n        )\n        SiteReport.objects.filter(pk=cr.pk).update(created_on=self._dt(2))\n\n        # One per-topic row\n        topic = Topic.objects.create(title=\"T\", slug=\"t\")\n        tr = SiteReport.objects.create(\n            topic=topic,\n            assets_not_started=5,\n            assets_published=0,\n            assets_started=None,\n        )\n        SiteReport.objects.filter(pk=tr.pk).update(created_on=self._dt(1))\n\n        updated = backfill_assets_started_for_site_reports.run()\n        # Each single-row series sets assets_started to 0\n        self.assertEqual(updated, 3)\n\n        rt.refresh_from_db()\n        cr.refresh_from_db()\n        tr.refresh_from_db()\n        self.assertEqual(rt.assets_started, 0)\n        self.assertEqual(cr.assets_started, 0)\n        self.assertEqual(tr.assets_started, 0)\n\n    def test_skip_existing_branch_emits_heartbeat_due_to_time(self):\n        # First row already populated (skipped); second row needs update.\n        from unittest import mock\n\n        prev = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_not_started=100,\n            assets_published=10,\n            assets_started=0,\n        )\n        curr = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_not_started=90,\n            assets_published=15,\n            assets_started=None,\n        )\n\n        SiteReport.objects.filter(pk=prev.pk).update(created_on=self._dt(2))\n        SiteReport.objects.filter(pk=curr.pk).update(created_on=self._dt(1))\n\n        # Use a monotonic function that always advances time enough to trip the\n        # heartbeat-by-time condition, without exhausting side effects.\n        def make_monotonic(step=11.0):\n            state = {\"t\": 0.0}\n\n            def _mono():\n                state[\"t\"] += step\n                return state[\"t\"]\n\n            return _mono\n\n        with (\n            mock.patch(\"concordia.tasks.reports.backfill.structured_logger\") as slog,\n            mock.patch(\n                \"concordia.tasks.reports.backfill.time.monotonic\",\n                new=make_monotonic(),\n            ),\n        ):\n            updated = backfill_assets_started_for_site_reports.run()\n            self.assertEqual(updated, 1)\n\n            hb_calls = [\n                c\n                for c in slog.info.call_args_list\n                if c.kwargs.get(\"event_code\")\n                == \"assets_started_backfill_series_heartbeat\"\n                and c.kwargs.get(\"series\") == \"TOTAL\"\n                and c.kwargs.get(\"scanned_rows\") == 1\n                and c.kwargs.get(\"last_seen_site_report_id\") == prev.id\n            ]\n            self.assertTrue(hb_calls)\n\n    def test_post_scan_heartbeat_emitted_due_to_time(self):\n        # Single-row series where a save occurs, then a heartbeat fires due to\n        # elapsed time.\n        from unittest import mock\n\n        single = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_not_started=50,\n            assets_published=5,\n            assets_started=None,\n        )\n        SiteReport.objects.filter(pk=single.pk).update(created_on=self._dt(1))\n\n        def make_monotonic(step=11.0):\n            state = {\"t\": 0.0}\n\n            def _mono():\n                state[\"t\"] += step\n                return state[\"t\"]\n\n            return _mono\n\n        with (\n            mock.patch(\"concordia.tasks.reports.backfill.structured_logger\") as slog,\n            mock.patch(\n                \"concordia.tasks.reports.backfill.time.monotonic\",\n                new=make_monotonic(),\n            ),\n        ):\n            updated = backfill_assets_started_for_site_reports.run()\n            self.assertEqual(updated, 1)\n\n            slog.info.assert_any_call(\n                \"Scanning series...\",\n                event_code=\"assets_started_backfill_series_heartbeat\",\n                series=\"TOTAL\",\n                scanned_rows=1,\n                updated_rows=1,\n                last_seen_site_report_id=single.id,\n            )\n\n    def test_no_update_when_equal_with_skip_existing_false(self):\n        # First row already equals the calculated value (zero for first snapshot),\n        # so no save should occur on that row when recomputing.\n        from unittest import mock\n\n        prev = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_not_started=100,\n            assets_published=10,\n            assets_started=0,\n        )\n        curr = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_not_started=90,\n            assets_published=15,\n            assets_started=None,\n        )\n\n        SiteReport.objects.filter(pk=prev.pk).update(created_on=self._dt(2))\n        SiteReport.objects.filter(pk=curr.pk).update(created_on=self._dt(1))\n\n        with mock.patch(\"concordia.tasks.reports.backfill.structured_logger\") as slog:\n            updated = backfill_assets_started_for_site_reports.run(skip_existing=False)\n            self.assertEqual(updated, 1)\n\n            # Row logs should not include the first row, since it already matched.\n            row_logs = [\n                c.kwargs\n                for c in slog.info.call_args_list\n                if c.kwargs.get(\"event_code\") == \"assets_started_backfill_row\"\n            ]\n            self.assertTrue(any(kw.get(\"site_report_id\") == curr.id for kw in row_logs))\n            self.assertFalse(\n                any(kw.get(\"site_report_id\") == prev.id for kw in row_logs)\n            )\n\n    def test_total_assets_started_is_rolled_up_from_campaign_series(self):\n        # Ensure the TOTAL series does not derive assets_started from its own\n        # assets_total/assets_not_started deltas when campaign series data for\n        # the same reporting days exists.\n        camp = Campaign.objects.create(title=\"C\", slug=\"c\")\n\n        total_prev = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_total=999,\n            assets_not_started=999,\n            assets_started=None,\n        )\n        total_curr = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            assets_total=999,\n            assets_not_started=0,\n            assets_started=None,\n        )\n\n        c_prev = SiteReport.objects.create(\n            campaign=camp,\n            assets_total=20,\n            assets_not_started=20,\n            assets_started=None,\n        )\n        c_curr = SiteReport.objects.create(\n            campaign=camp,\n            assets_total=20,\n            assets_not_started=15,\n            assets_started=None,\n        )\n\n        dt_prev = self._dt(2)\n        dt_curr = self._dt(1)\n\n        SiteReport.objects.filter(pk=total_prev.pk).update(created_on=dt_prev)\n        SiteReport.objects.filter(pk=total_curr.pk).update(created_on=dt_curr)\n        SiteReport.objects.filter(pk=c_prev.pk).update(created_on=dt_prev)\n        SiteReport.objects.filter(pk=c_curr.pk).update(created_on=dt_curr)\n\n        updated = backfill_assets_started_for_site_reports.run(skip_existing=False)\n        self.assertEqual(updated, 4)\n\n        total_prev.refresh_from_db()\n        total_curr.refresh_from_db()\n        c_prev.refresh_from_db()\n        c_curr.refresh_from_db()\n\n        self.assertEqual(c_prev.assets_started, 0)\n        self.assertEqual(c_curr.assets_started, 5)\n\n        self.assertEqual(total_prev.assets_started, 0)\n        self.assertEqual(total_curr.assets_started, 5)\n"
  },
  {
    "path": "concordia/tests/test_tasks_reports_key_metrics.py",
    "content": "from datetime import date, datetime\nfrom types import SimpleNamespace\nfrom unittest import mock\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom concordia.models import KeyMetricsReport, SiteReport\nfrom concordia.tasks.reports.key_metrics import build_key_metrics_reports\n\n\nclass BuildKeyMetricsReportsTaskTests(TestCase):\n    def _dt(self, days_ago):\n        return timezone.now() - timezone.timedelta(days=days_ago)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_recompute_all_calls_all_upserts(self, mock_localdate):\n        # Fix \"today\" to a stable mid-month date.\n        today = date(2024, 3, 15)\n        mock_localdate.return_value = today\n\n        # Earliest SiteReport in the current month so only one month is walked.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        tz = timezone.get_current_timezone()\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 3, 10, 12, 0, 0), tz)\n        )\n\n        fy = KeyMetricsReport.get_fiscal_year_for_date(today)\n        fq = KeyMetricsReport.get_fiscal_quarter_for_date(today)\n\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=today.replace(day=1),\n            period_end=today,\n            fiscal_year=fy,\n            fiscal_quarter=fq,\n            month=today.month,\n        )\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=today.replace(day=1),\n            period_end=today,\n            fiscal_year=fy,\n            fiscal_quarter=fq,\n        )\n\n        with (\n            mock.patch.object(KeyMetricsReport, \"upsert_month\") as up_m,\n            mock.patch.object(KeyMetricsReport, \"upsert_quarter\") as up_q,\n            mock.patch.object(KeyMetricsReport, \"upsert_fiscal_year\") as up_y,\n        ):\n            up_m.return_value = mock.Mock(period_start=None, period_end=None)\n            up_q.return_value = mock.Mock(period_start=None, period_end=None)\n            up_y.return_value = mock.Mock(period_start=None, period_end=None)\n\n            changed = build_key_metrics_reports.run(recompute_all=True)\n\n        # One month, four quarters, one fiscal year\n        self.assertEqual(changed, 6)\n        self.assertEqual(up_m.call_count, 1)\n        self.assertEqual(up_q.call_count, 4)\n        self.assertEqual(up_y.call_count, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_incremental_refresh_and_creates(self, mock_localdate):\n        # Fix \"today\" to a stable mid-month date so the month logic is\n        # deterministic.\n        today = date(2024, 3, 15)\n        mock_localdate.return_value = today\n\n        # Make one SiteReport this month so the month is considered.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        tz = timezone.get_current_timezone()\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 3, 10, 12, 0, 0), tz)\n        )\n\n        fy = KeyMetricsReport.get_fiscal_year_for_date(today)\n        fq = KeyMetricsReport.get_fiscal_quarter_for_date(today)\n\n        # Existing MONTHLY row with old updated_on so it is refreshed.\n        monthly = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=today.replace(day=1),\n            period_end=today,\n            fiscal_year=fy,\n            fiscal_quarter=fq,\n            month=today.month,\n        )\n        KeyMetricsReport.objects.filter(pk=monthly.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 3, 1, 0, 0, 0), tz)\n        )\n\n        # Existing QUARTERLY row for the same quarter; the other three quarters\n        # are missing and will be created. We keep updated_on equal to the\n        # MONTHLY row so only the missing quarters are upserted.\n        quarter = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=today.replace(day=1),\n            period_end=today,\n            fiscal_year=fy,\n            fiscal_quarter=fq,\n        )\n        KeyMetricsReport.objects.filter(pk=quarter.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 3, 1, 0, 0, 0), tz)\n        )\n\n        # No FY row yet so it will be created in the FY stage.\n        with (\n            mock.patch.object(KeyMetricsReport, \"upsert_month\") as up_m,\n            mock.patch.object(KeyMetricsReport, \"upsert_quarter\") as up_q,\n            mock.patch.object(KeyMetricsReport, \"upsert_fiscal_year\") as up_y,\n        ):\n            up_m.return_value = mock.Mock(period_start=None, period_end=None)\n            up_q.return_value = mock.Mock(period_start=None, period_end=None)\n            up_y.return_value = mock.Mock(period_start=None, period_end=None)\n\n            changed = build_key_metrics_reports(recompute_all=False)\n\n        # One monthly refresh, three quarterly creates (no refresh since the\n        # mock does not bump monthly.updated_on), and one fiscal year create.\n        self.assertEqual(changed, 5)\n        self.assertEqual(up_m.call_count, 1)\n        self.assertEqual(up_q.call_count, 3)\n        self.assertEqual(up_y.call_count, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.SiteReport\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_early_return_after_backsteps(self, mock_local, mock_sr, slog):\n        # Force \"today\" to mid-March so last_month_start starts at Mar 1.\n        mock_local.return_value = date(2024, 3, 15)\n\n        # Earliest SR is mid-December so first_month_start is Dec 1.\n        earliest = SimpleNamespace(\n            created_on=timezone.make_aware(datetime(2023, 12, 15, 12, 0, 0))\n        )\n        mock_sr.objects.order_by.return_value.first.return_value = earliest\n\n        # Pretend there are no snapshots by EOM for any month we check.\n        mock_sr.objects.filter.return_value.exists.return_value = False\n\n        changed = build_key_metrics_reports(recompute_all=False)\n        self.assertEqual(changed, 0)\n\n        # Ensure we logged the \"no months\" message.\n        codes = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertIn(\"key_metrics_build_no_months\", codes)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_recompute_all_month_upsert_and_december_rollover(\n        self, mock_local, upsert_month, slog\n    ):\n        # Make yesterday in December so the month we process is December.\n        mock_local.return_value = date(2023, 12, 20)\n\n        # Create a TOTAL snapshot in December so the scan does not early-return.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2023, 12, 15, 10, 0, 0))\n        )\n\n        # Return a stub report so the \"upserted\" logging runs.\n        upsert_month.return_value = SimpleNamespace(\n            period_start=date(2023, 12, 1),\n            period_end=date(2023, 12, 31),\n        )\n\n        changed = build_key_metrics_reports(recompute_all=True)\n        self.assertGreaterEqual(changed, 1)\n\n        codes = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertIn(\"key_metrics_month_upserted\", codes)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_incremental_month_create_and_refresh(\n        self,\n        mock_local,\n        upsert_month,\n        upsert_year,\n        upsert_quarter,\n        slog,\n    ):\n        mock_local.return_value = date(2024, 2, 1)\n\n        sr_jan = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr_jan.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 10, 9, 0, 0))\n        )\n        sr_dec = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr_dec.pk).update(\n            created_on=timezone.make_aware(datetime(2023, 12, 20, 9, 0, 0))\n        )\n\n        dec_month = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2023, 12, 1),\n            period_end=date(2023, 12, 31),\n            fiscal_year=2024,\n            fiscal_quarter=1,\n            month=12,\n        )\n        KeyMetricsReport.objects.filter(pk=dec_month.pk).update(\n            updated_on=timezone.make_aware(datetime(2023, 12, 1, 0, 0, 0))\n        )\n\n        # Monthly upsert produces a stub (so it counts as 1 change per call)\n        upsert_month.return_value = SimpleNamespace(\n            period_start=date(2024, 1, 1), period_end=date(2024, 1, 31)\n        )\n        # Disable quarterly and fiscal-year increments\n        upsert_quarter.return_value = None\n        upsert_year.return_value = None\n\n        changed = build_key_metrics_reports(recompute_all=False)\n        self.assertEqual(changed, 2)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.objects\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_quarter_recompute_all_logs(\n        self, mock_local, upsert_month, upsert_quarter, kmr_objects, slog\n    ):\n        mock_local.return_value = date(2024, 1, 15)\n\n        # Ensure we do not early-return (one SR anywhere is fine).\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 1, 8, 0, 0))\n        )\n\n        # We are not using monthly upserts here.\n        upsert_month.return_value = None\n\n        # monthly_rows -> one fiscal year (2024)\n        kmr_objects.filter.return_value.values.return_value.annotate.return_value = [\n            {\"fiscal_year\": 2024}\n        ]\n        # quarter_exists .first() can be anything; ignored in recompute_all.\n        kmr_objects.filter.return_value.first.return_value = None\n        # Prevent FY stage from running by returning no quarter years later.\n        kmr_objects.filter.return_value.values_list.return_value = []\n\n        upsert_quarter.return_value = SimpleNamespace(\n            period_start=date(2024, 1, 1), period_end=date(2024, 3, 31)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=True)\n        # Four quarters upserted\n        self.assertGreaterEqual(changed, 4)\n        self.assertEqual(upsert_quarter.call_count, 4)\n\n        codes = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertIn(\"key_metrics_quarter_upserted\", codes)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.objects\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_quarter_incremental_refresh_all_quarters(\n        self, mock_local, upsert_month, kmr_objects, upsert_quarter, slog\n    ):\n        mock_local.return_value = date(2024, 6, 15)\n\n        # Ensure we do not early-return.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 5, 10, 8, 0, 0))\n        )\n\n        # No monthly creation in this test.\n        upsert_month.return_value = None\n\n        # Signal that we have monthly rows for fiscal_year=2024.\n        kmr_objects.filter.return_value.values.return_value.annotate.return_value = [\n            {\"fiscal_year\": 2024}\n        ]\n\n        # quarter_exists present for all four quarters.\n        quarter_stub = SimpleNamespace(\n            updated_on=timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0))\n        )\n\n        def filter_side_effect(*args, **kwargs):\n            # For QUARTERLY lookups with fiscal_quarter, return an object\n            # whose first() yields a stub so \"refresh\" path is taken.\n            class QS:\n                def __init__(self, exists_value=False):\n                    self._exists = exists_value\n\n                def first(self):\n                    return quarter_stub\n\n                def exists(self):\n                    return self._exists\n\n                def values(self, *a, **k):\n                    return self\n\n                def annotate(self, *a, **k):\n                    return [{\"fiscal_year\": 2024}]\n\n                def values_list(self, *a, **k):\n                    # Avoid FY stage in this test\n                    return []\n\n            pt = kwargs.get(\"period_type\")\n            if pt == KeyMetricsReport.PeriodType.MONTHLY and \"updated_on__gt\" in kwargs:\n                # Make monthly_newer_exists True\n                return QS(exists_value=True)\n            return QS()\n\n        kmr_objects.filter.side_effect = filter_side_effect\n\n        upsert_quarter.return_value = SimpleNamespace(\n            period_start=date(2024, 4, 1), period_end=date(2024, 6, 30)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=False)\n        # Four refreshes (Q1..Q4)\n        self.assertGreaterEqual(changed, 4)\n        self.assertEqual(upsert_quarter.call_count, 4)\n\n        codes = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertIn(\"key_metrics_quarter_refreshed\", codes)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.objects\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_fiscal_year_recompute_all_logs(\n        self, mock_local, upsert_month, kmr_objects, upsert_year, slog\n    ):\n        mock_local.return_value = date(2024, 1, 15)\n\n        # Ensure no early-return.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 2, 8, 0, 0))\n        )\n\n        upsert_month.return_value = None\n\n        # No monthlies needed; quarters present for FY 2027.\n        kmr_objects.filter.return_value.values.return_value.annotate.return_value = []\n        kmr_objects.filter.return_value.values_list.return_value = [2027]\n        kmr_objects.filter.return_value.first.return_value = None\n\n        upsert_year.return_value = SimpleNamespace(\n            period_start=date(2026, 10, 1), period_end=date(2027, 9, 30)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=True)\n        self.assertGreaterEqual(changed, 1)\n\n        codes = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertIn(\"key_metrics_year_upserted\", codes)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.objects\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_fiscal_year_incremental_create_and_refresh(\n        self, mock_local, upsert_month, kmr_objects, upsert_year, slog\n    ):\n        mock_local.return_value = date(2024, 5, 1)\n\n        # Ensure no early-return.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 4, 15, 8, 0, 0))\n        )\n        upsert_month.return_value = None\n\n        # First, drive \"create\" path: quarters exist, FY row is missing.\n        def filter_values_list_side_effect(*args, **kwargs):\n            # This handles the \"fiscal_years_with_quarters\" query.\n            class QS:\n                def values_list(self, *a, **k):\n                    return [2026]\n\n                def first(self):\n                    return None\n\n                def values(self, *a, **k):\n                    return self\n\n                def annotate(self, *a, **k):\n                    return []\n\n                def exists(self):\n                    return False\n\n            return QS()\n\n        kmr_objects.filter.side_effect = filter_values_list_side_effect\n\n        upsert_year.return_value = SimpleNamespace(\n            period_start=date(2025, 10, 1), period_end=date(2026, 9, 30)\n        )\n\n        changed1 = build_key_metrics_reports(recompute_all=False)\n        self.assertGreaterEqual(changed1, 1)\n        codes1 = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertIn(\"key_metrics_year_created\", codes1)\n\n        # Now drive \"refresh\" path: FY exists, a newer quarter exists.\n        fy_stub = SimpleNamespace(\n            updated_on=timezone.make_aware(datetime(2024, 3, 1, 0, 0, 0))\n        )\n\n        def filter_refresh_side_effect(*args, **kwargs):\n            class QS:\n                def __init__(self, pt=None):\n                    self.pt = pt\n\n                def values_list(self, *a, **k):\n                    return [2026]\n\n                def first(self):\n                    # When asking for the FY row, return a stub\n                    return fy_stub\n\n                def values(self, *a, **k):\n                    return self\n\n                def annotate(self, *a, **k):\n                    return []\n\n                def exists(self):\n                    # This is called for quarters newer than FY.updated_on\n                    return True\n\n            return QS()\n\n        kmr_objects.filter.side_effect = filter_refresh_side_effect\n\n        upsert_year.return_value = SimpleNamespace(\n            period_start=date(2025, 10, 1), period_end=date(2026, 9, 30)\n        )\n\n        changed2 = build_key_metrics_reports(recompute_all=False)\n        self.assertGreaterEqual(changed2, 1)\n        codes2 = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertIn(\"key_metrics_year_refreshed\", codes2)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_recompute_all_quarter_upserts_only(\n        self, mock_local, mock_month, mock_quarter, mock_year, slog\n    ):\n        mock_local.return_value = date(2024, 2, 1)\n\n        # Seed one site snapshot so the task has a start month (Jan 2024).\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 10, 9, 0, 0))\n        )\n\n        # Seed a MONTHLY row so the quarter loop sees FY 2024 in the set.\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 1, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=1,\n        )\n\n        # Monthly does nothing; quarter upserts return a stub; FY does nothing.\n        mock_month.return_value = None\n        mock_year.return_value = None\n\n        def quarter_stub(**kwargs):\n            return SimpleNamespace(\n                period_start=date(2024, 1, 1), period_end=date(2024, 3, 31)\n            )\n\n        mock_quarter.side_effect = quarter_stub\n\n        changed = build_key_metrics_reports(recompute_all=True)\n\n        # Only quarters (4) should have counted.\n        self.assertEqual(changed, 4)\n        self.assertEqual(mock_quarter.call_count, 4)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_incremental_quarter_refresh_only(\n        self, mock_local, mock_month, mock_quarter, mock_year, slog\n    ):\n        mock_local.return_value = date(2024, 4, 1)\n\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 10, 9, 0, 0))\n        )\n\n        # Monthlies for Q2; newer than the quarter row we will refresh\n        for m in (1, 2, 3):\n            mr = KeyMetricsReport.objects.create(\n                period_type=KeyMetricsReport.PeriodType.MONTHLY,\n                period_start=date(2024, m, 1),\n                period_end=KeyMetricsReport.month_bounds(date(2024, m, 15))[1],\n                fiscal_year=2024,\n                fiscal_quarter=2,\n                month=m,\n            )\n            KeyMetricsReport.objects.filter(pk=mr.pk).update(\n                updated_on=timezone.make_aware(datetime(2024, 3, 31, 12, 0, 0))\n            )\n\n        # Pre-create Q1, Q3, Q4 so they are not created by the task\n        for fq, ps, pe in [\n            (1, date(2023, 10, 1), date(2023, 12, 31)),\n            (3, date(2024, 4, 1), date(2024, 6, 30)),\n            (4, date(2024, 7, 1), date(2024, 9, 30)),\n        ]:\n            KeyMetricsReport.objects.create(\n                period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n                period_start=ps,\n                period_end=pe,\n                fiscal_year=2024,\n                fiscal_quarter=fq,\n            )\n\n        # Existing Q2 with older updated_on so only this quarter refreshes\n        q2 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n        KeyMetricsReport.objects.filter(pk=q2.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 1, 15, 0, 0, 0))\n        )\n\n        mock_month.return_value = None\n        mock_year.return_value = None\n        mock_quarter.return_value = SimpleNamespace(\n            period_start=date(2024, 1, 1), period_end=date(2024, 3, 31)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=False)\n\n        self.assertEqual(changed, 1)\n        self.assertEqual(mock_quarter.call_count, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_recompute_all_year_upsert_only(\n        self, mock_local, mock_month, mock_quarter, mock_year, slog\n    ):\n        mock_local.return_value = date(2024, 2, 1)\n\n        # Seed snapshot to allow the task to pick a month.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 10, 9, 0, 0))\n        )\n\n        # Ensure the 'fiscal_years_with_quarters' set is not empty.\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n\n        # Monthly and quarterly stages do nothing; FY upsert returns a stub.\n        mock_month.return_value = None\n        mock_quarter.return_value = None\n        mock_year.return_value = SimpleNamespace(\n            period_start=date(2024, 10, 1), period_end=date(2025, 9, 30)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=True)\n\n        self.assertEqual(changed, 1)\n        self.assertEqual(mock_year.call_count, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_incremental_year_create(\n        self, mock_local, mock_month, mock_quarter, mock_year, slog\n    ):\n        mock_local.return_value = date(2024, 2, 1)\n\n        # Seed snapshot and a quarterly row so year loop triggers.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 10, 9, 0, 0))\n        )\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n\n        mock_month.return_value = None\n        mock_quarter.return_value = None\n        mock_year.return_value = SimpleNamespace(\n            period_start=date(2024, 10, 1), period_end=date(2025, 9, 30)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=False)\n\n        self.assertEqual(changed, 1)\n        self.assertEqual(mock_year.call_count, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_incremental_year_refresh(\n        self, mock_local, mock_month, mock_quarter, mock_year, slog\n    ):\n        mock_local.return_value = date(2024, 4, 1)\n\n        # Seed a quarterly row with new updated_on.\n        q = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n        KeyMetricsReport.objects.filter(pk=q.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 3, 31, 12, 0, 0))\n        )\n\n        # Create an older FY row that should be refreshed.\n        fy = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2023, 10, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n        )\n        KeyMetricsReport.objects.filter(pk=fy.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 1, 1, 0, 0, 0))\n        )\n\n        # Need a snapshot so the task can initialize months; it is not used\n        # further because we neutralize month and quarter stages.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 10, 9, 0, 0))\n        )\n\n        mock_month.return_value = None\n        mock_quarter.return_value = None\n        mock_year.return_value = SimpleNamespace(\n            period_start=date(2023, 10, 1), period_end=date(2024, 9, 30)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=False)\n\n        self.assertEqual(changed, 1)\n        self.assertEqual(mock_year.call_count, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_quarter_recompute_all_upserts_and_continue(\n        self,\n        mock_localdate,\n        mock_upsert_month,\n        mock_upsert_quarter,\n        mock_upsert_year,\n        slog,\n    ):\n        # Make the \"monthly\" section inert (no changes).\n        mock_localdate.return_value = date(2024, 4, 1)\n        mock_upsert_month.return_value = None\n        mock_upsert_year.return_value = None\n\n        # Seed minimal SiteReport so the monthly stage can compute bounds safely.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 10, 9, 0, 0))\n        )\n\n        # Ensure at least one fiscal_year is discovered from MONTHLY rows.\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 1, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=1,\n        )\n\n        # Each quarter upsert returns a non-None object so rows_changed increments.\n        mock_upsert_quarter.return_value = SimpleNamespace(\n            period_start=date(2024, 1, 1), period_end=date(2024, 3, 31)\n        )\n\n        changed = build_key_metrics_reports.run(recompute_all=True)\n\n        # Four quarters upserted; monthly and FY upserts return None.\n        self.assertEqual(changed, 4)\n        self.assertEqual(mock_upsert_quarter.call_count, 4)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_fiscal_year_recompute_all_upserts_and_continue(\n        self,\n        mock_localdate,\n        mock_upsert_month,\n        mock_upsert_quarter,\n        mock_upsert_year,\n        slog,\n    ):\n        mock_localdate.return_value = date(2024, 4, 1)\n        mock_upsert_month.return_value = None\n        mock_upsert_quarter.return_value = None\n\n        # Seed a quarter so the FY stage finds a fiscal year to process.\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n\n        # Earliest SiteReport so earlier stages do not error.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 5, 9, 0, 0))\n        )\n\n        mock_upsert_year.return_value = SimpleNamespace(\n            period_start=date(2023, 10, 1), period_end=date(2024, 9, 30)\n        )\n\n        changed = build_key_metrics_reports.run(recompute_all=True)\n\n        # Only FY upsert counts (quarter/month upserts return None).\n        self.assertEqual(changed, 1)\n        self.assertEqual(mock_upsert_year.call_count, 1)\n\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_quarter_recompute_all_non_none_continue_edge(\n        self, mock_localdate, mock_upsert_quarter, mock_upsert_month\n    ):\n        mock_localdate.return_value = date(2024, 5, 20)\n\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 5, 10, 12, 0, 0))\n        )\n\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 5, 1),\n            period_end=date(2024, 5, 31),\n            fiscal_year=2024,\n            fiscal_quarter=3,\n            month=5,\n        )\n\n        dummy = mock.MagicMock(\n            period_start=date(2024, 1, 1), period_end=date(2024, 3, 31)\n        )\n        mock_upsert_quarter.return_value = dummy\n\n        changed = build_key_metrics_reports(recompute_all=True)\n\n        self.assertEqual(changed, 4)\n        self.assertEqual(mock_upsert_quarter.call_count, 4)\n\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\",\n        return_value=mock.MagicMock(\n            period_start=date(2024, 10, 1), period_end=date(2025, 9, 30)\n        ),\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_quarter_incremental_refresh_monthly_newer(\n        self,\n        mock_localdate,\n        mock_upsert_fy,\n        mock_upsert_quarter,\n        mock_upsert_month,\n    ):\n        mock_localdate.return_value = date(2024, 1, 20)\n\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 10, 9, 0, 0))\n        )\n\n        jan = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 1, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=1,\n        )\n        KeyMetricsReport.objects.filter(pk=jan.pk).update(updated_on=timezone.now())\n\n        now = timezone.now()\n        older = now - timezone.timedelta(days=10)\n        q1 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2023, 10, 1),\n            period_end=date(2023, 12, 31),\n            fiscal_year=2024,\n            fiscal_quarter=1,\n        )\n        q2 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n        q3 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 4, 1),\n            period_end=date(2024, 6, 30),\n            fiscal_year=2024,\n            fiscal_quarter=3,\n        )\n        q4 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 7, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n            fiscal_quarter=4,\n        )\n        KeyMetricsReport.objects.filter(pk=q1.pk).update(updated_on=now)\n        KeyMetricsReport.objects.filter(pk=q2.pk).update(updated_on=older)\n        KeyMetricsReport.objects.filter(pk=q3.pk).update(updated_on=now)\n        KeyMetricsReport.objects.filter(pk=q4.pk).update(updated_on=now)\n\n        fy = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2023, 10, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n        )\n        KeyMetricsReport.objects.filter(pk=fy.pk).update(updated_on=now)\n\n        mock_upsert_quarter.return_value = mock.MagicMock(\n            period_start=date(2024, 1, 1), period_end=date(2024, 3, 31)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=False)\n\n        self.assertEqual(changed, 1)\n        self.assertGreaterEqual(mock_upsert_quarter.call_count, 1)\n\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\",\n        return_value=mock.MagicMock(\n            period_start=date(2024, 10, 1), period_end=date(2025, 9, 30)\n        ),\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_fiscal_year_recompute_all_non_none_continue_edge(\n        self, mock_localdate, mock_upsert_fy, mock_upsert_quarter, mock_upsert_month\n    ):\n        mock_localdate.return_value = date(2024, 5, 20)\n\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 5, 10, 12, 0, 0))\n        )\n\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n\n        changed = build_key_metrics_reports(recompute_all=True)\n        self.assertEqual(changed, 1)\n\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\",\n        return_value=mock.MagicMock(\n            period_start=date(2024, 10, 1), period_end=date(2025, 9, 30)\n        ),\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_fiscal_year_incremental_create_missing(\n        self,\n        mock_localdate,\n        mock_upsert_fy,\n        mock_upsert_quarter,\n        mock_upsert_month,\n    ):\n        mock_localdate.return_value = date(2024, 5, 20)\n\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        tz = timezone.get_current_timezone()\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 5, 10, 12, 0, 0), tz)\n        )\n\n        for qn, start, end in [\n            (1, date(2023, 10, 1), date(2023, 12, 31)),\n            (2, date(2024, 1, 1), date(2024, 3, 31)),\n            (3, date(2024, 4, 1), date(2024, 6, 30)),\n            (4, date(2024, 7, 1), date(2024, 9, 30)),\n        ]:\n            KeyMetricsReport.objects.create(\n                period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n                period_start=start,\n                period_end=end,\n                fiscal_year=2024,\n                fiscal_quarter=qn,\n            )\n\n        changed = build_key_metrics_reports(recompute_all=False)\n        self.assertEqual(changed, 1)\n\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\",\n        return_value=mock.MagicMock(\n            period_start=date(2024, 10, 1), period_end=date(2025, 9, 30)\n        ),\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_fiscal_year_incremental_refresh_when_quarter_newer(\n        self, mock_localdate, mock_upsert_fy, mock_upsert_quarter, mock_upsert_month\n    ):\n        mock_localdate.return_value = date(2024, 5, 20)\n\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 5, 10, 12, 0, 0))\n        )\n\n        older = timezone.now() - timezone.timedelta(days=7)\n        newer = timezone.now()\n\n        fy = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2023, 10, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n        )\n        KeyMetricsReport.objects.filter(pk=fy.pk).update(updated_on=older)\n\n        q2 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n        KeyMetricsReport.objects.filter(pk=q2.pk).update(updated_on=newer)\n\n        changed = build_key_metrics_reports(recompute_all=False)\n        self.assertEqual(changed, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\",\n        return_value=None,\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_quarter_recompute_all_none_branch_continue(\n        self, mock_localdate, upsert_quarter, upsert_month, upsert_year, slog\n    ):\n        # Keep the monthly scan minimal and stable\n        mock_localdate.return_value = date(2024, 2, 10)\n\n        # Seed a site snapshot so the task computes month bounds\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(\n                datetime(2024, 2, 9, 12, 0, 0), timezone.get_current_timezone()\n            )\n        )\n\n        # Ensure the quarterly stage iterates a fiscal year by having a MONTHLY\n        # row\n        fy = KeyMetricsReport.get_fiscal_year_for_date(mock_localdate.return_value)\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 2, 1),\n            period_end=date(2024, 2, 29),\n            fiscal_year=fy,\n            fiscal_quarter=2,\n            month=2,\n        )\n\n        # upsert_quarter returns None -> branch falls through to 'continue'\n        changed = build_key_metrics_reports(recompute_all=True)\n\n        # No rows changed because monthly and FY are neutralized and quarter\n        # upserts return None (hitting the continue path each time).\n        self.assertEqual(changed, 0)\n        self.assertEqual(upsert_quarter.call_count, 4)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\",\n        return_value=None,\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_quarter_incremental_refresh_none_branch_continue(\n        self,\n        mock_localdate,\n        mock_upsert_quarter,\n        mock_upsert_month,\n        mock_upsert_year,\n        slog,\n    ):\n        # Ensure monthly scan has a valid window\n        mock_localdate.return_value = date(2024, 2, 10)\n\n        # Seed one site snapshot so month range can be computed\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        tz = timezone.get_current_timezone()\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 5, 12, 0, 0), tz)\n        )\n\n        # Provide a MONTHLY row in FY 2024; make it \"newer\" than Q2\n        jan = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.MONTHLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 1, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n            month=1,\n        )\n        KeyMetricsReport.objects.filter(pk=jan.pk).update(updated_on=timezone.now())\n\n        # Create quarter rows so the incremental branch runs.\n        # Only Q2 should be older than the monthly row to trigger refresh.\n        now = timezone.now()\n        older = now - timezone.timedelta(days=10)\n        quarters = {\n            1: ((date(2023, 10, 1), date(2023, 12, 31)), now),\n            2: ((date(2024, 1, 1), date(2024, 3, 31)), older),\n            3: ((date(2024, 4, 1), date(2024, 6, 30)), now),\n            4: ((date(2024, 7, 1), date(2024, 9, 30)), now),\n        }\n        for fq, val in quarters.items():\n            (ps, pe), updated = val\n            q = KeyMetricsReport.objects.create(\n                period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n                period_start=ps,\n                period_end=pe,\n                fiscal_year=2024,\n                fiscal_quarter=fq,\n            )\n            KeyMetricsReport.objects.filter(pk=q.pk).update(updated_on=updated)\n\n        # upsert_quarter is mocked to return None, so when the code reaches the\n        # monthly_newer_exists refresh path for Q2 it will take the \"is None\"\n        # branch and continue without incrementing rows_changed.\n        changed = build_key_metrics_reports(recompute_all=False)\n\n        # No rows changed: month and year upserts return None, and Q2 refresh\n        # returned None (so branch continued). Only one refresh attempt expected.\n        self.assertEqual(changed, 0)\n        self.assertEqual(mock_upsert_quarter.call_count, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_fiscal_year_recompute_all_none_branch_continue(\n        self,\n        mock_localdate,\n        mock_upsert_month,\n        mock_upsert_quarter,\n        mock_upsert_year,\n        slog,\n    ):\n        mock_localdate.return_value = date(2024, 5, 20)\n\n        # Ensure monthly scan can initialize.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        tz = timezone.get_current_timezone()\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 5, 10, 12, 0, 0), tz)\n        )\n\n        # Ensure at least one fiscal year is present for the FY stage.\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n\n        # Month/quarter upserts are mocked to None; FY upsert also None.\n        changed = build_key_metrics_reports(recompute_all=True)\n\n        # Nothing should be counted since FY upsert returned None and the code\n        # immediately continued the loop without incrementing or logging.\n        self.assertEqual(changed, 0)\n        self.assertEqual(mock_upsert_year.call_count, 1)\n\n        codes = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertNotIn(\"key_metrics_year_upserted\", codes)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\",\n        return_value=None,\n    )\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\",\n        return_value=None,\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_fiscal_year_incremental_refresh_none_branch_continue(\n        self,\n        mock_localdate,\n        mock_upsert_month,\n        mock_upsert_quarter,\n        mock_upsert_year,\n        slog,\n    ):\n        mock_localdate.return_value = date(2024, 5, 20)\n\n        # Make monthly stage computable.\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        tz = timezone.get_current_timezone()\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 5, 10, 12, 0, 0), tz)\n        )\n\n        # Existing FY row with older updated_on so a newer quarter will\n        # trigger the refresh path.\n        fy = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2023, 10, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n        )\n        KeyMetricsReport.objects.filter(pk=fy.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 3, 1, 0, 0, 0), tz)\n        )\n\n        # Quarter newer than the FY row to make quarter_newer_exists True.\n        q2 = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n        KeyMetricsReport.objects.filter(pk=q2.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 3, 15, 0, 0, 0), tz)\n        )\n\n        # FY upsert returns None so the branch is skipped and loop continues.\n        changed = build_key_metrics_reports(recompute_all=False)\n\n        self.assertEqual(changed, 0)\n        self.assertEqual(mock_upsert_year.call_count, 1)\n\n        codes = [kw.get(\"event_code\") for _, kw in slog.info.call_args_list if kw]\n        self.assertNotIn(\"key_metrics_year_refreshed\", codes)\n        self.assertNotIn(\"key_metrics_year_created\", codes)\n        self.assertNotIn(\"key_metrics_year_upserted\", codes)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_incremental_fiscal_year_created_branch(\n        self,\n        mock_localdate,\n        mock_upsert_month,\n        mock_upsert_quarter,\n        mock_upsert_year,\n        slog,\n    ):\n        mock_localdate.return_value = date(2024, 4, 1)\n        mock_upsert_month.return_value = None\n        mock_upsert_quarter.return_value = None\n\n        # Quarter exists for FY discovery; no FY row exists yet.\n        KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 2, 9, 0, 0))\n        )\n\n        mock_upsert_year.return_value = SimpleNamespace(\n            period_start=date(2023, 10, 1), period_end=date(2024, 9, 30)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=False)\n\n        self.assertEqual(changed, 1)\n        self.assertEqual(mock_upsert_year.call_count, 1)\n\n    @mock.patch(\"concordia.tasks.reports.key_metrics.structured_logger\")\n    @mock.patch(\n        \"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_fiscal_year\"\n    )\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_quarter\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.KeyMetricsReport.upsert_month\")\n    @mock.patch(\"concordia.tasks.reports.key_metrics.timezone.localdate\")\n    def test_incremental_fiscal_year_refresh_due_to_newer_quarter(\n        self,\n        mock_localdate,\n        mock_upsert_month,\n        mock_upsert_quarter,\n        mock_upsert_year,\n        slog,\n    ):\n        mock_localdate.return_value = date(2024, 4, 1)\n        mock_upsert_month.return_value = None\n        mock_upsert_quarter.return_value = None\n\n        # Existing FY row with earlier updated_on.\n        fy = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.FISCAL_YEAR,\n            period_start=date(2023, 10, 1),\n            period_end=date(2024, 9, 30),\n            fiscal_year=2024,\n        )\n        KeyMetricsReport.objects.filter(pk=fy.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 3, 1, 0, 0, 0))\n        )\n\n        # Quarter with newer updated_on to trigger the refresh path.\n        q = KeyMetricsReport.objects.create(\n            period_type=KeyMetricsReport.PeriodType.QUARTERLY,\n            period_start=date(2024, 1, 1),\n            period_end=date(2024, 3, 31),\n            fiscal_year=2024,\n            fiscal_quarter=2,\n        )\n        KeyMetricsReport.objects.filter(pk=q.pk).update(\n            updated_on=timezone.make_aware(datetime(2024, 3, 15, 0, 0, 0))\n        )\n\n        sr = SiteReport.objects.create(report_name=SiteReport.ReportName.TOTAL)\n        SiteReport.objects.filter(pk=sr.pk).update(\n            created_on=timezone.make_aware(datetime(2024, 1, 3, 9, 0, 0))\n        )\n\n        mock_upsert_year.return_value = SimpleNamespace(\n            period_start=date(2023, 10, 1), period_end=date(2024, 9, 30)\n        )\n\n        changed = build_key_metrics_reports(recompute_all=False)\n\n        self.assertEqual(changed, 1)\n        self.assertEqual(mock_upsert_year.call_count, 1)\n"
  },
  {
    "path": "concordia/tests/test_tasks_reports_sitereport.py",
    "content": "from datetime import timedelta\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom concordia.models import Asset, Campaign, SiteReport, Transcription\nfrom concordia.tasks.reports.sitereport import (\n    _daily_active_users,\n    campaign_report,\n    retired_total_report,\n    site_report,\n)\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_tag,\n    create_tag_collection,\n    create_topic,\n    create_transcription,\n)\n\n\nclass SiteReportTestCase(CreateTestUsers, TestCase):\n    @classmethod\n    def setUpTestData(cls):\n        # We use setUpTestData instead of setUp so the database is only set\n        # up once rather than for each individual test in this test case\n        cls.user1 = cls.create_user(username=\"tester1\")\n        cls.user2 = cls.create_user(username=\"tester2\")\n        cls.user3 = cls.create_user(username=\"tester3\")\n        cls.anonymous_user = get_anonymous_user()\n        cls.asset1 = create_asset()\n        cls.item1 = cls.asset1.item\n        cls.project1 = cls.item1.project\n        cls.campaign1 = cls.project1.campaign\n        cls.asset1_transcription1 = create_transcription(\n            asset=cls.asset1, user=cls.user1, accepted=timezone.now()\n        )\n        cls.asset1_transcription2 = create_transcription(\n            asset=cls.asset1,\n            user=cls.anonymous_user,\n            rejected=timezone.now(),\n            reviewed_by=cls.user1,\n        )\n        cls.topic1 = create_topic(project=cls.project1)\n        cls.tag1 = create_tag()\n        cls.tag_collection1 = create_tag_collection(\n            tag=cls.tag1, asset=cls.asset1, user=cls.user1\n        )\n\n        cls.campaign2 = create_campaign(slug=\"test-campaign-slug-2\")\n        cls.project2 = create_project(\n            campaign=cls.campaign2, slug=\"test-project-slug-2\"\n        )\n        cls.item2 = create_item(project=cls.project2, item_id=\"2\")\n        cls.asset2 = create_asset(item=cls.item2, slug=\"test-asset-slug-2\")\n        cls.topic2 = create_topic(\n            project=cls.asset2.item.project, slug=\"test-topic-slug-2\"\n        )\n        cls.tag_collection2 = create_tag_collection(\n            tag=cls.tag1, asset=cls.asset2, user=cls.user1\n        )\n\n        cls.campaign3 = create_campaign(slug=\"test-campaign-slug-3\")\n        cls.project3 = create_project(\n            campaign=cls.campaign3, slug=\"test-project-slug-3\"\n        )\n        cls.item3 = create_item(project=cls.project3, item_id=\"3\")\n        cls.asset3 = create_asset(item=cls.item3, slug=\"test-asset-slug-3\")\n        cls.asset4 = create_asset(\n            item=cls.item3, slug=\"test-asset-slug-4\", published=False\n        )\n        cls.item4 = create_item(project=cls.project3, item_id=\"4\", published=False)\n        cls.asset5 = create_asset(\n            item=cls.item4, slug=\"test-asset-slug-5\", published=False\n        )\n\n        cls.project3.topics.add(cls.topic1)\n        cls.project3.topics.add(cls.topic2)\n\n        cls.retired_campaign = create_campaign(slug=\"retired-campaign-slug\")\n        cls.retired_project = create_project(\n            campaign=cls.retired_campaign, slug=\"retired-project-slug\"\n        )\n        cls.retired_item = create_item(project=cls.retired_project)\n        cls.retired_asset = create_asset(\n            item=cls.retired_item, slug=\"retired-asset-slug\"\n        )\n        time = timezone.now() - timedelta(days=1, hours=1)\n        cls.retired_asset_transcription1 = create_transcription(\n            asset=cls.retired_asset, user=cls.user1, accepted=time\n        )\n        # Done like this to override auto_now_add and auto_now\n        Transcription.objects.filter(pk=cls.retired_asset_transcription1.pk).update(\n            created_on=time, updated_on=time\n        )\n        time = timezone.now() - timedelta(days=1, seconds=1)\n        cls.retired_asset_transcription2 = create_transcription(\n            asset=cls.retired_asset,\n            user=cls.user2,\n            rejected=time,\n            reviewed_by=cls.user1,\n        )\n        # Done like this to override auto_now_add and auto_now\n        Transcription.objects.filter(pk=cls.retired_asset_transcription2.pk).update(\n            created_on=time, updated_on=time\n        )\n\n        # Generate the campaign report before \"retiring\" the campaign to populate\n        # the retired total report\n        cls.retired_campaign_report = campaign_report(campaign=cls.retired_campaign)\n        cls.retired_asset.delete()\n        cls.retired_item.delete()\n        cls.retired_project.delete()\n        cls.retired_campaign.status = Campaign.Status.RETIRED\n        cls.retired_campaign.save()\n\n        site_report()\n        cls.site_report = SiteReport.objects.filter(\n            report_name=SiteReport.ReportName.TOTAL\n        ).first()\n        cls.retired_site_report = SiteReport.objects.filter(\n            report_name=SiteReport.ReportName.RETIRED_TOTAL\n        ).first()\n        cls.campaign1_report = SiteReport.objects.filter(campaign=cls.campaign1).first()\n        cls.topic1_report = SiteReport.objects.filter(topic=cls.topic1).first()\n\n    def test_daily_active_users(self):\n        self.assertEqual(_daily_active_users(), 2)\n\n    def test_site_report(self):\n        self.assertEqual(self.site_report.assets_total, 5)\n        self.assertEqual(self.site_report.assets_published, 3)\n        self.assertEqual(self.site_report.assets_not_started, 4)\n        self.assertEqual(self.site_report.assets_in_progress, 1)\n        self.assertEqual(self.site_report.assets_waiting_review, 0)\n        self.assertEqual(self.site_report.assets_completed, 0)\n        self.assertEqual(self.site_report.assets_unpublished, 2)\n        self.assertEqual(self.site_report.items_published, 3)\n        self.assertEqual(self.site_report.items_unpublished, 1)\n        self.assertEqual(self.site_report.projects_published, 3)\n        self.assertEqual(self.site_report.projects_unpublished, 0)\n        self.assertEqual(self.site_report.anonymous_transcriptions, 1)\n        self.assertEqual(self.site_report.transcriptions_saved, 2)\n        self.assertEqual(self.site_report.daily_review_actions, 2)\n        self.assertEqual(self.site_report.distinct_tags, 1)\n        self.assertEqual(self.site_report.tag_uses, 2)\n        self.assertEqual(self.site_report.campaigns_published, 4)\n        self.assertEqual(self.site_report.campaigns_unpublished, 0)\n        self.assertEqual(self.site_report.users_registered, 4)\n        self.assertEqual(self.site_report.users_activated, 4)\n        self.assertEqual(self.site_report.daily_active_users, 2)\n\n    def test_retired_site_report(self):\n        self.assertEqual(self.retired_site_report.assets_total, 1)\n        self.assertEqual(self.retired_site_report.assets_published, 1)\n        self.assertEqual(self.retired_site_report.assets_not_started, 0)\n        self.assertEqual(self.retired_site_report.assets_in_progress, 1)\n        self.assertEqual(self.retired_site_report.assets_waiting_review, 0)\n        self.assertEqual(self.retired_site_report.assets_completed, 0)\n        self.assertEqual(self.retired_site_report.assets_unpublished, 0)\n        self.assertEqual(self.retired_site_report.items_published, 1)\n        self.assertEqual(self.retired_site_report.items_unpublished, 0)\n        self.assertEqual(self.retired_site_report.projects_published, 1)\n        self.assertEqual(self.retired_site_report.projects_unpublished, 0)\n        self.assertEqual(self.retired_site_report.anonymous_transcriptions, 0)\n        self.assertEqual(self.retired_site_report.transcriptions_saved, 2)\n        self.assertEqual(self.retired_site_report.daily_review_actions, 0)\n        self.assertEqual(self.retired_site_report.distinct_tags, 0)\n        self.assertEqual(self.retired_site_report.tag_uses, 0)\n        self.assertEqual(self.retired_site_report.registered_contributors, 2)\n\n    def test_campaign_report(self):\n        self.assertEqual(self.campaign1_report.assets_total, 1)\n        self.assertEqual(self.campaign1_report.assets_published, 1)\n        self.assertEqual(self.campaign1_report.assets_not_started, 0)\n        self.assertEqual(self.campaign1_report.assets_in_progress, 1)\n        self.assertEqual(self.campaign1_report.assets_waiting_review, 0)\n        self.assertEqual(self.campaign1_report.assets_completed, 0)\n        self.assertEqual(self.campaign1_report.assets_unpublished, 0)\n        self.assertEqual(self.campaign1_report.items_published, 1)\n        self.assertEqual(self.campaign1_report.items_unpublished, 0)\n        self.assertEqual(self.campaign1_report.projects_published, 1)\n        self.assertEqual(self.campaign1_report.projects_unpublished, 0)\n        self.assertEqual(self.campaign1_report.anonymous_transcriptions, 1)\n        self.assertEqual(self.campaign1_report.transcriptions_saved, 2)\n        self.assertEqual(self.campaign1_report.daily_review_actions, 2)\n        self.assertEqual(self.campaign1_report.distinct_tags, 1)\n        self.assertEqual(self.campaign1_report.tag_uses, 1)\n        self.assertEqual(self.campaign1_report.registered_contributors, 2)\n\n    def test_topic_report(self):\n        self.assertEqual(self.topic1_report.assets_total, 4)\n        self.assertEqual(self.topic1_report.assets_published, 2)\n        self.assertEqual(self.topic1_report.assets_not_started, 3)\n        self.assertEqual(self.topic1_report.assets_in_progress, 1)\n        self.assertEqual(self.topic1_report.assets_waiting_review, 0)\n        self.assertEqual(self.topic1_report.assets_completed, 0)\n        self.assertEqual(self.topic1_report.assets_unpublished, 2)\n        self.assertEqual(self.topic1_report.items_published, 2)\n        self.assertEqual(self.topic1_report.items_unpublished, 1)\n        self.assertEqual(self.topic1_report.projects_published, 2)\n        self.assertEqual(self.topic1_report.projects_unpublished, 0)\n        self.assertEqual(self.topic1_report.anonymous_transcriptions, 1)\n        self.assertEqual(self.topic1_report.transcriptions_saved, 2)\n        self.assertEqual(self.topic1_report.daily_review_actions, 2)\n        self.assertEqual(self.topic1_report.distinct_tags, 1)\n        self.assertEqual(self.topic1_report.tag_uses, 1)\n\n    def test_topic_report_zero_assets_emits_warning(self):\n        # Create a new topic attached to a project with no items/assets so the\n        # topic report computes zero total assets and emits a warning.\n        from unittest import mock\n\n        empty_campaign = create_campaign(slug=\"sr-empty-c\")\n        empty_project = create_project(campaign=empty_campaign, slug=\"sr-empty-p\")\n        empty_topic = create_topic(project=empty_project, slug=\"sr-empty-t\")\n\n        with mock.patch(\"concordia.tasks.reports.sitereport.structured_logger\") as slog:\n            site_report()\n\n            warn_calls = [\n                c\n                for c in slog.warning.call_args_list\n                if c.kwargs.get(\"event_code\") == \"topic_report_zero_assets\"\n                and c.kwargs.get(\"topic\") == empty_topic\n            ]\n            self.assertTrue(warn_calls)\n\n\nclass SiteReportAssetsStartedRollupTests(CreateTestUsers, TestCase):\n    def test_total_assets_started_rolls_up_campaign_deltas_ignoring_retirements(\n        self,\n    ):\n        \"\"\"\n        The TOTAL assets_started value should be derived from per-campaign\n        daily deltas for the same reporting day.\n\n        This protects the site-wide daily count from being suppressed when a\n        campaign retires and its already-started assets are removed from the\n        active asset tables.\n        \"\"\"\n        from unittest import mock\n\n        active_campaign = create_campaign(slug=\"rollup-active-c\")\n        retiring_campaign = create_campaign(slug=\"rollup-retiring-c\")\n\n        active_project = create_project(\n            campaign=active_campaign, slug=\"rollup-active-p\"\n        )\n        active_item = create_item(project=active_project, item_id=\"ra\")\n        active_asset = create_asset(item=active_item, slug=\"rollup-active-a\")\n\n        retiring_project = create_project(\n            campaign=retiring_campaign, slug=\"rollup-retiring-p\"\n        )\n        retiring_item = create_item(project=retiring_project, item_id=\"rb\")\n        retiring_asset = create_asset(item=retiring_item, slug=\"rollup-retiring-a\")\n\n        # Day 1 snapshot: one not-started asset in the active campaign and one\n        # already-started asset in the campaign that will retire.\n        Asset.objects.filter(pk=active_asset.pk).update(\n            transcription_status=\"not_started\"\n        )\n        Asset.objects.filter(pk=retiring_asset.pk).update(\n            transcription_status=\"in_progress\"\n        )\n\n        base_now = timezone.now()\n        day1 = base_now - timedelta(days=2)\n        day2 = base_now - timedelta(days=1)\n\n        with mock.patch(\"django.utils.timezone.now\", return_value=day1):\n            site_report()\n\n        # Between snapshots, the active campaign starts its asset.\n        Asset.objects.filter(pk=active_asset.pk).update(\n            transcription_status=\"in_progress\"\n        )\n\n        # The other campaign is retired and its content is removed.\n        retiring_asset.delete()\n        retiring_item.delete()\n        retiring_project.delete()\n        retiring_campaign.status = Campaign.Status.RETIRED\n        retiring_campaign.save()\n\n        with mock.patch(\"django.utils.timezone.now\", return_value=day2):\n            site_report()\n\n        total_day2 = (\n            SiteReport.objects.filter(\n                report_name=SiteReport.ReportName.TOTAL,\n                campaign__isnull=True,\n                topic__isnull=True,\n                created_on__date=day2.date(),\n            )\n            .order_by(\"-created_on\", \"-pk\")\n            .first()\n        )\n        active_day2 = (\n            SiteReport.objects.filter(\n                campaign=active_campaign, created_on__date=day2.date()\n            )\n            .order_by(\"-created_on\", \"-pk\")\n            .first()\n        )\n\n        self.assertIsNotNone(total_day2)\n        self.assertIsNotNone(active_day2)\n\n        self.assertEqual(active_day2.assets_started, 1)\n        self.assertEqual(total_day2.assets_started, 1)\n\n    def test_retired_total_assets_started_is_always_zero(self):\n        retired_campaign = create_campaign(slug=\"rollup-retired-c\")\n        retired_campaign.status = Campaign.Status.RETIRED\n        retired_campaign.save()\n\n        r1 = SiteReport.objects.create(\n            campaign=retired_campaign,\n            assets_total=10,\n            assets_not_started=0,\n            assets_started=4,\n        )\n        r2 = SiteReport.objects.create(\n            campaign=retired_campaign,\n            assets_total=10,\n            assets_not_started=0,\n            assets_started=7,\n        )\n\n        now = timezone.now()\n        SiteReport.objects.filter(pk=r1.pk).update(created_on=now - timedelta(days=2))\n        SiteReport.objects.filter(pk=r2.pk).update(created_on=now - timedelta(days=1))\n\n        self.assertEqual(SiteReport.objects.get(pk=r2.pk).assets_started, 7)\n\n        retired_total_report()\n\n        retired_total = (\n            SiteReport.objects.filter(report_name=SiteReport.ReportName.RETIRED_TOTAL)\n            .order_by(\"-created_on\", \"-pk\")\n            .first()\n        )\n\n        self.assertIsNotNone(retired_total)\n        self.assertEqual(retired_total.assets_started, 0)\n"
  },
  {
    "path": "concordia/tests/test_tasks_retirement.py",
    "content": "from unittest import mock\n\nfrom django.core.exceptions import ObjectDoesNotExist\nfrom django.test import TestCase\n\nfrom concordia.models import Asset, Project\nfrom concordia.tasks.retirement import (\n    assets_removal_success,\n    delete_asset,\n    item_removal_success,\n    project_removal_success,\n    remove_next_assets,\n    remove_next_item,\n    remove_next_project,\n    retire_campaign,\n)\n\nfrom .utils import (\n    create_asset,\n    create_campaign,\n    create_campaign_retirement_progress,\n    create_item,\n    create_project,\n)\n\n\nclass RetirementTasksTests(TestCase):\n    def test_retire_campaign_initializes_totals_and_sets_status_and_triggers(self):\n        # Build a campaign with 2 projects, 2 items, 3 assets.\n        camp = create_campaign(slug=\"ret-c1\")\n        p1 = create_project(campaign=camp, slug=\"ret-p1\")\n        p2 = create_project(campaign=camp, slug=\"ret-p2\")\n        i1 = create_item(project=p1, item_id=\"ret-i1\")\n        i2 = create_item(project=p2, item_id=\"ret-i2\")\n        a1 = create_asset(item=i1, slug=\"ret-a1\")\n        a2 = create_asset(item=i1, slug=\"ret-a2\")\n        a3 = create_asset(item=i2, slug=\"ret-a3\")\n        self.assertTrue(all([a1.pk, a2.pk, a3.pk]))\n\n        with mock.patch(\n            \"concordia.tasks.retirement.remove_next_project.delay\"\n        ) as m_delay:\n            prog = retire_campaign(camp.id)\n\n        prog.refresh_from_db()\n        self.assertEqual(prog.project_total, 2)\n        self.assertEqual(prog.item_total, 2)\n        self.assertEqual(prog.asset_total, 3)\n        camp.refresh_from_db()\n        # Status must be set to RETIRED.\n        self.assertEqual(camp.status, camp.Status.RETIRED)  # type: ignore[attr-defined]\n        m_delay.assert_called_once_with(camp.id)\n\n    def test_retire_campaign_existing_progress_and_already_retired(self):\n        camp = create_campaign(slug=\"ret-c2\")\n        # Pre-create progress so the totals branch is skipped.\n        prog = create_campaign_retirement_progress(campaign=camp)\n        prog.project_total = 7\n        prog.item_total = 8\n        prog.asset_total = 9\n        prog.save()\n        # Mark campaign retired to skip status change.\n        camp.status = camp.Status.RETIRED  # type: ignore[attr-defined]\n        camp.save()\n\n        with mock.patch(\n            \"concordia.tasks.retirement.remove_next_project.delay\"\n        ) as m_delay:\n            retire_campaign(camp.id)\n\n        prog.refresh_from_db()\n        self.assertEqual(prog.project_total, 7)\n        self.assertEqual(prog.item_total, 8)\n        self.assertEqual(prog.asset_total, 9)\n        camp.refresh_from_db()\n        self.assertEqual(camp.status, camp.Status.RETIRED)  # type: ignore[attr-defined]\n        m_delay.assert_called_once_with(camp.id)\n\n    def test_remove_next_project_calls_remove_next_item_when_project_exists(self):\n        camp = create_campaign(slug=\"ret-c3\")\n        proj = create_project(campaign=camp, slug=\"ret-p3\")\n        create_campaign_retirement_progress(campaign=camp)\n\n        with mock.patch(\"concordia.tasks.retirement.remove_next_item.delay\") as m_delay:\n            remove_next_project(camp.id)\n\n        m_delay.assert_called_once_with(proj.id)\n\n    def test_remove_next_project_marks_complete_when_no_projects(self):\n        camp = create_campaign(slug=\"ret-c4\")\n        prog = create_campaign_retirement_progress(campaign=camp)\n\n        with mock.patch(\"concordia.tasks.retirement.remove_next_item.delay\") as m_delay:\n            remove_next_project(camp.id)\n\n        prog.refresh_from_db()\n        self.assertTrue(prog.complete)\n        self.assertIsNotNone(prog.completed_on)\n        m_delay.assert_not_called()\n\n    def test_project_removal_success_increments_and_triggers_next(self):\n        camp = create_campaign(slug=\"ret-c5\")\n        prog = create_campaign_retirement_progress(campaign=camp)\n        self.assertEqual(prog.projects_removed, 0)\n\n        with mock.patch(\n            \"concordia.tasks.retirement.remove_next_project.delay\"\n        ) as m_delay:\n            project_removal_success(project_id=123, campaign_id=camp.id)\n\n        prog.refresh_from_db()\n        self.assertEqual(prog.projects_removed, 1)\n        self.assertTrue(any(e.get(\"id\") == 123 for e in prog.removal_log))\n        m_delay.assert_called_once_with(camp.id)\n\n    def test_remove_next_item_calls_remove_next_assets_when_item_exists(self):\n        camp = create_campaign(slug=\"ret-c6\")\n        proj = create_project(campaign=camp, slug=\"ret-p6\")\n        itm = create_item(project=proj, item_id=\"ret-i6\")\n\n        with mock.patch(\n            \"concordia.tasks.retirement.remove_next_assets.delay\"\n        ) as m_delay:\n            remove_next_item(proj.id)\n\n        m_delay.assert_called_once_with(itm.id)\n\n    def test_remove_next_item_deletes_project_and_triggers_when_no_items(self):\n        camp = create_campaign(slug=\"ret-c7\")\n        proj = create_project(campaign=camp, slug=\"ret-p7\")\n\n        with mock.patch(\n            \"concordia.tasks.retirement.project_removal_success.delay\"\n        ) as m_delay:\n            remove_next_item(proj.id)\n\n        with self.assertRaises(ObjectDoesNotExist):\n            Project.objects.get(pk=proj.id)\n        m_delay.assert_called_once_with(proj.id, camp.id)\n\n    def test_assets_removal_success_updates_counts_and_triggers_next(self):\n        camp = create_campaign(slug=\"ret-c8\")\n        prog = create_campaign_retirement_progress(campaign=camp)\n        self.assertEqual(prog.assets_removed, 0)\n\n        with mock.patch(\n            \"concordia.tasks.retirement.remove_next_assets.delay\"\n        ) as m_delay:\n            assets_removal_success([10, 11, 12], campaign_id=camp.id, item_id=55)\n\n        prog.refresh_from_db()\n        self.assertEqual(prog.assets_removed, 3)\n        self.assertTrue(any(e.get(\"id\") == 10 for e in prog.removal_log))\n        self.assertTrue(any(e.get(\"id\") == 11 for e in prog.removal_log))\n        self.assertTrue(any(e.get(\"id\") == 12 for e in prog.removal_log))\n        m_delay.assert_called_once_with(55)\n\n    def test_remove_next_assets_when_no_assets_deletes_item_and_triggers(self):\n        camp = create_campaign(slug=\"ret-c9\")\n        proj = create_project(campaign=camp, slug=\"ret-p9\")\n        itm = create_item(project=proj, item_id=\"ret-i9\")\n\n        with mock.patch(\n            \"concordia.tasks.retirement.item_removal_success.delay\"\n        ) as m_delay:\n            remove_next_assets(itm.id)\n\n        with self.assertRaises(ObjectDoesNotExist):\n            # Item should be deleted.\n            type(itm).objects.get(pk=itm.id)  # type: ignore[attr-defined]\n        m_delay.assert_called_once_with(itm.id, camp.id, proj.id)\n\n    def test_remove_next_assets_with_assets_uses_chord_in_chunks_of_10(self):\n        camp = create_campaign(slug=\"ret-c10\")\n        proj = create_project(campaign=camp, slug=\"ret-p10\")\n        itm = create_item(project=proj, item_id=\"ret-i10\")\n        # Create 12 assets; only 10 should be in the chord header.\n        ids = []\n        for n in range(12):\n            a = create_asset(item=itm, slug=f\"ret-a10-{n}\", sequence=n)\n            ids.append(a.id)\n        first_ten = list(\n            Asset.objects.filter(item=itm)\n            .order_by(\"id\")\n            .values_list(\"id\", flat=True)[:10]\n        )\n\n        with (\n            mock.patch(\"concordia.tasks.retirement.chord\") as m_chord,\n            mock.patch(\"concordia.tasks.retirement.delete_asset.s\") as m_del_sig,\n            mock.patch(\n                \"concordia.tasks.retirement.assets_removal_success.s\"\n            ) as m_body_sig,\n        ):\n            runner = mock.MagicMock()\n            m_chord.return_value = runner\n            m_del_sig.side_effect = lambda aid: f\"S({aid})\"\n            m_body_sig.return_value = \"BODY\"\n\n            remove_next_assets(itm.id)\n\n            # Header should contain exactly 10 signatures, matching first ten ids.\n            header_iter = m_chord.call_args[0][0]\n            header_list = list(header_iter)\n            self.assertEqual(header_list, [f\"S({aid})\" for aid in first_ten])\n            # The body signature should be called with campaign and item ids.\n            m_body_sig.assert_called_once_with(camp.id, itm.id)\n            runner.assert_called_once_with(\"BODY\")\n\n    def test_delete_asset_deletes_storage_and_model_and_returns_id(self):\n        itm = create_item(item_id=\"ret-i11\")\n        a = create_asset(item=itm, slug=\"ret-a11\", sequence=11)\n\n        with mock.patch(\"django.core.files.storage.FileSystemStorage.delete\") as m_del:\n            ret_id = delete_asset(a.id)\n\n        self.assertEqual(ret_id, a.id)\n        self.assertFalse(Asset.objects.filter(pk=a.id).exists())\n        m_del.assert_called()\n\n    def test_item_removal_success_increments_and_triggers_next(self):\n        camp = create_campaign(slug=\"ret-c12\")\n        proj = create_project(campaign=camp, slug=\"ret-p12\")\n        itm = create_item(project=proj, item_id=\"ret-i12\")\n        prog = create_campaign_retirement_progress(campaign=camp)\n        self.assertEqual(prog.items_removed, 0)\n\n        with mock.patch(\"concordia.tasks.retirement.remove_next_item.delay\") as m_delay:\n            item_removal_success(\n                item_id=itm.id, campaign_id=camp.id, project_id=proj.id\n            )\n\n        prog.refresh_from_db()\n        self.assertEqual(prog.items_removed, 1)\n        self.assertTrue(\n            any(\n                entry.get(\"type\") == \"item\" and entry.get(\"id\") == itm.id\n                for entry in prog.removal_log\n            )\n        )\n        m_delay.assert_called_once_with(proj.id)\n"
  },
  {
    "path": "concordia/tests/test_tasks_search_index.py",
    "content": "from unittest import mock\n\nfrom django.test import TestCase\n\nfrom concordia.tasks.search_index import (\n    create_opensearch_indices,\n    delete_opensearch_indices,\n    populate_opensearch_assets_indices,\n    populate_opensearch_indices,\n    populate_opensearch_users_indices,\n    rebuild_opensearch_indices,\n)\n\n\nclass SearchIndexTasksTests(TestCase):\n    def test_create_opensearch_indices_calls_management_command(self):\n        with mock.patch(\"concordia.tasks.search_index.call_command\") as m_call:\n            result = create_opensearch_indices()\n            self.assertIsNone(result)\n            m_call.assert_called_once_with(\n                \"opensearch\",\n                \"index\",\n                \"create\",\n                verbosity=2,\n                force=True,\n                ignore_error=True,\n            )\n\n    def test_delete_opensearch_indices_calls_management_command(self):\n        with mock.patch(\"concordia.tasks.search_index.call_command\") as m_call:\n            result = delete_opensearch_indices()\n            self.assertIsNone(result)\n            m_call.assert_called_once_with(\n                \"opensearch\", \"index\", \"delete\", force=True, ignore_error=True\n            )\n\n    def test_rebuild_opensearch_indices_calls_management_command(self):\n        with mock.patch(\"concordia.tasks.search_index.call_command\") as m_call:\n            result = rebuild_opensearch_indices()\n            self.assertIsNone(result)\n            m_call.assert_called_once_with(\n                \"opensearch\",\n                \"index\",\n                \"rebuild\",\n                verbosity=2,\n                force=True,\n                ignore_error=True,\n            )\n\n    def test_populate_users_indices_calls_management_command(self):\n        with mock.patch(\"concordia.tasks.search_index.call_command\") as m_call:\n            result = populate_opensearch_users_indices()\n            self.assertIsNone(result)\n            m_call.assert_called_once_with(\n                \"opensearch\",\n                \"document\",\n                \"index\",\n                \"--indices\",\n                \"users\",\n                \"--force\",\n                \"--parallel\",\n            )\n\n    def test_populate_assets_indices_calls_management_command(self):\n        with mock.patch(\"concordia.tasks.search_index.call_command\") as m_call:\n            result = populate_opensearch_assets_indices()\n            self.assertIsNone(result)\n            m_call.assert_called_once_with(\n                \"opensearch\",\n                \"document\",\n                \"index\",\n                \"--indices\",\n                \"assets\",\n                \"--force\",\n                \"--parallel\",\n            )\n\n    def test_populate_all_indices_calls_management_command(self):\n        with mock.patch(\"concordia.tasks.search_index.call_command\") as m_call:\n            result = populate_opensearch_indices()\n            self.assertIsNone(result)\n            m_call.assert_called_once_with(\n                \"opensearch\", \"document\", \"index\", \"--force\", \"--parallel\"\n            )\n"
  },
  {
    "path": "concordia/tests/test_tasks_thumbnails.py",
    "content": "from unittest import mock\n\nfrom django.test import TestCase\n\nfrom concordia.tasks.thumbnails import (\n    download_item_thumbnail_task,\n    download_missing_thumbnails_task,\n)\n\nfrom .utils import create_campaign, create_item, create_project\n\n\nclass ThumbnailsTasksTests(TestCase):\n    def test_download_item_thumbnail_task_returns_skip_when_no_url(self):\n        # Item has no thumbnail_url; task should return skip message.\n        proj = create_project(campaign=create_campaign(slug=\"t-c1\"), slug=\"t-p1\")\n        item = create_item(project=proj, item_id=\"t-i1\", thumbnail_url=\"\")\n\n        with mock.patch(\"importer.tasks.items.download_and_set_item_thumbnail\") as m_dl:\n            result = download_item_thumbnail_task.run(item.id, force=False)\n\n        self.assertEqual(result, \"No thumbnail URL available.\")\n        m_dl.assert_not_called()\n\n    def test_download_item_thumbnail_task_calls_helper_with_force(self):\n        # Item has a thumbnail_url; helper should be called with force flag.\n        proj = create_project(campaign=create_campaign(slug=\"t-c2\"), slug=\"t-p2\")\n        item = create_item(\n            project=proj,\n            item_id=\"t-i2\",\n            thumbnail_url=\"https://ex.invalid/t.jpg\",\n            thumbnail_image=\"\",\n        )\n\n        with mock.patch(\"importer.tasks.items.download_and_set_item_thumbnail\") as m_dl:\n            m_dl.return_value = \"stored/path/t.jpg\"\n            result = download_item_thumbnail_task.run(item.id, force=True)\n\n        self.assertEqual(result, \"stored/path/t.jpg\")\n        m_dl.assert_called_once()\n        # First arg is the Item instance, second the source URL.\n        args, kwargs = m_dl.call_args\n        self.assertEqual(args[0].id, item.id)\n        self.assertEqual(args[1], \"https://ex.invalid/t.jpg\")\n        self.assertTrue(kwargs.get(\"force\"))\n\n    def test_download_missing_thumbnails_task_returns_zero_when_none(self):\n        # No items meet the filter; should log and return 0, no group calls.\n        with mock.patch(\"concordia.tasks.thumbnails.group\") as m_group:\n            count = download_missing_thumbnails_task.run()\n\n        self.assertEqual(count, 0)\n        m_group.assert_not_called()\n\n    def test_download_missing_thumbnails_task_filters_and_batches_once(self):\n        from unittest import mock\n\n        camp = create_campaign(slug=\"t-c3\")\n        proj_a = create_project(campaign=camp, slug=\"t-p3a\")\n        proj_b = create_project(campaign=camp, slug=\"t-p3b\")\n\n        i1 = create_item(\n            project=proj_a,\n            item_id=\"t-i3-1\",\n            thumbnail_url=\"http://example.com/img1.jpg\",\n            thumbnail_image=\"\",\n        )\n        i2 = create_item(\n            project=proj_a,\n            item_id=\"t-i3-2\",\n            thumbnail_url=\"http://example.com/img2.jpg\",\n            thumbnail_image=\"\",\n        )\n        create_item(  # wrong project -> not eligible\n            project=proj_b,\n            item_id=\"t-i3-3\",\n            thumbnail_url=\"http://example.com/img3.jpg\",\n            thumbnail_image=\"\",\n        )\n        create_item(  # already has image -> not eligible\n            project=proj_a,\n            item_id=\"t-i3-4\",\n            thumbnail_url=\"http://example.com/img4.jpg\",\n            thumbnail_image=\"has-file\",\n        )\n\n        with (\n            mock.patch(\"concordia.tasks.thumbnails.group\") as m_group,\n            mock.patch(\n                \"concordia.tasks.thumbnails.download_item_thumbnail_task.s\"\n            ) as m_sig,\n        ):\n            runner = mock.MagicMock()\n            runner.apply_async.return_value.get.return_value = None\n\n            def fake_group(header_iter):\n                # Force generator evaluation so .s(...) is actually called.\n                list(header_iter)\n                return runner\n\n            m_group.side_effect = fake_group\n\n            count = download_missing_thumbnails_task.run(\n                project_id=proj_a.id, batch_size=2, limit=10, force=True\n            )\n\n        self.assertEqual(count, 2)\n        m_sig.assert_has_calls(\n            [mock.call(i1.id, force=True), mock.call(i2.id, force=True)],\n            any_order=False,\n        )\n        m_group.assert_called_once()\n        runner.apply_async.assert_called_once()\n        runner.apply_async.return_value.get.assert_called_once_with(\n            disable_sync_subtasks=False\n        )\n\n    def test_download_missing_thumbnails_task_multiple_waves(self):\n        from unittest import mock\n\n        camp = create_campaign(slug=\"t-c4\")\n        proj = create_project(campaign=camp, slug=\"t-p4\")\n        items = [\n            create_item(\n                project=proj,\n                item_id=f\"t-i4-{n}\",\n                thumbnail_url=f\"http://example.com/{n}.jpg\",\n                thumbnail_image=\"\",\n            )\n            for n in range(5)\n        ]\n\n        with (\n            mock.patch(\"concordia.tasks.thumbnails.group\") as m_group,\n            mock.patch(\n                \"concordia.tasks.thumbnails.download_item_thumbnail_task.s\"\n            ) as m_sig,\n        ):\n            runners = [mock.MagicMock() for _ in range(3)]\n            for r in runners:\n                r.apply_async.return_value.get.return_value = None\n            it = iter(runners)\n\n            def fake_group(header_iter):\n                # Force generator consumption each wave.\n                list(header_iter)\n                return next(it)\n\n            m_group.side_effect = fake_group\n\n            count = download_missing_thumbnails_task.run(\n                project_id=proj.id, batch_size=2, limit=None, force=False\n            )\n\n        self.assertEqual(count, 5)\n        self.assertEqual(m_group.call_count, 3)\n        expected = [mock.call(itm.id, force=False) for itm in items]\n        self.assertEqual(m_sig.call_args_list, expected)\n"
  },
  {
    "path": "concordia/tests/test_tasks_unusualactivity.py",
    "content": "from datetime import UTC, datetime, timedelta\nfrom unittest import mock\n\nfrom django.test import TestCase, override_settings\nfrom django.utils import timezone\n\nfrom concordia.tasks.unusualactivity import unusual_activity\n\n\nclass UnusualActivityTaskTests(TestCase):\n    @override_settings(CONCORDIA_ENVIRONMENT=\"development\")\n    def test_noop_when_not_production_and_not_ignored(self):\n        # Should not render templates or send mail.\n        with (\n            mock.patch(\n                \"concordia.tasks.unusualactivity.loader.get_template\"\n            ) as m_get_tmpl,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.EmailMultiAlternatives\"\n            ) as m_email,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.Transcription.objects\"\n            ) as m_mgr,\n        ):\n            unusual_activity(ignore_env=False)\n\n        m_get_tmpl.assert_not_called()\n        m_email.assert_not_called()\n        # Manager methods should not be called either.\n        self.assertFalse(m_mgr.transcribe_incidents.called)\n        self.assertFalse(m_mgr.review_incidents.called)\n\n    @override_settings(\n        CONCORDIA_ENVIRONMENT=\"production\",\n        DEFAULT_FROM_EMAIL=\"noreply@example.com\",\n        DEFAULT_TO_EMAIL=\"\",\n    )\n    def test_runs_in_production_without_default_to(self):\n        # Executes, builds subject without env suffix, and sends to one addr.\n        fixed_now_dt = timezone.make_aware(datetime(2025, 1, 1, 12, 0), timezone=UTC)\n        expected_one_day_ago = fixed_now_dt - timedelta(days=1)\n\n        with (\n            mock.patch(\n                \"concordia.tasks.unusualactivity.Site.objects.get_current\"\n            ) as m_site,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.timezone.localtime\"\n            ) as m_localtime,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.timezone.now\",\n                return_value=fixed_now_dt,\n            ),\n            mock.patch(\n                \"concordia.tasks.unusualactivity.loader.get_template\"\n            ) as m_get_tmpl,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.EmailMultiAlternatives\"\n            ) as m_email,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.Transcription.objects\"\n            ) as m_mgr,\n        ):\n            lt = mock.Mock()\n            lt.strftime.return_value = \"STAMP\"\n            m_localtime.return_value = lt\n\n            m_site.return_value = mock.Mock(domain=\"example.com\")\n\n            txt_tmpl = mock.Mock()\n            html_tmpl = mock.Mock()\n            txt_tmpl.render.return_value = \"TEXT\"\n            html_tmpl.render.return_value = \"HTML\"\n            m_get_tmpl.side_effect = lambda name: (\n                txt_tmpl if name.endswith(\".txt\") else html_tmpl\n            )\n\n            m_mgr.transcribe_incidents.return_value = []\n            m_mgr.review_incidents.return_value = []\n\n            msg = mock.Mock()\n            m_email.return_value = msg\n\n            unusual_activity(ignore_env=False)\n\n        expected_subject = \"Unusual User Activity Report for STAMP\"\n        args, kwargs = m_email.call_args\n        self.assertEqual(kwargs[\"subject\"], expected_subject)\n        self.assertEqual(kwargs[\"from_email\"], \"noreply@example.com\")\n        self.assertEqual(kwargs[\"to\"], [\"rsar@loc.gov\"])\n        self.assertEqual(kwargs[\"reply_to\"], [\"noreply@example.com\"])\n\n        txt_tmpl.render.assert_called_once()\n        html_tmpl.render.assert_called_once()\n        self.assertEqual(\n            m_mgr.transcribe_incidents.call_args[0][0], expected_one_day_ago\n        )\n        self.assertEqual(m_mgr.review_incidents.call_args[0][0], expected_one_day_ago)\n        msg.attach_alternative.assert_called_once_with(\"HTML\", \"text/html\")\n        msg.send.assert_called_once()\n\n    @override_settings(\n        CONCORDIA_ENVIRONMENT=\"test\",\n        DEFAULT_FROM_EMAIL=\"notify@example.com\",\n        DEFAULT_TO_EMAIL=\"extra@example.com\",\n    )\n    def test_ignore_env_appends_suffix_and_includes_default_to(self):\n        fixed_now_dt = timezone.make_aware(datetime(2025, 1, 2, 9, 30), timezone=UTC)\n        expected_one_day_ago = fixed_now_dt - timedelta(days=1)\n\n        with (\n            mock.patch(\n                \"concordia.tasks.unusualactivity.Site.objects.get_current\"\n            ) as m_site,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.timezone.localtime\"\n            ) as m_localtime,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.timezone.now\",\n                return_value=fixed_now_dt,\n            ),\n            mock.patch(\n                \"concordia.tasks.unusualactivity.loader.get_template\"\n            ) as m_get_tmpl,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.EmailMultiAlternatives\"\n            ) as m_email,\n            mock.patch(\n                \"concordia.tasks.unusualactivity.Transcription.objects\"\n            ) as m_mgr,\n        ):\n            lt = mock.Mock()\n            lt.strftime.return_value = \"STAMP2\"\n            m_localtime.return_value = lt\n\n            m_site.return_value = mock.Mock(domain=\"example.net\")\n\n            txt_tmpl = mock.Mock()\n            html_tmpl = mock.Mock()\n            txt_tmpl.render.return_value = \"TEXT2\"\n            html_tmpl.render.return_value = \"HTML2\"\n            m_get_tmpl.side_effect = lambda name: (\n                txt_tmpl if name.endswith(\".txt\") else html_tmpl\n            )\n\n            m_mgr.transcribe_incidents.return_value = []\n            m_mgr.review_incidents.return_value = []\n\n            msg = mock.Mock()\n            m_email.return_value = msg\n\n            unusual_activity(ignore_env=True)\n\n        expected_subject = \"Unusual User Activity Report for STAMP2 [TEST]\"\n        args, kwargs = m_email.call_args\n        self.assertEqual(kwargs[\"subject\"], expected_subject)\n        self.assertEqual(kwargs[\"to\"], [\"rsar@loc.gov\", \"extra@example.com\"])\n        self.assertEqual(kwargs[\"from_email\"], \"notify@example.com\")\n        self.assertEqual(kwargs[\"reply_to\"], [\"notify@example.com\"])\n\n        txt_tmpl.render.assert_called_once()\n        html_tmpl.render.assert_called_once()\n        self.assertEqual(\n            m_mgr.transcribe_incidents.call_args[0][0], expected_one_day_ago\n        )\n        self.assertEqual(m_mgr.review_incidents.call_args[0][0], expected_one_day_ago)\n        msg.attach_alternative.assert_called_once_with(\"HTML2\", \"text/html\")\n        msg.send.assert_called_once()\n"
  },
  {
    "path": "concordia/tests/test_tasks_useractivity.py",
    "content": "from unittest import mock\n\nfrom django.core import mail\nfrom django.core.cache import cache\nfrom django.test import TestCase\n\nfrom concordia.exceptions import CacheLockedError\nfrom concordia.models import Campaign, Transcription, UserProfileActivity\nfrom concordia.tasks.unusualactivity import unusual_activity\nfrom concordia.tasks.useractivity import (\n    populate_active_campaign_counts,\n    populate_completed_campaign_counts,\n    update_useractivity_cache,\n    update_userprofileactivity_from_cache,\n)\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_tag,\n    create_tag_collection,\n    create_transcription,\n)\n\n\nclass UserActivityTaskTestCase(CreateTestUsers, TestCase):\n    def setUp(self):\n        cache.clear()\n        self.user = self.create_test_user()\n        self.campaign = create_campaign()\n        self.key = f\"userprofileactivity_{self.campaign.pk}\"\n\n    @mock.patch(\"concordia.tasks.useractivity.update_userprofileactivity_table\")\n    def test_update_userprofileactivity_from_cache_no_updates(self, mock_update_table):\n        cache.set(self.key, None)\n        with mock.patch(\"concordia.logging.ConcordiaLogger.debug\") as mock_debug:\n            update_userprofileactivity_from_cache()\n            self.assertEqual(mock_debug.call_count, 2)\n            mock_debug.assert_called_with(\n                \"Cache contained no updates for key. Skipping\",\n                event_code=\"update_userprofileactivity_from_cache_no_updates\",\n                key=self.key,\n            )\n        self.assertEqual(mock_update_table.call_count, 0)\n\n    @mock.patch(\"concordia.tasks.useractivity.update_userprofileactivity_table\")\n    def test_update_userprofileactivity_from_cache_update(self, mock_update_table):\n        cache.set(self.key, {self.user.pk: (1, 0)})\n        update_userprofileactivity_from_cache()\n        self.assertEqual(mock_update_table.call_count, 2)\n        mock_update_table.assert_has_calls(\n            [\n                mock.call(self.user, self.campaign.id, \"transcribe_count\", 1),\n                mock.call(self.user, self.campaign.id, \"review_count\", 0),\n            ]\n        )\n        self.assertIsNone(cache.get(self.key))\n\n    @mock.patch(\"concordia.tasks.unusualactivity.Transcription.objects\")\n    def test_unusual_activity(self, mock_transcription):\n        mock_transcription.transcribe_incidents.return_value = (\n            Transcription.objects.none()\n        )\n        mock_transcription.review_incidents.return_value = Transcription.objects.none()\n        unusual_activity(ignore_env=True)\n        self.assertEqual(len(mail.outbox), 1)\n        expected_subject = \"Unusual User Activity Report\"\n        self.assertIn(expected_subject, mail.outbox[0].subject)\n\n    @mock.patch(\"django.core.cache.cache.add\")\n    @mock.patch(\"django.core.cache.cache.delete\")\n    @mock.patch(\"concordia.tasks.useractivity._update_useractivity_cache\")\n    def test_update_useractivity_cache(self, mock_update, mock_delete, mock_add):\n        user = self.user\n        campaign = self.campaign\n\n        mock_add.return_value = False\n        with self.assertRaises(CacheLockedError):\n            update_useractivity_cache(user.id, campaign.id, \"transcribe\")\n        self.assertEqual(mock_update.call_count, 0)\n        self.assertEqual(mock_delete.call_count, 0)\n\n        mock_add.return_value = True\n        update_useractivity_cache(user.id, campaign.id, \"transcribe\")\n        self.assertEqual(mock_update.call_count, 1)\n        mock_update.assert_called_with(user.id, campaign.id, \"transcribe\")\n        self.assertEqual(mock_delete.call_count, 1)\n        mock_delete.assert_called_with(\"userprofileactivity_cache_lock\")\n\n        update_useractivity_cache(user.id, campaign.id, \"review\")\n        self.assertEqual(mock_update.call_count, 2)\n        mock_update.assert_called_with(user.id, campaign.id, \"review\")\n        self.assertEqual(mock_delete.call_count, 2)\n        mock_delete.assert_called_with(\"userprofileactivity_cache_lock\")\n\n    def test_populate_active_campaign_counts_computes_user_and_anon_rows(self):\n        camp = create_campaign(slug=\"ua-camp-a\")\n        proj = create_project(campaign=camp, slug=\"ua-proj-a\")\n        item = create_item(project=proj, item_id=\"ua-item-a\")\n        a1 = create_asset(item=item, slug=\"ua-a1\", sequence=1)\n        a2 = create_asset(item=item, slug=\"ua-a2\", sequence=2)\n\n        u1 = self.create_test_user(\"ua-u1\")\n        u2 = self.create_test_user(\"ua-u2\")\n        anon = get_anonymous_user()\n\n        create_transcription(asset=a1, user=u1, reviewed_by=u2)\n        create_transcription(asset=a2, user=u2, reviewed_by=u1)\n        create_transcription(asset=a1, user=anon)\n        create_transcription(asset=a2, user=u2, reviewed_by=anon)\n\n        t1 = create_tag(value=\"ua-t1\")\n        t2 = create_tag(value=\"ua-t2\")\n        create_tag_collection(tag=t1, asset=a1, user=u1)\n        create_tag_collection(tag=t2, asset=a2, user=u1)\n        create_tag_collection(tag=t1, asset=a2, user=anon)\n\n        populate_active_campaign_counts.run()\n\n        rows = UserProfileActivity.objects.filter(campaign=camp)\n        self.assertEqual(rows.count(), 3)\n\n        r_u1 = rows.get(user=u1)\n        r_u2 = rows.get(user=u2)\n        r_an = rows.get(user=anon)\n\n        self.assertEqual(r_u1.asset_count, 2)\n        self.assertEqual(r_u1.asset_tag_count, 2)\n        self.assertEqual(r_u1.transcribe_count, 1)\n        self.assertEqual(r_u1.review_count, 1)\n\n        self.assertEqual(r_u2.asset_count, 2)\n        self.assertEqual(r_u2.asset_tag_count, 0)\n        self.assertEqual(r_u2.transcribe_count, 2)\n        self.assertEqual(r_u2.review_count, 1)\n\n        self.assertEqual(r_an.asset_count, 2)\n        self.assertEqual(r_an.asset_tag_count, 1)\n        self.assertEqual(r_an.transcribe_count, 1)\n        self.assertEqual(r_an.review_count, 1)\n\n    def test_populate_completed_campaign_counts_processes_non_active_only(self):\n        active = create_campaign(slug=\"ua-act-1\")\n        p1 = create_project(campaign=active, slug=\"ua-act-proj\")\n        it1 = create_item(project=p1, item_id=\"ua-act-item\")\n        a_act = create_asset(item=it1, slug=\"ua-act-a\")\n        u_act = self.create_test_user(\"ua-act-u\")\n        create_transcription(asset=a_act, user=u_act)\n\n        retired = create_campaign(slug=\"ua-ret-1\", status=Campaign.Status.RETIRED)\n        p2 = create_project(campaign=retired, slug=\"ua-ret-proj\")\n        it2 = create_item(project=p2, item_id=\"ua-ret-item\")\n        a_ret = create_asset(item=it2, slug=\"ua-ret-a\")\n        u_ret = self.create_test_user(\"ua-ret-u\")\n        create_transcription(asset=a_ret, user=u_ret)\n\n        populate_completed_campaign_counts.run()\n\n        self.assertFalse(UserProfileActivity.objects.filter(campaign=active).exists())\n        self.assertTrue(UserProfileActivity.objects.filter(campaign=retired).exists())\n\n    def test_update_useractivity_cache_lock_max_retries_sends_email(self):\n        with (\n            mock.patch(\"django.core.cache.cache.add\", return_value=False),\n            mock.patch.object(update_useractivity_cache, \"max_retries\", 0, create=True),\n            mock.patch(\"concordia.tasks.useractivity.send_mail\") as m_send,\n            mock.patch(\"concordia.logging.ConcordiaLogger.warning\") as m_warn,\n            mock.patch(\"concordia.logging.ConcordiaLogger.exception\") as m_exc,\n            mock.patch(\"concordia.tasks.useractivity.logger.error\") as m_err,\n        ):\n            with self.assertRaises(CacheLockedError):\n                update_useractivity_cache.run(\n                    self.user.id, self.campaign.id, \"transcribe\"\n                )\n\n        # Structured logs were emitted\n        self.assertTrue(m_warn.called)\n        self.assertTrue(m_exc.called)\n\n        # Email sent with expected subject\n        self.assertTrue(m_send.called)\n        sent_args, sent_kwargs = m_send.call_args\n        self.assertEqual(\n            sent_args[0],\n            \"Task update_useractivity_cache failed: cache is locked.\",\n        )\n        # Unstructured error log emitted\n        self.assertTrue(m_err.called)\n\n    def test_update_useractivity_cache_update_exception_releases_lock(self):\n        with (\n            mock.patch(\"concordia.tasks.useractivity.cache.add\", return_value=True),\n            mock.patch(\"concordia.tasks.useractivity.cache.delete\") as m_del,\n            mock.patch(\n                \"concordia.tasks.useractivity._update_useractivity_cache\",\n                side_effect=RuntimeError(\"boom\"),\n            ),\n            mock.patch(\"concordia.tasks.useractivity.send_mail\") as m_mail,\n        ):\n            with self.assertRaises(RuntimeError):\n                update_useractivity_cache.run(self.user.id, self.campaign.id, \"review\")\n\n        m_del.assert_called_once_with(\"userprofileactivity_cache_lock\")\n        m_mail.assert_not_called()\n\n\nclass UpdateUserprofileactivityFromCacheTestCase(CreateTestUsers, TestCase):\n    def setUp(self):\n        cache.clear()\n        self.user = self.create_test_user()\n        self.campaign = create_campaign()\n        self.key = f\"userprofileactivity_{self.campaign.pk}\"\n\n    @mock.patch(\"concordia.tasks.useractivity.update_userprofileactivity_table\")\n    def test_no_updates(self, mock_update_table):\n        cache.set(self.key, None)\n        with mock.patch(\"concordia.logging.ConcordiaLogger.debug\") as mock_debug:\n            update_userprofileactivity_from_cache()\n            self.assertEqual(mock_debug.call_count, 2)\n            mock_debug.assert_called_with(\n                \"Cache contained no updates for key. Skipping\",\n                event_code=\"update_userprofileactivity_from_cache_no_updates\",\n                key=self.key,\n            )\n        self.assertEqual(mock_update_table.call_count, 0)\n\n    @mock.patch(\"concordia.tasks.useractivity.update_userprofileactivity_table\")\n    def test_update(self, mock_update_table):\n        cache.set(self.key, {self.user.pk: (1, 0)})\n        update_userprofileactivity_from_cache()\n        self.assertEqual(mock_update_table.call_count, 2)\n        mock_update_table.assert_has_calls(\n            [\n                mock.call(self.user, self.campaign.id, \"transcribe_count\", 1),\n                mock.call(self.user, self.campaign.id, \"review_count\", 0),\n            ]\n        )\n        self.assertIsNone(cache.get(self.key))\n"
  },
  {
    "path": "concordia/tests/test_tasks_visualizations.py",
    "content": "from datetime import timedelta\nfrom unittest import mock\n\nfrom django.core.cache import caches\nfrom django.test import TestCase, override_settings\nfrom django.utils import timezone\n\nfrom concordia.models import Campaign, SiteReport, TranscriptionStatus\nfrom concordia.tasks.visualizations import (\n    populate_asset_status_visualization_cache,\n    populate_daily_activity_visualization_cache,\n)\n\nfrom .utils import create_asset, create_campaign, create_item, create_project\n\n\n@override_settings(\n    CACHES={\n        \"default\": {\n            \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        },\n        \"visualization_cache\": {\n            \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        },\n    }\n)\nclass VisualizationCacheTasksTests(TestCase):\n    class _UploadFailed(Exception):\n        pass\n\n    def setUp(self):\n        self.cache = caches[\"visualization_cache\"]\n        self.cache.clear()\n\n    def test_populate_asset_status_visualization_cache(self):\n        c1 = create_campaign(status=Campaign.Status.ACTIVE, title=\"Alpha\")\n        c2 = create_campaign(status=Campaign.Status.ACTIVE, title=\"Beta\")\n        p1 = create_project(campaign=c1)\n        i1 = create_item(project=p1)\n        p2 = create_project(campaign=c2)\n        i2 = create_item(project=p2)\n        create_asset(item=i1, transcription_status=TranscriptionStatus.NOT_STARTED)\n        create_asset(\n            item=i2,\n            slug=\"test-asset-2\",\n            transcription_status=TranscriptionStatus.IN_PROGRESS,\n        )\n        create_asset(\n            item=i2,\n            slug=\"test-asset-3\",\n            transcription_status=TranscriptionStatus.SUBMITTED,\n        )\n        create_asset(\n            item=i2,\n            slug=\"test-asset-4\",\n            transcription_status=TranscriptionStatus.COMPLETED,\n        )\n\n        populate_asset_status_visualization_cache.run()\n\n        overview = self.cache.get(\"asset-status-overview\")\n        expected_labels = [\n            TranscriptionStatus.CHOICE_MAP[key]\n            for key, _ in TranscriptionStatus.CHOICES\n        ]\n        self.assertEqual(overview[\"status_labels\"], expected_labels)\n        # Totals: 1 not_started, 1 in_progress, 1 submitted, 1 completed\n        self.assertEqual(overview[\"total_counts\"], [1, 1, 1, 1])\n\n    def test_populate_daily_activity_visualization_cache(self):\n        today = timezone.localdate()\n        date1 = today - timedelta(days=2)\n        date2 = today - timedelta(days=1)\n\n        sr1 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            transcriptions_saved=5,\n            daily_review_actions=1,\n        )\n        sr2 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            transcriptions_saved=10,\n            daily_review_actions=2,\n        )\n        # Set specific created_on dates directly in DB\n        SiteReport.objects.filter(pk=sr1.pk).update(created_on=date1)\n        SiteReport.objects.filter(pk=sr2.pk).update(created_on=date2)\n\n        populate_daily_activity_visualization_cache.run()\n\n        result = self.cache.get(\"daily-transcription-activity-last-28-days\")\n        self.assertIsNotNone(result)\n\n        expected_labels = [(date2 - timedelta(days=1)), date2]\n        expected_labels = [d.strftime(\"%Y-%m-%d\") for d in expected_labels]\n\n        # Extract the two datasets\n        datasets = result[\"transcription_datasets\"]\n        self.assertEqual(len(datasets), 2)\n        trans = next(ds for ds in datasets if ds[\"label\"] == \"Transcriptions\")\n        reviews = next(ds for ds in datasets if ds[\"label\"] == \"Reviews\")\n\n        # transcriptions = 5 on date1, 10 - 5 = 5 on date2\n        # reviews = 1 on date1, 2 on date2\n        self.assertEqual(trans[\"data\"][-2:], [5, 5])  # last two days in the data range\n        self.assertEqual(reviews[\"data\"][-2:], [1, 2])\n\n    def test_negative_daily_saved_clamps_to_zero(self):\n        today = timezone.localdate()\n        date1 = today - timedelta(days=2)\n        date2 = today - timedelta(days=1)\n\n        sr1 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            transcriptions_saved=10,\n            daily_review_actions=0,\n        )\n        sr2 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            transcriptions_saved=5,  # decreased total, which should not happen\n            daily_review_actions=0,\n        )\n        SiteReport.objects.filter(pk=sr1.pk).update(created_on=date1)\n        SiteReport.objects.filter(pk=sr2.pk).update(created_on=date2)\n\n        populate_daily_activity_visualization_cache.run()\n\n        result = self.cache.get(\"daily-transcription-activity-last-28-days\")\n        self.assertIsNotNone(result)\n\n        datasets = result[\"transcription_datasets\"]\n        trans = next(ds for ds in datasets if ds[\"label\"] == \"Transcriptions\")\n\n        # Should clamp the second day to 0\n        self.assertEqual(trans[\"data\"][-2:], [10, 0])\n\n    def test_asset_status_unchanged_skips_upload_and_cache_update(self):\n        campaign = create_campaign(status=Campaign.Status.ACTIVE, title=\"Only\")\n        project = create_project(campaign=campaign)\n        item = create_item(project=project)\n        create_asset(item=item, transcription_status=TranscriptionStatus.NOT_STARTED)\n        create_asset(\n            item=item, slug=\"a2\", transcription_status=TranscriptionStatus.IN_PROGRESS\n        )\n        create_asset(\n            item=item, slug=\"a3\", transcription_status=TranscriptionStatus.SUBMITTED\n        )\n        create_asset(\n            item=item, slug=\"a4\", transcription_status=TranscriptionStatus.COMPLETED\n        )\n\n        expected_counts = [1, 1, 1, 1]\n\n        existing_payload = {\n            \"status_labels\": [\n                TranscriptionStatus.CHOICE_MAP[key]\n                for key, _ in TranscriptionStatus.CHOICES\n            ],\n            \"total_counts\": expected_counts,\n            \"csv_url\": \"https://old.example/asset-status.csv\",\n        }\n        self.cache.set(\"asset-status-overview\", existing_payload, None)\n\n        with (\n            mock.patch(\n                \"concordia.tasks.visualizations.VISUALIZATION_STORAGE.save\"\n            ) as mock_save,\n            mock.patch(\"concordia.tasks.visualizations.structured_logger\") as mock_log,\n        ):\n            populate_asset_status_visualization_cache.run()\n\n            mock_save.assert_not_called()\n            # Cache should remain as-is\n            self.assertEqual(self.cache.get(\"asset-status-overview\"), existing_payload)\n            # Logged unchanged\n            self.assertTrue(mock_log.info.called)\n            self.assertEqual(\n                mock_log.info.call_args.kwargs.get(\"event_code\"),\n                \"asset_status_vis_unchanged\",\n            )\n\n    def test_asset_status_upload_failure_with_prior_url_falls_back(self):\n        campaign = create_campaign(status=Campaign.Status.ACTIVE, title=\"Only\")\n        project = create_project(campaign=campaign)\n        item = create_item(project=project)\n        create_asset(item=item, transcription_status=TranscriptionStatus.NOT_STARTED)\n\n        # Ensure \"existing\" differs so code takes the non-unchanged path\n        self.cache.set(\n            \"asset-status-overview\",\n            {\n                \"status_labels\": [],\n                \"total_counts\": [0, 0, 0, 0],\n                \"csv_url\": \"https://old.example/asset-status.csv\",\n            },\n            None,\n        )\n\n        with (\n            mock.patch(\n                \"concordia.tasks.visualizations.VISUALIZATION_STORAGE.save\",\n                side_effect=self._UploadFailed(\"test exception\"),\n            ),\n            mock.patch(\"concordia.tasks.visualizations.structured_logger\") as mock_log,\n        ):\n            # Should not raise because we have a prior CSV URL to fall back to\n            populate_asset_status_visualization_cache.run()\n\n            updated = self.cache.get(\"asset-status-overview\")\n            # Counts should reflect the new data (1 in NOT_STARTED; others 0)\n            expected = [\n                1 if key == TranscriptionStatus.NOT_STARTED else 0\n                for key, _ in TranscriptionStatus.CHOICES\n            ]\n            self.assertEqual(updated[\"total_counts\"], expected)\n            # URL should remain the old one\n            self.assertEqual(updated[\"csv_url\"], \"https://old.example/asset-status.csv\")\n\n            # Logged exception with the non-missing-url code\n            self.assertTrue(mock_log.exception.called)\n            self.assertEqual(\n                mock_log.exception.call_args.kwargs.get(\"event_code\"),\n                \"asset_status_vis_csv_error\",\n            )\n\n    def test_asset_status_upload_failure_without_prior_url_raises(self):\n        campaign = create_campaign(status=Campaign.Status.ACTIVE, title=\"Only\")\n        project = create_project(campaign=campaign)\n        item = create_item(project=project)\n        create_asset(item=item, transcription_status=TranscriptionStatus.NOT_STARTED)\n\n        # No existing cache entry, so no prior URL\n        with (\n            mock.patch(\n                \"concordia.tasks.visualizations.VISUALIZATION_STORAGE.save\",\n                side_effect=self._UploadFailed(\"test exception\"),\n            ),\n            mock.patch(\"concordia.tasks.visualizations.structured_logger\") as mock_log,\n        ):\n            with self.assertRaises(self._UploadFailed):\n                populate_asset_status_visualization_cache.run()\n\n            self.assertTrue(mock_log.exception.called)\n            self.assertEqual(\n                mock_log.exception.call_args.kwargs.get(\"event_code\"),\n                \"asset_status_vis_csv_missing_url_error\",\n            )\n\n    def test_daily_activity_unchanged_skips_upload_and_cache_update(self):\n        # With no SiteReports, both series are 28 zeros; pre-populate matching cache\n        zeros = [0] * 28\n        existing = {\n            \"labels\": [],  # labels do not matter for the dedupe\n            \"transcription_datasets\": [\n                {\"label\": \"Transcriptions\", \"data\": zeros},\n                {\"label\": \"Reviews\", \"data\": zeros},\n            ],\n            \"csv_url\": \"https://old.example/daily.csv\",\n        }\n        self.cache.set(\"daily-transcription-activity-last-28-days\", existing, None)\n\n        with (\n            mock.patch(\n                \"concordia.tasks.visualizations.VISUALIZATION_STORAGE.save\"\n            ) as mock_save,\n            mock.patch(\"concordia.tasks.visualizations.structured_logger\") as mock_log,\n        ):\n            populate_daily_activity_visualization_cache.run()\n\n            mock_save.assert_not_called()\n            self.assertEqual(\n                self.cache.get(\"daily-transcription-activity-last-28-days\"), existing\n            )\n            self.assertTrue(mock_log.info.called)\n            self.assertEqual(\n                mock_log.info.call_args.kwargs.get(\"event_code\"),\n                \"daily_activity_vis_unchanged\",\n            )\n\n    def test_daily_activity_upload_failure_with_prior_url_falls_back(self):\n        # Build reports so new data will not be all zeros (ensures \"changed\" path)\n        today = timezone.localdate()\n        date1 = today - timedelta(days=2)\n        date2 = today - timedelta(days=1)\n        sr1 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            transcriptions_saved=3,\n            daily_review_actions=1,\n        )\n        sr2 = SiteReport.objects.create(\n            report_name=SiteReport.ReportName.TOTAL,\n            transcriptions_saved=5,\n            daily_review_actions=2,\n        )\n        SiteReport.objects.filter(pk=sr1.pk).update(created_on=date1)\n        SiteReport.objects.filter(pk=sr2.pk).update(created_on=date2)\n\n        # Prior cache with different series and a CSV URL to fall back to\n        self.cache.set(\n            \"daily-transcription-activity-last-28-days\",\n            {\n                \"labels\": [],\n                \"transcription_datasets\": [\n                    {\"label\": \"Transcriptions\", \"data\": [0] * 28},\n                    {\"label\": \"Reviews\", \"data\": [0] * 28},\n                ],\n                \"csv_url\": \"https://old.example/daily.csv\",\n            },\n            None,\n        )\n\n        with (\n            mock.patch(\n                \"concordia.tasks.visualizations.VISUALIZATION_STORAGE.save\",\n                side_effect=self._UploadFailed(\"test exception\"),\n            ),\n            mock.patch(\"concordia.tasks.visualizations.structured_logger\") as mock_log,\n        ):\n            # Should not raise because we have a prior CSV URL\n            populate_daily_activity_visualization_cache.run()\n\n            updated = self.cache.get(\"daily-transcription-activity-last-28-days\")\n            self.assertIsNotNone(updated)\n            # Still using the old URL\n            self.assertEqual(updated[\"csv_url\"], \"https://old.example/daily.csv\")\n            # Logged exception with the non-missing-url code\n            self.assertTrue(mock_log.exception.called)\n            self.assertEqual(\n                mock_log.exception.call_args.kwargs.get(\"event_code\"),\n                \"daily_activity_vis_csv_error\",\n            )\n\n    def test_daily_activity_upload_failure_without_prior_url_raises(self):\n        # No existing cache entry -> csv_url is None\n        with (\n            mock.patch(\n                \"concordia.tasks.visualizations.VISUALIZATION_STORAGE.save\",\n                side_effect=self._UploadFailed(\"test exception\"),\n            ),\n            mock.patch(\"concordia.tasks.visualizations.structured_logger\") as mock_log,\n        ):\n            with self.assertRaises(self._UploadFailed):\n                populate_daily_activity_visualization_cache.run()\n\n            self.assertTrue(mock_log.exception.called)\n            self.assertEqual(\n                mock_log.exception.call_args.kwargs.get(\"event_code\"),\n                \"daily_activity_vis_csv_missing_url_error\",\n            )\n"
  },
  {
    "path": "concordia/tests/test_templatetags.py",
    "content": "from django.http import QueryDict\nfrom django.template import Context, Template\nfrom django.templatetags.static import static\nfrom django.test import TestCase, override_settings\nfrom django.utils.html import escape, format_html\n\nfrom concordia.models import TranscriptionStatus\nfrom concordia.templatetags.concordia_filtering_tags import transcription_status_filters\nfrom concordia.templatetags.concordia_text_tags import reprchar\nfrom concordia.templatetags.custom_math import multiply\nfrom concordia.templatetags.reject_filter import reject\nfrom concordia.templatetags.truncation import (\n    WordBreakTruncator,\n    truncatechars_on_word_break,\n)\nfrom concordia.templatetags.visualization import concordia_visualization\n\n\nclass TestTemplateTags(TestCase):\n    def test_truncatechars_on_word_break(self):\n        test_string = \"Lorem ipsum \\u0317 dolor sit amet, consectetur adipiscing elit\"\n\n        self.assertEqual(truncatechars_on_word_break(test_string, 0), \"[…]\")\n        self.assertEqual(truncatechars_on_word_break(test_string, 1), \"[…]\")\n        self.assertEqual(truncatechars_on_word_break(test_string, 10), \"Lorem[…]\")\n        self.assertEqual(\n            truncatechars_on_word_break(test_string, 30),\n            \"Lorem ipsum \\u0317 dolor sit[…]\",\n        )\n        self.assertEqual(truncatechars_on_word_break(test_string, 1000), test_string)\n        self.assertEqual(\n            truncatechars_on_word_break(test_string, \"badvalue\"), test_string\n        )\n\n        self.assertEqual(\n            WordBreakTruncator(test_string).word_break(30, \"[\\u0317]\"),\n            \"Lorem ipsum \\u0317 dolor sit[\\u0317]\",\n        )\n\n    def test_multiply(self):\n        self.assertEqual(multiply(5, 5), 5 * 5)\n        self.assertEqual(multiply(0, 5), 0 * 5)\n        self.assertEqual(multiply(1, 2), 1 * 2)\n\n    def test_transcription_status_filters(self):\n        status_counts = []\n        for choice in TranscriptionStatus.CHOICES:\n            status_counts.append((choice, 0, 1))\n\n        transcription_status_filters(status_counts, \"\")\n        transcription_status_filters(status_counts, \"\", reversed_order=True)\n        transcription_status_filters(status_counts, TranscriptionStatus.CHOICES[0][0])\n\n    def test_qs_alter(self):\n        base_template = \"{% load concordia_querystring %}\"\n\n        out = Template(\n            base_template + \"{% qs_alter 'bar=baz&baz=taz' foo='bar' %}\"\n        ).render(Context())\n        self.assertEqual(out, \"bar=baz&amp;baz=taz&amp;foo=bar\")\n\n        data = QueryDict(\"bar=baz&baz=taz&bar=foo\")\n        out = Template(base_template + \"{% qs_alter data foo='bar' %}\").render(\n            Context({\"data\": data})\n        )\n        self.assertEqual(out, \"bar=baz&amp;bar=foo&amp;baz=taz&amp;foo=bar\")\n\n        out = Template(base_template + \"{% qs_alter data delete:bar %}\").render(\n            Context({\"data\": data})\n        )\n        self.assertEqual(out, \"baz=taz\")\n\n        out = Template(base_template + \"{% qs_alter data delete:taz %}\").render(\n            Context({\"data\": data})\n        )\n        self.assertEqual(out, \"bar=baz&amp;bar=foo&amp;baz=taz\")\n\n        out = Template(\n            base_template + \"{% qs_alter data delete_value:\\\"bar\\\",'foo' %}\"\n        ).render(Context({\"data\": data}))\n        self.assertEqual(out, \"bar=baz&amp;baz=taz\")\n\n        out = Template(\n            base_template + \"{% qs_alter data delete_value:'bar','taz' %}\"\n        ).render(Context({\"data\": data}))\n        self.assertEqual(out, \"bar=baz&amp;bar=foo&amp;baz=taz\")\n\n        out = Template(\n            base_template + \"{% qs_alter data foo='bar' as new_data %}\" \"{{ new_data }}\"\n        ).render(Context({\"data\": data}))\n        self.assertEqual(out, \"bar=baz&amp;bar=foo&amp;baz=taz&amp;foo=bar\")\n\n        # Test add_if_missing when the key already exists (should not overwrite)\n        out = Template(\n            base_template + \"{% qs_alter data add_if_missing:bar='newvalue' %}\"\n        ).render(Context({\"data\": data}))\n        self.assertEqual(out, \"bar=baz&amp;bar=foo&amp;baz=taz\")\n\n    def test_reprchar_variants(self):\n        cases = [\n            (\"A\", \"A\"),\n            (\"\\n\", \"\\\\n\"),\n            (\"\\x00\", \"\\\\x00\"),\n            (\"\\u200b\", \"\\\\u200b\"),\n            (\"\\\\\", \"\\\\\\\\\"),\n        ]\n        for ch, expected in cases:\n            self.assertEqual(reprchar(ch), expected)\n\n\nclass RejectFilterTests(TestCase):\n    def test_returns_input_when_falsy(self):\n        self.assertEqual(reject(\"\", \"x\"), \"\")\n        self.assertEqual(reject([], \"x\"), [])\n        self.assertIsNone(reject(None, \"x\"))\n        self.assertEqual(reject((), \"x\"), ())\n\n    def test_string_single_reject(self):\n        self.assertEqual(\n            reject(\"error warn marked-safe\", \"marked-safe\"),\n            \"error warn\",\n        )\n\n    def test_string_multiple_rejects(self):\n        self.assertEqual(\n            reject(\"error warn marked-safe\", \"marked-safe,warn\"),\n            \"error\",\n        )\n\n    def test_string_no_match(self):\n        self.assertEqual(reject(\"one two\", \"three\"), \"one two\")\n\n    def test_string_empty_args(self):\n        self.assertEqual(reject(\"one two\", \"\"), \"one two\")\n\n    def test_string_whitespace_split_and_join(self):\n        self.assertEqual(reject(\"a   b\\tc\", \"b\"), \"a c\")\n\n    def test_string_case_sensitivity(self):\n        self.assertEqual(reject(\"A a\", \"a\"), \"A\")\n\n    def test_iterable_list(self):\n        self.assertEqual(\n            reject([\"ok\", \"deprecated\", \"x\", \"hidden\"], \"deprecated,hidden\"),\n            [\"ok\", \"x\"],\n        )\n\n    def test_iterable_tuple_and_duplicates(self):\n        self.assertEqual(reject((\"a\", \"b\", \"c\", \"b\"), \"b\"), [\"a\", \"c\"])\n\n    def test_iterable_no_match(self):\n        self.assertEqual(reject([\"one\", \"two\"], \"three\"), [\"one\", \"two\"])\n\n    def test_iterable_empty_args(self):\n        self.assertEqual(reject([\"one\", \"two\"], \"\"), [\"one\", \"two\"])\n\n\n@override_settings(\n    STORAGES={\n        \"default\": {\"BACKEND\": \"django.core.files.storage.FileSystemStorage\"},\n        \"staticfiles\": {\n            \"BACKEND\": \"django.contrib.staticfiles.storage.StaticFilesStorage\",\n        },\n    },\n    STATICFILES_STORAGE=\"django.contrib.staticfiles.storage.StaticFilesStorage\",\n)\nclass VisualizationTagsTests(TestCase):\n    def test_without_attrs_renders_section_and_script(self):\n        # No attributes: should render a plain <section> and matching <script>\n        result = concordia_visualization(\"daily-activity\")\n        expected_section = (\n            '<div class=\"visualization-container\"><section>'\n            '<canvas id=\"daily-activity\"></canvas></section></div>'\n        )\n        self.assertHTMLEqual(result, expected_section)\n\n    def test_with_attrs_and_escaping(self):\n        # Attributes that include characters needing HTML escaping\n        attrs = {\"class\": \"test-class\", \"style\": \"width:100%;\", \"data-info\": \"<alert>\"}\n        result = concordia_visualization(\"chart1\", **attrs)\n\n        escaped_value = escape(\"<alert>\")\n        expected_section = (\n            f'<div class=\"visualization-container test-class\" '\n            f'style=\"width:100%;\" data-info=\"{escaped_value}\">'\n            f\"<section >\"\n            f'<canvas id=\"chart1\"></canvas>'\n            f\"</section>\"\n            f\"</div>\"\n        )\n        self.assertHTMLEqual(result, expected_section)\n\n    def test_name_escaping_in_id_and_script_src(self):\n        # Name contains characters needing HTML escaping\n        name = 'x\"><script>alert(1)</script>'\n        script_src = static(f\"js/visualizations/{name}.js\")\n        script_html = format_html(\n            '<script type=\"module\" src=\"{}\"></script>', script_src\n        )\n        result = concordia_visualization(name) + script_html\n\n        # The id attribute must have the name escaped\n        escaped_id = escape(name)\n        self.assertIn(f'id=\"{escaped_id}\"', result)\n\n        # The script src must also be escaped\n        raw_src = static(f\"js/visualizations/{name}.js\")\n        escaped_src = escape(raw_src)\n        self.assertIn(f'src=\"{escaped_src}\"', result)\n"
  },
  {
    "path": "concordia/tests/test_top_level_views.py",
    "content": "\"\"\"\nTests for for the top-level & “CMS” views\n\"\"\"\n\nfrom unittest.mock import patch\n\nfrom django.core.cache import cache\nfrom django.test import RequestFactory, TestCase\nfrom django.urls import reverse\nfrom maintenance_mode.core import get_maintenance_mode, set_maintenance_mode\n\nfrom concordia.models import (\n    Banner,\n    CarouselSlide,\n    Guide,\n    OverlayPosition,\n    SimplePage,\n    SiteReport,\n)\nfrom concordia.views.simple_pages import simple_page\n\nfrom .utils import (\n    CacheControlAssertions,\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_guide,\n    create_site_report,\n)\n\n\nclass TopLevelViewTests(\n    JSONAssertMixin, CreateTestUsers, CacheControlAssertions, TestCase\n):\n    def setUp(self):\n        cache.clear()\n\n    def tearDown(self):\n        cache.clear()\n\n    def test_healthz(self):\n        data = self.assertValidJSON(self.client.get(\"/healthz\"))\n\n        for k in (\n            \"current_time\",\n            \"load_average\",\n            \"debug\",\n            \"database_has_data\",\n            \"application_version\",\n        ):\n            self.assertIn(k, data)\n\n    def test_homepage(self):\n        response = self.client.get(reverse(\"homepage\"))\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(response, \"home.html\")\n\n        banner = Banner.objects.create(\n            slug=\"test-banner\", text=\"Test Banner\", active=True\n        )\n        response = self.client.get(reverse(\"homepage\"))\n        context = response.context\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(response, \"home.html\")\n        self.assertIn(\"banner\", context)\n        self.assertEqual(context[\"banner\"].text, banner.text)\n        banner.delete()\n\n        slide = CarouselSlide.objects.create(\n            published=True,\n            overlay_position=OverlayPosition.LEFT,\n            headline=\"Test Headline\",\n        )\n        response = self.client.get(reverse(\"homepage\"))\n        context = response.context\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(response, \"home.html\")\n        self.assertIn(\"firstslide\", context)\n        self.assertEqual(context[\"firstslide\"].headline, slide.headline)\n        slide.delete()\n\n    def test_contact_us_redirect(self):\n        response = self.client.get(reverse(\"contact\"))\n\n        self.assertEqual(response.status_code, 302)\n        self.assertEqual(response[\"Location\"], \"https://ask.loc.gov/crowd\")\n\n    def test_simple_page(self):\n        s = SimplePage.objects.create(\n            title=\"Get Started 123\",\n            body=\"not the real body\",\n            path=reverse(\"welcome-guide\"),\n        )\n\n        s2 = SimplePage.objects.create(\n            title=\"Get Started Spanish 123\",\n            body=\"not the real spanish body\",\n            path=reverse(\"welcome-guide-spanish\"),\n        )\n\n        resp = self.client.get(reverse(\"welcome-guide\"))\n        self.assertEqual(200, resp.status_code)\n        self.assertEqual(s.title, resp.context[\"title\"])\n        self.assertEqual(\n            [(reverse(\"welcome-guide\"), s.title)], resp.context[\"breadcrumbs\"]\n        )\n        self.assertEqual(resp.context[\"body\"], f\"<p>{s.body}</p>\")\n\n        request = RequestFactory().get(\"/\")\n        resp = simple_page(request, path=reverse(\"welcome-guide\"))\n        self.assertEqual(200, resp.status_code)\n\n        resp = self.client.get(reverse(\"welcome-guide-spanish\"))\n        self.assertEqual(200, resp.status_code)\n        self.assertEqual(s2.title, resp.context[\"title\"])\n        self.assertEqual(\"es\", resp.context[\"language_code\"])\n        self.assertEqual(\n            [(reverse(\"welcome-guide-spanish\"), s2.title)], resp.context[\"breadcrumbs\"]\n        )\n        self.assertEqual(resp.context[\"body\"], f\"<p>{s2.body}</p>\")\n\n    def test_nested_simple_page(self):\n        Guide.objects.create(title=\"How to Tag\")\n        l1 = SimplePage.objects.create(\n            title=\"Get Started\",\n            body=\"not the real body\",\n            path=reverse(\"welcome-guide\"),\n        )\n\n        l2 = SimplePage.objects.create(\n            title=\"How to Tag\",\n            body=\"This is _not_ the real page\",\n            path=reverse(\"how-to-tag\"),\n        )\n\n        resp = self.client.get(reverse(\"how-to-tag\"))\n        self.assertEqual(200, resp.status_code)\n        self.assertEqual(l2.title, resp.context[\"title\"])\n        self.assertEqual(\n            resp.context[\"breadcrumbs\"],\n            [(reverse(\"welcome-guide\"), l1.title), (reverse(\"how-to-tag\"), l2.title)],\n        )\n        self.assertHTMLEqual(\n            resp.context[\"body\"], \"<p>This is <em>not</em> the real page</p>\"\n        )\n\n        create_guide(page=l1)\n        resp = self.client.get(reverse(\"welcome-guide\"))\n        self.assertEqual(200, resp.status_code)\n\n    def test_simple_page_with_context(self):\n        path = reverse(\"about\")\n        page_body = (\n            \"<p>{{ assets_published}}</p> \"\n            \"<p>{{ campaigns_published }}</p> \"\n            \"<p>{{ assets_completed }}</p> \"\n            \"<p>{{ assets_waiting_review }}</p> \"\n            \"<p>{{ users_activated }}</p>\"\n        )\n        about_page = SimplePage.objects.create(\n            title=\"About\",\n            body=page_body,\n            path=reverse(\"about\"),\n        )\n\n        # Test with no SiteReports\n        response = self.client.get(path)\n        context = response.context\n        self.assertEqual(200, response.status_code)\n        self.assertEqual(about_page.title, context[\"title\"])\n        self.assertEqual([(path, about_page.title)], context[\"breadcrumbs\"])\n        self.assertEqual(\n            context[\"body\"], \"<p>0</p>\\n<p>0</p>\\n<p>0</p>\\n<p>0</p>\\n<p>0</p>\"\n        )\n\n        # Test with only active SiteReport\n        cache.clear()\n        create_site_report(\n            report_name=SiteReport.ReportName.TOTAL,\n            campaigns_published=1,\n            assets_published=1,\n            assets_completed=1,\n            assets_waiting_review=1,\n            users_activated=1,\n        )\n\n        response = self.client.get(path)\n        context = response.context\n        self.assertEqual(\n            context[\"body\"], \"<p>1</p>\\n<p>1</p>\\n<p>1</p>\\n<p>1</p>\\n<p>1</p>\"\n        )\n\n        # Test with both SiteReports, but with cached values from above\n        # So we should expect the retired SiteReport to not be included in data\n        create_site_report(\n            report_name=SiteReport.ReportName.RETIRED_TOTAL,\n            assets_published=1,\n            assets_completed=1,\n            assets_waiting_review=1,\n        )\n\n        response = self.client.get(path)\n        context = response.context\n        self.assertEqual(\n            context[\"body\"], \"<p>1</p>\\n<p>1</p>\\n<p>1</p>\\n<p>1</p>\\n<p>1</p>\"\n        )\n\n        # Test without bad cached data\n        cache.clear()\n        response = self.client.get(path)\n        context = response.context\n        self.assertEqual(\n            context[\"body\"], \"<p>2</p>\\n<p>1</p>\\n<p>2</p>\\n<p>2</p>\\n<p>1</p>\"\n        )\n\n\nclass HelpCenterRedirectTests(TestCase):\n    def test_HelpCenterRedirectView(self):\n        SimplePage.objects.create(\n            title=\"Get Started Page\",\n            body=\"Page Body\",\n            path=\"/get-started/page/\",\n        )\n\n        self.assertRedirects(\n            self.client.get(\"/help-center/page/\"), \"/get-started/page/\"\n        )\n\n    def test_HelpCenterSpanishRedirectView(self):\n        SimplePage.objects.create(\n            title=\"Get Started Page\",\n            body=\"Page Body\",\n            path=\"/get-started-esp/page-esp/\",\n        )\n\n        self.assertRedirects(\n            self.client.get(\"/help-center/page-esp/\"), \"/get-started-esp/page-esp/\"\n        )\n\n\nclass MaintenanceModeTests(TestCase, CreateTestUsers):\n    def setUp(self):\n        cache.clear()\n        self.timestamp_value = 1\n        self.user = None\n\n    def tearDown(self):\n        cache.clear()\n\n    def test_maintenance_mode_off(self):\n        self.user = self.create_super_user()\n        self.login_user()\n        set_maintenance_mode(True)\n\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_off\")),\n                f\"/?t={self.timestamp_value}\",\n            )\n        self.assertEqual(get_maintenance_mode(), False)\n\n        self.user = self.create_test_user()\n        self.login_user()\n        set_maintenance_mode(True)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_off\")),\n                f\"/?t={self.timestamp_value}\",\n                target_status_code=503,\n            )\n        self.assertEqual(get_maintenance_mode(), True)\n\n    def test_maintenance_mode_on_without_frontend(self):\n        cache.set(\"maintenance_mode_frontend_available\", False, None)\n\n        self.user = self.create_super_user()\n        self.login_user()\n        set_maintenance_mode(False)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_on\")),\n                f\"/?t={self.timestamp_value}\",\n                target_status_code=503,\n            )\n        self.assertEqual(get_maintenance_mode(), True)\n\n        self.user = self.create_test_user()\n        self.login_user()\n        set_maintenance_mode(False)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_on\")),\n                f\"/?t={self.timestamp_value}\",\n            )\n        self.assertEqual(get_maintenance_mode(), False)\n\n    def test_maintenance_mode_on_with_frontend(self):\n        cache.set(\"maintenance_mode_frontend_available\", True, None)\n\n        self.user = self.create_super_user()\n        self.login_user()\n        set_maintenance_mode(False)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_on\")),\n                f\"/?t={self.timestamp_value}\",\n            )\n        self.assertEqual(get_maintenance_mode(), True)\n\n        self.user = self.create_test_user()\n        self.login_user()\n        set_maintenance_mode(False)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_on\")),\n                f\"/?t={self.timestamp_value}\",\n            )\n        self.assertEqual(get_maintenance_mode(), False)\n\n    def test_maintenance_mode_frontend_available(self):\n        self.user = self.create_super_user()\n        self.login_user()\n        cache.set(\"maintenance_mode_frontend_available\", False, None)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_frontend_available\")),\n                f\"/?t={self.timestamp_value}\",\n            )\n        self.assertEqual(cache.get(\"maintenance_mode_frontend_available\"), True)\n\n        self.user = self.create_test_user()\n        self.login_user()\n        cache.set(\"maintenance_mode_frontend_available\", False, None)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_frontend_available\")),\n                f\"/?t={self.timestamp_value}\",\n            )\n        self.assertEqual(cache.get(\"maintenance_mode_frontend_available\"), False)\n\n    def test_maintenance_mode_frontend_unavailable(self):\n        self.user = self.create_super_user()\n        self.login_user()\n        cache.set(\"maintenance_mode_frontend_available\", True, None)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_frontend_unavailable\")),\n                f\"/?t={self.timestamp_value}\",\n            )\n        self.assertEqual(cache.get(\"maintenance_mode_frontend_available\"), False)\n\n        self.user = self.create_test_user()\n        self.login_user()\n        cache.set(\"maintenance_mode_frontend_available\", True, None)\n        with patch(\"concordia.views.maintenance_mode.time\") as mock:\n            mock.return_value = self.timestamp_value\n            self.assertRedirects(\n                self.client.get(reverse(\"maintenance_mode_frontend_unavailable\")),\n                f\"/?t={self.timestamp_value}\",\n            )\n        self.assertEqual(cache.get(\"maintenance_mode_frontend_available\"), True)\n"
  },
  {
    "path": "concordia/tests/test_utils_celery.py",
    "content": "from types import SimpleNamespace\nfrom unittest import mock\n\nfrom django.test import TestCase\n\nfrom concordia.utils.celery import get_registered_task\n\n\nclass CeleryUtilsTests(TestCase):\n    def test_get_registered_task_returns_task_from_registry(self):\n        name = \"pkg.tasks.do_thing\"\n        dummy_task = object()\n        app = SimpleNamespace(tasks={name: dummy_task}, send_task=mock.Mock())\n\n        with mock.patch(\n            \"concordia.utils.celery.concordia_celery_app\",\n            app,\n        ):\n            got = get_registered_task(name)\n\n        self.assertIs(got, dummy_task)\n        app.send_task.assert_not_called()\n\n    def test_get_registered_task_raises_runtime_error_with_cause(self):\n        name = \"pkg.tasks.missing\"\n        app = SimpleNamespace(tasks={}, send_task=mock.Mock())\n\n        with mock.patch(\n            \"concordia.utils.celery.concordia_celery_app\",\n            app,\n        ):\n            with self.assertRaises(RuntimeError) as ctx:\n                get_registered_task(name)\n\n        message = str(ctx.exception)\n        self.assertIn(f\"Task {name} is not registered.\", message)\n        self.assertIn(\"Did you typo it?\", message)\n        self.assertIsInstance(ctx.exception.__cause__, KeyError)\n        app.send_task.assert_not_called()\n"
  },
  {
    "path": "concordia/tests/test_utils_logging.py",
    "content": "from types import SimpleNamespace\n\nfrom django.test import TestCase\n\nfrom concordia.logging import get_logging_user_id\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import CreateTestUsers\n\n\nclass LoggingTests(CreateTestUsers, TestCase):\n    def test_get_logging_user_id_authenticated_user(self):\n        user = self.create_test_user()\n        self.assertEqual(get_logging_user_id(user), str(user.id))\n\n    def test_get_logging_user_id_anonymous_user(self):\n        anon = get_anonymous_user()\n        self.assertEqual(get_logging_user_id(anon), \"anonymous\")\n\n    def test_get_logging_user_id_missing_auth_attribute(self):\n        mock_user = object()\n        self.assertEqual(get_logging_user_id(mock_user), \"anonymous\")\n\n    def test_get_logging_user_id_authenticated_no_id(self):\n        user = SimpleNamespace(is_authenticated=True, username=\"someuser\")\n        self.assertEqual(get_logging_user_id(user), \"anonymous\")\n"
  },
  {
    "path": "concordia/tests/test_utils_next_asset_reviewable_campaign.py",
    "content": "from unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase\nfrom django.utils.timezone import now\n\nfrom concordia.models import (\n    AssetTranscriptionReservation,\n    NextReviewableCampaignAsset,\n)\nfrom concordia.utils import get_anonymous_user\nfrom concordia.utils.next_asset import (\n    find_new_reviewable_campaign_assets,\n    find_next_reviewable_campaign_asset,\n    find_reviewable_campaign_asset,\n)\nfrom concordia.utils.next_asset.reviewable.campaign import (\n    _eligible_reviewable_base_qs,\n    _find_reviewable_in_item,\n    _find_reviewable_in_project,\n    _next_seq_after,\n    _reserved_asset_ids_subq,\n    find_and_order_potential_reviewable_campaign_assets,\n    find_invalid_next_reviewable_campaign_assets,\n)\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_transcription,\n)\n\n\nclass NextReviewableCampaignAssetTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(sequence=1)\n        self.asset2 = create_asset(\n            item=self.asset1.item, sequence=2, slug=\"test-asset-2\"\n        )\n        self.campaign = self.asset1.campaign\n\n    def test_find_new_reviewable_campaign_assets_filters_correctly(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n\n        queryset = find_new_reviewable_campaign_assets(self.campaign, self.user)\n        self.assertIn(self.asset1, queryset)\n\n    def test_find_new_reviewable_campaign_assets_without_user(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        # Covers lines 28–30\n        queryset = find_new_reviewable_campaign_assets(self.campaign, None)\n        self.assertIn(self.asset1, queryset)\n\n    def test_find_reviewable_campaign_asset_from_next_table(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n\n        NextReviewableCampaignAsset.objects.create(\n            asset=self.asset1,\n            campaign=self.campaign,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            sequence=self.asset1.sequence,\n            transcriber_ids=[],\n        )\n\n        asset = find_reviewable_campaign_asset(self.campaign, self.user)\n        self.assertEqual(asset, self.asset1)\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_find_reviewable_campaign_asset_falls_back_and_spawns_task(\n        self, mock_get_task\n    ):\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_reviewable_campaign_asset(self.campaign, self.user)\n        self.assertEqual(asset, self.asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_find_next_reviewable_campaign_asset_orders_and_falls_back(\n        self, mock_get_task\n    ):\n        \"\"\"\n        With short-circuiting: project-level returns the eligible asset\n        and we do not spawn a task.\n        \"\"\"\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(asset, self.asset1)\n        # Short-circuit satisfied -> no cache fallback -> no task spawned\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_find_next_reviewable_campaign_asset_when_next_asset_exists(\n        self, mock_get_task\n    ):\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        NextReviewableCampaignAsset.objects.create(\n            asset=self.asset2,\n            campaign=self.campaign,\n            item=self.asset2.item,\n            item_item_id=self.asset2.item.item_id,\n            project=self.asset2.item.project,\n            project_slug=self.asset2.item.project.slug,\n            sequence=self.asset2.sequence,\n            transcriber_ids=[],\n        )\n\n        asset = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=self.asset2.item.project.slug,\n            item_id=self.asset2.item.item_id,\n            original_asset_id=self.asset2.id - 1,\n        )\n        self.assertEqual(asset, self.asset2)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_short_circuit_same_item_excludes_users_own_work(self):\n        \"\"\"\n        Reviewable item short-circuit must not return assets\n        transcribed by the requesting user.\n        \"\"\"\n        # Two submitted in same item: one by self.user, one by anon\n        mine = self.asset1\n        other = self.asset2\n        create_transcription(asset=mine, user=self.user, submitted=now())\n        create_transcription(asset=other, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=mine.item.project.slug,\n            item_id=mine.item.item_id,\n            original_asset_id=mine.id,\n        )\n        self.assertEqual(chosen, other)\n\n    def test_item_short_circuit_reviewable_respects_after_sequence_and_reservations(\n        self,\n    ):\n        \"\"\"\n        Item reviewable short-circuit should choose next by sequence\n        and skip reserved assets.\n        \"\"\"\n        # Three submitted in the same item (none by self.user)\n        asset1 = self.asset1\n        asset2 = self.asset2\n        asset3 = create_asset(item=asset1.item, sequence=3, slug=\"rev-a3\")\n        for asset in (asset1, asset2, asset3):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n\n        # After asset1, pick asset2\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=asset1.item.project.slug,\n            item_id=asset1.item.item_id,\n            original_asset_id=asset1.id,\n        )\n        self.assertEqual(chosen, asset2)\n\n        # Reserve asset2, so should pick asset3\n        AssetTranscriptionReservation.objects.create(\n            asset=asset2, reservation_token=\"rv\"  # nosec\n        )\n        chosen2 = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=asset1.item.project.slug,\n            item_id=asset1.item.item_id,\n            original_asset_id=asset1.id,\n        )\n        self.assertEqual(chosen2, asset3)\n\n    def test_project_short_circuit_when_item_has_only_users_work(self):\n        \"\"\"\n        If the only SUBMITTED assets in the item were transcribed by the user,\n        the function should fall back to other assets in the same project.\n        \"\"\"\n        # Item-level: only user's work\n        create_transcription(asset=self.asset1, user=self.user, submitted=now())\n        create_transcription(asset=self.asset2, user=self.user, submitted=now())\n\n        # Project-level: someone else's work\n        other_item = create_item(project=self.asset1.item.project, item_id=\"p2\")\n        project_asset = create_asset(item=other_item, slug=\"rev-p-asset\")\n        create_transcription(asset=project_asset, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, project_asset)\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_cache_excludes_user_and_triggers_spawn_task(self, mock_get_task):\n        \"\"\"\n        When the only cached asset is excluded via transcriber_ids containing the user,\n        the function should skip cache, fall back to manual and spawn a populate task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Cached entry references the user's own work (excluded by contains)\n        create_transcription(asset=self.asset1, user=self.user, submitted=now())\n        NextReviewableCampaignAsset.objects.create(\n            asset=self.asset1,\n            campaign=self.campaign,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            sequence=self.asset1.sequence,\n            transcriber_ids=[self.user.id],\n        )\n\n        # A valid reviewable exists elsewhere\n        other = create_asset(item=self.asset1.item, sequence=3, slug=\"rev-other\")\n        create_transcription(asset=other, user=self.anon, submitted=now())\n\n        # Pass empty project/item to ensure we hit cache (and thus spawn task)\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=None,\n        )\n        self.assertEqual(chosen, other)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n\nclass ReviewableCampaignInternalsTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(sequence=1, slug=\"rc-a1\")\n        # same item, higher sequence\n        self.asset2 = create_asset(item=self.asset1.item, sequence=2, slug=\"rc-a2\")\n        self.campaign = self.asset1.campaign\n\n    def test_reserved_asset_ids_subq_filters_to_campaign(self):\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset1, reservation_token=\"r1\"  # nosec\n        )\n        other_campaign = create_campaign(slug=\"rc-camp-a\", title=\"rc-camp-a\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"rc-proj-a\", title=\"rc-proj-a\"\n        )\n        other_item = create_item(project=other_project, item_id=\"rc-other-item\")\n        other_campaign_asset = create_asset(item=other_item, slug=\"rc-other-camp-a\")\n        AssetTranscriptionReservation.objects.create(\n            asset=other_campaign_asset, reservation_token=\"r2\"  # nosec\n        )\n\n        id_set = set(\n            _reserved_asset_ids_subq(self.campaign).values_list(\"asset_id\", flat=True)\n        )\n        self.assertIn(self.asset1.id, id_set)\n        self.assertNotIn(other_campaign_asset.id, id_set)\n\n    def test_eligible_reviewable_base_qs_excludes_user_and_requires_submitted(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.user, submitted=now())\n        asset3 = create_asset(item=self.asset1.item, sequence=3, slug=\"rc-a3\")\n\n        queryset_user = _eligible_reviewable_base_qs(self.campaign, user=self.user)\n        self.assertIn(self.asset1, queryset_user)\n        self.assertNotIn(self.asset2, queryset_user)\n        self.assertNotIn(asset3, queryset_user)\n\n        queryset_none = _eligible_reviewable_base_qs(self.campaign, user=None)\n        self.assertIn(self.asset1, queryset_none)\n        self.assertIn(self.asset2, queryset_none)\n        self.assertNotIn(asset3, queryset_none)\n\n    def test_next_seq_after_none_missing_and_valid(self):\n        self.assertIsNone(_next_seq_after(None))\n        self.assertIsNone(_next_seq_after(99999999))\n        self.assertEqual(_next_seq_after(self.asset2.pk), self.asset2.sequence)\n\n    def test_find_reviewable_in_item_after_none_returns_first(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        chosen = _find_reviewable_in_item(\n            self.campaign,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=None,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_find_reviewable_in_item_after_asset_in_other_item_ignores_gate(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"rc-other-item\"\n        )\n        other_asset = create_asset(item=other_item, slug=\"rc-other-asset\")\n        create_transcription(asset=other_asset, user=self.anon, submitted=now())\n\n        chosen = _find_reviewable_in_item(\n            self.campaign,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=other_asset.id,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_find_reviewable_in_item_after_asset_missing_ignores_gate(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        chosen = _find_reviewable_in_item(\n            self.campaign,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=987654321,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_find_reviewable_in_item_after_asset_sidc_ignores_gate(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        other_campaign = create_campaign(slug=\"rc-camp-b\", title=\"rc-camp-b\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"rc-proj-b\", title=\"rc-proj-b\"\n        )\n        other_item = create_item(\n            project=other_project, item_id=self.asset1.item.item_id\n        )\n        other_campaign_asset = create_asset(item=other_item, slug=\"rc-cross-camp\")\n        create_transcription(\n            asset=other_campaign_asset, user=self.anon, submitted=now()\n        )\n\n        chosen = _find_reviewable_in_item(\n            self.campaign,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=other_campaign_asset.id,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_find_reviewable_in_project_orders_and_excludes_user(self):\n        other_item = create_item(project=self.asset1.item.project, item_id=\"rc-p-item\")\n        mine = create_asset(item=other_item, sequence=1, slug=\"rc-p-mine\")\n        theirs = create_asset(item=other_item, sequence=2, slug=\"rc-p-theirs\")\n        create_transcription(asset=mine, user=self.user, submitted=now())\n        create_transcription(asset=theirs, user=self.anon, submitted=now())\n\n        chosen = _find_reviewable_in_project(\n            self.campaign,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertEqual(chosen, theirs)\n\n    def test_find_reviewable_in_project_returns_none_when_only_users_work(self):\n        other_item = create_item(project=self.asset1.item.project, item_id=\"rc-p2\")\n        mine = create_asset(item=other_item, sequence=1, slug=\"rc-p2-mine\")\n        create_transcription(asset=mine, user=self.user, submitted=now())\n\n        chosen = _find_reviewable_in_project(\n            self.campaign,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertIsNone(chosen)\n\n    def test_find_new_reviewable_campaign_assets_excludes_reserved_and_next_table(\n        self,\n    ):\n        asset_reserved = create_asset(\n            item=self.asset1.item, sequence=3, slug=\"rc-a-res\"\n        )\n        asset_cached = create_asset(\n            item=self.asset1.item, sequence=4, slug=\"rc-a-cached\"\n        )\n        for asset in (asset_reserved, asset_cached):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n\n        AssetTranscriptionReservation.objects.create(\n            asset=asset_reserved, reservation_token=\"rv\"  # nosec\n        )\n\n        from concordia.models import NextReviewableCampaignAsset\n\n        NextReviewableCampaignAsset.objects.create(\n            asset=asset_cached,\n            campaign=self.campaign,\n            item=asset_cached.item,\n            item_item_id=asset_cached.item.item_id,\n            project=asset_cached.item.project,\n            project_slug=asset_cached.item.project.slug,\n            sequence=asset_cached.sequence,\n            transcriber_ids=[],\n        )\n\n        queryset = find_new_reviewable_campaign_assets(self.campaign, self.user)\n        self.assertNotIn(asset_reserved, queryset)\n        self.assertNotIn(asset_cached, queryset)\n\n    def test_find_and_order_potential_reviewable_campaign_assets_ordering(self):\n        base_item = self.asset1.item\n\n        same_item_next = create_asset(item=base_item, sequence=10, slug=\"rc-ci-next\")\n\n        other_item_same_project = create_asset(\n            item=create_item(project=base_item.project, item_id=\"rc-it-2\"),\n            sequence=5,\n            slug=\"rc-p-next\",\n        )\n\n        other_project = create_project(\n            campaign=self.campaign, slug=\"rc-proj\", title=\"rc-proj\"\n        )\n        other_project_item = create_item(project=other_project, item_id=\"rc-it-3\")\n        other_project_asset = create_asset(\n            item=other_project_item, sequence=1, slug=\"rc-op\"\n        )\n\n        for asset in (same_item_next, other_item_same_project, other_project_asset):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n\n        def cache_row(asset):\n            return NextReviewableCampaignAsset.objects.create(\n                asset=asset,\n                campaign=self.campaign,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                sequence=asset.sequence,\n                transcriber_ids=[],\n            )\n\n        cache_row(same_item_next)\n        cache_row(other_item_same_project)\n        cache_row(other_project_asset)\n\n        after_primary_key = self.asset1.id\n\n        ordered = find_and_order_potential_reviewable_campaign_assets(\n            self.campaign,\n            self.user,\n            project_slug=base_item.project.slug,\n            item_id=base_item.item_id,\n            asset_pk=after_primary_key,\n        ).values_list(\"asset_id\", flat=True)\n\n        ordered = list(ordered)\n        self.assertEqual(ordered[0], same_item_next.id)\n        self.assertEqual(ordered[1], other_item_same_project.id)\n        self.assertIn(other_project_asset.id, ordered[2:])\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_find_reviewable_campaign_asset_no_eligible_spawns_task_and_returns_none(\n        self, mock_get_task\n    ):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_reviewable_campaign_asset(self.campaign, self.user)\n        self.assertIsNone(asset)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_manual_fallback_orders_and_spawns_task(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset_x = create_asset(item=self.asset1.item, sequence=7, slug=\"rc-mf-x\")\n        asset_y = create_asset(item=self.asset1.item, sequence=8, slug=\"rc-mf-y\")\n        create_transcription(asset=asset_x, user=self.anon, submitted=now())\n        create_transcription(asset=asset_y, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=asset_x.id,  # makes asset_y the \"next\" by id\n        )\n        self.assertEqual(chosen, asset_y)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    def test_find_invalid_next_reviewable_campaign_assets_reserved_and_wrong_status(\n        self,\n    ):\n        from concordia.models import NextReviewableCampaignAsset\n\n        reserved_asset = create_asset(\n            item=self.asset1.item, sequence=30, slug=\"rc-inv-res\"\n        )\n        create_transcription(asset=reserved_asset, user=self.anon, submitted=now())\n        AssetTranscriptionReservation.objects.create(\n            asset=reserved_asset, reservation_token=\"rv\"  # nosec\n        )\n        NextReviewableCampaignAsset.objects.create(\n            asset=reserved_asset,\n            campaign=self.campaign,\n            item=reserved_asset.item,\n            item_item_id=reserved_asset.item.item_id,\n            project=reserved_asset.item.project,\n            project_slug=reserved_asset.item.project.slug,\n            sequence=reserved_asset.sequence,\n            transcriber_ids=[],\n        )\n\n        wrong_status_asset = create_asset(\n            item=self.asset1.item, sequence=31, slug=\"rc-inv-wrong\"\n        )\n        create_transcription(asset=wrong_status_asset, user=self.anon)  # IN_PROGRESS\n        NextReviewableCampaignAsset.objects.create(\n            asset=wrong_status_asset,\n            campaign=self.campaign,\n            item=wrong_status_asset.item,\n            item_item_id=wrong_status_asset.item.item_id,\n            project=wrong_status_asset.item.project,\n            project_slug=wrong_status_asset.item.project.slug,\n            sequence=wrong_status_asset.sequence,\n            transcriber_ids=[],\n        )\n\n        invalid = list(\n            find_invalid_next_reviewable_campaign_assets(self.campaign.id).values_list(\n                \"asset_id\", flat=True\n            )\n        )\n        self.assertIn(reserved_asset.id, invalid)\n        self.assertIn(wrong_status_asset.id, invalid)\n\n    def test_item_short_circuit_internal_applies_after_and_skips_reserved(self):\n        asset1 = self.asset1\n        asset2 = self.asset2\n        asset3 = create_asset(item=asset1.item, sequence=3, slug=\"rc-int-a3\")\n        for asset in (asset1, asset2, asset3):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n        AssetTranscriptionReservation.objects.create(\n            asset=asset2, reservation_token=\"rv-int\"  # nosec\n        )\n\n        chosen = _find_reviewable_in_item(\n            self.campaign,\n            self.user,\n            item_id=asset1.item.item_id,\n            after_asset_pk=asset1.id,\n        )\n        self.assertEqual(chosen, asset3)\n\n    def test_item_short_circuit_internal_excludes_users_own_work(self):\n        mine = self.asset1\n        other = self.asset2\n        create_transcription(asset=mine, user=self.user, submitted=now())\n        create_transcription(asset=other, user=self.anon, submitted=now())\n\n        chosen = _find_reviewable_in_item(\n            self.campaign,\n            self.user,\n            item_id=mine.item.item_id,\n            after_asset_pk=None,\n        )\n        self.assertEqual(chosen, other)\n\n    def test_project_short_circuit_internal_skips_reserved_first(self):\n        project = self.asset1.item.project\n        item2 = create_item(project=project, item_id=\"rc-proj-int\")\n        first = create_asset(item=item2, sequence=1, slug=\"rc-proj-int-1\")\n        second = create_asset(item=item2, sequence=2, slug=\"rc-proj-int-2\")\n        for asset in (first, second):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n        AssetTranscriptionReservation.objects.create(\n            asset=first, reservation_token=\"rv-proj-int\"  # nosec\n        )\n\n        chosen = _find_reviewable_in_project(\n            self.campaign,\n            self.user,\n            project_slug=project.slug,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertEqual(chosen, second)\n\n    def test_order_potential_without_after_prefers_item_then_project(self):\n        base_item = self.asset1.item\n\n        same_item = create_asset(item=base_item, sequence=9, slug=\"rc-ci-none\")\n        same_project = create_asset(\n            item=create_item(project=base_item.project, item_id=\"rc-it-np\"),\n            sequence=2,\n            slug=\"rc-p-none\",\n        )\n        other_project = create_asset(\n            item=create_item(\n                project=create_project(\n                    campaign=self.campaign, slug=\"rc-proj-none\", title=\"rc-proj-none\"\n                ),\n                item_id=\"rc-it-op-none\",\n            ),\n            sequence=1,\n            slug=\"rc-op-none\",\n        )\n        for asset in (same_item, same_project, other_project):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n\n        NextReviewableCampaignAsset.objects.create(\n            asset=same_item,\n            campaign=self.campaign,\n            item=same_item.item,\n            item_item_id=same_item.item.item_id,\n            project=same_item.item.project,\n            project_slug=same_item.item.project.slug,\n            sequence=same_item.sequence,\n            transcriber_ids=[],\n        )\n        NextReviewableCampaignAsset.objects.create(\n            asset=same_project,\n            campaign=self.campaign,\n            item=same_project.item,\n            item_item_id=same_project.item.item_id,\n            project=same_project.item.project,\n            project_slug=same_project.item.project.slug,\n            sequence=same_project.sequence,\n            transcriber_ids=[],\n        )\n        NextReviewableCampaignAsset.objects.create(\n            asset=other_project,\n            campaign=self.campaign,\n            item=other_project.item,\n            item_item_id=other_project.item.item_id,\n            project=other_project.item.project,\n            project_slug=other_project.item.project.slug,\n            sequence=other_project.sequence,\n            transcriber_ids=[],\n        )\n\n        ordered = find_and_order_potential_reviewable_campaign_assets(\n            self.campaign,\n            self.user,\n            project_slug=base_item.project.slug,\n            item_id=base_item.item_id,\n            asset_pk=None,  # ensure next_asset==0\n        ).values_list(\"asset_id\", flat=True)\n\n        ordered = list(ordered)\n        self.assertEqual(ordered[0], same_item.id)\n        self.assertEqual(ordered[1], same_project.id)\n        self.assertIn(other_project.id, ordered[2:])\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_next_reviewable_manual_fallback_no_after_spawns_and_picks_lowest_seq(\n        self, mock_get_task\n    ):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset1 = self.asset1\n        asset2 = self.asset2\n        create_transcription(asset=asset1, user=self.anon, submitted=now())\n        create_transcription(asset=asset2, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=None,  # triggers Value(0) annotation branch\n        )\n        self.assertEqual(chosen, asset1)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_next_reviewable_manual_fallback_invalid_after_str(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset1 = self.asset1\n        asset2 = self.asset2\n        create_transcription(asset=asset1, user=self.anon, submitted=now())\n        create_transcription(asset=asset2, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=\"not-an-int\",\n        )\n        self.assertEqual(chosen, asset1)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_next_reviewable_cached_path_when_short_circuits_fail(self, mock_get_task):\n        \"\"\"\n        Provide item_id and project_slug so short-circuits run but fail\n        (only user's SUBMITTED work in that scope), then ensure we use\n        the cached table (no task spawned).\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        create_transcription(asset=self.asset1, user=self.user, submitted=now())\n        create_transcription(asset=self.asset2, user=self.user, submitted=now())\n\n        cached_project = create_project(\n            campaign=self.campaign, slug=\"rc-cached-proj\", title=\"rc-cached-proj\"\n        )\n        cached_item = create_item(project=cached_project, item_id=\"rc-cached-item\")\n        cached_asset = create_asset(item=cached_item, slug=\"rc-cached-asset\")\n        create_transcription(asset=cached_asset, user=self.anon, submitted=now())\n\n        NextReviewableCampaignAsset.objects.create(\n            asset=cached_asset,\n            campaign=self.campaign,\n            item=cached_asset.item,\n            item_item_id=cached_asset.item.item_id,\n            project=cached_asset.item.project,\n            project_slug=cached_asset.item.project.slug,\n            sequence=cached_asset.sequence,\n            transcriber_ids=[],\n        )\n\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, cached_asset)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.campaign.get_registered_task\")\n    def test_next_reviewable_uses_cache_when_bypassing_short_circuits(\n        self, mock_get_task\n    ):\n        \"\"\"\n        Skip both short-circuits by passing blanks; ensure we return from\n        the cache table directly (no task spawned).\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        cached_project = create_project(\n            campaign=self.campaign,\n            slug=\"rc-cached-proj-2\",\n            title=\"rc-cached-proj-2\",\n        )\n        cached_item = create_item(project=cached_project, item_id=\"rc-cached-item-2\")\n        cached_asset = create_asset(item=cached_item, slug=\"rc-cached-asset-2\")\n        create_transcription(asset=cached_asset, user=self.anon, submitted=now())\n\n        NextReviewableCampaignAsset.objects.create(\n            asset=cached_asset,\n            campaign=self.campaign,\n            item=cached_asset.item,\n            item_item_id=cached_asset.item.item_id,\n            project=cached_asset.item.project,\n            project_slug=cached_asset.item.project.slug,\n            sequence=cached_asset.sequence,\n            transcriber_ids=[],\n        )\n\n        chosen = find_next_reviewable_campaign_asset(\n            self.campaign,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=None,\n        )\n        self.assertEqual(chosen, cached_asset)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n"
  },
  {
    "path": "concordia/tests/test_utils_next_asset_reviewable_topic.py",
    "content": "from unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase\nfrom django.utils.timezone import now\n\nfrom concordia.models import (\n    AssetTranscriptionReservation,\n    NextReviewableTopicAsset,\n)\nfrom concordia.utils import get_anonymous_user\nfrom concordia.utils.next_asset import (\n    find_new_reviewable_topic_assets,\n    find_next_reviewable_topic_asset,\n    find_reviewable_topic_asset,\n)\nfrom concordia.utils.next_asset.reviewable.topic import (\n    _eligible_reviewable_base_qs as topic_eligible_reviewable_base_qs,\n)\nfrom concordia.utils.next_asset.reviewable.topic import (\n    _find_reviewable_in_item as topic_find_reviewable_in_item,\n)\nfrom concordia.utils.next_asset.reviewable.topic import (\n    _find_reviewable_in_project as topic_find_reviewable_in_project,\n)\nfrom concordia.utils.next_asset.reviewable.topic import (\n    _next_seq_after as topic_next_seq_after,\n)\nfrom concordia.utils.next_asset.reviewable.topic import (\n    _reserved_asset_ids_subq as topic_reserved_asset_ids_subq,\n)\nfrom concordia.utils.next_asset.reviewable.topic import (\n    find_and_order_potential_reviewable_topic_assets,\n    find_invalid_next_reviewable_topic_assets,\n)\nfrom concordia.utils.next_asset.reviewable.topic import (\n    find_next_reviewable_topic_assets as find_cached_reviewable_topic_assets,\n)\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_topic,\n    create_transcription,\n)\n\n\nclass NextReviewableTopicAssetTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(sequence=1)\n        self.asset2 = create_asset(\n            item=self.asset1.item, sequence=2, slug=\"test-asset-2\"\n        )\n        self.topic = create_topic(project=self.asset1.item.project)\n\n    def test_find_new_reviewable_topic_assets_filters_correctly(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n\n        queryset = find_new_reviewable_topic_assets(self.topic, self.user)\n        self.assertIn(self.asset1, queryset)\n\n    def test_find_new_reviewable_topic_assets_without_user(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n\n        queryset = find_new_reviewable_topic_assets(self.topic, None)\n        self.assertIn(self.asset1, queryset)\n\n    def test_find_reviewable_topic_asset_from_next_table(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n\n        NextReviewableTopicAsset.objects.create(\n            asset=self.asset1,\n            topic=self.topic,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            sequence=self.asset1.sequence,\n            transcriber_ids=[],\n        )\n\n        asset = find_reviewable_topic_asset(self.topic, self.user)\n        self.assertEqual(asset, self.asset1)\n\n    @patch(\"concordia.utils.next_asset.reviewable.topic.get_registered_task\")\n    def test_find_reviewable_topic_asset_falls_back_and_spawns_task(\n        self, mock_get_task\n    ):\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_reviewable_topic_asset(self.topic, self.user)\n        self.assertEqual(asset, self.asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.topic.get_registered_task\")\n    def test_find_next_reviewable_topic_asset_orders_and_falls_back(\n        self, mock_get_task\n    ):\n        \"\"\"\n        With short-circuiting: project-level returns\n        the eligible asset and we do not spawn a task.\n        \"\"\"\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(asset, self.asset1)\n        # Short-circuit satisfied -> no cache fallback -> no task spawned\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.topic.get_registered_task\")\n    def test_find_next_reviewable_topic_asset_when_next_asset_exists(\n        self, mock_get_task\n    ):\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        NextReviewableTopicAsset.objects.create(\n            asset=self.asset2,\n            topic=self.topic,\n            item=self.asset2.item,\n            item_item_id=self.asset2.item.item_id,\n            project=self.asset2.item.project,\n            project_slug=self.asset2.item.project.slug,\n            sequence=self.asset2.sequence,\n            transcriber_ids=[],\n        )\n\n        asset = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=self.asset2.item.project.slug,\n            item_id=self.asset2.item.item_id,\n            original_asset_id=self.asset2.id - 1,\n        )\n        self.assertEqual(asset, self.asset2)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_short_circuit_same_item_topic_excludes_users_own_work(self):\n        mine = self.asset1\n        other = self.asset2\n        create_transcription(asset=mine, user=self.user, submitted=now())\n        create_transcription(asset=other, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=mine.item.project.slug,\n            item_id=mine.item.item_id,\n            original_asset_id=mine.id,\n        )\n        self.assertEqual(chosen, other)\n\n    def test_item_short_circuit_topic_reviewable_respects_after_and_reservations(self):\n        asset3 = create_asset(item=self.asset1.item, sequence=3, slug=\"rev-topic-a3\")\n        for asset in (self.asset1, self.asset2, asset3):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, self.asset2)\n\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset2, reservation_token=\"rv\"  # nosec\n        )\n        chosen2 = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen2, asset3)\n\n    @patch(\"concordia.utils.next_asset.reviewable.topic.get_registered_task\")\n    def test_cache_excludes_user_and_triggers_spawn_task_topic(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        create_transcription(asset=self.asset1, user=self.user, submitted=now())\n        NextReviewableTopicAsset.objects.create(\n            asset=self.asset1,\n            topic=self.topic,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            sequence=self.asset1.sequence,\n            transcriber_ids=[self.user.id],\n        )\n\n        other = create_asset(item=self.asset1.item, sequence=3, slug=\"rev-topic-other\")\n        create_transcription(asset=other, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=None,\n        )\n        self.assertEqual(chosen, other)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    def test_find_next_reviewable_topic_assets_excludes_user(self):\n        create_transcription(asset=self.asset1, user=self.user, submitted=now())\n        NextReviewableTopicAsset.objects.create(\n            asset=self.asset1,\n            topic=self.topic,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            sequence=self.asset1.sequence,\n            transcriber_ids=[self.user.id],\n        )\n        NextReviewableTopicAsset.objects.create(\n            asset=self.asset2,\n            topic=self.topic,\n            item=self.asset2.item,\n            item_item_id=self.asset2.item.item_id,\n            project=self.asset2.item.project,\n            project_slug=self.asset2.item.project.slug,\n            sequence=self.asset2.sequence,\n            transcriber_ids=[],\n        )\n        queryset = find_cached_reviewable_topic_assets(self.topic, self.user)\n        self.assertNotIn(self.asset1.id, queryset.values_list(\"asset_id\", flat=True))\n        self.assertIn(self.asset2.id, queryset.values_list(\"asset_id\", flat=True))\n\n    @patch(\"concordia.utils.next_asset.reviewable.topic.get_registered_task\")\n    def test_next_reviewable_cached_path_when_short_circuits_fail_topic(\n        self, mock_get_task\n    ):\n        \"\"\"\n        Item+project short-circuits fail (only user's work), so we should pull\n        from the cached table and not spawn a task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Only user's submitted work in current item/project\n        create_transcription(asset=self.asset1, user=self.user, submitted=now())\n        create_transcription(asset=self.asset2, user=self.user, submitted=now())\n\n        # Cached eligible asset in another project (not reachable via short-circuit)\n        cached_project = create_project(\n            campaign=self.asset1.campaign,\n            slug=\"topic-cached-proj\",\n            title=\"topic-cached-proj\",\n        )\n        cached_item = create_item(project=cached_project, item_id=\"topic-cached-item\")\n        cached_asset = create_asset(item=cached_item, slug=\"topic-cached-asset\")\n        create_transcription(asset=cached_asset, user=self.anon, submitted=now())\n\n        NextReviewableTopicAsset.objects.create(\n            asset=cached_asset,\n            topic=self.topic,\n            item=cached_asset.item,\n            item_item_id=cached_asset.item.item_id,\n            project=cached_asset.item.project,\n            project_slug=cached_asset.item.project.slug,\n            sequence=cached_asset.sequence,\n            transcriber_ids=[],\n        )\n\n        chosen = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, cached_asset)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.topic.get_registered_task\")\n    def test_next_reviewable_uses_cache_when_bypassing_short_circuits_topic(\n        self, mock_get_task\n    ):\n        \"\"\"\n        Pass blanks for project/item so we bypass short-circuits and hit cache.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        cached_project = create_project(\n            campaign=self.asset1.campaign,\n            slug=\"topic-cached-proj-2\",\n            title=\"topic-cached-proj-2\",\n        )\n        cached_item = create_item(project=cached_project, item_id=\"topic-cached-item-2\")\n        cached_asset = create_asset(item=cached_item, slug=\"topic-cached-asset-2\")\n        create_transcription(asset=cached_asset, user=self.anon, submitted=now())\n\n        NextReviewableTopicAsset.objects.create(\n            asset=cached_asset,\n            topic=self.topic,\n            item=cached_asset.item,\n            item_item_id=cached_asset.item.item_id,\n            project=cached_asset.item.project,\n            project_slug=cached_asset.item.project.slug,\n            sequence=cached_asset.sequence,\n            transcriber_ids=[],\n        )\n\n        chosen = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=None,\n        )\n        self.assertEqual(chosen, cached_asset)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.topic.get_registered_task\")\n    def test_next_reviewable_manual_fallback_no_after_spawns_and_picks_lowest_seq_topic(\n        self, mock_get_task\n    ):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        other_item = create_item(project=self.asset1.item.project, item_id=\"t-mf-item\")\n        asset_x = create_asset(item=other_item, sequence=7, slug=\"t-mf-x\")\n        asset_y = create_asset(item=other_item, sequence=8, slug=\"t-mf-y\")\n        create_transcription(asset=asset_x, user=self.anon, submitted=now())\n        create_transcription(asset=asset_y, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=None,\n        )\n        self.assertEqual(chosen, asset_x)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.reviewable.topic.get_registered_task\")\n    def test_next_reviewable_manual_fallback_invalid_after_str_topic(\n        self, mock_get_task\n    ):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"t-mf-item-2\"\n        )\n        asset_a = create_asset(item=other_item, sequence=1, slug=\"t-mf-a\")\n        asset_b = create_asset(item=other_item, sequence=2, slug=\"t-mf-b\")\n        create_transcription(asset=asset_a, user=self.anon, submitted=now())\n        create_transcription(asset=asset_b, user=self.anon, submitted=now())\n\n        chosen = find_next_reviewable_topic_asset(\n            self.topic,\n            self.user,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=\"not-an-int\",\n        )\n        self.assertEqual(chosen, asset_a)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n\nclass ReviewableTopicInternalsTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(sequence=1, slug=\"rt-a1\")\n        self.asset2 = create_asset(item=self.asset1.item, sequence=2, slug=\"rt-a2\")\n        self.topic = create_topic(project=self.asset1.item.project)\n\n    def test_topic_reserved_asset_ids_subq_unfiltered(self):\n        # Reservation tied to this test topic\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset1,\n            reservation_token=\"rt-r1\",  # nosec\n        )\n        # Reservation in entirely different campaign/project\n        other_campaign = create_campaign(slug=\"rt-camp-x\", title=\"rt-camp-x\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"rt-proj-x\", title=\"rt-proj-x\"\n        )\n        other_item = create_item(project=other_project, item_id=\"rt-item-x\")\n        other_asset = create_asset(item=other_item, slug=\"rt-asset-x\")\n        AssetTranscriptionReservation.objects.create(\n            asset=other_asset,\n            reservation_token=\"rt-r2\",  # nosec\n        )\n\n        id_set = set(topic_reserved_asset_ids_subq().values_list(\"asset_id\", flat=True))\n        self.assertIn(self.asset1.id, id_set)\n        self.assertIn(other_asset.id, id_set)\n\n    def test_topic_eligible_reviewable_base_qs_excludes_user_and_requires_submitted(\n        self,\n    ):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.user, submitted=now())\n        asset3 = create_asset(item=self.asset1.item, sequence=3, slug=\"rt-a3\")\n        # asset3 has no submitted timestamp -> not SUBMITTED\n\n        queryset_user = topic_eligible_reviewable_base_qs(self.topic, user=self.user)\n        self.assertIn(self.asset1, queryset_user)\n        self.assertNotIn(self.asset2, queryset_user)\n        self.assertNotIn(asset3, queryset_user)\n\n        queryset_none = topic_eligible_reviewable_base_qs(self.topic, user=None)\n        self.assertIn(self.asset1, queryset_none)\n        self.assertIn(self.asset2, queryset_none)\n        self.assertNotIn(asset3, queryset_none)\n\n    def test_topic_next_seq_after_none_missing_and_valid(self):\n        self.assertIsNone(topic_next_seq_after(None))\n        self.assertIsNone(topic_next_seq_after(987654321))\n        self.assertEqual(topic_next_seq_after(self.asset2.pk), self.asset2.sequence)\n\n    def test_topic_find_reviewable_in_item_after_none_returns_first(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        chosen = topic_find_reviewable_in_item(\n            self.topic,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=None,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_topic_find_reviewable_in_item_after_asset_in_other_item_ignores_gate(\n        self,\n    ):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"rt-other-item\"\n        )\n        other_asset = create_asset(item=other_item, slug=\"rt-other-asset\")\n        create_transcription(asset=other_asset, user=self.anon, submitted=now())\n\n        chosen = topic_find_reviewable_in_item(\n            self.topic,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=other_asset.id,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_topic_find_reviewable_in_item_after_asset_missing_ignores_gate(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        chosen = topic_find_reviewable_in_item(\n            self.topic,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=123456789,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_topic_find_reviewable_in_item_after_asset_sidc_ignores_gate(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        other_campaign = create_campaign(slug=\"rt-camp-b\", title=\"rt-camp-b\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"rt-proj-b\", title=\"rt-proj-b\"\n        )\n        other_item = create_item(\n            project=other_project, item_id=self.asset1.item.item_id\n        )\n        cross_asset = create_asset(item=other_item, slug=\"rt-cross\")\n        create_transcription(asset=cross_asset, user=self.anon, submitted=now())\n\n        chosen = topic_find_reviewable_in_item(\n            self.topic,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=cross_asset.id,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_topic_find_reviewable_in_project_orders_and_excludes_user(self):\n        project = self.asset1.item.project\n        other_item = create_item(project=project, item_id=\"rt-p-item\")\n        mine = create_asset(item=other_item, sequence=1, slug=\"rt-p-mine\")\n        theirs = create_asset(item=other_item, sequence=2, slug=\"rt-p-theirs\")\n        create_transcription(asset=mine, user=self.user, submitted=now())\n        create_transcription(asset=theirs, user=self.anon, submitted=now())\n\n        chosen = topic_find_reviewable_in_project(\n            self.topic,\n            self.user,\n            project_slug=project.slug,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertEqual(chosen, theirs)\n\n    def test_topic_find_reviewable_in_project_returns_none_when_only_users_work(self):\n        project = self.asset1.item.project\n        other_item = create_item(project=project, item_id=\"rt-p2\")\n        mine = create_asset(item=other_item, sequence=1, slug=\"rt-p2-mine\")\n        create_transcription(asset=mine, user=self.user, submitted=now())\n\n        chosen = topic_find_reviewable_in_project(\n            self.topic,\n            self.user,\n            project_slug=project.slug,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertIsNone(chosen)\n\n    def test_find_and_order_potential_reviewable_topic_assets_ordering(self):\n        base_item = self.asset1.item\n\n        same_item_next = create_asset(item=base_item, sequence=10, slug=\"rt-ci-next\")\n        other_item_same_project = create_asset(\n            item=create_item(project=base_item.project, item_id=\"rt-it-2\"),\n            sequence=5,\n            slug=\"rt-p-next\",\n        )\n        other_project = create_project(\n            campaign=self.asset1.campaign, slug=\"rt-proj\", title=\"rt-proj\"\n        )\n        other_project_item = create_item(project=other_project, item_id=\"rt-it-3\")\n        other_project_asset = create_asset(\n            item=other_project_item, sequence=1, slug=\"rt-op\"\n        )\n\n        for asset in (same_item_next, other_item_same_project, other_project_asset):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n            NextReviewableTopicAsset.objects.create(\n                asset=asset,\n                topic=self.topic,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                sequence=asset.sequence,\n                transcriber_ids=[],\n            )\n\n        ordered = find_and_order_potential_reviewable_topic_assets(\n            self.topic,\n            self.user,\n            project_slug=base_item.project.slug,\n            item_id=base_item.item_id,\n            asset_pk=self.asset1.id,\n        ).values_list(\"asset_id\", flat=True)\n\n        ordered = list(ordered)\n        self.assertEqual(ordered[0], same_item_next.id)\n        self.assertEqual(ordered[1], other_item_same_project.id)\n        self.assertIn(other_project_asset.id, ordered[2:])\n\n    def test_order_potential_without_after_prefers_item_then_project_topic(self):\n        base_item = self.asset1.item\n\n        same_item = create_asset(item=base_item, sequence=9, slug=\"rt-ci-none\")\n        same_project = create_asset(\n            item=create_item(project=base_item.project, item_id=\"rt-it-np\"),\n            sequence=2,\n            slug=\"rt-p-none\",\n        )\n        other_project = create_project(\n            campaign=self.asset1.campaign, slug=\"rt-proj-none\", title=\"rt-proj-none\"\n        )\n        other_project_item = create_item(project=other_project, item_id=\"rt-it-op-none\")\n        other_project_asset = create_asset(\n            item=other_project_item, sequence=1, slug=\"rt-op-none\"\n        )\n        for asset in (same_item, same_project, other_project_asset):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n            NextReviewableTopicAsset.objects.create(\n                asset=asset,\n                topic=self.topic,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                sequence=asset.sequence,\n                transcriber_ids=[],\n            )\n\n        ordered = find_and_order_potential_reviewable_topic_assets(\n            self.topic,\n            self.user,\n            project_slug=base_item.project.slug,\n            item_id=base_item.item_id,\n            asset_pk=None,  # next_asset==0 branch\n        ).values_list(\"asset_id\", flat=True)\n\n        ordered = list(ordered)\n        self.assertEqual(ordered[0], same_item.id)\n        self.assertEqual(ordered[1], same_project.id)\n        self.assertIn(other_project_asset.id, ordered[2:])\n\n    def test_find_invalid_next_reviewable_topic_assets_reserved_and_wrong_status(\n        self,\n    ):\n        # Reserved\n        reserved_asset = create_asset(\n            item=self.asset1.item, sequence=30, slug=\"rt-inv-res\"\n        )\n        create_transcription(asset=reserved_asset, user=self.anon, submitted=now())\n        AssetTranscriptionReservation.objects.create(\n            asset=reserved_asset, reservation_token=\"rt-rv\"  # nosec\n        )\n        NextReviewableTopicAsset.objects.create(\n            asset=reserved_asset,\n            topic=self.topic,\n            item=reserved_asset.item,\n            item_item_id=reserved_asset.item.item_id,\n            project=reserved_asset.item.project,\n            project_slug=reserved_asset.item.project.slug,\n            sequence=reserved_asset.sequence,\n            transcriber_ids=[],\n        )\n\n        # Wrong status (IN_PROGRESS)\n        wrong_status_asset = create_asset(\n            item=self.asset1.item, sequence=31, slug=\"rt-inv-wrong\"\n        )\n        create_transcription(asset=wrong_status_asset, user=self.anon)\n        NextReviewableTopicAsset.objects.create(\n            asset=wrong_status_asset,\n            topic=self.topic,\n            item=wrong_status_asset.item,\n            item_item_id=wrong_status_asset.item.item_id,\n            project=wrong_status_asset.item.project,\n            project_slug=wrong_status_asset.item.project.slug,\n            sequence=wrong_status_asset.sequence,\n            transcriber_ids=[],\n        )\n\n        invalid_ids = list(\n            find_invalid_next_reviewable_topic_assets(self.topic.id).values_list(\n                \"asset_id\", flat=True\n            )\n        )\n        self.assertIn(reserved_asset.id, invalid_ids)\n        self.assertIn(wrong_status_asset.id, invalid_ids)\n\n    def test_topic_project_short_circuit_internal_skips_reserved_first(self):\n        project = self.asset1.item.project\n        item2 = create_item(project=project, item_id=\"rt-proj-int\")\n        first = create_asset(item=item2, sequence=1, slug=\"rt-proj-int-1\")\n        second = create_asset(item=item2, sequence=2, slug=\"rt-proj-int-2\")\n        for asset in (first, second):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n        AssetTranscriptionReservation.objects.create(\n            asset=first, reservation_token=\"rt-proj-int\"  # nosec\n        )\n\n        chosen = topic_find_reviewable_in_project(\n            self.topic,\n            self.user,\n            project_slug=project.slug,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertEqual(chosen, second)\n\n    def test_topic_item_short_circuit_internal_excludes_users_own_work(self):\n        mine = self.asset1\n        other = self.asset2\n        create_transcription(asset=mine, user=self.user, submitted=now())\n        create_transcription(asset=other, user=self.anon, submitted=now())\n\n        chosen = topic_find_reviewable_in_item(\n            self.topic,\n            self.user,\n            item_id=mine.item.item_id,\n            after_asset_pk=None,\n        )\n        self.assertEqual(chosen, other)\n\n    def test_topic_item_short_circuit_internal_applies_after_and_skips_reserved(self):\n        asset3 = create_asset(item=self.asset1.item, sequence=3, slug=\"rt-int-a3\")\n        for asset in (self.asset1, self.asset2, asset3):\n            create_transcription(asset=asset, user=self.anon, submitted=now())\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset2, reservation_token=\"rt-int-rv\"  # nosec\n        )\n\n        chosen = topic_find_reviewable_in_item(\n            self.topic,\n            self.user,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertEqual(chosen, asset3)\n"
  },
  {
    "path": "concordia/tests/test_utils_next_asset_transcribable_campaign.py",
    "content": "from unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase\nfrom django.utils.timezone import now\n\nfrom concordia.models import (\n    Asset,\n    AssetTranscriptionReservation,\n    NextTranscribableCampaignAsset,\n    TranscriptionStatus,\n)\nfrom concordia.utils import get_anonymous_user\nfrom concordia.utils.next_asset import (\n    find_new_transcribable_campaign_assets,\n    find_next_transcribable_campaign_asset,\n    find_transcribable_campaign_asset,\n)\nfrom concordia.utils.next_asset.transcribable.campaign import (\n    _eligible_transcribable_base_qs as tc_eligible_base_qs,\n)\nfrom concordia.utils.next_asset.transcribable.campaign import (\n    _find_transcribable_in_item as tc_find_in_item,\n)\nfrom concordia.utils.next_asset.transcribable.campaign import (\n    _find_transcribable_not_started_in_project as tc_find_ns_in_proj,\n)\nfrom concordia.utils.next_asset.transcribable.campaign import (\n    _next_seq_after as tc_next_seq_after,\n)\nfrom concordia.utils.next_asset.transcribable.campaign import (\n    _order_unstarted_first as tc_order_unstarted_first,\n)\nfrom concordia.utils.next_asset.transcribable.campaign import (\n    _reserved_asset_ids_subq as tc_reserved_ids_subq,\n)\nfrom concordia.utils.next_asset.transcribable.campaign import (\n    find_and_order_potential_transcribable_campaign_assets,\n    find_invalid_next_transcribable_campaign_assets,\n)\nfrom concordia.utils.next_asset.transcribable.campaign import (\n    find_next_transcribable_campaign_assets as find_cached_transcribable_assets,\n)\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_transcription,\n)\n\n\nclass NextTranscribableCampaignAssetTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(\n            sequence=1, slug=\"test-asset-1\", title=\"Test Asset 1\"\n        )\n        self.asset2 = create_asset(\n            item=self.asset1.item, sequence=2, slug=\"test-asset-2\", title=\"Test Asset 2\"\n        )\n        self.campaign = self.asset1.campaign\n\n    def test_find_new_transcribable_campaign_assets_filters_correctly(self):\n        create_transcription(\n            asset=self.asset1,\n            user=self.anon,\n            submitted=now(),\n        )\n\n        queryset = find_new_transcribable_campaign_assets(self.campaign)\n        self.assertNotIn(self.asset1, queryset)\n        self.assertIn(self.asset2, queryset)\n\n    def test_find_transcribable_campaign_asset_from_next_table(self):\n        NextTranscribableCampaignAsset.objects.create(\n            asset=self.asset1,\n            campaign=self.campaign,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            sequence=self.asset1.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n\n        asset = find_transcribable_campaign_asset(self.campaign)\n        self.assertEqual(asset, self.asset1)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_find_transcribable_campaign_asset_falls_back_and_spawns_task(\n        self, mock_get_task\n    ):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_transcribable_campaign_asset(self.campaign)\n        self.assertEqual(asset, self.asset1)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_find_next_transcribable_campaign_asset_orders_and_falls_back(\n        self, mock_get_task\n    ):\n        \"\"\"\n        With short-circuiting: item-level returns the next asset\n        and we do not spawn a task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(asset, self.asset2)\n        # Short-circuit satisfied -> no cache fallback -> no task spawned\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_find_next_transcribable_campaign_asset_when_next_asset_exists(\n        self, mock_get_task\n    ):\n        # Make asset2 eligible (IN_PROGRESS)\n        create_transcription(\n            asset=self.asset2,\n            user=self.anon,\n        )\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Cache has asset2\n        NextTranscribableCampaignAsset.objects.create(\n            asset=self.asset2,\n            campaign=self.campaign,\n            item=self.asset2.item,\n            item_item_id=self.asset2.item.item_id,\n            project=self.asset2.item.project,\n            project_slug=self.asset2.item.project.slug,\n            sequence=self.asset2.sequence,\n            transcription_status=TranscriptionStatus.IN_PROGRESS,\n        )\n\n        # Bypass item/project short-circuits so we hit the cache\n        asset = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=None,\n        )\n        self.assertEqual(asset, self.asset2)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_short_circuit_same_item_respects_after_sequence_and_reservations(self):\n        \"\"\"\n        Same item short-circuit:\n        - Picks the next by sequence (> original)\n        - Skips reserved assets\n        \"\"\"\n        asset3 = create_asset(\n            item=self.asset1.item, sequence=3, slug=\"test-asset-3\", title=\"Test Asset 3\"\n        )\n\n        # Normal: after asset1 => choose asset2\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, self.asset2)\n\n        # Reserve asset2 => should skip to asset3\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset2, reservation_token=\"tkn\"  # nosec\n        )\n        chosen2 = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen2, asset3)\n\n    def test_project_short_circuit_prefers_not_started_over_in_progress(self):\n        \"\"\"\n        When item-level has no eligible assets, project-level should:\n        - Prefer NOT_STARTED over IN_PROGRESS\n        - Order by (item_id, sequence) within same status\n        \"\"\"\n        # Exhaust item: mark both item assets submitted\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"proj-only-item\"\n        )\n        in_progress_asset = create_asset(\n            item=other_item, slug=\"proj-inprog\", title=\"Proj InProg\"\n        )\n        create_transcription(asset=in_progress_asset, user=self.anon)  # IN_PROGRESS\n\n        not_started_asset = create_asset(\n            item=other_item, slug=\"proj-notstarted\", title=\"Proj NotStarted\"\n        )\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,  # same item, but it's exhausted\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, not_started_asset)\n\n    def test_project_short_circuit_when_item_id_empty_string(self):\n        \"\"\"\n        If item_id is '', skip item short-circuit and use project-level.\n        \"\"\"\n        other_item = create_item(project=self.asset1.item.project, item_id=\"proj2\")\n        project_asset = create_asset(\n            item=other_item, slug=\"proj-asset\", title=\"Proj Asset\"\n        )\n        # Make current item ineligible\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=\"\",  # empty skips item short-circuit\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, project_asset)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_find_transcribable_campaign_asset_none_spawns(self, mock_get_task):\n        \"\"\"\n        When no NOT_STARTED/IN_PROGRESS exist, return None and trigger populate.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n        # Make both assets SUBMITTED (no transcribable remain)\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        asset = find_transcribable_campaign_asset(self.campaign)\n        self.assertIsNone(asset)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_next_transcribable_manual_no_after_prefers_not_started(\n        self, mock_get_task\n    ):\n        \"\"\"\n        With no short-circuit and empty cache, pick NOT_STARTED and spawn task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        campaign2 = create_campaign(slug=\"tc-na-camp\", title=\"tc-na-camp\")\n        project2 = create_project(\n            campaign=campaign2, slug=\"tc-na-proj\", title=\"tc-na-proj\"\n        )\n        item2 = create_item(project=project2, item_id=\"tc-na-item\")\n\n        not_started_asset = create_asset(\n            item=item2, sequence=2, slug=\"tc-na-ns\", title=\"TC NA NS\"\n        )\n        in_progress_asset = create_asset(\n            item=item2, sequence=1, slug=\"tc-na-ip\", title=\"TC NA IP\"\n        )\n        create_transcription(asset=in_progress_asset, user=self.anon)  # IN_PROGRESS\n\n        chosen = find_next_transcribable_campaign_asset(\n            campaign2, project_slug=\"\", item_id=\"\", original_asset_id=None\n        )\n        self.assertEqual(chosen, not_started_asset)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_next_transcribable_manual_invalid_after_str(self, mock_get_task):\n        \"\"\"\n        Treat a non-integer \"after\" like None: choose NOT_STARTED and spawn task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Make existing setup assets ineligible for selection.\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tc-mf-item-2\"\n        )\n        asset_a = create_asset(\n            item=other_item, sequence=1, slug=\"tc-mf-a\", title=\"TC MF A\"\n        )\n        asset_b = create_asset(\n            item=other_item, sequence=2, slug=\"tc-mf-b\", title=\"TC MF B\"\n        )\n        create_transcription(asset=asset_b, user=self.anon)  # IN_PROGRESS\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign, project_slug=\"\", item_id=\"\", original_asset_id=None\n        )\n        self.assertEqual(chosen, asset_a)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_next_transcribable_none_anywhere_spawns(self, mock_get_task):\n        \"\"\"\n        With no cache and no manual candidates: return None; do not spawn a task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        campaign2 = create_campaign(slug=\"tc-none-camp\", title=\"tc-none-camp\")\n\n        chosen = find_next_transcribable_campaign_asset(\n            campaign2, project_slug=\"\", item_id=\"\", original_asset_id=None\n        )\n        self.assertIsNone(chosen)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_item_short_circuit_missing_after_pk_treated_as_none_top(self):\n        # Both assets are NOT_STARTED in the same item. Give a missing \"after\".\n        missing_pk = 987_654_321\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=missing_pk,\n        )\n        # With no valid \"after\" seq, returns the first NOT_STARTED (asset1).\n        self.assertEqual(chosen, self.asset1)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_cache_excludes_original_pk_and_chooses_next(self, mock_get_task):\n        # Two cached rows; original points at the first -> second should be chosen.\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tc-cache-pk-item\"\n        )\n        first = create_asset(item=other_item, sequence=1, slug=\"tc-cache-first\")\n        second = create_asset(item=other_item, sequence=2, slug=\"tc-cache-second\")\n\n        for asset in (first, second):\n            NextTranscribableCampaignAsset.objects.create(\n                asset=asset,\n                campaign=self.campaign,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                sequence=asset.sequence,\n                transcription_status=TranscriptionStatus.NOT_STARTED,\n            )\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=first.id,\n        )\n        self.assertEqual(chosen, second)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_project_short_circuit_without_original_id(self):\n        # Exhaust current item; ensure project-level returns NOT_STARTED with\n        # original_asset_id=None.\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tc-proj-no-orig\"\n        )\n        pick = create_asset(item=other_item, sequence=5, slug=\"tc-proj-pick\")\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=None,\n        )\n        self.assertEqual(chosen, pick)\n\n    def test_item_short_circuit_after_pk_in_other_item_ignores_gate(self):\n        # Original PK exists but belongs to a different item; treat as no \"after\".\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tc-oth-it-ignores-gate\"\n        )\n        other_asset = create_asset(item=other_item, slug=\"tc-oth-a-ignores-gate\")\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=other_asset.id,\n        )\n        # With no valid \"after\" in this item, pick first NOT_STARTED by sequence.\n        self.assertEqual(chosen, self.asset1)\n\n    def test_next_transcribable_after_pk_missing_treats_as_no_after(self):\n        \"\"\"\n        Missing original_asset_id -> ignore 'after' gate and pick first NS in item.\n        \"\"\"\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=987654321,  # missing\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_no_ns_anywhere_and_no_ip_in_item_returns_none(self, mock_get_task):\n        \"\"\"\n        With item_id present: no NOT_STARTED anywhere and no same-item IN_PROGRESS\n        so return None and do not spawn a task.\n        \"\"\"\n        # Exhaust the only item in the project/campaign.\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        got = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertIsNone(got)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_after_pk_digit_string_missing_treats_as_no_after(self):\n        \"\"\"\n        original_asset_id is a digit string for a missing PK -> treat like no 'after'.\n        Covers the DoesNotExist branch distinct from non-digit ValueError.\n        \"\"\"\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=\"987654321\",  # digit string, no such Asset\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_same_item_inprogress_selected_when_no_ns_and_no_after(self):\n        \"\"\"\n        With item_id present, no NOT_STARTED anywhere and original_asset_id=None,\n        pick same-item IN_PROGRESS (after_seq is None path).\n        \"\"\"\n        create_transcription(asset=self.asset2, user=self.anon)  # IN_PROGRESS\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n\n        got = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=None,  # after_seq is None in IP fallback\n        )\n        self.assertEqual(got, self.asset2)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_manual_invalid_after_str_campaign_valueerror_branch(self, mock_get_task):\n        \"\"\"\n        original_asset_id is a non-digit string -> ValueError path.\n        Bypass short-circuits and empty cache => manual picks first NOT_STARTED\n        and spawns populate task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=\"not-an-int\",\n        )\n        self.assertEqual(chosen, self.asset1)  # first NOT_STARTED by ordering\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n\nclass TranscribableCampaignInternalsTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(sequence=1, slug=\"tc-a1\")\n        self.asset2 = create_asset(item=self.asset1.item, sequence=2, slug=\"tc-a2\")\n        self.campaign = self.asset1.campaign\n\n    def test_new_transcribable_excludes_reserved_and_cached(self):\n        reserved_asset = create_asset(item=self.asset1.item, sequence=3, slug=\"tc-res\")\n        cached_asset = create_asset(item=self.asset1.item, sequence=4, slug=\"tc-cached\")\n        # Make both potentially transcribable\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n        # Reserve one and cache the other\n        AssetTranscriptionReservation.objects.create(\n            asset=reserved_asset, reservation_token=\"tc-rv\"  # nosec\n        )\n        NextTranscribableCampaignAsset.objects.create(\n            asset=cached_asset,\n            campaign=self.campaign,\n            item=cached_asset.item,\n            item_item_id=cached_asset.item.item_id,\n            project=cached_asset.item.project,\n            project_slug=cached_asset.item.project.slug,\n            sequence=cached_asset.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        queryset = find_new_transcribable_campaign_assets(self.campaign)\n        self.assertNotIn(reserved_asset, queryset)\n        self.assertNotIn(cached_asset, queryset)\n\n    def test_order_potential_transcribable_pref(self):\n        \"\"\"\n        Cached ordering should favor next id, then same project, then same item.\n        \"\"\"\n        base_item = self.asset1.item\n        same_item_next = create_asset(item=base_item, sequence=10, slug=\"tc-ci-next\")\n        same_project = create_asset(\n            item=create_item(project=base_item.project, item_id=\"tc-it-2\"),\n            sequence=5,\n            slug=\"tc-p-next\",\n        )\n        other_project_asset = create_asset(\n            item=create_item(\n                project=create_project(\n                    campaign=self.campaign, slug=\"tc-op-proj\", title=\"tc-op-proj\"\n                ),\n                item_id=\"tc-op-item\",\n            ),\n            sequence=1,\n            slug=\"tc-op\",\n        )\n        for asset in (same_item_next, same_project, other_project_asset):\n            NextTranscribableCampaignAsset.objects.create(\n                asset=asset,\n                campaign=self.campaign,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                sequence=asset.sequence,\n                transcription_status=TranscriptionStatus.NOT_STARTED,\n            )\n        ordered = find_and_order_potential_transcribable_campaign_assets(\n            self.campaign,\n            project_slug=base_item.project.slug,\n            item_id=base_item.item_id,\n            asset_pk=self.asset1.id,\n        ).values_list(\"asset_id\", flat=True)\n        ordered = list(ordered)\n        self.assertEqual(ordered[0], same_item_next.id)\n        self.assertEqual(ordered[1], same_project.id)\n        self.assertIn(other_project_asset.id, ordered[2:])\n\n    def test_order_potential_transcribable_no_after(self):\n        \"\"\"\n        With no 'after', prefer same item, then same project.\n        \"\"\"\n        base_item = self.asset1.item\n        same_item = create_asset(item=base_item, sequence=9, slug=\"tc-ci-none\")\n        same_project = create_asset(\n            item=create_item(project=base_item.project, item_id=\"tc-it-np\"),\n            sequence=2,\n            slug=\"tc-p-none\",\n        )\n        other_project_asset = create_asset(\n            item=create_item(\n                project=create_project(\n                    campaign=self.campaign, slug=\"tc-proj-none\", title=\"tc-proj-none\"\n                ),\n                item_id=\"tc-it-op-none\",\n            ),\n            sequence=1,\n            slug=\"tc-op-none\",\n        )\n        for asset in (same_item, same_project, other_project_asset):\n            NextTranscribableCampaignAsset.objects.create(\n                asset=asset,\n                campaign=self.campaign,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                sequence=asset.sequence,\n                transcription_status=TranscriptionStatus.NOT_STARTED,\n            )\n        ordered = find_and_order_potential_transcribable_campaign_assets(\n            self.campaign,\n            project_slug=base_item.project.slug,\n            item_id=base_item.item_id,\n            asset_pk=None,\n        ).values_list(\"asset_id\", flat=True)\n        ordered = list(ordered)\n        self.assertEqual(ordered[0], same_item.id)\n        self.assertEqual(ordered[1], same_project.id)\n        self.assertIn(other_project_asset.id, ordered[2:])\n\n    def test_invalid_next_transcribable_reserved_and_submitted(self):\n        \"\"\"\n        Invalid cache rows include reserved or SUBMITTED assets.\n        \"\"\"\n        reserved_asset = create_asset(\n            item=self.asset1.item, sequence=30, slug=\"tc-inv-res\"\n        )\n        AssetTranscriptionReservation.objects.create(\n            asset=reserved_asset, reservation_token=\"tc-rv-2\"  # nosec\n        )\n        NextTranscribableCampaignAsset.objects.create(\n            asset=reserved_asset,\n            campaign=self.campaign,\n            item=reserved_asset.item,\n            item_item_id=reserved_asset.item.item_id,\n            project=reserved_asset.item.project,\n            project_slug=reserved_asset.item.project.slug,\n            sequence=reserved_asset.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        wrong_status_asset = create_asset(\n            item=self.asset1.item, sequence=31, slug=\"tc-inv-wrong\"\n        )\n        create_transcription(asset=wrong_status_asset, user=self.anon, submitted=now())\n        NextTranscribableCampaignAsset.objects.create(\n            asset=wrong_status_asset,\n            campaign=self.campaign,\n            item=wrong_status_asset.item,\n            item_item_id=wrong_status_asset.item.item_id,\n            project=wrong_status_asset.item.project,\n            project_slug=wrong_status_asset.item.project.slug,\n            sequence=wrong_status_asset.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        bad = list(\n            find_invalid_next_transcribable_campaign_assets(\n                self.campaign.id\n            ).values_list(\"asset_id\", flat=True)\n        )\n        self.assertIn(reserved_asset.id, bad)\n        self.assertIn(wrong_status_asset.id, bad)\n\n\nclass TranscribableCampaignMoreInternalsTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(sequence=1, slug=\"tc-more-a1\", title=\"TC More A1\")\n        self.asset2 = create_asset(\n            item=self.asset1.item, sequence=2, slug=\"tc-more-a2\", title=\"TC More A2\"\n        )\n        self.campaign = self.asset1.campaign\n\n    def test_tc_reserved_ids_filters_to_campaign(self):\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset1, reservation_token=\"tc-res-here\"  # nosec\n        )\n        other_campaign = create_campaign(slug=\"tc-other-c\", title=\"tc-other-c\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"tc-other-p\", title=\"tc-other-p\"\n        )\n        other_item = create_item(project=other_project, item_id=\"tc-other-it\")\n        other_asset = create_asset(item=other_item, slug=\"tc-other-a\")\n        AssetTranscriptionReservation.objects.create(\n            asset=other_asset, reservation_token=\"tc-res-there\"  # nosec\n        )\n\n        id_set = set(\n            tc_reserved_ids_subq(self.campaign).values_list(\"asset_id\", flat=True)\n        )\n        self.assertIn(self.asset1.id, id_set)\n        self.assertNotIn(other_asset.id, id_set)\n\n    def test_tc_next_seq_after_variants(self):\n        self.assertIsNone(tc_next_seq_after(None))\n        self.assertIsNone(tc_next_seq_after(999_999_999))\n        self.assertEqual(tc_next_seq_after(self.asset2.id), self.asset2.sequence)\n\n    def test_tc_order_unstarted_first_prefers_not_started(self):\n        create_transcription(asset=self.asset2, user=self.anon)\n        queryset = Asset.objects.filter(id__in=[self.asset1.id, self.asset2.id])\n        ordered = list(tc_order_unstarted_first(queryset).values_list(\"id\", flat=True))\n        self.assertEqual(ordered[0], self.asset1.id)\n        self.assertEqual(ordered[1], self.asset2.id)\n\n    def test_find_in_item_after_none_returns_first_not_started(self):\n        item_id = self.asset1.item.item_id\n        chosen = tc_find_in_item(self.campaign, item_id=item_id, after_asset_pk=None)\n        self.assertEqual(chosen, self.asset1)\n\n    def test_find_in_item_skips_inprog_and_reserved_and_advances(self):\n        create_transcription(asset=self.asset1, user=self.anon)\n        asset3 = create_asset(item=self.asset1.item, sequence=3, slug=\"tc-more-a3\")\n        AssetTranscriptionReservation.objects.create(\n            asset=asset3, reservation_token=\"tc-res-a3\"  # nosec\n        )\n        chosen = tc_find_in_item(\n            self.campaign,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertEqual(chosen, self.asset2)\n\n    def test_find_in_item_after_missing_excludes_id_only(self):\n        missing_pk = 987654321\n        chosen = tc_find_in_item(\n            self.campaign, item_id=self.asset1.item.item_id, after_asset_pk=missing_pk\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_find_ns_in_proj_excludes_item_and_reserved(self):\n        project = self.asset1.item.project\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n        item2 = create_item(project=project, item_id=\"tc-more-it-2\")\n        not_started1 = create_asset(item=item2, sequence=1, slug=\"tc-more-ns1\")\n        not_started2 = create_asset(item=item2, sequence=2, slug=\"tc-more-ns2\")\n        AssetTranscriptionReservation.objects.create(\n            asset=not_started1, reservation_token=\"tc-res-ns1\"  # nosec\n        )\n        chosen = tc_find_ns_in_proj(\n            self.campaign,\n            project_slug=project.slug,\n            exclude_item_id=self.asset1.item.item_id,\n        )\n        self.assertEqual(chosen, not_started2)\n\n    def test_find_ns_in_proj_blank_slug_none(self):\n        self.assertIsNone(tc_find_ns_in_proj(self.campaign, project_slug=\"\"))\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_cache_same_item_is_ignored_then_manual_selects(self, mock_get_task):\n        \"\"\"\n        Same-item cache entries should be ignored; manual should return other item.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        NextTranscribableCampaignAsset.objects.create(\n            asset=self.asset2,\n            campaign=self.campaign,\n            item=self.asset2.item,\n            item_item_id=self.asset2.item.item_id,\n            project=self.asset2.item.project,\n            project_slug=self.asset2.item.project.slug,\n            sequence=self.asset2.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n\n        item2 = create_item(project=self.asset1.item.project, item_id=\"tc-more-it-man\")\n        picked_asset = create_asset(item=item2, sequence=10, slug=\"tc-more-pick\")\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=\"\",  # skip project-level short-circuit\n            item_id=self.asset1.item.item_id,  # forces same-item short-circuit first\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, picked_asset)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.campaign.get_registered_task\")\n    def test_manual_excludes_original_pk_and_same_item(self, mock_get_task):\n        \"\"\"\n        Manual ranking must exclude the original asset and the current item.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        item2 = create_item(project=self.asset1.item.project, item_id=\"tc-more-it-3\")\n        keep = create_asset(item=item2, sequence=1, slug=\"tc-more-keep\")\n        toss = create_asset(item=item2, sequence=2, slug=\"tc-more-toss\")\n\n        chosen = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=\"\",\n            item_id=self.asset1.item.item_id,\n            original_asset_id=toss.id,\n        )\n        self.assertEqual(chosen, keep)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    def test_same_item_inprog_after_when_no_not_started(self):\n        \"\"\"\n        If no NOT_STARTED anywhere qualifies, select IN_PROGRESS in same item.\n        \"\"\"\n        create_transcription(asset=self.asset2, user=self.anon)  # IN_PROGRESS\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n\n        got = find_next_transcribable_campaign_asset(\n            self.campaign,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(got, self.asset2)\n\n    def test_eligible_base_qs_filters_status_and_published(self):\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        not_started_asset = create_asset(\n            item=self.asset1.item, sequence=3, slug=\"tc-more-ns-ok\"\n        )\n        in_progress_asset = create_asset(\n            item=self.asset1.item, sequence=4, slug=\"tc-more-ip-ok\"\n        )\n        create_transcription(asset=in_progress_asset, user=self.anon)  # IN_PROGRESS\n\n        other_campaign = create_campaign(slug=\"tc-ebq-c\", title=\"tc-ebq-c\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"tc-ebq-p\", title=\"tc-ebq-p\"\n        )\n        other_item = create_item(project=other_project, item_id=\"tc-ebq-i\")\n        other_asset = create_asset(item=other_item, slug=\"tc-ebq-a\")\n\n        queryset = tc_eligible_base_qs(self.campaign)\n        id_set = set(queryset.values_list(\"id\", flat=True))\n        self.assertIn(not_started_asset.id, id_set)\n        self.assertIn(in_progress_asset.id, id_set)\n        self.assertNotIn(self.asset1.id, id_set)\n        self.assertNotIn(other_asset.id, id_set)\n\n    def test_cached_transcribable_accessor_returns_rows(self):\n        row = NextTranscribableCampaignAsset.objects.create(\n            asset=self.asset1,\n            campaign=self.campaign,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            sequence=self.asset1.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        queryset = find_cached_transcribable_assets(self.campaign)\n        self.assertIn(row.id, queryset.values_list(\"id\", flat=True))\n\n    def test_find_in_item_blank_item_id_none(self):\n        chosen = tc_find_in_item(self.campaign, item_id=\"\", after_asset_pk=None)\n        self.assertIsNone(chosen)\n\n    def test_find_ns_in_proj_without_exclude_includes_same_item(self):\n        \"\"\"\n        exclude_item_id is falsy, so branch where no exclusion is applied.\n        Should pick the first NOT_STARTED asset, even if it's in the same item.\n        \"\"\"\n        project = self.asset1.item.project\n        chosen = tc_find_ns_in_proj(self.campaign, project_slug=project.slug)\n        self.assertEqual(chosen, self.asset1)\n"
  },
  {
    "path": "concordia/tests/test_utils_next_asset_transcribable_topic.py",
    "content": "from unittest.mock import MagicMock, patch\n\nfrom django.test import TestCase\nfrom django.utils.timezone import now\n\nfrom concordia.models import (\n    Asset,\n    AssetTranscriptionReservation,\n    NextTranscribableTopicAsset,\n    TranscriptionStatus,\n)\nfrom concordia.utils import get_anonymous_user\nfrom concordia.utils.next_asset import (\n    find_new_transcribable_topic_assets,\n    find_next_transcribable_topic_asset,\n    find_transcribable_topic_asset,\n)\nfrom concordia.utils.next_asset.transcribable.topic import (\n    _eligible_transcribable_base_qs as topic_transcribable_eligible_base_qs,\n)\nfrom concordia.utils.next_asset.transcribable.topic import (\n    _find_transcribable_in_item_for_topic as topic_find_in_item_for_topic,\n)\nfrom concordia.utils.next_asset.transcribable.topic import (\n    _find_transcribable_not_started_in_project_for_topic,\n    find_and_order_potential_transcribable_topic_assets,\n    find_invalid_next_transcribable_topic_assets,\n)\nfrom concordia.utils.next_asset.transcribable.topic import (\n    _next_seq_after as topic_next_seq_after_for_transcribable,\n)\nfrom concordia.utils.next_asset.transcribable.topic import (\n    _order_unstarted_first as topic_order_unstarted_first,\n)\nfrom concordia.utils.next_asset.transcribable.topic import (\n    _reserved_asset_ids_subq as topic_transcribable_reserved_ids_subq,\n)\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_topic,\n    create_transcription,\n)\n\ntopic_find_not_started_in_project_for_topic = (\n    _find_transcribable_not_started_in_project_for_topic\n)\nfind_invalid_next_transcribable_topic_assets_fn = (\n    find_invalid_next_transcribable_topic_assets\n)\n\n\nclass NextTranscribableTopicAssetTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anon = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(\n            slug=\"topic-asset-1\", sequence=1, title=\"Topic Asset 1\"\n        )\n        self.asset2 = create_asset(\n            item=self.asset1.item,\n            sequence=2,\n            slug=\"topic-asset-2\",\n            title=\"Topic Asset 2\",\n        )\n        self.topic = create_topic(project=self.asset1.item.project)\n\n    def test_find_new_transcribable_topic_assets_filters_correctly(self):\n        create_transcription(\n            asset=self.asset1,\n            user=self.anon,\n            submitted=now(),\n        )\n\n        queryset = find_new_transcribable_topic_assets(self.topic)\n        self.assertNotIn(self.asset1, queryset)\n        assert_in = self.assertIn\n        assert_in(self.asset2, queryset)\n\n    def test_find_transcribable_topic_asset_from_next_table(self):\n        NextTranscribableTopicAsset.objects.create(\n            asset=self.asset1,\n            topic=self.topic,\n            item=self.asset1.item,\n            item_item_id=self.asset1.item.item_id,\n            project=self.asset1.item.project,\n            project_slug=self.asset1.item.project.slug,\n            sequence=self.asset1.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n\n        asset = find_transcribable_topic_asset(self.topic)\n        self.assertEqual(asset, self.asset1)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_find_transcribable_topic_asset_falls_back_and_spawns_task(\n        self, mock_get_task\n    ):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_transcribable_topic_asset(self.topic)\n        self.assertEqual(asset, self.asset1)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_find_next_transcribable_topic_asset_orders_and_falls_back(\n        self, mock_get_task\n    ):\n        \"\"\"\n        With short-circuiting: item-level returns the next asset\n        and we do not spawn a task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        asset = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(asset, self.asset2)\n        # Short-circuit satisfied -> no cache fallback -> no task spawned\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_find_next_transcribable_topic_asset_when_next_asset_exists(\n        self, mock_get_task\n    ):\n        create_transcription(asset=self.asset2, user=self.anon)\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        NextTranscribableTopicAsset.objects.create(\n            asset=self.asset2,\n            topic=self.topic,\n            item=self.asset2.item,\n            item_item_id=self.asset2.item.item_id,\n            project=self.asset2.item.project,\n            project_slug=self.asset2.item.project.slug,\n            sequence=self.asset2.sequence,\n            transcription_status=TranscriptionStatus.IN_PROGRESS,\n        )\n\n        asset = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=None,\n        )\n        self.assertEqual(asset, self.asset2)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_short_circuit_same_item_topic_respects_after_sequence_and_reservations(\n        self,\n    ):\n        third = create_asset(\n            item=self.asset1.item,\n            sequence=3,\n            slug=\"topic-asset-3\",\n            title=\"Topic Asset 3\",\n        )\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, self.asset2)\n\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset2, reservation_token=\"tkn\"  # nosec\n        )\n        chosen2 = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen2, third)\n\n    def test_project_short_circuit_topic_prefers_not_started_over_in_progress(self):\n        # Exhaust item\n        create_transcription(asset=self.asset1, user=self.anon, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anon, submitted=now())\n\n        other_item = create_item(project=self.asset1.item.project, item_id=\"tproj-item\")\n        in_progress = create_asset(\n            item=other_item, sequence=1, slug=\"tproj-inprog\", title=\"TProj InProg\"\n        )\n        create_transcription(asset=in_progress, user=self.anon)\n\n        not_started_asset = create_asset(\n            item=other_item,\n            sequence=2,\n            slug=\"tproj-notstarted\",\n            title=\"TProj NotStarted\",\n        )\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, not_started_asset)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_project_short_circuit_topic_without_item_id_allows_same_item(\n        self, mock_get_task\n    ):\n        \"\"\"\n        With item_id not set, the project-level short-circuit\n        should return the first NOT_STARTED in the project,\n        ordered by (item__item_id, sequence, id).\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=\"\",  # falsy -> do not exclude same item\n            original_asset_id=None,  # no exclusion of original\n        )\n        # Both assets are NOT_STARTED in the same item; ordering picks asset1.\n        self.assertEqual(chosen, self.asset1)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n\nclass TranscribableTopicInternalsTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anonymous = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(sequence=1, slug=\"tt-int-a1\")\n        self.asset2 = create_asset(item=self.asset1.item, sequence=2, slug=\"tt-int-a2\")\n        self.topic = create_topic(project=self.asset1.item.project)\n\n    def test_topic_transcribable_reserved_ids_is_unfiltered(self):\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset1, reservation_token=\"tt-res-here\"  # nosec\n        )\n        other_campaign = create_campaign(slug=\"tt-oc\", title=\"tt-oc\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"tt-op\", title=\"tt-op\"\n        )\n        other_item = create_item(project=other_project, item_id=\"tt-oi\")\n        other_asset = create_asset(item=other_item, slug=\"tt-oa\")\n        AssetTranscriptionReservation.objects.create(\n            asset=other_asset, reservation_token=\"tt-res-there\"  # nosec\n        )\n        ids = set(\n            topic_transcribable_reserved_ids_subq().values_list(\"asset_id\", flat=True)\n        )\n        self.assertIn(self.asset1.id, ids)\n        self.assertIn(other_asset.id, ids)\n\n    def test_topic_transcribable_eligible_base_qs_filters_correctly(self):\n        # Submitted, so excluded\n        create_transcription(asset=self.asset2, user=self.anonymous, submitted=now())\n        # Not started (included)\n        asset_not_started = self.asset1\n        # In progress (included)\n        asset_in_progress = create_asset(\n            item=self.asset1.item, sequence=3, slug=\"tt-int-ip\"\n        )\n        create_transcription(asset=asset_in_progress, user=self.anonymous)\n        # Other campaign (excluded)\n        other_campaign = create_campaign(slug=\"tt-ebq-c\", title=\"tt-ebq-c\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"tt-ebq-p\", title=\"tt-ebq-p\"\n        )\n        other_item = create_item(project=other_project, item_id=\"tt-ebq-i\")\n        other_asset = create_asset(item=other_item, slug=\"tt-ebq-a\")\n        queryset = topic_transcribable_eligible_base_qs(self.topic)\n        ids = set(queryset.values_list(\"id\", flat=True))\n        self.assertIn(asset_not_started.id, ids)\n        self.assertIn(asset_in_progress.id, ids)\n        self.assertNotIn(self.asset2.id, ids)\n        self.assertNotIn(other_asset.id, ids)\n\n    def test_topic_next_seq_after_variants_for_transcribable(self):\n        self.assertIsNone(topic_next_seq_after_for_transcribable(None))\n        self.assertIsNone(topic_next_seq_after_for_transcribable(987654321))\n        self.assertEqual(\n            topic_next_seq_after_for_transcribable(self.asset2.id),\n            self.asset2.sequence,\n        )\n\n    def test_topic_find_in_item_for_topic_after_none_returns_first_not_started(self):\n        chosen = topic_find_in_item_for_topic(\n            self.topic, item_id=self.asset1.item.item_id, after_asset_pk=None\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_topic_find_in_item_for_topic_skips_reserved_and_advances(self):\n        third = create_asset(item=self.asset1.item, sequence=3, slug=\"tt-int-a3\")\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset2, reservation_token=\"tt-int-a2\"  # nosec\n        )\n        chosen = topic_find_in_item_for_topic(\n            self.topic,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=self.asset1.id,\n        )\n        self.assertEqual(chosen, third)\n\n    def test_topic_find_in_item_for_topic_after_missing_excludes_only_id(self):\n        chosen = topic_find_in_item_for_topic(\n            self.topic,\n            item_id=self.asset1.item.item_id,\n            after_asset_pk=987654321,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_topic_find_in_item_for_topic_blank_item_id_returns_none(self):\n        chosen = topic_find_in_item_for_topic(\n            self.topic, item_id=\"\", after_asset_pk=None\n        )\n        self.assertIsNone(chosen)\n\n    def test_topic_find_not_started_in_project_excludes_item_and_reserved(self):\n        other_item = create_item(project=self.asset1.item.project, item_id=\"tt-int-ex\")\n        not_started_1 = create_asset(item=other_item, sequence=1, slug=\"tt-int-ns1\")\n        not_started_2 = create_asset(item=other_item, sequence=2, slug=\"tt-int-ns2\")\n        AssetTranscriptionReservation.objects.create(\n            asset=not_started_1, reservation_token=\"tt-int-res\"  # nosec\n        )\n        chosen = topic_find_not_started_in_project_for_topic(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            exclude_item_id=self.asset1.item.item_id,\n        )\n        self.assertEqual(chosen, not_started_2)\n\n    def test_topic_find_not_started_in_project_blank_slug_none(self):\n        self.assertIsNone(\n            topic_find_not_started_in_project_for_topic(self.topic, project_slug=\"\")\n        )\n\n    def test_topic_order_unstarted_first_prefers_not_started(self):\n        in_progress = create_asset(\n            item=self.asset1.item, sequence=4, slug=\"tt-int-ip-2\"\n        )\n        create_transcription(asset=in_progress, user=self.anonymous)\n        queryset = Asset.objects.filter(id__in=[self.asset1.id, in_progress.id])\n        ordered = list(\n            topic_order_unstarted_first(queryset).values_list(\"id\", flat=True)\n        )\n        self.assertEqual(ordered[0], self.asset1.id)\n        self.assertEqual(ordered[1], in_progress.id)\n\n    def test_topic_find_not_started_in_project_without_exclude_includes_same_item(\n        self,\n    ):\n        \"\"\"\n        With exclude_item_id falsy, the helper should consider the same item and\n        pick the first NOT_STARTED asset there (covers the else branch of L154->157).\n        \"\"\"\n        project = self.asset1.item.project\n        chosen = topic_find_not_started_in_project_for_topic(\n            self.topic, project_slug=project.slug, exclude_item_id=\"\"\n        )\n        self.assertEqual(chosen, self.asset1)\n\n\nclass NextTranscribableTopicMoreTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.anonymous = get_anonymous_user()\n        self.user = self.create_test_user()\n        self.asset1 = create_asset(slug=\"tt-more-a1\", sequence=1, title=\"TT More A1\")\n        self.asset2 = create_asset(\n            item=self.asset1.item, slug=\"tt-more-a2\", sequence=2, title=\"TT More A2\"\n        )\n        self.topic = create_topic(project=self.asset1.item.project)\n\n    def test_new_transcribable_topic_excludes_reserved_and_cached(self):\n        reserved_asset = create_asset(\n            item=self.asset1.item, sequence=3, slug=\"tt-more-res\"\n        )\n        cached_asset = create_asset(\n            item=self.asset1.item, sequence=4, slug=\"tt-more-cached\"\n        )\n        NextTranscribableTopicAsset.objects.create(\n            asset=cached_asset,\n            topic=self.topic,\n            item=cached_asset.item,\n            item_item_id=cached_asset.item.item_id,\n            project=cached_asset.item.project,\n            project_slug=cached_asset.item.project.slug,\n            sequence=cached_asset.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        AssetTranscriptionReservation.objects.create(\n            asset=reserved_asset, reservation_token=\"tt-more-rv\"  # nosec\n        )\n        queryset = find_new_transcribable_topic_assets(self.topic)\n        self.assertNotIn(reserved_asset, queryset)\n        self.assertNotIn(cached_asset, queryset)\n\n    def test_find_and_order_potential_transcribable_topic_assets_ordering(self):\n        base_item = self.asset1.item\n        same_item_next = create_asset(\n            item=base_item, sequence=10, slug=\"tt-pot-ci-next\"\n        )\n        same_project_other_item = create_asset(\n            item=create_item(project=base_item.project, item_id=\"tt-pot-it-2\"),\n            sequence=5,\n            slug=\"tt-pot-proj\",\n        )\n        other_project = create_project(\n            campaign=self.asset1.campaign,\n            slug=\"tt-pot-proj-oth\",\n            title=\"tt-pot-proj-oth\",\n        )\n        other_item = create_item(project=other_project, item_id=\"tt-pot-it-3\")\n        other_project_asset = create_asset(\n            item=other_item, sequence=1, slug=\"tt-pot-op\"\n        )\n\n        for asset in (same_item_next, same_project_other_item, other_project_asset):\n            NextTranscribableTopicAsset.objects.create(\n                asset=asset,\n                topic=self.topic,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                sequence=asset.sequence,\n                transcription_status=TranscriptionStatus.NOT_STARTED,\n            )\n\n        ordered = find_and_order_potential_transcribable_topic_assets(\n            self.topic,\n            project_slug=base_item.project.slug,\n            item_id=base_item.item_id,\n            asset_pk=self.asset1.id,\n        ).values_list(\"asset_id\", flat=True)\n\n        ordered = list(ordered)\n        # Prefer same item, then same project, then others\n        self.assertEqual(ordered[0], same_item_next.id)\n        self.assertEqual(ordered[1], same_project_other_item.id)\n        self.assertIn(other_project_asset.id, ordered[2:])\n\n    def test_find_invalid_next_transcribable_topic_assets_reserved_and_status(self):\n        reserved_asset = create_asset(\n            item=self.asset1.item, sequence=30, slug=\"tt-inv-res\"\n        )\n        create_transcription(asset=reserved_asset, user=self.anonymous)\n        AssetTranscriptionReservation.objects.create(\n            asset=reserved_asset, reservation_token=\"tt-inv-rv\"  # nosec\n        )\n        NextTranscribableTopicAsset.objects.create(\n            asset=reserved_asset,\n            topic=self.topic,\n            item=reserved_asset.item,\n            item_item_id=reserved_asset.item.item_id,\n            project=reserved_asset.item.project,\n            project_slug=reserved_asset.item.project.slug,\n            sequence=reserved_asset.sequence,\n            transcription_status=TranscriptionStatus.IN_PROGRESS,\n        )\n        wrong_status_asset = create_asset(\n            item=self.asset1.item, sequence=31, slug=\"tt-inv-wrong\"\n        )\n        create_transcription(\n            asset=wrong_status_asset, user=self.anonymous, submitted=now()\n        )\n        NextTranscribableTopicAsset.objects.create(\n            asset=wrong_status_asset,\n            topic=self.topic,\n            item=wrong_status_asset.item,\n            item_item_id=wrong_status_asset.item.item_id,\n            project=wrong_status_asset.item.project,\n            project_slug=wrong_status_asset.item.project.slug,\n            sequence=wrong_status_asset.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        bad = list(\n            find_invalid_next_transcribable_topic_assets_fn(self.topic.id).values_list(\n                \"asset_id\", flat=True\n            )\n        )\n        self.assertIn(reserved_asset.id, bad)\n        self.assertIn(wrong_status_asset.id, bad)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_cache_same_item_is_ignored_then_manual_selects_topic(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Same-item cached row should be excluded, forcing manual fallback.\n        NextTranscribableTopicAsset.objects.create(\n            asset=self.asset2,\n            topic=self.topic,\n            item=self.asset2.item,\n            item_item_id=self.asset2.item.item_id,\n            project=self.asset2.item.project,\n            project_slug=self.asset2.item.project.slug,\n            sequence=self.asset2.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n        # Make same-item short-circuit fail by reserving the only candidate.\n        AssetTranscriptionReservation.objects.create(\n            asset=self.asset2, reservation_token=\"tt-cache-same\"  # nosec\n        )\n        # Provide a valid choice elsewhere to be picked by manual fallback.\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tt-cache-oth\"\n        )\n        picked = create_asset(item=other_item, sequence=5, slug=\"tt-cache-pick\")\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=\"\",\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, picked)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_cache_excludes_original_pk_and_chooses_next_topic(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tt-cache-exc\"\n        )\n        first = create_asset(item=other_item, sequence=1, slug=\"tt-cache-first\")\n        second = create_asset(item=other_item, sequence=2, slug=\"tt-cache-second\")\n        for asset in (first, second):\n            NextTranscribableTopicAsset.objects.create(\n                asset=asset,\n                topic=self.topic,\n                item=asset.item,\n                item_item_id=asset.item.item_id,\n                project=asset.item.project,\n                project_slug=asset.item.project.slug,\n                sequence=asset.sequence,\n                transcription_status=TranscriptionStatus.NOT_STARTED,\n            )\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic, project_slug=\"\", item_id=\"\", original_asset_id=first.id\n        )\n        self.assertEqual(chosen, second)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_same_item_inprogress_selected_when_no_not_started_topic(self):\n        create_transcription(asset=self.asset2, user=self.anonymous)\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n        got = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=None,\n        )\n        self.assertEqual(got, self.asset2)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_next_transcribable_topic_none_anywhere_returns_none_no_spawn(\n        self, mock_get_task\n    ):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Use a brand-new topic with no eligible assets anywhere.\n        empty_campaign = create_campaign(slug=\"tt-none-c\", title=\"tt-none-c\")\n        empty_project = create_project(\n            campaign=empty_campaign, slug=\"tt-none-p\", title=\"tt-none-p\"\n        )\n        empty_topic = create_topic(project=empty_project)\n\n        chosen = find_next_transcribable_topic_asset(\n            topic=empty_topic, project_slug=\"\", item_id=\"\", original_asset_id=None\n        )\n        self.assertIsNone(chosen)\n        self.assertFalse(mock_get_task.called)\n        self.assertFalse(mock_task.delay.called)\n\n    def test_item_gate_ignored_when_original_is_other_item_topic(self):\n        \"\"\"\n        original_asset_id exists but belongs to a different item; item gate is ignored\n        and we return the first NOT_STARTED in the requested item\n        \"\"\"\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tt-oth-item\"\n        )\n        other_asset = create_asset(item=other_item, slug=\"tt-oth-a\")\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=other_asset.id,\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    def test_item_digit_string_missing_treats_as_no_after_topic(self):\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=\"987654321\",  # valid digits, missing PK\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_manual_same_item_ip_when_no_ns_anywhere_topic(self, mock_get_task):\n        \"\"\"\n        Manual fallback path with item_id present: when there are\n        no NOT_STARTED anywhere, choose same-item IN_PROGRESS.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # No NOT_STARTED anywhere in this topic's project; only same-item IN_PROGRESS\n        create_transcription(asset=self.asset2, user=self.anonymous)  # IN_PROGRESS\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n\n        got = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=\"\",  # bypass short-circuit so we hit the manual path\n            item_id=self.asset1.item.item_id,\n            original_asset_id=None,\n        )\n        self.assertEqual(got, self.asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    def test_item_invalid_after_str_valueerror_branch_topic(self):\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=\"not-an-int\",\n        )\n        self.assertEqual(chosen, self.asset1)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_manual_valid_after_excludes_original_and_picks_next_topic(\n        self, mock_get_task\n    ):\n        \"\"\"\n        Manual fallback with a valid original_asset_id: use after_seq to exclude the\n        original and return the next NOT_STARTED\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tt-man-item\"\n        )\n        first = create_asset(item=other_item, sequence=1, slug=\"tt-man-first\")\n        second = create_asset(item=other_item, sequence=2, slug=\"tt-man-second\")\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=\"\",\n            item_id=\"\",\n            original_asset_id=first.id,\n        )\n        self.assertEqual(chosen, second)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_inprogress_fallback_spawns_and_uses_after_gate_topic(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Make original (seq=1) SUBMITTED so it can't be chosen; keep it as \"original\".\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n        # Only candidate anywhere: same-item IN_PROGRESS (seq=2).\n        create_transcription(asset=self.asset2, user=self.anonymous)  # IN_PROGRESS\n\n        # Ensure there are no other items/assets in the topic to be found\n        # by manual path (manual path excludes same item when item_id is provided).\n        # No cached rows either.\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n\n        self.assertEqual(chosen, self.asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_inprogress_fallback_with_digit_string_original_id(self, mock_get_task):\n        \"\"\"\n        Same as above, but pass original_asset_id as a DIGIT STRING to\n        exercise the int(original_asset_id) path inside the after-seq filter.\n        Also ensures exclude(pk=original_asset_id) runs without ValueError.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Original: SUBMITTED, seq=1\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n        # Only candidate: same-item IN_PROGRESS, seq=2\n        create_transcription(asset=self.asset2, user=self.anonymous)\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=str(self.asset1.id),\n        )\n\n        self.assertEqual(chosen, self.asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_inprogress_fallback_spawns_and_returns_asset_topic(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Use a brand-new topic/project so no cached rows can interfere.\n        campaign = create_campaign(slug=\"tt-ip-c1\", title=\"tt-ip-c1\")\n        project = create_project(\n            campaign=campaign,\n            slug=\"tt-ip-p1\",\n            title=\"tt-ip-p1\",\n        )\n        topic = create_topic(project=project)\n        item = create_item(project=project, item_id=\"tt-ip-i1\")\n\n        asset1 = create_asset(item=item, sequence=1, slug=\"tt-ip-a1\")\n        asset2 = create_asset(item=item, sequence=2, slug=\"tt-ip-a2\")\n        create_transcription(asset=asset1, user=get_anonymous_user(), submitted=now())\n        create_transcription(asset=asset2, user=get_anonymous_user())\n\n        chosen = find_next_transcribable_topic_asset(\n            topic=topic,\n            project_slug=project.slug,\n            item_id=item.item_id,\n            original_asset_id=asset1.id,\n        )\n\n        self.assertEqual(chosen, asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_inprogress_fallback_with_digit_str_original_id_topic(self, mock_get_task):\n        \"\"\"\n        Same scenario as above, but pass original_asset_id as a DIGIT STRING to\n        run the int(...) path inside the after-seq filter and still spawn the task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        campaign = create_campaign(slug=\"tt-ip-c2\", title=\"tt-ip-c2\")\n        project = create_project(\n            campaign=campaign,\n            slug=\"tt-ip-p2\",\n            title=\"tt-ip-p2\",\n        )\n        topic = create_topic(project=project)\n        item = create_item(project=project, item_id=\"tt-ip-i2\")\n\n        asset1 = create_asset(item=item, sequence=1, slug=\"tt-ip2-a1\")\n        asset2 = create_asset(item=item, sequence=2, slug=\"tt-ip2-a2\")\n        create_transcription(asset=asset1, user=get_anonymous_user(), submitted=now())\n        create_transcription(asset=asset2, user=get_anonymous_user())  # IN_PROGRESS\n\n        chosen = find_next_transcribable_topic_asset(\n            topic=topic,\n            project_slug=project.slug,\n            item_id=item.item_id,\n            original_asset_id=str(asset1.id),  # digit-string pk\n        )\n\n        self.assertEqual(chosen, asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    def test_project_short_circuit_topic_excludes_current_item_via_item_filter(self):\n        \"\"\"\n        project-level short-circuit executes with item_id truthy,\n        so the code runs candidate = candidate.exclude(item__item_id=item_id).\n        Item-level has no NOT_STARTED, so we land in the project block.\n        \"\"\"\n        # Exhaust current item (no NOT_STARTED left there)\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anonymous, submitted=now())\n\n        # Create a NOT_STARTED candidate in the same project but a different item\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"tt-proj-ex-branch\"\n        )\n        pick = create_asset(item=other_item, sequence=1, slug=\"tt-proj-ex-pick\")\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, pick)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_inprogress_fallback_spawns_task_with_item_id_topic(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Same item: one IN_PROGRESS, one SUBMITTED -> no NOT_STARTED anywhere.\n        create_transcription(asset=self.asset2, user=self.anonymous)\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n\n        # With item_id set, manual fallback excludes same-item candidates, so\n        # it returns nothing (spawn_task=True). Then the IN_PROGRESS fallback\n        # must return asset2 and trigger the task.\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=None,\n        )\n        self.assertEqual(chosen, self.asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_manual_same_item_inprogress_triggers_spawn_task_topic(self, mock_get_task):\n        \"\"\"\n        When there are no NOT_STARTED candidates anywhere (after excluding current\n        item/original in the manual fallback), the same-item IN_PROGRESS fallback\n        should return an asset AND spawn the cache population task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Same item only:\n        # - original (NOT_STARTED) will be excluded by manual fallback,\n        # - next is IN_PROGRESS (eligible for final fallback).\n        create_transcription(asset=self.asset2, user=self.anonymous)\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=\"\",\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, self.asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_project_short_circuit_excludes_current_item_topic(self, mock_get_task):\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anonymous, submitted=now())\n\n        other_item = create_item(\n            project=self.asset1.item.project, item_id=\"topic-proj-exclude-item\"\n        )\n        pick = create_asset(item=other_item, sequence=5, slug=\"topic-proj-exclude-pick\")\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=self.asset1.id,\n        )\n        self.assertEqual(chosen, pick)\n        self.assertFalse(mock_get_task.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_manual_inprogress_fallback_triggers_spawn_task_topic(self, mock_get_task):\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anonymous)\n\n        chosen = find_next_transcribable_topic_asset(\n            self.topic,\n            project_slug=self.asset1.item.project.slug,\n            item_id=self.asset1.item.item_id,\n            original_asset_id=None,\n        )\n        self.assertEqual(chosen, self.asset2)\n        self.assertTrue(mock_get_task.called)\n        self.assertTrue(mock_task.delay.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_inprogress_fallback_item_id_returns_none_when_no_candidates_topic(\n        self, mock_get_task\n    ):\n        \"\"\"\n        With item_id truthy but no same-item IN_PROGRESS (and no NOT_STARTED anywhere),\n        the IN_PROGRESS fallback should yield no asset and the function returns None.\n        Ensures the path where `asset` is falsy in the IN_PROGRESS block is covered.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Fresh topic/project with a single item and no transcribable assets at all.\n        campaign = create_campaign(slug=\"tt-ip-none-c\", title=\"tt-ip-none-c\")\n        project = create_project(\n            campaign=campaign, slug=\"tt-ip-none-p\", title=\"tt-ip-none-p\"\n        )\n        topic = create_topic(project=project)\n        item = create_item(project=project, item_id=\"tt-ip-none-i\")\n\n        a1 = create_asset(item=item, sequence=1, slug=\"tt-ip-none-a1\")\n        a2 = create_asset(item=item, sequence=2, slug=\"tt-ip-none-a2\")\n        # Make both SUBMITTED so neither NOT_STARTED nor IN_PROGRESS exists.\n        create_transcription(asset=a1, user=get_anonymous_user(), submitted=now())\n        create_transcription(asset=a2, user=get_anonymous_user(), submitted=now())\n\n        got = find_next_transcribable_topic_asset(\n            topic=topic,\n            project_slug=project.slug,\n            item_id=item.item_id,  # truthy, so we enter the IN_PROGRESS fallback\n            original_asset_id=None,\n        )\n        self.assertIsNone(got)\n        # No task should be spawned since the IN_PROGRESS block found nothing.\n        self.assertFalse(mock_get_task.called)\n\n    @patch(\"concordia.utils.next_asset.transcribable.topic.get_registered_task\")\n    def test_inprogress_fallback_returns_asset_without_spawning(self, mock_get_task):\n        \"\"\"\n        Force a path where the cache stage is exercised (so spawn_task stays False),\n        but yields no usable asset (simulated by making the cached asset retrieval\n        return None). The function should then fall through to the IN_PROGRESS\n        fallback, return that asset and NOT spawn the population task.\n        \"\"\"\n        mock_task = mock_get_task.return_value\n        mock_task.delay = MagicMock()\n\n        # Current item: seq1 SUBMITTED, seq2 IN_PROGRESS -> same-item NOT_STARTED fails.\n        create_transcription(asset=self.asset1, user=self.anonymous, submitted=now())\n        create_transcription(asset=self.asset2, user=self.anonymous)  # IN_PROGRESS\n\n        # Prepare a cached candidate in a DIFFERENT project so project short-circuit\n        # doesn't grab it (only checks current project_slug).\n        other_campaign = create_campaign(slug=\"tt-ip-cache-c\", title=\"tt-ip-cache-c\")\n        other_project = create_project(\n            campaign=other_campaign, slug=\"tt-ip-cache-p\", title=\"tt-ip-cache-p\"\n        )\n        other_item = create_item(project=other_project, item_id=\"tt-ip-cache-i\")\n        cached_asset = create_asset(item=other_item, sequence=1, slug=\"tt-ip-cache-a\")\n\n        NextTranscribableTopicAsset.objects.create(\n            asset=cached_asset,\n            topic=self.topic,\n            item=cached_asset.item,\n            item_item_id=cached_asset.item.item_id,\n            project=cached_asset.item.project,\n            project_slug=cached_asset.item.project.slug,\n            sequence=cached_asset.sequence,\n            transcription_status=TranscriptionStatus.NOT_STARTED,\n        )\n\n        # Patch Asset.objects.filter only for id=cached_asset.id so the cache lookup\n        # produces an asset_id (spawn_task remains False) but then returns no row,\n        # forcing the function into the IN_PROGRESS fallback.\n        real_filter = Asset.objects.filter\n\n        def filter_side_effect(*args, **kwargs):\n            if kwargs == {\"id\": cached_asset.id}:\n                qs_mock = MagicMock()\n                qs_mock.select_for_update.return_value = qs_mock\n                qs_mock.select_related.return_value = qs_mock\n                qs_mock.first.return_value = None  # simulate skip-locked/missing row\n                return qs_mock\n            return real_filter(*args, **kwargs)\n\n        with patch.object(Asset.objects, \"filter\", side_effect=filter_side_effect):\n            chosen = find_next_transcribable_topic_asset(\n                self.topic,\n                project_slug=self.asset1.item.project.slug,\n                item_id=self.asset1.item.item_id,\n                original_asset_id=None,\n            )\n\n        # Should fall back to same-item IN_PROGRESS and NOT spawn the task.\n        self.assertEqual(chosen, self.asset2)\n        self.assertFalse(mock_get_task.called)\n"
  },
  {
    "path": "concordia/tests/test_validators.py",
    "content": "import string\n\nfrom django.core.exceptions import ValidationError\nfrom django.test import TestCase\n\nfrom concordia.passwords.validators import ComplexityValidator\nfrom concordia.validators import DjangoPasswordsValidator\n\n\nclass TestValidators(TestCase):\n    def test_DjangoPasswordsValidator(self):\n        validator = DjangoPasswordsValidator()\n        expected_error = \"Must be more complex (%s)\"\n        self.assertIsNone(validator.validate(\"Ab1!\"))\n\n        expected_suberror = \"must contain 1 or more unique lowercase characters\"\n        with self.assertRaises(ValidationError) as cm:\n            validator.validate(\"AB1!\")\n        self.assertEqual(cm.exception.messages, [expected_error % expected_suberror])\n        self.assertEqual(cm.exception.error_list[0].code, \"complexity\")\n\n        expected_suberror = \"must contain 1 or more unique uppercase characters\"\n        with self.assertRaises(ValidationError) as cm:\n            validator.validate(\"ab1!\")\n        self.assertEqual(cm.exception.messages, [expected_error % expected_suberror])\n        self.assertEqual(cm.exception.error_list[0].code, \"complexity\")\n\n        expected_suberror = \"must contain 1 or more unique digits\"\n        with self.assertRaises(ValidationError) as cm:\n            validator.validate(\"Ab!\")\n        self.assertEqual(cm.exception.messages, [expected_error % expected_suberror])\n        self.assertEqual(cm.exception.error_list[0].code, \"complexity\")\n\n        expected_suberror = \"must contain 1 or more non unique special characters\"\n        with self.assertRaises(ValidationError) as cm:\n            validator.validate(\"Ab1\")\n        self.assertEqual(cm.exception.messages, [expected_error % expected_suberror])\n        self.assertEqual(cm.exception.error_list[0].code, \"complexity\")\n\n        self.assertEqual(\n            validator.get_help_text(),\n            \"Your password fails to meet our complexity requirements.\",\n        )\n\n\nclass ComplexityValidatorTests(TestCase):\n    def assertValid(self, validator, string):\n        try:\n            validator(string)\n        except ValidationError:\n            self.fail(f\"String {string} failed validation unexpectedly\")\n\n    def assertInvalid(self, validator, string):\n        self.assertRaises(ValidationError, validator, string)\n\n    def make_validator(self, **complexities):\n        return ComplexityValidator(complexities=complexities)\n\n    def test_empty_validator(self):\n        validator = ComplexityValidator(complexities=None)\n        self.assertValid(validator, \"\")\n\n    def test_minimum_uppercase_count(self):\n        validator = self.make_validator(UPPER=0)\n        self.assertValid(validator, \"no uppercase\")\n        self.assertValid(validator, \"Some UpperCase\")\n        self.assertValid(validator, \"ALL UPPERCASE\")\n\n        validator = self.make_validator(UPPER=1)\n        self.assertInvalid(validator, \"no uppercase\")\n        self.assertValid(validator, \"Some UpperCase\")\n        self.assertValid(validator, \"ALL UPPERCASE\")\n\n        validator = self.make_validator(UPPER=100)\n        self.assertInvalid(validator, \"no uppercase\")\n        self.assertInvalid(validator, \"Some UpperCase\")\n        self.assertInvalid(validator, \"ALL UPPERCASE\")\n\n    def test_minimum_lowercase_count(self):\n        validator = self.make_validator(LOWER=0)\n        self.assertValid(validator, \"NO LOWERCASE\")\n        self.assertValid(validator, \"sOME lOWERCASE\")\n        self.assertValid(validator, \"all lowercase\")\n\n        validator = self.make_validator(LOWER=1)\n        self.assertInvalid(validator, \"NO LOWERCASE\")\n        self.assertValid(validator, \"sOME lOWERCASE\")\n        self.assertValid(validator, \"all lowercase\")\n\n        validator = self.make_validator(LOWER=100)\n        self.assertInvalid(validator, \"NO LOWERCASE\")\n        self.assertInvalid(validator, \"sOME lOWERCASE\")\n        self.assertInvalid(validator, \"all lowercase\")\n\n    def test_minimum_letter_count(self):\n        validator = self.make_validator(LETTERS=0)\n        self.assertValid(validator, \"1234. ?\")\n        self.assertValid(validator, \"soME 123\")\n        self.assertValid(validator, \"allletters\")\n\n        validator = self.make_validator(LETTERS=1)\n        self.assertInvalid(validator, \"1234. ?\")\n        self.assertValid(validator, \"soME 123\")\n        self.assertValid(validator, \"allletters\")\n\n        validator = self.make_validator(LETTERS=100)\n        self.assertInvalid(validator, \"1234. ?\")\n        self.assertInvalid(validator, \"soME 123\")\n        self.assertInvalid(validator, \"allletters\")\n\n    def test_minimum_digit_count(self):\n        validator = self.make_validator(DIGITS=0)\n        self.assertValid(validator, \"\")\n        self.assertValid(validator, \"0\")\n        self.assertValid(validator, \"1\")\n        self.assertValid(validator, \"11\")\n        self.assertValid(validator, \"one 1\")\n\n        validator = self.make_validator(DIGITS=1)\n        self.assertInvalid(validator, \"\")\n        self.assertValid(validator, \"0\")\n        self.assertValid(validator, \"1\")\n        self.assertValid(validator, \"11\")\n        self.assertValid(validator, \"one 1\")\n\n    def test_minimum_punctuation_count(self):\n        none = \"no punctuation\"\n        one = \"ffs!\"\n        mixed = r\"w@oo%lo(om!ol~oo&\"\n        allpunc = string.punctuation\n\n        validator = self.make_validator(SPECIAL=0)\n        self.assertValid(validator, none)\n        self.assertValid(validator, one)\n        self.assertValid(validator, mixed)\n        self.assertValid(validator, allpunc)\n\n        validator = self.make_validator(SPECIAL=1)\n        self.assertInvalid(validator, none)\n        self.assertValid(validator, one)\n        self.assertValid(validator, mixed)\n        self.assertValid(validator, allpunc)\n\n        validator = self.make_validator(SPECIAL=100)\n        self.assertInvalid(validator, none)\n        self.assertInvalid(validator, one)\n        self.assertInvalid(validator, mixed)\n        self.assertInvalid(validator, allpunc)\n\n    def test_minimum_nonascii_count(self):\n        none = \"regularchars and numbers 100\"\n        one = \"\\x00\"  # null\n        many = \"\\x00\\x01\\x02\\x03\\x04\\x05\\t\\n\\r\"\n\n        validator = self.make_validator(SPECIAL=0)\n        self.assertValid(validator, none)\n        self.assertValid(validator, one)\n        self.assertValid(validator, many)\n\n        validator = self.make_validator(SPECIAL=1)\n        self.assertInvalid(validator, none)\n        self.assertValid(validator, one)\n        self.assertValid(validator, many)\n\n        validator = self.make_validator(SPECIAL=100)\n        self.assertInvalid(validator, none)\n        self.assertInvalid(validator, one)\n        self.assertInvalid(validator, many)\n\n    def test_minimum_words_count(self):\n        none = \"\"\n        one = \"oneword\"\n        some = \"one or two words\"\n        many = \"a b c d e f g h i 1 2 3 4 5 6 7 8 9 { $ # ! )}\"\n\n        validator = self.make_validator(WORDS=0)\n        self.assertValid(validator, none)\n        self.assertValid(validator, one)\n        self.assertValid(validator, some)\n        self.assertValid(validator, many)\n\n        validator = self.make_validator(WORDS=1)\n        self.assertInvalid(validator, none)\n        self.assertValid(validator, one)\n        self.assertValid(validator, some)\n        self.assertValid(validator, many)\n\n        validator = self.make_validator(WORDS=10)\n        self.assertInvalid(validator, none)\n        self.assertInvalid(validator, one)\n        self.assertInvalid(validator, some)\n        self.assertValid(validator, many)\n\n        validator = self.make_validator(WORDS=100)\n        self.assertInvalid(validator, none)\n        self.assertInvalid(validator, one)\n        self.assertInvalid(validator, some)\n        self.assertInvalid(validator, many)\n"
  },
  {
    "path": "concordia/tests/test_view_decorators.py",
    "content": "from unittest.mock import MagicMock, patch\n\nfrom django.core.exceptions import ObjectDoesNotExist, ValidationError\nfrom django.http import HttpRequest\nfrom django.test import TestCase\n\nfrom concordia.views.decorators import next_asset_rate\n\n\nclass TestNextAssetRate(TestCase):\n    def setUp(self):\n        self.request = HttpRequest()\n\n    def test_authenticated_user_returns_none(self):\n        self.request.user = MagicMock(is_authenticated=True)\n        result = next_asset_rate(\"any.group\", self.request)\n        self.assertIsNone(result)\n\n    @patch(\"concordia.views.decorators.configuration_value\")\n    @patch(\"concordia.views.decorators.validate_rate\")\n    def test_anonymous_user_valid_rate(self, mock_validate_rate, mock_config_value):\n        self.request.user = MagicMock(is_authenticated=False)\n        mock_config_value.return_value = \"10/m\"\n        mock_validate_rate.return_value = \"10/m\"\n\n        result = next_asset_rate(\"next_asset\", self.request)\n        self.assertEqual(result, \"10/m\")\n\n    @patch(\"concordia.views.decorators.configuration_value\")\n    @patch(\"concordia.views.decorators.validate_rate\")\n    def test_anonymous_user_invalid_rate_falls_back(\n        self, mock_validate_rate, mock_config_value\n    ):\n        self.request.user = MagicMock(is_authenticated=False)\n        mock_config_value.return_value = \"invalid\"\n        mock_validate_rate.side_effect = ValidationError(\"bad\")\n\n        result = next_asset_rate(\"next_asset\", self.request)\n        self.assertEqual(result, \"4/m\")\n\n    @patch(\"concordia.views.decorators.configuration_value\")\n    def test_anonymous_user_missing_value_falls_back(self, mock_config_value):\n        self.request.user = MagicMock(is_authenticated=False)\n        mock_config_value.side_effect = ObjectDoesNotExist()\n\n        result = next_asset_rate(\"next_asset\", self.request)\n        self.assertEqual(result, \"4/m\")\n"
  },
  {
    "path": "concordia/tests/test_views.py",
    "content": "import json\nfrom datetime import date, timedelta\nfrom unittest.mock import patch\n\nfrom django import forms\nfrom django.contrib.auth.models import AnonymousUser\nfrom django.core.cache import caches\nfrom django.db.models.signals import post_save\nfrom django.http import HttpResponse, JsonResponse\nfrom django.test import (\n    Client,\n    RequestFactory,\n    TestCase,\n    override_settings,\n)\nfrom django.urls import reverse\nfrom django.utils.decorators import method_decorator\nfrom django.utils.timezone import now\n\nfrom concordia.models import (\n    Asset,\n    Campaign,\n    Transcription,\n)\nfrom concordia.signals.handlers import on_transcription_save\nfrom concordia.tasks.reports.sitereport import campaign_report\nfrom concordia.utils import get_anonymous_user\nfrom concordia.views.accounts import AccountProfileView, registration_rate\nfrom concordia.views.campaigns import CompletedCampaignListView\nfrom concordia.views.decorators import reserve_rate, user_cache_control\nfrom concordia.views.items import FilteredItemDetailView\nfrom concordia.views.projects import FilteredProjectDetailView\nfrom concordia.views.rate_limit import ratelimit_view\nfrom concordia.views.visualizations import VisualizationDataView\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n    create_campaign,\n    create_card_family,\n    create_guide,\n    create_item,\n    create_project,\n    create_research_center,\n    create_tag_collection,\n    create_topic,\n    create_transcription,\n)\n\n\ndef setup_view(view, request, user=None, *args, **kwargs):\n    \"\"\"\n    https://stackoverflow.com/a/33647251/10320488\n    \"\"\"\n    if user:\n        request.user = user\n    view.request = request\n    view.args = args\n    view.kwargs = kwargs\n    return view\n\n\nclass AccountProfileViewTests(CreateTestUsers, TestCase):\n    \"\"\"\n    This class contains the unit tests for the AccountProfileView.\n    \"\"\"\n\n    def test_get_queryset(self):\n        \"\"\"\n        Test the get_queryset method\n        \"\"\"\n        self.login_user()\n        v = setup_view(\n            AccountProfileView(),\n            RequestFactory().get(\"account/password_reset/\"),\n            user=self.user,\n        )\n        qs = v.get_queryset()\n        self.assertEqual(qs.count(), 0)\n\n\nclass CompletedCampaignListViewTests(TestCase):\n    \"\"\"\n    This class contains the unit tests for the CompletedCampaignListView\n    \"\"\"\n\n    def setUp(self):\n        today = date.today()\n        yesterday = today - timedelta(days=1)\n\n        self.research_center = create_research_center()\n        self.campaign2 = create_campaign(\n            published=True,\n            status=Campaign.Status.COMPLETED,\n            slug=\"test-campaign-2\",\n            completed_date=yesterday,\n        )\n        self.campaign2.research_centers.add(self.research_center)\n        self.campaign3 = create_campaign(\n            published=True,\n            status=Campaign.Status.RETIRED,\n            slug=\"test-campaign-3\",\n            completed_date=yesterday,\n        )\n\n    def test_get_all_campaigns(self):\n        active = create_campaign(\n            published=True,\n            slug=\"test-campaign-4\",\n            completed_date=self.campaign2.completed_date,\n        )\n        view = CompletedCampaignListView()\n        view.request = RequestFactory().get(\"/campaigns/completed/\")\n        completed_and_retired = view._get_all_campaigns()\n        self.assertNotIn(active, completed_and_retired)\n        self.assertIn(self.campaign2, completed_and_retired)\n        self.assertIn(self.campaign3, completed_and_retired)\n\n        view.request = RequestFactory().get(\"/campaigns/completed/?type=completed\")\n        completed_campaigns = view._get_all_campaigns()\n        self.assertNotIn(active, completed_campaigns)\n        self.assertIn(self.campaign2, completed_campaigns)\n        self.assertNotIn(self.campaign3, completed_campaigns)\n\n        view.request = RequestFactory().get(\"/campaigns/completed/?type=retired\")\n        retired_campaigns = view._get_all_campaigns()\n        self.assertNotIn(active, retired_campaigns)\n        self.assertNotIn(self.campaign2, retired_campaigns)\n        self.assertIn(self.campaign3, retired_campaigns)\n\n    def test_queryset(self):\n        today = date.today()\n        create_campaign(\n            published=True, status=Campaign.Status.COMPLETED, completed_date=today\n        )\n\n        view = CompletedCampaignListView()\n\n        # Test default\n        view.request = RequestFactory().get(\"/campaigns/completed/\")\n        queryset = view.get_queryset()\n        self.assertGreater(\n            queryset.first().completed_date, queryset.last().completed_date\n        )\n\n        # Test retired\n        view.request = RequestFactory().get(\"/campaigns/completed/?type=retired\")\n        queryset = view.get_queryset()\n        self.assertEqual(queryset.count(), 1)\n\n    def test_context_data(self):\n        request = RequestFactory().get(\"/campaigns/completed/\")\n        response = CompletedCampaignListView.as_view()(request)\n        self.assertIsInstance(response.context_data, dict)\n        self.assertEqual(response.context_data[\"result_count\"], 2)\n\n        request = RequestFactory().get(\"/campaigns/completed/?type=completed\")\n        response = CompletedCampaignListView.as_view()(request)\n        self.assertIsInstance(response.context_data, dict)\n        self.assertEqual(response.context_data[\"result_count\"], 1)\n\n        request = RequestFactory().get(\"/campaigns/completed/?type=completed\")\n        response = CompletedCampaignListView.as_view()(request)\n        self.assertIsInstance(response.context_data, dict)\n        self.assertEqual(response.context_data[\"result_count\"], 1)\n\n        request = RequestFactory().get(\n            f\"/campaigns/completed/?research_center={self.research_center.id}\"\n        )\n        response = CompletedCampaignListView.as_view()(request)\n        self.assertIsInstance(response.context_data, dict)\n        self.assertEqual(response.context_data[\"result_count\"], 1)\n\n    def test_research_centers(self):\n        today = date.today()\n\n        create_campaign(\n            published=True, status=Campaign.Status.COMPLETED, completed_date=today\n        )\n\n        url = f\"/campaigns/completed/?research_center={self.research_center.id}\"\n\n        # Test queryset directly\n        view = CompletedCampaignListView()\n        view.request = RequestFactory().get(url)\n        queryset = view.get_queryset()\n\n        self.assertEqual(queryset.count(), 1)\n\n        # Test get_context_data through a get\n        response = self.client.get(url)\n\n        self.assertIn(\"research_centers\", response.context)\n        self.assertEqual(response.context[\"research_centers\"][0], self.research_center)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False, SESSION_ENGINE=\"django.contrib.sessions.backends.cache\"\n)\nclass ConcordiaViewTests(CreateTestUsers, JSONAssertMixin, TestCase):\n    \"\"\"\n    This class contains the unit tests for the view in the concordia app.\n    \"\"\"\n\n    def setUp(self):\n        for cache in caches.all():\n            cache.clear()\n\n    def tearDown(self):\n        for cache in caches.all():\n            cache.clear()\n\n    def test_ratelimit_view(self):\n        c = Client()\n        response = c.get(\"/error/429/\")\n        self.assertIsInstance(response, HttpResponse)\n        self.assertEqual(response.status_code, 429)\n\n        headers = {\"HTTP_X_REQUESTED_WITH\": \"XMLHttpRequest\"}\n        response = c.get(\"/error/429/\", **headers)\n        self.assertIsInstance(response, JsonResponse)\n        self.assertEqual(response.status_code, 429)\n\n    def test_campaign_topic_list_view(self):\n        \"\"\"\n        Test the GET method for route /campaigns-topics\n        \"\"\"\n        campaign = create_campaign(title=\"Hello Everyone\")\n        topic_project = create_project(campaign=campaign)\n        campaign_item = create_item(project=topic_project)\n        create_asset(item=campaign_item)\n        unlisted_campaign = create_campaign(\n            title=\"Hello to only certain people\", unlisted=True\n        )\n        unlisted_topic_project = create_project(campaign=unlisted_campaign)\n        unlisted_campaign_item = create_item(project=unlisted_topic_project)\n        create_asset(item=unlisted_campaign_item)\n        topic = create_topic(title=\"A Listed Topic\", project=topic_project)\n        unlisted_topic = create_topic(\n            title=\"An Unlisted Topic\", unlisted=True, project=unlisted_topic_project\n        )\n\n        response = self.client.get(reverse(\"campaign-topic-list\"))\n\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_topic_list.html\"\n        )\n        self.assertContains(response, topic.title)\n        self.assertNotContains(response, unlisted_topic.title)\n        self.assertContains(response, campaign.title)\n        self.assertNotContains(response, unlisted_campaign.title)\n\n    def test_campaign_list_view(self):\n        \"\"\"\n        Test the GET method for route /campaigns\n        \"\"\"\n        campaign = create_campaign(title=\"Hello Everyone 2\")\n        unlisted_campaign = create_campaign(\n            title=\"Hello to only certain people 2\", unlisted=True\n        )\n\n        response = self.client.get(reverse(\"transcriptions:campaign-list\"))\n\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_list.html\"\n        )\n        self.assertContains(response, campaign.title)\n        self.assertNotContains(response, unlisted_campaign.title)\n\n    def test_campaign_detail_view(self):\n        \"\"\"\n        Test GET on route /campaigns/<slug-value> (campaign)\n        \"\"\"\n        campaign = create_campaign(title=\"GET Campaign\", slug=\"get-campaign\")\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-detail\", args=(campaign.slug,))\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_detail.html\"\n        )\n        self.assertContains(response, campaign.title)\n        # Filter by reviewable parameter check\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-detail\", args=(campaign.slug,)),\n            {\"filter_by_reviewable\": True},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_detail.html\"\n        )\n        self.assertContains(response, campaign.title)\n        # Bad status parameter check\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-detail\", args=(campaign.slug,)),\n            {\"transcription_status\": \"bad_parameter\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_detail.html\"\n        )\n        self.assertContains(response, campaign.title)\n\n        # Unlisted\n        campaign = create_campaign(\n            title=\"GET Unlisted Campaign\", unlisted=True, slug=\"get-unlisted-campaign\"\n        )\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-detail\", args=(campaign.slug,))\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_detail.html\"\n        )\n        self.assertContains(response, campaign.title)\n\n        # Completed\n        campaign = create_campaign(\n            title=\"GET Completed Campaign\",\n            slug=\"get-completed-campaign\",\n            status=Campaign.Status.COMPLETED,\n        )\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-detail\", args=(campaign.slug,))\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_detail_completed.html\"\n        )\n        self.assertContains(response, campaign.title)\n\n        # Retired\n        campaign = create_campaign(\n            title=\"GET Retired Campaign\",\n            slug=\"get-retired-campaign\",\n            status=Campaign.Status.RETIRED,\n        )\n        # We need a site report for a retired campaign because\n        # that's where the view pulls data from\n        campaign_report(campaign=campaign)\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-detail\", args=(campaign.slug,))\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_detail_retired.html\"\n        )\n        self.assertContains(response, campaign.title)\n\n    def test_campaign_unicode_slug(self):\n        \"\"\"Confirm that Unicode characters are usable in Campaign URLs\"\"\"\n\n        campaign = create_campaign(title=\"你好 World\")\n\n        self.assertEqual(campaign.slug, \"你好-world\")\n\n        response = self.client.get(campaign.get_absolute_url())\n\n        self.assertEqual(response.status_code, 200)\n\n    def test_concordiaCampaignView_get_page2(self):\n        \"\"\"\n        Test GET on route /campaigns/<slug-value>/ (campaign) on page 2\n        \"\"\"\n        c = create_campaign()\n\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-detail\", args=(c.slug,)), {\"page\": 2}\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/campaign_detail.html\"\n        )\n\n    def test_empty_item_detail_view(self):\n        \"\"\"\n        Test item detail display with no assets\n        \"\"\"\n\n        item = create_item()\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:item-detail\",\n                args=(item.project.campaign.slug, item.project.slug, item.item_id),\n            )\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/item_detail.html\"\n        )\n        self.assertContains(response, item.title)\n        self.assertEqual(0, response.context[\"not_started_percent\"])\n        self.assertEqual(0, response.context[\"in_progress_percent\"])\n        self.assertEqual(0, response.context[\"submitted_percent\"])\n        self.assertEqual(0, response.context[\"completed_percent\"])\n\n    def test_item_detail_view(self):\n        \"\"\"\n        Test item detail display with assets\n        \"\"\"\n\n        self.login_user()  # Implicitly create the test account\n        anon = get_anonymous_user()\n\n        item = create_item()\n        # We'll create 10 assets and transcriptions for some of them so we can\n        # confirm that the math is working correctly:\n        for i in range(1, 11):\n            asset = create_asset(item=item, sequence=i, slug=f\"test-{i}\")\n            if i > 9:\n                t = asset.transcription_set.create(asset=asset, user=anon)\n                t.submitted = now()\n                t.accepted = now()\n                t.reviewed_by = self.user\n            elif i > 7:\n                t = asset.transcription_set.create(asset=asset, user=anon)\n                t.submitted = now()\n            elif i > 4:\n                t = asset.transcription_set.create(asset=asset, user=anon)\n            else:\n                continue\n\n            t.full_clean()\n            t.save()\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:item-detail\",\n                args=(item.project.campaign.slug, item.project.slug, item.item_id),\n            )\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/item_detail.html\"\n        )\n        self.assertContains(response, item.title)\n        # We have 10 total, 6 of which have transcription records and of those\n        # 6, 3 have been submitted and one of those was accepted:\n        self.assertEqual(40, response.context[\"not_started_percent\"])\n        self.assertEqual(30, response.context[\"in_progress_percent\"])\n        self.assertEqual(20, response.context[\"submitted_percent\"])\n        self.assertEqual(10, response.context[\"completed_percent\"])\n        # Filter by reviewable parameter check\n        response = self.client.get(\n            reverse(\n                \"transcriptions:item-detail\",\n                args=(item.project.campaign.slug, item.project.slug, item.item_id),\n            ),\n            {\"filter_by_reviewable\": True},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/item_detail.html\"\n        )\n        # Bad status parameter check\n        response = self.client.get(\n            reverse(\n                \"transcriptions:item-detail\",\n                args=(item.project.campaign.slug, item.project.slug, item.item_id),\n            ),\n            {\"transcription_status\": \"bad_parameter\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/item_detail.html\"\n        )\n\n        # Non-existent item in an existing campaign\n        response = self.client.get(\n            reverse(\n                \"transcriptions:item-detail\",\n                args=(item.project.campaign.slug, item.project.slug, \"bad-id\"),\n            )\n        )\n        self.assertRedirects(\n            response,\n            reverse(\n                \"transcriptions:campaign-detail\", args=(item.project.campaign.slug,)\n            ),\n        )\n\n    def test_asset_unicode_slug(self):\n        \"\"\"Confirm that Unicode characters are usable in Asset URLs\"\"\"\n\n        asset = create_asset(title=\"你好 World\")\n\n        self.assertEqual(asset.slug, \"你好-world\")\n\n        response = self.client.get(asset.get_absolute_url())\n\n        self.assertEqual(response.status_code, 200)\n\n    def test_asset_detail_view(self):\n        \"\"\"\n        This unit test test the GET route /campaigns/<campaign>/asset/<Asset_name>/\n        with already in use.\n        \"\"\"\n        self.login_user()\n\n        asset = create_asset(sequence=100)\n\n        self.transcription = asset.transcription_set.create(\n            user_id=self.user.id, text=\"Test transcription 1\"\n        )\n        self.transcription.save()\n\n        asset.item.project.campaign.card_family = create_card_family()\n        asset.item.project.campaign.save()\n        title = \"Transcription: Basic Rules\"\n        create_guide(title=title)\n\n        tag_collection = create_tag_collection(asset=asset)\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:asset-detail\",\n                kwargs={\n                    \"campaign_slug\": asset.item.project.campaign.slug,\n                    \"project_slug\": asset.item.project.slug,\n                    \"item_id\": asset.item.item_id,\n                    \"slug\": asset.slug,\n                },\n            )\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"cards\", response.context)\n        self.assertIn(\"guides\", response.context)\n        self.assertEqual(title, response.context[\"guides\"][0][\"title\"])\n        self.assertIn(\"tags\", response.context)\n        self.assertEqual([tag_collection.tags.all()[0].value], response.context[\"tags\"])\n\n        # Next and previous asset checks\n        previous_asset = create_asset(\n            item=asset.item, slug=\"previous-asset\", sequence=1\n        )\n        next_asset = create_asset(item=asset.item, slug=\"next-asset\", sequence=1000)\n        response = self.client.get(\n            reverse(\n                \"transcriptions:asset-detail\",\n                kwargs={\n                    \"campaign_slug\": asset.item.project.campaign.slug,\n                    \"project_slug\": asset.item.project.slug,\n                    \"item_id\": asset.item.item_id,\n                    \"slug\": asset.slug,\n                },\n            )\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"previous_asset_url\", response.context)\n        self.assertEqual(\n            previous_asset.get_absolute_url(), response.context[\"previous_asset_url\"]\n        )\n        self.assertIn(\"next_asset_url\", response.context)\n        self.assertEqual(\n            next_asset.get_absolute_url(), response.context[\"next_asset_url\"]\n        )\n\n        # Download URL iiif check\n        asset.download_url = \"http://tile.loc.gov/image-services/iiif/service:music:mussuffrage:mussuffrage-100183:mussuffrage-100183.0001/full/pct:100/0/default.jpg\"\n        asset.save()\n        response = self.client.get(\n            reverse(\n                \"transcriptions:asset-detail\",\n                kwargs={\n                    \"campaign_slug\": asset.item.project.campaign.slug,\n                    \"project_slug\": asset.item.project.slug,\n                    \"item_id\": asset.item.item_id,\n                    \"slug\": asset.slug,\n                },\n            )\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"thumbnail_url\", response.context)\n        self.assertEqual(\n            \"https://tile.loc.gov/image-services/iiif/service:music:mussuffrage:mussuffrage-100183:mussuffrage-100183.0001/full/!512,512/0/default.jpg\",\n            response.context[\"thumbnail_url\"],\n        )\n\n        # Non-existent asset in an existing campaign\n        response = self.client.get(\n            reverse(\n                \"transcriptions:asset-detail\",\n                kwargs={\n                    \"campaign_slug\": asset.item.project.campaign.slug,\n                    \"project_slug\": asset.item.project.slug,\n                    \"item_id\": asset.item.item_id,\n                    \"slug\": \"bad-slug\",\n                },\n            )\n        )\n        self.assertRedirects(\n            response,\n            reverse(\n                \"transcriptions:campaign-detail\",\n                args=(asset.item.project.campaign.slug,),\n            ),\n        )\n\n    @patch.object(Asset, \"get_ocr_transcript\")\n    def test_generate_ocr_transcription(self, mock):\n        asset1 = create_asset(storage_image=\"tests/test-european.jpg\")\n        url = reverse(\"generate-ocr-transcription\", kwargs={\"asset_pk\": asset1.pk})\n\n        # Anonymous user test; should redirect\n        response = self.client.post(url)\n        self.assertEqual(302, response.status_code)\n        self.assertFalse(mock.called)\n        mock.reset_mock()\n\n        self.login_user()\n        response = self.client.post(url)\n        self.assertEqual(201, response.status_code)\n        self.assertTrue(mock.called)\n        mock.reset_mock()\n\n        asset2 = create_asset(\n            item=asset1.item,\n            slug=\"test-asset-2\",\n            storage_image=\"tests/test-european.jpg\",\n        )\n        url = reverse(\"generate-ocr-transcription\", kwargs={\"asset_pk\": asset2.pk})\n        response = self.client.post(url, data={\"language\": \"spa\"})\n        self.assertEqual(201, response.status_code)\n        mock.assert_called_with(\"spa\")\n        mock.reset_mock()\n\n        with patch(\n            \"concordia.views.ajax.get_transcription_superseded\"\n        ) as superseded_mock:\n            # Test case if the trancription being replaced has already been superseded\n            superseded_mock.return_value = HttpResponse(status=409)\n            url = reverse(\"generate-ocr-transcription\", kwargs={\"asset_pk\": asset2.pk})\n            response = self.client.post(url)\n            self.assertEqual(409, response.status_code)\n            self.assertTrue(superseded_mock.called)\n            self.assertFalse(mock.called)\n\n            # Test case if the transcription being replaced hasn't been superseded\n            superseded_mock.reset_mock()\n            superseded_mock.return_value = create_transcription(\n                asset=asset2, user=get_anonymous_user(), submitted=now()\n            )\n            url = reverse(\"generate-ocr-transcription\", kwargs={\"asset_pk\": asset2.pk})\n            response = self.client.post(url)\n            self.assertEqual(201, response.status_code)\n            self.assertTrue(superseded_mock.called)\n            self.assertTrue(mock.called)\n\n    def test_project_detail_view(self):\n        \"\"\"\n        Test GET on route /campaigns/<slug-value> (campaign)\n        \"\"\"\n        project = create_project()\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:project-detail\",\n                args=(project.campaign.slug, project.slug),\n            )\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/project_detail.html\"\n        )\n        # Filter by reviewable parameter check\n        response = self.client.get(\n            reverse(\n                \"transcriptions:project-detail\",\n                args=(project.campaign.slug, project.slug),\n            ),\n            {\"filter_by_reviewable\": True},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/project_detail.html\"\n        )\n        # Bad status parameter check\n        response = self.client.get(\n            reverse(\n                \"transcriptions:project-detail\",\n                args=(project.campaign.slug, project.slug),\n            ),\n            {\"transcription_status\": \"bad_parameter\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/project_detail.html\"\n        )\n\n        # Non-existent project in an existing campaign\n        response = self.client.get(\n            reverse(\n                \"transcriptions:project-detail\",\n                args=(project.campaign.slug, \"bad-slug\"),\n            )\n        )\n        self.assertRedirects(\n            response,\n            reverse(\"transcriptions:campaign-detail\", args=(project.campaign.slug,)),\n        )\n\n    def test_project_unicode_slug(self):\n        \"\"\"Confirm that Unicode characters are usable in Project URLs\"\"\"\n\n        project = create_project(title=\"你好 World\")\n\n        self.assertEqual(project.slug, \"你好-world\")\n\n        response = self.client.get(project.get_absolute_url())\n\n        self.assertEqual(response.status_code, 200)\n\n    def test_campaign_report(self):\n        \"\"\"\n        Test campaign reporting\n        \"\"\"\n\n        item = create_item()\n        # We'll create 10 assets and transcriptions for some of them so we can\n        # confirm that the math is working correctly:\n        for i in range(1, 11):\n            create_asset(item=item, sequence=i, slug=f\"test-{i}\")\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:campaign-report\",\n                kwargs={\"campaign_slug\": item.project.campaign.slug},\n            )\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(response, \"transcriptions/campaign_report.html\")\n\n        ctx = response.context\n\n        self.assertEqual(ctx[\"title\"], item.project.campaign.title)\n        self.assertEqual(ctx[\"total_asset_count\"], 10)\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:campaign-report\",\n                kwargs={\"campaign_slug\": item.project.campaign.slug},\n            ),\n            {\"page\": \"not-an-int\"},\n        )\n\n        ctx = response.context\n\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(response, \"transcriptions/campaign_report.html\")\n        self.assertEqual(ctx[\"projects\"].number, 1)\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:campaign-report\",\n                kwargs={\"campaign_slug\": item.project.campaign.slug},\n            ),\n            {\"page\": 10000},\n        )\n\n        ctx = response.context\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(response, \"transcriptions/campaign_report.html\")\n        self.assertEqual(ctx[\"projects\"].number, 1)\n\n\nclass UserCacheControlTest(CreateTestUsers, TestCase):\n    \"\"\"\n    Tests for the user_cache_control decorator\n    \"\"\"\n\n    def setUp(self):\n        self.factory = RequestFactory()\n        self.user = self.create_user(\"testuser\")\n\n    def test_vary_on_cookie(self):\n        @method_decorator(user_cache_control, name=\"dispatch\")\n        def a_view(request):\n            return HttpResponse()\n\n        request = self.factory.get(\"/rand\")\n        request.user = self.user\n        resp = a_view(None, request)\n        self.assertEqual(resp.status_code, 200)\n\n\nclass FilteredCampaignDetailViewTests(CreateTestUsers, TestCase):\n    def test_get_context_data(self):\n        campaign = create_campaign()\n        kwargs = {\"slug\": campaign.slug}\n        url = reverse(\"transcriptions:filtered-campaign-detail\", kwargs=kwargs)\n\n        self.login_user(is_staff=False)\n        response = self.client.get(url, kwargs)\n        self.assertFalse(response.context.get(\"filter_by_reviewable\", False))\n        self.logout_user()\n\n        self.user = self.create_staff_user()\n        self.login_user()\n        response = self.client.get(url, kwargs)\n        self.assertTrue(response.context.get(\"filter_by_reviewable\"))\n\n\nclass FilteredProjectDetailViewTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.project = create_project()\n        self.kwargs = {\n            \"campaign_slug\": self.project.campaign.slug,\n            \"slug\": self.project.slug,\n        }\n        self.url = reverse(\"transcriptions:filtered-project-detail\", kwargs=self.kwargs)\n        self.login_user()\n\n    def test_get_queryset(self):\n        item1 = create_item(project=self.project, item_id=\"testitem.012345679\")\n        asset1 = create_asset(item=item1)\n        create_transcription(asset=asset1, user=get_anonymous_user(), submitted=now())\n\n        item2 = create_item(\n            project=create_project(slug=\"project-two\", campaign=self.project.campaign)\n        )\n        asset2 = create_asset(item=item2)\n        create_transcription(asset=asset2, user=self.user, submitted=now())\n\n        view = FilteredProjectDetailView()\n        view.kwargs = self.kwargs\n        view.request = RequestFactory().get(self.url, self.kwargs)\n        view.request.user = self.user\n        qs = view.get_queryset()\n        self.assertIn(item1, qs)\n        self.assertNotIn(item2, qs)\n\n    def test_get_context_data(self):\n        response = self.client.get(self.url, self.kwargs)\n        self.assertTrue(response.context.get(\"filter_by_reviewable\"))\n\n    def tearDown(self):\n        post_save.connect(on_transcription_save, sender=Transcription)\n\n\nclass FilteredItemDetailViewTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.item = create_item()\n        self.kwargs = {\n            \"campaign_slug\": self.item.project.campaign.slug,\n            \"project_slug\": self.item.project.slug,\n            \"item_id\": self.item.item_id,\n        }\n        self.url = reverse(\"transcriptions:filtered-item-detail\", kwargs=self.kwargs)\n        self.login_user()\n\n    def test_get_queryset(self):\n        asset1 = create_asset(item=self.item)\n        create_transcription(asset=asset1, user=get_anonymous_user(), submitted=now())\n\n        asset2 = create_asset(item=self.item, slug=\"asset-two\")\n        create_transcription(asset=asset2, user=self.user, submitted=now())\n\n        view = FilteredItemDetailView()\n        view.kwargs = self.kwargs\n        view.request = RequestFactory().get(self.url, self.kwargs)\n        view.request.user = self.user\n        qs = view.get_queryset()\n        self.assertIn(asset1, qs)\n        self.assertNotIn(asset2, qs)\n\n    def test_get_context_data(self):\n        response = self.client.get(self.url, self.kwargs)\n        self.assertTrue(response.context.get(\"filter_by_reviewable\"))\n\n    def tearDown(self):\n        post_save.connect(on_transcription_save, sender=Transcription)\n\n\nclass RateLimitTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.request_factory = RequestFactory()\n        self.user = self.create_user(\"test-user\")\n\n    def test_registration_rate(self):\n        request = self.request_factory.get(\"/\")\n        self.assertEqual(registration_rate(None, request), \"10/h\")\n        with patch(\"concordia.views.accounts.UserRegistrationForm\", autospec=True):\n            # This causes the form to test as valid even though there's no data\n            self.assertIsNone(registration_rate(None, request))\n\n    def test_ratelimit_view(self):\n        request = self.request_factory.post(\"/\")\n        exception = Exception()\n        response = ratelimit_view(request, exception)\n        self.assertEqual(response.status_code, 429)\n        self.assertNotEqual(response[\"Retry-After\"], 0)\n\n    def test_reserve_rate(self):\n        request = self.request_factory.post(\"/\")\n\n        request.user = AnonymousUser()\n        self.assertEqual(\"100/m\", reserve_rate(\"test.group\", request))\n\n        request.user = self.user\n        self.assertEqual(None, reserve_rate(\"test.group\", request))\n\n\nclass LoginTests(TestCase, CreateTestUsers):\n    def setUp(self):\n        self.user = self.create_user(\"test-user\")\n\n    def test_ConcordiaLoginView(self):\n        with patch(\"concordia.turnstile.fields.TurnstileField.validate\") as mock:\n            mock.side_effect = forms.ValidationError(\n                \"Testing error\", code=\"invalid_turnstile\"\n            )\n            response = self.client.post(\n                reverse(\"registration_login\"),\n                data={\"username\": self.user.username, \"password\": self.user._password},\n            )\n        self.assertIn(\"user\", response.context)\n        self.assertFalse(response.context[\"user\"].is_authenticated)\n\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            response = self.client.post(\n                reverse(\"registration_login\"),\n                data={\"username\": self.user.username, \"password\": self.user._password},\n                follow=True,\n            )\n        self.assertRedirects(\n            response,\n            expected_url=reverse(\"homepage\"),\n            target_status_code=200,\n        )\n        self.assertIn(\"user\", response.context)\n        self.assertTrue(response.context[\"user\"].is_authenticated)\n\n\nclass TranscriptionViewTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n\n    def test_rollback_transcription(self):\n        path = reverse(\"rollback-transcription\", args=[self.asset.id])\n        self.login_user()\n\n        # Test rollback when there are no transcriptions\n        response = self.client.post(path)\n        self.assertEqual(400, response.status_code)\n        self.assertIn(\"error\", response.json())\n\n        transcription1 = create_transcription(\n            asset=self.asset, text=\"Test transcription 1\"\n        )\n        user = transcription1.user\n\n        # Test rollback when there are no transcriptions to rollback to\n        response = self.client.post(path)\n        self.assertEqual(400, response.status_code)\n        self.assertIn(\"error\", response.json())\n\n        # Test successful rollback\n        create_transcription(asset=self.asset, user=user, text=\"Test transcription 2\")\n        response = self.client.post(path)\n        self.assertEqual(201, response.status_code)\n        response_json = response.json()\n        self.assertIn(\"id\", response_json)\n        self.assertIn(\"text\", response_json)\n        self.assertEqual(response_json[\"text\"], transcription1.text)\n        self.assertIn(\"undo_available\", response_json)\n        self.assertEqual(response_json[\"undo_available\"], False)\n        self.assertIn(\"redo_available\", response_json)\n        self.assertEqual(response_json[\"redo_available\"], True)\n\n        # Test after a rollforward\n        self.asset.rollforward_transcription(user)\n        response = self.client.post(path)\n        self.assertEqual(201, response.status_code)\n        response_json = response.json()\n        self.assertIn(\"id\", response_json)\n        self.assertIn(\"text\", response_json)\n        self.assertEqual(response_json[\"text\"], transcription1.text)\n        self.assertIn(\"undo_available\", response_json)\n        self.assertEqual(response_json[\"undo_available\"], False)\n        self.assertIn(\"redo_available\", response_json)\n        self.assertEqual(response_json[\"redo_available\"], True)\n\n        # Test anonymous user\n        self.client.logout()\n        create_transcription(asset=self.asset, user=user, text=\"Test transcription 3\")\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            response = self.client.post(path)\n        self.assertEqual(201, response.status_code)\n        response_json = response.json()\n        self.assertIn(\"id\", response_json)\n        self.assertIn(\"text\", response_json)\n        self.assertEqual(response_json[\"text\"], transcription1.text)\n        self.assertIn(\"undo_available\", response_json)\n        self.assertEqual(response_json[\"undo_available\"], False)\n        self.assertIn(\"redo_available\", response_json)\n        self.assertEqual(response_json[\"redo_available\"], True)\n\n    def test_rollforward_transcription(self):\n        path = reverse(\"rollforward-transcription\", args=[self.asset.id])\n        self.login_user()\n\n        # Test rollforward when there are no transcriptions\n        response = self.client.post(path)\n        self.assertEqual(400, response.status_code)\n        self.assertIn(\"error\", response.json())\n\n        transcription1 = create_transcription(\n            asset=self.asset, text=\"Test transcription 1\"\n        )\n        user = transcription1.user\n\n        # Test rollback when there are no transcriptions to rollforward to\n        response = self.client.post(path)\n        self.assertEqual(400, response.status_code)\n        self.assertIn(\"error\", response.json())\n\n        # Test successful rollforward, which requires a rollback first\n        transcription2 = create_transcription(\n            asset=self.asset, user=user, text=\"Test transcription 2\"\n        )\n        self.asset.rollback_transcription(user)\n        response = self.client.post(path)\n        self.assertEqual(201, response.status_code)\n        response_json = response.json()\n        self.assertIn(\"id\", response_json)\n        self.assertIn(\"text\", response_json)\n        self.assertEqual(response_json[\"text\"], transcription2.text)\n        self.assertIn(\"undo_available\", response_json)\n        self.assertEqual(response_json[\"undo_available\"], True)\n        self.assertIn(\"redo_available\", response_json)\n        self.assertEqual(response_json[\"redo_available\"], False)\n\n        # Test aftering rolling back then creating a new transcription\n        self.asset.rollback_transcription(user)\n        create_transcription(asset=self.asset, user=user, text=\"Test transcription 3\")\n        response = self.client.post(path)\n        self.assertEqual(400, response.status_code)\n        self.assertIn(\"error\", response.json())\n\n        # Test anonymous user after a rollback\n        self.client.logout()\n        transcription3 = create_transcription(\n            asset=self.asset, user=user, text=\"Test transcription 3\"\n        )\n        self.asset.rollback_transcription(user)\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            response = self.client.post(path)\n        response_json = response.json()\n        self.assertEqual(201, response.status_code)\n        self.assertIn(\"id\", response_json)\n        self.assertIn(\"text\", response_json)\n        self.assertEqual(response_json[\"text\"], transcription3.text)\n        self.assertIn(\"undo_available\", response_json)\n        self.assertEqual(response_json[\"undo_available\"], True)\n        self.assertIn(\"redo_available\", response_json)\n        self.assertEqual(response_json[\"redo_available\"], False)\n\n    def tearDown(self):\n        post_save.connect(on_transcription_save, sender=Transcription)\n\n\n@override_settings(\n    CACHES={\n        \"visualization_cache\": {\n            \"BACKEND\": \"django.core.cache.backends.locmem.LocMemCache\",\n        }\n    }\n)\nclass VisualizationDataViewTests(TestCase):\n    def setUp(self):\n        self.factory = RequestFactory()\n        self.cache = caches[\"visualization_cache\"]\n        VisualizationDataView.cache = self.cache\n        self.cache.clear()\n        self.view = VisualizationDataView.as_view()\n\n    def test_get_missing_data_returns_404(self):\n        # If no entry exists in the cache under the given name,\n        # the view should return a 404 with a JSON error message.\n        request = self.factory.get(\"/visualizations/data/missing-key/\")\n        response = self.view(request, name=\"missing-key\")\n        self.assertEqual(response.status_code, 404)\n        self.assertEqual(response[\"Content-Type\"], \"application/json\")\n        data = json.loads(response.content)\n        self.assertEqual(\n            data, {\"error\": \"No visualization data found for 'missing-key'\"}\n        )\n\n    def test_get_existing_data_returns_200_and_json(self):\n        # When the cache contains data for the given name,\n        # the view should return it as JSON with status 200.\n        sample_data = {\"foo\": \"bar\", \"numbers\": [1, 2, 3]}\n        self.cache.set(\"sample-key\", sample_data)\n        request = self.factory.get(\"/visualizations/data/sample-key/\")\n        response = self.view(request, name=\"sample-key\")\n        self.assertEqual(response.status_code, 200)\n        self.assertEqual(response[\"Content-Type\"], \"application/json\")\n        data = json.loads(response.content)\n        self.assertEqual(data, sample_data)\n"
  },
  {
    "path": "concordia/tests/test_views_asset_reservation.py",
    "content": "from datetime import timedelta\n\nfrom django.conf import settings\nfrom django.contrib.auth.models import User\nfrom django.db.models.signals import post_save\nfrom django.test import (\n    RequestFactory,\n    TransactionTestCase,\n    override_settings,\n)\nfrom django.urls import reverse\nfrom django.utils.timezone import now\n\nfrom concordia.models import (\n    AssetTranscriptionReservation,\n    Transcription,\n)\nfrom concordia.signals.handlers import on_transcription_save\nfrom concordia.tasks.reservations import (\n    delete_old_tombstoned_reservations,\n    expire_inactive_asset_reservations,\n    tombstone_old_active_asset_reservations,\n)\nfrom concordia.utils import get_anonymous_user, get_or_create_reservation_token\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False, SESSION_ENGINE=\"django.contrib.sessions.backends.cache\"\n)\nclass AssetReservationViewTests(CreateTestUsers, JSONAssertMixin, TransactionTestCase):\n    def test_asset_reservation(self):\n        \"\"\"\n        Test the basic Asset reservation process\n        \"\"\"\n\n        self.login_user()\n        self._asset_reservation_test_payload(self.user.pk)\n\n    def test_asset_reservation_anonymously(self):\n        \"\"\"\n        Test the basic Asset reservation process as an anonymous user\n        \"\"\"\n\n        anon_user = get_anonymous_user()\n        self._asset_reservation_test_payload(anon_user.pk, anonymous=True)\n\n    def _asset_reservation_test_payload(self, user_id, anonymous=False):\n        asset = create_asset()\n\n        # Acquire the reservation: 1 acquire\n        # + 1 reservation check\n        # + 1 logging if not anonymous\n        # + 1 session if not anonymous and using a database session engine:\n        expected_update_queries = 2\n        if not anonymous:\n            expected_update_queries += 1  # Added by django-structlog middleware\n            if settings.SESSION_ENGINE.endswith(\"db\"):\n                expected_update_queries += 1  # Added by database session engine\n            # We don't need to add an extra query for accessing request.user\n            # because the django-structlog middleware will do that for non-anonymous\n            expected_acquire_queries = expected_update_queries\n        else:\n            expected_acquire_queries = expected_update_queries + 1\n\n        with self.assertNumQueries(expected_acquire_queries):\n            resp = self.client.post(reverse(\"reserve-asset\", args=(asset.pk,)))\n        data = self.assertValidJSON(resp, expected_status=200)\n\n        reservation = AssetTranscriptionReservation.objects.get()\n        self.assertEqual(reservation.reservation_token, data[\"reservation_token\"])\n        self.assertEqual(reservation.asset, asset)\n\n        # Confirm that an update did not change the pk when it updated the timestamp:\n\n        with self.assertNumQueries(expected_update_queries):\n            resp = self.client.post(reverse(\"reserve-asset\", args=(asset.pk,)))\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertEqual(1, AssetTranscriptionReservation.objects.count())\n        updated_reservation = AssetTranscriptionReservation.objects.get()\n        self.assertEqual(\n            updated_reservation.reservation_token, data[\"reservation_token\"]\n        )\n        self.assertEqual(updated_reservation.asset, asset)\n        self.assertEqual(reservation.created_on, updated_reservation.created_on)\n        self.assertLess(reservation.created_on, updated_reservation.updated_on)\n\n        # Release the reservation now that we're done:\n        # 1 release\n        # + 1 logging if not anonymous\n        # + 1 session if not anonymous and using a database\n        expected_release_queries = 1\n        if not anonymous:\n            expected_release_queries += 1  # Added by django-structlog middleware\n            if settings.SESSION_ENGINE.endswith(\"db\"):\n                expected_release_queries += 1\n\n        with self.assertNumQueries(expected_release_queries):\n            resp = self.client.post(\n                reverse(\"reserve-asset\", args=(asset.pk,)), data={\"release\": True}\n            )\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertEqual(\n            updated_reservation.reservation_token, data[\"reservation_token\"]\n        )\n\n        self.assertEqual(0, AssetTranscriptionReservation.objects.count())\n\n    def test_asset_reservation_competition(self):\n        \"\"\"\n        Confirm that two users cannot reserve the same asset at the same time\n        \"\"\"\n\n        asset = create_asset()\n\n        # We'll reserve the test asset as the anonymous user and then attempt\n        # to edit it after logging in\n\n        # 4 queries =\n        # 1 expiry + 1 acquire + 2 get user ID + 2 get user profile from request\n        with self.assertNumQueries(6):\n            resp = self.client.post(reverse(\"reserve-asset\", args=(asset.pk,)))\n        self.assertEqual(200, resp.status_code)\n        self.assertEqual(1, AssetTranscriptionReservation.objects.count())\n\n        # Clear the login session so the reservation_token will be regenerated:\n        self.client.logout()\n        self.login_user()\n\n        # 1 session check + 1 acquire + get user ID from request\n        with self.assertNumQueries(3 if settings.SESSION_ENGINE.endswith(\"db\") else 2):\n            resp = self.client.post(reverse(\"reserve-asset\", args=(asset.pk,)))\n        self.assertEqual(409, resp.status_code)\n        self.assertEqual(1, AssetTranscriptionReservation.objects.count())\n\n    def test_asset_reservation_expiration(self):\n        \"\"\"\n        Simulate an expired reservation which should not cause the request to fail\n        \"\"\"\n        asset = create_asset()\n\n        stale_reservation = AssetTranscriptionReservation(  # nosec\n            asset=asset, reservation_token=\"stale\"\n        )\n        stale_reservation.full_clean()\n        stale_reservation.save()\n        # Backdate the object as if it happened 31 minutes ago:\n        old_timestamp = now() - timedelta(minutes=31)\n        AssetTranscriptionReservation.objects.update(\n            created_on=old_timestamp, updated_on=old_timestamp\n        )\n\n        expire_inactive_asset_reservations()\n\n        self.login_user()\n\n        # 1 reservation check + 1 acquire + 1 get user ID from request\n        expected_queries = 3\n        if settings.SESSION_ENGINE.endswith(\"db\"):\n            # 1 session check\n            expected_queries += 1\n\n        with self.assertNumQueries(expected_queries):\n            resp = self.client.post(reverse(\"reserve-asset\", args=(asset.pk,)))\n\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertEqual(1, AssetTranscriptionReservation.objects.count())\n        reservation = AssetTranscriptionReservation.objects.get()\n        self.assertEqual(reservation.reservation_token, data[\"reservation_token\"])\n\n    def test_asset_reservation_tombstone(self):\n        \"\"\"\n        Simulate a tombstoned reservation which should:\n            - return 408 during the tombstone period\n            - during the tombstone period, another user may\n              obtain the reservation but the original user may not\n        \"\"\"\n        asset = create_asset()\n        self.login_user()\n        request_factory = RequestFactory()\n        request = request_factory.get(\"/\")\n        request.session = {}\n        reservation_token = get_or_create_reservation_token(request)\n\n        session = self.client.session\n        session[\"reservation_token\"] = reservation_token\n        session.save()\n\n        tombstone_reservation = AssetTranscriptionReservation(  # nosec\n            asset=asset, reservation_token=reservation_token\n        )\n        tombstone_reservation.full_clean()\n        tombstone_reservation.save()\n        # Backdate the object as if it was created hours ago,\n        # even if it was recently updated\n        old_timestamp = now() - timedelta(\n            hours=settings.TRANSCRIPTION_RESERVATION_TOMBSTONE_HOURS + 1\n        )\n        current_timestamp = now()\n        AssetTranscriptionReservation.objects.update(\n            created_on=old_timestamp, updated_on=current_timestamp\n        )\n\n        tombstone_old_active_asset_reservations()\n        self.assertEqual(1, AssetTranscriptionReservation.objects.count())\n        reservation = AssetTranscriptionReservation.objects.get()\n        self.assertEqual(reservation.tombstoned, True)\n\n        # 1 session check + 1 reservation check + 1 logging\n        if settings.SESSION_ENGINE.endswith(\"db\"):\n            expected_queries = 3\n        else:\n            expected_queries = 2\n\n        with self.assertNumQueries(expected_queries):\n            resp = self.client.post(reverse(\"reserve-asset\", args=(asset.pk,)))\n\n        self.assertEqual(resp.status_code, 408)\n        self.assertEqual(1, AssetTranscriptionReservation.objects.count())\n        reservation = AssetTranscriptionReservation.objects.get()\n        self.assertEqual(reservation.reservation_token, reservation_token)\n\n        self.client.logout()\n\n        # 1 reservation check + 1 acquire + 1 get user ID\n        expected_queries = 3\n        if settings.SESSION_ENGINE.endswith(\"db\"):\n            # + 1 session check\n            expected_queries += 1\n\n        User.objects.create_user(username=\"anonymous\")\n        with self.assertNumQueries(expected_queries):\n            resp = self.client.post(reverse(\"reserve-asset\", args=(asset.pk,)))\n\n        self.assertValidJSON(resp, expected_status=200)\n        self.assertEqual(2, AssetTranscriptionReservation.objects.count())\n\n    def test_asset_reservation_tombstone_expiration(self):\n        \"\"\"\n        Simulate a tombstoned reservation which should expire after\n        the configured period of time, allowing the original user\n        to reserve the asset again\n        \"\"\"\n        asset = create_asset()\n        self.login_user()\n        request_factory = RequestFactory()\n        request = request_factory.get(\"/\")\n        request.session = {}\n        reservation_token = get_or_create_reservation_token(request)\n\n        session = self.client.session\n        session[\"reservation_token\"] = reservation_token\n        session.save()\n\n        tombstone_reservation = AssetTranscriptionReservation(  # nosec\n            asset=asset, reservation_token=reservation_token\n        )\n        tombstone_reservation.full_clean()\n        tombstone_reservation.save()\n        # Backdate the object as if it was created hours ago\n        # and tombstoned hours ago\n        old_timestamp = now() - timedelta(\n            hours=settings.TRANSCRIPTION_RESERVATION_TOMBSTONE_HOURS\n            + settings.TRANSCRIPTION_RESERVATION_TOMBSTONE_LENGTH_HOURS\n            + 1\n        )\n        not_as_old_timestamp = now() - timedelta(\n            hours=settings.TRANSCRIPTION_RESERVATION_TOMBSTONE_LENGTH_HOURS + 1\n        )\n        AssetTranscriptionReservation.objects.update(\n            created_on=old_timestamp, updated_on=not_as_old_timestamp, tombstoned=True\n        )\n\n        delete_old_tombstoned_reservations()\n        self.assertEqual(0, AssetTranscriptionReservation.objects.count())\n\n        # 1 session check + 1 reservation check + 1 acquire + 1logging\n        if settings.SESSION_ENGINE.endswith(\"db\"):\n            expected_queries = 4\n        else:\n            expected_queries = 3\n\n        with self.assertNumQueries(expected_queries):\n            resp = self.client.post(reverse(\"reserve-asset\", args=(asset.pk,)))\n\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertEqual(1, AssetTranscriptionReservation.objects.count())\n        reservation = AssetTranscriptionReservation.objects.get()\n        self.assertEqual(reservation.reservation_token, data[\"reservation_token\"])\n        self.assertEqual(reservation.tombstoned, False)\n\n    def tearDown(self):\n        # We'll test the signal handler separately\n        post_save.connect(on_transcription_save, sender=Transcription)\n"
  },
  {
    "path": "concordia/tests/test_views_redirect_next_reviewable.py",
    "content": "from unittest.mock import patch\n\nfrom django.db.models.signals import post_save\nfrom django.test import (\n    TransactionTestCase,\n    override_settings,\n)\nfrom django.urls import reverse\nfrom django.utils.timezone import now\n\nfrom concordia.models import (\n    Transcription,\n)\nfrom concordia.signals.handlers import on_transcription_save\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_topic,\n)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False, SESSION_ENGINE=\"django.contrib.sessions.backends.cache\"\n)\nclass NextReviewableRedirectViewTests(\n    CreateTestUsers, JSONAssertMixin, TransactionTestCase\n):\n    def test_find_next_reviewable_no_campaign(self):\n        user = self.create_user(\"test-user\")\n        anon = get_anonymous_user()\n\n        # Test case where there are no reviewable assets\n        response = self.client.get(reverse(\"redirect-to-next-reviewable-asset\"))\n        self.assertRedirects(response, expected_url=\"/\")\n\n        asset1 = create_asset(slug=\"test-asset-1\", title=\"Test Asset 1\")\n        asset2 = create_asset(\n            item=asset1.item, slug=\"test-asset-2\", title=\"Test Asset 2\"\n        )\n        asset3 = create_asset(\n            item=asset1.item, slug=\"test-asset-3\", title=\"Test Asset 3\"\n        )\n        campaign = asset1.item.project.campaign\n\n        t1 = Transcription(asset=asset1, user=user, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        t2 = Transcription(asset=asset2, user=anon, text=\"test\", submitted=now())\n        t2.full_clean()\n        t2.save()\n\n        t3 = Transcription(asset=asset3, user=anon, text=\"test\", submitted=now())\n        t3.full_clean()\n        t3.save()\n\n        response = self.client.get(reverse(\"redirect-to-next-reviewable-asset\"))\n        self.assertRedirects(response, expected_url=asset1.get_absolute_url())\n\n        # Test logged in user (this creates a new user)\n        # asset1 is no longer available due to the request above reserving it\n        self.login_user()\n        response = self.client.get(reverse(\"redirect-to-next-reviewable-asset\"))\n        self.assertRedirects(response, expected_url=asset2.get_absolute_url())\n\n        # Configure campaign to be next review cmpaign for tests below\n        campaign.next_review_campaign = True\n        campaign.save()\n\n        # Test when next reviewable campaign doesn't exist and there\n        # are no other campaigns/assets\n        with patch(\"concordia.models.Campaign.objects.get\") as mock:\n            mock.side_effect = IndexError\n            response = self.client.get(reverse(\"redirect-to-next-reviewable-asset\"))\n        self.assertRedirects(response, expected_url=\"/\")\n\n        # Test case when a campaign is configured to be default next reviewable\n        response = self.client.get(reverse(\"redirect-to-next-reviewable-asset\"))\n        self.assertRedirects(response, expected_url=asset3.get_absolute_url())\n\n        # Test when next reviewable campaign has no reviewable assets\n        asset1.delete()\n        asset2.delete()\n        response = self.client.get(reverse(\"redirect-to-next-reviewable-asset\"))\n        self.assertRedirects(response, expected_url=\"/\")\n\n        # Test when next reviewable campaign has no reviewable assets\n        # and other campaigns exist and have no reviewable assets\n        create_campaign(slug=\"test-campaign-2\")\n        response = self.client.get(reverse(\"redirect-to-next-reviewable-asset\"))\n        self.assertRedirects(response, expected_url=\"/\")\n\n    def test_find_next_reviewable_campaign(self):\n        anon = get_anonymous_user()\n\n        asset1 = create_asset(slug=\"test-review-asset-1\", title=\"Test Asset 1\")\n        asset2 = create_asset(\n            item=asset1.item, slug=\"test-review-asset-2\", title=\"Test Asset 2\"\n        )\n\n        t1 = Transcription(asset=asset1, user=anon, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        t2 = Transcription(asset=asset2, user=anon, text=\"test\", submitted=now())\n        t2.full_clean()\n        t2.save()\n\n        campaign = asset1.item.project.campaign\n\n        # Anonymous user test\n        response = self.client.get(\n            reverse(\n                \"transcriptions:redirect-to-next-reviewable-campaign-asset\",\n                kwargs={\"campaign_slug\": campaign.slug},\n            )\n        )\n        self.assertRedirects(response, expected_url=asset1.get_absolute_url())\n\n        # Authenticated user test\n        # asset1 is no longer available since the previous request reserved it\n        self.login_user()\n        response = self.client.get(\n            reverse(\n                \"transcriptions:redirect-to-next-reviewable-campaign-asset\",\n                kwargs={\"campaign_slug\": campaign.slug},\n            )\n        )\n        self.assertRedirects(response, expected_url=asset2.get_absolute_url())\n\n    def test_find_next_reviewable_topic(self):\n        anon = get_anonymous_user()\n\n        asset1 = create_asset(slug=\"test-review-asset-1\")\n        asset2 = create_asset(item=asset1.item, slug=\"test-review-asset-2\")\n        project = asset1.item.project\n        topic = create_topic(project=project)\n\n        t1 = Transcription(asset=asset1, user=anon, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        t2 = Transcription(asset=asset2, user=anon, text=\"test\", submitted=now())\n        t2.full_clean()\n        t2.save()\n\n        # Anonymous user test\n        response = self.client.get(\n            reverse(\n                \"redirect-to-next-reviewable-topic-asset\",\n                kwargs={\"topic_slug\": topic.slug},\n            )\n        )\n        self.assertRedirects(response, expected_url=asset1.get_absolute_url())\n\n        # Authenticated user test\n        # We expect that asset1 is no longer available. Even though\n        # anonymous users can't reserve assets for review, we still will\n        # have removed the asset from the NextReviewableTopicAsset table\n        # to ensure two users don't receive the same asset\n        self.login_user()\n        response = self.client.get(\n            reverse(\n                \"redirect-to-next-reviewable-topic-asset\",\n                kwargs={\"topic_slug\": topic.slug},\n            )\n        )\n        self.assertRedirects(response, expected_url=asset2.get_absolute_url())\n\n    def test_find_next_reviewable_unlisted_campaign(self):\n        anon = get_anonymous_user()\n\n        unlisted_campaign = create_campaign(\n            slug=\"campaign-transcribe-redirect-unlisted\",\n            title=\"Test Unlisted Review Redirect Campaign\",\n            unlisted=True,\n        )\n        unlisted_project = create_project(\n            title=\"Unlisted Project\",\n            slug=\"unlisted-project\",\n            campaign=unlisted_campaign,\n        )\n        unlisted_item = create_item(\n            title=\"Unlisted Item\",\n            item_id=\"unlisted-item\",\n            item_url=\"https://blah.com/unlisted-item\",\n            project=unlisted_project,\n        )\n\n        asset1 = create_asset(slug=\"test-asset-1\", item=unlisted_item)\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n\n        t1 = Transcription(asset=asset1, user=anon, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        t2 = Transcription(asset=asset2, user=anon, text=\"test\", submitted=now())\n        t2.full_clean()\n        t2.save()\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:redirect-to-next-reviewable-campaign-asset\",\n                kwargs={\"campaign_slug\": unlisted_campaign.slug},\n            )\n        )\n\n        self.assertRedirects(response, expected_url=asset1.get_absolute_url())\n\n    def tearDown(self):\n        # We'll test the signal handler separately\n        post_save.connect(on_transcription_save, sender=Transcription)\n"
  },
  {
    "path": "concordia/tests/test_views_redirect_next_transcribable.py",
    "content": "from unittest.mock import patch\n\nfrom django.db.models.signals import post_save\nfrom django.test import (\n    TransactionTestCase,\n    override_settings,\n)\nfrom django.urls import reverse\n\nfrom concordia.models import (\n    AssetTranscriptionReservation,\n    Transcription,\n    TranscriptionStatus,\n)\nfrom concordia.signals.handlers import on_transcription_save\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_topic,\n)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False, SESSION_ENGINE=\"django.contrib.sessions.backends.cache\"\n)\nclass NextTranscribableRedirectViewTests(\n    CreateTestUsers, JSONAssertMixin, TransactionTestCase\n):\n    def test_find_next_transcribable_no_campaign(self):\n        # Test case where there are no transcribable assets\n        resp = self.client.get(reverse(\"redirect-to-next-transcribable-asset\"))\n        self.assertRedirects(resp, expected_url=\"/\")\n\n        asset1 = create_asset(slug=\"test-asset-1\")\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n        campaign = asset1.item.project.campaign\n\n        resp = self.client.get(reverse(\"redirect-to-next-transcribable-asset\"))\n        self.assertRedirects(resp, expected_url=asset1.get_absolute_url())\n\n        # Configure next transcription campaign for tests below\n        campaign.next_transcription_campaign = True\n        campaign.save()\n\n        # Test when next transcribable campaign doesn't exist and there\n        # are no other campaigns/assets\n        with patch(\"concordia.models.Campaign.objects.get\") as mock:\n            mock.side_effect = IndexError\n            response = self.client.get(reverse(\"redirect-to-next-transcribable-asset\"))\n        self.assertRedirects(response, expected_url=\"/\")\n\n        # Test case when a campaign is configured to be default next transcribable\n        response = self.client.get(reverse(\"redirect-to-next-transcribable-asset\"))\n        self.assertRedirects(response, expected_url=asset2.get_absolute_url())\n\n        # Test when next transcribable campaign has not transcribable assets\n        asset1.delete()\n        asset2.delete()\n        response = self.client.get(reverse(\"redirect-to-next-transcribable-asset\"))\n        self.assertRedirects(response, expected_url=\"/\")\n\n        # Test when next transcription campaign has no transcribable assets\n        # and other campaigns exist and have no transcribable assets\n        create_campaign(slug=\"test-campaign-2\")\n        response = self.client.get(reverse(\"redirect-to-next-transcribable-asset\"))\n        self.assertRedirects(response, expected_url=\"/\")\n\n    def test_find_next_transcribable_campaign(self):\n        asset1 = create_asset(slug=\"test-asset-1\")\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n        campaign = asset1.item.project.campaign\n\n        # Anonymous user test\n        resp = self.client.get(\n            reverse(\n                \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                kwargs={\"campaign_slug\": campaign.slug},\n            )\n        )\n        self.assertRedirects(resp, expected_url=asset1.get_absolute_url())\n\n        # Authenticated user test\n        self.login_user()\n        resp = self.client.get(\n            reverse(\n                \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                kwargs={\"campaign_slug\": campaign.slug},\n            )\n        )\n        self.assertRedirects(resp, expected_url=asset2.get_absolute_url())\n\n    def test_find_next_transcribable_topic(self):\n        asset1 = create_asset(slug=\"test-asset-1\")\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n        project = asset1.item.project\n        topic = create_topic(project=project)\n\n        # Anonymous user test\n        resp = self.client.get(\n            reverse(\n                \"redirect-to-next-transcribable-topic-asset\",\n                kwargs={\"topic_slug\": topic.slug},\n            )\n        )\n        self.assertRedirects(resp, expected_url=asset1.get_absolute_url())\n\n        # Authenticated user test\n        self.login_user()\n        resp = self.client.get(\n            reverse(\n                \"redirect-to-next-transcribable-topic-asset\",\n                kwargs={\"topic_slug\": topic.slug},\n            )\n        )\n        self.assertRedirects(resp, expected_url=asset2.get_absolute_url())\n\n    def test_find_next_transcribable_unlisted_campaign(self):\n        unlisted_campaign = create_campaign(\n            slug=\"campaign-transcribe-redirect-unlisted\",\n            title=\"Test Unlisted Transcribe Redirect Campaign\",\n            unlisted=True,\n        )\n        unlisted_project = create_project(\n            title=\"Unlisted Project\",\n            slug=\"unlisted-project\",\n            campaign=unlisted_campaign,\n        )\n        unlisted_item = create_item(\n            title=\"Unlisted Item\",\n            item_id=\"unlisted-item\",\n            item_url=\"https://blah.com/unlisted-item\",\n            project=unlisted_project,\n        )\n\n        asset1 = create_asset(slug=\"test-asset-1\", item=unlisted_item)\n        create_asset(item=asset1.item, slug=\"test-asset-2\")\n\n        response = self.client.get(\n            reverse(\n                \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                kwargs={\"campaign_slug\": unlisted_campaign.slug},\n            )\n        )\n\n        self.assertRedirects(response, expected_url=asset1.get_absolute_url())\n\n    def test_find_next_transcribable_single_asset(self):\n        asset = create_asset()\n        campaign = asset.item.project.campaign\n\n        resp = self.client.get(\n            reverse(\n                \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                kwargs={\"campaign_slug\": campaign.slug},\n            )\n        )\n\n        self.assertRedirects(resp, expected_url=asset.get_absolute_url())\n\n    def test_find_next_transcribable_in_singleton_campaign(self):\n        asset = create_asset(transcription_status=TranscriptionStatus.SUBMITTED)\n        campaign = asset.item.project.campaign\n\n        resp = self.client.get(\n            reverse(\n                \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                kwargs={\"campaign_slug\": campaign.slug},\n            )\n        )\n\n        self.assertRedirects(resp, expected_url=reverse(\"homepage\"))\n\n    def test_find_next_transcribable_project_redirect(self):\n        asset = create_asset(transcription_status=TranscriptionStatus.SUBMITTED)\n        project = asset.item.project\n        campaign = project.campaign\n\n        resp = self.client.get(\n            \"%s?project=%s\"\n            % (\n                reverse(\n                    \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                    kwargs={\"campaign_slug\": campaign.slug},\n                ),\n                project.slug,\n            )\n        )\n\n        self.assertRedirects(resp, expected_url=reverse(\"homepage\"))\n\n    def test_find_next_transcribable_hierarchy(self):\n        \"\"\"Confirm that find-next-page selects assets in the expected order\"\"\"\n\n        asset = create_asset()\n        item = asset.item\n        project = item.project\n        campaign = project.campaign\n\n        asset_in_item = create_asset(item=item, slug=\"test-asset-in-same-item\")\n        in_progress_asset_in_item = create_asset(\n            item=item,\n            slug=\"inprogress-asset-in-same-item\",\n            transcription_status=TranscriptionStatus.IN_PROGRESS,\n        )\n\n        asset_in_project = create_asset(\n            item=create_item(project=project, item_id=\"other-item-in-same-project\"),\n            title=\"test-asset-in-same-project\",\n        )\n\n        asset_in_campaign = create_asset(\n            item=create_item(\n                project=create_project(campaign=campaign, title=\"other project\"),\n                item_id=\"another-item-in-different-project\",\n                title=\"item in other project\",\n            ),\n            slug=\"test-asset-in-same-campaign\",\n        )\n\n        # Now that we have test assets we'll see what find-next-page gives us as\n        # successive test records are marked as submitted and thus ineligible.\n        # The expected ordering is that it will favor moving forward (i.e. not\n        # landing you on the same asset unless that's the only one available),\n        # and will keep you closer to the asset you started from (i.e. within\n        # the same item or project in that order).\n\n        self.assertRedirects(\n            self.client.get(\n                reverse(\n                    \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                    kwargs={\"campaign_slug\": campaign.slug},\n                ),\n                {\"project\": project.slug, \"item\": item.item_id, \"asset\": asset.pk},\n            ),\n            asset_in_item.get_absolute_url(),\n        )\n\n        asset_in_item.transcription_status = TranscriptionStatus.SUBMITTED\n        asset_in_item.save()\n        AssetTranscriptionReservation.objects.all().delete()\n\n        self.assertRedirects(\n            self.client.get(\n                reverse(\n                    \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                    kwargs={\"campaign_slug\": campaign.slug},\n                ),\n                {\"project\": project.slug, \"item\": item.item_id, \"asset\": asset.pk},\n            ),\n            asset_in_project.get_absolute_url(),\n        )\n\n        asset_in_project.transcription_status = TranscriptionStatus.SUBMITTED\n        asset_in_project.save()\n        AssetTranscriptionReservation.objects.all().delete()\n\n        self.assertRedirects(\n            self.client.get(\n                reverse(\n                    \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                    kwargs={\"campaign_slug\": campaign.slug},\n                ),\n                {\"project\": project.slug, \"item\": item.item_id, \"asset\": asset.pk},\n            ),\n            asset_in_campaign.get_absolute_url(),\n        )\n\n        asset_in_campaign.transcription_status = TranscriptionStatus.SUBMITTED\n        asset_in_campaign.save()\n        AssetTranscriptionReservation.objects.all().delete()\n\n        self.assertRedirects(\n            self.client.get(\n                reverse(\n                    \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                    kwargs={\"campaign_slug\": campaign.slug},\n                ),\n                {\"project\": project.slug, \"item\": item.item_id, \"asset\": asset.pk},\n            ),\n            in_progress_asset_in_item.get_absolute_url(),\n        )\n\n    def tearDown(self):\n        # We'll test the signal handler separately\n        post_save.connect(on_transcription_save, sender=Transcription)\n"
  },
  {
    "path": "concordia/tests/test_views_tags.py",
    "content": "from unittest.mock import patch\n\nfrom django import forms\nfrom django.contrib.auth.models import User\nfrom django.db.models.signals import post_save\nfrom django.test import (\n    TransactionTestCase,\n    override_settings,\n)\nfrom django.urls import reverse\n\nfrom concordia.models import (\n    Transcription,\n)\nfrom concordia.signals.handlers import on_transcription_save\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False, SESSION_ENGINE=\"django.contrib.sessions.backends.cache\"\n)\nclass TagSubmissionViewTests(CreateTestUsers, JSONAssertMixin, TransactionTestCase):\n    def test_anonymous_tag_submission(self):\n        \"\"\"Confirm that anonymous users cannot submit tags\"\"\"\n        asset = create_asset()\n        submit_url = reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk})\n\n        resp = self.client.post(submit_url, data={\"tags\": [\"foo\", \"bar\"]})\n        self.assertRedirects(resp, \"%s?next=%s\" % (reverse(\"login\"), submit_url))\n\n    def test_tag_submission(self):\n        asset = create_asset()\n\n        self.login_user()\n\n        test_tags = [\"foo\", \"bar\"]\n\n        resp = self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": test_tags},\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertIn(\"user_tags\", data)\n        self.assertIn(\"all_tags\", data)\n\n        self.assertEqual(sorted(test_tags), data[\"user_tags\"])\n        self.assertEqual(sorted(test_tags), data[\"all_tags\"])\n\n    def test_invalid_tag_submission(self):\n        asset = create_asset()\n\n        self.login_user()\n\n        test_tags = [\"foo\", \"bar\"]\n\n        with patch(\"concordia.models.Tag.full_clean\") as mock:\n            mock.side_effect = forms.ValidationError(\"Testing error\")\n            resp = self.client.post(\n                reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n                data={\"tags\": test_tags},\n            )\n            data = self.assertValidJSON(resp, expected_status=400)\n            self.assertIn(\"error\", data)\n\n    def test_tag_submission_with_diacritics(self):\n        asset = create_asset()\n\n        self.login_user()\n\n        test_tags = [\"Café\", \"château\", \"señor\", \"façade\"]\n\n        resp = self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": test_tags},\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertIn(\"user_tags\", data)\n        self.assertIn(\"all_tags\", data)\n\n        self.assertEqual(sorted(test_tags), data[\"user_tags\"])\n        self.assertEqual(sorted(test_tags), data[\"all_tags\"])\n\n    def test_tag_submission_with_multiple_users(self):\n        asset = create_asset()\n        self.login_user()\n\n        test_tags = [\"foo\", \"bar\"]\n\n        resp = self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": test_tags},\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertIn(\"user_tags\", data)\n        self.assertIn(\"all_tags\", data)\n\n        self.assertEqual(sorted(test_tags), data[\"user_tags\"])\n        self.assertEqual(sorted(test_tags), data[\"all_tags\"])\n\n    def test_duplicate_tag_submission(self):\n        \"\"\"Confirm that tag values cannot be duplicated\"\"\"\n        asset = create_asset()\n\n        self.login_user()\n\n        resp = self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": [\"foo\", \"bar\", \"baaz\"]},\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n\n        second_user = self.create_test_user(\n            username=\"second_tester\", email=\"second_tester@example.com\"\n        )\n        self.client.login(username=second_user.username, password=second_user._password)\n\n        resp = self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": [\"foo\", \"bar\", \"quux\"]},\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n\n        # Even though the user submitted (through some horrible bug) duplicate\n        # values, they should not be stored:\n        self.assertEqual([\"bar\", \"foo\", \"quux\"], data[\"user_tags\"])\n        # Users are allowed to delete other users' tags, so since the second\n        # user didn't send the \"baaz\" tag, it was removed\n        self.assertEqual([\"bar\", \"foo\", \"quux\"], data[\"all_tags\"])\n\n    def test_tag_deletion(self):\n        asset = create_asset()\n        self.login_user()\n\n        initial_tags = [\"foo\", \"bar\"]\n        self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": initial_tags},\n        )\n        updated_tags = [\n            \"foo\",\n        ]\n        resp = self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": updated_tags},\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertIn(\"user_tags\", data)\n        self.assertIn(\"all_tags\", data)\n\n        self.assertCountEqual(updated_tags, data[\"user_tags\"])\n        self.assertCountEqual(updated_tags, data[\"all_tags\"])\n\n    def test_tag_deletion_with_multiple_users(self):\n        asset = create_asset()\n        self.login_user(\"first_user\")\n        initial_tags = [\"foo\", \"bar\"]\n        resp = self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": initial_tags},\n        )\n        self.assertIn(\n            \"first_user\",\n            asset.userassettagcollection_set.values().values_list(\n                \"user__username\", flat=True\n            ),\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertIn(\"user_tags\", data)\n        self.assertIn(\"all_tags\", data)\n        self.assertCountEqual(initial_tags, data[\"user_tags\"])\n        self.assertCountEqual(initial_tags, data[\"all_tags\"])\n\n        self.client.logout()\n\n        second_user = self.create_test_user(\"second_user\")\n        self.client.login(username=second_user.username, password=second_user._password)\n        updated_tags = [\n            \"foo\",\n        ]\n        resp = self.client.post(\n            reverse(\"submit-tags\", kwargs={\"asset_pk\": asset.pk}),\n            data={\"tags\": updated_tags},\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n\n        self.assertIn(\n            \"second_user\",\n            asset.userassettagcollection_set.values().values_list(\n                \"user__username\", flat=True\n            ),\n        )\n        self.assertEqual(asset.userassettagcollection_set.count(), 2)\n        self.assertEqual(\n            User.objects.filter(userassettagcollection__asset=asset).count(), 2\n        )\n        self.assertIn(\"user_tags\", data)\n        self.assertIn(\"all_tags\", data)\n        self.assertCountEqual(updated_tags, data[\"user_tags\"])\n\n    def tearDown(self):\n        # We'll test the signal handler separately\n        post_save.connect(on_transcription_save, sender=Transcription)\n"
  },
  {
    "path": "concordia/tests/test_views_topics.py",
    "content": "from django.core.cache import caches\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\n\nfrom concordia.models import ProjectTopic, TranscriptionStatus\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_topic,\n)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False,\n    SESSION_ENGINE=\"django.contrib.sessions.backends.cache\",\n    CACHES={\n        \"default\": {\"BACKEND\": \"django.core.cache.backends.dummy.DummyCache\"},\n        \"view_cache\": {\"BACKEND\": \"django.core.cache.backends.dummy.DummyCache\"},\n    },\n)\nclass TopicDetailViewTests(CreateTestUsers, JSONAssertMixin, TestCase):\n    \"\"\"\n    Focused tests for the Topic detail view.\n    \"\"\"\n\n    def setUp(self):\n        for cache in caches.all():\n            cache.clear()\n\n    def tearDown(self):\n        for cache in caches.all():\n            cache.clear()\n\n    def test_topic_detail_basic(self):\n        topic = create_topic(title=\"GET Topic\", slug=\"get-topic\")\n        response = self.client.get(reverse(\"topic-detail\", args=(topic.slug,)))\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/topic_detail.html\"\n        )\n        self.assertContains(response, topic.title)\n\n    def test_unlisted_topic_detail_view(self):\n        c2 = create_topic(\n            title=\"GET Unlisted Topic\", unlisted=True, slug=\"get-unlisted-topic\"\n        )\n\n        response2 = self.client.get(reverse(\"topic-detail\", args=(c2.slug,)))\n\n        self.assertEqual(response2.status_code, 200)\n        self.assertTemplateUsed(\n            response2, template_name=\"transcriptions/topic_detail.html\"\n        )\n        self.assertContains(response2, c2.title)\n\n    def test_topic_detail_with_status_sets_querystring(self):\n        \"\"\"\n        When a valid transcription_status is supplied, sublevel_querystring\n        contains only that param.\n        \"\"\"\n        topic = create_topic(title=\"GET Topic\", slug=\"get-topic\")\n        response = self.client.get(\n            reverse(\"topic-detail\", args=(topic.slug,)),\n            {\"transcription_status\": \"not_started\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(\n            response, template_name=\"transcriptions/topic_detail.html\"\n        )\n        self.assertContains(response, topic.title)\n        self.assertIn(\"sublevel_querystring\", response.context)\n        self.assertEqual(\n            response.context[\"sublevel_querystring\"], \"transcription_status=not_started\"\n        )\n\n    def test_url_filter_links_without_sublevel_querystring(self):\n        \"\"\"\n        With a project-level url_filter and no sublevel filter, links for that\n        project include transcription_status=<url_filter>, while projects without\n        a url_filter do not include a transcription_status param.\n        \"\"\"\n        topic = create_topic(title=\"Filter Topic\", slug=\"filter-topic\")\n        campaign = create_campaign(title=\"Filter Test Campaign\", slug=\"filter-test\")\n\n        project_with_filter = create_project(\n            campaign=campaign, title=\"Project With Filter\", slug=\"with-filter\"\n        )\n        project_without_filter = create_project(\n            campaign=campaign, title=\"Project Without Filter\", slug=\"without-filter\"\n        )\n\n        ProjectTopic.objects.create(\n            project=project_with_filter,\n            topic=topic,\n            url_filter=TranscriptionStatus.SUBMITTED,\n        )\n        ProjectTopic.objects.create(\n            project=project_without_filter,\n            topic=topic,\n            url_filter=None,\n        )\n\n        response = self.client.get(reverse(\"topic-detail\", args=(topic.slug,)))\n        self.assertEqual(response.status_code, 200)\n\n        # project_with_filter has ?transcription_status=submitted\n        # (appears twice: image+title)\n        self.assertContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_with_filter.slug}/?transcription_status=submitted\",\n            2,\n        )\n        # project_without_filter should not include any transcription_status param\n        self.assertNotContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_without_filter.slug}/?transcription_status=\",\n        )\n\n    def test_sublevel_querystring_only_keeps_transcription_status(self):\n        \"\"\"\n        If extra params are provided along with transcription_status, only\n        transcription_status is retained in sublevel_querystring.\n        \"\"\"\n        topic = create_topic(title=\"GET Topic\", slug=\"get-topic\")\n        response = self.client.get(\n            reverse(\"topic-detail\", args=(topic.slug,)),\n            {\"transcription_status\": \"not_started\", \"another_param\": \"some_value\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"sublevel_querystring\", response.context)\n        self.assertEqual(\n            response.context[\"sublevel_querystring\"], \"transcription_status=not_started\"\n        )\n\n    def test_with_status_and_no_assets_excludes_projects(self):\n        \"\"\"\n        When a transcription_status is present and projects have no assets,\n        those projects are excluded (no links rendered).\n        \"\"\"\n        topic = create_topic(title=\"Filter Topic\", slug=\"filter-topic\")\n        campaign = create_campaign(title=\"Filter Test Campaign\", slug=\"filter-test\")\n\n        project_with_filter = create_project(\n            campaign=campaign, title=\"Project With Filter\", slug=\"with-filter\"\n        )\n        project_without_filter = create_project(\n            campaign=campaign, title=\"Project Without Filter\", slug=\"without-filter\"\n        )\n\n        ProjectTopic.objects.create(\n            project=project_with_filter,\n            topic=topic,\n            url_filter=TranscriptionStatus.SUBMITTED,\n        )\n        ProjectTopic.objects.create(\n            project=project_without_filter,\n            topic=topic,\n            url_filter=None,\n        )\n\n        response = self.client.get(\n            reverse(\"topic-detail\", args=(topic.slug,)),\n            {\"transcription_status\": \"not_started\", \"another_param\": \"some_value\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"sublevel_querystring\", response.context)\n        self.assertEqual(\n            response.context[\"sublevel_querystring\"], \"transcription_status=not_started\"\n        )\n\n        # No assets exist, so neither project should appear with the filter applied\n        self.assertContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_with_filter.slug}/?transcription_status=not_started\",\n            0,\n        )\n        self.assertContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_without_filter.slug}/?transcription_status=not_started\",\n            0,\n        )\n\n    def test_with_status_and_assets_uses_sublevel_and_overrides_url_filter(self):\n        \"\"\"\n        When assets exist and a transcription_status is supplied, projects with no\n        url_filter are shown using the sublevel filter. Projects with a url_filter\n        that does not match the sublevel filter are excluded.\n        \"\"\"\n        topic = create_topic(title=\"Filter Topic\", slug=\"filter-topic\")\n        campaign = create_campaign(title=\"Filter Test Campaign\", slug=\"filter-test\")\n\n        project_with_filter = create_project(\n            campaign=campaign, title=\"Project With Filter\", slug=\"with-filter\"\n        )\n        project_without_filter = create_project(\n            campaign=campaign, title=\"Project Without Filter\", slug=\"without-filter\"\n        )\n\n        ProjectTopic.objects.create(\n            project=project_with_filter,\n            topic=topic,\n            url_filter=TranscriptionStatus.SUBMITTED,\n        )\n        ProjectTopic.objects.create(\n            project=project_without_filter,\n            topic=topic,\n            url_filter=None,\n        )\n\n        # Add assets so eligible projects will display\n        item_with_filter = create_item(project=project_with_filter)\n        create_asset(item=item_with_filter)\n        item_without_filter = create_item(project=project_without_filter)\n        create_asset(item=item_without_filter)\n\n        response = self.client.get(\n            reverse(\"topic-detail\", args=(topic.slug,)),\n            {\"transcription_status\": \"not_started\", \"another_param\": \"some_value\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"sublevel_querystring\", response.context)\n        self.assertEqual(\n            response.context[\"sublevel_querystring\"], \"transcription_status=not_started\"\n        )\n\n        # Project WITH a mismatching url_filter should be excluded\n        self.assertContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_with_filter.slug}/?transcription_status=not_started\",\n            0,\n        )\n\n        # Project WITHOUT a url_filter should use the sublevel filter\n        # (appears twice: image + title)\n        self.assertContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_without_filter.slug}/?transcription_status=not_started\",\n            2,\n        )\n\n    def test_with_status_and_assets_includes_matching_url_filter(self):\n        \"\"\"\n        When assets exist and a transcription_status is supplied, projects with a\n        matching url_filter should be included, and links should use that status.\n        \"\"\"\n        topic = create_topic(title=\"Filter Topic\", slug=\"filter-topic\")\n        campaign = create_campaign(title=\"Filter Test Campaign\", slug=\"filter-test\")\n\n        project_with_filter = create_project(\n            campaign=campaign, title=\"Project With Filter\", slug=\"with-filter\"\n        )\n        project_without_filter = create_project(\n            campaign=campaign, title=\"Project Without Filter\", slug=\"without-filter\"\n        )\n\n        ProjectTopic.objects.create(\n            project=project_with_filter,\n            topic=topic,\n            url_filter=TranscriptionStatus.SUBMITTED,\n        )\n        ProjectTopic.objects.create(\n            project=project_without_filter,\n            topic=topic,\n            url_filter=None,\n        )\n\n        # Ensure both projects have at least one asset counted as \"submitted\"\n        item_with_filter = create_item(project=project_with_filter)\n        a1 = create_asset(item=item_with_filter)\n        a1.transcription_status = TranscriptionStatus.SUBMITTED\n        a1.save(update_fields=[\"transcription_status\"])\n\n        item_without_filter = create_item(project=project_without_filter)\n        a2 = create_asset(item=item_without_filter)\n        a2.transcription_status = TranscriptionStatus.SUBMITTED\n        a2.save(update_fields=[\"transcription_status\"])\n\n        response = self.client.get(\n            reverse(\"topic-detail\", args=(topic.slug,)),\n            {\"transcription_status\": \"submitted\"},\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"sublevel_querystring\", response.context)\n        self.assertEqual(\n            response.context[\"sublevel_querystring\"], \"transcription_status=submitted\"\n        )\n\n        # Project WITH a matching url_filter should be included (2 links: image + title)\n        self.assertContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_with_filter.slug}/?transcription_status=submitted\",\n            2,\n        )\n\n        # Project WITHOUT a url_filter should also be included (also 2 links)\n        self.assertContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_without_filter.slug}/?transcription_status=submitted\",\n            2,\n        )\n\n    def test_topic_detail_with_invalid_status_ignores_filter(self):\n        \"\"\"\n        If transcription_status is present but invalid, the view should treat it\n        as absent: no filtering by status and no sublevel_querystring.\n        \"\"\"\n        topic = create_topic(title=\"Filter Topic\", slug=\"filter-topic\")\n        campaign = create_campaign(title=\"Filter Test Campaign\", slug=\"filter-test\")\n\n        project_with_filter = create_project(\n            campaign=campaign, title=\"Project With Filter\", slug=\"with-filter\"\n        )\n        project_without_filter = create_project(\n            campaign=campaign, title=\"Project Without Filter\", slug=\"without-filter\"\n        )\n\n        ProjectTopic.objects.create(\n            project=project_with_filter,\n            topic=topic,\n            url_filter=TranscriptionStatus.SUBMITTED,\n        )\n        ProjectTopic.objects.create(\n            project=project_without_filter,\n            topic=topic,\n            url_filter=None,\n        )\n\n        # Make both projects eligible to display\n        create_asset(item=create_item(project=project_with_filter))\n        create_asset(item=create_item(project=project_without_filter))\n\n        # Supply an invalid status\n        response = self.client.get(\n            reverse(\"topic-detail\", args=(topic.slug,)),\n            {\"transcription_status\": \"not-a-real-status\", \"another_param\": \"x\"},\n        )\n        self.assertEqual(response.status_code, 200)\n\n        # sublevel_querystring should be empty (invalid status ignored)\n        self.assertIn(\"sublevel_querystring\", response.context)\n        self.assertEqual(response.context[\"sublevel_querystring\"], \"\")\n\n        # Project WITH a url_filter should use its own filter in links\n        self.assertContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_with_filter.slug}/?transcription_status=submitted\",\n            2,\n        )\n        # Project WITHOUT a url_filter should not include a transcription_status param\n        self.assertNotContains(\n            response,\n            f\"/campaigns/{campaign.slug}/{project_without_filter.slug}/?transcription_status=\",\n        )\n\n    def test_url_filter_empty_string_treated_as_missing(self):\n        topic = create_topic(title=\"Filter Topic\", slug=\"filter-topic\")\n        campaign = create_campaign(title=\"Filter Test Campaign\", slug=\"filter-test\")\n\n        project_empty_filter = create_project(\n            campaign=campaign, title=\"Project Empty Filter\", slug=\"empty-filter\"\n        )\n        project_none_filter = create_project(\n            campaign=campaign, title=\"Project None Filter\", slug=\"none-filter\"\n        )\n\n        ProjectTopic.objects.create(\n            project=project_empty_filter, topic=topic, url_filter=\"\"\n        )\n        ProjectTopic.objects.create(\n            project=project_none_filter, topic=topic, url_filter=None\n        )\n\n        # Make both eligible\n        item_empty = create_item(project=project_empty_filter)\n        asset_empty = create_asset(item=item_empty)\n        item_none = create_item(project=project_none_filter)\n        asset_none = create_asset(item=item_none)\n\n        # no sublevel filter, so neither link should have transcription_status\n        resp1 = self.client.get(reverse(\"topic-detail\", args=(topic.slug,)))\n        self.assertEqual(resp1.status_code, 200)\n        self.assertNotContains(\n            resp1,\n            f\"/campaigns/{campaign.slug}/{project_empty_filter.slug}/?transcription_status=\",\n        )\n        self.assertNotContains(\n            resp1,\n            f\"/campaigns/{campaign.slug}/{project_none_filter.slug}/?transcription_status=\",\n        )\n\n        # Set at least one asset to SUBMITTED for each project (so they’re not excluded)\n        asset_empty.transcription_status = TranscriptionStatus.SUBMITTED\n        asset_empty.save(update_fields=[\"transcription_status\"])\n\n        asset_none.transcription_status = TranscriptionStatus.SUBMITTED\n        asset_none.save(update_fields=[\"transcription_status\"])\n\n        # valid sublevel filter, so both included and use that status in links\n        resp2 = self.client.get(\n            reverse(\"topic-detail\", args=(topic.slug,)),\n            {\"transcription_status\": \"submitted\"},\n        )\n        self.assertEqual(resp2.status_code, 200)\n        self.assertContains(\n            resp2,\n            f\"/campaigns/{campaign.slug}/{project_empty_filter.slug}/?transcription_status=submitted\",\n            2,\n        )\n        self.assertContains(\n            resp2,\n            f\"/campaigns/{campaign.slug}/{project_none_filter.slug}/?transcription_status=submitted\",\n            2,\n        )\n"
  },
  {
    "path": "concordia/tests/test_views_transcription_review.py",
    "content": "from django.core.cache import caches\nfrom django.db.models.signals import post_save\nfrom django.test import (\n    TransactionTestCase,\n    override_settings,\n)\nfrom django.urls import reverse\nfrom django.utils.timezone import now\n\nfrom concordia.models import (\n    Asset,\n    Transcription,\n    TranscriptionStatus,\n)\nfrom concordia.signals.handlers import on_transcription_save\nfrom concordia.utils import get_anonymous_user\nfrom configuration.models import Configuration\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n    create_transcription,\n)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False, SESSION_ENGINE=\"django.contrib.sessions.backends.cache\"\n)\nclass ReviewTranscriptionViewTests(\n    CreateTestUsers, JSONAssertMixin, TransactionTestCase\n):\n    def test_transcription_review(self):\n        asset = create_asset()\n\n        anon = get_anonymous_user()\n\n        t1 = Transcription(asset=asset, user=anon, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        self.login_user()\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"foobar\"}\n        )\n        data = self.assertValidJSON(resp, expected_status=400)\n        self.assertIn(\"error\", data)\n\n        self.assertEqual(\n            1, Transcription.objects.filter(pk=t1.pk, accepted__isnull=True).count()\n        )\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"accept\"}\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n\n        self.assertEqual(\n            1, Transcription.objects.filter(pk=t1.pk, accepted__isnull=False).count()\n        )\n\n    def test_transcription_review_rate_limit(self):\n        for cache in caches.all():\n            cache.clear()\n        anon = get_anonymous_user()\n        self.login_user()\n        try:\n            config = Configuration.objects.get(key=\"review_rate_limit\")\n            config.value = \"4\"\n            config.data_type = Configuration.DataType.NUMBER\n            config.save()\n        except Configuration.DoesNotExist:\n            Configuration.objects.create(\n                key=\"review_rate_limit\",\n                value=\"4\",\n                data_type=Configuration.DataType.NUMBER,\n            )\n\n        Configuration.objects.get_or_create(\n            key=\"review_rate_limit_popup_message\",\n            defaults={\n                \"value\": \"Test message\",\n                \"data_type\": Configuration.DataType.HTML,\n            },\n        )\n        Configuration.objects.get_or_create(\n            key=\"review_rate_limit_popup_title\",\n            defaults={\n                \"value\": \"Test message\",\n                \"data_type\": Configuration.DataType.HTML,\n            },\n        )\n        Configuration.objects.get_or_create(\n            key=\"review_rate_limit_banner_message\",\n            defaults={\n                \"value\": \"Test message\",\n                \"data_type\": Configuration.DataType.HTML,\n            },\n        )\n\n        asset = create_asset()\n        t1 = create_transcription(user=anon, asset=asset)\n        t2 = create_transcription(\n            user=anon, asset=create_asset(item=asset.item, slug=\"test-asset-2\")\n        )\n        t3 = create_transcription(\n            user=anon, asset=create_asset(item=asset.item, slug=\"test-asset-3\")\n        )\n        t4 = create_transcription(\n            user=anon, asset=create_asset(item=asset.item, slug=\"test-asset-4\")\n        )\n        t5 = create_transcription(\n            user=anon, asset=create_asset(item=asset.item, slug=\"test-asset-5\")\n        )\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t2.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t3.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t4.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t5.pk,)), data={\"action\": \"accept\"}\n        )\n        data = self.assertValidJSON(resp, expected_status=429)\n        self.assertIn(\"error\", data)\n\n    def test_transcription_review_rate_limit_superuser(self):\n        for cache in caches.all():\n            cache.clear()\n        anon = get_anonymous_user()\n        self.user = self.create_super_user()\n        self.login_user()\n        try:\n            config = Configuration.objects.get(key=\"review_rate_limit\")\n            config.value = \"4\"\n            config.data_type = Configuration.DataType.NUMBER\n            config.save()\n        except Configuration.DoesNotExist:\n            Configuration.objects.create(\n                key=\"review_rate_limit\",\n                value=\"4\",\n                data_type=Configuration.DataType.NUMBER,\n            )\n\n        Configuration.objects.get_or_create(\n            key=\"review_rate_limit_popup_message\",\n            defaults={\n                \"value\": \"Test message\",\n                \"data_type\": Configuration.DataType.HTML,\n            },\n        )\n        Configuration.objects.get_or_create(\n            key=\"review_rate_limit_popup_title\",\n            defaults={\n                \"value\": \"Test message\",\n                \"data_type\": Configuration.DataType.HTML,\n            },\n        )\n        Configuration.objects.get_or_create(\n            key=\"review_rate_limit_banner_message\",\n            defaults={\n                \"value\": \"Test message\",\n                \"data_type\": Configuration.DataType.HTML,\n            },\n        )\n\n        asset = create_asset()\n        t1 = create_transcription(user=anon, asset=asset)\n        t2 = create_transcription(\n            user=anon, asset=create_asset(item=asset.item, slug=\"test-asset-2\")\n        )\n        t3 = create_transcription(\n            user=anon, asset=create_asset(item=asset.item, slug=\"test-asset-3\")\n        )\n        t4 = create_transcription(\n            user=anon, asset=create_asset(item=asset.item, slug=\"test-asset-4\")\n        )\n        t5 = create_transcription(\n            user=anon, asset=create_asset(item=asset.item, slug=\"test-asset-5\")\n        )\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t2.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t3.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t4.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t5.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n    def test_transcription_review_asset_status_updates(self):\n        \"\"\"\n        Confirm that the Asset.transcription_status field is correctly updated\n        throughout the review process\n        \"\"\"\n        asset = create_asset()\n\n        anon = get_anonymous_user()\n\n        # We should see NOT_STARTED only when no transcription records exist:\n        self.assertEqual(asset.transcription_set.count(), 0)\n        self.assertEqual(\n            Asset.objects.get(pk=asset.pk).transcription_status,\n            TranscriptionStatus.NOT_STARTED,\n        )\n\n        t1 = Transcription(asset=asset, user=anon, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        self.assertEqual(\n            Asset.objects.get(pk=asset.pk).transcription_status,\n            TranscriptionStatus.SUBMITTED,\n        )\n\n        # “Login” so we can review the anonymous transcription:\n        self.login_user()\n\n        self.assertEqual(\n            1, Transcription.objects.filter(pk=t1.pk, accepted__isnull=True).count()\n        )\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"reject\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        # After rejecting a transcription, the asset status should be reset to\n        # in-progress:\n        self.assertEqual(\n            1,\n            Transcription.objects.filter(\n                pk=t1.pk, accepted__isnull=True, rejected__isnull=False\n            ).count(),\n        )\n        self.assertEqual(\n            Asset.objects.get(pk=asset.pk).transcription_status,\n            TranscriptionStatus.IN_PROGRESS,\n        )\n\n        # We'll simulate a second attempt:\n\n        t2 = Transcription(\n            asset=asset, user=anon, text=\"test\", submitted=now(), supersedes=t1\n        )\n        t2.full_clean()\n        t2.save()\n\n        self.assertEqual(\n            Asset.objects.get(pk=asset.pk).transcription_status,\n            TranscriptionStatus.SUBMITTED,\n        )\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t2.pk,)), data={\"action\": \"accept\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n\n        self.assertEqual(\n            1, Transcription.objects.filter(pk=t2.pk, accepted__isnull=False).count()\n        )\n        self.assertEqual(\n            Asset.objects.get(pk=asset.pk).transcription_status,\n            TranscriptionStatus.COMPLETED,\n        )\n\n    def test_transcription_disallow_self_review(self):\n        asset = create_asset()\n\n        self.login_user()\n\n        t1 = Transcription(asset=asset, user=self.user, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"accept\"}\n        )\n        data = self.assertValidJSON(resp, expected_status=400)\n        self.assertIn(\"error\", data)\n        self.assertEqual(\"You cannot accept your own transcription\", data[\"error\"])\n\n    def test_transcription_allow_self_reject(self):\n        asset = create_asset()\n\n        self.login_user()\n\n        t1 = Transcription(asset=asset, user=self.user, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"reject\"}\n        )\n        self.assertValidJSON(resp, expected_status=200)\n        self.assertEqual(\n            Asset.objects.get(pk=asset.pk).transcription_status,\n            TranscriptionStatus.IN_PROGRESS,\n        )\n        self.assertEqual(Transcription.objects.get(pk=t1.pk).reviewed_by, self.user)\n\n    def test_transcription_double_review(self):\n        asset = create_asset()\n\n        anon = get_anonymous_user()\n\n        t1 = Transcription(asset=asset, user=anon, text=\"test\", submitted=now())\n        t1.full_clean()\n        t1.save()\n\n        self.login_user()\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"accept\"}\n        )\n        data = self.assertValidJSON(resp, expected_status=200)\n\n        resp = self.client.post(\n            reverse(\"review-transcription\", args=(t1.pk,)), data={\"action\": \"reject\"}\n        )\n        data = self.assertValidJSON(resp, expected_status=400)\n        self.assertIn(\"error\", data)\n        self.assertEqual(\"This transcription has already been reviewed\", data[\"error\"])\n\n    def tearDown(self):\n        # We'll test the signal handler separately\n        post_save.connect(on_transcription_save, sender=Transcription)\n"
  },
  {
    "path": "concordia/tests/test_views_transcription_save.py",
    "content": "import sys\nfrom unittest.mock import patch\n\nfrom django import forms\nfrom django.db.models.signals import post_save\nfrom django.test import (\n    TransactionTestCase,\n    override_settings,\n)\nfrom django.urls import reverse\n\nfrom concordia.models import (\n    Transcription,\n)\nfrom concordia.signals.handlers import on_transcription_save\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False, SESSION_ENGINE=\"django.contrib.sessions.backends.cache\"\n)\nclass SaveTranscriptionViewTests(CreateTestUsers, JSONAssertMixin, TransactionTestCase):\n    def setUp(self):\n        self.asset = create_asset()\n\n    def test_turnstile_validation_fails(self):\n        # Test when Turnstile validation failes\n        with patch(\"concordia.turnstile.fields.TurnstileField.validate\") as mock:\n            mock.side_effect = forms.ValidationError(\n                \"Testing error\", code=\"invalid_turnstile\"\n            )\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n            data = self.assertValidJSON(resp, expected_status=401)\n            self.assertIn(\"error\", data)\n\n    def test_initial_save_success(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n        data = self.assertValidJSON(resp, expected_status=201)\n        self.assertIn(\"submissionUrl\", data)\n\n    def test_duplicate_without_supersedes_conflict(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n            # Test attempts to create a second transcription without marking that it\n            # supersedes the previous one:\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n        data = self.assertValidJSON(resp, expected_status=409)\n        self.assertIn(\"error\", data)\n\n    def test_save_with_url_error(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n            # If a transcription contains a URL, it should return an error\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\n                    \"text\": \"http://example.com\",\n                    \"supersedes\": self.asset.transcription_set.get().pk,\n                },\n            )\n        data = self.assertValidJSON(resp, expected_status=400)\n        self.assertIn(\"error\", data)\n\n    def test_unacceptable_characters_are_removed_on_save(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            bad_text = \"He\\u200bllo\\tWorld\\xa0\\u3000\\u2003!\\nBad\\x00Char\\x1fHere\\u200b\"\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": bad_text},\n            )\n        data = self.assertValidJSON(resp, expected_status=201)\n        self.assertIn(\"submissionUrl\", data)\n        t = self.asset.transcription_set.get()\n        self.assertEqual(t.text, \"Hello\\tWorld\\xa0\\u3000\\u2003!\\nBadCharHere\")\n\n    def test_unacceptable_characters_are_removed_when_superseding(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"first\"},\n            )\n            bad_text = \"b\\u200bad\\x00\"\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\n                    \"text\": bad_text,\n                    \"supersedes\": self.asset.transcription_set.get().pk,\n                },\n            )\n        data = self.assertValidJSON(resp, expected_status=201)\n        self.assertIn(\"submissionUrl\", data)\n        new_t = self.asset.transcription_set.order_by(\"pk\").last()\n        self.assertEqual(new_t.text, \"bad\")\n\n    def test_save_with_supersedes_success(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n            # Test that it correctly works when supersedes is set\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\n                    \"text\": \"test\",\n                    \"supersedes\": self.asset.transcription_set.get().pk,\n                },\n            )\n        data = self.assertValidJSON(resp, expected_status=201)\n        self.assertIn(\"submissionUrl\", data)\n\n    def test_supersedes_sets_ocr_originated_when_previous_was_ocr_originated(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n            # Test that it correctly works when supersedes is set and confirm\n            # ocr_originaed is properly set\n            transcription = self.asset.transcription_set.order_by(\"pk\").last()\n            transcription.ocr_originated = True\n            transcription.save()\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\n                    \"text\": \"test\",\n                    \"supersedes\": self.asset.transcription_set.order_by(\"pk\").last().pk,\n                },\n            )\n        data = self.assertValidJSON(resp, expected_status=201)\n        self.assertIn(\"submissionUrl\", data)\n        new_transcription = self.asset.transcription_set.order_by(\"pk\").last()\n        self.assertTrue(new_transcription.ocr_originated)\n\n    def test_supersede_already_superseded_conflict(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            first_resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n            self.assertValidJSON(first_resp, expected_status=201)\n            first_pk = self.asset.transcription_set.order_by(\"pk\").first().pk\n\n            self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test 2\", \"supersedes\": first_pk},\n            )\n\n            # We should see an error if you attempt to supersede a transcription\n            # which has already been superseded:\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\n                    \"text\": \"test\",\n                    \"supersedes\": self.asset.transcription_set.order_by(\"pk\")\n                    .first()\n                    .pk,\n                },\n            )\n        data = self.assertValidJSON(resp, expected_status=409)\n        self.assertIn(\"error\", data)\n\n    def test_supersede_nonexistent_returns_400(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            # We should get an error if you attempt to supersede a transcription\n            # that doesn't exist\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\n                    \"text\": \"test\",\n                    \"supersedes\": sys.maxsize,\n                },\n            )\n        data = self.assertValidJSON(resp, expected_status=400)\n        self.assertIn(\"error\", data)\n\n    def test_supersede_invalid_pk_returns_400(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            # We should get an error if you attempt to supersede with\n            # with a pk that is invalid (i.e., a string instead of int)\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\n                    \"text\": \"test\",\n                    \"supersedes\": \"bad-pk\",\n                },\n            )\n        data = self.assertValidJSON(resp, expected_status=400)\n        self.assertIn(\"error\", data)\n\n    def test_logged_in_user_can_take_over_from_anonymous(self):\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            anon_resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\"text\": \"test\"},\n            )\n            self.assertValidJSON(anon_resp, expected_status=201)\n\n            # A logged in user can take over from an anonymous user:\n            self.login_user()\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(self.asset.pk,)),\n                data={\n                    \"text\": \"test\",\n                    \"supersedes\": self.asset.transcription_set.order_by(\"pk\").last().pk,\n                },\n            )\n        data = self.assertValidJSON(resp, expected_status=201)\n        self.assertIn(\"submissionUrl\", data)\n\n    def tearDown(self):\n        # We'll test the signal handler separately\n        post_save.connect(on_transcription_save, sender=Transcription)\n"
  },
  {
    "path": "concordia/tests/test_views_transcription_submit.py",
    "content": "from unittest.mock import patch\n\nfrom django import forms\nfrom django.db.models.signals import post_save\nfrom django.test import (\n    TransactionTestCase,\n    override_settings,\n)\nfrom django.urls import reverse\n\nfrom concordia.models import (\n    Transcription,\n)\nfrom concordia.signals.handlers import on_transcription_save\nfrom concordia.utils import get_anonymous_user\n\nfrom .utils import (\n    CreateTestUsers,\n    JSONAssertMixin,\n    create_asset,\n)\n\n\n@override_settings(\n    RATELIMIT_ENABLE=False, SESSION_ENGINE=\"django.contrib.sessions.backends.cache\"\n)\nclass SubmitTranscriptionViewTests(\n    CreateTestUsers, JSONAssertMixin, TransactionTestCase\n):\n    def test_anonymous_transcription_submission(self):\n        asset = create_asset()\n        anon = get_anonymous_user()\n\n        transcription = Transcription(asset=asset, user=anon, text=\"previous entry\")\n        transcription.full_clean()\n        transcription.save()\n\n        with patch(\"concordia.turnstile.fields.TurnstileField.validate\") as mock:\n            mock.side_effect = forms.ValidationError(\n                \"Testing error\", code=\"invalid_turnstile\"\n            )\n            resp = self.client.post(\n                reverse(\"submit-transcription\", args=(transcription.pk,))\n            )\n        data = self.assertValidJSON(resp, expected_status=401)\n        self.assertIn(\"error\", data)\n\n        self.assertFalse(Transcription.objects.filter(submitted__isnull=False).exists())\n\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            self.client.post(\n                reverse(\"submit-transcription\", args=(transcription.pk,)),\n            )\n            self.assertTrue(\n                Transcription.objects.filter(submitted__isnull=False).exists()\n            )\n\n    def test_transcription_submission(self):\n        asset = create_asset()\n\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            resp = self.client.post(\n                reverse(\"save-transcription\", args=(asset.pk,)), data={\"text\": \"test\"}\n            )\n        data = self.assertValidJSON(resp, expected_status=201)\n\n        transcription = Transcription.objects.get()\n        self.assertIsNone(transcription.submitted)\n\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            resp = self.client.post(\n                reverse(\"submit-transcription\", args=(transcription.pk,))\n            )\n        data = self.assertValidJSON(resp, expected_status=200)\n        self.assertIn(\"id\", data)\n        self.assertEqual(data[\"id\"], transcription.pk)\n\n        transcription = Transcription.objects.get()\n        self.assertTrue(transcription.submitted)\n\n    def test_stale_transcription_submission(self):\n        asset = create_asset()\n\n        anon = get_anonymous_user()\n\n        t1 = Transcription(asset=asset, user=anon, text=\"test\")\n        t1.full_clean()\n        t1.save()\n\n        t2 = Transcription(asset=asset, user=anon, text=\"test\", supersedes=t1)\n        t2.full_clean()\n        t2.save()\n\n        with patch(\n            \"concordia.turnstile.fields.TurnstileField.validate\", return_value=True\n        ):\n            resp = self.client.post(reverse(\"submit-transcription\", args=(t1.pk,)))\n            data = self.assertValidJSON(resp, expected_status=400)\n            self.assertIn(\"error\", data)\n\n    def tearDown(self):\n        # We'll test the signal handler separately\n        post_save.connect(on_transcription_save, sender=Transcription)\n"
  },
  {
    "path": "concordia/tests/test_views_utils.py",
    "content": "import datetime\nfrom time import time\n\nfrom django.contrib.auth.models import AnonymousUser\nfrom django.test import RequestFactory, TestCase, override_settings\nfrom django.utils.timezone import make_aware, now\n\nfrom concordia.models import (\n    Asset,\n    Transcription,\n    TranscriptionStatus,\n)\nfrom concordia.views.utils import (\n    AnonymousUserValidationCheckMixin,\n    _get_pages,\n    annotate_children_with_progress_stats,\n    calculate_asset_stats,\n)\n\nfrom .utils import (\n    CreateTestUsers,\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n    create_transcription,\n)\n\n\nclass GetPagesTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.factory = RequestFactory()\n        self.user = self.create_test_user()\n\n        # Base campaign, project, item setup\n        self.campaign = create_campaign(slug=\"gp-camp\", title=\"gp-camp\")\n        self.project = create_project(\n            campaign=self.campaign, slug=\"gp-proj\", title=\"gp-proj\"\n        )\n        self.item = create_item(project=self.project, item_id=\"gp-item\")\n\n        # Two assets in the same item\n        self.asset1 = create_asset(item=self.item, slug=\"gp-a1\", sequence=1)\n        self.asset2 = create_asset(item=self.item, slug=\"gp-a2\", sequence=2)\n\n        # Another campaign and project for campaign filtering tests\n        self.campaign2 = create_campaign(slug=\"gp-camp-2\", title=\"gp-camp-2\")\n        self.project2 = create_project(\n            campaign=self.campaign2, slug=\"gp-proj-2\", title=\"gp-proj-2\"\n        )\n        self.item2 = create_item(project=self.project2, item_id=\"gp-item-2\")\n        self.asset3_other_campaign = create_asset(\n            item=self.item2, slug=\"gp-a3\", sequence=1\n        )\n\n    def _request(self, params: dict[str, str]):\n        request = self.factory.get(\"/dummy\", data=params)\n        request.user = self.user\n        return request\n\n    def _touch_transcription_times(\n        self,\n        transcription: Transcription,\n        *,\n        created_on=None,\n        updated_on=None,\n        reviewer=None,\n    ):\n        if reviewer is not None:\n            transcription.reviewed_by = reviewer\n        if created_on is not None:\n            transcription.created_on = created_on\n        if updated_on is not None:\n            transcription.updated_on = updated_on\n        transcription.save(update_fields=[\"reviewed_by\", \"created_on\", \"updated_on\"])\n\n    def test_activity_filters_transcribed_vs_reviewed_vs_default(self):\n        now_reference = now()\n\n        # asset1: transcribed by self.user\n        transcription1 = create_transcription(asset=self.asset1, user=self.user)\n        self._touch_transcription_times(\n            transcription1, created_on=now_reference - datetime.timedelta(hours=2)\n        )\n\n        # asset2: reviewed by self.user\n        transcription2 = create_transcription(\n            asset=self.asset2, user=self.create_test_user(\"transcriber\")\n        )\n        self._touch_transcription_times(\n            transcription2,\n            reviewer=self.user,\n            updated_on=now_reference - datetime.timedelta(hours=1),\n        )\n\n        # Default behavior includes both\n        queryset_default = _get_pages(self._request({}))\n        self.assertCountEqual(\n            list(queryset_default.values_list(\"id\", flat=True)),\n            [self.asset1.id, self.asset2.id],\n        )\n\n        # Transcribed only\n        queryset_transcribed = _get_pages(self._request({\"activity\": \"transcribed\"}))\n        self.assertListEqual(\n            list(queryset_transcribed.values_list(\"id\", flat=True)),\n            [self.asset1.id],\n        )\n\n        # Reviewed only\n        queryset_reviewed = _get_pages(self._request({\"activity\": \"reviewed\"}))\n        self.assertListEqual(\n            list(queryset_reviewed.values_list(\"id\", flat=True)),\n            [self.asset2.id],\n        )\n\n    def test_status_filter_exclusions(self):\n        # Ensure the user is associated with each asset via transcriptions\n        create_transcription(asset=self.asset1, user=self.user)\n        create_transcription(asset=self.asset2, user=self.user, submitted=now())\n\n        # Mark asset1 as IN_PROGRESS explicitly\n        Asset.objects.filter(pk=self.asset1.pk).update(\n            transcription_status=TranscriptionStatus.IN_PROGRESS\n        )\n\n        # Also add an asset in COMPLETED with a user transcription\n        completed_asset = create_asset(item=self.item, slug=\"gp-a4\", sequence=3)\n        create_transcription(asset=completed_asset, user=self.user)\n        Asset.objects.filter(pk=completed_asset.pk).update(\n            transcription_status=TranscriptionStatus.COMPLETED\n        )\n\n        # Only \"submitted\" requested, so exclude IN_PROGRESS and COMPLETED\n        queryset = _get_pages(self._request({\"status\": \"submitted\"}))\n        self.assertListEqual(\n            list(queryset.values_list(\"id\", flat=True)), [self.asset2.id]\n        )\n\n    def test_date_range_and_single_day_filters_and_ordering(self):\n        # Transcriptions (associate user) with distinct activity dates\n        today = now()\n        day_minus_3 = make_aware(\n            datetime.datetime.combine(\n                (today - datetime.timedelta(days=3)).date(), datetime.time(12)\n            )\n        )\n        day_minus_1 = make_aware(\n            datetime.datetime.combine(\n                (today - datetime.timedelta(days=1)).date(), datetime.time(12)\n            )\n        )\n\n        transcription1 = create_transcription(asset=self.asset1, user=self.user)\n        self._touch_transcription_times(\n            transcription1, created_on=day_minus_3, updated_on=day_minus_3\n        )\n\n        transcription2 = create_transcription(asset=self.asset2, user=self.user)\n        self._touch_transcription_times(\n            transcription2, created_on=day_minus_1, updated_on=day_minus_1\n        )\n\n        # The range filter from two days ago through today should include\n        # asset2 (day minus one) and exclude asset1 (day minus three)\n        start = (today - datetime.timedelta(days=2)).strftime(\"%Y-%m-%d\")\n        end = today.strftime(\"%Y-%m-%d\")\n        queryset_range = _get_pages(self._request({\"start\": start, \"end\": end}))\n        self.assertListEqual(\n            list(queryset_range.values_list(\"id\", flat=True)), [self.asset2.id]\n        )\n\n        # A single-day filter for day minus three picks asset1\n        only_day = (today - datetime.timedelta(days=3)).strftime(\"%Y-%m-%d\")\n        queryset_single = _get_pages(self._request({\"start\": only_day}))\n        self.assertListEqual(\n            list(queryset_single.values_list(\"id\", flat=True)), [self.asset1.id]\n        )\n\n        # Ordering: ascending vs default (descending)\n        queryset_ascending = _get_pages(self._request({\"order_by\": \"date-ascending\"}))\n        self.assertEqual(\n            list(queryset_ascending.values_list(\"id\", flat=True)),\n            [self.asset1.id, self.asset2.id],\n        )\n\n        queryset_descending = _get_pages(self._request({}))\n        self.assertEqual(\n            list(queryset_descending.values_list(\"id\", flat=True)),\n            [self.asset2.id, self.asset1.id],\n        )\n\n    def test_campaign_filter_and_six_month_cutoff(self):\n        # Link user to assets in both campaigns\n        recent_timestamp = now() - datetime.timedelta(days=5)\n        old_timestamp = now() - datetime.timedelta(days=6 * 30 + 10)\n\n        # Asset in base campaign (recent)\n        transcription1 = create_transcription(asset=self.asset1, user=self.user)\n        self._touch_transcription_times(\n            transcription1, created_on=recent_timestamp, updated_on=recent_timestamp\n        )\n\n        # Asset in other campaign (recent)\n        transcription2 = create_transcription(\n            asset=self.asset3_other_campaign, user=self.user\n        )\n        self._touch_transcription_times(\n            transcription2, created_on=recent_timestamp, updated_on=recent_timestamp\n        )\n\n        # Very old activity on asset2 so it should be filtered out by the\n        # six months cutoff\n        transcription_old = create_transcription(asset=self.asset2, user=self.user)\n        self._touch_transcription_times(\n            transcription_old, created_on=old_timestamp, updated_on=old_timestamp\n        )\n\n        # Without a campaign filter, both recent assets are present\n        # and the old one is excluded\n        queryset = _get_pages(self._request({}))\n        asset_ids = set(queryset.values_list(\"id\", flat=True))\n        self.assertSetEqual(asset_ids, {self.asset1.id, self.asset3_other_campaign.id})\n\n        # The campaign filter picks only the other campaign's asset\n        queryset_campaign2 = _get_pages(\n            self._request({\"campaign\": str(self.campaign2.pk)})\n        )\n        self.assertListEqual(\n            list(queryset_campaign2.values_list(\"id\", flat=True)),\n            [self.asset3_other_campaign.id],\n        )\n\n    def test_status_filter_includes_completed_when_requested(self):\n        \"\"\"\n        When \"completed\" is requested, completed assets are kept while\n        submitted and in progress assets are excluded.\n        \"\"\"\n        # Prepare three assets that all have activity from this user.\n        completed_asset = create_asset(\n            item=self.item, slug=\"gp-a4-completed\", sequence=4\n        )\n        create_transcription(asset=completed_asset, user=self.user)\n        Asset.objects.filter(pk=completed_asset.pk).update(\n            transcription_status=TranscriptionStatus.COMPLETED\n        )\n\n        create_transcription(asset=self.asset1, user=self.user)\n        Asset.objects.filter(pk=self.asset1.pk).update(\n            transcription_status=TranscriptionStatus.IN_PROGRESS\n        )\n\n        create_transcription(\n            asset=self.asset2, user=self.user, submitted=now()\n        )  # submitted\n\n        # Request only \"completed\" status.\n        queryset = _get_pages(self._request({\"status\": \"completed\"}))\n        self.assertListEqual(\n            list(queryset.values_list(\"id\", flat=True)), [completed_asset.id]\n        )\n\n    def test_status_filter_includes_in_progress_and_excludes_submitted_not_requested(\n        self,\n    ):\n        \"\"\"\n        When \"in_progress\" is requested, in progress assets are kept and\n        submitted assets are excluded because \"submitted\" is not requested.\n        \"\"\"\n        # Prepare one in progress and one submitted asset with this user's activity.\n        create_transcription(asset=self.asset1, user=self.user)\n        Asset.objects.filter(pk=self.asset1.pk).update(\n            transcription_status=TranscriptionStatus.IN_PROGRESS\n        )\n\n        create_transcription(\n            asset=self.asset2, user=self.user, submitted=now()\n        )  # submitted\n\n        # Request only \"in_progress\" status.\n        queryset = _get_pages(self._request({\"status\": \"in_progress\"}))\n        ids = list(queryset.values_list(\"id\", flat=True))\n        self.assertIn(self.asset1.id, ids)\n        self.assertNotIn(self.asset2.id, ids)\n\n\nclass CalculateAssetStatsTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.user = self.create_test_user()\n        self.campaign = create_campaign(slug=\"cas-c\", title=\"cas-c\")\n        self.project = create_project(\n            campaign=self.campaign, slug=\"cas-p\", title=\"cas-p\"\n        )\n        self.item = create_item(project=self.project, item_id=\"cas-i\")\n\n    def test_counts_percents_and_contributors_remove_none_branch(self):\n        # Build a small asset set with varied statuses.\n        asset_not_started = create_asset(item=self.item, slug=\"cas-ns\", sequence=1)\n        asset_in_progress = create_asset(item=self.item, slug=\"cas-ip\", sequence=2)\n        asset_submitted = create_asset(item=self.item, slug=\"cas-sub\", sequence=3)\n\n        # Set desired statuses directly.\n        Asset.objects.filter(pk=asset_not_started.pk).update(\n            transcription_status=TranscriptionStatus.NOT_STARTED\n        )\n        Asset.objects.filter(pk=asset_in_progress.pk).update(\n            transcription_status=TranscriptionStatus.IN_PROGRESS\n        )\n        Asset.objects.filter(pk=asset_submitted.pk).update(\n            transcription_status=TranscriptionStatus.SUBMITTED\n        )\n\n        # Create transcriptions ONLY for the assets that should not remain NOT_STARTED.\n        # For IN_PROGRESS, a plain transcription moves or keeps the asset in progress.\n        transcription_in_progress = create_transcription(\n            asset=asset_in_progress, user=self.user\n        )\n        # For SUBMITTED, mark the transcription as submitted so the\n        # signal preserves SUBMITTED.\n        transcription_submitted = create_transcription(\n            asset=asset_submitted, user=self.user, submitted=now()\n        )\n        # Ensure there is at least one None in reviewed_by so the remove(None)\n        # path is exercised.\n        Transcription.objects.filter(\n            pk__in=[transcription_in_progress.pk, transcription_submitted.pk]\n        ).update(reviewed_by=None)\n\n        context = {}\n        calculate_asset_stats(\n            Asset.objects.filter(\n                pk__in=[asset_not_started.pk, asset_in_progress.pk, asset_submitted.pk]\n            ),\n            context,\n        )\n\n        # contributor_count counts unique user_ids and reviewed_by values, minus None.\n        self.assertEqual(context[\"contributor_count\"], 1)\n\n        # Counts per status.\n        self.assertEqual(context[\"not_started_count\"], 1)\n        self.assertEqual(context[\"in_progress_count\"], 1)\n        self.assertEqual(context[\"submitted_count\"], 1)\n        # COMPLETED not present.\n        self.assertEqual(context.get(\"completed_count\", 0), 0)\n\n        # Percentages should round sensibly for 1 out of 3.\n        self.assertEqual(context[\"not_started_percent\"], round(100 * (1 / 3)))\n        self.assertEqual(context[\"in_progress_percent\"], round(100 * (1 / 3)))\n        self.assertEqual(context[\"submitted_percent\"], round(100 * (1 / 3)))\n\n        # Labeled list populated and includes \"not_started\".\n        self.assertTrue(\n            any(\n                status_key == \"not_started\"\n                for status_key, _, _ in context[\"transcription_status_counts\"]\n            )\n        )\n\n    def test_contributors_keyerror_branch_and_cap_99(self):\n        # Create 100 assets and set 99 to NOT_STARTED and 1 to IN_PROGRESS.\n        assets = []\n        for i in range(1, 101):\n            a = create_asset(item=self.item, slug=f\"cas-bulk-{i}\", sequence=i)\n            assets.append(a)\n\n        Asset.objects.filter(pk__in=[a.pk for a in assets[:99]]).update(\n            transcription_status=TranscriptionStatus.NOT_STARTED\n        )\n        Asset.objects.filter(pk=assets[-1].pk).update(\n            transcription_status=TranscriptionStatus.IN_PROGRESS\n        )\n\n        # Create a transcription ONLY for the single IN_PROGRESS asset\n        # and set a reviewer. This ensures there is no None in reviewed_by,\n        # which triggers the KeyError branch when calculate_asset_stats\n        # attempts to remove(None) from the contributor set.\n        other_user = self.create_test_user(username=\"cas-reviewer\")\n        transcription = create_transcription(asset=assets[-1], user=self.user)\n        transcription.reviewed_by = other_user\n        transcription.save(update_fields=[\"reviewed_by\"])\n\n        context = {}\n        calculate_asset_stats(\n            Asset.objects.filter(pk__in=[a.pk for a in assets]), context\n        )\n\n        # Two distinct contributors: the creator (self.user) and\n        # the reviewer (other_user).\n        self.assertEqual(context[\"contributor_count\"], 2)\n\n        # Verify percentages and that the 99 percent capping behavior is applied.\n        self.assertEqual(context[\"not_started_percent\"], 99)\n        self.assertEqual(context[\"in_progress_percent\"], 1)\n        self.assertEqual(context.get(\"submitted_percent\", 0), 0)\n        self.assertEqual(context.get(\"completed_percent\", 0), 0)\n\n        # Also verify counts to ensure the underlying distribution is as intended.\n        self.assertEqual(context[\"not_started_count\"], 99)\n        self.assertEqual(context[\"in_progress_count\"], 1)\n        self.assertEqual(context.get(\"submitted_count\", 0), 0)\n        self.assertEqual(context.get(\"completed_count\", 0), 0)\n\n\nclass AnnotateChildrenProgressStatsTests(TestCase):\n    class Obj:\n        pass\n\n    def test_progress_stats_with_capping_and_lowest_status(self):\n        obj = self.Obj()\n        # Construct counts such that one bucket yields at least ninety nine\n        # but less than one hundred percent\n        obj.not_started_count = 99\n        obj.in_progress_count = 1\n        obj.submitted_count = 0\n        obj.completed_count = 0\n\n        annotate_children_with_progress_stats([obj])\n\n        # Total\n        self.assertEqual(obj.total_count, 100)\n        # Capping at ninety nine\n        self.assertEqual(obj.not_started_percent, 99)\n        # Others\n        self.assertEqual(obj.in_progress_percent, 1)\n        self.assertEqual(obj.submitted_percent, 0)\n        self.assertEqual(obj.completed_percent, 0)\n        # Lowest is the first non-zero by CHOICES order; expect \"not_started\"\n        self.assertEqual(obj.lowest_transcription_status, \"not_started\")\n\n    def test_progress_stats_zero_total(self):\n        obj = self.Obj()\n        obj.not_started_count = 0\n        obj.in_progress_count = 0\n        obj.submitted_count = 0\n        obj.completed_count = 0\n\n        annotate_children_with_progress_stats([obj])\n\n        self.assertEqual(obj.total_count, 0)\n        self.assertEqual(obj.not_started_percent, 0)\n        self.assertEqual(obj.in_progress_percent, 0)\n        self.assertEqual(obj.submitted_percent, 0)\n        self.assertEqual(obj.completed_percent, 0)\n        self.assertIsNone(obj.lowest_transcription_status)\n\n\nclass _BaseView:\n    \"\"\"\n    Minimal base class that provides get_context_data so the mixin can call super().\n    \"\"\"\n\n    def get_context_data(self, **kwargs):\n        return {}\n\n\nclass DummyTemplateView(AnonymousUserValidationCheckMixin, _BaseView):\n    \"\"\"\n    Stand-in view. The mixin is first in the MRO so its get_context_data runs,\n    then it calls super() which resolves to _BaseView.get_context_data.\n    \"\"\"\n\n    pass\n\n\nclass AnonymousUserValidationCheckMixinTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.factory = RequestFactory()\n        self.user = self.create_test_user()\n\n    def _attach_session(self, request):\n        # Attach a session dictionary-like attribute without middleware dependency\n        request.session = {}\n        return request\n\n    @override_settings(ANONYMOUS_USER_VALIDATION_INTERVAL=10)\n    def test_unauthenticated_requires_validation_when_stale(self):\n        request = self.factory.get(\"/dummy\")\n        request.user = AnonymousUser()\n        self._attach_session(request)\n        # There is no prior validation so the default timestamp is zero\n        # and the validation is stale\n        view = DummyTemplateView()\n        view.request = request\n        context = view.get_context_data()\n        self.assertTrue(context[\"anonymous_user_validation_required\"])\n\n    @override_settings(ANONYMOUS_USER_VALIDATION_INTERVAL=10)\n    def test_unauthenticated_recent_validation_is_not_required(self):\n        request = self.factory.get(\"/dummy\")\n        request.user = AnonymousUser()\n        self._attach_session(request)\n        request.session[\"turnstile_last_validated\"] = int(time())\n        view = DummyTemplateView()\n        view.request = request\n        context = view.get_context_data()\n        self.assertFalse(context[\"anonymous_user_validation_required\"])\n\n    @override_settings(ANONYMOUS_USER_VALIDATION_INTERVAL=10)\n    def test_authenticated_never_requires_validation(self):\n        request = self.factory.get(\"/dummy\")\n        request.user = self.user\n        self._attach_session(request)\n        view = DummyTemplateView()\n        view.request = request\n        context = view.get_context_data()\n        self.assertFalse(context[\"anonymous_user_validation_required\"])\n"
  },
  {
    "path": "concordia/tests/test_widgets.py",
    "content": "from django.test import TestCase, override_settings\n\nfrom concordia.turnstile.widgets import TurnstileWidget\nfrom concordia.widgets import EmailWidget\n\n\nclass TestWidgets(TestCase):\n    def test_EmailWidget(self):\n        widget = EmailWidget()\n        output = widget.render(\"email\", None)\n        self.assertHTMLEqual(\n            output, '<input class=\"fst-italic form-control\" name=\"email\" type=\"email\">'\n        )\n\n        output = widget.render(\"email\", \"test@example.com\")\n        self.assertHTMLEqual(\n            output,\n            '<input class=\"fst-italic form-control\" name=\"email\"'\n            ' placeholder=\"Change your email address\" type=\"email\">',\n        )\n\n        output = widget.render(\"email\", None, attrs={\"display\": \"none;\"})\n        self.assertHTMLEqual(\n            output,\n            '<input class=\"fst-italic form-control\" display=\"none;\"'\n            ' name=\"email\" type=\"email\">',\n        )\n\n    @override_settings(TURNSTILE_SITEKEY=\"test-key\", TURNSTILE_JS_API_URL=\"test-url\")\n    def test_TurnstileWidget(self):\n        widget = TurnstileWidget()\n\n        # Testing basic validation\n        self.assertEqual(widget.value_from_datadict({}, None, None), None)\n\n        # Testing validation with data\n        data = {\"cf-turnstile-response\": \"test-data\"}\n        self.assertEqual(widget.value_from_datadict(data, None, None), \"test-data\")\n\n        # Testing basic attrs\n        self.assertEqual(widget.build_attrs({}), {\"data-sitekey\": \"test-key\"})\n\n        # Testing with extra ttrs\n        self.assertEqual(\n            widget.build_attrs(\n                {\"id\": \"test-id\"}, extra_attrs={\"custom-attr\": \"test-attr\"}\n            ),\n            {\"data-sitekey\": \"test-key\", \"id\": \"test-id\", \"custom-attr\": \"test-attr\"},\n        )\n\n        # Testing basic context\n        self.assertEqual(\n            widget.get_context(\"test-name\", \"test value\", {}),\n            {\n                \"widget\": {\n                    \"name\": \"test-name\",\n                    \"is_hidden\": False,\n                    \"required\": False,\n                    \"value\": \"test value\",\n                    \"attrs\": {\"data-sitekey\": \"test-key\"},\n                    \"template_name\": \"forms/widgets/turnstile_widget.html\",\n                },\n                \"api_url\": \"test-url\",\n            },\n        )\n\n        # Testing with special context\n        widget.extra_url = {\n            \"test-parameter1\": \"test-value1\",\n            \"test-parameter2\": \"test-value2\",\n        }\n        self.assertEqual(\n            widget.get_context(\"test-name\", \"test value\", {}),\n            {\n                \"widget\": {\n                    \"name\": \"test-name\",\n                    \"is_hidden\": False,\n                    \"required\": False,\n                    \"value\": \"test value\",\n                    \"attrs\": {\"data-sitekey\": \"test-key\"},\n                    \"template_name\": \"forms/widgets/turnstile_widget.html\",\n                },\n                \"api_url\": \"test-url?test-parameter1=test-value1&\"\n                \"test-parameter2=test-value2\",\n            },\n        )\n"
  },
  {
    "path": "concordia/tests/utils.py",
    "content": "import json\nfrom functools import wraps\nfrom secrets import token_hex\n\nfrom django.utils.text import slugify\n\nfrom concordia.models import (\n    Asset,\n    Banner,\n    Campaign,\n    CampaignRetirementProgress,\n    Card,\n    CardFamily,\n    CarouselSlide,\n    ConcordiaFile,\n    Guide,\n    HelpfulLink,\n    Item,\n    MediaType,\n    Project,\n    ResearchCenter,\n    SimplePage,\n    SiteReport,\n    Tag,\n    Topic,\n    Transcription,\n    User,\n    UserAssetTagCollection,\n    UserProfileActivity,\n)\n\n\ndef ensure_slug(original_function):\n    @wraps(original_function)\n    def inner(*args, **kwargs):\n        title = kwargs.get(\"title\")\n        slug = kwargs.get(\"slug\")\n        if title and slug is None:\n            kwargs[\"slug\"] = slugify(title, allow_unicode=True)\n\n        return original_function(*args, **kwargs)\n\n    return inner\n\n\n@ensure_slug\ndef create_campaign(\n    *,\n    title=\"Test Campaign\",\n    slug=\"test-campaign\",\n    short_description=\"Short Description\",\n    description=\"Test Description\",\n    published=True,\n    unlisted=False,\n    status=Campaign.Status.ACTIVE,\n    do_save=True,\n    **kwargs,\n):\n    campaign = Campaign(\n        title=title,\n        slug=slug,\n        description=description,\n        unlisted=unlisted,\n        published=published,\n        status=status,\n        **kwargs,\n    )\n    campaign.full_clean()\n    if do_save:\n        campaign.save()\n    return campaign\n\n\ndef create_simple_page(*, do_save=True, **kwargs):\n    simple_page = SimplePage(**kwargs)\n    if do_save:\n        simple_page.save()\n    return simple_page\n\n\ndef create_site_report(*, do_save=True, **kwargs):\n    site_report = SiteReport(**kwargs)\n    if do_save:\n        site_report.save()\n    return site_report\n\n\n@ensure_slug\ndef create_topic(\n    *,\n    project=None,\n    title=\"Test Topic\",\n    slug=\"test-topic\",\n    description=\"Test Description\",\n    published=True,\n    unlisted=False,\n    do_save=True,\n    **kwargs,\n):\n    if project is None:\n        project = create_project(published=published)\n\n    topic = Topic(\n        title=title,\n        slug=slug,\n        description=description,\n        unlisted=unlisted,\n        published=published,\n        **kwargs,\n    )\n    topic.full_clean()\n    if do_save:\n        topic.save()\n\n    topic.project_set.add(project)\n\n    if do_save:\n        topic.save()\n    return topic\n\n\n@ensure_slug\ndef create_project(\n    *,\n    campaign=None,\n    title=\"Test Project\",\n    slug=\"test-project\",\n    description=\"Test Description\",\n    published=True,\n    do_save=True,\n    **kwargs,\n):\n    if campaign is None:\n        campaign = create_campaign(published=published)\n\n    project = Project(\n        campaign=campaign, title=title, slug=slug, published=published, **kwargs\n    )\n    project.full_clean()\n    if do_save:\n        project.save()\n    return project\n\n\ndef create_item(\n    *,\n    project=None,\n    title=\"Test Item\",\n    item_id=\"testitem.0123456789\",\n    item_url=\"http://example.com/item/testitem.0123456789/\",\n    published=True,\n    do_save=True,\n    **kwargs,\n):\n    if project is None:\n        project = create_project(published=published)\n\n    item = Item(\n        project=project,\n        title=title,\n        item_id=item_id,\n        item_url=item_url,\n        published=published,\n        **kwargs,\n    )\n    item.full_clean()\n    if do_save:\n        item.save()\n    return item\n\n\n@ensure_slug\ndef create_asset(\n    *,\n    item=None,\n    title=\"Test Asset\",\n    slug=\"test-asset\",\n    media_type=MediaType.IMAGE,\n    published=True,\n    storage_image=\"unittest1.jpg\",\n    do_save=True,\n    **kwargs,\n):\n    if item is None:\n        item = create_item(published=published)\n    asset = Asset(\n        item=item,\n        campaign=item.project.campaign,\n        title=title,\n        slug=slug,\n        media_type=media_type,\n        published=published,\n        storage_image=storage_image,\n        **kwargs,\n    )\n    asset.full_clean()\n    if do_save:\n        asset.save()\n    return asset\n\n\ndef create_transcription(*, asset=None, user=None, do_save=True, **kwargs):\n    if asset is None:\n        asset = create_asset()\n    if user is None:\n        user = CreateTestUsers.create_user(f\"asset-{asset.id}-user\")\n    transcription = Transcription(asset=asset, user=user, **kwargs)\n    transcription.full_clean()\n    if do_save:\n        transcription.save()\n    return transcription\n\n\ndef create_tag(*, value=\"tag-value\", do_save=True, **kwargs):\n    tag = Tag(value=value, **kwargs)\n    tag.full_clean()\n    if do_save:\n        tag.save()\n    return tag\n\n\ndef create_tag_collection(*, tag=None, asset=None, user=None, **kwargs):\n    # This function doesn't use do_save because ManyToMany fields don't\n    # work until the model is saved.\n    if tag is None:\n        tag = create_tag()\n    if asset is None:\n        asset = create_asset()\n    if user is None:\n        user = CreateTestUsers.create_user(\"tag-user\")\n    tag_collection = UserAssetTagCollection(asset=asset, user=user, **kwargs)\n    tag_collection.full_clean()\n    tag_collection.save()\n    tag_collection.tags.add(tag)\n    return tag_collection\n\n\ndef create_banner(*, slug=\"Test Banner\", do_save=True, **kwargs):\n    banner = Banner(slug=slug, **kwargs)\n    if do_save:\n        banner.save()\n    return banner\n\n\ndef create_card(*, title=\"Test Card\", do_save=True, **kwargs):\n    card = Card(title=title, **kwargs)\n    if do_save:\n        card.save()\n    return card\n\n\ndef create_card_family(*, slug=\"test-card-family\", do_save=True, **kwargs):\n    card_family = CardFamily(slug=slug, **kwargs)\n    if do_save:\n        card_family.save()\n    return card_family\n\n\ndef create_carousel_slide(*, headline=\"Test Headline\", do_save=True, **kwargs):\n    slide = CarouselSlide(**kwargs)\n    if do_save:\n        slide.save()\n    return slide\n\n\ndef create_guide(*, do_save=True, **kwargs):\n    guide = Guide(**kwargs)\n    if do_save:\n        guide.save()\n    return guide\n\n\ndef create_helpful_link(*, title=\"Test Helpful Link\", do_save=True, **kwargs):\n    link = HelpfulLink(title=title, **kwargs)\n    if do_save:\n        link.save()\n    return link\n\n\ndef create_concordia_file(\n    *, name=\"Test Concordia File\", uploaded_file=\"file.pdf\", do_save=True, **kwargs\n):\n    concordia_file = ConcordiaFile(name=name, uploaded_file=uploaded_file, **kwargs)\n    if do_save:\n        concordia_file.save()\n    return concordia_file\n\n\ndef create_user_profile_activity(\n    *,\n    campaign=None,\n    user=None,\n    do_save=True,\n    **kwargs,\n):\n    if campaign is None:\n        campaign = create_campaign()\n    if user is None:\n        user = CreateTestUsers.create_user(\"profile-user\")\n    activity = UserProfileActivity(campaign=campaign, user=user)\n    if do_save:\n        activity.save()\n    return activity\n\n\ndef create_campaign_retirement_progress(\n    *,\n    campaign=None,\n    do_save=True,\n    **kwargs,\n):\n    if campaign is None:\n        campaign = create_campaign()\n    progress = CampaignRetirementProgress(campaign=campaign)\n    if do_save:\n        progress.save()\n    return progress\n\n\ndef create_research_center(*, title=\"Test Research Center\", do_save=True, **kwargs):\n    center = ResearchCenter(title=title, **kwargs)\n    if do_save:\n        center.save()\n    return center\n\n\nclass JSONAssertMixin(object):\n    def assertValidJSON(self, response, expected_status=200):\n        \"\"\"\n        Assert that a response contains valid JSON and return the decoded JSON\n        \"\"\"\n        self.assertEqual(response.status_code, expected_status)\n\n        try:\n            data = json.loads(response.content.decode(\"utf-8\"))\n        except json.JSONDecodeError as exc:\n            self.fail(msg=f\"response content failed to decode: {exc}\")\n            raise\n\n        return data\n\n\nclass CreateTestUsers(object):\n    def login_user(self, username=\"tester\", **kwargs):\n        \"\"\"\n        Create a user and log the user in\n        \"\"\"\n        if not hasattr(self, \"user\") or self.user is None:\n            self.user = self.create_test_user(username, **kwargs)\n\n        self.client.login(username=self.user.username, password=self.user._password)\n\n    def logout_user(self):\n        self.client.logout()\n        self.user = None\n\n    @classmethod\n    def create_user(cls, username, is_active=True, **kwargs):\n        if \"email\" not in kwargs:\n            kwargs[\"email\"] = f\"{username}@example.com\"\n\n        user = User.objects.create_user(username=username, **kwargs)\n        fake_pw = token_hex(24)\n        user.is_active = is_active\n        user.set_password(fake_pw)\n        user.save()\n\n        user._password = fake_pw\n\n        return user\n\n    @classmethod\n    def create_test_user(cls, username=\"testuser\", **kwargs):\n        \"\"\"\n        Creates an activated test User account\n        \"\"\"\n        return cls.create_user(username, is_active=True, **kwargs)\n\n    @classmethod\n    def create_inactive_user(cls, username=\"testinactiveuser\", **kwargs):\n        \"\"\"\n        Creates an inactive test User account\n        \"\"\"\n        return cls.create_user(username, is_active=False, **kwargs)\n\n    @classmethod\n    def create_staff_user(cls, username=\"teststaffuser\", **kwargs):\n        \"\"\"\n        Creates a staff test User account\n        \"\"\"\n        return cls.create_user(username, is_staff=True, is_active=True, **kwargs)\n\n    @classmethod\n    def create_super_user(cls, username=\"testsuperuser\", **kwargs):\n        \"\"\"\n        Creates a super user User account\n        \"\"\"\n        return cls.create_user(\n            username, is_staff=True, is_superuser=True, is_active=True, **kwargs\n        )\n\n\nclass CacheControlAssertions(object):\n    def assertUncacheable(self, response):\n        self.assertIn(\"Cache-Control\", response)\n        self.assertIn(\"no-cache\", response[\"Cache-Control\"])\n        self.assertIn(\"no-store\", response[\"Cache-Control\"])\n\n    def assertCachePrivate(self, response):\n        self.assertIn(\"Cache-Control\", response)\n        self.assertIn(\"private\", response[\"Cache-Control\"])\n\n\nclass StreamingTestMixin(object):\n    def get_streaming_content(self, response):\n        self.assertTrue(response.streaming)\n        return b\"\".join(response.streaming_content)\n"
  },
  {
    "path": "concordia/turnstile/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Zhang Minghan\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "concordia/turnstile/__init__.py",
    "content": ""
  },
  {
    "path": "concordia/turnstile/context_processors.py",
    "content": "from typing import Any, Dict\n\nfrom django.conf import settings\nfrom django.http import HttpRequest\n\n\ndef turnstile_default_settings(request: \"HttpRequest\") -> \"Dict[str, Any]\":\n    \"\"\"\n    Provide Turnstile-related settings to template context.\n\n    Behavior:\n        Mirrors a subset of Django settings into a dictionary for use in\n        templates. Values are retrieved with `getattr` so that each key has a\n        sensible default even if the corresponding setting is not defined.\n\n    Args:\n        request (HttpRequest): The current request. Included to satisfy the\n            Django context processor signature; it is not used.\n\n    Returns:\n        Dict[str, Any]: Mapping of keys to values for template context. Keys:\n            - \"TURNSTILE_JS_API_URL\" (str): Base URL for the Turnstile\n              JavaScript API. Default:\n              \"https://challenges.cloudflare.com/turnstile/v0/api.js\".\n            - \"TURNSTILE_VERIFY_URL\" (str): Verification endpoint used by the\n              server to validate tokens. Default:\n              \"https://challenges.cloudflare.com/turnstile/v0/siteverify\".\n            - \"TURNSTILE_SITEKEY\" (str): Public site key. Default:\n              \"1x00000000000000000000BB\".\n            - \"TURNSTILE_SECRET\" (str): Private secret key. Default:\n              \"1x0000000000000000000000000000000AA\".\n            - \"TURNSTILE_TIMEOUT\" (int): Timeout in seconds for verification\n              requests. Default: 5.\n            - \"TURNSTILE_DEFAULT_CONFIG\" (dict[str, Any]): Default widget\n              configuration applied as `data-*` attributes. Default: {}.\n            - \"TURNSTILE_PROXIES\" (dict[str, Any]): Proxy configuration for\n              outbound verification requests. Default: {}.\n    \"\"\"\n    return {\n        \"TURNSTILE_JS_API_URL\": getattr(\n            settings,\n            \"TURN_JS_API_URL\",\n            \"https://challenges.cloudflare.com/turnstile/v0/api.js\",\n        ),\n        \"TURNSTILE_VERIFY_URL\": getattr(\n            settings,\n            \"TURNSTILE_VERIFY_URL\",\n            \"https://challenges.cloudflare.com/turnstile/v0/siteverify\",\n        ),\n        \"TURNSTILE_SITEKEY\": getattr(\n            settings, \"TURNSTILE_SITEKEY\", \"1x00000000000000000000BB\"\n        ),\n        \"TURNSTILE_SECRET\": getattr(\n            settings,\n            \"TURNSTILE_SECRET\",\n            \"1x0000000000000000000000000000000AA\",  # nosec B106: test-only dummy secret\n        ),\n        \"TURNSTILE_TIMEOUT\": getattr(settings, \"TURNSTILE_TIMEOUT\", 5),\n        \"TURNSTILE_DEFAULT_CONFIG\": getattr(settings, \"TURNSTILE_DEFAULT_CONFIG\", {}),\n        \"TURNSTILE_PROXIES\": getattr(settings, \"TURNSTILE_PROXIES\", {}),\n    }\n"
  },
  {
    "path": "concordia/turnstile/fields.py",
    "content": "# Originally from\n# https://github.com/zmh-program/django-turnstile/blob/main/turnstile/fields.py\n\nimport inspect\nimport json\nfrom logging import getLogger\nfrom typing import Any, Dict\nfrom urllib.error import HTTPError\nfrom urllib.parse import urlencode\nfrom urllib.request import ProxyHandler, Request, build_opener\n\nfrom django import forms\nfrom django.conf import settings\nfrom django.utils.translation import gettext_lazy as _\n\nfrom concordia.logging import ConcordiaLogger\n\nfrom ..turnstile.widgets import TurnstileWidget\n\nlogger = getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\nclass TurnstileField(forms.Field):\n    \"\"\"\n    Field that renders a Turnstile widget and validates its response token.\n\n    Behavior:\n        - Collects widget configuration from keyword arguments that are not\n          consumed by `forms.Field.__init__` and stores them in\n          `self.widget_settings`.\n        - Extracts specific script URL options (`onload`, `render`, `hl`) from\n          `self.widget_settings` and assigns them to\n          `self.widget.extra_url` for query string construction.\n        - Renders using `TurnstileWidget`.\n        - Validates the submitted token by POSTing to the configured\n          Turnstile verify endpoint and raises `forms.ValidationError` on\n          failure.\n\n    Args:\n        **kwargs: Standard `forms.Field` keyword arguments plus any Turnstile\n            configuration that should be emitted as `data-*` attributes on the\n            widget. The following keys are treated as script URL parameters and\n            moved to `self.widget.extra_url`:\n            - `onload`\n            - `render`\n            - `hl`\n\n    Attributes:\n        widget (TurnstileWidget): The widget class used to render Turnstile.\n        default_error_messages (dict[str, str]): Error messages for invalid or\n            failed verification states.\n\n    Requirements:\n        The following Django settings must be defined:\n        - `TURNSTILE_DEFAULT_CONFIG` (dict)\n        - `TURNSTILE_JS_API_URL` (string)\n        - `TURNSTILE_VERIFY_URL` (string)\n        - `TURNSTILE_SECRET` (string)\n        - `TURNSTILE_TIMEOUT` (float or int)\n        - `TURNSTILE_PROXIES` (dict or None)\n\n    Statuses and errors:\n        - Raises `forms.ValidationError(code=\"error_turnstile\")` when an HTTP\n          error occurs while contacting the verify endpoint.\n        - Raises `forms.ValidationError(code=\"invalid_turnstile\")` when the\n          verify endpoint returns a non-success response.\n        - Uses the standard `required` message when no token is provided.\n    \"\"\"\n\n    widget = TurnstileWidget\n    default_error_messages = {\n        \"error_turnstile\": _(\"Turnstile could not be verified.\"),\n        \"invalid_turnstile\": _(\"Turnstile could not be verified.\"),\n        \"required\": _(\"Please prove you are a human.\"),\n    }\n\n    def __init__(self, **kwargs: Any) -> None:\n        \"\"\"\n        Initialize the field and partition keyword arguments.\n\n        Behavior:\n            - Splits `kwargs` into those accepted by `forms.Field.__init__`\n              and those intended as Turnstile configuration.\n            - Moves `onload`, `render`, and `hl` from the configuration into\n              `self.widget.extra_url` so they are appended to the API script\n              URL as a query string.\n            - Retains the remaining configuration in `self.widget_settings` to\n              be emitted as `data-*` attributes by `widget_attrs`.\n        \"\"\"\n        superclass_parameters = inspect.signature(super().__init__).parameters\n        superclass_kwargs: Dict[str, Any] = {}\n        widget_settings = settings.TURNSTILE_DEFAULT_CONFIG.copy()\n        for key, value in kwargs.items():\n            if key in superclass_parameters:\n                superclass_kwargs[key] = value\n            else:\n                widget_settings[key] = value\n\n        widget_url_settings: Dict[str, Any] = {}\n        for prop in filter(lambda p: p in widget_settings, (\"onload\", \"render\", \"hl\")):\n            widget_url_settings[prop] = widget_settings[prop]\n            del widget_settings[prop]\n        self.widget_settings = widget_settings\n\n        super().__init__(**superclass_kwargs)\n\n        self.widget.extra_url = widget_url_settings\n\n    def widget_attrs(self, widget: forms.Widget) -> dict[str, Any]:\n        \"\"\"\n        Extend `forms.Field.widget_attrs`.\n\n        Behavior:\n            Calls the base implementation to get default attributes, then adds\n            one `data-*` attribute per key in `self.widget_settings`. Keys are\n            lowercased as-is and prefixed with `data-`.\n\n        Returns:\n            dict[str, Any]: Combined widget attributes.\n        \"\"\"\n        attrs = super().widget_attrs(widget)\n        for key, value in self.widget_settings.items():\n            attrs[\"data-%s\" % key] = value\n        return attrs\n\n    def validate(self, value: str | None) -> None:\n        \"\"\"\n        Validate the submitted Turnstile token against the verify endpoint.\n\n        Behavior:\n            - Calls `forms.Field.validate` for base required checks.\n            - Issues a POST request to `settings.TURNSTILE_VERIFY_URL` using\n              `urllib` with `TURNSTILE_PROXIES` and `TURNSTILE_TIMEOUT`.\n            - Parses the JSON response and checks the `success` field.\n\n        Args:\n            value (str | None): The token returned by the Turnstile widget.\n\n        Raises:\n            forms.ValidationError: If Turnstile verification fails or if an HTTP\n                error occurs while contacting the verify endpoint.\n        \"\"\"\n        super().validate(value)\n\n        structured_logger.debug(\n            \"Turnstile validation started.\",\n            event_code=\"turnstile_validate_start\",\n            has_token=bool(value),\n            verify_url=settings.TURNSTILE_VERIFY_URL,\n        )\n\n        opener = build_opener(ProxyHandler(settings.TURNSTILE_PROXIES))\n        post_data = urlencode(\n            {\n                \"secret\": settings.TURNSTILE_SECRET,\n                \"response\": value,\n            }\n        ).encode()\n\n        request = Request(settings.TURNSTILE_VERIFY_URL, post_data)\n\n        try:\n            structured_logger.debug(\n                \"Submitting token to Turnstile verify endpoint.\",\n                event_code=\"turnstile_request_submit\",\n                verify_url=settings.TURNSTILE_VERIFY_URL,\n            )\n            response = opener.open(request, timeout=settings.TURNSTILE_TIMEOUT)\n            structured_logger.debug(\n                \"Received response from Turnstile verify endpoint.\",\n                event_code=\"turnstile_response_received\",\n                verify_url=settings.TURNSTILE_VERIFY_URL,\n                http_status=getattr(response, \"status\", None),\n            )\n        except HTTPError as exc:\n            logger.exception(\"HTTPError received from Turnstile: %s\", exc, exc_info=exc)\n            structured_logger.exception(\n                \"HTTPError received from Turnstile verify endpoint.\",\n                event_code=\"turnstile_http_error\",\n                reason=\"HTTP error while contacting Turnstile verify endpoint\",\n                reason_code=\"http_error\",\n                verify_url=settings.TURNSTILE_VERIFY_URL,\n                http_status=getattr(exc, \"code\", None),\n            )\n            raise forms.ValidationError(\n                self.error_messages[\"error_turnstile\"], code=\"error_turnstile\"\n            ) from exc\n\n        response_data = json.loads(response.read().decode(\"utf-8\"))\n\n        # Non-success responses from Turnstile.\n        if not response_data.get(\"success\"):\n            logger.exception(\n                \"Failure received from Turnstile. Error codes: %s. Messages: %s\",\n                response_data.get(\"error-codes\"),\n                response_data.get(\"messages\"),\n            )\n            structured_logger.info(\n                \"Turnstile verification failed.\",\n                event_code=\"turnstile_validate_failed\",\n                verify_url=settings.TURNSTILE_VERIFY_URL,\n                error_codes=response_data.get(\"error-codes\"),\n                messages=response_data.get(\"messages\"),\n            )\n            raise forms.ValidationError(\n                self.error_messages[\"invalid_turnstile\"], code=\"invalid_turnstile\"\n            )\n\n        structured_logger.debug(\n            \"Turnstile verification succeeded.\",\n            event_code=\"turnstile_validate_success\",\n            verify_url=settings.TURNSTILE_VERIFY_URL,\n        )\n"
  },
  {
    "path": "concordia/turnstile/widgets.py",
    "content": "# Originally from\n# https://github.com/zmh-program/django-turnstile/blob/main/turnstile/widgets.py\n\nfrom typing import Any, Dict, Mapping\nfrom urllib.parse import urlencode\n\nfrom django import forms\nfrom django.conf import settings\n\n\nclass TurnstileWidget(forms.Widget):\n    \"\"\"\n    A Django form widget for Cloudflare Turnstile.\n\n    Behavior:\n        Renders using the `forms/widgets/turnstile_widget.html` template and\n        augments the base widget behavior by injecting the configured site key\n        into the rendered attributes and the Turnstile script URL into the\n        template context. Optional query parameters for the script URL may be\n        supplied via the `extra_url` dictionary.\n\n    Requirements:\n        - `settings.TURNSTILE_SITEKEY` must be defined.\n        - `settings.TURNSTILE_JS_API_URL` must be defined.\n\n    Attributes:\n        template_name (str): Template used to render the widget.\n        extra_url (Dict[str, str]): Optional query parameters appended to the\n            Turnstile JavaScript URL.\n    \"\"\"\n\n    template_name = \"forms/widgets/turnstile_widget.html\"\n\n    def __init__(self, *args, **kwargs) -> None:\n        \"\"\"\n        Initialize the widget.\n\n        Notes:\n            Initializes `extra_url` to an empty dictionary.\n\n        Args:\n            *args (Any): Positional arguments passed through to `forms.Widget`.\n            **kwargs (Any): Keyword arguments passed through to `forms.Widget`.\n        \"\"\"\n        self.extra_url = {}\n        super().__init__(*args, **kwargs)\n\n    def value_from_datadict(\n        self,\n        data: \"Mapping[str, Any]\",\n        files: \"Mapping[str, Any]\",\n        name: str,\n    ) -> \"str | None\":\n        \"\"\"\n        Extract the Turnstile response token from submitted form data.\n\n        Request Parameters:\n            - `cf-turnstile-response` (str): The token provided by the\n              Turnstile widget.\n\n        Args:\n            data (Mapping[str, Any]): The POST data.\n            files (Mapping[str, Any]): The file data (unused).\n            name (str): The field name (unused for extraction).\n\n        Returns:\n            str | None: The Turnstile token if present, otherwise `None`.\n        \"\"\"\n        return data.get(\"cf-turnstile-response\")\n\n    def build_attrs(\n        self,\n        base_attrs: \"Dict[str, Any]\",\n        extra_attrs: \"Dict[str, Any] | None\" = None,\n    ) -> \"Dict[str, Any]\":\n        \"\"\"\n        Override of `forms.Widget.build_attrs`.\n\n        Difference from base:\n            Calls the base method to merge attributes, then sets the\n            `data-sitekey` attribute using `settings.TURNSTILE_SITEKEY`.\n\n        Args:\n            base_attrs (Dict[str, Any]): Base HTML attributes.\n            extra_attrs (Dict[str, Any] | None): Additional attributes to merge.\n\n        Returns:\n            Dict[str, Any]: The merged attributes with `data-sitekey` set.\n        \"\"\"\n        attrs = super().build_attrs(base_attrs, extra_attrs)\n        attrs[\"data-sitekey\"] = settings.TURNSTILE_SITEKEY\n        return attrs\n\n    def get_context(\n        self,\n        name: str,\n        value: \"Any\",\n        attrs: \"Dict[str, Any] | None\",\n    ) -> \"Dict[str, Any]\":\n        \"\"\"\n        Override of `forms.Widget.get_context`.\n\n        Difference from base:\n            Calls the base method to build the context, then adds `api_url`\n            from `settings.TURNSTILE_JS_API_URL`. If `extra_url` has entries,\n            appends them as a query string.\n\n        Args:\n            name (str): Field name.\n            value (Any): Field value.\n            attrs (Dict[str, Any] | None): HTML attributes for rendering.\n\n        Returns:\n            Dict[str, Any]: Template context including `api_url`.\n        \"\"\"\n        context = super().get_context(name, value, attrs)\n        context[\"api_url\"] = settings.TURNSTILE_JS_API_URL\n        if self.extra_url:\n            context[\"api_url\"] += \"?\" + urlencode(self.extra_url)\n        return context\n"
  },
  {
    "path": "concordia/urls.py",
    "content": "from django.conf import settings\nfrom django.contrib import admin\nfrom django.http import Http404, HttpResponseForbidden\nfrom django.urls import include, path\nfrom django.urls.converters import register_converter\nfrom django.views.defaults import page_not_found, permission_denied, server_error\nfrom django.views.generic import RedirectView\n\nfrom exporter import views as exporter_views\nfrom prometheus_metrics.views import MetricsView\n\nfrom . import converters, views\n\nregister_converter(converters.UnicodeSlugConverter, \"uslug\")\nregister_converter(converters.ItemIdConverter, \"item_id\")\n\ntx_urlpatterns = (\n    [\n        path(\"\", views.campaigns.CampaignListView.as_view(), name=\"campaign-list\"),\n        path(\n            \"completed/\",\n            views.campaigns.CompletedCampaignListView.as_view(),\n            name=\"completed-campaign-list\",\n        ),\n        path(\n            \"<uslug:slug>/reviewable/\",\n            views.campaigns.FilteredCampaignDetailView.as_view(),\n            name=\"filtered-campaign-detail\",\n        ),\n        path(\n            \"<uslug:slug>/\",\n            views.campaigns.CampaignDetailView.as_view(),\n            name=\"campaign-detail\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/export/csv/\",\n            exporter_views.ExportCampaignToCSV.as_view(),\n            name=\"campaign-export-csv\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/export/bagit/\",\n            exporter_views.ExportCampaignToBagIt.as_view(),\n            name=\"campaign-export-bagit\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/<uslug:project_slug>/export/bagit/\",\n            exporter_views.ExportProjectToBagIt.as_view(),\n            name=\"project-export-bagit\",\n        ),\n        path(\n            (\n                \"<uslug:campaign_slug>/<uslug:project_slug>/\"\n                \"<item_id:item_id>/export/bagit/\"\n            ),\n            exporter_views.ExportItemToBagIt.as_view(),\n            name=\"item-export-bagit\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/report/\",\n            views.campaigns.ReportCampaignView.as_view(),\n            name=\"campaign-report\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/<uslug:project_slug>/<item_id:item_id>/reviewable/\",\n            views.items.FilteredItemDetailView.as_view(),\n            name=\"filtered-item-detail\",\n        ),\n        path(\n            (\n                \"<uslug:campaign_slug>/<uslug:project_slug>/\"\n                \"<item_id:item_id>/<uslug:slug>/\"\n            ),\n            views.assets.AssetDetailView.as_view(),\n            name=\"asset-detail\",\n        ),\n        # n.b. this must be above project-detail to avoid being seen as a project slug:\n        path(\n            \"<uslug:campaign_slug>/next-transcribable-asset/\",\n            views.assets.redirect_to_next_transcribable_campaign_asset,\n            name=\"redirect-to-next-transcribable-campaign-asset\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/next-reviewable-asset/\",\n            views.assets.redirect_to_next_reviewable_campaign_asset,\n            name=\"redirect-to-next-reviewable-campaign-asset\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/<uslug:slug>/reviewable/\",\n            views.projects.FilteredProjectDetailView.as_view(),\n            name=\"filtered-project-detail\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/<uslug:slug>/\",\n            views.projects.ProjectDetailView.as_view(),\n            name=\"project-detail\",\n        ),\n        path(\n            \"<uslug:campaign_slug>/<uslug:project_slug>/<item_id:item_id>/\",\n            views.items.ItemDetailView.as_view(),\n            name=\"item-detail\",\n        ),\n    ],\n    \"transcriptions\",\n)\n\nurlpatterns = [\n    path(\"\", views.HomeView.as_view(), name=\"homepage\"),\n    path(\"healthz\", views.healthz, name=\"health-check\"),\n    path(\"letter\", views.accounts.account_letter, name=\"user-letter\"),\n    path(\"about/\", views.simple_pages.about_simple_page, name=\"about\"),\n    # These patterns are to make sure various links to help-center URLs don't break\n    # when the URLs are changed to not include help-center and can be removed after\n    # all links are updated.\n    path(\n        \"help-center/\",\n        RedirectView.as_view(pattern_name=\"welcome-guide\"),\n        name=\"help-center\",\n    ),\n    path(\n        \"help-center/welcome-guide/\", RedirectView.as_view(pattern_name=\"welcome-guide\")\n    ),\n    path(\n        \"help-center/welcome-guide-esp/\",\n        RedirectView.as_view(pattern_name=\"welcome-guide-spanish\"),\n    ),\n    path(\n        \"help-center/<slug:page_slug>-esp/\",\n        views.simple_pages.HelpCenterSpanishRedirectView.as_view(),\n    ),\n    path(\n        \"help-center/<slug:page_slug>/\",\n        views.simple_pages.HelpCenterRedirectView.as_view(),\n    ),\n    # End of help-center patterns\n    path(\"get-started/\", views.simple_pages.simple_page, name=\"welcome-guide\"),\n    path(\n        \"get-started/how-to-transcribe/\",\n        views.simple_pages.simple_page,\n        name=\"transcription-basic-rules\",\n    ),\n    path(\n        \"get-started/how-to-review/\",\n        views.simple_pages.simple_page,\n        name=\"how-to-review\",\n    ),\n    path(\"get-started/how-to-tag/\", views.simple_pages.simple_page, name=\"how-to-tag\"),\n    path(\n        \"get-started/<uslug:slug>/\", views.simple_pages.simple_page, name=\"simple-page\"\n    ),\n    path(\n        \"get-started-esp/\",\n        views.simple_pages.simple_page,\n        name=\"welcome-guide-spanish\",\n    ),\n    path(\n        \"get-started-esp/how-to-transcribe-esp/\",\n        views.simple_pages.simple_page,\n        name=\"how-to-transcribe-spanish\",\n    ),\n    path(\n        \"get-started-esp/how-to-review-esp/\",\n        views.simple_pages.simple_page,\n        name=\"how-to-review-spanish\",\n    ),\n    path(\n        \"get-started-esp/how-to-tag-esp/\",\n        views.simple_pages.simple_page,\n        name=\"how-to-tag-spanish\",\n    ),\n    path(\n        \"get-started-esp/<uslug:slug>/\",\n        views.simple_pages.simple_page,\n        name=\"simple-page-spanish\",\n    ),\n    path(\n        \"for-educators/\",\n        views.simple_pages.simple_page,\n        name=\"for-educators\",\n    ),\n    path(\n        \"for-staff/\",\n        views.simple_pages.simple_page,\n        name=\"for-staff\",\n    ),\n    path(\n        \"resources/\",\n        RedirectView.as_view(\n            pattern_name=\"guidelines\", permanent=True, query_string=True\n        ),\n        name=\"resources\",\n    ),\n    path(\n        \"service/\",\n        views.simple_pages.simple_page,\n        name=\"service\",\n    ),\n    path(\n        \"guidelines/\",\n        views.simple_pages.simple_page,\n        name=\"guidelines\",\n    ),\n    path(\n        \"programs/\",\n        views.simple_pages.simple_page,\n        name=\"programs\",\n    ),\n    path(\n        \"latest/\",\n        RedirectView.as_view(pattern_name=\"about\", permanent=True, query_string=True),\n    ),\n    path(\"questions/\", views.simple_pages.simple_page, name=\"questions\"),\n    path(\n        \"contact/\",\n        RedirectView.as_view(url=\"https://ask.loc.gov/crowd\"),\n        name=\"contact\",\n    ),\n    path(\n        \"help-center/\",\n        RedirectView.as_view(pattern_name=\"welcome-guide\"),\n        name=\"help-center\",\n    ),\n    path(\n        \"campaigns-topics/\",\n        views.campaigns.CampaignTopicListView.as_view(),\n        name=\"campaign-topic-list\",\n    ),\n    path(\n        \"topics/<uslug:slug>/\",\n        views.topics.TopicDetailView.as_view(),\n        name=\"topic-detail\",\n    ),\n    path(\n        \"topics/<uslug:topic_slug>/next-transcribable-asset/\",\n        views.assets.redirect_to_next_transcribable_topic_asset,\n        name=\"redirect-to-next-transcribable-topic-asset\",\n    ),\n    path(\n        \"topics/<uslug:topic_slug>/next-reviewable-asset/\",\n        views.assets.redirect_to_next_reviewable_topic_asset,\n        name=\"redirect-to-next-reviewable-topic-asset\",\n    ),\n    path(\n        \"next-transcribable-asset/\",\n        views.assets.redirect_to_next_transcribable_asset,\n        name=\"redirect-to-next-transcribable-asset\",\n    ),\n    path(\n        \"next-reviewable-asset/\",\n        views.assets.redirect_to_next_reviewable_asset,\n        name=\"redirect-to-next-reviewable-asset\",\n    ),\n    path(\"campaigns/\", include(tx_urlpatterns, namespace=\"transcriptions\")),\n    path(\n        \"reserve-asset/<int:asset_pk>/\", views.ajax.reserve_asset, name=\"reserve-asset\"\n    ),\n    path(\n        \"assets/<int:asset_pk>/transcriptions/save/\",\n        views.ajax.save_transcription,\n        name=\"save-transcription\",\n    ),\n    path(\n        \"transcriptions/<int:pk>/submit/\",\n        views.ajax.submit_transcription,\n        name=\"submit-transcription\",\n    ),\n    path(\n        \"transcriptions/<int:pk>/review/\",\n        views.ajax.review_transcription,\n        name=\"review-transcription\",\n    ),\n    path(\n        \"assets/<int:asset_pk>/transcriptions/generate-ocr/\",\n        views.ajax.generate_ocr_transcription,\n        name=\"generate-ocr-transcription\",\n    ),\n    path(\n        \"assets/<int:asset_pk>/transcriptions/rollback/\",\n        views.ajax.rollback_transcription,\n        name=\"rollback-transcription\",\n    ),\n    path(\n        \"assets/<int:asset_pk>/transcriptions/rollforward/\",\n        views.ajax.rollforward_transcription,\n        name=\"rollforward-transcription\",\n    ),\n    path(\n        \"assets/<int:asset_pk>/tags/submit/\", views.ajax.submit_tags, name=\"submit-tags\"\n    ),\n    path(\n        \"account/ajax-status/\",\n        views.ajax.ajax_session_status,\n        name=\"ajax-session-status\",\n    ),\n    path(\"account/ajax-messages/\", views.ajax.ajax_messages, name=\"ajax-messages\"),\n    path(\n        \"account/register/\",\n        views.accounts.ConcordiaRegistrationView.as_view(),\n        name=\"registration_register\",\n    ),\n    path(\n        \"account/login/\",\n        views.accounts.ConcordiaLoginView.as_view(),\n        name=\"registration_login\",\n    ),\n    path(\"account/get_pages/\", views.accounts.get_pages, name=\"get_pages\"),\n    path(\n        \"account/profile/\",\n        views.accounts.AccountProfileView.as_view(),\n        name=\"user-profile\",\n    ),\n    path(\n        \"account/password_reset/\",\n        views.accounts.ConcordiaPasswordResetRequestView.as_view(),\n        name=\"password_reset\",\n    ),\n    path(\n        \"account/reset/<uidb64>/<token>/\",\n        views.accounts.ConcordiaPasswordResetConfirmView.as_view(),\n        name=\"password_reset_confirm\",\n    ),\n    path(\"account/\", include(\"django_registration.backends.activation.urls\")),\n    path(\"account/\", include(\"django.contrib.auth.urls\")),\n    path(\n        \"account/email_confirmation/<str:confirmation_key>/\",\n        views.accounts.EmailReconfirmationView.as_view(),\n        name=\"email-reconfirmation\",\n    ),\n    path(\n        \"account/delete/\",\n        views.accounts.AccountDeletionView.as_view(),\n        name=\"account-deletion\",\n    ),\n    path(\n        \".well-known/change-password\",  # https://wicg.github.io/change-password-url/\n        RedirectView.as_view(pattern_name=\"password_change\"),\n    ),\n    path(\"admin/\", admin.site.urls),\n    # Internal support assists:\n    path(\"error/500/\", server_error),\n    path(\"error/404/\", page_not_found, {\"exception\": Http404()}),\n    path(\"error/429/\", views.rate_limit.ratelimit_view),\n    path(\"error/403/\", permission_denied, {\"exception\": HttpResponseForbidden()}),\n    path(\"tinymce/\", include(\"tinymce.urls\")),\n    path(\"metrics\", MetricsView.as_view(), name=\"prometheus-django-metrics\"),\n    path(\"robots.txt\", include(\"robots.urls\")),\n    path(\n        \"maintenance-mode/off/\",\n        views.maintenance_mode.maintenance_mode_off,\n        name=\"maintenance_mode_off\",\n    ),\n    path(\n        \"maintenance-mode/on/\",\n        views.maintenance_mode.maintenance_mode_on,\n        name=\"maintenance_mode_on\",\n    ),\n    path(\n        \"maintenance-mode/frontend/available\",\n        views.maintenance_mode.maintenance_mode_frontend_available,\n        name=\"maintenance_mode_frontend_available\",\n    ),\n    path(\n        \"maintenance-mode/frontend/unavailable\",\n        views.maintenance_mode.maintenance_mode_frontend_unavailable,\n        name=\"maintenance_mode_frontend_unavailable\",\n    ),\n    path(\n        \"api/visualization/<slug:name>/\",\n        views.visualizations.VisualizationDataView.as_view(),\n        name=\"visualization\",\n    ),\n]\n\nif settings.DEBUG:\n    import debug_toolbar\n    from django.conf.urls.static import static\n    from django.views.generic import TemplateView\n\n    from concordia.api import api as concordia_api\n\n    urlpatterns = [path(\"__debug__/\", include(debug_toolbar.urls))] + urlpatterns\n\n    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)\n\n    urlpatterns += (\n        path(\n            \"transcription/\",\n            TemplateView.as_view(template_name=\"transcriptions/transcription.html\"),\n            name=\"transcription\",\n        ),\n        path(\"api/\", concordia_api.urls, name=\"api\"),\n    )\n"
  },
  {
    "path": "concordia/utils/__init__.py",
    "content": "from secrets import token_hex\n\nfrom django.contrib.auth.models import User\n\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.templatetags.concordia_media_tags import asset_media_url\n\n__all__ = [\n    \"get_anonymous_user\",\n    \"request_accepts_json\",\n    \"get_or_create_reservation_token\",\n    \"get_image_urls_from_asset\",\n]\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef get_anonymous_user():\n    \"\"\"\n    Get the user called \"anonymous\" if it exist. Create the user if it doesn't\n    exist This is the default concordia user if someone is working on the site\n    without logging in first.\n    \"\"\"\n\n    try:\n        return User.objects.get(username=\"anonymous\")\n    except User.DoesNotExist:\n        return User.objects.create_user(username=\"anonymous\")\n\n\ndef request_accepts_json(request):\n    accept_header = request.headers.get(\"Accept\", \"*/*\")\n\n    return \"application/json\" in accept_header\n\n\ndef get_or_create_reservation_token(request):\n    # Reservation tokens are 44 characters (22 bytes\n    # converted into 44 hex digits) plus the user's\n    # database id padded with leading zeroes until it's\n    # at least 6 characters long\n    if \"reservation_token\" not in request.session:\n        request.session[\"reservation_token\"] = token_hex(22)\n        user = getattr(request, \"user\", None)\n        if user is not None:\n            uid = user.id\n            if uid is None:\n                uid = get_anonymous_user().id\n            request.session[\"reservation_token\"] += str(uid).zfill(6)\n            structured_logger.info(\n                \"Reservation token created.\",\n                event_code=\"reservation_token_created\",\n                reservation_token=request.session[\"reservation_token\"],\n                user=user,\n            )\n    else:\n        structured_logger.info(\n            \"Reservation token reused.\",\n            event_code=\"reservation_token_reused\",\n            reservation_token=request.session[\"reservation_token\"],\n        )\n    return request.session[\"reservation_token\"]\n\n\ndef get_image_urls_from_asset(asset):\n    \"\"\"\n    Given an Asset, return a tuple containing the normalized full-size and\n    thumbnail-size image URLs\n    \"\"\"\n\n    image_url = asset_media_url(asset)\n    if asset.download_url and \"iiif\" in asset.download_url:\n        thumbnail_url = asset.download_url.replace(\n            \"http://tile.loc.gov\", \"https://tile.loc.gov\"\n        )\n    else:\n        thumbnail_url = image_url\n\n    return image_url, thumbnail_url\n"
  },
  {
    "path": "concordia/utils/celery.py",
    "content": "from celery import Task\n\nfrom concordia.celery import app as concordia_celery_app\n\n\ndef get_registered_task(name: str) -> Task:\n    \"\"\"\n    Retrieve a Celery task by its fully qualified name.\n\n    This function looks up a task in the Celery app task registry. It raises a\n    RuntimeError if the task is not found. The purpose of this function is to\n    provide a usable interface for safely calling a task without importing it\n    directly, to avoid issues such as circular imports. This avoids issues with\n    `app.send_task`, which ignores settings such as `ALWAYS_EAGER`.\n\n    Args:\n        name (str): Fully qualified task name, for example\n            \"myapp.tasks.my_task\".\n\n    Returns:\n        Task: The registered Celery task object.\n\n    Raises:\n        RuntimeError: If the task name is not found in the registry.\n    \"\"\"\n    try:\n        return concordia_celery_app.tasks[name]\n    except KeyError as err:\n        raise RuntimeError(f\"Task {name} is not registered. Did you typo it?\") from err\n"
  },
  {
    "path": "concordia/utils/constants.py",
    "content": "from django.contrib import messages\n\nASSETS_PER_PAGE = 36\nPROJECTS_PER_PAGE = 36\nITEMS_PER_PAGE = 36\nURL_REGEX = r\"http[s]?://\"\n\nMESSAGE_LEVEL_NAMES = dict(\n    zip(\n        messages.DEFAULT_LEVELS.values(),\n        map(str.lower, messages.DEFAULT_LEVELS.keys()),\n        strict=False,\n    )\n)\n"
  },
  {
    "path": "concordia/utils/next_asset/__init__.py",
    "content": "from concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    NextReviewableCampaignAsset,\n    NextReviewableTopicAsset,\n    NextTranscribableCampaignAsset,\n    NextTranscribableTopicAsset,\n)\n\nfrom .reviewable import (\n    find_and_order_potential_reviewable_campaign_assets,\n    find_and_order_potential_reviewable_topic_assets,\n    find_invalid_next_reviewable_campaign_assets,\n    find_invalid_next_reviewable_topic_assets,\n    find_new_reviewable_campaign_assets,\n    find_new_reviewable_topic_assets,\n    find_next_reviewable_campaign_asset,\n    find_next_reviewable_campaign_assets,\n    find_next_reviewable_topic_asset,\n    find_next_reviewable_topic_assets,\n    find_reviewable_campaign_asset,\n    find_reviewable_topic_asset,\n)\nfrom .transcribable import (\n    find_and_order_potential_transcribable_campaign_assets,\n    find_and_order_potential_transcribable_topic_assets,\n    find_invalid_next_transcribable_campaign_assets,\n    find_invalid_next_transcribable_topic_assets,\n    find_new_transcribable_campaign_assets,\n    find_new_transcribable_topic_assets,\n    find_next_transcribable_campaign_asset,\n    find_next_transcribable_campaign_assets,\n    find_next_transcribable_topic_asset,\n    find_next_transcribable_topic_assets,\n    find_transcribable_campaign_asset,\n    find_transcribable_topic_asset,\n)\n\n__all__ = [\n    \"find_and_order_potential_transcribable_campaign_assets\",\n    \"find_and_order_potential_transcribable_topic_assets\",\n    \"find_new_transcribable_campaign_assets\",\n    \"find_new_transcribable_topic_assets\",\n    \"find_next_transcribable_campaign_asset\",\n    \"find_next_transcribable_topic_asset\",\n    \"find_next_transcribable_campaign_assets\",\n    \"find_next_transcribable_topic_assets\",\n    \"find_transcribable_campaign_asset\",\n    \"find_transcribable_topic_asset\",\n    \"find_and_order_potential_reviewable_campaign_assets\",\n    \"find_and_order_potential_reviewable_topic_assets\",\n    \"find_new_reviewable_campaign_assets\",\n    \"find_new_reviewable_topic_assets\",\n    \"find_next_reviewable_campaign_assets\",\n    \"find_next_reviewable_topic_assets\",\n    \"find_next_reviewable_campaign_asset\",\n    \"find_next_reviewable_topic_asset\",\n    \"find_reviewable_campaign_asset\",\n    \"find_reviewable_topic_asset\",\n    \"remove_next_asset_objects\",\n    \"find_invalid_next_reviewable_campaign_assets\",\n    \"find_invalid_next_reviewable_topic_assets\",\n    \"find_invalid_next_transcribable_campaign_assets\",\n    \"find_invalid_next_transcribable_topic_assets\",\n]\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef remove_next_asset_objects(asset_id):\n    \"\"\"\n    Remove all cached next asset entries associated with the given asset id.\n\n    This function deletes entries from the four next asset tables:\n    - NextTranscribableCampaignAsset\n    - NextTranscribableTopicAsset\n    - NextReviewableCampaignAsset\n    - NextReviewableTopicAsset\n\n    It is typically used when an asset is no longer valid for caching\n    (e.g., after transcription or review status changes, or when a user\n    reserves the asset).\n\n    Args:\n        asset_id (int): The ID of the asset to remove from next-asset tables.\n    \"\"\"\n    structured_logger.info(\n        \"Removing next asset objects\",\n        event_code=\"remove_next_asset_objects\",\n        asset_id=asset_id,\n    )\n    NextTranscribableCampaignAsset.objects.filter(asset_id=asset_id).delete()\n    NextTranscribableTopicAsset.objects.filter(asset_id=asset_id).delete()\n    NextReviewableCampaignAsset.objects.filter(asset_id=asset_id).delete()\n    NextReviewableTopicAsset.objects.filter(asset_id=asset_id).delete()\n"
  },
  {
    "path": "concordia/utils/next_asset/reviewable/__init__.py",
    "content": "from .campaign import (\n    find_and_order_potential_reviewable_campaign_assets,\n    find_invalid_next_reviewable_campaign_assets,\n    find_new_reviewable_campaign_assets,\n    find_next_reviewable_campaign_asset,\n    find_next_reviewable_campaign_assets,\n    find_reviewable_campaign_asset,\n)\nfrom .topic import (\n    find_and_order_potential_reviewable_topic_assets,\n    find_invalid_next_reviewable_topic_assets,\n    find_new_reviewable_topic_assets,\n    find_next_reviewable_topic_asset,\n    find_next_reviewable_topic_assets,\n    find_reviewable_topic_asset,\n)\n\n__all__ = [\n    \"find_new_reviewable_campaign_assets\",\n    \"find_next_reviewable_campaign_assets\",\n    \"find_reviewable_campaign_asset\",\n    \"find_and_order_potential_reviewable_campaign_assets\",\n    \"find_next_reviewable_campaign_asset\",\n    \"find_and_order_potential_reviewable_topic_assets\",\n    \"find_new_reviewable_topic_assets\",\n    \"find_next_reviewable_topic_asset\",\n    \"find_next_reviewable_topic_assets\",\n    \"find_reviewable_topic_asset\",\n    \"find_invalid_next_reviewable_campaign_assets\",\n    \"find_invalid_next_reviewable_topic_assets\",\n]\n"
  },
  {
    "path": "concordia/utils/next_asset/reviewable/campaign.py",
    "content": "from typing import Dict\n\nfrom django.contrib.auth.models import User\nfrom django.db import transaction\nfrom django.db.models import Case, IntegerField, Q, QuerySet, Subquery, Value, When\n\nfrom concordia import models as concordia_models\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.utils.celery import get_registered_task\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef _reserved_asset_ids_subq(\n    campaign: concordia_models.Campaign,\n) -> \"QuerySet[Dict[str, int]]\":\n    \"\"\"\n    Return a subquery of reserved asset identifiers for a campaign.\n\n    Behavior:\n        Produces a subquery suitable for use with `Subquery(...)` and\n        `exclude(pk__in=...)` clauses to filter out assets that currently have\n        an active reservation.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign whose reserved\n        assets should be excluded.\n\n    Returns:\n        QuerySet[Dict[str, int]]: A queryset of dictionaries with a single key\n            \"asset_id\" corresponding to reserved assets.\n    \"\"\"\n    return concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__campaign=campaign\n    ).values(\"asset_id\")\n\n\ndef _eligible_reviewable_base_qs(\n    campaign: concordia_models.Campaign,\n    user: User | None = None,\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Build the base queryset of reviewable assets for a campaign.\n\n    Behavior:\n        Restricts to published projects, items, and assets, and to assets whose\n        transcription status is `SUBMITTED`. Optionally excludes assets\n        transcribed by the supplied user.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign scope for filtering.\n        user (User | None): If provided, exclude assets transcribed by this user.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Reviewable assets, with `item` and\n            `item__project` selected via `select_related`.\n    \"\"\"\n    qs = concordia_models.Asset.objects.filter(\n        campaign_id=campaign.id,\n        item__project__published=True,\n        item__published=True,\n        published=True,\n        transcription_status=concordia_models.TranscriptionStatus.SUBMITTED,\n    ).select_related(\"item\", \"item__project\")\n    if user:\n        qs = qs.exclude(transcription__user=user.id)\n    return qs\n\n\ndef _next_seq_after(pk: int | None) -> int | None:\n    \"\"\"\n    Resolve the sequence number for a given asset primary key.\n\n    Behavior:\n        Convenience utility for ordering logic when advancing within a series\n        of assets.\n\n    Args:\n        pk (int | None): Asset primary key whose sequence to resolve.\n\n    Returns:\n        int | None: The asset's sequence number, or None if `pk` is falsy\n            or the asset does not exist.\n    \"\"\"\n    if not pk:\n        return None\n    return (\n        concordia_models.Asset.objects.filter(pk=pk)\n        .values_list(\"sequence\", flat=True)\n        .first()\n    )\n\n\n@transaction.atomic\ndef _find_reviewable_in_item(\n    campaign: concordia_models.Campaign,\n    user: User,\n    *,\n    item_id: str,\n    after_asset_pk: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Select the next reviewable asset within the same item.\n\n    Behavior:\n        Attempts a short-circuit within the user's current item to provide a\n        locally contiguous review flow.\n\n    Eligibility:\n        - Asset, Item, and Project are published.\n        - Asset transcription status is `SUBMITTED`.\n        - Asset is not reserved.\n        - Asset was not transcribed by the current user.\n\n    Ordering:\n        - If `after_asset_pk` refers to an asset in the same item and campaign,\n          select the earliest asset whose (sequence, id) is strictly greater\n          than the current asset's pair.\n        - Otherwise, select the earliest eligible by (sequence, id).\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign scope.\n        user (User): Current user; used to exclude their own work.\n        item_id (str): Identifier of the item to stay within.\n        after_asset_pk (int | None): Asset primary key to advance from.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or\n            None if no match is available.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__item__item_id=item_id,\n        asset__item__project__campaign=campaign,\n    ).values(\"asset_id\")\n\n    eligible = (\n        concordia_models.Asset.objects.filter(\n            item__item_id=item_id,\n            item__project__campaign=campaign,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.SUBMITTED,\n        )\n        .exclude(pk__in=Subquery(reserved_asset_ids))\n        .exclude(transcription__user=user.id)\n    )\n\n    seq_gt_filter = None\n    if after_asset_pk is not None:\n        try:\n            current = (\n                concordia_models.Asset.objects.only(\"id\", \"sequence\", \"item_id\", \"item\")\n                .select_related(\"item\")\n                .get(pk=after_asset_pk)\n            )\n            if (\n                current.item.item_id == item_id\n                and current.item.project.campaign_id == campaign.id\n            ):\n                seq_gt_filter = Q(sequence__gt=current.sequence) | (\n                    Q(sequence=current.sequence) & Q(id__gt=after_asset_pk)\n                )\n        except concordia_models.Asset.DoesNotExist:\n            pass\n\n    if seq_gt_filter is not None:\n        eligible = eligible.filter(seq_gt_filter)\n\n    asset = (\n        eligible.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .order_by(\"sequence\", \"id\")\n        .first()\n    )\n\n    structured_logger.debug(\n        \"Item short-circuit (campaign reviewable) resolved.\",\n        event_code=\"reviewable_item_short_circuit_campaign\",\n        campaign=campaign,\n        item_id=item_id,\n        after_asset_pk=after_asset_pk,\n        chosen_asset_id=getattr(asset, \"id\", None),\n    )\n    return asset\n\n\n@transaction.atomic\ndef _find_reviewable_in_project(\n    campaign: concordia_models.Campaign,\n    user: User,\n    *,\n    project_slug: str,\n    after_asset_pk: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Select the first eligible reviewable asset within the same project.\n\n    Behavior:\n        Short-circuit when staying within a project. Sequence is per item,\n        so this returns the first eligible asset, not strictly \"after\" a given asset.\n\n    Eligibility:\n        - Same campaign and project.\n        - Asset, Item, and Project are published.\n        - Asset transcription status is `SUBMITTED`.\n        - Asset is not reserved.\n        - Asset was not transcribed by the current user.\n\n    Ordering:\n        Deterministic by (item__item_id, sequence, id).\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign scope.\n        user (User): Current user; used to exclude their own work.\n        project_slug (str): Slug of the project to stay within.\n        after_asset_pk (int | None): Present for parity with the item\n            variant; not used for ordering here.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or\n            None if no match is available.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__item__project__slug=project_slug,\n        asset__item__project__campaign=campaign,\n    ).values(\"asset_id\")\n\n    eligible = (\n        concordia_models.Asset.objects.filter(\n            item__project__campaign=campaign,\n            item__project__slug=project_slug,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.SUBMITTED,\n        )\n        .exclude(pk__in=Subquery(reserved_asset_ids))\n        .exclude(transcription__user=user.id)\n        .select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .order_by(\"item__item_id\", \"sequence\", \"id\")\n        .first()\n    )\n\n    structured_logger.debug(\n        \"Project short-circuit (campaign reviewable) resolved.\",\n        event_code=\"reviewable_project_short_circuit_campaign\",\n        campaign=campaign,\n        project_slug=project_slug,\n        after_asset_pk=after_asset_pk,\n        chosen_asset_id=getattr(eligible, \"id\", None),\n    )\n    return eligible\n\n\ndef find_new_reviewable_campaign_assets(\n    campaign: concordia_models.Campaign,\n    user: User | None = None,\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Return assets in a campaign that are eligible to be added to the cache.\n\n    Behavior:\n        Builds the candidate set for the NextReviewableCampaignAsset cache by\n        excluding assets that are not `SUBMITTED`, assets already reserved, and\n        assets already present in the cache. Optionally excludes assets\n        transcribed by the provided user.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to filter by.\n        user (User | None): If provided, exclude assets transcribed by this user.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Eligible assets ordered by sequence.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__campaign=campaign\n    ).values(\"asset_id\")\n    next_asset_ids = concordia_models.NextReviewableCampaignAsset.objects.filter(\n        campaign=campaign\n    ).values(\"asset_id\")\n\n    queryset = (\n        concordia_models.Asset.objects.filter(\n            campaign_id=campaign.id,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n        )\n        .filter(transcription_status=concordia_models.TranscriptionStatus.SUBMITTED)\n        .exclude(pk__in=Subquery(reserved_asset_ids))\n        .exclude(pk__in=Subquery(next_asset_ids))\n        .order_by(\"sequence\")\n    )\n    if user:\n        queryset = queryset.exclude(transcription__user=user.id)\n    return queryset\n\n\ndef find_next_reviewable_campaign_assets(\n    campaign: concordia_models.Campaign,\n    user: User,\n) -> \"QuerySet[concordia_models.NextReviewableCampaignAsset]\":\n    \"\"\"\n    Return cached reviewable assets in a campaign not transcribed by the user.\n\n    Behavior:\n        Reads from the NextReviewableCampaignAsset cache table and filters out\n        assets where the requesting user appears in transcriber_ids.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to retrieve cached assets from.\n        user (User): Requesting user.\n\n    Returns:\n        QuerySet[concordia_models.NextReviewableCampaignAsset]: Cached candidate rows\n        for the given user.\n    \"\"\"\n    return concordia_models.NextReviewableCampaignAsset.objects.filter(\n        campaign=campaign\n    ).exclude(transcriber_ids__contains=[user.id])\n\n\n@transaction.atomic\ndef find_reviewable_campaign_asset(\n    campaign: concordia_models.Campaign,\n    user: User,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Retrieve a single reviewable asset for a user from a campaign.\n\n    Behavior:\n        First attempts to select a cached asset from NextReviewableCampaignAsset.\n        If none is available, falls back to a direct query over Asset and\n        triggers a background task to replenish the cache.\n\n    Concurrency:\n        Uses `select_for_update(skip_locked=True, of=(\"self\",))` so only the\n        Asset row is locked and concurrent consumers skip locked rows.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to search within.\n        user (User): Requesting user; their own transcriptions are excluded.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or None if unavailable.\n    \"\"\"\n    next_asset = (\n        find_next_reviewable_campaign_assets(campaign, user)\n        .select_for_update(skip_locked=True, of=(\"self\",))\n        .values_list(\"asset_id\", flat=True)\n        .first()\n    )\n\n    spawn_task = False\n    if next_asset:\n        asset_query = concordia_models.Asset.objects.filter(id=next_asset)\n    else:\n        # No asset in the NextReviewableCampaignAsset table for this campaign\n        # and user, so fallback to manually finding one\n        structured_logger.debug(\n            \"No cached assets available, falling back to manual lookup\",\n            event_code=\"reviewable_fallback_manual_lookup\",\n            campaign=campaign,\n            user=user,\n        )\n        spawn_task = True\n        asset_query = find_new_reviewable_campaign_assets(campaign, user)\n\n    # select_for_update(of=(\"self\",)) causes the row locking only to\n    # apply to the Asset table, rather than also locking joined item table\n    asset = (\n        asset_query.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .first()\n    )\n\n    if spawn_task:\n        # Spawn a task to populate the table for this campaign\n        # We wait to do this until after getting an asset because otherwise there's\n        # a chance all valid assets get grabbed by the task and our query will return\n        # nothing\n        structured_logger.debug(\n            \"Spawned background task to populate cache\",\n            event_code=\"reviewable_cache_population_triggered\",\n            campaign=campaign,\n            user=user,\n        )\n        populate_task = get_registered_task(\n            \"concordia.tasks.next_asset.reviewable.populate_next_reviewable_for_campaign\"\n        )\n        populate_task.delay(campaign.id)\n\n    return asset\n\n\ndef find_and_order_potential_reviewable_campaign_assets(\n    campaign: concordia_models.Campaign,\n    user: User,\n    project_slug: str,\n    item_id: str,\n    asset_pk: int | None,\n) -> \"QuerySet[concordia_models.NextReviewableCampaignAsset]\":\n    \"\"\"\n    Retrieve and prioritize cached reviewable assets for proximity.\n\n    Behavior:\n        Orders cached candidates from NextReviewableCampaignAsset to prefer\n        continuity with the user's current location.\n\n    Annotations added to each row (transient fields):\n        - next_asset (int): 1 if the candidate's asset_id is greater than\n            asset_pk, else 0.\n        - same_project (int): 1 if the candidate shares the given\n            project_slug, else 0.\n        - same_item (int): 1 if the candidate shares the given item_id, else 0.\n\n    Prioritization (descending on the following keys, then ascending by sequence):\n        - next_asset\n        - same_project\n        - same_item\n        - sequence\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to filter by.\n        user (User): Requesting user.\n        project_slug (str): Slug of the user's current project.\n        item_id (str): Identifier of the user's current item.\n        asset_pk (int | None): Identifier of the current asset, if any.\n\n    Returns:\n        QuerySet[concordia_models.NextReviewableCampaignAsset]: Prioritized\n            cached candidates.\n    \"\"\"\n    potential_next_assets = find_next_reviewable_campaign_assets(campaign, user)\n\n    # We'll favor assets which are in the same item or project as the original:\n    next_case = (\n        Case(\n            When(asset_id__gt=asset_pk, then=1),\n            default=0,\n            output_field=IntegerField(),\n        )\n        if asset_pk is not None\n        else Value(0, output_field=IntegerField())\n    )\n\n    potential_next_assets = potential_next_assets.annotate(\n        same_project=Case(\n            When(project_slug=project_slug, then=1),\n            default=0,\n            output_field=IntegerField(),\n        ),\n        same_item=Case(\n            When(item_item_id=item_id, then=1), default=0, output_field=IntegerField()\n        ),\n        next_asset=next_case,\n    ).order_by(\"-next_asset\", \"-same_project\", \"-same_item\", \"sequence\")\n\n    return potential_next_assets\n\n\n@transaction.atomic\ndef find_next_reviewable_campaign_asset(\n    campaign: concordia_models.Campaign,\n    user: User,\n    project_slug: str,\n    item_id: str,\n    original_asset_id: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Retrieve the next best reviewable asset for a user within a campaign.\n\n    Strategy:\n        1. If `item_id` is provided, try a same-item short-circuit that advances\n           by (sequence, id) relative to `original_asset_id`.\n        2. Else, if `project_slug` is provided, select the first eligible asset\n           within that project (short-circuit).\n        3. Else, prioritize cached candidates, and if none are suitable, fall\n           back to computing from Asset and trigger cache population.\n\n    Concurrency:\n        Uses `select_for_update(skip_locked=True, of=(\"self\",))` to avoid\n        double-assignments across concurrent consumers.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to search within.\n        user (User): Requesting user.\n        project_slug (str): Slug of the user's current project.\n        item_id (str): Identifier of the user's current item.\n        original_asset_id (int | None): Identifier of the asset just reviewed.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or None if unavailable.\n    \"\"\"\n    try:\n        after_pk = int(original_asset_id) if original_asset_id else None\n    except (TypeError, ValueError):\n        after_pk = None\n\n    # Short-circuit: same item\n    if item_id:\n        asset = _find_reviewable_in_item(\n            campaign, user, item_id=item_id, after_asset_pk=after_pk\n        )\n        if asset:\n            return asset\n\n    # Short-circuit: same project\n    if project_slug:\n        asset = _find_reviewable_in_project(\n            campaign, user, project_slug=project_slug, after_asset_pk=after_pk\n        )\n        if asset:\n            return asset\n\n    # cache-backed selection, then manual fallback\n    potential_next_assets = find_and_order_potential_reviewable_campaign_assets(\n        campaign, user, project_slug, item_id, after_pk\n    )\n\n    asset_id = (\n        potential_next_assets.select_for_update(skip_locked=True, of=(\"self\",))\n        .values_list(\"asset_id\", flat=True)\n        .first()\n    )\n\n    spawn_task = False\n    if asset_id:\n        asset_query = concordia_models.Asset.objects.filter(id=asset_id)\n    else:\n        # Since we had no potential next assets in the caching table, we have to check\n        # the asset table directly.\n        structured_logger.debug(\n            \"No cached assets matched, falling back to manual lookup\",\n            event_code=\"reviewable_next_fallback_manual\",\n            campaign=campaign,\n            user=user,\n        )\n        spawn_task = True\n        asset_query = find_new_reviewable_campaign_assets(campaign, user)\n\n        next_case = (\n            Case(\n                When(id__gt=after_pk, then=1),\n                default=0,\n                output_field=IntegerField(),\n            )\n            if after_pk is not None\n            else Value(0, output_field=IntegerField())\n        )\n\n        asset_query = asset_query.annotate(\n            same_project=Case(\n                When(item__project__slug=project_slug, then=1),\n                default=0,\n                output_field=IntegerField(),\n            ),\n            same_item=Case(\n                When(item__item_id=item_id, then=1),\n                default=0,\n                output_field=IntegerField(),\n            ),\n            next_asset=next_case,\n        ).order_by(\"-next_asset\", \"-same_project\", \"-same_item\", \"sequence\")\n\n    asset = (\n        asset_query.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .first()\n    )\n\n    if spawn_task:\n        # Spawn a task to populate the table for this campaign\n        # We wait to do this until after getting an asset because otherwise there's\n        # a chance all valid assets get grabbed by the task and our query will return\n        # nothing\n        structured_logger.debug(\n            \"Spawned background task to populate cache\",\n            event_code=\"reviewable_next_cache_population\",\n            campaign=campaign,\n            user=user,\n        )\n        populate_task = get_registered_task(\n            \"concordia.tasks.next_asset.reviewable.populate_next_reviewable_for_campaign\"\n        )\n        populate_task.delay(campaign.id)\n\n    return asset\n\n\ndef find_invalid_next_reviewable_campaign_assets(\n    campaign_id: int,\n) -> \"QuerySet[concordia_models.NextReviewableCampaignAsset]\":\n    \"\"\"\n    Return cache rows that are invalid for review for a given campaign.\n\n    Behavior:\n        Identifies NextReviewableCampaignAsset rows that are no longer valid\n        because the underlying asset is not `SUBMITTED` or because the asset is\n        currently reserved.\n\n    Args:\n        campaign_id (int): Identifier of the campaign.\n\n    Returns:\n        QuerySet[concordia_models.NextReviewableCampaignAsset]: Distinct\n            invalid cache rows.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__campaign_id=campaign_id\n    ).values(\"asset_id\")\n\n    status_filtered = concordia_models.NextReviewableCampaignAsset.objects.exclude(\n        asset__transcription_status=concordia_models.TranscriptionStatus.SUBMITTED\n    ).filter(campaign_id=campaign_id)\n\n    reserved_filtered = concordia_models.NextReviewableCampaignAsset.objects.filter(\n        campaign_id=campaign_id, asset_id__in=Subquery(reserved_asset_ids)\n    )\n\n    return (status_filtered | reserved_filtered).distinct()\n"
  },
  {
    "path": "concordia/utils/next_asset/reviewable/topic.py",
    "content": "from typing import Dict\n\nfrom django.contrib.auth.models import User\nfrom django.db import transaction\nfrom django.db.models import Case, IntegerField, Q, QuerySet, Subquery, Value, When\n\nfrom concordia import models as concordia_models\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.utils.celery import get_registered_task\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef _reserved_asset_ids_subq() -> \"QuerySet[Dict[str, int]]\":\n    \"\"\"\n    Return a subquery of reserved asset identifiers.\n\n    Behavior:\n        Produces a subquery suitable for use with `Subquery(...)` and\n        `exclude(pk__in=...)` to filter out assets that currently have an\n        active reservation. This is not filtered to the topic to avoid\n        additional joins.\n\n    Returns:\n        QuerySet[Dict[str, int]]: A queryset of dictionaries with a single key\n            \"asset_id\" corresponding to reserved assets.\n    \"\"\"\n    return concordia_models.AssetTranscriptionReservation.objects.values(\"asset_id\")\n\n\ndef _eligible_reviewable_base_qs(\n    topic: concordia_models.Topic,\n    user: User | None = None,\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Build the base queryset of reviewable assets for a topic.\n\n    Behavior:\n        Restricts to published projects, items, and assets, and to assets whose\n        transcription status is `SUBMITTED`. Optionally excludes assets\n        transcribed by the supplied user.\n\n    Args:\n        topic (concordia_models.Topic): Topic scope for filtering.\n        user (User | None): If provided, exclude assets transcribed by this user.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Reviewable assets, with `item` and\n            `item__project` selected via `select_related`.\n    \"\"\"\n    qs = concordia_models.Asset.objects.filter(\n        item__project__topics=topic.id,\n        item__project__published=True,\n        item__published=True,\n        published=True,\n        transcription_status=concordia_models.TranscriptionStatus.SUBMITTED,\n    ).select_related(\"item\", \"item__project\")\n    if user:\n        qs = qs.exclude(transcription__user=user.id)\n    return qs\n\n\ndef _next_seq_after(pk: int | None) -> int | None:\n    \"\"\"\n    Resolve the sequence number for a given asset primary key.\n\n    Behavior:\n        Convenience utility for ordering logic when advancing within a series\n        of assets.\n\n    Args:\n        pk (int | None): Asset primary key whose sequence to resolve.\n\n    Returns:\n        int | None: The asset's sequence number, or None if `pk` is falsy\n            or the asset does not exist.\n    \"\"\"\n    if not pk:\n        return None\n    return (\n        concordia_models.Asset.objects.filter(pk=pk)\n        .values_list(\"sequence\", flat=True)\n        .first()\n    )\n\n\n@transaction.atomic\ndef _find_reviewable_in_item(\n    topic: concordia_models.Topic,\n    user: User,\n    *,\n    item_id: str,\n    after_asset_pk: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Select the next reviewable asset within the same item.\n\n    Behavior:\n        Attempts a short-circuit within the user's current item to provide a\n        locally contiguous review flow.\n\n    Eligibility:\n        - Asset, Item, and Project are published.\n        - Asset transcription status is `SUBMITTED`.\n        - Asset is not reserved.\n        - Asset was not transcribed by the current user.\n\n    Ordering:\n        - If `after_asset_pk` refers to an asset in the same item whose project\n          is in `topic`, select the earliest asset whose (sequence, id) is\n          strictly greater than the current asset's pair.\n        - Otherwise, select the earliest eligible by (sequence, id).\n\n    Args:\n        topic (concordia_models.Topic): Topic scope.\n        user (User): Current user; used to exclude their own work.\n        item_id (str): Identifier of the item to stay within.\n        after_asset_pk (int | None): Asset primary key to advance from.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or\n            None if no match is available.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__item__item_id=item_id,\n        asset__item__project__topics=topic,\n    ).values(\"asset_id\")\n\n    eligible = (\n        concordia_models.Asset.objects.filter(\n            item__item_id=item_id,\n            item__project__topics=topic,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.SUBMITTED,\n        )\n        .exclude(pk__in=Subquery(reserved_asset_ids))\n        .exclude(transcription__user=user.id)\n    )\n\n    seq_gt_filter = None\n    if after_asset_pk is not None:\n        try:\n            current = (\n                concordia_models.Asset.objects.only(\"id\", \"sequence\", \"item_id\", \"item\")\n                .select_related(\"item\", \"item__project\")\n                .get(pk=after_asset_pk)\n            )\n            if (\n                current.item.item_id == item_id\n                and current.item.project.topics.filter(pk=topic.pk).exists()\n            ):\n                seq_gt_filter = Q(sequence__gt=current.sequence) | (\n                    Q(sequence=current.sequence) & Q(id__gt=after_asset_pk)\n                )\n        except concordia_models.Asset.DoesNotExist:\n            pass\n\n    if seq_gt_filter is not None:\n        eligible = eligible.filter(seq_gt_filter)\n\n    asset = (\n        eligible.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .order_by(\"sequence\", \"id\")\n        .first()\n    )\n\n    structured_logger.debug(\n        \"Item short-circuit (topic reviewable) resolved.\",\n        event_code=\"reviewable_item_short_circuit_topic\",\n        topic=topic,\n        item_id=item_id,\n        after_asset_pk=after_asset_pk,\n        chosen_asset_id=getattr(asset, \"id\", None),\n    )\n    return asset\n\n\n@transaction.atomic\ndef _find_reviewable_in_project(\n    topic: concordia_models.Topic,\n    user: User,\n    *,\n    project_slug: str,\n    after_asset_pk: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Select the first eligible reviewable asset within the same project.\n\n    Behavior:\n        Short-circuit when staying within a project. Sequence is per item,\n        so this returns the first eligible asset, not strictly \"after\" a given\n        asset.\n\n    Eligibility:\n        - Same topic and project.\n        - Asset, Item, and Project are published.\n        - Asset transcription status is `SUBMITTED`.\n        - Asset is not reserved.\n        - Asset was not transcribed by the current user.\n\n    Ordering:\n        Deterministic by (item__item_id, sequence, id).\n\n    Args:\n        topic (concordia_models.Topic): Topic scope.\n        user (User): Current user; used to exclude their own work.\n        project_slug (str): Slug of the project to stay within.\n        after_asset_pk (int | None): Present for parity with the item\n            variant; not used for ordering here.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or\n            None if no match is available.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__item__project__slug=project_slug,\n        asset__item__project__topics=topic,\n    ).values(\"asset_id\")\n\n    eligible = (\n        concordia_models.Asset.objects.filter(\n            item__project__topics=topic,\n            item__project__slug=project_slug,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.SUBMITTED,\n        )\n        .exclude(pk__in=Subquery(reserved_asset_ids))\n        .exclude(transcription__user=user.id)\n        .select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .order_by(\"item__item_id\", \"sequence\", \"id\")\n        .first()\n    )\n\n    structured_logger.debug(\n        \"Project short-circuit (topic reviewable) resolved.\",\n        event_code=\"reviewable_project_short_circuit_topic\",\n        topic=topic,\n        project_slug=project_slug,\n        after_asset_pk=after_asset_pk,\n        chosen_asset_id=getattr(eligible, \"id\", None),\n    )\n    return eligible\n\n\ndef find_new_reviewable_topic_assets(\n    topic: concordia_models.Topic,\n    user: User | None = None,\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Return assets in a topic that are eligible to be added to the cache.\n\n    Behavior:\n        Builds the candidate set for the `NextReviewableTopicAsset` cache by\n        excluding assets that are not `SUBMITTED`, assets already reserved, and\n        assets already present in the cache. Optionally excludes assets\n        transcribed by the provided user.\n\n    Args:\n        topic (concordia_models.Topic): Topic to filter by.\n        user (User | None): If provided, exclude assets transcribed by this user.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Eligible assets ordered by sequence.\n    \"\"\"\n    # Filtering this to the topic would be more costly than just getting all ids\n    # in most cases because it requires joining the asset table to the item table to\n    # the project table to the topic table.\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.values(\n        \"asset_id\"\n    )\n    next_asset_ids = concordia_models.NextReviewableTopicAsset.objects.filter(\n        topic=topic\n    ).values(\"asset_id\")\n\n    queryset = (\n        concordia_models.Asset.objects.filter(\n            item__project__topics=topic.id,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n        )\n        .filter(transcription_status=concordia_models.TranscriptionStatus.SUBMITTED)\n        .exclude(pk__in=Subquery(reserved_asset_ids))\n        .exclude(pk__in=Subquery(next_asset_ids))\n        .order_by(\"sequence\")\n    )\n    if user:\n        queryset = queryset.exclude(transcription__user=user.id)\n    return queryset\n\n\ndef find_next_reviewable_topic_assets(\n    topic: concordia_models.Topic,\n    user: User,\n) -> \"QuerySet[concordia_models.NextReviewableTopicAsset]\":\n    \"\"\"\n    Return cached reviewable assets in a topic not transcribed by the user.\n\n    Behavior:\n        Reads from the `NextReviewableTopicAsset` cache table and filters out\n        assets where the requesting user appears in `transcriber_ids`.\n\n    Args:\n        topic (concordia_models.Topic): Topic to retrieve cached assets from.\n        user (User): Requesting user.\n\n    Returns:\n        QuerySet[concordia_models.NextReviewableTopicAsset]: Cached candidate rows\n            for the given user.\n    \"\"\"\n    return concordia_models.NextReviewableTopicAsset.objects.filter(\n        topic=topic\n    ).exclude(transcriber_ids__contains=[user.id])\n\n\n@transaction.atomic\ndef find_reviewable_topic_asset(\n    topic: concordia_models.Topic,\n    user: User,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Retrieve a single reviewable asset for a user from a topic.\n\n    Behavior:\n        First attempts to select a cached asset from `NextReviewableTopicAsset`.\n        If none is available, falls back to a direct query over `Asset` and\n        triggers a background task to replenish the cache.\n\n    Concurrency:\n        Uses `select_for_update(skip_locked=True, of=(\"self\",))` so only the\n        `Asset` row is locked and concurrent consumers skip locked rows.\n\n    Args:\n        topic (concordia_models.Topic): Topic to search within.\n        user (User): Requesting user; their own transcriptions are excluded.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or None\n            if unavailable.\n    \"\"\"\n    next_asset = (\n        find_next_reviewable_topic_assets(topic, user)\n        .select_for_update(skip_locked=True, of=(\"self\",))\n        .values_list(\"asset_id\", flat=True)\n        .first()\n    )\n\n    spawn_task = False\n    if next_asset:\n        asset_query = concordia_models.Asset.objects.filter(id=next_asset)\n    else:\n        # No asset in the NextReviewableTopicAsset table for this topic,\n        # so fallback to manually finding one\n        structured_logger.debug(\n            \"No cached assets available, falling back to manual lookup\",\n            event_code=\"reviewable_fallback_manual_lookup\",\n            topic=topic,\n            user=user,\n        )\n        spawn_task = True\n        asset_query = find_new_reviewable_topic_assets(topic, user)\n\n    # select_for_update(of=(\"self\",)) causes the row locking only to\n    # apply to the Asset table, rather than also locking joined item table\n    asset = (\n        asset_query.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .first()\n    )\n\n    if spawn_task:\n        # Spawn a task to populate the table for this topic\n        # We wait to do this until after getting an asset because otherwise there's a\n        # a chance all valid assets get grabbed by the task and our query will return\n        # nothing\n        structured_logger.debug(\n            \"Spawned background task to populate cache\",\n            event_code=\"reviewable_cache_population_triggered\",\n            topic=topic,\n            user=user,\n        )\n        populate_task = get_registered_task(\n            \"concordia.tasks.next_asset.reviewable.populate_next_reviewable_for_topic\"\n        )\n        populate_task.delay(topic.id)\n\n    return asset\n\n\ndef find_and_order_potential_reviewable_topic_assets(\n    topic: concordia_models.Topic,\n    user: User,\n    project_slug: str,\n    item_id: str,\n    asset_pk: int | None,\n) -> \"QuerySet[concordia_models.NextReviewableTopicAsset]\":\n    \"\"\"\n    Retrieve and prioritize cached reviewable assets for proximity.\n\n    Behavior:\n        Orders cached candidates from `NextReviewableTopicAsset` to prefer\n        continuity with the user's current location.\n\n    Annotations added to each row (transient fields):\n        - next_asset (int): 1 if the candidate's asset_id is greater than\n            asset_pk, else 0.\n        - same_project (int): 1 if the candidate shares the given\n            project_slug, else 0.\n        - same_item (int): 1 if the candidate shares the given item_id, else 0.\n\n    Prioritization (descending on the following keys, then ascending by sequence):\n        - next_asset\n        - same_project\n        - same_item\n        - sequence\n\n    Args:\n        topic (concordia_models.Topic): Topic to filter by.\n        user (User): Requesting user.\n        project_slug (str): Slug of the user's current project.\n        item_id (str): Identifier of the user's current item.\n        asset_pk (int | None): Identifier of the current asset, if any.\n\n    Returns:\n        QuerySet[concordia_models.NextReviewableTopicAsset]: Prioritized\n            cached candidates.\n    \"\"\"\n    potential_next_assets = find_next_reviewable_topic_assets(topic, user)\n\n    # Handle None safely for the \"next\" signal\n    next_case = (\n        Case(\n            When(asset_id__gt=asset_pk, then=1),\n            default=0,\n            output_field=IntegerField(),\n        )\n        if asset_pk is not None\n        else Value(0, output_field=IntegerField())\n    )\n\n    # We'll favor assets which are in the same item or project as the original:\n    potential_next_assets = potential_next_assets.annotate(\n        same_project=Case(\n            When(project_slug=project_slug, then=1),\n            default=0,\n            output_field=IntegerField(),\n        ),\n        same_item=Case(\n            When(item_item_id=item_id, then=1), default=0, output_field=IntegerField()\n        ),\n        next_asset=next_case,\n    ).order_by(\"-next_asset\", \"-same_project\", \"-same_item\", \"sequence\")\n\n    return potential_next_assets\n\n\n@transaction.atomic\ndef find_next_reviewable_topic_asset(\n    topic: concordia_models.Topic,\n    user: User,\n    project_slug: str,\n    item_id: str,\n    original_asset_id: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Retrieve the next best reviewable asset for a user within a topic.\n\n    Strategy:\n        1. If `item_id` is provided, try a same-item short-circuit that advances\n           by (sequence, id) relative to `original_asset_id`.\n        2. Else, if `project_slug` is provided, select the first eligible asset\n           within that project (short-circuit).\n        3. Else, prioritize cached candidates, and if none are suitable, fall\n           back to computing from `Asset` and trigger cache population.\n\n    Concurrency:\n        Uses `select_for_update(skip_locked=True, of=(\"self\",))` to avoid\n        double-assignments across concurrent consumers.\n\n    Args:\n        topic (concordia_models.Topic): Topic to search within.\n        user (User): Requesting user.\n        project_slug (str): Slug of the user's current project.\n        item_id (str): Identifier of the user's current item.\n        original_asset_id (int | None): Identifier of the asset just reviewed.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or None if\n            unavailable.\n    \"\"\"\n    # Normalize the \"after\" reference\n    try:\n        after_pk = int(original_asset_id) if original_asset_id else None\n    except (TypeError, ValueError):\n        after_pk = None\n\n    # Short-circuit: same item\n    if item_id:\n        asset = _find_reviewable_in_item(\n            topic, user, item_id=item_id, after_asset_pk=after_pk\n        )\n        if asset:\n            return asset\n\n    # Short-circuit: same project\n    if project_slug:\n        asset = _find_reviewable_in_project(\n            topic, user, project_slug=project_slug, after_asset_pk=after_pk\n        )\n        if asset:\n            return asset\n\n    # Cache-backed selection, then manual fallback\n    potential_next_assets = find_and_order_potential_reviewable_topic_assets(\n        topic, user, project_slug, item_id, after_pk\n    )\n    asset_id = (\n        potential_next_assets.select_for_update(skip_locked=True, of=(\"self\",))\n        .values_list(\"asset_id\", flat=True)\n        .first()\n    )\n\n    spawn_task = False\n    if asset_id:\n        asset_query = concordia_models.Asset.objects.filter(id=asset_id)\n    else:\n        # Since we had no potential next assets in the caching table, we have to check\n        # the asset table directly.\n        structured_logger.debug(\n            \"No cached assets matched, falling back to manual lookup\",\n            event_code=\"reviewable_next_fallback_manual\",\n            topic=topic,\n            user=user,\n        )\n        spawn_task = True\n        asset_query = find_new_reviewable_topic_assets(topic, user)\n\n        next_case = (\n            Case(\n                When(id__gt=after_pk, then=1),\n                default=0,\n                output_field=IntegerField(),\n            )\n            if after_pk is not None\n            else Value(0, output_field=IntegerField())\n        )\n\n        asset_query = asset_query.annotate(\n            same_project=Case(\n                When(item__project__slug=project_slug, then=1),\n                default=0,\n                output_field=IntegerField(),\n            ),\n            same_item=Case(\n                When(item__item_id=item_id, then=1),\n                default=0,\n                output_field=IntegerField(),\n            ),\n            next_asset=next_case,\n        ).order_by(\"-next_asset\", \"-same_project\", \"-same_item\", \"sequence\")\n\n    asset = (\n        asset_query.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .first()\n    )\n\n    if spawn_task:\n        # Spawn a task to populate the table for this topic\n        # We wait to do this until after getting an asset because otherwise there's\n        # a chance all valid assets get grabbed by the task and our query will return\n        # nothing\n        structured_logger.debug(\n            \"Spawned background task to populate cache\",\n            event_code=\"reviewable_next_cache_population\",\n            topic=topic,\n            user=user,\n        )\n        populate_task = get_registered_task(\n            \"concordia.tasks.next_asset.reviewable.populate_next_reviewable_for_topic\"\n        )\n        populate_task.delay(topic.id)\n\n    return asset\n\n\ndef find_invalid_next_reviewable_topic_assets(\n    topic_id: int,\n) -> \"QuerySet[concordia_models.NextReviewableTopicAsset]\":\n    \"\"\"\n    Return cache rows that are invalid for review for a given topic.\n\n    Behavior:\n        Identifies `NextReviewableTopicAsset` rows that are no longer valid\n        because the underlying asset is not `SUBMITTED` or because the asset is\n        currently reserved.\n\n    Args:\n        topic_id (int): Identifier of the topic.\n\n    Returns:\n        QuerySet[concordia_models.NextReviewableTopicAsset]: Distinct invalid\n            cache rows.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__item__project__topics=topic_id\n    ).values(\"asset_id\")\n\n    status_filtered = concordia_models.NextReviewableTopicAsset.objects.exclude(\n        asset__transcription_status=concordia_models.TranscriptionStatus.SUBMITTED\n    ).filter(topic_id=topic_id)\n\n    reserved_filtered = concordia_models.NextReviewableTopicAsset.objects.filter(\n        topic_id=topic_id, asset_id__in=Subquery(reserved_asset_ids)\n    )\n\n    return (status_filtered | reserved_filtered).distinct()\n"
  },
  {
    "path": "concordia/utils/next_asset/transcribable/__init__.py",
    "content": "from .campaign import (\n    find_and_order_potential_transcribable_campaign_assets,\n    find_invalid_next_transcribable_campaign_assets,\n    find_new_transcribable_campaign_assets,\n    find_next_transcribable_campaign_asset,\n    find_next_transcribable_campaign_assets,\n    find_transcribable_campaign_asset,\n)\nfrom .topic import (\n    find_and_order_potential_transcribable_topic_assets,\n    find_invalid_next_transcribable_topic_assets,\n    find_new_transcribable_topic_assets,\n    find_next_transcribable_topic_asset,\n    find_next_transcribable_topic_assets,\n    find_transcribable_topic_asset,\n)\n\n__all__ = [\n    \"find_new_transcribable_campaign_assets\",\n    \"find_next_transcribable_campaign_assets\",\n    \"find_transcribable_campaign_asset\",\n    \"find_and_order_potential_transcribable_campaign_assets\",\n    \"find_next_transcribable_campaign_asset\",\n    \"find_and_order_potential_transcribable_topic_assets\",\n    \"find_new_transcribable_topic_assets\",\n    \"find_next_transcribable_topic_asset\",\n    \"find_next_transcribable_topic_assets\",\n    \"find_transcribable_topic_asset\",\n    \"find_invalid_next_transcribable_campaign_assets\",\n    \"find_invalid_next_transcribable_topic_assets\",\n]\n"
  },
  {
    "path": "concordia/utils/next_asset/transcribable/campaign.py",
    "content": "from typing import Dict\n\nfrom django.db import transaction\nfrom django.db.models import Case, IntegerField, Q, QuerySet, Subquery, When\n\nfrom concordia import models as concordia_models\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.utils.celery import get_registered_task\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef _reserved_asset_ids_subq(\n    campaign: concordia_models.Campaign,\n) -> \"QuerySet[Dict[str, int]]\":\n    \"\"\"\n    Return a subquery of reserved asset identifiers for a campaign.\n\n    Behavior:\n        Produces a subquery suitable for use with `Subquery(...)` and\n        `exclude(pk__in=...)` clauses to filter out assets that currently have\n        an active reservation.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign whose reserved assets\n            should be excluded.\n\n    Returns:\n        QuerySet[Dict[str, int]]: A queryset of dictionaries with a single key\n            \"asset_id\" corresponding to reserved assets.\n    \"\"\"\n    return concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__campaign=campaign\n    ).values(\"asset_id\")\n\n\ndef _eligible_transcribable_base_qs(\n    campaign: concordia_models.Campaign,\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Build the base queryset of transcribable assets for a campaign.\n\n    Behavior:\n        Restricts to published projects, items, and assets, and to assets whose\n        transcription status is either `NOT_STARTED` or `IN_PROGRESS`.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign scope for filtering.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Transcribable assets, with `item` and\n            `item__project` selected via `select_related`.\n    \"\"\"\n    return concordia_models.Asset.objects.filter(\n        campaign_id=campaign.id,\n        item__project__published=True,\n        item__published=True,\n        published=True,\n        transcription_status__in=[\n            concordia_models.TranscriptionStatus.NOT_STARTED,\n            concordia_models.TranscriptionStatus.IN_PROGRESS,\n        ],\n    ).select_related(\"item\", \"item__project\")\n\n\ndef _next_seq_after(pk: int | None) -> int | None:\n    \"\"\"\n    Resolve the sequence number for a given asset primary key.\n\n    Behavior:\n        Convenience utility for ordering logic when advancing within a series\n        of assets.\n\n    Args:\n        pk (int | None): Asset primary key whose sequence to resolve.\n\n    Returns:\n        int | None: The asset's sequence number, or None if `pk` is falsy\n            or the asset does not exist.\n    \"\"\"\n    if not pk:\n        return None\n    return (\n        concordia_models.Asset.objects.filter(pk=pk)\n        .values_list(\"sequence\", flat=True)\n        .first()\n    )\n\n\ndef _order_unstarted_first(\n    qs: \"QuerySet[concordia_models.Asset]\",\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Apply a stable ordering that prefers `NOT_STARTED` over `IN_PROGRESS`,\n    then orders by `sequence`.\n\n    Args:\n        qs (QuerySet[concordia_models.Asset]): Base queryset to annotate and sort.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Annotated and ordered queryset with a\n            transient `unstarted` field (1 for `NOT_STARTED`, else 0).\n    \"\"\"\n    return qs.annotate(\n        unstarted=Case(\n            When(\n                transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n                then=1,\n            ),\n            default=0,\n            output_field=IntegerField(),\n        )\n    ).order_by(\"-unstarted\", \"sequence\")\n\n\n@transaction.atomic\ndef _find_transcribable_in_item(\n    campaign: concordia_models.Campaign,\n    *,\n    item_id: str,\n    after_asset_pk: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Fast path: find the next transcribable asset in the same item.\n\n    Behavior:\n        - Exclude the current asset.\n        - Advance by `(sequence, id)` within the item.\n        - Return only `NOT_STARTED` here (defer `IN_PROGRESS` to later fallbacks).\n        - Skip reserved assets.\n        - Respect published flags on campaign, project, item, and asset.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign scope.\n        item_id (str): Identifier of the item to stay within.\n        after_asset_pk (int | None): Asset primary key to advance from.\n\n    Returns:\n        concordia_models.Asset | None: The next eligible asset, or None if none.\n    \"\"\"\n    if not item_id:\n        return None\n\n    # Find current sequence to advance correctly within the item\n    cur_seq = None\n    if after_asset_pk:\n        cur_seq = (\n            concordia_models.Asset.objects.filter(pk=after_asset_pk)\n            .values_list(\"sequence\", flat=True)\n            .first()\n        )\n\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.values(\n        \"asset_id\"\n    )\n\n    base = concordia_models.Asset.objects.filter(\n        item__item_id=item_id,\n        item__published=True,\n        item__project__published=True,\n        published=True,\n        campaign_id=campaign.id,\n    ).exclude(pk__in=Subquery(reserved_asset_ids))\n\n    if after_asset_pk:\n        if cur_seq is not None:\n            base = base.filter(\n                Q(sequence__gt=cur_seq)\n                | (Q(sequence=cur_seq) & Q(id__gt=after_asset_pk))\n            )\n        else:\n            base = base.exclude(id=after_asset_pk)\n\n    # ONLY NOT_STARTED in this short-circuit\n    return (\n        base.filter(\n            transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED\n        )\n        .order_by(\"sequence\", \"id\")\n        .first()\n    )\n\n\ndef _find_transcribable_not_started_in_project(\n    campaign: concordia_models.Campaign,\n    *,\n    project_slug: str,\n    exclude_item_id: str | None = None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Fast path: find the first `NOT_STARTED` asset in the same project.\n\n    Behavior:\n        Allows different items (optionally excluding the current item to avoid\n        bouncing back). Uses a stable ordering by `(item_id, sequence, id)`.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign scope.\n        project_slug (str): Slug of the project to stay within.\n        exclude_item_id (str | None): If provided, exclude this item.\n\n    Returns:\n        concordia_models.Asset | None: The first eligible asset, or None if none.\n    \"\"\"\n    if not project_slug:\n        return None\n\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.values(\n        \"asset_id\"\n    )\n\n    base = concordia_models.Asset.objects.filter(\n        campaign_id=campaign.id,\n        item__project__slug=project_slug,\n        item__published=True,\n        item__project__published=True,\n        published=True,\n        transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n    ).exclude(pk__in=Subquery(reserved_asset_ids))\n\n    if exclude_item_id:\n        base = base.exclude(item__item_id=exclude_item_id)\n\n    return base.order_by(\"item__item_id\", \"sequence\", \"id\").first()\n\n\ndef find_new_transcribable_campaign_assets(\n    campaign: concordia_models.Campaign,\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Return assets in a campaign that are eligible to be added to the cache.\n\n    Behavior:\n        Builds the candidate set for the `NextTranscribableCampaignAsset` cache\n        by excluding assets that are not `NOT_STARTED` or `IN_PROGRESS`, assets\n        already reserved, and assets already present in the cache.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to filter by.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Eligible assets ordered by `sequence`.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__campaign=campaign\n    ).values(\"asset_id\")\n    next_asset_ids = concordia_models.NextTranscribableCampaignAsset.objects.filter(\n        campaign=campaign\n    ).values(\"asset_id\")\n\n    return (\n        concordia_models.Asset.objects.filter(\n            campaign_id=campaign.id,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n        )\n        .filter(\n            Q(transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED)\n            | Q(transcription_status=concordia_models.TranscriptionStatus.IN_PROGRESS)\n        )\n        .exclude(pk__in=Subquery(reserved_asset_ids))\n        .exclude(pk__in=Subquery(next_asset_ids))\n        .order_by(\"sequence\")\n    )\n\n\ndef find_next_transcribable_campaign_assets(\n    campaign: concordia_models.Campaign,\n) -> \"QuerySet[concordia_models.NextTranscribableCampaignAsset]\":\n    \"\"\"\n    Return all cached transcribable assets for a campaign.\n\n    Behavior:\n        Reads from the `NextTranscribableCampaignAsset` cache table for the\n        given campaign.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to retrieve cached assets for.\n\n    Returns:\n        QuerySet[concordia_models.NextTranscribableCampaignAsset]: Cached candidates.\n    \"\"\"\n    return concordia_models.NextTranscribableCampaignAsset.objects.filter(\n        campaign=campaign\n    )\n\n\n@transaction.atomic\ndef find_transcribable_campaign_asset(\n    campaign: concordia_models.Campaign,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Retrieve a single transcribable asset from the campaign.\n\n    Behavior:\n        First attempts to select a cached asset from\n        `NextTranscribableCampaignAsset`. If none is available, falls back to a\n        direct query over `Asset` and triggers a background task to replenish\n        the cache.\n\n    Concurrency:\n        Uses `select_for_update(skip_locked=True, of=(\"self\",))` so only the\n        `Asset` row is locked and concurrent consumers skip locked rows.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to search within.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or None if\n            unavailable.\n    \"\"\"\n    next_asset = (\n        find_next_transcribable_campaign_assets(campaign)\n        .select_for_update(skip_locked=True, of=(\"self\",))\n        .values_list(\"asset_id\", flat=True)\n        .first()\n    )\n\n    spawn_task = False\n    if next_asset:\n        asset_query = concordia_models.Asset.objects.filter(id=next_asset)\n    else:\n        # No asset in the NextTranscribableCampaignAsset table for this campaign,\n        # so fallback to manually finding on\n        structured_logger.debug(\n            \"No cached assets available, falling back to manual lookup\",\n            event_code=\"transcribable_fallback_manual_lookup\",\n            campaign=campaign,\n        )\n        asset_query = find_new_transcribable_campaign_assets(campaign)\n        spawn_task = True\n    # select_for_update(of=(\"self\",)) causes the row locking only to\n    # apply to the Asset table, rather than also locking joined item table\n    asset = (\n        asset_query.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .first()\n    )\n    if spawn_task:\n        # Spawn a task to populate the table for this campaign\n        # We wait to do this until after getting an asset because otherwise there's a\n        # a chance all valid assets get grabbed by the task and our query will return\n        # nothing\n        structured_logger.debug(\n            \"Spawned background task to populate cache\",\n            event_code=\"transcribable_cache_population_triggered\",\n            campaign=campaign,\n        )\n        populate_task = get_registered_task(\n            \"concordia.tasks.next_asset.transcribable.populate_next_transcribable_for_campaign\"\n        )\n        populate_task.delay(campaign.id)\n    return asset\n\n\ndef find_and_order_potential_transcribable_campaign_assets(\n    campaign: concordia_models.Campaign,\n    project_slug: str,\n    item_id: str,\n    asset_pk: int,\n) -> \"QuerySet[concordia_models.NextTranscribableCampaignAsset]\":\n    \"\"\"\n    Retrieve and prioritize cached transcribable assets based on proximity\n    and status.\n\n    Behavior:\n        Orders cached candidates from `NextTranscribableCampaignAsset` to prefer:\n        - `NOT_STARTED` over `IN_PROGRESS` (via transient `unstarted` flag),\n        - same project,\n        - same item,\n        then by `sequence` and `asset_id` for stability.\n\n    Annotations added to each row (transient fields):\n        - unstarted (int): 1 if transcription status is `NOT_STARTED`, else 0.\n        - same_project (int): 1 if the candidate shares `project_slug`, else 0.\n        - same_item (int): 1 if the candidate shares `item_id`, else 0.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to filter by.\n        project_slug (str): Slug of the original asset's project.\n        item_id (str): Item identifier of the original asset.\n        asset_pk (int): Primary key of the original asset.\n\n    Returns:\n        QuerySet[concordia_models.NextTranscribableCampaignAsset]: Prioritized\n            cached candidates.\n    \"\"\"\n    potential_next_assets = find_next_transcribable_campaign_assets(campaign)\n\n    potential_next_assets = potential_next_assets.annotate(\n        unstarted=Case(\n            When(\n                transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n                then=1,\n            ),\n            default=0,\n            output_field=IntegerField(),\n        ),\n        same_project=Case(\n            When(project_slug=project_slug, then=1),\n            default=0,\n            output_field=IntegerField(),\n        ),\n        same_item=Case(\n            When(item_item_id=item_id, then=1),\n            default=0,\n            output_field=IntegerField(),\n        ),\n    ).order_by(\n        \"-unstarted\",\n        \"-same_project\",\n        \"-same_item\",\n        \"sequence\",\n        \"asset_id\",\n    )\n\n    return potential_next_assets\n\n\n@transaction.atomic\ndef find_next_transcribable_campaign_asset(\n    campaign: concordia_models.Campaign,\n    project_slug: str,\n    item_id: str,\n    original_asset_id: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Retrieve the next best transcribable asset within a campaign.\n\n    Priority for short-circuit selection (before cache and fallback):\n        1) If `item_id` is provided, return the next `NOT_STARTED` asset in\n           that item by sequence (strictly after the original asset when known).\n        2) If `project_slug` is provided, return the first `NOT_STARTED` asset\n           in that project (ordered by item id, then sequence), excluding the\n           current item to keep moving forward.\n\n    If none of the above match, fall back to the cache-backed path:\n        Attempts to retrieve a candidate from `NextTranscribableCampaignAsset`. If\n        none is found, compute from `Asset` and trigger cache population.\n\n    After exhausting `NOT_STARTED` options, consider `IN_PROGRESS` assets in the\n    same item (strictly after the original when known).\n\n    Concurrency:\n        Uses `select_for_update(skip_locked=True, of=(\"self\",))` to avoid\n        double-assignments across concurrent consumers.\n\n    Args:\n        campaign (concordia_models.Campaign): Campaign to search within.\n        project_slug (str): Slug of the current project.\n        item_id (str): Identifier of the current item.\n        original_asset_id (int | None): Identifier of the asset just transcribed.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or None if\n            unavailable.\n    \"\"\"\n    # Normalize original_asset_id for safe use in filters/comparisons\n    try:\n        original_pk = int(original_asset_id) if original_asset_id is not None else None\n    except (TypeError, ValueError):\n        original_pk = None\n\n    # Resolve \"after sequence\" only when the original asset belongs to the same item.\n    after_seq = None\n    if item_id and original_pk is not None:\n        try:\n            orig = (\n                concordia_models.Asset.objects.select_related(\"item\")\n                .only(\"id\", \"sequence\", \"item__item_id\")\n                .get(pk=original_pk)\n            )\n            if getattr(orig.item, \"item_id\", None) == item_id:\n                after_seq = orig.sequence\n        except concordia_models.Asset.DoesNotExist:\n            after_seq = None\n\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.values(\n        \"asset_id\"\n    )\n\n    # Short-circuit: same item and NOT_STARTED after current sequence\n    if item_id:\n        qs = concordia_models.Asset.objects.filter(\n            campaign_id=campaign.id,\n            item__item_id=item_id,\n            item__published=True,\n            item__project__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n        ).exclude(pk__in=Subquery(reserved_asset_ids))\n        if original_pk is not None:\n            qs = qs.exclude(pk=original_pk)\n        if after_seq is not None:\n            qs = qs.filter(\n                Q(sequence__gt=after_seq)\n                | (Q(sequence=after_seq) & Q(id__gt=original_pk))\n            )\n        asset = (\n            qs.order_by(\"sequence\", \"id\")\n            .select_for_update(skip_locked=True, of=(\"self\",))\n            .select_related(\"item\", \"item__project\")\n            .first()\n        )\n        if asset:\n            return asset\n\n    # Short-circuit: same project and NOT_STARTED (avoid current item and original)\n    if project_slug:\n        candidate = concordia_models.Asset.objects.filter(\n            campaign_id=campaign.id,\n            item__project__slug=project_slug,\n            item__published=True,\n            item__project__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n        ).exclude(pk__in=Subquery(reserved_asset_ids))\n        if original_pk is not None:\n            candidate = candidate.exclude(pk=original_pk)\n        if item_id:\n            candidate = candidate.exclude(item__item_id=item_id)\n\n        asset = (\n            candidate.order_by(\"item__item_id\", \"sequence\", \"id\")\n            .select_for_update(skip_locked=True, of=(\"self\",))\n            .select_related(\"item\", \"item__project\")\n            .first()\n        )\n        if asset:\n            return asset\n\n    # Cache-backed selection (NOT_STARTED), then manual fallback (also NOT_STARTED)\n    potential_next_assets = find_and_order_potential_transcribable_campaign_assets(\n        campaign, project_slug, item_id, original_asset_id\n    )\n    if original_pk is not None:\n        potential_next_assets = potential_next_assets.exclude(asset_id=original_pk)\n    if item_id:\n        # Keep moving forward: avoid bouncing to the same item\n        potential_next_assets = potential_next_assets.exclude(item_item_id=item_id)\n\n    asset_id = (\n        potential_next_assets.select_for_update(skip_locked=True, of=(\"self\",))\n        .values_list(\"asset_id\", flat=True)\n        .first()\n    )\n\n    spawn_task = False\n    if asset_id:\n        asset_query = concordia_models.Asset.objects.filter(id=asset_id)\n    else:\n        structured_logger.debug(\n            \"No cached assets matched, falling back to manual lookup\",\n            event_code=\"transcribable_next_fallback_manual\",\n            campaign=campaign,\n        )\n        spawn_task = True\n        asset_query = find_new_transcribable_campaign_assets(campaign)\n        if original_pk is not None:\n            asset_query = asset_query.exclude(pk=original_pk)\n        if item_id:\n            asset_query = asset_query.exclude(item__item_id=item_id)\n        asset_query = asset_query.annotate(\n            unstarted=Case(\n                When(\n                    transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n                    then=1,\n                ),\n                default=0,\n                output_field=IntegerField(),\n            ),\n            same_project=Case(\n                When(item__project__slug=project_slug, then=1),\n                default=0,\n                output_field=IntegerField(),\n            ),\n            same_item=Case(\n                When(item__item_id=item_id, then=1),\n                default=0,\n                output_field=IntegerField(),\n            ),\n        ).order_by(\n            \"-unstarted\",\n            \"-same_project\",\n            \"-same_item\",\n            \"sequence\",\n            \"id\",\n        )\n\n    asset = (\n        asset_query.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .first()\n    )\n    if asset:\n        if spawn_task:\n            structured_logger.debug(\n                \"Spawned background task to populate cache\",\n                event_code=\"transcribable_next_cache_population\",\n                campaign=campaign,\n            )\n            populate_task = get_registered_task(\n                \"concordia.tasks.next_asset.transcribable.populate_next_transcribable_for_campaign\"\n            )\n            populate_task.delay(campaign.id)\n        return asset\n\n    # Only now consider same-item IN_PROGRESS after current sequence\n    if item_id:\n        qs = concordia_models.Asset.objects.filter(\n            campaign_id=campaign.id,\n            item__item_id=item_id,\n            item__published=True,\n            item__project__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.IN_PROGRESS,\n        ).exclude(pk__in=Subquery(reserved_asset_ids))\n        if original_pk is not None:\n            qs = qs.exclude(pk=original_pk)\n        if after_seq is not None:\n            qs = qs.filter(\n                Q(sequence__gt=after_seq)\n                | (Q(sequence=after_seq) & Q(id__gt=original_pk))\n            )\n        asset = (\n            qs.order_by(\"sequence\", \"id\")\n            .select_for_update(skip_locked=True, of=(\"self\",))\n            .select_related(\"item\", \"item__project\")\n            .first()\n        )\n        if asset:\n            return asset\n\n    return None\n\n\ndef find_invalid_next_transcribable_campaign_assets(\n    campaign_id: int,\n) -> \"QuerySet[concordia_models.NextTranscribableCampaignAsset]\":\n    \"\"\"\n    Return cached rows that are invalid for transcription for a campaign.\n\n    Behavior:\n        Identifies `NextTranscribableCampaignAsset` rows that are no longer valid\n        because the underlying asset is neither `NOT_STARTED` nor `IN_PROGRESS`,\n        or because the asset is currently reserved.\n\n    Args:\n        campaign_id (int): Identifier of the campaign.\n\n    Returns:\n        QuerySet[concordia_models.NextTranscribableCampaignAsset]: Distinct invalid\n            cache rows.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__campaign_id=campaign_id\n    ).values(\"asset_id\")\n\n    # Assets with transcription_status not eligible for transcription\n    status_filtered = concordia_models.NextTranscribableCampaignAsset.objects.filter(\n        campaign_id=campaign_id\n    ).exclude(\n        asset__transcription_status__in=[\n            concordia_models.TranscriptionStatus.NOT_STARTED,\n            concordia_models.TranscriptionStatus.IN_PROGRESS,\n        ]\n    )\n\n    # Assets that are reserved\n    reserved_filtered = concordia_models.NextTranscribableCampaignAsset.objects.filter(\n        campaign_id=campaign_id, asset_id__in=Subquery(reserved_asset_ids)\n    )\n\n    return (status_filtered | reserved_filtered).distinct()\n"
  },
  {
    "path": "concordia/utils/next_asset/transcribable/topic.py",
    "content": "from typing import Dict\n\nfrom django.db import transaction\nfrom django.db.models import Case, IntegerField, Q, QuerySet, Subquery, When\n\nfrom concordia import models as concordia_models\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.utils.celery import get_registered_task\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef _reserved_asset_ids_subq() -> \"QuerySet[Dict[str, int]]\":\n    \"\"\"\n    Return a subquery of reserved asset identifiers.\n\n    Behavior:\n        Not filtered to the topic to avoid extra joins. Produces a subquery\n        suitable for use with `Subquery(...)` and `exclude(pk__in=...)`\n        clauses to filter out assets that currently have an active\n        reservation.\n\n    Returns:\n        QuerySet[Dict[str, int]]: A queryset of dictionaries with a single key\n            \"asset_id\" corresponding to reserved assets.\n    \"\"\"\n    return concordia_models.AssetTranscriptionReservation.objects.values(\"asset_id\")\n\n\ndef _eligible_transcribable_base_qs(\n    topic: \"concordia_models.Topic\",\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Build the base queryset of transcribable assets for a topic.\n\n    Behavior:\n        Restricts to published projects, items, and assets, and to assets whose\n        transcription status is either `NOT_STARTED` or `IN_PROGRESS`.\n\n    Args:\n        topic (concordia_models.Topic): Topic scope for filtering.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Transcribable assets, with `item` and\n            `item__project` selected via `select_related`.\n    \"\"\"\n    return concordia_models.Asset.objects.filter(\n        item__project__topics=topic.id,\n        item__project__published=True,\n        item__published=True,\n        published=True,\n        transcription_status__in=[\n            concordia_models.TranscriptionStatus.NOT_STARTED,\n            concordia_models.TranscriptionStatus.IN_PROGRESS,\n        ],\n    ).select_related(\"item\", \"item__project\")\n\n\ndef _next_seq_after(pk: int | None) -> int | None:\n    \"\"\"\n    Resolve the sequence number for a given asset primary key.\n\n    Behavior:\n        Convenience utility for ordering logic when advancing within a series\n        of assets.\n\n    Args:\n        pk (int | None): Asset primary key whose sequence to resolve.\n\n    Returns:\n        int | None: The asset's sequence number, or None if `pk` is falsy\n            or the asset does not exist.\n    \"\"\"\n    if not pk:\n        return None\n    return (\n        concordia_models.Asset.objects.filter(pk=pk)\n        .values_list(\"sequence\", flat=True)\n        .first()\n    )\n\n\ndef _order_unstarted_first(\n    qs: \"QuerySet[concordia_models.Asset]\",\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Apply a stable ordering that prefers `NOT_STARTED` over `IN_PROGRESS`,\n    then orders by `sequence`.\n\n    Args:\n        qs (QuerySet[concordia_models.Asset]): Base queryset to annotate and sort.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Annotated and ordered queryset with a\n            transient `unstarted` field (1 for `NOT_STARTED`, else 0).\n    \"\"\"\n    return qs.annotate(\n        unstarted=Case(\n            When(\n                transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n                then=1,\n            ),\n            default=0,\n            output_field=IntegerField(),\n        )\n    ).order_by(\"-unstarted\", \"sequence\")\n\n\ndef _find_transcribable_in_item_for_topic(\n    topic: \"concordia_models.Topic\",\n    *,\n    item_id: str,\n    after_asset_pk: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Fast path: find the next transcribable asset in the same item, constrained\n    to the topic.\n\n    Behavior:\n        - Asset must belong to a project that is in this topic.\n        - Exclude the current asset.\n        - Advance by `(sequence, id)` within the item.\n        - Return only `NOT_STARTED` here (defer `IN_PROGRESS` to later fallbacks).\n        - Skip reserved assets.\n        - Respect published flags.\n\n    Args:\n        topic (concordia_models.Topic): Topic scope.\n        item_id (str): Identifier of the item to stay within.\n        after_asset_pk (int | None): Asset primary key to advance from.\n\n    Returns:\n        concordia_models.Asset | None: The next eligible asset, or None if none.\n    \"\"\"\n    if not item_id:\n        return None\n\n    cur_seq = None\n    if after_asset_pk:\n        cur_seq = (\n            concordia_models.Asset.objects.filter(pk=after_asset_pk)\n            .values_list(\"sequence\", flat=True)\n            .first()\n        )\n\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.values(\n        \"asset_id\"\n    )\n\n    base = concordia_models.Asset.objects.filter(\n        item__item_id=item_id,\n        item__project__topics=topic.id,\n        item__published=True,\n        item__project__published=True,\n        published=True,\n    ).exclude(pk__in=Subquery(reserved_asset_ids))\n\n    if after_asset_pk:\n        if cur_seq is not None:\n            base = base.filter(\n                Q(sequence__gt=cur_seq)\n                | (Q(sequence=cur_seq) & Q(id__gt=after_asset_pk))\n            )\n        else:\n            base = base.exclude(id=after_asset_pk)\n\n    # ONLY NOT_STARTED in this short-circuit\n    return (\n        base.filter(\n            transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED\n        )\n        .order_by(\"sequence\", \"id\")\n        .first()\n    )\n\n\ndef _find_transcribable_not_started_in_project_for_topic(\n    topic: \"concordia_models.Topic\",\n    *,\n    project_slug: str,\n    exclude_item_id: str | None = None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Fast path: find the first `NOT_STARTED` asset in the same project within\n    this topic.\n\n    Behavior:\n        Optionally exclude the current item. Uses a stable ordering by\n        `(item__item_id, sequence, id)`.\n\n    Args:\n        topic (concordia_models.Topic): Topic scope.\n        project_slug (str): Slug of the project to stay within.\n        exclude_item_id (str | None): If provided, exclude this item.\n\n    Returns:\n        concordia_models.Asset | None: The first eligible asset, or None if none.\n    \"\"\"\n    if not project_slug:\n        return None\n\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.values(\n        \"asset_id\"\n    )\n\n    base = concordia_models.Asset.objects.filter(\n        item__project__topics=topic.id,\n        item__project__slug=project_slug,\n        item__published=True,\n        item__project__published=True,\n        published=True,\n        transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n    ).exclude(pk__in=Subquery(reserved_asset_ids))\n\n    if exclude_item_id:\n        base = base.exclude(item__item_id=exclude_item_id)\n\n    return base.order_by(\"item__item_id\", \"sequence\", \"id\").first()\n\n\ndef find_new_transcribable_topic_assets(\n    topic: \"concordia_models.Topic\",\n) -> \"QuerySet[concordia_models.Asset]\":\n    \"\"\"\n    Return assets in a topic that are eligible to be added to the cache.\n\n    Behavior:\n        Builds the candidate set for the `NextTranscribableTopicAsset` cache by\n        excluding assets that are not `NOT_STARTED` or `IN_PROGRESS`, assets\n        already reserved, and assets already present in the cache.\n\n    Args:\n        topic (concordia_models.Topic): Topic to filter by.\n\n    Returns:\n        QuerySet[concordia_models.Asset]: Eligible assets ordered by `sequence`.\n    \"\"\"\n    # Filtering this to the topic would be more costly than just getting all ids\n    # in most cases because it requires joining the asset table to the item table to\n    # the project table to the topic table.\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.values(\n        \"asset_id\"\n    )\n    next_asset_ids = concordia_models.NextTranscribableTopicAsset.objects.filter(\n        topic=topic\n    ).values(\"asset_id\")\n\n    return (\n        concordia_models.Asset.objects.filter(\n            item__project__topics=topic.id,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n        )\n        .filter(\n            Q(transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED)\n            | Q(transcription_status=concordia_models.TranscriptionStatus.IN_PROGRESS)\n        )\n        .exclude(pk__in=Subquery(reserved_asset_ids))\n        .exclude(pk__in=Subquery(next_asset_ids))\n        .order_by(\"sequence\")\n    )\n\n\ndef find_next_transcribable_topic_assets(\n    topic: \"concordia_models.Topic\",\n) -> \"QuerySet[concordia_models.NextTranscribableTopicAsset]\":\n    \"\"\"\n    Return all cached transcribable assets for a topic.\n\n    Behavior:\n        Reads from the `NextTranscribableTopicAsset` cache table for the\n        given topic.\n\n    Args:\n        topic (concordia_models.Topic): Topic to retrieve cached assets for.\n\n    Returns:\n        QuerySet[concordia_models.NextTranscribableTopicAsset]: Cached candidates.\n    \"\"\"\n    return concordia_models.NextTranscribableTopicAsset.objects.filter(topic=topic)\n\n\n@transaction.atomic\ndef find_transcribable_topic_asset(\n    topic: \"concordia_models.Topic\",\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Retrieve a single transcribable asset from the topic.\n\n    Behavior:\n        First attempts to select a cached asset from\n        `NextTranscribableTopicAsset`. If none is available, falls back to a\n        direct query over `Asset` and triggers a background task to replenish\n        the cache.\n\n    Concurrency:\n        Uses `select_for_update(skip_locked=True, of=(\"self\",))` so only the\n        `Asset` row is locked and concurrent consumers skip locked rows.\n\n    Args:\n        topic (concordia_models.Topic): Topic to search within.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or None if\n            unavailable.\n    \"\"\"\n    next_asset = (\n        find_next_transcribable_topic_assets(topic)\n        .select_for_update(skip_locked=True, of=(\"self\",))\n        .values_list(\"asset_id\", flat=True)\n        .first()\n    )\n\n    spawn_task = False\n    if next_asset:\n        asset_query = concordia_models.Asset.objects.filter(id=next_asset)\n    else:\n        # No asset in the NextTranscribableTopicAsset table for this topic,\n        # so fallback to manually finding one\n        structured_logger.debug(\n            \"No cached assets available, falling back to manual lookup\",\n            event_code=\"transcribable_fallback_manual_lookup\",\n            topic=topic,\n        )\n        asset_query = find_new_transcribable_topic_assets(topic)\n        spawn_task = True\n    # select_for_update(of=(\"self\",)) causes the row locking only to\n    # apply to the Asset table, rather than also locking joined item table\n    asset = (\n        asset_query.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .first()\n    )\n    if spawn_task:\n        # Spawn a task to populate the table for this topic\n        # We wait to do this until after getting an asset because otherwise there's a\n        # a chance all valid assets get grabbed by the task and our query will return\n        # nothing\n        structured_logger.debug(\n            \"Spawned background task to populate cache\",\n            event_code=\"transcribable_cache_population_triggered\",\n            topic=topic,\n        )\n        populate_task = get_registered_task(\n            \"concordia.tasks.next_asset.transcribable.populate_next_transcribable_for_topic\"\n        )\n        populate_task.delay(topic.id)\n    return asset\n\n\ndef find_and_order_potential_transcribable_topic_assets(\n    topic: \"concordia_models.Topic\",\n    project_slug: str,\n    item_id: str,\n    asset_pk: int,\n) -> \"QuerySet[concordia_models.NextTranscribableTopicAsset]\":\n    \"\"\"\n    Retrieve and prioritize cached transcribable assets based on proximity\n    and status.\n\n    Behavior:\n        Orders cached candidates from `NextTranscribableTopicAsset` to prefer:\n        - `NOT_STARTED` over `IN_PROGRESS` (via transient `unstarted` flag),\n        - same project,\n        - same item,\n        then by `sequence` and `asset_id` for stability.\n\n    Annotations added to each row (transient fields):\n        - unstarted (int): 1 if transcription status is `NOT_STARTED`, else 0.\n        - same_project (int): 1 if the candidate shares `project_slug`, else 0.\n        - same_item (int): 1 if the candidate shares `item_id`, else 0.\n\n    Args:\n        topic (concordia_models.Topic): Topic to filter by.\n        project_slug (str): Slug of the original asset's project.\n        item_id (str): Item identifier of the original asset.\n        asset_pk (int): Primary key of the original asset.\n\n    Returns:\n        QuerySet[concordia_models.NextTranscribableTopicAsset]: Prioritized\n            cached candidates.\n    \"\"\"\n    potential_next_assets = find_next_transcribable_topic_assets(topic)\n\n    potential_next_assets = potential_next_assets.annotate(\n        unstarted=Case(\n            When(\n                transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n                then=1,\n            ),\n            default=0,\n            output_field=IntegerField(),\n        ),\n        same_project=Case(\n            When(project_slug=project_slug, then=1),\n            default=0,\n            output_field=IntegerField(),\n        ),\n        same_item=Case(\n            When(item_item_id=item_id, then=1),\n            default=0,\n            output_field=IntegerField(),\n        ),\n    ).order_by(\n        \"-unstarted\",\n        \"-same_project\",\n        \"-same_item\",\n        \"sequence\",\n        \"asset_id\",\n    )\n\n    return potential_next_assets\n\n\n@transaction.atomic\ndef find_next_transcribable_topic_asset(\n    topic: \"concordia_models.Topic\",\n    project_slug: str,\n    item_id: str,\n    original_asset_id: int | None,\n) -> \"concordia_models.Asset | None\":\n    \"\"\"\n    Retrieve the next best transcribable asset within a topic.\n\n    Priority for short-circuit selection (before cache and fallback):\n        1) If `item_id` is provided, return the next `NOT_STARTED` asset in\n           that item by sequence (strictly after the original asset when known).\n        2) If `project_slug` is provided, return the first `NOT_STARTED` asset\n           in that project (ordered by item id, then sequence), excluding the\n           current item to keep moving forward.\n\n    If none of the above match, fall back to the cache-backed path:\n        Attempts to retrieve a candidate from `NextTranscribableTopicAsset`. If\n        none is found, compute from `Asset` and trigger cache population.\n\n    After exhausting `NOT_STARTED` options, consider `IN_PROGRESS` assets in the\n    same item (strictly after the original when known).\n\n    Concurrency:\n        Uses `select_for_update(skip_locked=True, of=(\"self\",))` to avoid\n        double-assignments across concurrent consumers.\n\n    Args:\n        topic (concordia_models.Topic): Topic to search within.\n        project_slug (str): Slug of the current project.\n        item_id (str): Identifier of the current item.\n        original_asset_id (int | None): Identifier of the asset just transcribed.\n\n    Returns:\n        concordia_models.Asset | None: A locked eligible asset, or None if\n            unavailable.\n    \"\"\"\n    # Resolve original context safely (int or digit-string only)\n    after_seq = None\n    orig = None\n    orig_item_id = None\n    orig_id_valid = isinstance(original_asset_id, int) or (\n        isinstance(original_asset_id, str) and original_asset_id.isdigit()\n    )\n    if orig_id_valid:\n        try:\n            orig = (\n                concordia_models.Asset.objects.select_related(\"item\")\n                .only(\"id\", \"sequence\", \"item__item_id\")\n                .get(pk=original_asset_id)\n            )\n            orig_item_id = getattr(orig.item, \"item_id\", None)\n            # Keep sequence handy for same-item gating in any path\n            after_seq = orig.sequence\n        except concordia_models.Asset.DoesNotExist:\n            orig = None\n            orig_item_id = None\n            after_seq = None\n\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.values(\n        \"asset_id\"\n    )\n\n    # Short-circuit: same item and NOT_STARTED after current sequence\n    if item_id:\n        qs = concordia_models.Asset.objects.filter(\n            item__project__topics=topic.id,\n            item__item_id=item_id,\n            item__published=True,\n            item__project__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n        ).exclude(pk__in=Subquery(reserved_asset_ids))\n        if orig_id_valid:\n            qs = qs.exclude(pk=original_asset_id)\n        if after_seq is not None and orig_item_id == item_id:\n            qs = qs.filter(\n                Q(sequence__gt=after_seq)\n                | (Q(sequence=after_seq) & Q(id__gt=int(original_asset_id)))\n            )\n        asset = (\n            qs.order_by(\"sequence\", \"id\")\n            .select_for_update(skip_locked=True, of=(\"self\",))\n            .select_related(\"item\", \"item__project\")\n            .first()\n        )\n        if asset:\n            return asset\n\n    # Short-circuit: same project and NOT_STARTED (topic-constrained)\n    if project_slug:\n        candidate = concordia_models.Asset.objects.filter(\n            item__project__topics=topic.id,\n            item__project__slug=project_slug,\n            item__published=True,\n            item__project__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n        ).exclude(pk__in=Subquery(reserved_asset_ids))\n        if orig_id_valid:\n            candidate = candidate.exclude(pk=original_asset_id)\n        if item_id:\n            candidate = candidate.exclude(item__item_id=item_id)\n\n        asset = (\n            candidate.order_by(\"item__item_id\", \"sequence\", \"id\")\n            .select_for_update(skip_locked=True, of=(\"self\",))\n            .select_related(\"item\", \"item__project\")\n            .first()\n        )\n        if asset:\n            return asset\n\n    # Cache-backed selection (NOT_STARTED anywhere), then manual fallback.\n    potential_next_assets = find_and_order_potential_transcribable_topic_assets(\n        topic, project_slug, item_id, original_asset_id\n    )\n    if orig_id_valid:\n        potential_next_assets = potential_next_assets.exclude(\n            asset_id=original_asset_id\n        )\n    if item_id:\n        potential_next_assets = potential_next_assets.exclude(item_item_id=item_id)\n\n    asset_id = (\n        potential_next_assets.select_for_update(skip_locked=True, of=(\"self\",))\n        .values_list(\"asset_id\", flat=True)\n        .first()\n    )\n\n    spawn_task = False\n    if asset_id:\n        asset_query = concordia_models.Asset.objects.filter(id=asset_id)\n    else:\n        structured_logger.debug(\n            \"No cached assets matched, falling back to manual lookup\",\n            event_code=\"transcribable_next_fallback_manual\",\n            topic=topic,\n        )\n        spawn_task = True\n        asset_query = find_new_transcribable_topic_assets(topic)\n        if orig_id_valid:\n            asset_query = asset_query.exclude(pk=original_asset_id)\n        if item_id:\n            asset_query = asset_query.exclude(item__item_id=item_id)\n        # If we know the original's item/seq, keep moving forward within that item\n        if orig_item_id and after_seq is not None:\n            asset_query = asset_query.exclude(\n                Q(item__item_id=orig_item_id, sequence__lte=after_seq)\n            )\n\n        # Prefer same project and same item; if item_id is blank, prefer original's item\n        ref_item_id = item_id or orig_item_id\n        asset_query = asset_query.annotate(\n            unstarted=Case(\n                When(\n                    transcription_status=concordia_models.TranscriptionStatus.NOT_STARTED,\n                    then=1,\n                ),\n                default=0,\n                output_field=IntegerField(),\n            ),\n            same_project=Case(\n                When(item__project__slug=project_slug, then=1),\n                default=0,\n                output_field=IntegerField(),\n            ),\n            same_item=Case(\n                When(item__item_id=ref_item_id, then=1),\n                default=0,\n                output_field=IntegerField(),\n            ),\n        ).order_by(\n            \"-unstarted\",\n            \"-same_project\",\n            \"-same_item\",\n            \"sequence\",\n            \"id\",\n        )\n\n    asset = (\n        asset_query.select_for_update(skip_locked=True, of=(\"self\",))\n        .select_related(\"item\", \"item__project\")\n        .first()\n    )\n    if asset:\n        if spawn_task:\n            structured_logger.debug(\n                \"Spawned background task to populate cache\",\n                event_code=\"transcribable_next_cache_population\",\n                topic=topic,\n            )\n            populate_task = get_registered_task(\n                \"concordia.tasks.next_asset.transcribable.populate_next_transcribable_for_topic\"\n            )\n            populate_task.delay(topic.id)\n        return asset\n\n    # Only now consider same-item IN_PROGRESS after current sequence\n    if item_id:\n        qs = concordia_models.Asset.objects.filter(\n            item__project__topics=topic.id,\n            item__item_id=item_id,\n            item__published=True,\n            item__project__published=True,\n            published=True,\n            transcription_status=concordia_models.TranscriptionStatus.IN_PROGRESS,\n        ).exclude(pk__in=Subquery(reserved_asset_ids))\n        if orig_id_valid:\n            qs = qs.exclude(pk=original_asset_id)\n        if after_seq is not None and orig_item_id == item_id:\n            qs = qs.filter(\n                Q(sequence__gt=after_seq)\n                | (Q(sequence=after_seq) & Q(id__gt=int(original_asset_id)))\n            )\n        asset = (\n            qs.order_by(\"sequence\", \"id\")\n            .select_for_update(skip_locked=True, of=(\"self\",))\n            .select_related(\"item\", \"item__project\")\n            .first()\n        )\n        if asset:\n            if spawn_task:\n                structured_logger.debug(\n                    \"Spawned background task to populate cache\",\n                    event_code=\"transcribable_next_cache_population\",\n                    topic=topic,\n                )\n                populate_task = get_registered_task(\n                    \"concordia.tasks.next_asset.transcribable.populate_next_transcribable_for_topic\"\n                )\n                populate_task.delay(topic.id)\n            return asset\n\n    return None\n\n\ndef find_invalid_next_transcribable_topic_assets(\n    topic_id: int,\n) -> \"QuerySet[concordia_models.NextTranscribableTopicAsset]\":\n    \"\"\"\n    Return cached rows that are invalid for transcription for a topic.\n\n    Behavior:\n        Identifies `NextTranscribableTopicAsset` rows that are no longer valid\n        because the underlying asset is neither `NOT_STARTED` nor\n        `IN_PROGRESS`, or because the asset is currently reserved.\n\n    Args:\n        topic_id (int): Identifier of the topic.\n\n    Returns:\n        QuerySet[concordia_models.NextTranscribableTopicAsset]: Distinct invalid\n            cache rows.\n    \"\"\"\n    reserved_asset_ids = concordia_models.AssetTranscriptionReservation.objects.filter(\n        asset__item__project__topics=topic_id\n    ).values(\"asset_id\")\n\n    status_filtered = concordia_models.NextTranscribableTopicAsset.objects.filter(\n        topic_id=topic_id\n    ).exclude(\n        asset__transcription_status__in=[\n            concordia_models.TranscriptionStatus.NOT_STARTED,\n            concordia_models.TranscriptionStatus.IN_PROGRESS,\n        ]\n    )\n\n    reserved_filtered = concordia_models.NextTranscribableTopicAsset.objects.filter(\n        topic_id=topic_id, asset_id__in=Subquery(reserved_asset_ids)\n    )\n\n    return (status_filtered | reserved_filtered).distinct()\n"
  },
  {
    "path": "concordia/validators.py",
    "content": "from django.conf import settings\nfrom django.utils.translation import gettext_lazy as _\n\nfrom .passwords.validators import ComplexityValidator\n\n\nclass DjangoPasswordsValidator(object):\n    \"\"\"\n    Wrapper for the django-passwords complexity validator which is compatible\n    with the Django 1.9+ password validation API\n    Because django-passwords is not compatible with Django 4+, the validator\n    has been integrated into the concordia.passwords module instead.\n    \"\"\"\n\n    message = _(\"Must be more complex (%s)\")\n    code = \"complexity\"\n\n    def __init__(self):\n        self.validator = ComplexityValidator(settings.PASSWORD_COMPLEXITY)\n\n    def get_help_text(self):\n        return _(\"Your password fails to meet our complexity requirements.\")\n\n    def validate(self, value, user=None):\n        return self.validator(value)\n"
  },
  {
    "path": "concordia/version.py",
    "content": "import functools\n\nfrom setuptools_scm import get_version\n\n\n@functools.lru_cache(maxsize=None)\ndef get_concordia_version():\n    return get_version()\n"
  },
  {
    "path": "concordia/views/README.md",
    "content": "## **init**.py\n\nInitializes the `views` module and re-exports submodules for chained attribute access\nsuch as `views.campaigns`. Also includes some basic views.\n\n### Class-based Views\n\n-   **HomeView** - A `ListView` displaying featured campaigns on the homepage\n\n### Function-based Views\n\n-   **healthz** - Returns a JSON response with system and application status\n\n## accounts.py\n\nViews related to user account management, including login, registration and profile updates\n\n### Class-based Views\n\n-   **ConcordiaPasswordResetConfirmView** - Customized password reset confirmation\n-   **ConcordiaPasswordResetRequestView** - Customized password reset requests\n-   **ConcordiaRegistrationView** - Custom registration view with rate limiting\n-   **ConcordiaLoginView** - Login view with Turnstile challenge validation\n-   **AccountProfileView** - View for managing a user's profile and displaying contributions\n-   **AccountDeletionView** - View for users to delete their account (or anonymize it)\n-   **EmailReconfirmationView** - Handles confirming a user's changed email address\n\n### Function-based Views\n\n-   **account_letter** - Generates and returns a PDF letter summarizing contributions\n-   **get_pages** - Renders a fragment of recent contributed pages\n\n### Functions\n\n-   **registration_rate** - Rate-limit for failed registration attempts\n\n## ajax.py\n\nAJAX endpoints for dynamic client interactions\n\n### Function-based Views\n\n-   **ajax_session_status** - Returns user-specific session data used by the frontend\n-   **ajax_messages** - Returns any queued messages for the current user\n-   **generate_ocr_transcription** - Generates a new transcription using OCR\n-   **rollback_transcription** - Reverts transcription to the previous version\n-   **rollforward_transcription** - Reverts the most recent transcription rollback\n-   **save_transcription** - Saves a new transcription\n-   **submit_transcription** - Marks a transcription as submitted for review\n-   **review_transcription** - Accepts or rejects a submitted transcription\n-   **submit_tags** - Updates the tag list for an asset\n-   **reserve_asset** - Manages reservation of an asset to prevent conflicts\n\n### Functions\n\n-   **get_transcription_superseded** - Determines if the superseded transcription is valid\n-   **update_reservation** - Updates the timestamp for a reservation\n-   **obtain_reservation** - Creates a new reservation\n\n## assets.py\n\nViews for displaying asset detail pages and redirecting users to the next appropriate asset\n\n### Class-based Views\n\n-   **AssetDetailView** - Displays the transcription interface for a single asset\n\n### Function-based Views\n\n-   **redirect_to_next_asset** - Redirects to provided asset\n-   **redirect_to_next_reviewable_asset** - Finds and redirects to a reviewable asset\n-   **redirect_to_next_transcribable_asset** - Finds and redirects to a transcribable asset\n-   **redirect_to_next_reviewable_campaign_asset** - Finds and redirects to the a reviewable asset for a campaign\n-   **redirect_to_next_transcribable_campaign_asset** - Finds and redirects to a transcribable asset for a campaign\n-   **redirect_to_next_reviewable_topic_asset** - Finds and redirects to a reviewable asset for a topic\n-   **redirect_to_next_transcribable_topic_asset** - Finds and redirects to a transcribable asset for a topic\n\n## campaigns.py\n\nViews for listing campaigns, rendering campaign details, showing reports and filtering by reviewable status\n\n### Class-based Views\n\n-   **CampaignListView** - Lists all active campaigns (unused)\n-   **CompletedCampaignListView** - Lists all completed and retired campaigns\n-   **CampaignTopicListView** - Primary active campaign list view; also includes active topics\n-   **CampaignDetailView** - Shows full details about a single campaign\n-   **FilteredCampaignDetailView** - Variant of `CampaignDetailView` that applies filtering based on the user\n-   **ReportCampaignView** - Displays a campaign report summarizing stats such as asset counts and contributors\n\n## decorators.py\n\nCustom decorators used by views\n\n### Functions\n\n-   **default_cache_control** - Applies default public caching headers for pages that don't vary per user\n-   **user_cache_control** - Applies public caching headers with variation for logged-in users\n-   **validate_anonymous_user** - Validates anonymous users via Turnstile before processing requests\n-   **reserve_rate** - Returns a rate-limit value for unauthenticated users for reserving assets\n-   **next_asset_rate** - Returns a rate-limit value for unauthenicated users for next\\_\\*\\_asset views\n\n## items.py\n\nViews for displaying individual item detail pages\n\n### Class-based Views\n\n-   **ItemDetailView** - Displays a paginated list of assets within an item\n-   **FilteredItemDetailView** - Variant of `ItemDetailView` that applies filtering based on the user\n\n## maintenance_mode.py\n\nViews for toggling the site's maintenance mode. Only accessible to superusers\n\n### Function-based Views\n\n-   **maintenance_mode_off** - Disables maintenance mode\n-   **maintenance_mode_on** - Enables maintenance mode\n-   **maintenance_mode_frontend_available** - Enables access to the frontend for staff while in maintenance mode\n-   **maintenance_mode_frontend_unavailable** - Disables access to the frontend for staff while in maintenance mode\n\n## projects.py\n\nViews for displaying project detail pages\n\n### Class-based Views\n\n-   **ProjectDetailView** - Displays a project and its items\n-   **FilteredProjectDetailView** - Variant of `ProjectDetailView` that applies filtering based on the user\n\n## rate_limit.py\n\nCustom handler for responding to requests that exceed rate limits\n\n### Function-based Views\n\n-   **ratelimit_view** - Returns a 429 response when a user is rate-limited\n\n## simple_pages.py\n\nViews and redirects for rendering static pages stored in the database\n\n### Function-based Views\n\n-   **simple_page** - Renders a simple static page from the database\n-   **about_simple_page** - Renders the \"about\" simple page, which includes some additional data\n\n### Class-based Views\n\n-   **HelpCenterRedirectView** - Redirects old help center URLs to new equivalents\n-   **HelpCenterSpanishRedirectView** - Redirects old Spanish help center URLs to new equivalents\n\n## topics.py\n\nView for displaying a topic's detail page\n\n### Class-based Views\n\n-   **TopicDetailView** - Displays a topic's associated projects\n\n## utils.py\n\nUtility functions, constants and mixins used throughout the views module\n\n### Constants\n\n-   **ASSETS_PER_PAGE** - Default number of assets to show per page\n-   **PROJECTS_PER_PAGE** - Default number of projects to show per page\n-   **ITEMS_PER_PAGE** - Default number of items to show per page\n-   **URL_REGEX** - Regular expression used to detect URLs in transcription text\n-   **MESSAGE_LEVEL_NAMES** - Dictionary mapping Django message levels to lowercase names\n\n### Functions\n\n-   **\\_get_pages** - Returns a queryset of assets a user has worked on\n-   **calculate_asset_stats** - Adds contributor and transcription status to the provided assets\n-   **annotate_children_with_progress_stats** - Annotates a list of objects with progress information\n\n### Classes\n\n-   **AnonymousUserValidationCheckMixin** - Requires anonymous users to pass Turnstile validation\n\n## visualization.py\n\nViews for displaying visualizations\n\n### Classes\n\n-   **VisualizationDataView** - Returns JSON representing the visualization `name`\n"
  },
  {
    "path": "concordia/views/__init__.py",
    "content": "import json\nimport logging\nimport os\nfrom time import time\n\nfrom django.conf import settings\nfrom django.http import HttpResponse\nfrom django.utils.decorators import method_decorator\nfrom django.views.decorators.cache import never_cache\nfrom django.views.generic import ListView\n\nfrom concordia.models import Banner, Campaign, CarouselSlide\nfrom concordia.version import get_concordia_version\n\n# These imports are required to make chainted attribute access like, e.g.,\n# views.campaigns.CampaignDetailView work correctly\nfrom . import (\n    accounts,  # noqa: F401\n    ajax,  # noqa: F401\n    assets,  # noqa: F401\n    campaigns,  # noqa: F401\n    items,  # noqa: F401\n    maintenance_mode,  # noqa: F401\n    projects,  # noqa: F401\n    rate_limit,  # noqa: F401\n    simple_pages,  # noqa: F401\n    topics,  # noqa: F401\n    visualizations,  # noqa: F401\n)\nfrom .decorators import default_cache_control\n\nlogger = logging.getLogger(__name__)\n\n\n@never_cache\ndef healthz(request):\n    status = {\n        \"current_time\": time(),\n        \"load_average\": os.getloadavg(),\n        \"debug\": settings.DEBUG,\n    }\n\n    # We don't want to query a large table but we do want to hit the database\n    # at last once:\n    status[\"database_has_data\"] = Campaign.objects.count() > 0\n\n    status[\"application_version\"] = get_concordia_version()\n\n    return HttpResponse(content=json.dumps(status), content_type=\"application/json\")\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\nclass HomeView(ListView):\n    template_name = \"home.html\"\n\n    queryset = (\n        Campaign.objects.published()\n        .listed()\n        .filter(display_on_homepage=True)\n        .order_by(\"ordering\", \"title\")\n    )\n    context_object_name = \"campaigns\"\n\n    def get_context_data(self, *args, **kwargs):\n        ctx = super().get_context_data(*args, **kwargs)\n\n        banner = Banner.objects.filter(active=True).first()\n\n        if banner is not None:\n            ctx[\"banner\"] = banner\n\n        ctx[\"slides\"] = CarouselSlide.objects.published().order_by(\"ordering\")\n\n        if ctx[\"slides\"]:\n            ctx[\"firstslide\"] = ctx[\"slides\"][0]\n\n        return ctx\n"
  },
  {
    "path": "concordia/views/accounts.py",
    "content": "import gc\nimport logging\nimport tracemalloc\nimport uuid\nfrom smtplib import SMTPException\nfrom typing import Any, Optional, Type\n\nfrom django.conf import settings\nfrom django.contrib import messages\nfrom django.contrib.auth import logout\nfrom django.contrib.auth.decorators import login_required\nfrom django.contrib.auth.mixins import LoginRequiredMixin\nfrom django.contrib.auth.views import (\n    LoginView,\n    PasswordResetConfirmView,\n    PasswordResetView,\n)\nfrom django.contrib.sites.shortcuts import get_current_site\nfrom django.core import signing\nfrom django.core.exceptions import ValidationError\nfrom django.core.mail import send_mail\nfrom django.core.paginator import Paginator\nfrom django.db.models import Sum\nfrom django.forms import Form\nfrom django.http import HttpRequest, HttpResponse, HttpResponseRedirect, JsonResponse\nfrom django.shortcuts import redirect\nfrom django.template import loader\nfrom django.template.loader import render_to_string\nfrom django.urls import reverse_lazy\nfrom django.utils.decorators import method_decorator\nfrom django.utils.translation import gettext_lazy as _\nfrom django.views.decorators.cache import never_cache\nfrom django.views.generic import FormView, ListView, TemplateView\nfrom django_ratelimit.decorators import ratelimit\nfrom django_registration.backends.activation.views import RegistrationView\nfrom weasyprint import HTML\n\nfrom concordia.forms import (\n    AccountDeletionForm,\n    ActivateAndSetPasswordForm,\n    AllowInactivePasswordResetForm,\n    TurnstileForm,\n    UserLoginForm,\n    UserNameForm,\n    UserProfileForm,\n    UserRegistrationForm,\n)\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import Campaign, ConcordiaUser, UserProfileActivity\n\nfrom .utils import _get_pages\n\nlogger = logging.getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\nclass ConcordiaPasswordResetConfirmView(PasswordResetConfirmView):\n    \"\"\"\n    Confirm a password reset and automatically log in the user.\n\n    Extends Django’s built-in\n    [PasswordResetConfirmView](https://docs.djangoproject.com/en/stable/topics/auth/default/#django.contrib.auth.views.PasswordResetConfirmView)\n    to use a custom form and enable automatic login after a successful reset.\n\n    Attributes:\n        post_reset_login (bool): Whether to log the user in after resetting\n            the password.\n        form_class (Form): The form used to set the new password and activate\n            the account.\n\n    Returns:\n        response (HttpResponse): Renders the password reset confirmation page or\n            redirects after successful password change and login.\n    \"\"\"\n\n    post_reset_login: bool = True\n    form_class: type[Form] = ActivateAndSetPasswordForm\n\n\nclass ConcordiaPasswordResetRequestView(PasswordResetView):\n    \"\"\"\n    Request a password reset, supporting inactive users.\n\n    Extends Django’s built-in\n    [`PasswordResetView`](https://docs.djangoproject.com/en/stable/topics/auth/default/#django.contrib.auth.views.PasswordResetView)\n    to use a custom form that allows inactive users to reset their password\n    and activate their account in one step.\n\n    Attributes:\n        form_class (Form): The form used to validate and process the password\n            reset request.\n\n    Returns:\n        response (HttpResponse): Renders the password reset form or redirects\n            after successful submission.\n    \"\"\"\n\n    form_class: type[Form] = AllowInactivePasswordResetForm\n\n\ndef registration_rate(group: str, request: HttpRequest) -> Optional[str]:\n    \"\"\"\n    Determine the throttling rate for registration attempts.\n\n    Used with the\n    [ratelimit](https://django-ratelimit.readthedocs.io/en/stable/usage.html#ratelimit)\n    decorator from `django-ratelimit` to dynamically adjust the request rate based\n    on form validation.\n\n    If the submitted form is invalid, limits requests to 10 per hour. If the\n    form is valid, allows the request without throttling.\n\n    Args:\n        group (str): The rate limit group name. Example: `\"registration\"`\n        request (HttpRequest): The request containing registration form data.\n\n    Returns:\n        rate (str or None): The rate limit string (e.g., \"10/h\") if the form is\n            invalid; otherwise `None` to indicate no throttling.\n    \"\"\"\n    registration_form = UserRegistrationForm(request.POST)\n    user = getattr(request, \"user\", None)\n    if registration_form.is_valid():\n        structured_logger.debug(\n            \"Registration form valid.\",\n            event_code=\"registration_rate_ok\",\n            user=user,\n        )\n        return None\n    else:\n        structured_logger.debug(\n            \"Registration form invalid, throttling.\",\n            event_code=\"registration_rate_throttle\",\n            user=user,\n        )\n        return \"10/h\"\n\n\n@method_decorator(never_cache, name=\"dispatch\")\n@method_decorator(\n    ratelimit(\n        group=\"registration\",\n        key=\"header:cf-connecting-ip\",\n        rate=registration_rate,\n        method=\"POST\",\n        block=settings.RATELIMIT_BLOCK,\n    ),\n    name=\"post\",\n)\nclass ConcordiaRegistrationView(RegistrationView):\n    \"\"\"\n    User registration view with rate limiting.\n\n    Extends\n    [django_registration.views.RegistrationView](https://django-registration.readthedocs.io/en/stable/views.html#django_registration.views.RegistrationView)\n    to apply a POST-specific rate limit using the\n    [django-ratelimit](https://django-ratelimit.readthedocs.io/en/stable/usage.html#ratelimit)\n    decorator. This protects against abuse by restricting failed registration attempts\n    while  allowing valid submissions to proceed freely.\n\n    Attributes:\n        form_class (Form): The form used to collect and validate user registration\n            data. Example: `UserRegistrationForm`.\n\n    Returns:\n        response (HttpResponse): Renders the registration form or redirects after\n            successful registration.\n    \"\"\"\n\n    form_class: Type[Form] = UserRegistrationForm\n\n\n@method_decorator(never_cache, name=\"dispatch\")\nclass ConcordiaLoginView(LoginView):\n    \"\"\"\n    Login view with Turnstile validation.\n\n    Extends Django's\n    [LoginView](https://docs.djangoproject.com/en/stable/topics/auth/default/#django.contrib.auth.views.LoginView)\n    to integrate Turnstile validation during POST requests.\n\n    Attributes:\n        form_class (Form): The login form used to authenticate users.\n\n    Returns:\n        response (HttpResponse): The rendered login form or redirect response,\n            depending on the validation outcome.\n\n    Return Behavior:\n        - On GET: Renders the login form with the embedded Turnstile widget.\n        - On POST:\n            - If both login and Turnstile succeed: redirects to the next page.\n            - If Turnstile fails: returns the login form with an error message.\n            - If the login form is invalid: returns the form with validation errors.\n    \"\"\"\n\n    form_class = UserLoginForm\n\n    def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:\n        structured_logger.debug(\n            \"Login POST received.\",\n            event_code=\"login_post_entry\",\n            user=request.user,\n        )\n        form = self.get_form()\n        if form.is_valid():\n            turnstile_form = TurnstileForm(request.POST)\n            if turnstile_form.is_valid():\n                structured_logger.debug(\n                    \"Login and Turnstile ok.\",\n                    event_code=\"login_success\",\n                    user=request.user,\n                )\n                return self.form_valid(form)\n            else:\n                structured_logger.warning(\n                    \"Turnstile failed for login.\",\n                    event_code=\"login_turnstile_failed\",\n                    reason=\"Turnstile validation failed\",\n                    reason_code=\"turnstile_failed\",\n                    user=request.user,\n                )\n                form.add_error(\n                    None, \"Unable to validate. Please login or complete the challenge.\"\n                )\n                return self.form_invalid(form)\n\n        else:\n            structured_logger.debug(\n                \"Login form invalid.\",\n                event_code=\"login_form_invalid\",\n                user=request.user,\n            )\n            return self.form_invalid(form)\n\n    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:\n        ctx = super().get_context_data(**kwargs)\n\n        ctx[\"turnstile_form\"] = TurnstileForm(auto_id=False)\n        structured_logger.debug(\n            \"Added Turnstile form to context.\",\n            event_code=\"login_context_turnstile\",\n            user=self.request.user,\n        )\n        return ctx\n\n\n@login_required\n@never_cache\ndef account_letter(request: HttpRequest) -> HttpResponse:\n    \"\"\"\n    Generate and return a PDF letter summarizing a user's contributions.\n\n    This view creates a service letter for the logged-in user, summarizing their\n    transcription and review activity. It uses an HTML template rendered with\n    contribution data and converts it to a PDF using WeasyPrint.\n\n    Requires the user to be authenticated.\n\n    Returns:\n        response (HttpResponse): A PDF response with content type\n            `application/pdf` and a `Content-Disposition` header set to download\n            as `letter.pdf`.\n\n    Return Behavior:\n        - The generated PDF includes:\n            - User's name and join date.\n            - Total transcriptions and reviews.\n            - List of assets the user contributed to.\n    \"\"\"\n    structured_logger.debug(\n        \"Building account letter.\",\n        event_code=\"account_letter_start\",\n        user=request.user,\n    )\n    image_url = \"file://{0}/{1}/img/logo.jpg\".format(\n        settings.SITE_ROOT_DIR, settings.STATIC_ROOT\n    )\n    user_profile_activity = UserProfileActivity.objects.filter(user=request.user)\n    aggregate_sums = user_profile_activity.aggregate(\n        Sum(\"review_count\"), Sum(\"transcribe_count\")\n    )\n    asset_list = _get_pages(request)\n    context = {\n        \"user\": request.user,\n        \"join_date\": request.user.date_joined,\n        \"total_reviews\": aggregate_sums[\"review_count__sum\"],\n        \"total_transcriptions\": aggregate_sums[\"transcribe_count__sum\"],\n        \"image_url\": image_url,\n        \"asset_list\": asset_list,\n    }\n    template = loader.get_template(\"documents/service_letter.html\")\n    text = template.render(context)\n    html = HTML(string=text)\n    response = HttpResponse(\n        content=html.write_pdf(variant=\"pdf/ua-1\"), content_type=\"application/pdf\"\n    )\n    response[\"Content-Disposition\"] = \"attachment; filename=letter.pdf\"\n    structured_logger.debug(\n        \"Account letter generated.\",\n        event_code=\"account_letter_success\",\n        user=request.user,\n        total_reviews=aggregate_sums[\"review_count__sum\"],\n        total_transcriptions=aggregate_sums[\"transcribe_count__sum\"],\n        asset_count=len(asset_list),\n    )\n    return response\n\n\n@login_required\n@never_cache\ndef get_pages(request: HttpRequest) -> JsonResponse:\n    \"\"\"\n    Return a paginated and filtered list of the user's contributed assets as HTML.\n\n    Retrieves assets the current user has worked on, applies pagination, and\n    optionally filters by campaign, activity type, status, and date range. Renders\n    the results into a fragment of HTML for use in dynamic page updates.\n\n    Requires the user to be authenticated.\n\n    Args:\n        request (HttpRequest): The request from the authenticated user.\n\n    Request Parameters:\n        - `page` (int): Page number to display. Example: `2`\n        - `campaign` (int): Filter by campaign ID. Example: `17`\n        - `status` (list[str]): Filter by asset statuses. Example:\n          `[\"in_progress\", \"submitted\"]`\n        - `activity` (str): Filter by activity type. Example: `\"transcribe\"`\n        - `order_by` (str): Sort order. Example: `\"date-descending\"`\n        - `start` (str): Start date in YYYY-MM-DD format. Example: `\"2023-01-01\"`\n        - `end` (str): End date in YYYY-MM-DD format. Example: `\"2023-12-31\"`\n\n    Returns:\n        response (JsonResponse): A JSON object containing rendered HTML for recent\n            contributed pages.\n\n    Response Format - Success:\n        - `content` (str): Rendered HTML for recent pages.\n\n    Example:\n        ```json\n        {\n            \"content\": \"<div class='page-results'>...</div>\"\n        }\n        ```\n    \"\"\"\n    tracemalloc.start()\n    gc.collect()\n\n    structured_logger.debug(\n        \"Fetching recent pages.\",\n        event_code=\"recent_pages_entry\",\n        user=request.user,\n        page=request.GET.get(\"page\", \"1\"),\n        campaign=request.GET.get(\"campaign\"),\n        activity=request.GET.get(\"activity\"),\n    )\n    asset_list = _get_pages(request)\n\n    paginator = Paginator(asset_list, 30)  # Show 30 assets per page.\n\n    page_number = int(request.GET.get(\"page\", \"1\"))\n    context = {\n        \"paginator\": paginator,\n        \"page_obj\": paginator.get_page(page_number),\n        \"is_paginated\": True,\n        \"recent_campaigns\": Campaign.objects.filter(\n            project__item__asset__in=asset_list.values(\"pk\")\n        )\n        .distinct()\n        .order_by(\"title\")\n        .values(\"pk\", \"title\"),\n    }\n    for param in (\"activity\", \"end\", \"order_by\", \"start\", \"statuses\"):\n        context[param] = request.GET.get(param, None)\n    campaign = request.GET.get(\"campaign\", None)\n    context[\"statuses\"] = request.GET.getlist(\"status\")\n\n    if campaign is not None:\n        context[\"campaign\"] = Campaign.objects.get(pk=int(campaign))\n\n    data = {}\n    data[\"content\"] = loader.render_to_string(\n        \"fragments/recent-pages.html\", context, request=request\n    )\n\n    # Capture memory stats\n    current, peak = tracemalloc.get_traced_memory()\n    current_mb = current / 1024 / 1024\n    peak_mb = peak / 1024 / 1024\n\n    # For immediate visibility\n    structured_logger.info(\n        \"Recent pages rendered.\",\n        event_code=\"recent_pages_success\",\n        user=request.user,\n        assets=asset_list.count(),\n        num_pages=paginator.num_pages,\n        page=page_number,\n        memory_current_mb=round(current_mb, 2),\n        memory_peak_mb=round(peak_mb, 2),\n    )\n\n    tracemalloc.stop()\n    return JsonResponse(data)\n\n\n@method_decorator(never_cache, name=\"dispatch\")\nclass AccountProfileView(LoginRequiredMixin, FormView, ListView):\n    \"\"\"\n    Display and update user account profile and contribution history.\n\n    Combines functionality from:\n    - [LoginRequiredMixin](https://docs.djangoproject.com/en/stable/topics/auth/default/#the-loginrequiredmixin-mixin)\n    - [FormView](https://docs.djangoproject.com/en/stable/ref/class-based-views/generic-editing/#formview)\n    - [ListView](https://docs.djangoproject.com/en/stable/ref/class-based-views/generic-display/#listview)\n\n    Allows authenticated users to:\n    - Update their email address and name\n    - View a paginated list of assets they have contributed to\n    - See aggregate statistics on their transcription and review activity\n\n    Email changes require confirmation unless the setting\n    `REQUIRE_EMAIL_RECONFIRMATION` is False.\n\n    Attributes:\n        template_name (str): Template used to render the profile page.\n            Example: `\"account/profile.html\"`\n        form_class (Form): Form used to update the user's email address.\n            Example: `UserProfileForm`\n        success_url (str): Redirect URL after successful form submission.\n            Example: `\"/accounts/profile/\"`\n        allow_empty (bool): Whether to render the page if the user has no\n            contributions. Default is `True`\n        paginate_by (int): Number of contributed assets to show per page.\n            Default is `30`.\n        reconfirmation_email_body_template (str): Path to the plain text email\n            body template. Example: `\"emails/email_reconfirmation_body.txt\"`\n        reconfirmation_email_subject_template (str): Path to the email subject\n            template. Example: `\"emails/email_reconfirmation_subject.txt\"`\n\n    Returns:\n        response (HttpResponse): The rendered profile page with contribution data\n            or a redirect to `#account` after successful form submission.\n\n    Request Parameters:\n        - `page` (int): Page number. Example: `1`\n        - `campaign` (int): Campaign filter. Example: `42`\n        - `activity` (str): Activity type filter. Example: `\"transcribe\"`\n        - `status` (list[str]): Asset statuses. Example: `[\"completed\"]`\n        - `start` (str): Start date in YYYY-MM-DD format. Example: `\"2023-01-01\"`\n        - `end` (str): End date in YYYY-MM-DD format. Example: `\"2023-12-31\"`\n        - `order_by` (str): Sort field. Example: `\"date-descending\"`\n        - `tab` (str): Selected tab. Example: `\"account\"`\n    \"\"\"\n\n    template_name: str = \"account/profile.html\"\n    form_class: Type[Form] = UserProfileForm\n    success_url = reverse_lazy(\"user-profile\")\n    reconfirmation_email_body_template: str = \"emails/email_reconfirmation_body.txt\"\n    reconfirmation_email_subject_template: str = (\n        \"emails/email_reconfirmation_subject.txt\"\n    )\n\n    # This view will list the assets which the user has contributed to\n    # along with their most recent action on each asset. This will be\n    # presented in the template as a standard paginated list of Asset\n    # instances with annotations\n    allow_empty: bool = True\n    paginate_by: int = 30\n\n    def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:\n        structured_logger.debug(\n            \"Profile POST received.\",\n            event_code=\"profile_post_entry\",\n            user=request.user,\n        )\n        self.object_list = self.get_queryset()\n        if \"submit_name\" in request.POST:\n            form = UserNameForm(request.POST)\n            if form.is_valid():\n                user = ConcordiaUser.objects.get(id=request.user.id)\n                user.first_name = form.cleaned_data[\"first_name\"]\n                user.last_name = form.cleaned_data[\"last_name\"]\n                user.save()\n                structured_logger.debug(\n                    \"Updated profile name.\",\n                    event_code=\"profile_name_updated\",\n                    user=request.user,\n                )\n            return redirect(\"user-profile\")\n        else:\n            return super().post(request, *args, **kwargs)\n\n    def get_queryset(self) -> Any:\n        structured_logger.debug(\n            \"Fetching pages for profile.\",\n            event_code=\"profile_get_queryset\",\n            user=self.request.user,\n        )\n        return _get_pages(self.request)\n\n    def get_context_data(self, *args: Any, **kwargs: Any) -> dict[str, Any]:\n        ctx = super().get_context_data(*args, **kwargs)\n\n        page = self.request.GET.get(\"page\", None)\n        campaign = self.request.GET.get(\"campaign\", None)\n        activity = self.request.GET.get(\"activity\", None)\n        status_list = self.request.GET.getlist(\"status\")\n        start = self.request.GET.get(\"start\", None)\n        end = self.request.GET.get(\"end\", None)\n        order_by = self.request.GET.get(\"order_by\", None)\n        if any([activity, campaign, page, status_list, start, end, order_by]):\n            ctx[\"active_tab\"] = \"recent\"\n            if status_list:\n                ctx[\"status_list\"] = status_list\n            ctx[\"order_by\"] = self.request.GET.get(\"order_by\", \"date-descending\")\n        elif \"active_tab\" not in ctx:\n            ctx[\"active_tab\"] = self.request.GET.get(\"tab\", \"contributions\")\n        ctx[\"activity\"] = activity\n        if end is not None:\n            ctx[\"end\"] = end\n        ctx[\"order_by\"] = order_by\n        if start is not None:\n            ctx[\"start\"] = start\n\n        ctx[\"valid\"] = self.request.session.pop(\"valid\", None)\n\n        user = self.request.user\n        concordia_user = ConcordiaUser.objects.get(id=user.id)\n        user_profile_activity = UserProfileActivity.objects.filter(user=user).order_by(\n            \"campaign__title\"\n        )\n        ctx[\"user_profile_activity\"] = user_profile_activity\n\n        aggregate_sums = user_profile_activity.aggregate(\n            Sum(\"review_count\"), Sum(\"transcribe_count\"), Sum(\"asset_count\")\n        )\n        ctx[\"totalReviews\"] = aggregate_sums[\"review_count__sum\"]\n        ctx[\"totalTranscriptions\"] = aggregate_sums[\"transcribe_count__sum\"]\n        ctx[\"pages_worked_on\"] = aggregate_sums[\"asset_count__sum\"]\n        if ctx[\"totalReviews\"] is not None:\n            ctx[\"totalCount\"] = ctx[\"totalReviews\"] + ctx[\"totalTranscriptions\"]\n        ctx[\"unconfirmed_email\"] = concordia_user.get_email_for_reconfirmation()\n        ctx[\"name_form\"] = UserNameForm()\n        structured_logger.debug(\n            \"Profile context ready.\",\n            event_code=\"profile_context_ready\",\n            user=self.request.user,\n            total_reviews=ctx[\"totalReviews\"],\n            total_transcriptions=ctx[\"totalTranscriptions\"],\n            pages_worked=ctx[\"pages_worked_on\"],\n        )\n        return ctx\n\n    def get_initial(self) -> dict[str, Any]:\n        initial = super().get_initial()\n        initial[\"email\"] = self.request.user.email\n        return initial\n\n    def get_form_kwargs(self) -> dict[str, Any]:\n        # We'll expose the request object to the form so we can validate that an\n        # email is not in use:\n        kwargs = super().get_form_kwargs()\n        kwargs[\"request\"] = self.request\n        return kwargs\n\n    def form_valid(self, form: Form) -> HttpResponse:\n        user = self.request.user\n        new_email = form.cleaned_data[\"email\"]\n        structured_logger.info(\n            \"Profile email update submitted.\",\n            event_code=\"profile_email_update\",\n            user=user,\n            require_reconfirm=settings.REQUIRE_EMAIL_RECONFIRMATION,\n        )\n        # This is annoying, but there's no better way to get the proxy model here\n        # without being hacky (changing user.__class__ directly.)\n        # Every method (such as using a user profile) would incur the same\n        # database request.\n        concordia_user = ConcordiaUser.objects.get(id=user.id)\n        if settings.REQUIRE_EMAIL_RECONFIRMATION:\n            concordia_user.set_email_for_reconfirmation(new_email)\n            structured_logger.debug(\n                \"Email set for reconfirmation.\",\n                event_code=\"email_reconfirm_set\",\n                user=user,\n                new_email=new_email,\n            )\n            self.send_reconfirmation_email(concordia_user)\n        else:\n            concordia_user.email = new_email\n            concordia_user.full_clean()\n            concordia_user.save()\n            concordia_user.delete_email_for_reconfirmation()\n            structured_logger.debug(\n                \"Email updated without reconfirmation.\",\n                event_code=\"email_updated_no_reconfirm\",\n                user=user,\n                new_email=new_email,\n            )\n\n        self.request.session[\"valid\"] = True\n\n        return super().form_valid(form)\n\n    def form_invalid(self, form: Form) -> HttpResponse:\n        structured_logger.debug(\n            \"Profile form invalid.\",\n            event_code=\"profile_form_invalid\",\n            user=self.request.user,\n        )\n        self.request.session[\"valid\"] = False\n        return self.render_to_response(\n            self.get_context_data(form=form, active_tab=\"account\")\n        )\n\n    def get_success_url(self) -> str:\n        # automatically open the Account Settings tab\n        return \"{}#account\".format(super().get_success_url())\n\n    def get_reconfirmation_email_context(self, confirmation_key: str) -> dict[str, Any]:\n        return {\n            \"confirmation_key\": confirmation_key,\n            \"expiration_days\": settings.EMAIL_RECONFIRMATION_DAYS,\n            \"site\": get_current_site(self.request),\n        }\n\n    def send_reconfirmation_email(self, user: ConcordiaUser) -> None:\n        confirmation_key = user.get_email_reconfirmation_key()\n        context = self.get_reconfirmation_email_context(confirmation_key)\n        context[\"user\"] = user\n        subject = render_to_string(\n            template_name=self.reconfirmation_email_subject_template,\n            context=context,\n            request=self.request,\n        )\n        # Ensure subject is a single line\n        subject = \"\".join(subject.splitlines())\n        message = render_to_string(\n            template_name=self.reconfirmation_email_body_template,\n            context=context,\n            request=self.request,\n        )\n        try:\n            structured_logger.info(\n                \"Sending reconfirmation email.\",\n                event_code=\"email_reconfirm_send_start\",\n                user=user,\n                email=user.get_email_for_reconfirmation(),\n            )\n            send_mail(\n                subject,\n                message=message,\n                from_email=settings.DEFAULT_FROM_EMAIL,\n                recipient_list=[user.get_email_for_reconfirmation()],\n            )\n            structured_logger.debug(\n                \"Reconfirmation email sent.\",\n                event_code=\"email_reconfirm_send_success\",\n                user=user,\n                email=user.get_email_for_reconfirmation(),\n            )\n        except SMTPException:\n            logger.exception(\n                \"Unable to send email reconfirmation to %s\",\n                user.get_email_for_reconfirmation(),\n            )\n            structured_logger.exception(\n                \"Reconfirmation email send failed.\",\n                event_code=\"email_reconfirm_send_failed\",\n                reason=\"SMTPException\",\n                reason_code=\"smtp_error\",\n                user=user,\n                email=user.get_email_for_reconfirmation(),\n            )\n            messages.error(\n                self.request,\n                _(\"Email confirmation could not be sent.\"),\n            )\n\n\n@method_decorator(never_cache, name=\"dispatch\")\nclass AccountDeletionView(LoginRequiredMixin, FormView):\n    \"\"\"\n    Handle user-initiated account deletion.\n\n    Extends:\n        - [LoginRequiredMixin](https://docs.djangoproject.com/en/stable/topics/auth/default/#the-loginrequiredmixin-mixin)\n        - [FormView](https://docs.djangoproject.com/en/stable/ref/class-based-views/generic-editing/#formview)\n\n    Provides a confirmation form for deleting the user's account. If the user has\n    contributed transcriptions, their data is anonymized instead of being deleted.\n    Otherwise, the account is fully removed. A confirmation email is sent to the\n    user's address before deletion. After deletion, the user is logged out.\n\n    Requires the user to be authenticated.\n\n    Attributes:\n        template_name (str): Template used to render the confirmation form.\n            Example: `\"account/account_deletion.html\"`\n        form_class (Form): Form used to confirm account deletion.\n            Example: `AccountDeletionForm`\n        success_url (str): URL to redirect to after deletion.\n            Example: `\"/\"`\n        email_body_template (str): Template for the body of the confirmation email.\n            Example: `\"emails/delete_account_body.txt\"`\n        email_subject_template (str): Template for the subject of the confirmation\n            email. Example: `\"emails/delete_account_subject.txt\"`\n\n    Returns:\n        response (HttpResponse): A redirect to the homepage after deletion, or a\n            rendered form with errors if validation fails.\n\n    Return Behavior:\n        - If the user confirms deletion and has transcriptions: anonymizes their\n          account and logs them out.\n        - If the user has no transcriptions: deletes the account entirely and logs\n          them out.\n        - If the form is invalid: re-renders the confirmation form with errors.\n    \"\"\"\n\n    template_name: str = \"account/account_deletion.html\"\n    form_class: Type[Form] = AccountDeletionForm\n    success_url: str = reverse_lazy(\"homepage\")\n    email_body_template: str = \"emails/delete_account_body.txt\"\n    email_subject_template: str = \"emails/delete_account_subject.txt\"\n\n    def get_form_kwargs(self) -> dict[str, Any]:\n        # We expose the request object to the form so we can use it\n        # to log the user out after deletion\n        kwargs = super().get_form_kwargs()\n        kwargs[\"request\"] = self.request\n        return kwargs\n\n    def form_valid(self, form: Form) -> HttpResponse:\n        structured_logger.info(\n            \"Account deletion confirmed.\",\n            event_code=\"account_delete_confirmed\",\n            user=form.request.user,\n        )\n        self.delete_user(form.request.user, form.request)\n        return super().form_valid(form)\n\n    def delete_user(self, user: ConcordiaUser, request: HttpRequest) -> None:\n        logger.info(\"Deletion request for %s\", user)\n        structured_logger.info(\n            \"Processing account deletion.\",\n            event_code=\"account_delete_start\",\n            user=user,\n        )\n        email = user.email\n        if user.transcription_set.exists():\n            logger.info(\"Anonymizing %s\", user)\n            structured_logger.info(\n                \"Anonymizing user account.\",\n                event_code=\"account_anonymize\",\n                user=user,\n            )\n            user.username = \"Anonymized %s\" % uuid.uuid4()\n            user.first_name = \"\"\n            user.last_name = \"\"\n            user.email = \"\"\n            user.set_unusable_password()\n            user.is_staff = False\n            user.is_superuser = False\n            user.is_active = False\n            user.save()\n        else:\n            logger.info(\"Deleting %s\", user)\n            structured_logger.info(\n                \"Deleting user account.\",\n                event_code=\"account_delete\",\n                user=user,\n            )\n            user.delete()\n        self.send_deletion_email(email)\n        logout(request)\n        structured_logger.info(\n            \"Account deletion complete.\",\n            event_code=\"account_delete_complete\",\n            user=user,\n        )\n\n    def send_deletion_email(self, email: str) -> None:\n        context = {}\n        subject = render_to_string(\n            template_name=self.email_subject_template,\n            context=context,\n            request=self.request,\n        )\n        # Ensure subject is a single line\n        subject = \"\".join(subject.splitlines())\n        message = render_to_string(\n            template_name=self.email_body_template,\n            context=context,\n            request=self.request,\n        )\n        try:\n            structured_logger.info(\n                \"Sending deletion email.\",\n                event_code=\"account_delete_email_send_start\",\n                user=self.request.user,\n                email=email,\n            )\n            send_mail(\n                subject,\n                message=message,\n                from_email=settings.DEFAULT_FROM_EMAIL,\n                recipient_list=[email],\n            )\n            structured_logger.debug(\n                \"Deletion email sent.\",\n                event_code=\"account_delete_email_send_success\",\n                user=self.request.user,\n                email=email,\n            )\n        except SMTPException:\n            logger.exception(\n                \"Unable to send account deletion email to %s\",\n                email,\n            )\n            structured_logger.exception(\n                \"Deletion email send failed.\",\n                event_code=\"account_delete_email_send_failed\",\n                reason=\"SMTPException\",\n                reason_code=\"smtp_error\",\n                user=self.request.user,\n                email=email,\n            )\n            messages.error(\n                self.request,\n                _(\"Email confirmation of deletion could not be sent.\"),\n            )\n\n\nclass EmailReconfirmationView(TemplateView):\n    \"\"\"\n    Handle email reconfirmation via a signed URL token.\n\n    Extends:\n        - [TemplateView](https://docs.djangoproject.com/en/stable/ref/class-based-views/base/#templateview)\n\n    Validates a confirmation key sent to the user's new email address during\n    an address change. If valid and not expired, applies the email update. If\n    invalid, expired or mismatched, renders an error message.\n\n    Attributes:\n        template_name (str): Template rendered if the confirmation fails.\n            Example: `\"account/email_reconfirmation_failed.html\"`\n        success_url (str): URL to redirect to on success.\n            Example: `\"/accounts/profile/#account\"`\n        BAD_USERNAME_MESSAGE (str): Error if the user account cannot be found.\n        BAD_EMAIL_MESSAGE (str): Error if the email does not match expectations.\n        EXPIRED_MESSAGE (str): Error if the key is expired.\n        INVALID_KEY_MESSAGE (str): Error if the key signature is invalid.\n\n    Returns:\n        response (HttpResponse): Redirects to the profile page with `#account`\n            on success, or renders the failure template with error details.\n\n    Request Parameters:\n        confirmation_key (str): A signed token containing the username and new\n            email. Example: `\"ZHVtbXl1c2VyOnNvbWVvbmVAZXhhbXBsZS5jb20=\"`\n    \"\"\"\n\n    success_url = reverse_lazy(\"user-profile\")\n    template_name = \"account/email_reconfirmation_failed.html\"\n\n    BAD_USERNAME_MESSAGE: str = _(\"The account you attempted to confirm is invalid.\")\n    BAD_EMAIL_MESSAGE: str = _(\"The email you attempted to confirm is invalid.\")\n    EXPIRED_MESSAGE: str = _(\n        \"The confirmation key you provided is expired. Email confirmation links \"\n        \"expire after 7 days. If your key is expired, you will need to re-enter \"\n        \"your new email address\"\n    )\n    INVALID_KEY_MESSAGE: str = _(\n        \"The confirmation key you provided is invalid. Email confirmation links \"\n        \"expire after 7 days. If your key is expired, you will need to re-enter \"\n        \"your new email address.\"\n    )\n\n    def get_success_url(self) -> str:\n        return \"{}#account\".format(self.success_url)\n\n    def get(self, *args: Any, **kwargs: Any) -> HttpResponse:\n        extra_context = {}\n        try:\n            structured_logger.debug(\n                \"Email reconfirmation GET received.\",\n                event_code=\"email_reconfirm_entry\",\n            )\n            self.confirm(*args, **kwargs)\n        except ValidationError as exc:\n            structured_logger.warning(\n                \"Email reconfirmation failed.\",\n                event_code=\"email_reconfirm_failed\",\n                reason=str(exc.message),\n                reason_code=str(exc.code),\n            )\n            extra_context[\"reconfirmation_error\"] = {\n                \"message\": exc.message,\n                \"code\": exc.code,\n                \"params\": exc.params,\n            }\n            context_data = self.get_context_data()\n            context_data.update(extra_context)\n            return self.render_to_response(context_data, status=403)\n        else:\n            structured_logger.debug(\n                \"Email reconfirmation ok.\",\n                event_code=\"email_reconfirm_success\",\n            )\n            return HttpResponseRedirect(self.get_success_url())\n\n    def confirm(self, *args: Any, **kwargs: Any) -> ConcordiaUser:\n        username, email = self.validate_key(kwargs.get(\"confirmation_key\"))\n        user = self.get_user(username)\n        if not user.validate_reconfirmation_email(email):\n            raise ValidationError(self.BAD_EMAIL_MESSAGE, code=\"bad_email\") from None\n        try:\n            user.email = email\n            user.full_clean()\n        except ValidationError:\n            raise ValidationError(self.BAD_EMAIL_MESSAGE, code=\"bad_email\") from None\n        user.save()\n        user.delete_email_for_reconfirmation()\n        structured_logger.info(\n            \"Email reconfirmed and applied.\",\n            event_code=\"email_reconfirm_applied\",\n            user=user,\n            new_email=email,\n        )\n        return user\n\n    def validate_key(self, confirmation_key: str) -> tuple[str, str]:\n        try:\n            context = signing.loads(\n                confirmation_key, max_age=settings.EMAIL_RECONFIRMATION_TIMEOUT\n            )\n            return context[\"username\"], context[\"email\"]\n        except signing.SignatureExpired as exc:\n            raise ValidationError(self.EXPIRED_MESSAGE, code=\"expired\") from exc\n        except signing.BadSignature as exc:\n            raise ValidationError(\n                self.INVALID_KEY_MESSAGE,\n                code=\"invalid_key\",\n                params={\"confirmation_key\": confirmation_key},\n            ) from exc\n\n    def get_user(self, username: str) -> ConcordiaUser:\n        try:\n            user = ConcordiaUser.objects.get(username=username)\n            structured_logger.debug(\n                \"Loaded user for reconfirmation.\",\n                event_code=\"email_reconfirm_user_loaded\",\n                user=user,\n            )\n            return user\n        except ConcordiaUser.DoesNotExist as exc:\n            structured_logger.warning(\n                \"User not found for reconfirmation.\",\n                event_code=\"email_reconfirm_user_missing\",\n                reason=\"User does not exist\",\n                reason_code=\"user_missing\",\n            )\n            raise ValidationError(\n                self.BAD_USERNAME_MESSAGE, code=\"bad_username\"\n            ) from exc\n"
  },
  {
    "path": "concordia/views/ajax.py",
    "content": "import logging\nimport re\nfrom time import time\nfrom typing import Union\n\nfrom django.conf import settings\nfrom django.contrib.auth.decorators import login_required\nfrom django.contrib.messages import get_messages\nfrom django.core.exceptions import ValidationError\nfrom django.db import connection\nfrom django.db.transaction import atomic\nfrom django.http import HttpRequest, HttpResponse, JsonResponse\nfrom django.shortcuts import get_object_or_404\nfrom django.urls import reverse\nfrom django.utils.timezone import now\nfrom django.views.decorators.cache import cache_control, never_cache\nfrom django.views.decorators.csrf import csrf_exempt\nfrom django.views.decorators.http import require_POST\nfrom django_ratelimit.decorators import ratelimit\n\nfrom concordia.exceptions import RateLimitExceededError\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    Asset,\n    AssetTranscriptionReservation,\n    ConcordiaUser,\n    Tag,\n    Transcription,\n    UserAssetTagCollection,\n)\nfrom concordia.signals.signals import (\n    reservation_obtained,\n    reservation_released,\n)\nfrom concordia.utils import (\n    get_anonymous_user,\n    get_or_create_reservation_token,\n)\nfrom concordia.utils.constants import MESSAGE_LEVEL_NAMES, URL_REGEX\nfrom configuration.utils import configuration_value\nfrom exporter.utils import remove_unacceptable_characters\n\nfrom .decorators import reserve_rate, validate_anonymous_user\n\nlogger = logging.getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@cache_control(private=True, max_age=settings.DEFAULT_PAGE_TTL)\n@csrf_exempt\ndef ajax_session_status(request: HttpRequest) -> JsonResponse:\n    \"\"\"\n    Return a JSON object describing the authenticated session state.\n\n    If the user is authenticated, this includes a truncated username and\n    navigational links to profile, logout, and admin pages (if staff or superuser).\n    If the user is anonymous, returns an empty dictionary.\n\n    Args:\n        request (HttpRequest): The HTTP request initiating the session status check.\n\n    Returns:\n        response (JsonResponse): A dictionary containing either session information\n            or empty data.\n\n    Response Format - Success:\n        - `username` (str): The first 15 characters of the user's username.\n        - `links` (list[dict]): A list of links relevant to the user's session.\n            - `title` (str): The label for the link (e.g., \"Profile\", \"Logout\").\n            - `url` (str): The absolute URL for the link.\n\n    Example:\n        ```json\n        // If the user is authenticated:\n        {\n            \"username\": \"johndoe\",\n            \"links\": [\n                {\"title\": \"Profile\", \"url\": \"https://example.com/accounts/profile/\"},\n                {\"title\": \"Logout\", \"url\": \"https://example.com/accounts/logout/\"}\n            ]\n        }\n\n        // If the user is anonymous:\n        {}\n        ```\n    \"\"\"\n    user = request.user\n    if user.is_anonymous:\n        res = {}\n    else:\n        links = [\n            {\n                \"title\": \"Profile\",\n                \"type\": \"link\",\n                \"url\": request.build_absolute_uri(reverse(\"user-profile\")),\n            }\n        ]\n        if user.is_superuser or user.is_staff:\n            links.append(\n                {\n                    \"title\": \"Admin Area\",\n                    \"type\": \"link\",\n                    \"url\": request.build_absolute_uri(reverse(\"admin:index\")),\n                }\n            )\n        links.append(\n            {\n                \"title\": \"Logout\",\n                \"type\": \"post\",\n                \"url\": request.build_absolute_uri(reverse(\"logout\")),\n                \"fields\": {\"next\": \"/\"},\n            }\n        )\n\n        res = {\"username\": user.username[:15], \"links\": links}\n\n    return JsonResponse(res)\n\n\n@never_cache\n@login_required\n@csrf_exempt\ndef ajax_messages(request: HttpRequest) -> JsonResponse:\n    \"\"\"\n    Return a JSON object containing the user's queued messages.\n\n    Retrieves Django messages for the current request and formats them\n    as a list of dictionaries, each containing the message text and its\n    severity level.\n\n    Requires the user to be authenticated.\n\n    Args:\n        request (HttpRequest): The request from the authenticated user.\n\n    Returns:\n        response (JsonResponse): A dictionary with a `messages` field containing\n            a list of message entries.\n\n    Response Format - Success:\n        - `messages` (list[dict]): A list of user-visible messages.\n            - `level` (str): The severity level of the message\n              (e.g., \"info\", \"warning\", \"error\").\n            - `message` (str): The text content of the message.\n\n    Example:\n        ```json\n        {\n            \"messages\": [\n                {\"level\": \"info\", \"message\": \"You have been logged out.\"},\n                {\"level\": \"warning\", \"message\": \"Your session is about to expire.\"}\n            ]\n        }\n        ```\n    \"\"\"\n    return JsonResponse(\n        {\n            \"messages\": [\n                {\"level\": MESSAGE_LEVEL_NAMES[i.level], \"message\": i.message}\n                for i in get_messages(request)\n            ]\n        }\n    )\n\n\ndef get_transcription_superseded(\n    asset: Asset, supersedes_pk: Union[int, str, None]\n) -> Union[Transcription, JsonResponse, None]:\n    \"\"\"\n    Determine the superseded transcription, if any, for a new transcription.\n\n    If a valid `supersedes_pk` is provided, returns the corresponding transcription\n    unless it has already been superseded. If no `supersedes_pk` is provided,\n    checks whether the asset already has an open transcription.\n\n    This helper may return an error response to be passed directly to the client,\n    or a transcription object used when saving a new one.\n\n    Args:\n        asset (Asset): The asset the transcription is associated with.\n        supersedes_pk (int or str or None): The primary key of the transcription\n            being superseded, or `None` if this is the first transcription.\n\n    Returns:\n        response (Transcription or JsonResponse or None): A valid transcription,\n            an error response, or `None`.\n\n    Return Behavior:\n        - If a valid transcription is found, a `Transcription` object is returned.\n        - If the request is invalid or the transcription has already been superseded,\n          a `JsonResponse` with an error is returned.\n        - If there is no previous transcription to supersede, `None` is returned.\n\n    Response Format - Error:\n        - `error` (str): Explanation of why the transcription cannot be created.\n            - \"An open transcription already exists\"\n            - \"This transcription has been superseded\"\n            - \"Invalid supersedes value\"\n\n    Example:\n        ```json\n        {\n            \"error\": \"An open transcription already exists\"\n        }\n        ```\n    \"\"\"\n    structured_logger.info(\n        \"Checking for superseded transcription.\",\n        event_code=\"transcription_supersede_check_start\",\n        asset=asset,\n        supersedes_pk=supersedes_pk,\n    )\n    if not supersedes_pk:\n        if asset.transcription_set.filter(supersedes=None).exists():\n            structured_logger.warning(\n                \"Open transcription already exists for asset.\",\n                event_code=\"transcription_supersede_check_failed\",\n                reason=\"An open transcription already exists\",\n                reason_code=\"already_exists\",\n                asset=asset,\n            )\n            return JsonResponse(\n                {\"error\": \"An open transcription already exists\"}, status=409\n            )\n        else:\n            superseded = None\n    else:\n        try:\n            if asset.transcription_set.filter(supersedes=supersedes_pk).exists():\n                structured_logger.warning(\n                    \"Transcription already superseded.\",\n                    event_code=\"transcription_supersede_check_failed\",\n                    reason=\"This transcription has been superseded\",\n                    reason_code=\"already_superseded\",\n                    asset=asset,\n                    supersedes_pk=supersedes_pk,\n                )\n                return JsonResponse(\n                    {\"error\": \"This transcription has been superseded\"}, status=409\n                )\n\n            try:\n                superseded = asset.transcription_set.get(pk=supersedes_pk)\n            except Transcription.DoesNotExist:\n                structured_logger.warning(\n                    \"Supersedes transcription not found.\",\n                    event_code=\"transcription_supersede_check_failed\",\n                    reason=\"Invalid supersedes value\",\n                    reason_code=\"not_found\",\n                    asset=asset,\n                    supersedes_pk=supersedes_pk,\n                )\n                return JsonResponse({\"error\": \"Invalid supersedes value\"}, status=400)\n        except ValueError:\n            structured_logger.warning(\n                \"Invalid supersedes value (non-integer).\",\n                event_code=\"transcription_supersede_check_failed\",\n                reason=\"Supersedes value must be an integer\",\n                reason_code=\"invalid_pk_format\",\n                asset=asset,\n                supersedes_pk=supersedes_pk,\n            )\n            return JsonResponse({\"error\": \"Invalid supersedes value\"}, status=400)\n        structured_logger.info(\n            \"Superseded transcription found.\",\n            event_code=\"transcription_supersede_check_success\",\n            asset=asset,\n            supersedes_pk=supersedes_pk,\n        )\n    return superseded\n\n\n@require_POST\n@login_required\n@atomic\n@ratelimit(key=\"header:cf-connecting-ip\", rate=\"1/m\", block=settings.RATELIMIT_BLOCK)\ndef generate_ocr_transcription(\n    request: HttpRequest, *, asset_pk: Union[int, str]\n) -> JsonResponse:\n    \"\"\"\n    Create and save a new OCR-generated transcription for an asset.\n\n    If no prior transcription exists, creates a blank transcription to serve as\n    the superseded record. Otherwise, the specified previous transcription is\n    superseded by the new OCR transcription.\n\n    Requires the user to be authenticated.\n\n    Request Parameters:\n        - `supersedes` (int or str, optional): The ID of the transcription being\n          superseded.\n        - `language` (str, optional): The language code to influence OCR output.\n\n    Returns:\n        response (JsonResponse): A dictionary describing the new transcription\n            and asset status.\n\n    Response Format - Success:\n        - `id` (int): ID of the new transcription.\n        - `sent` (float): UNIX timestamp when the transcription was created.\n        - `submissionUrl` (str): URL to submit the transcription.\n        - `text` (str): The OCR-generated transcription content.\n        - `asset` (dict):\n            - `id` (int): ID of the associated asset.\n            - `status` (str): Current transcription status.\n            - `contributors` (int): Number of users who have contributed.\n        - `undo_available` (bool): Whether the user can roll back this transcription.\n        - `redo_available` (bool): Whether the user can roll forward to another version.\n\n    Example:\n        ```json\n        {\n            \"id\": 123,\n            \"sent\": 1716294920.927134,\n            \"submissionUrl\": \"/transcriptions/123/submit/\",\n            \"text\": \"Detected OCR content...\",\n            \"asset\": {\n                \"id\": 456,\n                \"status\": \"in_progress\",\n                \"contributors\": 2\n            },\n            \"undo_available\": true,\n            \"redo_available\": false\n        }\n        ```\n    \"\"\"\n    asset = get_object_or_404(Asset, pk=asset_pk)\n    user = request.user\n\n    supersedes_pk = request.POST.get(\"supersedes\")\n    language = request.POST.get(\"language\", None)\n    structured_logger.info(\n        \"Starting OCR transcription generation.\",\n        event_code=\"ocr_generation_start\",\n        user=user,\n        asset=asset,\n        supersedes_pk=supersedes_pk,\n        language=language,\n    )\n    superseded = get_transcription_superseded(asset, supersedes_pk)\n    if superseded:\n        # If superseded is an HttpResponse, that means\n        # this transcription has already been superseded, so\n        # we won't run OCR and instead send back an error\n        # Otherwise, we just have thr transcription the OCR\n        # is gong to supersede, so we can continue\n        if isinstance(superseded, HttpResponse):\n            structured_logger.warning(\n                \"OCR generation aborted: superseded transcription is invalid.\",\n                event_code=\"ocr_generation_aborted\",\n                reason=\"Superseded transcription is invalid\",\n                reason_code=\"superseded_invalid\",\n                user=user,\n                asset=asset,\n            )\n            return superseded\n    else:\n        # This means this is the first transcription on this asset.\n        # To enable undoing of the OCR transcription, we create\n        # an empty transcription for the OCR transcription to supersede\n        structured_logger.info(\n            \"No existing transcription; creating empty one for OCR supersession.\",\n            event_code=\"ocr_blank_supersede\",\n            user=user,\n            asset=asset,\n        )\n        superseded = Transcription(\n            asset=asset,\n            user=get_anonymous_user(),\n            text=\"\",\n        )\n        superseded.full_clean()\n        superseded.save()\n        structured_logger.info(\n            \"Blank superseded transcription created for OCR.\",\n            event_code=\"ocr_blank_transcription_created\",\n            user=user,\n            transcription=superseded,\n        )\n\n    transcription_text = asset.get_ocr_transcript(language)\n    transcription = Transcription(\n        asset=asset,\n        user=user,\n        supersedes=superseded,\n        text=transcription_text,\n        ocr_generated=True,\n        ocr_originated=True,\n    )\n    transcription.full_clean()\n    transcription.save()\n\n    structured_logger.info(\n        \"OCR transcription successfully created.\",\n        event_code=\"ocr_generation_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return JsonResponse(\n        {\n            \"id\": transcription.pk,\n            \"sent\": time(),\n            \"submissionUrl\": reverse(\"submit-transcription\", args=(transcription.pk,)),\n            \"text\": transcription.text,\n            \"asset\": {\n                \"id\": transcription.asset.id,\n                \"status\": transcription.asset.transcription_status,\n                \"contributors\": transcription.asset.get_contributor_count(),\n            },\n            \"undo_available\": asset.can_rollback()[0],\n            \"redo_available\": asset.can_rollforward()[0],\n        },\n        status=201,\n    )\n\n\n@require_POST\n@validate_anonymous_user\n@atomic\n@ratelimit(key=\"header:cf-connecting-ip\", rate=\"1/m\", block=settings.RATELIMIT_BLOCK)\ndef rollback_transcription(\n    request: HttpRequest, *, asset_pk: Union[int, str]\n) -> JsonResponse:\n    \"\"\"\n    Perform a rollback on the latest transcription for the given asset.\n\n    Restores the asset's transcription to the previous version in its history.\n    If rollback is not possible (e.g., no prior version exists), returns an error.\n\n    Anonymous users are supported and handled via `get_anonymous_user()`. The caller\n    must be validated via `validate_anonymous_user`.\n\n    Args:\n        request (HttpRequest): The POST request to initiate rollback.\n        asset_pk (int or str): The primary key of the asset being rolled back.\n\n    Returns:\n        response (JsonResponse): A dictionary containing the restored transcription\n            and asset status, or an error response if rollback fails.\n\n    Response Format - Success:\n        - `id` (int): ID of the restored transcription.\n        - `sent` (float): UNIX timestamp of the response.\n        - `submissionUrl` (str): URL to submit the transcription.\n        - `text` (str): The restored transcription text.\n        - `asset` (dict):\n            - `id` (int): ID of the asset.\n            - `status` (str): Current transcription status.\n            - `contributors` (int): Number of users who contributed.\n        - `message` (str): Confirmation message.\n        - `undo_available` (bool): Whether rollback is possible again.\n        - `redo_available` (bool): Whether rollforward is now available.\n\n    Response Format - Error:\n        - `error` (str): Explanation of the failure.\n            - \"No previous transcription available\"\n\n    Example:\n        ```json\n        {\n            \"id\": 123,\n            \"sent\": 1716295121.113204,\n            \"submissionUrl\": \"/transcriptions/123/submit/\",\n            \"text\": \"Previous transcription text\",\n            \"asset\": {\n                \"id\": 456,\n                \"status\": \"in_progress\",\n                \"contributors\": 1\n            },\n            \"message\": \"Successfully rolled back transcription to previous version\",\n            \"undo_available\": false,\n            \"redo_available\": true\n        }\n        ```\n    \"\"\"\n    asset = get_object_or_404(Asset, pk=asset_pk)\n\n    if request.user.is_anonymous:\n        user = get_anonymous_user()\n    else:\n        user = request.user\n\n    try:\n        transcription = asset.rollback_transcription(user)\n    except ValueError as e:\n        logger.exception(\"No previous transcription available for rollback\", exc_info=e)\n        structured_logger.warning(\n            \"Rollback failed: no previous transcription to revert to.\",\n            event_code=\"rollback_failed\",\n            reason_code=\"no_valid_target\",\n            reason=str(e),\n            asset=asset,\n            user=user,\n        )\n        return JsonResponse(\n            {\"error\": \"No previous transcription available\"}, status=400\n        )\n\n    structured_logger.info(\n        \"Rollback successfully performed.\",\n        event_code=\"rollback_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return JsonResponse(\n        {\n            \"id\": transcription.pk,\n            \"sent\": time(),\n            \"submissionUrl\": reverse(\"submit-transcription\", args=(transcription.pk,)),\n            \"text\": transcription.text,\n            \"asset\": {\n                \"id\": transcription.asset.id,\n                \"status\": transcription.asset.transcription_status,\n                \"contributors\": transcription.asset.get_contributor_count(),\n            },\n            \"message\": \"Successfully rolled back transcription to previous version\",\n            \"undo_available\": transcription.asset.can_rollback()[0],\n            \"redo_available\": transcription.asset.can_rollforward()[0],\n        },\n        status=201,\n    )\n\n\n@require_POST\n@validate_anonymous_user\n@atomic\n@ratelimit(key=\"header:cf-connecting-ip\", rate=\"1/m\", block=settings.RATELIMIT_BLOCK)\ndef rollforward_transcription(\n    request: HttpRequest, *, asset_pk: Union[int, str]\n) -> JsonResponse:\n    \"\"\"\n    Perform a rollforward to the transcription previously replaced by a rollback.\n\n    Restores the asset's transcription to the next version in its history,\n    if a valid rollforward target exists. If not, returns an error response.\n\n    Anonymous users are supported and handled via `get_anonymous_user()`. The caller\n    must be validated via `validate_anonymous_user`.\n\n    Args:\n        request (HttpRequest): The POST request to initiate rollforward.\n        asset_pk (int or str): The primary key of the asset being rolled forward.\n\n    Returns:\n        response (JsonResponse): A dictionary containing the restored transcription\n            and asset status, or an error response if rollforward fails.\n\n    Response Format - Success:\n        - `id` (int): ID of the restored transcription.\n        - `sent` (float): UNIX timestamp of the response.\n        - `submissionUrl` (str): URL to submit the transcription.\n        - `text` (str): The restored transcription text.\n        - `asset` (dict):\n            - `id` (int): ID of the asset.\n            - `status` (str): Current transcription status.\n            - `contributors` (int): Number of users who contributed.\n        - `message` (str): Confirmation message.\n        - `undo_available` (bool): Whether rollback is now possible.\n        - `redo_available` (bool): Whether another rollforward is possible.\n\n    Response Format - Error:\n        - `error` (str): Explanation of the failure.\n            - \"No transcription to restore\"\n\n    Example:\n        ```json\n        {\n            \"id\": 124,\n            \"sent\": 1716295243.029184,\n            \"submissionUrl\": \"/transcriptions/124/submit/\",\n            \"text\": \"Next transcription text\",\n            \"asset\": {\n                \"id\": 456,\n                \"status\": \"in_progress\",\n                \"contributors\": 1\n            },\n            \"message\": \"Successfully restored transcription to next version\",\n            \"undo_available\": true,\n            \"redo_available\": false\n        }\n        ```\n    \"\"\"\n    asset = get_object_or_404(Asset, pk=asset_pk)\n\n    if request.user.is_anonymous:\n        user = get_anonymous_user()\n    else:\n        user = request.user\n\n    try:\n        transcription = asset.rollforward_transcription(user)\n    except ValueError as e:\n        logger.exception(\"No transcription available for rollforward\", exc_info=e)\n        structured_logger.warning(\n            \"Rollforward failed: no transcription available to restore.\",\n            event_code=\"rollforward_failed\",\n            reason_code=\"no_valid_target\",\n            reason=str(e),\n            asset=asset,\n            user=user,\n        )\n        return JsonResponse({\"error\": \"No transcription to restore\"}, status=400)\n\n    structured_logger.info(\n        \"Rollforward successfully performed.\",\n        event_code=\"rollforward_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return JsonResponse(\n        {\n            \"id\": transcription.pk,\n            \"sent\": time(),\n            \"submissionUrl\": reverse(\"submit-transcription\", args=(transcription.pk,)),\n            \"text\": transcription.text,\n            \"asset\": {\n                \"id\": transcription.asset.id,\n                \"status\": transcription.asset.transcription_status,\n                \"contributors\": transcription.asset.get_contributor_count(),\n            },\n            \"message\": \"Successfully restored transcription to next version\",\n            \"undo_available\": transcription.asset.can_rollback()[0],\n            \"redo_available\": transcription.asset.can_rollforward()[0],\n        },\n        status=201,\n    )\n\n\n@require_POST\n@validate_anonymous_user\n@atomic\ndef save_transcription(\n    request: HttpRequest, *, asset_pk: Union[int, str]\n) -> JsonResponse:\n    \"\"\"\n    Save a transcription draft for a given asset.\n\n    Validates the transcription text for disallowed content (e.g., URLs).\n    Non-printable characters are automatically removed before saving,\n    using the shared exporter sanitization utilities. The view also checks\n    for supersession rules. If valid, it creates and saves a new\n    transcription associated with the current or anonymous user.\n\n    Request Parameters:\n        - `text` (str): The transcription text.\n        - `supersedes` (int or str, optional): The ID of the transcription\n          being superseded. Example: `\"123\"`\n\n    Returns:\n        response (JsonResponse): A dictionary describing the saved transcription\n            and asset status, or an error response if validation fails.\n\n    Response Format - Success:\n        - `id` (int): ID of the saved transcription.\n        - `sent` (float): UNIX timestamp of the response.\n        - `submissionUrl` (str): URL to submit the transcription.\n        - `asset` (dict):\n            - `id` (int): ID of the associated asset.\n            - `status` (str): Current transcription status.\n            - `contributors` (int): Number of users who contributed.\n        - `undo_available` (bool): Whether rollback is currently possible.\n        - `redo_available` (bool): Whether rollforward is currently possible.\n\n    Response Format - Error:\n        - `error` (str): Explanation of the validation failure.\n            - \"It looks like your text contains URLs.\"\n            - \"An open transcription already exists\"\n            - \"This transcription has been superseded\"\n            - \"Invalid supersedes value\"\n\n    Example:\n        ```json\n        {\n            \"id\": 125,\n            \"sent\": 1716295310.743182,\n            \"submissionUrl\": \"/transcriptions/125/submit/\",\n            \"text\" : \"Transcription text\\r\\nSecond line\",\n            \"asset\": {\n                \"id\": 456,\n                \"status\": \"in_progress\",\n                \"contributors\": 1\n            },\n            \"undo_available\": true,\n            \"redo_available\": false\n        }\n        ```\n    \"\"\"\n    asset = get_object_or_404(Asset, pk=asset_pk)\n    logger.info(\"Saving transcription for %s (%s)\", asset, asset.id)\n\n    if request.user.is_anonymous:\n        user = get_anonymous_user()\n    else:\n        user = request.user\n\n    structured_logger.info(\n        \"Starting transcription save.\",\n        event_code=\"transcription_save_start\",\n        user=user,\n        asset=asset,\n    )\n\n    transcription_text = request.POST[\"text\"]\n\n    # Check whether this transcription text contains any URLs.\n    # If so, ask the user to correct the transcription by removing the URLs.\n    url_match = re.search(URL_REGEX, transcription_text)\n    if url_match:\n        structured_logger.warning(\n            \"Transcription save rejected due to URL in text.\",\n            event_code=\"transcription_save_rejected\",\n            reason=\"Transcription text contains URLs\",\n            reason_code=\"url_detected\",\n            user=user,\n            asset=asset,\n        )\n        return JsonResponse(\n            {\n                \"error\": \"It looks like your text contains URLs. \"\n                \"Please remove the URLs and try again.\",\n                \"error-code\": \"url_detected\",\n            },\n            status=400,\n        )\n\n    # Sanitize the text by removing any unacceptable (non-printable) characters.\n    # This leverages the shared exporter whitelist and logic so behavior remains\n    # consistent across validation and export paths.\n    transcription_text = remove_unacceptable_characters(transcription_text)\n\n    supersedes_pk = request.POST.get(\"supersedes\")\n    superseded = get_transcription_superseded(asset, supersedes_pk)\n    if superseded and isinstance(superseded, HttpResponse):\n        logger.info(\"Transcription superseded\")\n        structured_logger.warning(\n            \"Superseded transcription is invalid; aborting save.\",\n            event_code=\"transcription_save_aborted\",\n            reason=\"Superseded transcription is invalid\",\n            reason_code=\"superseded_invalid\",\n            user=user,\n            asset=asset,\n        )\n        return superseded\n\n    if superseded and (superseded.ocr_generated or superseded.ocr_originated):\n        ocr_originated = True\n    else:\n        ocr_originated = False\n\n    transcription = Transcription(\n        asset=asset,\n        user=user,\n        supersedes=superseded,\n        text=transcription_text,\n        ocr_originated=ocr_originated,\n    )\n    transcription.full_clean()\n    transcription.save()\n    logger.info(\"Transction %s saved\", transcription.id)\n    structured_logger.info(\n        \"Transcription saved successfully.\",\n        event_code=\"transcription_save_success\",\n        user=user,\n        transcription=transcription,\n    )\n\n    return JsonResponse(\n        {\n            \"id\": transcription.pk,\n            \"sent\": time(),\n            \"submissionUrl\": reverse(\"submit-transcription\", args=(transcription.pk,)),\n            \"text\": transcription.text,\n            \"asset\": {\n                \"id\": transcription.asset.id,\n                \"status\": transcription.asset.transcription_status,\n                \"contributors\": transcription.asset.get_contributor_count(),\n            },\n            \"undo_available\": transcription.asset.can_rollback()[0],\n            \"redo_available\": transcription.asset.can_rollforward()[0],\n        },\n        status=201,\n    )\n\n\n@require_POST\n@validate_anonymous_user\ndef submit_transcription(request: HttpRequest, *, pk: Union[int, str]) -> JsonResponse:\n    \"\"\"\n    Submit a transcription for review.\n\n    Marks the transcription as submitted and clears any rejection state.\n    Prevents submission if the transcription has already been accepted or\n    superseded.\n\n    Anonymous users are supported and handled via `get_anonymous_user()`. The caller\n    must be validated via `validate_anonymous_user`.\n\n    Args:\n        request (HttpRequest): The POST request to submit the transcription.\n        pk (int or str): The primary key of the transcription to submit.\n\n    Returns:\n        response (JsonResponse): A dictionary with the asset status and submission\n            metadata, or an error response if submission is not allowed.\n\n    Response Format - Success:\n        - `id` (int): ID of the submitted transcription.\n        - `sent` (float): UNIX timestamp of the response.\n        - `asset` (dict):\n            - `id` (int): ID of the associated asset.\n            - `status` (str): Current transcription status.\n            - `contributors` (int): Number of users who contributed.\n        - `undo_available` (bool): Always `false` after submission.\n        - `redo_available` (bool): Always `false` after submission.\n\n    Response Format - Error:\n        - `error` (str): Explanation of the submission failure.\n            - \"This transcription has already been updated.\"\n\n    Example:\n        ```json\n        {\n            \"id\": 126,\n            \"sent\": 1716295421.019122,\n            \"asset\": {\n                \"id\": 456,\n                \"status\": \"submitted\",\n                \"contributors\": 1\n            },\n            \"undo_available\": false,\n            \"redo_available\": false\n        }\n        ```\n    \"\"\"\n    transcription = get_object_or_404(Transcription, pk=pk)\n    asset = transcription.asset\n\n    logger.info(\n        \"Transcription %s submitted for %s (%s)\", transcription.id, asset, asset.id\n    )\n\n    is_superseded = transcription.asset.transcription_set.filter(supersedes=pk).exists()\n    is_already_submitted = transcription.submitted and not transcription.rejected\n\n    if is_already_submitted or is_superseded:\n        logger.warning(\n            (\n                \"Submit for review was attempted for invalid transcription \"\n                \"record: submitted: %s pk: %d\"\n            ),\n            str(transcription.submitted),\n            pk,\n        )\n        structured_logger.warning(\n            \"Submission rejected: transcription already submitted or superseded.\",\n            event_code=\"transcription_submit_rejected\",\n            reason=\"Transcription already submitted or superseded\",\n            reason_code=\"already_updated\",\n            user=request.user,\n            transcription=transcription,\n        )\n\n        return JsonResponse(\n            {\n                \"error\": \"This transcription has already been updated.\"\n                \" Reload the current status before continuing.\"\n            },\n            status=400,\n        )\n\n    transcription.submitted = now()\n    transcription.rejected = None\n    transcription.full_clean()\n    transcription.save()\n\n    logger.info(\"Transcription %s successfully submitted\", transcription.id)\n    structured_logger.info(\n        \"Transcription submitted successfully.\",\n        event_code=\"transcription_submit_success\",\n        user=request.user,\n        transcription=transcription,\n    )\n\n    return JsonResponse(\n        {\n            \"id\": transcription.pk,\n            \"sent\": time(),\n            \"asset\": {\n                \"id\": transcription.asset.id,\n                \"status\": transcription.asset.transcription_status,\n                \"contributors\": transcription.asset.get_contributor_count(),\n            },\n            \"undo_available\": False,\n            \"redo_available\": False,\n        },\n        status=200,\n    )\n\n\n@require_POST\n@login_required\n@never_cache\ndef review_transcription(request: HttpRequest, *, pk: Union[int, str]) -> JsonResponse:\n    \"\"\"\n    Review and accept or reject a submitted transcription.\n\n    Only non-authors may accept a transcription. Users are limited by a\n    rate limit when accepting transcriptions. Review actions are rejected\n    if the transcription has already been reviewed or is invalid.\n\n    Args:\n        request (HttpRequest): The POST request containing the review action.\n        pk (int or str): The primary key of the transcription to review.\n\n    Returns:\n        response (JsonResponse): A dictionary with updated asset status and\n            metadata, or an error response if the review fails.\n\n    Response Format - Success:\n        - `id` (int): ID of the reviewed transcription.\n        - `sent` (float): UNIX timestamp of the response.\n        - `asset` (dict):\n            - `id` (int): ID of the associated asset.\n            - `status` (str): Updated transcription status.\n            - `contributors` (int): Number of users who contributed.\n\n    Response Format - Error:\n        - `error` (str): Explanation of the review failure.\n            - \"Invalid action\"\n            - \"This transcription has already been reviewed\"\n            - \"You cannot accept your own transcription\"\n            - Configuration-based rate limit messages\n\n    Example:\n        ```json\n        {\n            \"id\": 127,\n            \"sent\": 1716295502.642184,\n            \"asset\": {\n                \"id\": 456,\n                \"status\": \"completed\",\n                \"contributors\": 2\n            }\n        }\n        ```\n    \"\"\"\n    action = request.POST.get(\"action\")\n    structured_logger.info(\n        \"Starting transcription review.\",\n        event_code=\"transcription_review_start\",\n        user=request.user,\n        transcription_id=pk,\n        action=action,\n    )\n\n    if action not in (\"accept\", \"reject\"):\n        structured_logger.warning(\n            \"Transcription review failed: invalid action.\",\n            event_code=\"transcription_review_rejected\",\n            reason=\"Invalid review action\",\n            reason_code=\"invalid_action\",\n            user=request.user,\n            transcription_id=pk,\n            action=action,\n        )\n        return JsonResponse({\"error\": \"Invalid action\"}, status=400)\n\n    transcription = get_object_or_404(Transcription, pk=pk)\n    asset = transcription.asset\n\n    logger.info(\n        \"Transcription %s reviewed (%s) for %s (%s)\",\n        transcription.id,\n        action,\n        asset,\n        asset.id,\n    )\n\n    if transcription.accepted or transcription.rejected:\n        structured_logger.warning(\n            \"Review rejected: transcription already reviewed.\",\n            event_code=\"transcription_review_rejected\",\n            reason=\"Transcription has already been reviewed\",\n            reason_code=\"already_reviewed\",\n            user=request.user,\n            transcription=transcription,\n        )\n        return JsonResponse(\n            {\"error\": \"This transcription has already been reviewed\"}, status=400\n        )\n\n    if transcription.user.pk == request.user.pk and action == \"accept\":\n        logger.warning(\"Attempted self-acceptance for transcription %s\", transcription)\n        structured_logger.warning(\n            \"Review rejected: user attempted to accept their own transcription.\",\n            event_code=\"transcription_review_rejected\",\n            reason=\"User attempted to accept their own transcription\",\n            reason_code=\"self_accept\",\n            user=request.user,\n            transcription=transcription,\n        )\n        return JsonResponse(\n            {\"error\": \"You cannot accept your own transcription\"}, status=400\n        )\n\n    transcription.reviewed_by = request.user\n\n    if action == \"accept\":\n        concordia_user = ConcordiaUser.objects.get(id=request.user.id)\n        try:\n            concordia_user.check_and_track_accept_limit(transcription)\n        except RateLimitExceededError:\n            structured_logger.warning(\n                \"Review rejected: user exceeded review rate limit.\",\n                event_code=\"transcription_review_rejected\",\n                reason=\"User exceeded review rate limit\",\n                reason_code=\"rate_limit_exceeded\",\n                user=request.user,\n                transcription=transcription,\n            )\n            return JsonResponse(\n                {\n                    \"error\": configuration_value(\"review_rate_limit_banner_message\"),\n                    \"popupTitle\": configuration_value(\"review_rate_limit_popup_title\"),\n                    \"popupError\": configuration_value(\n                        \"review_rate_limit_popup_message\"\n                    ),\n                },\n                status=429,\n            )\n        transcription.accepted = now()\n    else:\n        transcription.rejected = now()\n\n    transcription.full_clean()\n    transcription.save()\n\n    logger.info(\"Transcription %s successfully reviewed (%s)\", transcription.id, action)\n    structured_logger.info(\n        \"Transcription review successful.\",\n        event_code=\"transcription_review_success\",\n        user=request.user,\n        transcription=transcription,\n        action=action,\n    )\n\n    return JsonResponse(\n        {\n            \"id\": transcription.pk,\n            \"sent\": time(),\n            \"asset\": {\n                \"id\": transcription.asset.id,\n                \"status\": transcription.asset.transcription_status,\n                \"contributors\": transcription.asset.get_contributor_count(),\n            },\n        },\n        status=200,\n    )\n\n\n@require_POST\n@login_required\n@atomic\ndef submit_tags(request: HttpRequest, *, asset_pk: Union[int, str]) -> JsonResponse:\n    \"\"\"\n    Submit a new set of tags for an asset from the current user.\n\n    Creates any new tags as needed and updates the user's tag collection\n    for the asset. Removes tags that are no longer present in the submission.\n\n    Args:\n        request (HttpRequest): The POST request containing tag values.\n        asset_pk (int or str): The primary key of the asset to tag.\n\n    Returns:\n        response (JsonResponse): A dictionary containing the updated user-specific\n            and global tag lists for the asset.\n\n    Response Format - Success:\n        - `user_tags` (list[str]): Tags currently assigned to the asset by this user.\n        - `all_tags` (list[str]): All tags currently applied to the asset by any user.\n\n    Response Format - Error:\n        - `error` (list[str]): Validation error messages for malformed/duplicate tags.\n\n    Example:\n        ```json\n        {\n            \"user_tags\": [\"map\", \"handwritten\"],\n            \"all_tags\": [\"handwritten\", \"map\", \"note\"]\n        }\n        ```\n    \"\"\"\n    asset = get_object_or_404(Asset, pk=asset_pk)\n    structured_logger.info(\n        \"Starting tag submission.\",\n        event_code=\"tag_submit_start\",\n        user=request.user,\n        asset=asset,\n    )\n\n    user_tags, created = UserAssetTagCollection.objects.get_or_create(\n        asset=asset, user=request.user\n    )\n\n    tags = set(request.POST.getlist(\"tags\"))\n    existing_tags = Tag.objects.filter(value__in=tags)\n    new_tag_values = tags.difference(i.value for i in existing_tags)\n    new_tags = [Tag(value=i) for i in new_tag_values]\n    try:\n        for i in new_tags:\n            i.full_clean()\n    except ValidationError as exc:\n        structured_logger.warning(\n            \"Tag submission rejected: validation error on new tags.\",\n            event_code=\"tag_submit_rejected\",\n            reason=\"Tag failed validation\",\n            reason_code=\"validation_error\",\n            user=request.user,\n            asset=asset,\n            errors=str(exc.messages),\n        )\n        return JsonResponse({\"error\": exc.messages}, status=400)\n\n    Tag.objects.bulk_create(new_tags)\n\n    # At this point we now have Tag objects for everything in the POSTed\n    # request. We'll add anything which wasn't previously in this user's tag\n    # collection and remove anything which is no longer present.\n\n    all_submitted_tags = list(existing_tags) + new_tags\n    existing_user_tags = user_tags.tags.all()\n\n    for tag in all_submitted_tags:\n        if tag not in existing_user_tags:\n            user_tags.tags.add(tag)\n\n    all_tags_qs = Tag.objects.filter(userassettagcollection__asset__pk=asset_pk)\n\n    for tag in all_tags_qs:\n        if tag not in all_submitted_tags:\n            for collection in asset.userassettagcollection_set.all():\n                collection.tags.remove(tag)\n\n    all_tags = all_tags_qs.order_by(\"value\")\n    final_user_tags = user_tags.tags.order_by(\"value\").values_list(\"value\", flat=True)\n    all_tags = all_tags.values_list(\"value\", flat=True).distinct()\n\n    structured_logger.info(\n        \"Tags submitted successfully.\",\n        event_code=\"tag_submit_success\",\n        user=request.user,\n        asset=asset,\n        user_tags=[tag.value for tag in user_tags.tags.all()],\n    )\n\n    return JsonResponse(\n        {\"user_tags\": list(final_user_tags), \"all_tags\": list(all_tags)}\n    )\n\n\n@ratelimit(\n    key=\"header:cf-connecting-ip\", rate=reserve_rate, block=settings.RATELIMIT_BLOCK\n)\n@require_POST\n@never_cache\ndef reserve_asset(request: HttpRequest, *, asset_pk: Union[int, str]) -> JsonResponse:\n    \"\"\"\n    Attempt to reserve an asset for transcription by the current session.\n\n    If no active reservation exists, creates a new one using the session's\n    reservation token. If a reservation exists for this session, updates it.\n    If the asset is reserved by another session, returns a conflict response.\n    Handles reservation release if `release` is set in the request body.\n\n    Request Parameters:\n        - `release` (bool, optional): If present and true, releases the current\n          reservation instead of acquiring or updating it. Example: `\"true\"`\n\n    Returns:\n        response (JsonResponse or HttpResponse): A dictionary indicating the\n        reservation status and token, or an HTTP 408/409 response for timeout\n        or conflict.\n\n    Response Format - Success:\n        - `asset_pk` (int): The ID of the reserved asset.\n        - `reservation_token` (str): A unique identifier for the reservation session.\n\n    Response Format - Error:\n        - `408 Request Timeout`: The current session's reservation is tombstoned.\n        - `409 Conflict`: The asset is actively reserved by another session.\n\n    Example:\n        ```json\n        {\n            \"asset_pk\": 789,\n            \"reservation_token\": \"abc123xyz\"\n        }\n        ```\n    \"\"\"\n\n    reservation_token = get_or_create_reservation_token(request)\n    structured_logger.info(\n        \"Handling reservation request.\",\n        event_code=\"asset_reserve_start\",\n        asset_pk=asset_pk,\n        reservation_token=reservation_token,\n    )\n\n    # If the browser is letting us know of a specific reservation release,\n    # let it go even if it's within the grace period.\n    if request.POST.get(\"release\"):\n        with connection.cursor() as cursor:\n            cursor.execute(\n                \"\"\"\n                DELETE FROM concordia_assettranscriptionreservation\n                WHERE asset_id = %s and reservation_token = %s\n                \"\"\",\n                [asset_pk, reservation_token],\n            )\n\n        # We'll pass the message to the WebSocket listeners before returning it:\n        msg = {\"asset_pk\": asset_pk, \"reservation_token\": reservation_token}\n        logger.info(\"Releasing reservation with token %s\", reservation_token)\n        structured_logger.info(\n            \"Releasing asset reservation via client request.\",\n            event_code=\"asset_reserve_release\",\n            asset_pk=asset_pk,\n            reservation_token=reservation_token,\n        )\n        reservation_released.send(sender=\"reserve_asset\", **msg)\n        return JsonResponse(msg)\n\n    # We're relying on the database to meet our integrity requirements and since\n    # this is called periodically we want to be fairly fast until we switch to\n    # something like Redis.\n\n    reservations = AssetTranscriptionReservation.objects.filter(\n        asset_id__exact=asset_pk\n    )\n\n    # Default: pretend there is no activity on the asset\n    is_it_already_mine = False\n    am_i_tombstoned = False\n    is_someone_else_tombstoned = False\n    is_someone_else_active = False\n\n    if reservations:\n        for reservation in reservations:\n            if reservation.tombstoned:\n                if reservation.reservation_token == reservation_token:\n                    am_i_tombstoned = True\n                    logger.debug(\"I'm tombstoned %s\", reservation_token)\n                else:\n                    is_someone_else_tombstoned = True\n                    logger.debug(\n                        \"Someone else is tombstoned %s\", reservation.reservation_token\n                    )\n            else:\n                if reservation.reservation_token == reservation_token:\n                    is_it_already_mine = True\n                    logger.debug(\n                        \"I already have this active reservation %s\", reservation_token\n                    )\n                if not is_it_already_mine:\n                    is_someone_else_active = True\n                    logger.info(\n                        \"Someone else has this active reservation %s\",\n                        reservation.reservation_token,\n                    )\n\n        if am_i_tombstoned:\n            structured_logger.warning(\n                \"Reservation rejected: client is tombstoned.\",\n                event_code=\"asset_reserve_rejected\",\n                reason=\"Client reservation token is tombstoned\",\n                reason_code=\"tombstoned_self\",\n                asset_pk=asset_pk,\n                reservation_token=reservation_token,\n            )\n            return HttpResponse(status=408)  # Request Timed Out\n\n        if is_someone_else_active:\n            structured_logger.warning(\n                \"Reservation rejected: asset is reserved by another client.\",\n                event_code=\"asset_reserve_rejected\",\n                reason=\"Asset is actively reserved by another session\",\n                reason_code=\"conflict_active_other\",\n                asset_pk=asset_pk,\n                reservation_token=reservation_token,\n            )\n            return HttpResponse(status=409)  # Conflict\n\n        if is_it_already_mine:\n            # This user already has the reservation and it's not tombstoned\n            structured_logger.info(\n                \"Reservation updated for client.\",\n                event_code=\"asset_reserve_updated\",\n                asset_pk=asset_pk,\n                reservation_token=reservation_token,\n            )\n            msg = update_reservation(asset_pk, reservation_token)\n            logger.debug(\"Updating reservation %s\", reservation_token)\n\n        if is_someone_else_tombstoned:\n            # No reservations = no activity = go ahead and do an insert\n            structured_logger.info(\n                \"Reservation acquired from tombstoned client.\",\n                event_code=\"asset_reserve_from_tombstone\",\n                asset_pk=asset_pk,\n                reservation_token=reservation_token,\n            )\n            msg = obtain_reservation(asset_pk, reservation_token)\n            logger.debug(\n                \"Obtaining reservation for %s from tombstoned user\", reservation_token\n            )\n    else:\n        # No reservations = no activity = go ahead and do an insert\n        structured_logger.info(\n            \"Initial reservation acquired (no existing reservations).\",\n            event_code=\"asset_reserve_fresh\",\n            asset_pk=asset_pk,\n            reservation_token=reservation_token,\n        )\n        msg = obtain_reservation(asset_pk, reservation_token)\n        logger.debug(\"No activity, just get the reservation %s\", reservation_token)\n\n    return JsonResponse(msg)\n\n\ndef update_reservation(\n    asset_pk: Union[int, str], reservation_token: str\n) -> dict[str, Union[int, str]]:\n    \"\"\"\n    Update the timestamp on an existing active reservation for an asset.\n\n    Refreshes the reservation's `updated_on` field to extend its validity\n    and emits the `reservation_obtained` signal.\n\n    Args:\n        asset_pk (int or str): The primary key of the reserved asset.\n        reservation_token (str): The session's reservation token.\n\n    Returns:\n        response (dict): A dictionary confirming the updated reservation state.\n\n    Response Format - Success:\n        - `asset_pk` (int): The ID of the reserved asset.\n        - `reservation_token` (str): The reservation token used by the session.\n\n    Example:\n        ```json\n        {\n            \"asset_pk\": 789,\n            \"reservation_token\": \"abc123xyz\"\n        }\n        ```\n    \"\"\"\n    structured_logger.info(\n        \"Attempting to update reservation timestamp.\",\n        event_code=\"reservation_update_start\",\n        asset_pk=asset_pk,\n        reservation_token=reservation_token,\n    )\n    with connection.cursor() as cursor:\n        cursor.execute(\n            \"\"\"\n        UPDATE concordia_assettranscriptionreservation AS atr\n            SET updated_on = current_timestamp\n            WHERE (\n                atr.asset_id = %s\n                AND atr.reservation_token = %s\n                AND atr.tombstoned != TRUE\n                )\n        \"\"\".strip(),\n            [asset_pk, reservation_token],\n        )\n    structured_logger.info(\n        \"Reservation update SQL executed.\",\n        event_code=\"reservation_update_sql_executed\",\n        asset_pk=asset_pk,\n        reservation_token=reservation_token,\n    )\n    # We'll pass the message to the WebSocket listeners before returning it:\n    msg = {\"asset_pk\": asset_pk, \"reservation_token\": reservation_token}\n    reservation_obtained.send(sender=\"reserve_asset\", **msg)\n    structured_logger.info(\n        \"Reservation update completed; signal dispatched.\",\n        event_code=\"reservation_update_success\",\n        asset_pk=asset_pk,\n        reservation_token=reservation_token,\n    )\n    return msg\n\n\ndef obtain_reservation(\n    asset_pk: Union[int, str], reservation_token: str\n) -> dict[str, Union[int, str]]:\n    \"\"\"\n    Create a new reservation entry for an asset.\n\n    Inserts a new reservation row in the database for the given asset and\n    session token. Emits the `reservation_obtained` signal to notify listeners.\n\n    Args:\n        asset_pk (int or str): The primary key of the asset to reserve.\n        reservation_token (str): The session's reservation token.\n\n    Returns:\n        response (dict): A dictionary confirming the newly obtained reservation.\n\n    Response Format - Success:\n        - `asset_pk` (int): The ID of the reserved asset.\n        - `reservation_token` (str): The reservation token used by the session.\n\n    Example:\n        ```json\n        {\n            \"asset_pk\": 789,\n            \"reservation_token\": \"abc123xyz\"\n        }\n        ```\n    \"\"\"\n    structured_logger.info(\n        \"Attempting to create new reservation.\",\n        event_code=\"reservation_obtain_start\",\n        asset_pk=asset_pk,\n        reservation_token=reservation_token,\n    )\n    with connection.cursor() as cursor:\n        cursor.execute(\n            \"\"\"\n        INSERT INTO concordia_assettranscriptionreservation AS atr\n            (asset_id, reservation_token, tombstoned, created_on,\n            updated_on)\n            VALUES (%s, %s, FALSE, current_timestamp,\n            current_timestamp)\n        \"\"\".strip(),\n            [asset_pk, reservation_token],\n        )\n    structured_logger.info(\n        \"Reservation INSERT executed successfully.\",\n        event_code=\"reservation_insert_success\",\n        asset_pk=asset_pk,\n        reservation_token=reservation_token,\n    )\n    # We'll pass the message to the WebSocket listeners before returning it:\n    msg = {\"asset_pk\": asset_pk, \"reservation_token\": reservation_token}\n    reservation_obtained.send(sender=\"reserve_asset\", **msg)\n    structured_logger.info(\n        \"Reservation successfully obtained; signal dispatched.\",\n        event_code=\"reservation_obtain_success\",\n        asset_pk=asset_pk,\n        reservation_token=reservation_token,\n    )\n    return msg\n"
  },
  {
    "path": "concordia/views/assets.py",
    "content": "import logging\nimport random\nfrom typing import Any\nfrom urllib.parse import urlencode\n\nfrom django.conf import settings\nfrom django.contrib import messages\nfrom django.contrib.auth.models import User\nfrom django.db.models import QuerySet\nfrom django.db.transaction import atomic\nfrom django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect\nfrom django.shortcuts import get_object_or_404, redirect\nfrom django.urls import reverse\nfrom django.utils.decorators import method_decorator\nfrom django.views.decorators.cache import never_cache\nfrom django_ratelimit.decorators import ratelimit\n\nfrom concordia.api_views import APIDetailView\nfrom concordia.forms import TurnstileForm\nfrom concordia.logging import ConcordiaLogger\nfrom concordia.models import (\n    Asset,\n    AssetTranscriptionReservation,\n    Campaign,\n    CardFamily,\n    Guide,\n    Topic,\n    TranscriptionStatus,\n    TutorialCard,\n    UserAssetTagCollection,\n)\nfrom concordia.templatetags.concordia_media_tags import asset_media_url\nfrom concordia.utils import (\n    get_anonymous_user,\n    get_or_create_reservation_token,\n)\nfrom concordia.utils.next_asset import (\n    find_next_reviewable_campaign_asset,\n    find_next_reviewable_topic_asset,\n    find_next_transcribable_campaign_asset,\n    find_next_transcribable_topic_asset,\n    find_reviewable_campaign_asset,\n    find_transcribable_campaign_asset,\n    remove_next_asset_objects,\n)\n\nfrom .decorators import next_asset_rate\nfrom .utils import AnonymousUserValidationCheckMixin\n\nlogger = logging.getLogger(__name__)\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\n@method_decorator(never_cache, name=\"dispatch\")\nclass AssetDetailView(AnonymousUserValidationCheckMixin, APIDetailView):\n    \"\"\"\n    Display details for a single asset and handle missing assets.\n\n    This view handles `GET` and `POST` requests by retrieving the published\n    `Asset` that matches the campaign, project and item.\n\n    It uses `AnonymousUserValidationCheckMixin` for anonymous-user validation\n    and `APIDetailView` for API-driven detail behavior. It overrides\n    `dispatch` to log and redirect to the parent campaign page if the asset\n    is not found.\n\n    Attributes:\n        template_name (str): Template used to render the asset detail page.\n    \"\"\"\n\n    template_name = \"transcriptions/asset_detail.html\"\n\n    def dispatch(\n        self,\n        request: HttpRequest,\n        *args: Any,\n        **kwargs: Any,\n    ) -> HttpResponse:\n        try:\n            return super().dispatch(request, *args, **kwargs)\n        except Http404:\n            structured_logger.info(\n                \"AssetDetailView: asset not found, redirecting to campaign \" \"page.\",\n                event_code=\"asset_detail_not_found_redirect\",\n                user=request.user,\n                campaign_slug=self.kwargs.get(\"campaign_slug\"),\n                project_slug=self.kwargs.get(\"project_slug\"),\n                item_id=self.kwargs.get(\"item_id\"),\n                asset_slug=self.kwargs.get(\"slug\"),\n            )\n            campaign = get_object_or_404(\n                Campaign.objects.published(), slug=self.kwargs[\"campaign_slug\"]\n            )\n            return redirect(campaign)\n\n    def get_queryset(self) -> QuerySet[Asset]:\n        asset_qs = Asset.objects.published().filter(\n            item__project__campaign__slug=self.kwargs[\"campaign_slug\"],\n            item__project__slug=self.kwargs[\"project_slug\"],\n            item__item_id=self.kwargs[\"item_id\"],\n            slug=self.kwargs[\"slug\"],\n        )\n        asset_qs = asset_qs.select_related(\"item__project__campaign\")\n\n        return asset_qs\n\n    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:\n        \"\"\"\n        Build the context for the asset detail template.\n\n        Constructs a dictionary with the entries described in the context\n        format.\n\n        Context Format:\n            - `asset` (Asset): Asset instance being viewed.\n            - `item` (Item): Parent item of the asset.\n            - `project` (Project): Parent project of the item.\n            - `campaign` (Campaign): Campaign that contains the project.\n            - `transcription` (Transcription | None): Latest transcription or\n              `None`.\n            - `next_open_asset_url` (str): URL to the next transcribable\n              asset.\n            - `next_review_asset_url` (str): URL to the next reviewable\n              asset.\n            - `transcription_status` (str): One of the keys from\n              `TranscriptionStatus`.\n            - `activity_mode` (str): `\"transcribe\"` or `\"review\"`, based on\n              the transcription status.\n            - `disable_ocr` (bool): Whether OCR should be disabled for this\n              asset.\n            - `previous_asset_url` (str | None): URL to the previous asset, if\n              any.\n            - `next_asset_url` (str | None): URL to the next asset, if any.\n            - `asset_navigation` (list[tuple[int, str]]): Sequence and slug\n              pairs for navigation.\n            - `thumbnail_url` (str): URL of the asset thumbnail image.\n            - `current_asset_url` (str): Absolute URL of this asset detail\n              view.\n            - `tags` (list[str]): Sorted tag values applied to the asset.\n            - `registered_contributors` (int): Number of users who have\n              contributed to the asset.\n            - `cards` (list[Card]): Tutorial cards for the campaign or the\n              default card set.\n            - `guides` (QuerySet[dict[str, Any]] | None): Tutorial guide\n              entries.\n            - `languages` (list[tuple[str, str]]): Supported language\n              code and name pairs.\n            - `undo_available` (bool): Whether a rollback is possible.\n            - `redo_available` (bool): Whether a rollforward is possible.\n            - `turnstile_form` (TurnstileForm): Form for the Turnstile\n              widget.\n\n        Args:\n            **kwargs (Any): Additional keyword arguments passed to the\n                superclass implementation.\n\n        Returns:\n            dict[str, Any]: Context data for rendering the asset detail page.\n        \"\"\"\n\n        ctx = super().get_context_data(**kwargs)\n        asset = ctx[\"asset\"]\n        # Bind a new logger so asset and user are always included\n        context_logger = structured_logger.bind(user=self.request.user, asset=asset)\n        context_logger.info(\n            \"AssetDetailView: building context.\",\n            event_code=\"asset_detail_context_start\",\n        )\n        ctx[\"item\"] = item = asset.item\n        ctx[\"project\"] = project = item.project\n        ctx[\"campaign\"] = project.campaign\n\n        transcription = asset.transcription_set.order_by(\"-pk\").first()\n        context_logger.debug(\n            \"AssetDetailView: latest transcription selected.\",\n            event_code=\"asset_detail_latest_transcription\",\n            transcription=transcription,\n        )\n        ctx[\"transcription\"] = transcription\n\n        ctx[\"next_open_asset_url\"] = \"%s?%s\" % (\n            reverse(\n                \"transcriptions:redirect-to-next-transcribable-campaign-asset\",\n                kwargs={\"campaign_slug\": project.campaign.slug},\n            ),\n            urlencode(\n                {\"project\": project.slug, \"item\": item.item_id, \"asset\": asset.id}\n            ),\n        )\n\n        ctx[\"next_review_asset_url\"] = \"%s?%s\" % (\n            reverse(\n                \"transcriptions:redirect-to-next-reviewable-campaign-asset\",\n                kwargs={\"campaign_slug\": project.campaign.slug},\n            ),\n            urlencode(\n                {\"project\": project.slug, \"item\": item.item_id, \"asset\": asset.id}\n            ),\n        )\n\n        # We handle the case where an item with no transcriptions should be\n        # shown as status=not_started here so the logic does not need to be\n        # repeated in templates.\n        if transcription:\n            for choice_key, choice_value in TranscriptionStatus.CHOICE_MAP.items():\n                if choice_value == transcription.status:\n                    transcription_status = choice_key\n        else:\n            transcription_status = TranscriptionStatus.NOT_STARTED\n        ctx[\"transcription_status\"] = transcription_status\n\n        context_logger.debug(\n            \"AssetDetailView: computed transcription status.\",\n            event_code=\"asset_detail_transcription_status\",\n            computed_status=transcription_status,\n            asset_status=asset.transcription_status,\n        )\n\n        if (\n            transcription_status == TranscriptionStatus.NOT_STARTED\n            or transcription_status == TranscriptionStatus.IN_PROGRESS\n        ):\n            ctx[\"activity_mode\"] = \"transcribe\"\n            ctx[\"disable_ocr\"] = asset.turn_off_ocr()\n        else:\n            ctx[\"disable_ocr\"] = True\n        if transcription_status == TranscriptionStatus.SUBMITTED:\n            ctx[\"activity_mode\"] = \"review\"\n\n        previous_asset = (\n            item.asset_set.published()\n            .filter(sequence__lt=asset.sequence)\n            .order_by(\"sequence\")\n            .last()\n        )\n        next_asset = (\n            item.asset_set.published()\n            .filter(sequence__gt=asset.sequence)\n            .order_by(\"sequence\")\n            .first()\n        )\n        context_logger.debug(\n            \"AssetDetailView: asset navigation resolved.\",\n            event_code=\"asset_detail_navigation\",\n            previous_asset_id=getattr(previous_asset, \"pk\", None),\n            next_asset_id=getattr(next_asset, \"pk\", None),\n        )\n        if previous_asset:\n            ctx[\"previous_asset_url\"] = previous_asset.get_absolute_url()\n        if next_asset:\n            ctx[\"next_asset_url\"] = next_asset.get_absolute_url()\n\n        ctx[\"asset_navigation\"] = (\n            item.asset_set.published()\n            .order_by(\"sequence\")\n            .values_list(\"sequence\", \"slug\")\n        )\n\n        image_url = asset_media_url(asset)\n        if asset.download_url and \"iiif\" in asset.download_url:\n            thumbnail_url = asset.download_url.replace(\n                \"http://tile.loc.gov\", \"https://tile.loc.gov\"\n            )\n            thumbnail_url = thumbnail_url.replace(\"/pct:100/\", \"/!512,512/\")\n        else:\n            thumbnail_url = image_url\n        context_logger.debug(\n            \"AssetDetailView: thumbnail URL determined.\",\n            event_code=\"asset_detail_thumbnail\",\n            thumbnail_url=thumbnail_url,\n        )\n        ctx[\"thumbnail_url\"] = thumbnail_url\n\n        ctx[\"current_asset_url\"] = self.request.build_absolute_uri()\n\n        tag_groups = UserAssetTagCollection.objects.filter(asset__slug=asset.slug)\n\n        tags = set()\n\n        for tag_group in tag_groups:\n            for tag in tag_group.tags.all():\n                tags.add(tag.value)\n\n        ctx[\"tags\"] = sorted(tags)\n\n        ctx[\"registered_contributors\"] = asset.get_contributor_count()\n\n        if project.campaign.card_family:\n            card_family = project.campaign.card_family\n        else:\n            card_family = CardFamily.objects.filter(default=True).first()\n        if card_family is not None:\n            unordered_cards = TutorialCard.objects.filter(tutorial=card_family)\n            ordered_cards = unordered_cards.order_by(\"order\")\n            ctx[\"cards\"] = [tutorial_card.card for tutorial_card in ordered_cards]\n\n        guides = Guide.objects.order_by(\"order\").values(\"title\", \"body\")\n        if guides.count() > 0:\n            ctx[\"guides\"] = guides\n\n        ctx[\"languages\"] = list(settings.LANGUAGE_CODES.items())\n\n        ctx[\"undo_available\"] = asset.can_rollback()[0] if transcription else False\n        ctx[\"redo_available\"] = asset.can_rollforward()[0] if transcription else False\n\n        ctx[\"turnstile_form\"] = TurnstileForm(auto_id=False)\n\n        context_logger.info(\n            \"AssetDetailView: context ready.\",\n            event_code=\"asset_detail_context_ready\",\n            transcription=transcription,\n            transcription_status=transcription_status,\n        )\n        return ctx\n\n\ndef redirect_to_next_asset(\n    asset: Asset | None,\n    mode: str,\n    request: HttpRequest,\n    user: User,\n) -> HttpResponseRedirect:\n    \"\"\"\n    Redirect the user to the appropriate asset view or the homepage.\n\n    If an asset is found, this helper creates a reservation for it and\n    removes the asset from the relevant caching tables. The user is then\n    redirected to the transcription page for that asset.\n\n    If no asset is provided, it redirects to the homepage and adds an\n    informational message.\n\n    Args:\n        asset (Asset | None): Asset to redirect to, or `None` if no asset is\n            available.\n        mode (str): Either `\"transcribe\"` or `\"review\"`, used for messaging.\n        request (HttpRequest): Request that initiated the redirect.\n        user (User): User being redirected.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the asset detail page or the\n        homepage.\n    \"\"\"\n    structured_logger.info(\n        \"Starting redirect to next asset.\",\n        event_code=\"redirect_next_asset_start\",\n        user=user,\n        mode=mode,\n        asset=asset,\n    )\n    reservation_token = get_or_create_reservation_token(request)\n    if asset:\n        # We previously created reservations for transcriptions but not\n        # reviews. This created a race condition with the next asset caching\n        # system because the non-reserved asset could be added into the cache\n        # table between when the user was redirected and when they made their\n        # own reservation. That could result in the asset being added to the\n        # caching system and sent to another user.\n        res = AssetTranscriptionReservation(\n            asset=asset, reservation_token=reservation_token\n        )\n        res.full_clean()\n        res.save()\n        structured_logger.info(\n            \"Asset reserved and redirecting to asset detail view.\",\n            event_code=\"redirect_next_asset_success\",\n            asset=asset,\n            user=user,\n        )\n        remove_next_asset_objects(asset.id)\n        return redirect(\n            \"transcriptions:asset-detail\",\n            asset.item.project.campaign.slug,\n            asset.item.project.slug,\n            asset.item.item_id,\n            asset.slug,\n        )\n    else:\n        no_pages_message = f\"There are no remaining pages to {mode}.\"\n        structured_logger.warning(\n            \"No available asset to redirect to.\",\n            event_code=\"redirect_next_asset_empty\",\n            reason=(\"There were no eligible assets found to assign to the user.\"),\n            reason_code=\"no_asset_available\",\n            asset=asset,\n            user=user,\n            mode=mode,\n        )\n        messages.info(request, no_pages_message)\n\n        return redirect(\"homepage\")\n\n\n@ratelimit(\n    key=\"header:cf-connecting-ip\",\n    rate=next_asset_rate,\n    group=\"next_asset\",\n    block=True,\n)\n@never_cache\n@atomic\ndef redirect_to_next_reviewable_asset(\n    request: HttpRequest,\n) -> HttpResponseRedirect:\n    \"\"\"\n    Redirect the user to a reviewable asset from any active reviewable\n    campaign.\n\n    This view iterates through campaigns marked as next-reviewable, then\n    falls back to other active campaigns if needed. It skips campaigns with\n    no eligible assets and uses asset caching when possible.\n\n    Args:\n        request (HttpRequest): Incoming HTTP request.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the selected asset or the\n        homepage.\n    \"\"\"\n    structured_logger.info(\n        \"Entered redirect_to_next_reviewable_asset view.\",\n        event_code=\"redirect_reviewable_entry\",\n        user=request.user,\n    )\n    if not request.user.is_authenticated:\n        user = get_anonymous_user()\n    else:\n        user = request.user\n\n    campaign_ids = list(\n        Campaign.objects.active()\n        .listed()\n        .published()\n        .get_next_review_campaigns()\n        .values_list(\"id\", flat=True)\n    )\n    structured_logger.debug(\n        \"Fetched candidate campaign IDs for reviewable assets.\",\n        event_code=\"redirect_reviewable_campaign_ids\",\n        user=user,\n        campaign_ids=campaign_ids,\n    )\n    asset = None\n    if campaign_ids:\n        random.shuffle(campaign_ids)  # nosec\n    else:\n        logger.info(\"No configured reviewable campaigns\")\n        structured_logger.info(\n            \"No configured reviewable campaigns.\",\n            event_code=\"redirect_reviewable_no_campaigns\",\n            user=user,\n        )\n\n    for campaign_id in campaign_ids:\n        try:\n            campaign = Campaign.objects.get(id=campaign_id)\n        except IndexError:\n            logger.error(\"Next reviewable campaign %s not found\", campaign_id)\n            structured_logger.error(\n                \"Failed to retrieve next reviewable campaign by ID.\",\n                event_code=\"redirect_reviewable_campaign_missing\",\n                reason=(\"Reviewable campaign with specified ID was not found.\"),\n                reason_code=\"reviewable_campaign_not_found\",\n                user=user,\n                campaign_id=campaign_id,\n            )\n            continue\n        asset = find_reviewable_campaign_asset(campaign, user)\n        if asset:\n            break\n        else:\n            logger.info(\"No reviewable assets found in %s\", campaign)\n            structured_logger.info(\n                \"No reviewable assets found in campaign.\",\n                event_code=\"redirect_reviewable_campaign_empty_primary\",\n                user=user,\n                campaign=campaign,\n            )\n\n    if not asset:\n        for campaign in (\n            Campaign.objects.active()\n            .listed()\n            .published()\n            .exclude(id__in=campaign_ids)\n            .order_by(\"launch_date\")\n        ):\n            asset = find_reviewable_campaign_asset(campaign, user)\n            if asset:\n                break\n            else:\n                logger.info(\"No reviewable assets found in %s\", campaign)\n                structured_logger.info(\n                    \"No reviewable assets found in campaign.\",\n                    event_code=\"redirect_reviewable_campaign_empty_fallback\",\n                    user=user,\n                    campaign=campaign,\n                )\n    structured_logger.info(\n        \"Redirecting to next reviewable asset.\",\n        event_code=\"redirect_reviewable_success\",\n        user=user,\n        asset=asset,\n    )\n    return redirect_to_next_asset(asset, \"review\", request, user)\n\n\n@ratelimit(\n    key=\"header:cf-connecting-ip\",\n    rate=next_asset_rate,\n    group=\"next_asset\",\n    block=True,\n)\n@never_cache\n@atomic\ndef redirect_to_next_transcribable_asset(\n    request: HttpRequest,\n) -> HttpResponseRedirect:\n    \"\"\"\n    Redirect the user to a transcribable asset from any active transcription\n    campaign.\n\n    This view iterates through campaigns marked as next-transcribable, then\n    falls back to other active campaigns if needed. It skips campaigns with\n    no eligible assets and uses asset caching when possible.\n\n    Args:\n        request (HttpRequest): Incoming HTTP request.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the selected asset or the\n        homepage.\n    \"\"\"\n    structured_logger.info(\n        \"Entered redirect_to_next_transcribable_asset view.\",\n        event_code=\"redirect_transcribable_entry\",\n        user=request.user,\n    )\n    campaign_ids = list(\n        Campaign.objects.active()\n        .listed()\n        .published()\n        .get_next_transcription_campaigns()\n        .values_list(\"id\", flat=True)\n    )\n    structured_logger.debug(\n        \"Fetched candidate campaign IDs for transcribable assets.\",\n        event_code=\"redirect_transcribable_campaign_ids\",\n        user=request.user,\n        campaign_ids=campaign_ids,\n    )\n    asset = None\n    if campaign_ids:\n        random.shuffle(campaign_ids)  # nosec\n    else:\n        logger.info(\"No configured transcribable campaigns\")\n        structured_logger.info(\n            \"No configured transcribable campaigns.\",\n            event_code=\"redirect_transcribable_no_campaigns\",\n            user=request.user,\n        )\n\n    for campaign_id in campaign_ids:\n        try:\n            campaign = Campaign.objects.get(id=campaign_id)\n        except IndexError:\n            logger.error(\"Next transcribable campaign %s not found\", campaign_id)\n            structured_logger.error(\n                \"Next transcribable campaign ID not found.\",\n                event_code=\"redirect_transcribable_campaign_missing\",\n                reason=(\"Transcribable campaign with specified ID was not found.\"),\n                reason_code=\"transcribable_campaign_not_found\",\n                user=request.user,\n                campaign_id=campaign_id,\n            )\n            continue\n        asset = find_transcribable_campaign_asset(campaign)\n        if asset:\n            break\n        else:\n            logger.info(\"No transcribable assets found in %s\", campaign)\n            structured_logger.info(\n                \"No transcribable assets found in campaign.\",\n                event_code=\"redirect_transcribable_campaign_empty_primary\",\n                user=request.user,\n                campaign=campaign,\n            )\n\n    if not asset:\n        for campaign in (\n            Campaign.objects.active()\n            .listed()\n            .published()\n            .exclude(id__in=campaign_ids)\n            .order_by(\"-launch_date\")\n        ):\n            asset = find_transcribable_campaign_asset(campaign)\n            if asset:\n                break\n            else:\n                logger.info(\"No transcribable assets found in %s\", campaign)\n                structured_logger.info(\n                    \"No transcribable assets found in campaign (fallback \" \"loop).\",\n                    event_code=\"redirect_transcribable_campaign_empty_fallback\",\n                    user=request.user,\n                    campaign=campaign,\n                )\n\n    if not asset:\n        logger.info(\"No transcribable assets found in any campaign\")\n        structured_logger.info(\n            \"No transcribable assets found in any campaign.\",\n            event_code=\"redirect_transcribable_no_assets_anywhere\",\n            user=request.user,\n        )\n\n    structured_logger.info(\n        \"Redirecting to next transcribable asset.\",\n        event_code=\"redirect_transcribable_success\",\n        user=request.user,\n        asset=asset,\n    )\n    return redirect_to_next_asset(asset, \"transcribe\", request, request.user)\n\n\n@ratelimit(\n    key=\"header:cf-connecting-ip\",\n    rate=next_asset_rate,\n    group=\"next_asset\",\n    block=True,\n)\n@never_cache\n@atomic\ndef redirect_to_next_reviewable_campaign_asset(\n    request: HttpRequest,\n    *,\n    campaign_slug: str,\n) -> HttpResponseRedirect:\n    \"\"\"\n    Redirect the user to the next reviewable asset within a campaign.\n\n    This view redirects within a specific campaign, which may be listed or\n    unlisted. It can use optional query parameters to influence which asset\n    is prioritized.\n\n    Request Parameters:\n        project (str): Current project slug.\n        item (str): Current item identifier. This is `item_id`, not the item\n            primary key.\n        asset (int): ID of the most recently reviewed asset.\n\n    Args:\n        request (HttpRequest): Incoming HTTP request.\n        campaign_slug (str): Slug for the target campaign.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the selected asset or the\n        homepage.\n    \"\"\"\n    structured_logger.info(\n        \"Entered redirect_to_next_reviewable_campaign_asset view.\",\n        event_code=\"redirect_reviewable_campaign_entry\",\n        user=request.user,\n        campaign_slug=campaign_slug,\n    )\n    # Campaign is specified: may be listed or unlisted\n    campaign = get_object_or_404(Campaign.objects.published(), slug=campaign_slug)\n    project_slug = request.GET.get(\"project\", \"\")\n    item_id = request.GET.get(\"item\", \"\")\n    asset_pk = request.GET.get(\"asset\", 0)\n    structured_logger.debug(\n        \"Parsed query parameters for reviewable asset redirection.\",\n        event_code=\"redirect_reviewable_campaign_query_params\",\n        user=request.user,\n        campaign=campaign,\n        project_slug=project_slug,\n        item_id=item_id,\n        asset_pk=asset_pk,\n    )\n\n    if not request.user.is_authenticated:\n        user = get_anonymous_user()\n    else:\n        user = request.user\n\n    # We pass request.user instead of user here to maintain pre-existing\n    # behavior (though it is probably unintended).\n    # TODO: Re-evaluate whether we should pass in user instead.\n    asset = find_next_reviewable_campaign_asset(\n        campaign, request.user, project_slug, item_id, asset_pk\n    )\n    structured_logger.info(\n        \"Redirecting to next reviewable asset in campaign.\",\n        event_code=\"redirect_reviewable_campaign_success\",\n        user=user,\n        request_user=request.user,\n        asset=asset,\n        campaign=campaign,  # We log campaign because asset might be None.\n    )\n    return redirect_to_next_asset(asset, \"review\", request, user)\n\n\n@ratelimit(\n    key=\"header:cf-connecting-ip\",\n    rate=next_asset_rate,\n    group=\"next_asset\",\n    block=True,\n)\n@never_cache\n@atomic\ndef redirect_to_next_transcribable_campaign_asset(\n    request: HttpRequest,\n    *,\n    campaign_slug: str,\n) -> HttpResponseRedirect:\n    \"\"\"\n    Redirect the user to the next transcribable asset within a campaign.\n\n    This view redirects within a specific campaign, which may be listed or\n    unlisted. It can use optional query parameters to influence which asset\n    is prioritized.\n\n    Request Parameters:\n        project (str): Current project slug.\n        item (str): Current item identifier. This is `item_id`, not the item\n            primary key.\n        asset (int): ID of the most recently transcribed asset.\n\n    Args:\n        request (HttpRequest): Incoming HTTP request.\n        campaign_slug (str): Slug for the target campaign.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the selected asset or the\n        homepage.\n    \"\"\"\n    structured_logger.info(\n        \"Entered redirect_to_next_transcribable_campaign_asset view.\",\n        event_code=\"redirect_transcribable_campaign_entry\",\n        user=request.user,\n        campaign_slug=campaign_slug,\n    )\n    # Campaign is specified: may be listed or unlisted\n    campaign = get_object_or_404(Campaign.objects.published(), slug=campaign_slug)\n    project_slug = request.GET.get(\"project\", \"\")\n    item_id = request.GET.get(\"item\", \"\")\n    asset_pk = request.GET.get(\"asset\", 0)\n    structured_logger.debug(\n        \"Parsed query parameters for transcribable asset redirection.\",\n        event_code=\"redirect_transcribable_campaign_query_params\",\n        user=request.user,\n        campaign=campaign,\n        project_slug=project_slug,\n        item_id=item_id,\n        asset_pk=asset_pk,\n    )\n\n    if not request.user.is_authenticated:\n        user = get_anonymous_user()\n    else:\n        user = request.user\n\n    asset = find_next_transcribable_campaign_asset(\n        campaign, project_slug, item_id, asset_pk\n    )\n    structured_logger.info(\n        \"Redirecting to next transcribable asset in campaign.\",\n        event_code=\"redirect_transcribable_campaign_success\",\n        user=user,\n        asset=asset,\n        campaign=campaign,  # We log campaign because asset may be None.\n    )\n    return redirect_to_next_asset(asset, \"transcribe\", request, user)\n\n\n@ratelimit(\n    key=\"header:cf-connecting-ip\",\n    rate=next_asset_rate,\n    group=\"next_asset\",\n    block=True,\n)\n@never_cache\n@atomic\ndef redirect_to_next_reviewable_topic_asset(\n    request: HttpRequest,\n    *,\n    topic_slug: str,\n) -> HttpResponseRedirect:\n    \"\"\"\n    Redirect the user to the next reviewable asset within a topic.\n\n    This view redirects within a specific topic, which may be listed or\n    unlisted. It can use optional query parameters to influence which asset\n    is prioritized.\n\n    Request Parameters:\n        project (str): Current project slug.\n        item (str): Current item identifier. This is `item_id`, not the item\n            primary key.\n        asset (int): ID of the most recently reviewed asset.\n\n    Args:\n        request (HttpRequest): Incoming HTTP request.\n        topic_slug (str): Slug for the target topic.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the selected asset or the\n        homepage.\n    \"\"\"\n    structured_logger.info(\n        \"Entered redirect_to_next_reviewable_topic_asset view.\",\n        event_code=\"redirect_reviewable_topic_entry\",\n        user=request.user,\n        topic_slug=topic_slug,\n    )\n    # Topic is specified: may be listed or unlisted\n    topic = get_object_or_404(Topic.objects.published(), slug=topic_slug)\n    project_slug = request.GET.get(\"project\", \"\")\n    item_id = request.GET.get(\"item\", \"\")\n    asset_pk = request.GET.get(\"asset\", 0)\n    structured_logger.debug(\n        \"Parsed query parameters for reviewable topic redirection.\",\n        event_code=\"redirect_reviewable_topic_query_params\",\n        user=request.user,\n        topic=topic,\n        project_slug=project_slug,\n        item_id=item_id,\n        asset_pk=asset_pk,\n    )\n\n    if not request.user.is_authenticated:\n        user = get_anonymous_user()\n    else:\n        user = request.user\n\n    # We pass request.user instead of user here to maintain pre-existing\n    # behavior (though it is probably unintended).\n    # TODO: Re-evaluate whether we should pass in user instead.\n    asset = find_next_reviewable_topic_asset(\n        topic, request.user, project_slug, item_id, asset_pk\n    )\n    structured_logger.info(\n        \"Redirecting to next reviewable asset in topic.\",\n        event_code=\"redirect_reviewable_topic_success\",\n        user=user,\n        request_user=request.user,\n        asset=asset,\n        topic=topic,\n    )\n\n    return redirect_to_next_asset(asset, \"review\", request, user)\n\n\n@ratelimit(\n    key=\"header:cf-connecting-ip\",\n    rate=next_asset_rate,\n    group=\"next_asset\",\n    block=True,\n)\n@never_cache\n@atomic\ndef redirect_to_next_transcribable_topic_asset(\n    request: HttpRequest,\n    *,\n    topic_slug: str,\n) -> HttpResponseRedirect:\n    \"\"\"\n    Redirect the user to the next transcribable asset within a topic.\n\n    This view redirects within a specific topic, which may be listed or\n    unlisted. It can use optional query parameters to influence which asset\n    is prioritized.\n\n    Request Parameters:\n        project (str): Current project slug.\n        item (str): Current item identifier. This is `item_id`, not the item\n            primary key.\n        asset (int): ID of the most recently transcribed asset.\n\n    Args:\n        request (HttpRequest): Incoming HTTP request.\n        topic_slug (str): Slug for the target topic.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the selected asset or the\n        homepage.\n    \"\"\"\n    structured_logger.info(\n        \"Entered redirect_to_next_transcribable_topic_asset view.\",\n        event_code=\"redirect_transcribable_topic_entry\",\n        user=request.user,\n        topic_slug=topic_slug,\n    )\n    # Topic is specified: may be listed or unlisted\n    topic = get_object_or_404(Topic.objects.published(), slug=topic_slug)\n    project_slug = request.GET.get(\"project\", \"\")\n    item_id = request.GET.get(\"item\", \"\")\n    asset_pk = request.GET.get(\"asset\", 0)\n    structured_logger.debug(\n        \"Parsed query parameters for transcribable topic redirection.\",\n        event_code=\"redirect_transcribable_topic_query_params\",\n        user=request.user,\n        topic=topic,\n        project_slug=project_slug,\n        item_id=item_id,\n        asset_pk=asset_pk,\n    )\n\n    if not request.user.is_authenticated:\n        user = get_anonymous_user()\n    else:\n        user = request.user\n\n    asset = find_next_transcribable_topic_asset(topic, project_slug, item_id, asset_pk)\n    structured_logger.info(\n        \"Redirecting to next transcribable asset in topic.\",\n        event_code=\"redirect_transcribable_topic_success\",\n        user=user,\n        asset=asset,\n        topic=topic,\n    )\n    return redirect_to_next_asset(asset, \"transcribe\", request, user)\n"
  },
  {
    "path": "concordia/views/campaigns.py",
    "content": "from typing import Any, Iterable\nfrom urllib.parse import urlencode\n\nfrom django.core.paginator import Paginator\nfrom django.db.models import Count, Q, QuerySet\nfrom django.http import HttpResponse\nfrom django.shortcuts import get_object_or_404, render\nfrom django.utils.decorators import method_decorator\nfrom django.views.generic import TemplateView\n\nfrom concordia.api_views import APIDetailView, APIListView\nfrom concordia.models import (\n    STATUS_COUNT_KEYS,\n    Asset,\n    Campaign,\n    Project,\n    ResearchCenter,\n    SiteReport,\n    Topic,\n    Transcription,\n    TranscriptionStatus,\n)\nfrom concordia.utils.constants import ASSETS_PER_PAGE\n\nfrom .decorators import default_cache_control, user_cache_control\nfrom .utils import (\n    annotate_children_with_progress_stats,\n    calculate_asset_stats,\n)\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\nclass CampaignListView(APIListView):\n    \"\"\"\n    Display a list of active campaigns.\n\n    Renders a list of published, listed, and active campaigns ordered by\n    their configured ordering and title. Adds context entries for topics\n    and completed campaigns for secondary display.\n\n    Inherits from APIListView to support both HTML rendering and API\n    serialization of campaigns.\n\n    Attributes:\n        template_name (str): Template used to render the campaign list.\n        queryset (QuerySet[Campaign]): The base queryset of campaigns.\n        context_object_name (str): The name of the context variable for campaigns.\n\n    Returns:\n        HttpResponse: Renders the campaign list template with context.\n    \"\"\"\n\n    template_name = \"transcriptions/campaign_list.html\"\n    queryset = (\n        Campaign.objects.published()\n        .listed()\n        .filter(status=Campaign.Status.ACTIVE)\n        .order_by(\"ordering\", \"title\")\n    )\n    context_object_name = \"campaigns\"\n\n    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:\n        \"\"\"\n        Build context data for the campaign list template.\n\n        Adds:\n        - 'topics': Ordered list of published topics.\n        - 'completed_campaigns': Ordered list of completed or retired campaigns.\n\n        Args:\n            **kwargs: Additional context arguments.\n\n        Returns:\n            dict[str, Any]: Context data for rendering.\n        \"\"\"\n        data = super().get_context_data(**kwargs)\n        data[\"topics\"] = (\n            Topic.objects.published().listed().order_by(\"ordering\", \"title\")\n        )\n        data[\"completed_campaigns\"] = (\n            Campaign.objects.published()\n            .listed()\n            .filter(status__in=[Campaign.Status.COMPLETED, Campaign.Status.RETIRED])\n            .order_by(\"ordering\", \"title\")\n        )\n        return data\n\n    def serialize_context(self, context: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"\n        Serialize context data for API responses.\n\n        Annotates each campaign object with its asset status counts.\n\n        Args:\n            context (dict[str, Any]): The view context.\n\n        Returns:\n            dict[str, Any]: Serialized context data for API output.\n        \"\"\"\n        data = super().serialize_context(context)\n\n        object_list = data[\"objects\"]\n\n        campaign_stats_qs = (\n            Campaign.objects.filter(pk__in=[i[\"id\"] for i in object_list])\n            .annotate(\n                **{\n                    v: Count(\n                        \"project__item__asset\",\n                        filter=Q(\n                            project__published=True,\n                            project__item__published=True,\n                            project__item__asset__published=True,\n                            project__item__asset__transcription_status=k,\n                        ),\n                    )\n                    for k, v in STATUS_COUNT_KEYS.items()\n                }\n            )\n            .values(\"pk\", *STATUS_COUNT_KEYS.values())\n        )\n\n        campaign_asset_counts = {}\n        for campaign_stats in campaign_stats_qs:\n            campaign_asset_counts[campaign_stats.pop(\"pk\")] = campaign_stats\n\n        for obj in object_list:\n            obj[\"asset_stats\"] = campaign_asset_counts[obj[\"id\"]]\n\n        return data\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\nclass CompletedCampaignListView(APIListView):\n    \"\"\"\n    Display a list of completed and/or retired campaigns.\n\n    Renders a list of published, listed campaigns filtered by completion or\n    retirement status. Optionally filters by research center or campaign type.\n\n    Attributes:\n        model (Model): The Campaign model class.\n        template_name (str): Template used to render the campaign list.\n        context_object_name (str): The name of the context variable for campaigns.\n\n    Returns:\n        HttpResponse: Renders the completed campaign list template with context.\n    \"\"\"\n\n    model = Campaign\n    template_name = \"transcriptions/campaign_list_small_blocks.html\"\n    context_object_name = \"campaigns\"\n\n    def _get_all_campaigns(self) -> QuerySet[Campaign]:\n        \"\"\"\n        Retrieve all completed or retired campaigns, optionally filtered by type.\n\n        Returns:\n            QuerySet[Campaign]: Filtered campaigns.\n        \"\"\"\n        campaignType = self.request.GET.get(\"type\", None)\n        campaigns = Campaign.objects.published().listed()\n        if campaignType is None:\n            return campaigns.filter(\n                status__in=[Campaign.Status.COMPLETED, Campaign.Status.RETIRED]\n            )\n        elif campaignType == \"retired\":\n            status = Campaign.Status.RETIRED\n        else:\n            status = Campaign.Status.COMPLETED\n\n        return campaigns.filter(status=status)\n\n    def get_queryset(self) -> QuerySet[Campaign]:\n        \"\"\"\n        Build the queryset of completed or retired campaigns.\n\n        Optionally filters by research center if provided.\n\n        Returns:\n            QuerySet[Campaign]: The queryset for completed campaigns.\n        \"\"\"\n        campaigns = self._get_all_campaigns()\n        research_center = self.request.GET.get(\"research_center\", None)\n        if research_center is not None:\n            campaigns = campaigns.filter(research_centers=research_center)\n        return campaigns.order_by(\"-completed_date\")\n\n    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:\n        \"\"\"\n        Build context data for the completed campaign list template.\n\n        Adds:\n        - 'result_count': The number of campaigns in the list.\n        - 'research_centers': Distinct research centers for these campaigns.\n\n        Args:\n            **kwargs: Additional context arguments.\n\n        Returns:\n            dict[str, Any]: Context data for rendering.\n        \"\"\"\n        campaigns = self._get_all_campaigns()\n        data = super().get_context_data(**kwargs)\n        data[\"result_count\"] = self.object_list.count()\n        data[\"research_centers\"] = ResearchCenter.objects.filter(\n            campaign__in=campaigns\n        ).distinct()\n\n        return data\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\nclass CampaignTopicListView(TemplateView):\n    \"\"\"\n    Display a list of campaigns grouped by topic.\n\n    Renders active campaigns, a subset of topics and completed/retired campaigns\n    for navigation and discovery pages.\n\n    Attributes:\n        template_name (str): Template used to render the campaign-topic list page.\n\n    Returns:\n        HttpResponse: Renders the campaign topic list template with context.\n    \"\"\"\n\n    template_name = \"transcriptions/campaign_topic_list.html\"\n\n    def get(self, request, *args: Any, **kwargs: Any) -> HttpResponse:\n        \"\"\"\n        Handle GET requests for the campaign-topic list page.\n\n        Builds context containing:\n        - 'campaigns': Ordered list of active campaigns.\n        - 'topics': Ordered list of up to 5 topics.\n        - 'completed_campaigns': Ordered list of completed and retired campaigns.\n\n        Args:\n            request (HttpRequest): The incoming HTTP request.\n            *args: Additional positional arguments.\n            **kwargs: Additional keyword arguments.\n\n        Returns:\n            HttpResponse: Rendered campaign topic list page.\n        \"\"\"\n        data = {}\n        data[\"campaigns\"] = (\n            Campaign.objects.published()\n            .listed()\n            .filter(status=Campaign.Status.ACTIVE)\n            .annotated()\n            .order_by(\"ordering\", \"title\")\n        )\n        data[\"topics\"] = (\n            Topic.objects.published().listed().order_by(\"ordering\", \"title\")[:5]\n        )\n        data[\"completed_campaigns\"] = (\n            Campaign.objects.published()\n            .listed()\n            .filter(status__in=[Campaign.Status.COMPLETED, Campaign.Status.RETIRED])\n            .order_by(\"ordering\", \"title\")\n        )\n\n        return render(request, self.template_name, data)\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\nclass CampaignDetailView(APIDetailView):\n    \"\"\"\n    Display details for a single campaign.\n\n    Renders campaign information, associated projects, and aggregated asset\n    statistics. Selects different templates based on campaign status\n    (active, completed, or retired).\n\n    Attributes:\n        template_name (str): Template for active campaigns.\n        completed_template_name (str): Template for completed campaigns.\n        retired_template_name (str): Template for retired campaigns.\n        context_object_name (str): Context variable name for the campaign.\n        queryset (QuerySet[Campaign]): Base queryset of campaigns.\n\n    Returns:\n        HttpResponse: Renders the campaign detail template with context.\n    \"\"\"\n\n    template_name = \"transcriptions/campaign_detail.html\"\n    completed_template_name = \"transcriptions/campaign_detail_completed.html\"\n    retired_template_name = \"transcriptions/campaign_detail_retired.html\"\n    context_object_name = \"campaign\"\n    queryset = Campaign.objects.published().order_by(\"title\")\n\n    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:\n        \"\"\"\n        Build context data for the campaign detail page.\n\n        Adds:\n        - For retired campaigns: contributor and completed counts from SiteReport.\n        - For active campaigns: filtered and annotated projects, asset statistics.\n\n        Args:\n            **kwargs: Additional context arguments.\n\n        Returns:\n            dict[str, Any]: Context data for rendering.\n        \"\"\"\n        ctx = super().get_context_data(**kwargs)\n        if self.object and self.object.status == Campaign.Status.RETIRED:\n            latest_report = SiteReport.objects.filter(campaign=ctx[\"campaign\"]).latest(\n                \"created_on\"\n            )\n            ctx[\"completed_count\"] = latest_report.assets_completed\n            ctx[\"contributor_count\"] = latest_report.registered_contributors\n        else:\n            projects = (\n                ctx[\"campaign\"].project_set.published().order_by(\"ordering\", \"title\")\n            )\n            ctx[\"filters\"] = filters = {}\n            filter_by_reviewable = kwargs.get(\"filter_by_reviewable\", False)\n            if filter_by_reviewable:\n                projects = projects.filter(\n                    item__asset__transcription__id__in=Transcription.objects.exclude(\n                        user=self.request.user.id\n                    ).values_list(\"id\", flat=True)\n                )\n                ctx[\"filter_assets\"] = True\n            projects = projects.annotate(\n                **{\n                    f\"{key}_count\": Count(\n                        \"item__asset\",\n                        filter=Q(\n                            item__published=True,\n                            item__asset__published=True,\n                            item__asset__transcription_status=key,\n                        ),\n                    )\n                    for key in TranscriptionStatus.CHOICE_MAP\n                }\n            )\n\n            if filter_by_reviewable:\n                status = TranscriptionStatus.SUBMITTED\n            else:\n                status = self.request.GET.get(\"transcription_status\")\n            if status in TranscriptionStatus.CHOICE_MAP:\n                projects = projects.exclude(**{f\"{status}_count\": 0})\n                # We only want to pass specific QS parameters\n                # to lower-level search pages:\n                filters[\"transcription_status\"] = status\n            ctx[\"sublevel_querystring\"] = urlencode(filters)\n\n            annotate_children_with_progress_stats(projects)\n            ctx[\"projects\"] = projects\n\n            campaign_assets = Asset.objects.filter(\n                item__project__campaign=self.object,\n                item__project__published=True,\n                item__published=True,\n                published=True,\n            )\n            if filter_by_reviewable:\n                campaign_assets = campaign_assets.exclude(\n                    transcription__user=self.request.user.id\n                )\n                ctx[\"transcription_status\"] = TranscriptionStatus.SUBMITTED\n            else:\n                ctx[\"transcription_status\"] = status\n\n            calculate_asset_stats(campaign_assets, ctx)\n\n        return ctx\n\n    def serialize_context(self, context: dict[str, Any]) -> dict[str, Any]:\n        \"\"\"\n        Serialize campaign context data for API responses.\n\n        Adds:\n        - 'related_links': Helpful Link title and URL pairs for the campaign.\n\n        Args:\n            context (dict[str, Any]): The view context.\n\n        Returns:\n            dict[str, Any]: Serialized context data for API output.\n        \"\"\"\n        ctx = super().serialize_context(context)\n        ctx[\"object\"][\"related_links\"] = [\n            {\"title\": title, \"url\": url}\n            for title, url in self.object.helpfullink_set.values_list(\n                \"title\", \"link_url\"\n            )\n        ]\n        return ctx\n\n    def get_template_names(self) -> list[str]:\n        \"\"\"\n        Determine the template to use based on campaign status.\n\n        Returns:\n            list[str]: List containing the selected template name.\n        \"\"\"\n        if self.object.status == Campaign.Status.COMPLETED:\n            return [self.completed_template_name]\n        elif self.object.status == Campaign.Status.RETIRED:\n            return [self.retired_template_name]\n        return super().get_template_names()\n\n\n@method_decorator(user_cache_control, name=\"dispatch\")\nclass FilteredCampaignDetailView(CampaignDetailView):\n    \"\"\"\n    Display campaign details with reviewable asset filtering for staff users.\n\n    Inherits from CampaignDetailView, overriding context data to include only\n    assets eligible for review by staff users when authenticated.\n\n    Returns:\n        HttpResponse: Renders the filtered campaign detail template with context.\n    \"\"\"\n\n    def get_context_data(self, **kwargs: Any) -> dict[str, Any]:\n        \"\"\"\n        Build context data with reviewable asset filtering for staff users.\n\n        Adds 'filter_by_reviewable' to kwargs when user is authenticated and staff.\n\n        Args:\n            **kwargs: Additional context arguments.\n\n        Returns:\n            dict[str, Any]: Context data for rendering.\n        \"\"\"\n        if self.request.user.is_authenticated and self.request.user.is_staff:\n            kwargs[\"filter_by_reviewable\"] = True\n\n        return super().get_context_data(**kwargs)\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\nclass ReportCampaignView(TemplateView):\n    \"\"\"\n    Display a report summarizing campaign resources and status.\n\n    Renders a paginated report including project-level asset counts, tag counts,\n    contributor counts, reviewer counts and transcription status summaries.\n\n    Attributes:\n        template_name (str): Template used to render the campaign report page.\n\n    Returns:\n        HttpResponse: Renders the campaign report template with context.\n    \"\"\"\n\n    template_name = \"transcriptions/campaign_report.html\"\n\n    def get(\n        self, request, campaign_slug: str, *args: Any, **kwargs: Any\n    ) -> HttpResponse:\n        \"\"\"\n        Handle GET requests for the campaign report page.\n\n        Builds context containing:\n        - Campaign title and slug\n        - Total asset count\n        - Paginated projects with asset, tag, transcriber and reviewer counts\n        - Transcription status summaries per project\n\n        Args:\n            request (HttpRequest): The incoming HTTP request.\n            campaign_slug (str): Slug for the campaign to report on.\n            *args: Additional positional arguments.\n            **kwargs: Additional keyword arguments.\n\n        Returns:\n            HttpResponse: Rendered campaign report page.\n        \"\"\"\n        campaign = get_object_or_404(Campaign.objects.published(), slug=campaign_slug)\n\n        try:\n            page = int(self.request.GET.get(\"page\", \"1\"))\n        except ValueError:\n            page = 1\n\n        campaign_assets = Asset.objects.published().filter(\n            item__project__campaign=campaign\n        )\n\n        ctx = {\n            \"title\": campaign.title,\n            \"campaign_slug\": campaign.slug,\n            \"total_asset_count\": campaign_assets.count(),\n        }\n\n        projects_qs = campaign.project_set.published().order_by(\"title\")\n\n        projects_qs = projects_qs.annotate(\n            asset_count=Count(\n                \"item__asset\",\n                filter=Q(item__published=True, item__asset__published=True),\n                distinct=True,\n            )\n        )\n        projects_qs = projects_qs.annotate(\n            tag_count=Count(\"item__asset__userassettagcollection__tags\", distinct=True)\n        )\n        projects_qs = projects_qs.annotate(\n            transcriber_count=Count(\"item__asset__transcription__user\", distinct=True),\n            reviewer_count=Count(\n                \"item__asset__transcription__reviewed_by\", distinct=True\n            ),\n        )\n\n        paginator = Paginator(projects_qs, ASSETS_PER_PAGE)\n        if page > paginator.num_pages:\n            page = 1\n        projects_page = paginator.get_page(page)\n\n        self.add_transcription_status_summary_to_projects(projects_page)\n\n        ctx[\"paginator\"] = paginator\n        ctx[\"projects\"] = projects_page\n\n        return render(self.request, self.template_name, ctx)\n\n    def add_transcription_status_summary_to_projects(\n        self, projects: Iterable[Project]\n    ) -> None:\n        \"\"\"\n        Annotate each project with a summary of transcription statuses.\n\n        Adds a 'transcription_statuses' attribute to each project, containing\n        status names and their respective counts, ordered by status.\n\n        Args:\n            projects (Iterable): Projects to annotate.\n\n        Returns:\n            None\n        \"\"\"\n        status_qs = Asset.objects.filter(\n            item__published=True, item__project__in=projects, published=True\n        )\n        status_qs = status_qs.values_list(\"item__project__id\", \"transcription_status\")\n        status_qs = status_qs.annotate(Count(\"transcription_status\"))\n        project_statuses = {}\n\n        for project_id, status_value, count in status_qs:\n            status_name = TranscriptionStatus.CHOICE_MAP[status_value]\n            project_statuses.setdefault(project_id, []).append((status_name, count))\n\n        # We'll sort the statuses in the same order they're presented in the choices\n        # list so the display order will be both stable and consistent with the way\n        # we talk about the workflow:\n        sort_order = [j for i, j in TranscriptionStatus.CHOICES]\n\n        for project in projects:\n            statuses = project_statuses.get(project.id, [])\n            statuses.sort(key=lambda i: sort_order.index(i[0]))\n            project.transcription_statuses = statuses\n"
  },
  {
    "path": "concordia/views/decorators.py",
    "content": "from collections.abc import Callable\nfrom functools import wraps\nfrom time import time\n\nfrom django.conf import settings\nfrom django.core.exceptions import ObjectDoesNotExist, ValidationError\nfrom django.http import HttpRequest, JsonResponse\nfrom django.views.decorators.cache import cache_control, never_cache\nfrom django.views.decorators.vary import vary_on_headers\n\nfrom concordia.forms import TurnstileForm\nfrom concordia.logging import ConcordiaLogger\nfrom configuration.utils import configuration_value\nfrom configuration.validation import validate_rate\n\nstructured_logger = ConcordiaLogger.get_logger(__name__)\n\n\ndef default_cache_control(view_function: Callable) -> Callable:\n    \"\"\"\n    Decorator that applies default cache control headers to public-facing views.\n\n    This decorator sets `Cache-Control: public` with a max-age defined in the\n    `DEFAULT_PAGE_TTL` Django setting. It also varies the response by the\n    `Accept-Encoding` header.\n\n    Args:\n        view_function (Callable): The view function to decorate.\n\n    Returns:\n        Callable: The wrapped view function with cache control headers applied.\n    \"\"\"\n\n    @vary_on_headers(\"Accept-Encoding\")\n    @cache_control(public=True, max_age=settings.DEFAULT_PAGE_TTL)\n    @wraps(view_function)\n    def inner(*args, **kwargs):\n        return view_function(*args, **kwargs)\n\n    return inner\n\n\ndef user_cache_control(view_function: Callable) -> Callable:\n    \"\"\"\n    Decorator that applies cache control headers for views varying by session.\n\n    This decorator is intended for views that may return different content\n    based on whether the user is authenticated. It sets\n    `Cache-Control: public` with the `DEFAULT_PAGE_TTL` setting and varies\n    the response by both `Accept-Encoding` and `Cookie` headers.\n\n    Args:\n        view_function (Callable): The view function to decorate.\n\n    Returns:\n        Callable: The wrapped view function with user-aware cache control\n            headers.\n    \"\"\"\n\n    @vary_on_headers(\"Accept-Encoding\", \"Cookie\")\n    @cache_control(public=True, max_age=settings.DEFAULT_PAGE_TTL)\n    @wraps(view_function)\n    def inner(*args, **kwargs):\n        return view_function(*args, **kwargs)\n\n    return inner\n\n\ndef validate_anonymous_user(view: Callable) -> Callable:\n    \"\"\"\n    Decorator that applies anonymous user validation for `POST` requests.\n\n    If the user is unauthenticated and submits a `POST` request, this\n    decorator checks whether the user has recently passed Turnstile\n    validation. If not, it validates the request using a `TurnstileForm`.\n    Failing validation returns a 401 JSON response.\n\n    The timestamp of a successful validation is stored in the user session\n    to avoid re-validating within the configured interval.\n\n    Args:\n        view (Callable): The view function to wrap.\n\n    Returns:\n        Callable: The wrapped view function with anonymous user validation\n            logic.\n    \"\"\"\n\n    @wraps(view)\n    @never_cache\n    def inner(request, *args, **kwargs):\n        if not request.user.is_authenticated and request.method == \"POST\":\n            # First check if the user has already been validated within the\n            # time limit. If so, validation can be skipped.\n            turnstile_last_validated = request.session.get(\n                \"turnstile_last_validated\", 0\n            )\n            age = time() - turnstile_last_validated\n            if age > settings.ANONYMOUS_USER_VALIDATION_INTERVAL:\n                form = TurnstileForm(request.POST)\n                if not form.is_valid():\n                    return JsonResponse(\n                        {\n                            \"error\": (\n                                \"Unable to validate. Please try again or \" \"login.\"\n                            )\n                        },\n                        status=401,\n                    )\n                else:\n                    # User has been validated, so cache the time in the\n                    # session.\n                    request.session[\"turnstile_last_validated\"] = time()\n\n        return view(request, *args, **kwargs)\n\n    return inner\n\n\ndef reserve_rate(group: str, request: HttpRequest) -> str | None:\n    \"\"\"\n    Determine the rate limit value for a request.\n\n    This helper is used to control throttling behavior. If the user is\n    anonymous, it returns a fixed rate limit string, for example \"100/m\".\n    Authenticated users are not rate-limited and it returns `None`.\n\n    The `group` parameter controls how rate limits are grouped. It defaults\n    to the dotted name of the view so each view is treated as its own rate\n    limit bucket unless explicitly overridden.\n\n    Args:\n        group (str): Group name used to bucket rate limits. Defaults to the\n            dotted view name if not set manually.\n        request (HttpRequest): The incoming HTTP request.\n\n    Returns:\n        str | None: A rate string such as \"100/m\" for anonymous users, or\n            `None` otherwise.\n    \"\"\"\n    return None if request.user.is_authenticated else \"100/m\"\n\n\ndef next_asset_rate(group: str, request: HttpRequest) -> str | None:\n    \"\"\"\n    Determine the rate limit value for a next-asset request.\n\n    If the user is anonymous, this helper returns a rate limit string from\n    the `next_asset_rate_limit` configuration value, for example \"4/m\".\n    Authenticated users are not rate-limited and it returns `None`.\n\n    The `group` parameter controls how rate limits are grouped. It is used\n    internally by `django-ratelimit`. It could be used to return different\n    rate limits based on the group, but that is not needed currently.\n\n    Args:\n        group (str): Group name used to bucket rate limits. Defaults to the\n            dotted view name if not set manually.\n        request (HttpRequest): The incoming HTTP request.\n\n    Returns:\n        str | None: A rate string such as \"4/m\" for anonymous users, or\n            `None` otherwise.\n    \"\"\"\n    if request.user.is_authenticated:\n        return None\n    try:\n        rate_limit = configuration_value(\"next_asset_rate_limit\")\n        return validate_rate(rate_limit)\n    except (ObjectDoesNotExist, ValidationError) as exc:\n        structured_logger.warning(\n            \"Falling back to default next-asset rate limit.\",\n            event_code=\"next_asset_rate_config_fallback\",\n            reason=\"Could not load or validate configured rate limit\",\n            reason_code=\"config_missing_or_invalid\",\n            group=group,\n            default_rate=\"4/m\",\n            user=request.user,\n            error_type=exc.__class__.__name__,\n            error=str(exc),\n        )\n        return \"4/m\"\n"
  },
  {
    "path": "concordia/views/items.py",
    "content": "from urllib.parse import urlencode\n\nfrom django.db.models import QuerySet\nfrom django.http import Http404, HttpRequest, HttpResponse\nfrom django.shortcuts import get_object_or_404, redirect\nfrom django.utils.decorators import method_decorator\n\nfrom concordia.api_views import APIListView\nfrom concordia.models import Campaign, Item, TranscriptionStatus\nfrom concordia.utils import get_image_urls_from_asset\n\nfrom .decorators import default_cache_control, user_cache_control\nfrom .utils import calculate_asset_stats\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\nclass ItemDetailView(APIListView):\n    \"\"\"\n    Display a paginated list of assets for a specific item.\n\n    This view handles GET requests and renders the item detail page,\n    which includes the item's assets, context for filtering, and transcription stats.\n\n    Uses `APIListView` to support both HTML rendering and optional JSON output for\n    frontend consumption.\n\n    Attributes:\n        template_name (str): Template used to render the asset list.\n        context_object_name (str): The variable name for assets in the template.\n        paginate_by (int): Number of assets to display per page.\n        http_method_names (list[str]): HTTP methods supported by the view.\n    \"\"\"\n\n    template_name = \"transcriptions/item_detail.html\"\n    context_object_name = \"assets\"\n    paginate_by = 10\n\n    http_method_names = [\"get\", \"options\", \"head\"]\n\n    def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:\n        \"\"\"\n        Handle incoming HTTP requests and redirect if the item or campaign is missing.\n\n        Args:\n            request (HttpRequest): The HTTP request object.\n\n        Returns:\n            HttpResponse: The response for the view or a redirect to the campaign page.\n        \"\"\"\n        try:\n            return super().dispatch(request, *args, **kwargs)\n        except Http404:\n            campaign = get_object_or_404(\n                Campaign.objects.published(), slug=self.kwargs[\"campaign_slug\"]\n            )\n            return redirect(campaign)\n\n    def _get_assets(self) -> QuerySet:\n        \"\"\"\n        Retrieve the queryset of published assets for the current item.\n\n        If the `filter_by_reviewable` flag is set in `self.kwargs`, excludes any assets\n        already transcribed by the current user to allow for review filtering.\n\n        Returns:\n            QuerySet: The filtered set of published `Asset` objects for the item.\n        \"\"\"\n        assets = self.item.asset_set.published()\n        if self.kwargs.get(\"filter_by_reviewable\", False):\n            assets = assets.exclude(transcription__user=self.request.user.id)\n        return assets\n\n    def get_queryset(self):\n        \"\"\"\n        Build and return the queryset of assets for the current item.\n\n        Retrieves the specified `Item` and filters its published assets based on\n        transcription status. If `filter_by_reviewable` is set in `self.kwargs`,\n        filters assets not yet transcribed by the current user and restricts the\n        status to SUBMITTED.\n\n        The resulting queryset is ordered by sequence and annotated for use in the view.\n        Also sets `self.filters` for use in context and querystring construction.\n\n        Returns:\n            QuerySet: The filtered and ordered queryset of `Asset` objects.\n        \"\"\"\n        self.item = get_object_or_404(\n            Item.objects.published().select_related(\"project__campaign\"),\n            project__campaign__slug=self.kwargs[\"campaign_slug\"],\n            project__slug=self.kwargs[\"project_slug\"],\n            item_id=self.kwargs[\"item_id\"],\n        )\n\n        asset_qs = self._get_assets().order_by(\"sequence\")\n        asset_qs = asset_qs.select_related(\n            \"item__project__campaign\", \"item__project\", \"item\"\n        )\n\n        self.filters = {}\n        if self.kwargs.get(\"filter_by_reviewable\", False):\n            status = TranscriptionStatus.SUBMITTED\n        else:\n            status = self.request.GET.get(\"transcription_status\")\n        if status in TranscriptionStatus.CHOICE_MAP:\n            asset_qs = asset_qs.filter(transcription_status=status)\n            # We only want to pass specific QS parameters to lower-level search\n            # pages so we'll record those here:\n            self.filters[\"transcription_status\"] = status\n\n        return asset_qs\n\n    def get_context_data(self, **kwargs):\n        \"\"\"\n        Construct the context dictionary for rendering the item detail page.\n\n        Adds campaign, project, item and transcription status to the context. Also\n        attaches filter state and querystring for pagination or navigation. If review\n        filtering is enabled, the context is marked accordingly.\n\n        Includes asset-level transcription statistics via `calculate_asset_stats()`.\n\n        Returns:\n            dict: The context data for the template rendering.\n        \"\"\"\n        ctx = super().get_context_data(**kwargs)\n\n        ctx.update(\n            {\n                \"campaign\": self.item.project.campaign,\n                \"project\": self.item.project,\n                \"item\": self.item,\n                \"sublevel_querystring\": urlencode(self.filters),\n                \"filters\": self.filters,\n            }\n        )\n\n        item_assets = self._get_assets()\n        if self.kwargs.get(\"filter_by_reviewable\", False):\n            ctx[\"filter_assets\"] = True\n            ctx[\"transcription_status\"] = TranscriptionStatus.SUBMITTED\n        else:\n            ctx[\"transcription_status\"] = self.request.GET.get(\"transcription_status\")\n\n        calculate_asset_stats(item_assets, ctx)\n\n        return ctx\n\n    def serialize_context(self, context: dict) -> dict:\n        \"\"\"\n        Serialize the context data for JSON responses.\n\n        Enhances each serialized asset with its associated image and thumbnail URLs.\n        Also includes serialized data for the parent item.\n\n        Args:\n            context (dict): The original context data returned by `get_context_data()`.\n\n        Returns:\n            dict: The serialized version of the context suitable for API responses.\n        \"\"\"\n        data = super().serialize_context(context)\n\n        for i, asset in enumerate(context[\"object_list\"]):\n            serialized_asset = data[\"objects\"][i]\n            image_url, thumbnail_url = get_image_urls_from_asset(asset)\n            serialized_asset[\"image_url\"] = image_url\n            serialized_asset[\"thumbnail_url\"] = thumbnail_url\n\n        data[\"item\"] = self.serialize_object(context[\"item\"])\n        return data\n\n\n@method_decorator(user_cache_control, name=\"dispatch\")\nclass FilteredItemDetailView(ItemDetailView):\n    \"\"\"\n    View that displays only reviewable assets for an item.\n\n    Inherits from `ItemDetailView` but overrides queryset and context behavior to\n    exclude assets already transcribed by the current user. Used to present assets\n    eligible for review.\n    \"\"\"\n\n    def get_queryset(self):\n        \"\"\"\n        Modify the queryset to include only reviewable assets.\n\n        Sets the `filter_by_reviewable` flag in `self.kwargs` to enable filtering logic\n        in the parent view.\n\n        Returns:\n            QuerySet: A filtered queryset of `Asset` objects for review.\n        \"\"\"\n        self.kwargs[\"filter_by_reviewable\"] = True\n        return super().get_queryset()\n\n    def get_context_data(self, **kwargs):\n        \"\"\"\n        Update the context to reflect that only reviewable assets are being shown.\n\n        Ensures `filter_by_reviewable` is set in both `self.kwargs` and `kwargs` so that\n        downstream logic (like filtering and labeling) behaves consistently.\n\n        Returns:\n            dict: The context dictionary for rendering the filtered item detail view.\n        \"\"\"\n        self.kwargs[\"filter_by_reviewable\"] = True\n        kwargs[\"filter_by_reviewable\"] = True\n        return super().get_context_data(**kwargs)\n"
  },
  {
    "path": "concordia/views/maintenance_mode.py",
    "content": "from time import time\n\nfrom django.core.cache import cache\nfrom django.http import HttpRequest, HttpResponseRedirect\nfrom maintenance_mode.core import set_maintenance_mode\n\n\ndef maintenance_mode_off(request: HttpRequest) -> HttpResponseRedirect:\n    \"\"\"\n    Deactivates maintenance mode and redirects to the site root.\n\n    Only superusers are allowed to use this view. If the requesting user is not a\n    superuser, no change is made to the system state.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the root path with a timestamp parameter\n        used for cache busting.\n    \"\"\"\n    if request.user.is_superuser:\n        set_maintenance_mode(False)\n\n    # Added cache busting to make sure maintenance mode banner is\n    # always displayed/removed\n    return HttpResponseRedirect(\"/?t={}\".format(int(time())))\n\n\ndef maintenance_mode_on(request: HttpRequest) -> HttpResponseRedirect:\n    \"\"\"\n    Activates maintenance mode and redirects to the site root.\n\n    Only superusers are allowed to use this view. If the requesting user is not a\n    superuser, no change is made to the system state.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the root path with a timestamp parameter\n        used for cache busting.\n    \"\"\"\n    if request.user.is_superuser:\n        set_maintenance_mode(True)\n\n    # Added cache busting to make sure maintenance mode banner is\n    # always displayed/removed\n    return HttpResponseRedirect(\"/?t={}\".format(int(time())))\n\n\ndef maintenance_mode_frontend_available(request: HttpRequest) -> HttpResponseRedirect:\n    \"\"\"\n    Enables frontend access during maintenance mode and redirects to the site root.\n\n    This sets a cache key (`maintenance_mode_frontend_available`) to allow staff and\n    superusers to bypass maintenance restrictions while the site is otherwise disabled.\n    Only superusers are allowed to use this view.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the root path with a timestamp parameter\n        used for cache busting.\n    \"\"\"\n    if request.user.is_superuser:\n        cache.set(\"maintenance_mode_frontend_available\", True, None)\n\n    return HttpResponseRedirect(\"/?t={}\".format(int(time())))\n\n\ndef maintenance_mode_frontend_unavailable(request: HttpRequest) -> HttpResponseRedirect:\n    \"\"\"\n    Disables frontend access during maintenance mode and redirects to the site root.\n\n    This clears the `maintenance_mode_frontend_available` cache key, fully locking out\n    all users (including staff) from the site frontend during maintenance mode.\n    Only superusers are allowed to use this view.\n\n    Returns:\n        HttpResponseRedirect: Redirect to the root path with a timestamp parameter\n        used for cache busting.\n    \"\"\"\n    if request.user.is_superuser:\n        cache.set(\"maintenance_mode_frontend_available\", False, None)\n\n    return HttpResponseRedirect(\"/?t={}\".format(int(time())))\n"
  },
  {
    "path": "concordia/views/projects.py",
    "content": "from urllib.parse import urlencode\n\nfrom django.db.models import Count, Q, QuerySet\nfrom django.http import Http404, HttpRequest, HttpResponse\nfrom django.shortcuts import get_object_or_404, redirect\nfrom django.utils.decorators import method_decorator\n\nfrom concordia.api_views import APIListView\nfrom concordia.models import Asset, Campaign, Project, TranscriptionStatus\n\nfrom .decorators import default_cache_control, user_cache_control\nfrom .utils import annotate_children_with_progress_stats, calculate_asset_stats\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\nclass ProjectDetailView(APIListView):\n    \"\"\"\n    Display a paginated list of items for a single project.\n\n    Handles GET requests for a published project scoped by campaign and project\n    slugs. Applies optional filtering to show only items with a specific\n    transcription status. Builds context including campaign/project metadata\n    and progress statistics.\n\n    Attributes:\n        template_name (str): Template used for project detail.\n        context_object_name (str): Context key under which the list of items is\n            exposed to templates.\n        paginate_by (int): Number of items per page.\n\n    Returns:\n        HttpResponse: Rendered project detail page or a redirect if the project\n            or campaign cannot be found.\n    \"\"\"\n\n    template_name = \"transcriptions/project_detail.html\"\n    context_object_name = \"items\"\n    paginate_by = 10\n\n    def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:\n        \"\"\"\n        Dispatch the request or redirect to campaign if the item is missing.\n\n        If an `Http404` occurs during normal dispatch (e.g., item not found),\n        redirect to the campaign page to keep navigation stable.\n\n        Args:\n            request (HttpRequest): The incoming request.\n            *args: Positional args forwarded to the superclass.\n            **kwargs: Keyword args forwarded to the superclass.\n\n        Returns:\n            HttpResponse: Normal response from `APIListView.dispatch` or a\n                redirect to the campaign page when the asset path is invalid.\n        \"\"\"\n        try:\n            return super().dispatch(request, *args, **kwargs)\n        except Http404:\n            campaign = get_object_or_404(\n                Campaign.objects.published(), slug=self.kwargs[\"campaign_slug\"]\n            )\n            return redirect(campaign)\n\n    def get_queryset(self, filter_by_reviewable: bool = False) -> QuerySet:\n        \"\"\"\n        Return the queryset of items for the current project.\n\n        Loads the published project identified by `campaign_slug` and `slug`,\n        then builds an ordered queryset of its published items. When\n        `filter_by_reviewable` is true, excludes items already transcribed by\n        the requesting user. Each item is annotated with per-status counts\n        using `TranscriptionStatus.CHOICE_MAP`.\n\n        Request Parameters:\n            - `transcription_status` (str, optional): If present and valid,\n              restrict the queryset to items that have at least one asset in\n              that status (items with zero count for that status are excluded).\n\n        Args:\n            filter_by_reviewable (bool): If true, exclude items containing an\n                asset with a transcription by the current user.\n\n        Returns:\n            QuerySet: Ordered queryset of items annotated with per-status\n                counts. Also sets `self.filters` to the propagated filters that\n                should appear in sublevel navigation links.\n        \"\"\"\n        self.project = get_object_or_404(\n            Project.objects.published().select_related(\"campaign\"),\n            slug=self.kwargs[\"slug\"],\n            campaign__slug=self.kwargs[\"campaign_slug\"],\n        )\n\n        item_qs = self.project.item_set.published().order_by(\"item_id\")\n        if filter_by_reviewable:\n            item_qs = item_qs.exclude(asset__transcription__user=self.request.user.id)\n        item_qs = item_qs.annotate(\n            **{\n                f\"{key}_count\": Count(\n                    \"asset\", filter=Q(asset__transcription_status=key)\n                )\n                for key in TranscriptionStatus.CHOICE_MAP\n            }\n        )\n\n        self.filters: dict[str, str] = {}\n\n        if filter_by_reviewable:\n            status = TranscriptionStatus.SUBMITTED\n        else:\n            status = self.request.GET.get(\"transcription_status\")\n        if status in TranscriptionStatus.CHOICE_MAP:\n            item_qs = item_qs.exclude(**{f\"{status}_count\": 0})\n            # We only want to pass specific QS parameters to lower-level search\n            # pages so we'll record those here:\n            self.filters[\"transcription_status\"] = status\n\n        return item_qs\n\n    def get_context_data(self, **kws) -> dict[str, object]:\n        \"\"\"\n        Build context for the project detail template.\n\n        Context Format:\n            - `items` (QuerySet): Paginated list of project items.\n            - `project` (Project): The current project.\n            - `campaign` (Campaign): The parent campaign.\n            - `filters` (dict[str, str] | absent): Filters applied to this view.\n            - `sublevel_querystring` (str | absent): URL-encoded filters to pass\n              into sublevel pages.\n            - `transcription_status` (str | None): Current status filter or\n              derived status when reviewable filtering is active.\n            - `filter_assets` (bool | absent): True when items are filtered to\n              exclude those transcribed by the current user.\n            - `total_assets` (int): Count of assets in the project (published).\n            - `completed_assets` (int): Count of completed assets.\n            - `in_progress_assets` (int): Count of in-progress assets.\n            - `not_started_assets` (int): Count of not-started assets.\n            - `submitted_assets` (int): Count of submitted assets.\n            - Progress statistics on each item are added in-place by\n              `annotate_children_with_progress_stats`.\n\n        Args:\n            **kws: Optional flags. Recognized:\n                - `filter_by_reviewable` (bool): When true, limit assets and\n                  force `transcription_status` to `SUBMITTED`.\n\n        Returns:\n            dict[str, object]: Template context including project/campaign\n                metadata, filters, and computed statistics.\n        \"\"\"\n        ctx = super().get_context_data(**kws)\n        ctx[\"project\"] = project = self.project\n        ctx[\"campaign\"] = project.campaign\n\n        if self.filters:\n            ctx[\"sublevel_querystring\"] = urlencode(self.filters)\n            ctx[\"filters\"] = self.filters\n\n        project_assets = Asset.objects.filter(\n            item__project=project, published=True, item__published=True\n        )\n        filter_by_reviewable = kws.get(\"filter_by_reviewable\", False)\n        if filter_by_reviewable:\n            project_assets = project_assets.exclude(\n                transcription__user=self.request.user.id\n            )\n            ctx[\"filter_assets\"] = True\n            ctx[\"transcription_status\"] = TranscriptionStatus.SUBMITTED\n        else:\n            ctx[\"transcription_status\"] = self.request.GET.get(\"transcription_status\")\n\n        calculate_asset_stats(project_assets, ctx)\n\n        annotate_children_with_progress_stats(ctx[\"items\"])\n\n        return ctx\n\n    def serialize_context(self, context: dict[str, object]) -> dict[str, object]:\n        \"\"\"\n        Serialize context for API responses.\n\n        Extends the base list serialization by attaching the serialized project\n        object. Mirrors the behavior used elsewhere to pair list payloads with\n        their parent container.\n\n        Args:\n            context (dict[str, object]): The view context to serialize.\n\n        Returns:\n            dict[str, object]: A JSON-serializable structure including:\n                - `results`/pagination fields from the base serializer.\n                - `project` (dict): Serialized project metadata.\n        \"\"\"\n        data = super().serialize_context(context)\n        data[\"project\"] = self.serialize_object(context[\"project\"])\n        return data\n\n\n@method_decorator(user_cache_control, name=\"dispatch\")\nclass FilteredProjectDetailView(ProjectDetailView):\n    \"\"\"\n    Project detail view that filters to reviewable items for the user.\n\n    This variant restricts the queryset and context to prioritize items that\n    are ready for review by the current user (i.e., excludes items with assets\n    already transcribed by that user). It also sets the effective status filter\n    to `SUBMITTED` in context.\n    \"\"\"\n\n    def get_queryset(self) -> QuerySet:\n        \"\"\"\n        Return the review-focused queryset.\n\n        Delegates to the parent implementation with `filter_by_reviewable=True`.\n\n        Returns:\n            QuerySet: Item queryset annotated with status counts and filtered\n                for reviewable content.\n        \"\"\"\n        return super().get_queryset(filter_by_reviewable=True)\n\n    def get_context_data(self, **kws) -> dict[str, object]:\n        \"\"\"\n        Build context with reviewable filtering enabled.\n\n        Sets the `filter_by_reviewable` flag before delegating to the parent\n        implementation so that downstream context keys (e.g., status and\n        `filter_assets`) reflect review-mode behavior.\n\n        Args:\n            **kws: Context keyword arguments.\n\n        Returns:\n            dict[str, object]: Context dictionary with reviewable filtering.\n        \"\"\"\n        kws[\"filter_by_reviewable\"] = True\n\n        return super().get_context_data(**kws)\n"
  },
  {
    "path": "concordia/views/rate_limit.py",
    "content": "from django.http import HttpRequest, HttpResponse, JsonResponse\nfrom django.shortcuts import render\n\nfrom concordia.utils import request_accepts_json\n\n\ndef ratelimit_view(\n    request: HttpRequest, exception: Exception | None = None\n) -> HttpResponse:\n    \"\"\"\n    Handles requests blocked due to rate limiting (HTTP 429).\n\n    Determines whether to return a JSON or HTML response based on the request headers.\n    Adds a `Retry-After` header instructing clients to wait 15 minutes before retrying.\n\n    Args:\n        request (HttpRequest): The incoming request that triggered the rate limit.\n        exception (Exception | None): The exception that caused the view to trigger,\n            if available.\n\n    Returns:\n        HttpResponse: A JSON or HTML 429 response with a retry header.\n    \"\"\"\n    status_code = 429\n\n    ctx = {\n        \"error\": \"You have been rate-limited. Please try again later.\",\n        \"status\": status_code,\n    }\n\n    if exception is not None:\n        ctx[\"exception\"] = str(exception)\n\n    if request.headers.get(\n        \"x-requested-with\"\n    ) == \"XMLHttpRequest\" or request_accepts_json(request):\n        response = JsonResponse(ctx, status=status_code)\n    else:\n        response = render(request, \"429.html\", context=ctx, status=status_code)\n\n    response[\"Retry-After\"] = 15 * 60\n\n    return response\n"
  },
  {
    "path": "concordia/views/simple_pages.py",
    "content": "import datetime\nfrom typing import Any\n\nimport markdown\nfrom django.core.cache import cache\nfrom django.http import HttpRequest, HttpResponse\nfrom django.shortcuts import get_object_or_404, render\nfrom django.template import Context, Template\nfrom django.utils.http import http_date\nfrom django.utils.timezone import now\nfrom django.views.generic import RedirectView\n\nfrom concordia.models import Guide, SimplePage, SiteReport\nfrom concordia.parser import paginate_blog_posts\n\nfrom .decorators import default_cache_control\n\n\n@default_cache_control\ndef simple_page(\n    request: HttpRequest,\n    path: str | None = None,\n    slug: str | None = None,\n    body_ctx: dict[str, Any] | None = None,\n    template: str = \"static-page.html\",\n) -> HttpResponse:\n    \"\"\"\n    Renders a simple Markdown-based page stored in the `SimplePage` model.\n\n    If no `path` is provided, defaults to the current request path. Markdown is\n    rendered with optional associated guide content. Breadcrumbs and language\n    detection are computed from the URL structure.\n\n    Request Parameters:\n        path (str, optional): The database path of the page. Defaults to the\n            current request path.\n        slug (str, optional): Unused in current logic; passed for route compatibility.\n        body_ctx (dict[str, Any], optional): Additional context injected into the page\n            body during rendering.\n        template (str): Template used to render the page.\n\n    Returns:\n        HttpResponse: Rendered HTML of the simple page.\n    \"\"\"\n    if not path:\n        path = request.path\n\n    if body_ctx is None:\n        body_ctx = {}\n\n    page = get_object_or_404(SimplePage, path=path)\n\n    md = markdown.Markdown(extensions=[\"meta\"])\n\n    breadcrumbs = []\n    path_components = request.path.strip(\"/\").split(\"/\")\n    for i, segment in enumerate(path_components[:-1], start=1):\n        breadcrumbs.append(\n            (\"/%s/\" % \"/\".join(path_components[0:i]), segment.replace(\"-\", \" \").title())\n        )\n    breadcrumbs.append((request.path, page.title))\n\n    language_code = \"en\"\n    if request.path.replace(\"/\", \"\").endswith(\"-esp\"):\n        language_code = \"es\"\n\n    ctx = {\n        \"language_code\": language_code,\n        \"title\": page.title,\n        \"breadcrumbs\": breadcrumbs,\n    }\n\n    guide = page.guide_set.all().first()\n    if guide is not None:\n        html = \"\".join((page.body, guide.body))\n        ctx[\"add_navigation\"] = True\n    else:\n        html = page.body\n    if \"add_navigation\" in ctx:\n        ctx[\"guides\"] = Guide.objects.order_by(\"order\")\n    body = Template(md.convert(html))\n    ctx[\"body\"] = body.render(Context(body_ctx))\n    ctx.update(body_ctx)\n\n    resp = render(request, template, ctx)\n    resp[\"Created\"] = http_date(page.created_on.timestamp())\n    return resp\n\n\n@default_cache_control\ndef about_simple_page(\n    request: HttpRequest, path: str | None = None, slug: str | None = None\n) -> HttpResponse:\n    \"\"\"\n    Renders the \"about\" simple page with additional cached campaign and blog stats.\n\n    Adds the following keys to the context:\n        - `report_date` (datetime): Yesterday’s date.\n        - `campaigns_published` (int): Count from active SiteReport.\n        - `assets_published` (int): Active + retired total.\n        - `assets_completed` (int): Active + retired total.\n        - `assets_waiting_review` (int): Active + retired total.\n        - `users_activated` (int): From active SiteReport.\n        - `blog_posts` (Callable): Reference to blog post fetcher.\n\n    Returns:\n        HttpResponse: Rendered HTML of the about page with campaign stats.\n    \"\"\"\n    context_cache_key = \"about_simple_page-about_context\"\n    about_context = cache.get(context_cache_key)\n    if not about_context:\n        try:\n            active_campaigns = SiteReport.objects.filter(\n                report_name=SiteReport.ReportName.TOTAL\n            ).latest()\n        except SiteReport.DoesNotExist:\n            active_campaigns = SiteReport(\n                campaigns_published=0,\n                assets_published=0,\n                assets_completed=0,\n                assets_waiting_review=0,\n                users_activated=0,\n            )\n        try:\n            retired_campaigns = SiteReport.objects.filter(\n                report_name=SiteReport.ReportName.RETIRED_TOTAL\n            ).latest()\n        except SiteReport.DoesNotExist:\n            retired_campaigns = SiteReport(\n                assets_published=0,\n                assets_completed=0,\n                assets_waiting_review=0,\n            )\n        about_context = {\n            \"report_date\": now() - datetime.timedelta(days=1),\n            \"campaigns_published\": active_campaigns.campaigns_published,\n            \"assets_published\": active_campaigns.assets_published\n            + retired_campaigns.assets_published,\n            \"assets_completed\": active_campaigns.assets_completed\n            + retired_campaigns.assets_completed,\n            \"assets_waiting_review\": active_campaigns.assets_waiting_review\n            + retired_campaigns.assets_waiting_review,\n            \"users_activated\": active_campaigns.users_activated,\n            \"blog_posts\": paginate_blog_posts(),\n            \"about_page\": True,\n        }\n        cache.set(context_cache_key, about_context, 60 * 60)\n\n    return simple_page(request, path, slug, about_context)\n\n\n# These views are to make sure various links to help-center URLs don't break\n# when the URLs are changed to not include help-center and can be removed after\n# all links are updated.\n\n\nclass HelpCenterRedirectView(RedirectView):\n    def get_redirect_url(self, *args, **kwargs):\n        path = kwargs[\"page_slug\"]\n        return \"/get-started/\" + path + \"/\"\n\n\nclass HelpCenterSpanishRedirectView(RedirectView):\n    def get_redirect_url(self, *args, **kwargs):\n        path = kwargs[\"page_slug\"]\n        return \"/get-started-esp/\" + path + \"-esp/\"\n\n\n# End of help-center views\n"
  },
  {
    "path": "concordia/views/topics.py",
    "content": "from typing import Any\nfrom urllib.parse import urlencode\n\nfrom django.db.models import Count, F, FilteredRelation, Q\nfrom django.utils.decorators import method_decorator\nfrom django.views.decorators.cache import cache_page\n\nfrom concordia.api_views import APIDetailView\nfrom concordia.models import Asset, Topic, TranscriptionStatus\n\nfrom .decorators import default_cache_control\nfrom .utils import annotate_children_with_progress_stats, calculate_asset_stats\n\n\n@method_decorator(default_cache_control, name=\"dispatch\")\n@method_decorator(cache_page(60 * 60, cache=\"view_cache\"), name=\"dispatch\")\nclass TopicDetailView(APIDetailView):\n    \"\"\"\n    Display a topic and its projects with aggregated progress stats.\n\n    Renders the topic detail page with a list of published projects tied to\n    the topic, annotated with per-status asset counts. Supports an optional\n    transcription-status filter which narrows projects to those containing\n    assets in that status and respects per-topic URL filter overrides.\n\n    Attributes:\n        template_name (str): Template used for topic detail.\n        context_object_name (str): Context key for the main object (`topic`).\n        queryset (QuerySet[Topic]): Base queryset for lookup and ordering.\n    \"\"\"\n\n    template_name = \"transcriptions/topic_detail.html\"\n    context_object_name = \"topic\"\n    queryset = Topic.objects.published().order_by(\"title\")\n\n    def get_context_data(self, **kwargs: Any) -> dict[str, object]:\n        \"\"\"\n        Build context for the topic detail template.\n\n        Computes project-level progress annotations and applies an optional\n        status filter. Also computes topic-wide asset statistics and prepares\n        sublevel querystring parameters for downstream pages.\n\n        Request Parameters:\n            - `transcription_status` (str, optional): When present and valid,\n              filters projects to those that:\n                * have at least one asset in the given status, and\n                * either have no `pt__url_filter` or have one matching the\n                  requested status.\n\n        Context Format:\n            - `topic` (Topic): The topic being viewed.\n            - `projects` (QuerySet): Topic projects with:\n                * per-status counts (e.g., `submitted_count`), and\n                * `topic_ordering` and `topic_url_filter` from the through\n                  relation.\n            - `filters` (dict[str, str]): Applied filter parameters.\n            - `sublevel_querystring` (str): URL-encoded `filters` for links.\n            - `transcription_status` (str | None): Reflected status filter.\n            - Aggregated asset stats for the topic (added by\n              `calculate_asset_stats`), including keys such as:\n                * `total_assets`, `completed_assets`, `in_progress_assets`,\n                  `not_started_assets`, `submitted_assets`.\n\n        Args:\n            **kwargs: Additional context arguments passed by the base class.\n\n        Returns:\n            dict[str, object]: Context for rendering the topic detail page.\n        \"\"\"\n        ctx = super().get_context_data(**kwargs)\n        topic = ctx[\"topic\"]\n\n        status = self.request.GET.get(\"transcription_status\")\n        status_valid = status in TranscriptionStatus.CHOICE_MAP\n\n        projects = (\n            topic.project_set.published().annotate(\n                **{\n                    f\"{key}_count\": Count(\n                        \"item__asset\",\n                        filter=Q(\n                            item__published=True,\n                            item__asset__published=True,\n                            item__asset__transcription_status=key,\n                        ),\n                    )\n                    for key in TranscriptionStatus.CHOICE_MAP.keys()\n                }\n            )\n            # Pin the through relation to THIS topic, otherwise it will annotate for\n            # each ProjectTopic the project is part of\n            .annotate(\n                pt=FilteredRelation(\n                    \"projecttopic\", condition=Q(projecttopic__topic=topic)\n                )\n            )\n            # Pull fields from the pinned alias\n            .annotate(\n                topic_ordering=F(\"pt__ordering\"),\n                topic_url_filter=F(\"pt__url_filter\"),\n            )\n        )\n\n        # If there's a status filter, we want to exclude any projects\n        # don't don't have assets in that status, as well as any\n        # that have a URL filter that's different than the status filter\n        if status_valid:\n            ctx[\"transcription_status\"] = status\n            projects = projects.filter(\n                Q(pt__url_filter__isnull=True)\n                | Q(pt__url_filter=\"\")\n                | Q(pt__url_filter=status)\n            ).exclude(**{f\"{status}_count\": 0})\n\n        projects = projects.order_by(\"topic_ordering\", \"campaign__title\", \"title\")\n\n        ctx[\"filters\"] = filters = {}\n        if status_valid:\n            # We only want to pass specific QS parameters to lower-level search pages:\n            filters[\"transcription_status\"] = status\n        ctx[\"sublevel_querystring\"] = urlencode(filters)\n\n        annotate_children_with_progress_stats(projects)\n        ctx[\"projects\"] = projects\n\n        topic_assets = Asset.objects.filter(\n            item__project__topics=self.object,\n            item__project__published=True,\n            item__published=True,\n            published=True,\n        )\n\n        calculate_asset_stats(topic_assets, ctx)\n\n        return ctx\n\n    def serialize_context(self, context: dict[str, object]) -> dict[str, object]:\n        \"\"\"\n        Serialize context for API consumers.\n\n        Extends the base serializer with a `related_links` list derived from the\n        topic's associated helpful links.\n\n        Args:\n            context (dict[str, object]): Fully built template context.\n\n        Returns:\n            dict[str, object]: JSON-serializable payload that includes the base\n                fields from `APIDetailView.serialize_context` and:\n                - `object.related_links` (list[dict]): Each with:\n                    * `title` (str)\n                    * `url` (str)\n        \"\"\"\n        ctx = super().serialize_context(context)\n        ctx[\"object\"][\"related_links\"] = [\n            {\"title\": title, \"url\": url}\n            for title, url, sequence in self.object.helpfullink_set.values_list(\n                \"title\", \"link_url\"\n            )\n        ]\n        return ctx\n"
  },
  {
    "path": "concordia/views/utils.py",
    "content": "import datetime\nfrom collections.abc import Iterable\nfrom time import time\n\nfrom django.conf import settings\nfrom django.db.models import Count, Max, Q, QuerySet\nfrom django.db.models.functions import Greatest\nfrom django.http import HttpRequest\nfrom django.utils import timezone\nfrom django.utils.timezone import now\n\nfrom concordia.models import Asset, Transcription, TranscriptionStatus\n\n\ndef _get_pages(request: HttpRequest) -> QuerySet:\n    \"\"\"\n    Retrieve a filtered and annotated queryset of assets based on user activity.\n\n    Filters the Asset queryset by:\n      - Activity type (transcribed or reviewed)\n      - Transcription status\n      - Date range (start, end or both)\n      - Campaign ID\n      - Last six months of activity\n\n    Assets are annotated with:\n      - Timestamps of last transcription/review activity\n      - Combined latest activity timestamp\n\n    Also applies ordering based on the selected sort parameter.\n\n    Args:\n        request (HttpRequest): The incoming HTTP request with query parameters.\n\n    Returns:\n        QuerySet: A queryset of `Asset` objects with applied filters and annotations.\n    \"\"\"\n    user = request.user\n    activity = request.GET.get(\"activity\", None)\n\n    if activity == \"transcribed\":\n        q = Q(transcription__user=user)\n    elif activity == \"reviewed\":\n        q = Q(transcription__reviewed_by=user)\n    else:\n        q = Q(transcription__user=user) | Q(transcription__reviewed_by=user)\n    assets = Asset.objects.filter(q)\n\n    status_list = request.GET.getlist(\"status\")\n    if status_list and status_list != []:\n        if \"completed\" not in status_list:\n            assets = assets.exclude(transcription_status=TranscriptionStatus.COMPLETED)\n        if \"submitted\" not in status_list:\n            assets = assets.exclude(transcription_status=TranscriptionStatus.SUBMITTED)\n        if \"in_progress\" not in status_list:\n            assets = assets.exclude(\n                transcription_status=TranscriptionStatus.IN_PROGRESS\n            )\n\n    assets = assets.select_related(\"item\", \"item__project\", \"item__project__campaign\")\n\n    assets = assets.annotate(\n        last_transcribed=Max(\n            \"transcription__created_on\",\n            filter=Q(transcription__user=user),\n        ),\n        last_reviewed=Max(\n            \"transcription__updated_on\",\n            filter=Q(transcription__reviewed_by=user),\n        ),\n        latest_activity=Greatest(\n            \"last_transcribed\",\n            \"last_reviewed\",\n            filter=Q(transcription__user=user) | Q(transcription__reviewed_by=user),\n        ),\n    )\n    fmt = \"%Y-%m-%d\"\n    start_date = None\n    start = request.GET.get(\"start\", None)\n    if start is not None and len(start) > 0:\n        start_date = timezone.make_aware(datetime.datetime.strptime(start, fmt))\n    end_date = None\n    end = request.GET.get(\"end\", None)\n    if end is not None and len(end) > 0:\n        end_date = timezone.make_aware(datetime.datetime.strptime(end, fmt))\n    if start_date is not None and end_date is not None:\n        end_date += datetime.timedelta(days=1)\n        end = end_date.strftime(fmt)\n        assets = assets.filter(latest_activity__range=[start, end])\n    elif start_date is not None or end_date is not None:\n        date = start_date if start_date else end_date\n        assets = assets.filter(\n            latest_activity__year=date.year,\n            latest_activity__month=date.month,\n            latest_activity__day=date.day,\n        )\n    # CONCD-189 only show pages from the last 6 months\n    # This should be an aware datetime, not a date. A date is cast\n    # to a naive datetime when it's compared to a datetime\n    # field, as is being done here\n    SIX_MONTHS_AGO = now() - datetime.timedelta(days=6 * 30)\n    assets = assets.filter(latest_activity__gte=SIX_MONTHS_AGO)\n    order_by = request.GET.get(\"order_by\", \"date-descending\")\n    if order_by == \"date-ascending\":\n        assets = assets.order_by(\"latest_activity\", \"-id\")\n    else:\n        assets = assets.order_by(\"-latest_activity\", \"-id\")\n\n    campaign_id = request.GET.get(\"campaign\", None)\n    if campaign_id is not None:\n        assets = assets.filter(item__project__campaign__pk=campaign_id)\n\n    return assets\n\n\ndef calculate_asset_stats(asset_qs: QuerySet, ctx: dict) -> None:\n    \"\"\"\n    Annotates the context dictionary with asset statistics and contributor data.\n\n    Computes:\n      - Total number of unique contributors across all transcriptions.\n      - Count and percentage of assets per transcription status.\n      - Labeled status counts for use in progress displays.\n\n    Percentages are capped at 99% for values between 99.0 and 99.999... to avoid\n    showing 100% prematurely.\n\n    Args:\n        asset_qs (QuerySet): A queryset of `Asset` objects to calculate statistics on.\n        ctx (dict): The context dictionary to populate with computed values.\n\n    Returns:\n        None\n    \"\"\"\n    asset_count = asset_qs.count()\n\n    trans_qs = Transcription.objects.filter(asset__in=asset_qs).values_list(\n        \"user_id\", \"reviewed_by\"\n    )\n    user_ids = set()\n    for i, j in trans_qs.iterator():\n        user_ids.add(i)\n        user_ids.add(j)\n    # Remove null values from the set, if it exists\n    try:\n        user_ids.remove(None)\n    except KeyError:\n        pass\n\n    ctx[\"contributor_count\"] = len(user_ids)\n\n    asset_state_qs = asset_qs.values_list(\"transcription_status\")\n    asset_state_qs = asset_state_qs.annotate(Count(\"transcription_status\")).order_by()\n    status_counts_by_key = dict(asset_state_qs)\n\n    ctx[\"transcription_status_counts\"] = labeled_status_counts = []\n\n    for status_key, status_label in TranscriptionStatus.CHOICES:\n        value = status_counts_by_key.get(status_key, 0)\n        if value:\n            pct_raw = 100 * (value / asset_count)\n            if pct_raw >= 99 and pct_raw < 100:\n                pct = 99\n            else:\n                pct = round(pct_raw)\n        else:\n            pct = 0\n\n        ctx[f\"{status_key}_percent\"] = pct\n        ctx[f\"{status_key}_count\"] = value\n        labeled_status_counts.append((status_key, status_label, value))\n\n\ndef annotate_children_with_progress_stats(children: Iterable) -> None:\n    \"\"\"\n    Annotates child objects with transcription progress statistics.\n\n    Each object is expected to have attributes named `{status}_count` corresponding to\n    each transcription status key. This function calculates:\n\n      - `total_count`: Total asset count for the object.\n      - `{status}_percent`: Percentage of total for each transcription status.\n      - `lowest_transcription_status`: The first non-zero status in defined order.\n\n    Percentages are capped at 99% for values between 99.0 and 99.999... to avoid\n    rounding up to 100% prematurely.\n\n    Args:\n        children (Iterable): A sequence of objects with `{status}_count` attributes.\n\n    Returns:\n        None\n    \"\"\"\n    for obj in children:\n        counts = {}\n\n        for k, __ in TranscriptionStatus.CHOICES:\n            counts[k] = getattr(obj, f\"{k}_count\", 0)\n\n        obj.total_count = total = sum(counts.values())\n\n        lowest_status = None\n\n        for k, __ in TranscriptionStatus.CHOICES:\n            count = counts[k]\n\n            if total > 0:\n                pct_raw = 100 * (count / total)\n                if pct_raw >= 99 and pct_raw < 100:\n                    pct = 99\n                else:\n                    pct = round(pct_raw)\n            else:\n                pct = 0\n\n            setattr(obj, f\"{k}_percent\", pct)\n\n            if lowest_status is None and count > 0:\n                lowest_status = k\n\n        obj.lowest_transcription_status = lowest_status\n\n\nclass AnonymousUserValidationCheckMixin:\n    \"\"\"\n    Mixin that injects anonymous user validation context into class-based views.\n\n    Adds a boolean `anonymous_user_validation_required` to the context, indicating\n    whether a Turnstile validation prompt should be displayed based on the time since\n    the user's last successful validation.\n\n    Intended for use with views that already implement `get_context_data()`, such as\n    Django's TemplateView or DetailView subclasses.\n    \"\"\"\n\n    def get_context_data(self, *args, **kwargs) -> dict:\n        \"\"\"\n        Add anonymous user validation flag to the context.\n\n        If the user is unauthenticated and the time since their last validation exceeds\n        the configured interval, the flag is set to True. Otherwise, it is set to False.\n\n        Returns:\n            dict: The updated template context with the validation flag included.\n        \"\"\"\n        context = super().get_context_data(**kwargs)\n        if not self.request.user.is_authenticated:\n            turnstile_last_validated = self.request.session.get(\n                \"turnstile_last_validated\", 0\n            )\n            age = time() - turnstile_last_validated\n            context[\"anonymous_user_validation_required\"] = (\n                age > settings.ANONYMOUS_USER_VALIDATION_INTERVAL\n            )\n        else:\n            context[\"anonymous_user_validation_required\"] = False\n        return context\n"
  },
  {
    "path": "concordia/views/visualizations.py",
    "content": "from django.core.cache import caches\nfrom django.http import JsonResponse\nfrom django.utils.decorators import method_decorator\nfrom django.views import View\nfrom django.views.decorators.cache import never_cache\n\n\n@method_decorator(never_cache, name=\"dispatch\")\nclass VisualizationDataView(View):\n    \"\"\"\n    Serve cached visualization data as JSON, returning a 404 JSON error if missing.\n\n    A single endpoint that, given a `name` slug in the URL, looks up exactly\n    that key in the 'visualization_cache' and returns its contents as JSON.\n    If no entry exists under that key, responds with a 404 and a JSON error message.\n\n    Attributes:\n        cache (BaseCache): The Django cache used to retrieve data.\n\n    URL Parameters:\n        name (str): The slug identifying which visualization data to return.\n            Example: \"daily-transcription-activity-by-campaign\".\n\n    Returns:\n        JsonResponse:\n            - On success: the cached data (any JSON-serializable structure).\n            - On failure: a JSON object {\"error\": \"...\"} with HTTP status 404.\n    \"\"\"\n\n    cache = caches[\"visualization_cache\"]\n\n    def get(self, request, name):\n        data = self.cache.get(name)\n        if data is None:\n            return JsonResponse(\n                {\"error\": f\"No visualization data found for '{name}'\"}, status=404\n            )\n\n        return JsonResponse(data)\n"
  },
  {
    "path": "concordia/widgets.py",
    "content": "from django import forms\n\n\nclass EmailWidget(forms.EmailInput):\n    template_name = \"forms/widgets/email.html\"\n"
  },
  {
    "path": "concordia/wsgi.py",
    "content": "\"\"\"\nWSGI config for concordia project.\n\nIt exposes the WSGI callable as a module-level variable named ``application``.\n\nFor more information on this file, see\nhttps://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/\n\"\"\"\n\nfrom django.core.wsgi import get_wsgi_application\n\napplication = get_wsgi_application()\n"
  },
  {
    "path": "configuration/__init__.py",
    "content": ""
  },
  {
    "path": "configuration/admin.py",
    "content": "from typing import Any\n\nfrom django.contrib import admin, messages\nfrom django.http import HttpRequest, HttpResponse\nfrom django.template.response import TemplateResponse\nfrom django.utils.html import format_html\n\nfrom configuration.models import Configuration\n\n\n@admin.register(Configuration)\nclass ConfigurationAdmin(admin.ModelAdmin):\n    \"\"\"\n    Admin configuration for the `Configuration` model.\n\n    Behavior:\n        - Displays the key, raw value, and description in the changelist.\n        - Provides a read-only `validated_value` field on the change form that\n          shows the interpreted value as returned by `Configuration.get_value`.\n        - Overrides `changeform_view` to add a two-step confirmation flow when\n          saving changes, including a preview of the parsed value. Also handles\n          an explicit cancel action by rebuilding the normal change form\n          context rather than delegating to the base implementation.\n    \"\"\"\n\n    list_display = (\"key\", \"value\", \"description\")\n    readonly_fields = (\"validated_value\",)\n\n    def validated_value(self, obj: Configuration) -> str:\n        \"\"\"\n        Render the parsed configuration value and explanatory text.\n\n        Notes:\n            This method does not alter the base `ModelAdmin` behavior. It is a\n            helper used by the change form to display both the interpreted\n            value from `Configuration.get_value()` and a short explanation\n            that this parsed value is what application code will consume.\n\n        Args:\n            obj (Configuration): The instance being edited.\n\n        Returns:\n            str: HTML-safe string produced by `format_html` containing the\n                parsed value and explanatory note.\n        \"\"\"\n        return format_html(\n            \"<div>{}</div><div style='color: #777; font-size: 0.9em;'>{}</div>\",\n            obj.get_value(),\n            \"This is the interpreted value based on the selected data type. \"\n            \"This value is what will be seen by the code that uses this \"\n            \"configuration.\",\n        )\n\n    def changeform_view(\n        self,\n        request: HttpRequest,\n        object_id: str | None = None,\n        form_url: str = \"\",\n        extra_context: dict[str, Any] | None = None,\n    ) -> HttpResponse:\n        \"\"\"\n        Override the base change form view to add a confirmation step.\n\n        Differences from the base implementation:\n            - On initial POST, validate the form and, if valid, render a\n              confirmation template that previews the parsed value produced by\n              `Configuration.get_value()`.\n            - On confirmation POST (`_confirm_update`), save the instance and\n              show a success message.\n            - On cancel POST (`cancel_update`), rebuild the standard change\n              form context manually and re-render the change form instead of\n              delegating to the base method (which would otherwise proceed with\n              the change because it is a POST).\n            - For all other flows, fall back to the base implementation.\n\n        Args:\n            request (HttpRequest): The current request.\n            object_id (str | None): Primary key of the object being edited.\n            form_url (str): Form action URL.\n            extra_context (dict[str, Any] | None): Extra template context.\n\n        Returns:\n            HttpResponse: Either the confirmation screen, the re-rendered\n                change form, or the default response from the base view.\n        \"\"\"\n        obj = self.get_object(request, object_id)\n\n        if request.method == \"POST\":\n            if \"_confirm_update\" in request.POST:\n                # Second POST: confirmation of update\n                form = self.get_form(request, obj)(request.POST, instance=obj)\n                if form.is_valid():\n                    form.save()\n                    self.message_user(request, \"Configuration updated and cached.\")\n                    return self.response_post_save_change(request, form.instance)\n                else:\n                    self.message_user(\n                        request, \"Invalid data on confirmation.\", level=messages.ERROR\n                    )\n            elif \"cancel_update\" in request.POST:\n                form = self.get_form(request, obj)(request.POST, instance=obj)\n\n                admin_form = admin.helpers.AdminForm(\n                    form,\n                    list(self.get_fieldsets(request, obj)),\n                    self.get_prepopulated_fields(request, obj),\n                    self.get_readonly_fields(request, obj),\n                    model_admin=self,\n                )\n                # We unfortunately have to manually construct this context, since using\n                # super causes it to just perform the cancelled change, because this is\n                # a POST request\n                context = {\n                    **self.admin_site.each_context(request),\n                    \"title\": f\"Edit Configuration: {obj.key}\",\n                    \"adminform\": admin_form,\n                    \"inline_admin_formsets\": [],\n                    \"media\": self.media + form.media,\n                    \"object_id\": object_id,\n                    \"original\": obj,\n                    \"opts\": self.model._meta,\n                    \"add\": False,\n                    \"change\": True,\n                    \"is_popup\": False,\n                    \"save_as\": self.save_as,\n                    \"has_view_permission\": self.has_view_permission(request, obj),\n                    \"has_add_permission\": self.has_add_permission(request),\n                    \"has_change_permission\": self.has_change_permission(request, obj),\n                    \"has_delete_permission\": self.has_delete_permission(request, obj),\n                    \"form_url\": form_url,\n                    \"to_field\": None,\n                    \"has_editable_inline_admin_formsets\": False,\n                }\n                return self.render_change_form(\n                    request,\n                    context=context,\n                    add=False,\n                    change=True,\n                    form_url=form_url,\n                    obj=obj,\n                )\n\n            else:\n                # First POST: validate and show confirmation screen\n                form = self.get_form(request, obj)(request.POST, instance=obj)\n                if form.is_valid():\n                    new_instance = form.save(commit=False)\n                    try:\n                        parsed_value = new_instance.get_value()\n                    except Exception as e:\n                        self.message_user(\n                            request, f\"Validation failed: {e}\", level=messages.ERROR\n                        )\n                        return super().changeform_view(\n                            request, object_id, form_url, extra_context=extra_context\n                        )\n\n                    context = {\n                        \"title\": (\n                            f\"Confirm Update of Configuration '{new_instance.key}'\"\n                        ),\n                        \"original\": self.model._default_manager.get(pk=obj.pk),\n                        \"new_instance\": new_instance,\n                        \"parsed_value\": parsed_value,\n                        \"opts\": self.model._meta,\n                        \"object_id\": object_id,\n                        \"form_url\": form_url,\n                        \"request\": request,\n                    }\n                    return TemplateResponse(\n                        request, \"admin/configuration_confirm_update.html\", context\n                    )\n\n        return super().changeform_view(request, object_id, form_url, extra_context)\n"
  },
  {
    "path": "configuration/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass ConfigurationConfig(AppConfig):\n    default_auto_field = \"django.db.models.BigAutoField\"\n    name = \"configuration\"\n\n    def ready(self):\n        import configuration.signals  # NOQA\n"
  },
  {
    "path": "configuration/management/__init__.py",
    "content": ""
  },
  {
    "path": "configuration/management/commands/__init__.py",
    "content": ""
  },
  {
    "path": "configuration/management/commands/configcache.py",
    "content": "from django.core.cache import caches\nfrom django.core.management.base import BaseCommand\n\n\nclass Command(BaseCommand):\n    help = \"Fetch a value from the configuration cache by key.\"  # NOQA: A003\n\n    def add_arguments(self, parser):\n        parser.add_argument(\"key\", type=str, help=\"The cache key to retrieve\")\n\n    def handle(self, *args, **options):\n        config_cache = caches[\"configuration_cache\"]\n        key = options[\"key\"]\n        cache_key = f\"config_{key}\"\n        value = config_cache.get(cache_key)\n\n        if value is None:\n            self.stdout.write(self.style.WARNING(f\"Key '{key}' not found in cache.\"))\n        else:\n            self.stdout.write(self.style.SUCCESS(f\"Key '{key}' found:\"))\n            self.stdout.write(str(value))\n"
  },
  {
    "path": "configuration/migrations/0001_initial.py",
    "content": "# Generated by Django 4.2.16 on 2025-02-25 19:21\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    initial = True\n\n    dependencies = []\n\n    operations = [\n        migrations.CreateModel(\n            name=\"Configuration\",\n            fields=[\n                (\n                    \"id\",\n                    models.BigAutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\n                    \"key\",\n                    models.CharField(\n                        help_text=\"Unique identifier for the configuration setting\",\n                        max_length=255,\n                        unique=True,\n                    ),\n                ),\n                (\n                    \"value\",\n                    models.TextField(help_text=\"Value of the configuration setting\"),\n                ),\n                (\n                    \"data_type\",\n                    models.CharField(\n                        choices=[\n                            (\"text\", \"Plain text\"),\n                            (\"number\", \"Number\"),\n                            (\"boolean\", \"Boolean\"),\n                            (\"json\", \"JSON\"),\n                            (\"html\", \"HTML\"),\n                        ],\n                        default=\"text\",\n                        help_text=\"Data type of the value\",\n                        max_length=10,\n                    ),\n                ),\n                (\n                    \"description\",\n                    models.TextField(\n                        blank=True,\n                        help_text=\"Optional description of the configuration setting\",\n                    ),\n                ),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "configuration/migrations/0002_populate_configurations.py",
    "content": "# Generated by Django 4.2.16 on 2025-02-25 20:15\n\nfrom django.db import migrations\n\n\ndef populate_configuration(apps, schema_editor):\n    Configuration = apps.get_model(\"configuration\", \"Configuration\")\n\n    # List of initial configuration entries\n    initial_data = [\n        {\n            \"key\": \"review_rate_limit_popup_message\",\n            \"data_type\": \"html\",\n            \"value\": \"<p>Volunteers can only accept {% configuration_value 'review_rate_limit' %} pages per minute.</p>\\r\\n<p>Please read all transcriptions completely to ensure they are whole and accurate. <a href=\\\"{% url 'how-to-review' %}\\\">See review instructions.</a></p>\",\n            \"description\": \"Message shown in the pop-up when a user exceeds the review rate limit\",\n        },\n        {\n            \"key\": \"review_rate_limit_popup_title\",\n            \"data_type\": \"html\",\n            \"value\": \"You cannot yet accept this page\",\n            \"description\": \"Title of the error pop-up displayed when a user exceeds the review rate limit\",\n        },\n        {\n            \"key\": \"review_rate_limit\",\n            \"data_type\": \"number\",\n            \"value\": \"4\",\n            \"description\": \"Number of reviews allowed per minute\",\n        },\n        {\n            \"key\": \"review_rate_limit_banner_message\",\n            \"data_type\": \"html\",\n            \"value\": \"You cannot yet accept this page. Volunteers can only accept {% configuration_value 'review_rate_limit' %} pages per minute. See <a href=\\\"{% url 'how-to-review' %}\\\">review instructions</a>.\",\n            \"description\": \"Message to display on the banner when a user exceeds the review rate limit\",\n        },\n    ]\n\n    # Insert data into the database\n    for entry in initial_data:\n        Configuration.objects.update_or_create(key=entry[\"key\"], defaults=entry)\n\n\ndef revert_populate_configuration(apps, schema_editor):\n    # We can't actually revert the data to the state it was before,\n    # and there's no actual need to, but we need this function to be\n    # able to reverse this migration\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"configuration\", \"0001_initial\"),\n    ]\n\n    operations = [\n        migrations.RunPython(populate_configuration, revert_populate_configuration),\n    ]\n"
  },
  {
    "path": "configuration/migrations/0003_populate_retry_configurations.py",
    "content": "# Generated by Django 4.2.16 on 2025-03-03 20:51\n\nfrom django.db import migrations\n\n\ndef populate_retry_configuration(apps, schema_editor):\n    Configuration = apps.get_model(\"configuration\", \"Configuration\")\n\n    # List of initial retry configuration entries\n    initial_data = [\n        {\n            \"key\": \"asset_image_import_max_retries\",\n            \"data_type\": \"number\",\n            \"value\": \"3\",\n            \"description\": \"The maximum number of times to retry downloading \"\n            \"an asset image during import\",\n        },\n        {\n            \"key\": \"asset_image_import_max_retry_delay\",\n            \"data_type\": \"number\",\n            \"value\": \"10\",\n            \"description\": \"The number of minutes to wait before retrying downloading \"\n            \"an image during import. A non-positive number will disable retries. Should \"\n            \"not be greater than around 45 to avoid the system duplicating the task.\",\n        },\n    ]\n\n    # Insert data into the database\n    for entry in initial_data:\n        Configuration.objects.update_or_create(key=entry[\"key\"], defaults=entry)\n\n\ndef revert_populate_retry_configuration(apps, schema_editor):\n    # We can't actually revert the data to the state it was before,\n    # and there's no actual need to, but we need this function to be\n    # able to reverse this migration\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"configuration\", \"0002_populate_configurations\"),\n    ]\n\n    operations = [\n        migrations.RunPython(\n            populate_retry_configuration, revert_populate_retry_configuration\n        ),\n    ]\n"
  },
  {
    "path": "configuration/migrations/0004_alter_configuration_options.py",
    "content": "# Generated by Django 4.2.16 on 2025-03-18 20:01\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"configuration\", \"0003_populate_retry_configurations\"),\n    ]\n\n    operations = [\n        migrations.AlterModelOptions(\n            name=\"configuration\",\n            options={\"ordering\": [\"key\"]},\n        ),\n    ]\n"
  },
  {
    "path": "configuration/migrations/0005_alter_configuration_data_type.py",
    "content": "# Generated by Django 4.2.22 on 2025-07-29 17:28\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"configuration\", \"0004_alter_configuration_options\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"configuration\",\n            name=\"data_type\",\n            field=models.CharField(\n                choices=[\n                    (\"text\", \"Plain text\"),\n                    (\"number\", \"Number\"),\n                    (\"boolean\", \"Boolean\"),\n                    (\"json\", \"JSON\"),\n                    (\"html\", \"HTML\"),\n                    (\"rate\", \"Rate\"),\n                ],\n                default=\"text\",\n                help_text=\"Data type of the value\",\n                max_length=10,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "configuration/migrations/0006_populate_next_asset_rate_limit.py",
    "content": "from django.db import migrations\n\n\ndef populate_configuration(apps, schema_editor):\n    Configuration = apps.get_model(\"configuration\", \"Configuration\")\n\n    initial_data = [\n        {\n            \"key\": \"next_asset_rate_limit\",\n            \"data_type\": \"rate\",\n            \"value\": \"4/m\",\n            \"description\": \"Rate limit of anonymous users for the next_*_asset views. Format is 'X/u', where 'X' is the number of requests and 'u' is 's', 'm', 'h' or'd' (second, minute, hour or day). '5/s' means 'five per second'.\",\n        },\n    ]\n\n    for entry in initial_data:\n        Configuration.objects.update_or_create(key=entry[\"key\"], defaults=entry)\n\n\ndef revert_populate_configuration(apps, schema_editor):\n    # We can't actually revert the data to the state it was before,\n    # and there's no actual need to, but we need this function to be\n    # able to reverse this migration\n    pass\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"configuration\", \"0005_alter_configuration_data_type\"),\n    ]\n\n    operations = [\n        migrations.RunPython(populate_configuration, revert_populate_configuration),\n    ]\n"
  },
  {
    "path": "configuration/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "configuration/models.py",
    "content": "import json\nfrom typing import Any\n\nfrom django import template\nfrom django.core.exceptions import ValidationError\nfrom django.db import models\n\nfrom configuration.validation import validate_rate\n\n\nclass Configuration(models.Model):\n    \"\"\"\n    Key/value configuration model with typed decoding.\n\n    Purpose:\n        Store site configuration as string values and expose a helper that\n        converts the stored text into a concrete Python type based on\n        `data_type`.\n\n    Fields:\n        key (models.CharField): Unique identifier for the setting.\n        data_type (models.CharField): One of `DataType` choices indicating how\n            `value` should be interpreted.\n        value (models.TextField): Raw text representation of the value.\n        description (models.TextField): Optional human-readable description.\n\n    Meta:\n        ordering: Sorted by `key`.\n    \"\"\"\n\n    class DataType(models.TextChoices):\n        \"\"\"\n        Supported data types for decoding `value` in `get_value`.\n        \"\"\"\n\n        TEXT = \"text\", \"Plain text\"\n        NUMBER = \"number\", \"Number\"\n        BOOLEAN = \"boolean\", \"Boolean\"\n        JSON = \"json\", \"JSON\"\n        HTML = \"html\", \"HTML\"\n        RATE = \"rate\", \"Rate\"\n\n    key = models.CharField(\n        max_length=255,\n        unique=True,\n        help_text=\"Unique identifier for the configuration setting\",\n    )\n    data_type = models.CharField(\n        max_length=10,\n        choices=DataType.choices,\n        default=DataType.TEXT,\n        help_text=\"Data type of the value\",\n    )\n    value = models.TextField(help_text=\"Value of the configuration setting\")\n    description = models.TextField(\n        blank=True, help_text=\"Optional description of the configuration setting\"\n    )\n\n    class Meta:\n        ordering = [\"key\"]\n\n    def __str__(self) -> str:\n        \"\"\"\n        Return the configuration key for display.\n        \"\"\"\n        return self.key\n\n    def get_value(self) -> \"Any\":\n        \"\"\"\n        Decode and return `value` according to `data_type`.\n\n        Behavior:\n            - `NUMBER`: Try `int(value)`, else try `float(value)`, else return 0.\n            - `BOOLEAN`: Return True if `value.lower() == \"true\"`, else False.\n            - `JSON`: Parse with `json.loads(value)` and return the result.\n            - `HTML`: Render `value` through Django's template engine with an\n              empty context and return the rendered string.\n            - `RATE`: Validate using `validate_rate(value)`. If validation\n              fails, return an empty string. Otherwise return the validated\n              value as provided by `validate_rate`.\n            - `TEXT` or any unrecognized type: Return `value` unchanged.\n\n        Returns:\n            Any: Decoded value. The concrete type depends on `data_type` and\n            may be `int`, `float`, `bool`, `str`, `dict`, `list`, or a value\n            returned by `validate_rate`.\n\n        Raises:\n            json.JSONDecodeError: If `data_type` is `JSON` and `value` is not\n            valid JSON.\n        \"\"\"\n        if self.data_type == Configuration.DataType.NUMBER:\n            try:\n                return int(self.value)\n            except ValueError:\n                try:\n                    return float(self.value)\n                except ValueError:\n                    return 0\n        elif self.data_type == Configuration.DataType.BOOLEAN:\n            if self.value.lower() == \"true\":\n                return True\n            else:\n                return False\n        elif self.data_type == Configuration.DataType.JSON:\n            return json.loads(self.value)\n        elif self.data_type == Configuration.DataType.HTML:\n            value = template.Template(self.value)\n            return value.render(template.Context({}))\n        elif self.data_type == Configuration.DataType.RATE:\n            try:\n                return validate_rate(self.value)\n            except ValidationError:\n                return \"\"\n        else:\n            # DataType.TEXT or an unknown type,\n            # so just return the value itself\n            return self.value\n"
  },
  {
    "path": "configuration/signals.py",
    "content": "from django.db.models.signals import post_save\nfrom django.dispatch import receiver\n\nfrom configuration.models import Configuration\nfrom configuration.utils import cache_configuration_value\n\n\n@receiver(post_save, sender=Configuration)\ndef update_cached_configuration_value(\n    sender: type[Configuration], *, instance: Configuration, **kwargs\n) -> None:\n    \"\"\"\n    Post-save signal handler that updates the cached configuration value.\n\n    Behavior:\n        - Parse the instance value using `Configuration.get_value()`.\n        - If parsing succeeds, write the parsed value to the cache via\n          `cache_configuration_value`.\n        - If parsing raises any exception, skip caching to avoid persisting an\n          invalid value.\n\n    Signals:\n        Connected to `django.db.models.signals.post_save` for\n        `configuration.models.Configuration`.\n\n    Args:\n        sender (type[Configuration]): The model class that sent the signal.\n        instance (Configuration): The saved instance whose parsed value should\n            be cached.\n\n    Returns:\n        None\n    \"\"\"\n    try:\n        value = instance.get_value()\n    except Exception:\n        # Do not cache if value is invalid\n        return\n    cache_configuration_value(instance.key, value)\n"
  },
  {
    "path": "configuration/templates/admin/configuration_confirm_update.html",
    "content": "{% extends \"admin/base_site.html\" %}\n{% load i18n %}\n\n{% block content %}\n  <form method=\"post\">\n    {% csrf_token %}\n    <input type=\"hidden\" name=\"_confirm_update\" value=\"1\">\n    <input type=\"hidden\" name=\"key\" value=\"{{ new_instance.key }}\">\n    <input type=\"hidden\" name=\"data_type\" value=\"{{ new_instance.data_type }}\">\n    <input type=\"hidden\" name=\"value\" value=\"{{ new_instance.value }}\">\n    <input type=\"hidden\" name=\"description\" value=\"{{ new_instance.description }}\">\n\n    <table class=\"admin-confirmation-table\">\n      <tr>\n        <th>Key</th>\n        <td>{{ new_instance.key }}</td>\n      </tr>\n      <tr>\n        <th>Original Value</th>\n        <td>{{ original.value }}</td>\n      </tr>\n      <tr>\n        <th>New Raw Value</th>\n        <td>{{ new_instance.value }}</td>\n      </tr>\n      <tr>\n        <th>Interpreted Value</th>\n        <td><pre>{{ parsed_value }}</pre></td>\n      </tr>\n    </table>\n\n    <div class=\"submit-row\">\n      <input type=\"submit\" value=\"{% trans \"Confirm Save\" %}\" class=\"default\">\n      <a href=\"#\" class=\"button cancel-link\" onclick=\"document.getElementById('cancel-form').submit(); return false;\">\n        {% trans \"Cancel\" %}\n      </a>\n    </div>\n  </form>\n\n  <form id=\"cancel-form\" method=\"post\">\n    {% csrf_token %}\n    <input type=\"hidden\" name=\"cancel_update\" value=\"1\">\n    <input type=\"hidden\" name=\"key\" value=\"{{ new_instance.key }}\">\n    <input type=\"hidden\" name=\"data_type\" value=\"{{ new_instance.data_type }}\">\n    <input type=\"hidden\" name=\"value\" value=\"{{ new_instance.value }}\">\n    <input type=\"hidden\" name=\"description\" value=\"{{ new_instance.description }}\">\n  </form>\n{% endblock %}\n"
  },
  {
    "path": "configuration/templatetags/__init__.py",
    "content": ""
  },
  {
    "path": "configuration/templatetags/configuration_tags.py",
    "content": "from typing import Any\n\nfrom django import template\n\nfrom configuration.models import Configuration\nfrom configuration.utils import configuration_value as _configuration_value\n\nregister = template.Library()\n\n\n@register.simple_tag\ndef configuration_value(key: str) -> Any:\n    \"\"\"\n    Return the parsed configuration value for a key, for use in templates.\n\n    Behavior:\n        Delegates to `configuration.utils.configuration_value` to fetch and parse\n        the value (including any casting based on the configured data type).\n        If the configuration is missing or parsing raises an exception of any\n        kind, return an empty string to keep template rendering resilient.\n\n    Args:\n        key (str): The unique configuration key.\n\n    Returns:\n        Any: The parsed value when available and valid; otherwise an empty string.\n    \"\"\"\n    try:\n        return _configuration_value(key)\n    except (Configuration.DoesNotExist, Exception):\n        # Return an empty string if the key does not exist or parsing fails\n        return \"\"\n"
  },
  {
    "path": "configuration/tests/__init__.py",
    "content": ""
  },
  {
    "path": "configuration/tests/test_admin.py",
    "content": "from django.contrib.auth import get_user_model\nfrom django.test import TestCase\nfrom django.urls import reverse\n\nfrom configuration.models import Configuration\n\n\nclass TestConfigurationAdmin(TestCase):\n    def setUp(self):\n        self.superuser = get_user_model().objects.create_superuser(\n            username=\"admin\",\n            email=\"admin@example.com\",\n            password=\"adminpass\",  # nosec\n        )\n        self.client.force_login(self.superuser)\n\n        self.config = Configuration.objects.create(\n            key=\"test-key\",\n            value=\"Initial value\",\n            data_type=Configuration.DataType.TEXT,\n            description=\"Initial description\",\n        )\n        self.url = reverse(\n            \"admin:configuration_configuration_change\", args=[self.config.pk]\n        )\n\n    def test_change_view_initial_get(self):\n        response = self.client.get(self.url)\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"test-key\")\n        self.assertContains(response, \"Initial value\")\n\n    def test_save_triggers_confirmation(self):\n        response = self.client.post(\n            self.url,\n            {\n                \"key\": self.config.key,\n                \"value\": \"Updated value\",\n                \"data_type\": Configuration.DataType.TEXT,\n                \"description\": \"Updated description\",\n            },\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(response, \"admin/configuration_confirm_update.html\")\n        self.assertContains(response, \"Confirm Update of Configuration\")\n\n    def test_confirm_save_updates_object(self):\n        # Step 1: post to trigger confirmation\n        confirmation_response = self.client.post(\n            self.url,\n            {\n                \"key\": self.config.key,\n                \"value\": \"Updated value\",\n                \"data_type\": Configuration.DataType.TEXT,\n                \"description\": \"Updated description\",\n            },\n        )\n        self.assertEqual(confirmation_response.status_code, 200)\n        self.assertTemplateUsed(\n            confirmation_response, \"admin/configuration_confirm_update.html\"\n        )\n\n        # Step 2: confirm the update\n        confirm_response = self.client.post(\n            self.url,\n            {\n                \"_confirm_update\": \"1\",\n                \"key\": self.config.key,\n                \"value\": \"Updated value\",\n                \"data_type\": Configuration.DataType.TEXT,\n                \"description\": \"Updated description\",\n            },\n            follow=True,\n        )\n\n        changelist_url = reverse(\"admin:configuration_configuration_changelist\")\n        self.assertRedirects(confirm_response, changelist_url)\n        self.assertContains(confirm_response, \"Configuration updated and cached.\")\n        self.config.refresh_from_db()\n        self.assertEqual(self.config.value, \"Updated value\")\n        self.assertEqual(self.config.description, \"Updated description\")\n\n    def test_cancel_preserves_input(self):\n        # Step 1: post to trigger confirmation\n        confirmation_response = self.client.post(\n            self.url,\n            {\n                \"key\": self.config.key,\n                \"value\": \"New value\",\n                \"data_type\": Configuration.DataType.TEXT,\n                \"description\": \"Changed description\",\n            },\n        )\n        self.assertEqual(confirmation_response.status_code, 200)\n\n        # Step 2: simulate \"Cancel\" by posting with cancel_update\n        cancel_response = self.client.post(\n            self.url,\n            {\n                \"cancel_update\": \"1\",\n                \"key\": self.config.key,\n                \"value\": \"New value\",\n                \"data_type\": Configuration.DataType.TEXT,\n                \"description\": \"Changed description\",\n            },\n        )\n\n        self.assertEqual(cancel_response.status_code, 200)\n        self.assertContains(cancel_response, \"Changed description\")\n        self.assertContains(cancel_response, \"New value\")\n\n        self.config.refresh_from_db()\n        self.assertEqual(self.config.value, \"Initial value\")\n        self.assertEqual(self.config.description, \"Initial description\")\n\n    def test_confirm_save_with_invalid_form(self):\n        # Step 1: trigger confirmation with valid initial post\n        self.client.post(\n            self.url,\n            {\n                \"key\": self.config.key,\n                \"value\": \"value\",\n                \"data_type\": Configuration.DataType.TEXT,\n                \"description\": \"desc\",\n            },\n        )\n\n        # Step 2: confirm with missing required field (invalid POST)\n        response = self.client.post(\n            self.url,\n            {\n                \"_confirm_update\": \"1\",\n                # Omit 'key' which is required\n                \"value\": \"value\",\n                \"data_type\": Configuration.DataType.TEXT,\n                \"description\": \"desc\",\n            },\n            follow=True,\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"Invalid data on confirmation.\")\n        self.config.refresh_from_db()\n        self.assertEqual(self.config.value, \"Initial value\")  # unchanged\n\n    def test_get_value_failure_on_confirmation(self):\n        # Create a config with data_type=JSON and invalid JSON\n        config = Configuration.objects.create(\n            key=\"bad-json-key\",\n            value=\"Not JSON\",\n            data_type=Configuration.DataType.JSON,\n            description=\"desc\",\n        )\n        url = reverse(\"admin:configuration_configuration_change\", args=[config.pk])\n\n        # Initial POST with invalid JSON triggers get_value failure\n        response = self.client.post(\n            url,\n            {\n                \"key\": config.key,\n                \"value\": \"Still not JSON\",\n                \"data_type\": Configuration.DataType.JSON,\n                \"description\": \"desc\",\n            },\n            follow=True,\n        )\n\n        self.assertEqual(response.status_code, 200)\n        self.assertContains(response, \"Validation failed:\")\n\n    def test_first_post_invalid_form(self):\n        response = self.client.post(\n            self.url,\n            {\n                \"key\": \"\",  # key is required, so this makes the form invalid\n                \"value\": \"Some value\",\n                \"data_type\": Configuration.DataType.TEXT,\n                \"description\": \"Bad post\",\n            },\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertTemplateUsed(response, \"admin/change_form.html\")\n        self.assertContains(response, \"This field is required.\")\n"
  },
  {
    "path": "configuration/tests/test_models.py",
    "content": "import json\n\nfrom django.core.cache import caches\nfrom django.test import TestCase\n\nfrom configuration.models import Configuration\n\n\nclass TestConfiguration(TestCase):\n    def setUp(self):\n        caches[\"configuration_cache\"].clear()\n\n    def test_str(self):\n        config = Configuration.objects.create(\n            key=\"test-key\", value=\"Test value\", data_type=Configuration.DataType.TEXT\n        )\n        self.assertEqual(str(config), \"test-key\")\n\n    def test_text(self):\n        config = Configuration.objects.create(\n            key=\"test-key\", value=\"Test value\", data_type=Configuration.DataType.TEXT\n        )\n        self.assertEqual(config.get_value(), \"Test value\")\n\n        config2 = Configuration.objects.create(\n            key=\"test-key2\", value=\"\", data_type=Configuration.DataType.TEXT\n        )\n        self.assertEqual(config2.get_value(), \"\")\n\n        config3 = Configuration.objects.create(\n            key=\"test-key3\",\n            value='{\"key\" : \"value\"}',\n            data_type=Configuration.DataType.TEXT,\n        )\n        self.assertEqual(config3.get_value(), '{\"key\" : \"value\"}')\n\n    def test_number(self):\n        config = Configuration.objects.create(\n            key=\"test-key\", value=\"100\", data_type=Configuration.DataType.NUMBER\n        )\n        self.assertEqual(config.get_value(), 100)\n\n        config2 = Configuration.objects.create(\n            key=\"test-key2\", value=\"100.12\", data_type=Configuration.DataType.NUMBER\n        )\n        self.assertEqual(config2.get_value(), 100.12)\n\n        config3 = Configuration.objects.create(\n            key=\"test-key3\", value=\"Test value\", data_type=Configuration.DataType.NUMBER\n        )\n        self.assertEqual(config3.get_value(), 0)\n\n        config4 = Configuration.objects.create(\n            key=\"test-key4\", value=\"\", data_type=Configuration.DataType.NUMBER\n        )\n        self.assertEqual(config4.get_value(), 0)\n\n    def test_boolean(self):\n        config = Configuration.objects.create(\n            key=\"test-key\", value=\"True\", data_type=Configuration.DataType.BOOLEAN\n        )\n        self.assertEqual(config.get_value(), True)\n\n        config2 = Configuration.objects.create(\n            key=\"test-key2\", value=\"true\", data_type=Configuration.DataType.BOOLEAN\n        )\n        self.assertEqual(config2.get_value(), True)\n\n        config3 = Configuration.objects.create(\n            key=\"test-key3\", value=\"TrUe\", data_type=Configuration.DataType.BOOLEAN\n        )\n        self.assertEqual(config3.get_value(), True)\n\n        config4 = Configuration.objects.create(\n            key=\"test-key4\", value=\"\", data_type=Configuration.DataType.BOOLEAN\n        )\n        self.assertEqual(config4.get_value(), False)\n\n        config5 = Configuration.objects.create(\n            key=\"test-key5\", value=\"1\", data_type=Configuration.DataType.BOOLEAN\n        )\n        self.assertEqual(config5.get_value(), False)\n\n        config6 = Configuration.objects.create(\n            key=\"test-key6\",\n            value=\"Test value\",\n            data_type=Configuration.DataType.BOOLEAN,\n        )\n        self.assertEqual(config6.get_value(), False)\n\n    def test_json(self):\n        config = Configuration.objects.create(\n            key=\"test-key\", value=\"true\", data_type=Configuration.DataType.JSON\n        )\n        self.assertEqual(config.get_value(), True)\n\n        config2 = Configuration.objects.create(\n            key=\"test-key2\", value=\"True\", data_type=Configuration.DataType.JSON\n        )\n        self.assertRaises(json.decoder.JSONDecodeError, config2.get_value)\n\n        config3 = Configuration.objects.create(\n            key=\"test-key3\",\n            value='{\"key\" : \"value\"}',\n            data_type=Configuration.DataType.JSON,\n        )\n        self.assertEqual(config3.get_value(), {\"key\": \"value\"})\n\n        config4 = Configuration.objects.create(\n            key=\"test-key4\", value=\"\", data_type=Configuration.DataType.JSON\n        )\n        self.assertRaises(json.decoder.JSONDecodeError, config4.get_value)\n\n        config5 = Configuration.objects.create(\n            key=\"test-key5\", value=\"1\", data_type=Configuration.DataType.JSON\n        )\n        self.assertEqual(config5.get_value(), 1)\n\n        config6 = Configuration.objects.create(\n            key=\"test-key6\", value=\"Test value\", data_type=Configuration.DataType.JSON\n        )\n        self.assertRaises(json.decoder.JSONDecodeError, config6.get_value)\n\n    def test_html(self):\n        config = Configuration.objects.create(\n            key=\"test-key\", value=\"Test value\", data_type=Configuration.DataType.HTML\n        )\n        self.assertEqual(config.get_value(), \"Test value\")\n\n        config2 = Configuration.objects.create(\n            key=\"test-key2\", value=\"\", data_type=Configuration.DataType.HTML\n        )\n        self.assertEqual(config2.get_value(), \"\")\n\n        config3 = Configuration.objects.create(\n            key=\"test-key3\",\n            value='{\"key\" : \"value\"}',\n            data_type=Configuration.DataType.HTML,\n        )\n        self.assertEqual(config3.get_value(), '{\"key\" : \"value\"}')\n\n        config4 = Configuration.objects.create(\n            key=\"test-key4\",\n            value=\"<p>Test value</p>\",\n            data_type=Configuration.DataType.HTML,\n        )\n        self.assertEqual(config4.get_value(), \"<p>Test value</p>\")\n\n        config5 = Configuration.objects.create(\n            key=\"test-key5\",\n            value=\"<p>{% configuration_value 'test-key' %}</p>\",\n            data_type=Configuration.DataType.HTML,\n        )\n        self.assertEqual(config5.get_value(), \"<p>Test value</p>\")\n\n        config6 = Configuration.objects.create(\n            key=\"test-key6\",\n            value=\"{% url 'homepage' %}\",\n            data_type=Configuration.DataType.HTML,\n        )\n        self.assertEqual(config6.get_value(), \"/\")\n\n    def test_rate(self):\n        # Valid rates\n        config1 = Configuration.objects.create(\n            key=\"test-key1\", value=\"1/s\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config1.get_value(), \"1/s\")\n\n        config2 = Configuration.objects.create(\n            key=\"test-key2\", value=\"100/m\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config2.get_value(), \"100/m\")\n\n        config3 = Configuration.objects.create(\n            key=\"test-key3\", value=\"50/h\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config3.get_value(), \"50/h\")\n\n        config4 = Configuration.objects.create(\n            key=\"test-key4\", value=\"1000/d\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config4.get_value(), \"1000/d\")\n\n        # Invalid formats\n        config5 = Configuration.objects.create(\n            key=\"test-key5\", value=\"5/hour\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config5.get_value(), \"\")\n\n        config6 = Configuration.objects.create(\n            key=\"test-key6\", value=\"ten/m\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config6.get_value(), \"\")\n\n        config7 = Configuration.objects.create(\n            key=\"test-key7\", value=\"10\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config7.get_value(), \"\")\n\n        config8 = Configuration.objects.create(\n            key=\"test-key8\", value=\"10/\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config8.get_value(), \"\")\n\n        config9 = Configuration.objects.create(\n            key=\"test-key9\", value=\"/m\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config9.get_value(), \"\")\n\n        # Zero and negative values\n        config10 = Configuration.objects.create(\n            key=\"test-key10\", value=\"0/s\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config10.get_value(), \"\")\n\n        config11 = Configuration.objects.create(\n            key=\"test-key11\", value=\"-5/m\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config11.get_value(), \"\")\n\n        # Empty value\n        config12 = Configuration.objects.create(\n            key=\"test-key12\", value=\"\", data_type=Configuration.DataType.RATE\n        )\n        self.assertEqual(config12.get_value(), \"\")\n"
  },
  {
    "path": "configuration/tests/test_signals.py",
    "content": "from django.core.cache import caches\nfrom django.test import TestCase\n\nfrom configuration.models import Configuration\n\n\nclass TestConfigurationSignal(TestCase):\n    def setUp(self):\n        caches[\"configuration_cache\"].clear()\n\n    def test_signal_caches_valid_value(self):\n        Configuration.objects.create(\n            key=\"signal-key\",\n            value=\"42\",\n            data_type=Configuration.DataType.NUMBER,\n        )\n        self.assertEqual(caches[\"configuration_cache\"].get(\"config_signal-key\"), 42)\n\n    def test_signal_does_not_cache_invalid_json(self):\n        Configuration.objects.create(\n            key=\"signal-json-invalid\",\n            value=\"not valid json\",\n            data_type=Configuration.DataType.JSON,\n        )\n        # Should not raise, but value should not be cached\n        self.assertIsNone(\n            caches[\"configuration_cache\"].get(\"config_signal-json-invalid\")\n        )\n"
  },
  {
    "path": "configuration/tests/test_templatetags.py",
    "content": "from django.test import TestCase\n\nfrom configuration.models import Configuration\nfrom configuration.templatetags.configuration_tags import configuration_value\n\n\nclass TestTemplatetags(TestCase):\n    def test_configuration_value(self):\n        Configuration.objects.create(\n            key=\"test-key\", value=\"Test value\", data_type=Configuration.DataType.TEXT\n        )\n        self.assertEqual(configuration_value(\"test-key\"), \"Test value\")\n\n        Configuration.objects.create(\n            key=\"test-key2\", value=\"100\", data_type=Configuration.DataType.NUMBER\n        )\n        self.assertEqual(configuration_value(\"test-key2\"), 100)\n\n        Configuration.objects.create(\n            key=\"test-key3\", value=\"TrUe\", data_type=Configuration.DataType.BOOLEAN\n        )\n        self.assertEqual(configuration_value(\"test-key3\"), True)\n\n        Configuration.objects.create(\n            key=\"test-key4\", value=\"Test value\", data_type=Configuration.DataType.JSON\n        )\n        # This raises an exception due to invalid JSON, but the template tag should\n        # catch and return a blank string instead\n        self.assertEqual(configuration_value(\"test-key4\"), \"\")\n\n        Configuration.objects.create(\n            key=\"test-key5\",\n            value='{\"key\" : \"value\"}',\n            data_type=Configuration.DataType.JSON,\n        )\n        self.assertEqual(configuration_value(\"test-key5\"), {\"key\": \"value\"})\n\n        Configuration.objects.create(\n            key=\"test-key6\",\n            value=\"<p>{% configuration_value 'test-key' %}</p>\",\n            data_type=Configuration.DataType.HTML,\n        )\n        self.assertEqual(configuration_value(\"test-key6\"), \"<p>Test value</p>\")\n"
  },
  {
    "path": "configuration/tests/test_utils.py",
    "content": "import json\n\nfrom django.core.cache import caches\nfrom django.test import TestCase\n\nfrom configuration.models import Configuration\nfrom configuration.utils import (\n    CONFIGURATION_KEY_PREFIX,\n    cache_configuration_value,\n    configuration_value,\n)\n\n\nclass TestConfigurationUtils(TestCase):\n    def setUp(self):\n        self.cache = caches[\"configuration_cache\"]\n        self.cache.clear()\n\n    def test_configuration_value(self):\n        Configuration.objects.create(\n            key=\"test-key\", value=\"Test value\", data_type=Configuration.DataType.TEXT\n        )\n        self.assertEqual(configuration_value(\"test-key\"), \"Test value\")\n\n        Configuration.objects.create(\n            key=\"test-key2\", value=\"100\", data_type=Configuration.DataType.NUMBER\n        )\n        self.assertEqual(configuration_value(\"test-key2\"), 100)\n\n        Configuration.objects.create(\n            key=\"test-key3\", value=\"TrUe\", data_type=Configuration.DataType.BOOLEAN\n        )\n        self.assertEqual(configuration_value(\"test-key3\"), True)\n\n        Configuration.objects.create(\n            key=\"test-key4\", value=\"\", data_type=Configuration.DataType.JSON\n        )\n        self.assertRaises(\n            json.decoder.JSONDecodeError, configuration_value, \"test-key4\"\n        )\n\n        Configuration.objects.create(\n            key=\"test-key5\",\n            value='{\"key\" : \"value\"}',\n            data_type=Configuration.DataType.JSON,\n        )\n        self.assertEqual(configuration_value(\"test-key5\"), {\"key\": \"value\"})\n\n        Configuration.objects.create(\n            key=\"test-key6\",\n            value=\"<p>{% configuration_value 'test-key' %}</p>\",\n            data_type=Configuration.DataType.HTML,\n        )\n        self.assertEqual(configuration_value(\"test-key6\"), \"<p>Test value</p>\")\n\n\nclass TestCacheConfigurationValue(TestCase):\n    def setUp(self):\n        self.cache = caches[\"configuration_cache\"]\n        self.cache.clear()\n\n    def test_explicit_value_is_cached(self):\n        key = \"explicit-key\"\n        cached = cache_configuration_value(key, 123)\n        self.assertEqual(cached, 123)\n        self.assertEqual(\n            self.cache.get(f\"{CONFIGURATION_KEY_PREFIX}_{key}\"),\n            123,\n        )\n\n    def test_value_is_fetched_from_model_if_not_supplied(self):\n        Configuration.objects.create(\n            key=\"fetched-key\", value=\"true\", data_type=Configuration.DataType.BOOLEAN\n        )\n        cached = cache_configuration_value(\"fetched-key\")\n        self.assertEqual(cached, True)\n        self.assertEqual(\n            self.cache.get(f\"{CONFIGURATION_KEY_PREFIX}_fetched-key\"),\n            True,\n        )\n"
  },
  {
    "path": "configuration/tests/test_validation.py",
    "content": "from django.core.exceptions import ValidationError\nfrom django.test import TestCase\n\nfrom configuration.validation import validate_rate\n\n\nclass TestValidation(TestCase):\n    def test_valid_rates(self):\n        self.assertEqual(validate_rate(\"1/s\"), \"1/s\")\n        self.assertEqual(validate_rate(\"10/m\"), \"10/m\")\n        self.assertEqual(validate_rate(\"100/h\"), \"100/h\")\n        self.assertEqual(validate_rate(\"1000/d\"), \"1000/d\")\n\n    def test_rate_stripping_whitespace(self):\n        # Leading/trailing spaces\n        self.assertEqual(validate_rate(\" 10/m \"), \"10/m\")\n        self.assertEqual(validate_rate(\"\\t10/m\"), \"10/m\")\n        self.assertEqual(validate_rate(\"10/m\\t\"), \"10/m\")\n        self.assertEqual(validate_rate(\"\\n10/m\\n\"), \"10/m\")\n        self.assertEqual(validate_rate(\" \\n\\t10/m\\t\\n \"), \"10/m\")\n\n        # Internal whitespace is not allowed and should still raise\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10 /m\")\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10/ m\")\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10 / m\")\n\n    def test_non_string_input(self):\n        with self.assertRaises(ValidationError):\n            validate_rate(10)\n\n        with self.assertRaises(ValidationError):\n            validate_rate(None)\n\n        with self.assertRaises(ValidationError):\n            validate_rate([\"5/m\"])\n\n    def test_invalid_format(self):\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10\")  # no unit\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10/min\")  # full word\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"ten/m\")  # non-numeric\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10/\")  # missing unit\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"/m\")  # missing number\n\n        # This is now valid due to stripping\n        self.assertEqual(validate_rate(\"10/m\\n\"), \"10/m\")\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10/m/extra\")  # too many parts\n\n    def test_zero_or_negative_values(self):\n        with self.assertRaises(ValidationError):\n            validate_rate(\"0/s\")\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"-5/m\")\n\n    def test_invalid_unit(self):\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10/w\")  # unsupported unit\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10/ms\")  # unsupported unit\n\n        with self.assertRaises(ValidationError):\n            validate_rate(\"10/seconds\")  # full unit\n"
  },
  {
    "path": "configuration/utils.py",
    "content": "from typing import Any\n\nfrom django.conf import settings\nfrom django.core.cache import caches\n\nfrom configuration.models import Configuration\n\nCONFIGURATION_KEY_PREFIX = \"config\"\n\n\ndef configuration_value(key: str) -> Any:\n    \"\"\"\n    Retrieve a configuration value by key with caching and type casting.\n\n    Behavior:\n        - Look up the value in the ``configuration_cache`` using a namespaced\n          cache key.\n        - If the value is missing, delegate to\n          ``cache_configuration_value(key)`` to fetch, cast, cache, and return\n          the value.\n        - Casting is performed by ``Configuration.get_value()`` based on the\n          instance's ``data_type``.\n\n    Caching:\n        - Values are stored in the cache alias ``configuration_cache``.\n        - Cache entries expire according to\n          ``settings.CONFIGURATION_CACHE_TIMEOUT``.\n\n    Args:\n        key (str): The configuration key to resolve.\n\n    Returns:\n        Any: The resolved and type-cast configuration value.\n\n    Raises:\n        Configuration.DoesNotExist: If the key is not present in the database\n            when attempting to populate the cache.\n    \"\"\"\n    config_cache = caches[\"configuration_cache\"]\n    cache_key = f\"{CONFIGURATION_KEY_PREFIX}_{key}\"\n    value = config_cache.get(cache_key)\n\n    if value is None:\n        value = cache_configuration_value(key)\n\n    return value\n\n\ndef cache_configuration_value(key: str, value: Any | None = None) -> Any:\n    \"\"\"\n    Populate or refresh the cached value for a configuration key.\n\n    Behavior:\n        - If ``value`` is ``None``, fetch the ``Configuration`` by ``key``,\n          cast it via ``get_value()``, and cache the result.\n        - If ``value`` is provided, cache that value directly.\n        - Always write to the ``configuration_cache`` using the configured\n          ``settings.CONFIGURATION_CACHE_TIMEOUT``.\n\n    Args:\n        key (str): The configuration key to cache.\n        value (Any | None): An explicit value to cache. If ``None``, the value\n            is loaded from the database and cast via ``get_value()``.\n\n    Returns:\n        Any: The value that was stored in the cache.\n\n    Raises:\n        Configuration.DoesNotExist: If ``value`` is ``None`` and there is no\n            ``Configuration`` row with the given key.\n    \"\"\"\n    config_cache = caches[\"configuration_cache\"]\n    cache_key = f\"{CONFIGURATION_KEY_PREFIX}_{key}\"\n\n    if value is None:\n        config = Configuration.objects.get(key=key)\n        value = config.get_value()\n\n    config_cache.set(cache_key, value, timeout=settings.CONFIGURATION_CACHE_TIMEOUT)\n    return value\n"
  },
  {
    "path": "configuration/validation.py",
    "content": "import re\n\nfrom django.core.exceptions import ValidationError\n\nRATE_LIMIT_PATTERN = re.compile(r\"^\\d+/(s|m|h|d)$\")\n\n\ndef validate_rate(rate: str) -> str:\n    \"\"\"\n    Validate that a rate string matches the expected pattern like '10/m'.\n\n    Behavior:\n        - Strip leading and trailing whitespace.\n        - Require the format '<positive integer>/<unit>' where unit is one of\n          's', 'm', 'h', or 'd' (seconds, minutes, hours, days).\n        - Return the cleaned string unchanged if valid.\n\n    Args:\n        rate (str): The candidate rate string to validate.\n\n    Returns:\n        str: The cleaned rate string if valid.\n\n    Raises:\n        ValidationError: If the input is not a string, if the format does not\n            match the required pattern, or if the integer portion is less than\n            or equal to zero.\n    \"\"\"\n    if not isinstance(rate, str):\n        raise ValidationError(\"Rate limit must be a string.\")\n\n    rate = rate.strip()\n\n    if not RATE_LIMIT_PATTERN.match(rate):\n        raise ValidationError(\"Invalid rate limit format. Use '<number>/<s|m|h|d>'.\")\n\n    count, unit = rate.split(\"/\")\n    if int(count) <= 0:\n        raise ValidationError(\"Rate limit count must be greater than 0.\")\n\n    return rate\n"
  },
  {
    "path": "configuration/views.py",
    "content": "# Create your views here.\n"
  },
  {
    "path": "db_scripts/Dockerfile",
    "content": "# Base layer with all tools\nFROM public.ecr.aws/amazonlinux/amazonlinux:2023-minimal AS base\n\n# Trusted CA for proxy\nRUN curl -fsO --output-dir /etc/pki/ca-trust/source/anchors/ http://crl.loc.gov/LOC-ROOT-CA-1.crt \\\n    && update-ca-trust\n\n# Install tools once\nRUN dnf -y upgrade && \\\n    dnf -y install postgresql15.x86_64 awscli-2.noarch && \\\n    dnf -y clean all\n\n# Logic for Dump\nFROM base AS dump\nCOPY dump.sh .\nRUN chmod +x dump.sh\nCMD [\"./dump.sh\"]\n\n# Logic for Restore\nFROM base AS restore\nCOPY restore.sh .\nRUN chmod +x restore.sh\nCMD [\"./restore.sh\"]\n"
  },
  {
    "path": "db_scripts/dump.sh",
    "content": "#!/bin/bash\n\nset -eu -o pipefail\n\nif [[ -z \"${ENV_NAME}\" ]]; then\n    echo \"ENV_NAME must be set prior to running this script.\"\n    exit 1\nfi\n\nif [ $ENV_NAME != \"prod\" ]; then\n    echo \"This script should only be run in the production environment.\"\n    exit 1\nfi\n\nTODAY=$(date +%Y%m%d)\nif [[ \"$TODAY\" =~ (0101|0401|0701|1001)$ ]]; then\n    TAGVALUE=\"true\"\nelse\n    TAGVALUE=\"false\"\nfi\nPOSTGRESQL_PW=\"$(aws secretsmanager get-secret-value --region us-east-1 --secret-id crowd/${ENV_NAME}/DB/MasterUserPassword | python3 -c 'import json,sys;Secret=json.load(sys.stdin);SecretString=json.loads(Secret[\"SecretString\"]);print(SecretString[\"password\"])')\"\nPOSTGRESQL_HOST=\"$(aws ssm get-parameter --region us-east-1 --name /concordia/${ENV_NAME}/db.url | python3 -c 'import json,sys;ParameterInput=json.load(sys.stdin);Parameter=ParameterInput[\"Parameter\"];print(Parameter[\"Value\"])')\"\nDUMP_FILE=concordia.dmp\n\necho \"${POSTGRESQL_HOST}:5432:*:concordia:${POSTGRESQL_PW}\" > ~/.pgpass\nchmod 600 ~/.pgpass\n\npg_dump -Fc --no-acl -U concordia -h \"${POSTGRESQL_HOST}\" concordia -f \"${DUMP_FILE}\"\n\nif [ -s $DUMP_FILE ]; then\n    aws s3 cp \"${DUMP_FILE}\" \"s3://crowd-deployment/database-dumps/concordia.${TODAY}.dmp\"\n    aws s3 cp \"${DUMP_FILE}\" s3://crowd-deployment/database-dumps/concordia.latest.dmp\n    aws s3api put-object-tagging --bucket 'crowd-deployment' --key database-dumps/concordia.${TODAY}.dmp --tagging '{\"TagSet\": [{ \"Key\": \"first-dmp-of-quarter\", \"Value\": \"'${TAGVALUE}'\" }]}'\n    aws s3api put-object-tagging --bucket 'crowd-deployment' --key database-dumps/concordia.latest.dmp --tagging '{\"TagSet\": [{ \"Key\": \"first-dmp-of-quarter\", \"Value\": \"'${TAGVALUE}'\" }]}'\nfi\necho $?\n"
  },
  {
    "path": "db_scripts/restore.sh",
    "content": "#!/bin/bash\n\nset -eu -o pipefail\n\nexport PATH=$HOME/.local/bin:$PATH\n\nif [[ -z \"${ENV_NAME}\" ]]; then\n    echo \"ENV_NAME must be set prior to running this script.\"\n    exit 1\nfi\n\nif [ $ENV_NAME = \"prod\" ]; then\n    echo \"This script should not be run in the production environment.\"\n    exit 1\nfi\n\nPOSTGRESQL_PW=\"$(aws secretsmanager get-secret-value --region us-east-1 --secret-id crowd/${ENV_NAME}/DB/MasterUserPassword | python3 -c 'import json,sys;Secret=json.load(sys.stdin);SecretString=json.loads(Secret[\"SecretString\"]);print(SecretString[\"password\"])')\"\nPOSTGRESQL_HOST=\"$(aws ssm get-parameter --region us-east-1 --name /concordia/${ENV_NAME}/db.url | python3 -c 'import json,sys;ParameterInput=json.load(sys.stdin);Parameter=ParameterInput[\"Parameter\"];print(Parameter[\"Value\"])')\"\nDUMP_FILE=concordia.dmp\n\naws s3 cp s3://crowd-deployment/database-dumps/concordia.latest.dmp ${DUMP_FILE}\n\necho \"${POSTGRESQL_HOST}:5432:*:concordia:${POSTGRESQL_PW}\" > ~/.pgpass\nchmod 600 ~/.pgpass\n\naws s3 sync s3://crowd-content s3://crowd-${ENV_NAME}-content --delete\n\npsql -U concordia -h \"$POSTGRESQL_HOST\" -d postgres -c \"select pg_terminate_backend(pid) from pg_stat_activity where datname='concordia';\"\npsql -U concordia -h \"$POSTGRESQL_HOST\" -d postgres -c \"drop database concordia with (force);\"\npg_restore --create -U concordia -h \"${POSTGRESQL_HOST}\" -Fc --dbname=postgres --no-owner --no-acl \"${DUMP_FILE}\"\nRETURNCODE=$?\necho $RETURNCODE\n\nif [ $RETURNCODE = 0 ] && [ $ENV_NAME = \"test\" ]; then\n    ECS_SERVICE=\"$(aws ecs list-services --region us-east-1 --cluster crowd-${ENV_NAME} | python3 -c 'import json,sys;ParameterInput=json.load(sys.stdin);Parameter=ParameterInput[\"serviceArns\"];print(Parameter[0].split(\"/\")[2])')\"\n\n    # If a feature branch env is running the number of services in the test cluster increases.\n    NUMBER_OF_SERVICES=\"$(aws ecs list-services --region us-east-1 --cluster crowd-test | python3 -c 'import json,sys;ParameterInput=json.load(sys.stdin);Parameter=ParameterInput[\"serviceArns\"];print(len(Parameter))')\"\n    if [ $NUMBER_OF_SERVICES = 3 ];then\n        # Normal\n        ECS_SERVICE_2=\"$(aws ecs list-services --region us-east-1 --cluster crowd-${ENV_NAME} | python3 -c 'import json,sys;ParameterInput=json.load(sys.stdin);Parameter=ParameterInput[\"serviceArns\"];print(Parameter[2].split(\"/\")[2])')\"\n    else\n        # Feature branch env exists.\n        ECS_SERVICE_2=\"$(aws ecs list-services --region us-east-1 --cluster crowd-${ENV_NAME} | python3 -c 'import json,sys;ParameterInput=json.load(sys.stdin);Parameter=ParameterInput[\"serviceArns\"];print(Parameter[3].split(\"/\")[2])')\"\n    fi\n\n    aws ecs update-service --region us-east-1 --force-new-deployment --cluster crowd-${ENV_NAME} --service ${ECS_SERVICE}\n    aws ecs update-service --region us-east-1 --force-new-deployment --cluster crowd-${ENV_NAME} --service ${ECS_SERVICE_2}\nelif [ $RETURNCODE = 0 ]; then\n    ECS_SERVICE=\"$(aws ecs list-services --region us-east-1 --cluster crowd-${ENV_NAME} | python3 -c 'import json,sys;ParameterInput=json.load(sys.stdin);Parameter=ParameterInput[\"serviceArns\"];print(Parameter[0].split(\"/\")[2])')\"\n    aws ecs update-service --region us-east-1 --force-new-deployment --cluster crowd-${ENV_NAME} --service ${ECS_SERVICE}\nfi\n"
  },
  {
    "path": "development/Containerfile",
    "content": "FROM python:3.12-slim-bookworm\n\n# Major Node.js version to install (e.g., 20, 22). This is used to select the\n# NodeSource APT repository \"node_<major>.x\".\nARG NODE_MAJOR=20\n\n# Define build-time arguments for UID and GID\nARG USERNAME\nARG UID\nARG GID\n\nENV DEBIAN_FRONTEND=\"noninteractive\"\n\n# Create the group and user with specified UID/GID\nRUN groupadd -g $GID $USERNAME && \\\n    useradd -m -u $UID -g $GID -s /bin/bash $USERNAME\n\n# Bootstrap minimal tooling needed later in the build:\n# - curl: download files/keys\n# - ca-certificates: validate HTTPS endpoints\n# - gnupg: import and dearmor APT repository signing keys\nRUN apt-get update -qy && apt-get install -qy curl ca-certificates gnupg\n\n# Ensure that the Library's certificate authority is trusted so the tampering\n# proxy will not break TLS validation. See\n# https://staff.loc.gov/wikis/display/SE/Configuring+HTTPS+clients+for+the+HTTPS+tampering+proxy.\n\nRUN curl -fso /etc/ssl/certs/LOC-ROOT-CA-1.crt http://crl.loc.gov/LOC-ROOT-CA-1.crt && openssl x509 -inform der -in /etc/ssl/certs/LOC-ROOT-CA-1.crt -outform pem -out /etc/ssl/certs/LOC-ROOT-CA-1.pem && c_rehash\n\n# Install Node.js via the NodeSource APT repository (manual setup; no setup\n# script). Debian bookworm ships Node 18; adding this repo allows installing a\n# newer major version (e.g., Node 20) via apt.\n#\n# This step:\n# - creates a dedicated keyring directory under /etc/apt/keyrings\n# - downloads and installs the NodeSource signing key into a keyring file\n# - registers the NodeSource repository for the selected Node.js major line\n#\n# Note: When installing Node.js from NodeSource, the `nodejs` package includes\n# npm (and npm comes with node-gyp), so there is no separate `npm` or\n# `node-gyp` APT package to install here.\n#\n# References: NodeSource \"Repository Manual Installation\" guide. https://github.com/nodesource/distributions/wiki/Repository-Manual-Installation\nRUN \\\n    # Create a dedicated directory for third-party APT keyrings.\n    mkdir -p /etc/apt/keyrings && \\\n    # Download the NodeSource repository signing key and store it as a keyring\n    # file that apt can use to verify NodeSource packages.\n    curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \\\n        | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \\\n    # Register the NodeSource repository for the selected Node.js major version.\n    # The \"signed-by=\" option scopes trust to just this repository entry.\n    echo \"deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main\" \\\n        > /etc/apt/sources.list.d/nodesource.list\n\nRUN apt-get update -qy && apt-get dist-upgrade -qy && apt-get install -o Dpkg::Options::='--force-confnew' -qy \\\n    build-essential \\\n    git \\\n    libmemcached-dev \\\n    # Pillow/Imaging: https://pillow.readthedocs.io/en/latest/installation.html#external-libraries\n    libz-dev libfreetype6-dev \\\n    libtiff-dev libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev \\\n    # Postgres client library to build psycopg\n    libpq-dev \\\n    locales \\\n    # Weasyprint requirements\n    libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 \\\n    # Tesseract\n    tesseract-ocr tesseract-ocr-all \\\n    # Selenium/Chrome/chromedriver requirements\n    libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libxdamage1 libxkbcommon0 libasound2 libatspi2.0-0 \\\n    # Additional tools for development\n    nano bash-completion \\\n    nodejs && apt-get -qy autoremove && apt-get -qy autoclean\n\nRUN locale-gen en_US.UTF-8\nENV LC_ALL=en_US.UTF-8\nENV LANG=en_US.UTF-8\nENV LANGUAGE=en_US.UTF-8\n\nENV PYTHONUNBUFFERED 1\nENV PYTHONPATH /workspace\n\nENV DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-concordia.settings_docker}\n\nRUN pip install --upgrade pip\nRUN pip install --no-cache-dir pre-commit\nRUN pip install --no-cache-dir pipenv\n\nRUN npm install --silent --global npm@10\n\n# Set the working directory and permissions\nWORKDIR /workspace\nCOPY . /workspace\nRUN chown -R $USERNAME:$USERNAME /workspace\n\n# Switch to the new user\nUSER $USERNAME\n\n# Set user's path to include local bin, which is where Python libraries will be installed\nENV PATH \"/home/${USERNAME}/.local/bin:$PATH\"\n\nRUN mkdir -p /workspace/logs\nRUN touch /workspace/logs/concordia.log\n\n# Front-end build and asset pipeline:\n# - update npm to a known major version\n# - install JS dependencies (production-only)\nRUN npm install --silent\n# Additional JS build step for Vite - compile scss, bundle, hash, and compress js files.\nRUN npm run build\n\nRUN pipenv install --system --dev --deploy && rm -rf ~/.cache/\n\nRUN pre-commit install-hooks\n\nEXPOSE 80\n\nCMD [\"bash\"]\n"
  },
  {
    "path": "development/README.md",
    "content": "# Concordia Development Containers\n\nThe files in this directory, `compose.yml` and `Containerfile`, have been created to better facilitate developing Concordia in a containerized environment. The files are compatible with both Docker and Podman, using docker-compose or podman-compose.\n\nThough newer versions of docker-compose and podman-compose support combining compose files and compose file overriding, the versions of these tools available on some current distributions (such as Red Hat Enterprise Linux 9) do not, so a singular compose file (`compose.yml`) with all the necessary settings is provided here.\n\n## Purpose\n\nThe intention of these files is to provide a usable development environment purely in containers.\n\nThe default configuration (`../docker-compose.yml`) creates a container environment that's not suitable for development. Primarily, it creates an container for Elasticsearch, which is often used except in production, and the `app` container is not configured well for development. It runs as root, which causes file permission issues, and runs the daphne asgi server, which can't be restarted without restarting the entire container (manually killing and starting daphne does not work, either, because that causes the container to shutdown).\n\nIn addition, the default container file for the `app` container (`../Dockerfile`) runs `../entrypoint.sh`, which does several things that are undesirable in a development environment that involves restarting containers regularly. It automatically generates and applies migrations, runs collectstatic and launches daphne. The development Containerfile instead simply launches a bash shell.\n\n## Configuration\n\nIn order to use `compose.yml` with your compose CLI tool of choice, you'll need to pass in the path, either through an environment variable or a command-line switch.\n\n```bash\npodman-compose -f development/compose.yml\n```\n\n```dotenv\nCOMPOSE_FILE=development/compose.yml\n```\n\nYour CLI tool should be executed in the concordia directory (`..`).\n\n### .env caveat\n\nNote that some versions of the tools (notably, podman-compose<=1.0.6) do not use .env files. If you wish to use environment variables, you'll need to inject those variables manually, such as by using a script that sources your .env file before executing podman-compose.\n\n## Development\n\nConfiguring for your environment when using containers follows the process in the [For Developers](../docs/for-developers.md) page, except that most of the work is done for you by `compose.yml` and `Containerfile`. Notably, you do not need to install any dependencies (except git and your compose CLI) on your host. You will still need to configure your .env file, but otherwise simply running `podman-compose up -d` or `docker-compose up -d`, with the proper COMPOSE_FILE configuration (and other additions to .env, see below), will create an app container with everything you need for development.\n\n### Additions to .env\n\nThere are a few additions to your .env file required to properly use the provided `compose.yml` and `Containerfile`:\n\n```dotenv\nCOMPOSE_FILE=development/compose.yml\nHOME_DIR=/home/<username>/\nAWS_SHARED_CREDENTIALS_FILE=/home/<username>/.aws/credentials\nCONTAINER_UID=<uid>\nCONTAINER_GID=<guid>\nCONTAINER_USERNAME=<username>\n```\n\nThese values should be for the user account that you'll be doing development with, the same one that owns your local repository. This information is used to mount various necessary directories in the container, as well as configure the user account inside the container (to avoid running as root).\n\nThe last three settings can automatically be added to your .env file with the following scripts (executed in the directory with the .env):\n\n```bash\n#!/bin/bash\n\nENV_FILE=\".env\"\nBACKUP_FILE=\".env.bak\"\n\n# Create the .env file if it doesn't exist\ntouch \"$ENV_FILE\"\n\n# Backup the original .env once\ncp \"$ENV_FILE\" \"$BACKUP_FILE\"\n\n# Set the values\nNEW_UID=$(id -u)\nNEW_GID=$(id -g)\nNEW_USERNAME=$(whoami)\n\n# Function to add or update a key in the .env file\nupdate_env_var() {\n    local key=\"$1\"\n    local value=\"$2\"\n    if grep -qE \"^${key}=\" \"$ENV_FILE\"; then\n        sed -i \"s/^${key}=.*/${key}=${value}/\" \"$ENV_FILE\"\n    else\n        echo \"${key}=${value}\" >> \"$ENV_FILE\"\n    fi\n}\n\n# Update the values\nupdate_env_var \"CONTAINER_UID\" \"$NEW_UID\"\nupdate_env_var \"CONTAINER_GID\" \"$NEW_GID\"\nupdate_env_var \"CONTAINER_USERNAME\" \"$NEW_USERNAME\"\n\necho \"Backup saved as $BACKUP_FILE\"\necho \".env updated with CONTAINER_UID=$NEW_UID, CONTAINER_GID=$NEW_GID, CONTAINER_USERNAME=$NEW_USERNAME\"\n```\n\n### Attaching to the app container\n\nOnce you've launched your containers, you can attach to a shell in the app container to perform development. Your compose CLI tool should provide a method for doing this.\n\n```bash\nsudo docker-compose exec -it app bash\n```\n\nNote that older versions of podman-compose do not properly pass switches to the underlying command, meaning the above won't work with those versions of podmon-compose. You can instead run it without the switches:\n\n```bash\nsudo podman-compose exec app bash\n```\n\nHowever, this has the disadvantage of not creating an interactive shell shell, which can cause issues with bash functionality. If you have a version of podman-compose with this limitation, the workaround is to use podman directly instead:\n\n```bash\nsudo podman exec -it concordia_app bash\n```\n\nYou have to use the full container name because compose.yml is not referenced when using podman directly.\n\n### Configuring for development\n\nYou will need to manually collect the static files before running the development server. This only needs to be done once after building the app container (and when changing static files in the future).\n\n```bash\nnpx vite build\npython manage.py collectstatic --no-post-process\n```\n\n### Launch the development server\n\nLaunching the development server is identical to launching it outside a container. The app container is configured to map port 8000 in the container to port 80 on the host:\n\n```bash\npython manage.py runserver 0.0.0.0:8000\n```\n\n### Committing changes\n\nGit and the Concordia precommit hooks are included in the app container. You can simply use git commands as normal inside /workspace in the container.\n"
  },
  {
    "path": "development/compose.yml",
    "content": "version: '3.6'\nservices:\n    redis:\n        container_name: concordia_redis\n        restart: unless-stopped\n        image: redis:latest\n        hostname: redis\n        ports:\n            - 63791:6379\n        volumes:\n            - redis_volume:/data\n\n    db:\n        container_name: concordia_db\n        restart: unless-stopped\n        image: postgres:15\n        environment:\n            POSTGRES_PASSWORD: ${POSTGRESQL_PW}\n            POSTGRES_USER: concordia\n            POSTGRES_MULTIPLE_DATABASES: test_concordia\n        ports:\n            - 54323:5432\n        volumes:\n            - ../postgresql:/docker-entrypoint-initdb.d:z\n            - db_volume:/var/lib/postgresql/data/\n\n    app:\n        container_name: concordia_app\n        hostname: app\n        restart: unless-stopped\n        build:\n            context: ..\n            dockerfile: development/Containerfile\n            args:\n                UID: ${CONTAINER_UID}\n                GID: ${CONTAINER_GID}\n                USERNAME: ${CONTAINER_USERNAME}\n        environment: &django-environment\n            WAIT_HOSTS: db:5432, redis:6379\n            POSTGRESQL_HOST: db\n            POSTGRESQL_PW: ${POSTGRESQL_PW}\n            CONCORDIA_ENVIRONMENT: development\n            DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-concordia.settings_template}\n            DEBUG: ${DEBUG:-}\n            REDIS_ADDRESS: redis\n            REDIS_PORT: 6379\n            AWS_PROFILE: ${AWS_PROFILE}\n            AWS_SHARED_CREDENTIALS_FILE: ${AWS_SHARED_CREDENTIALS_FILE}\n            TURNSTILE_SITEKEY: ${TURNSTILE_SITEKEY:-1x00000000000000000000AA}\n            TURNSTILE_SECRET: ${TURNSTILE_SECRET:-1x0000000000000000000000000000000AA}\n        depends_on:\n            - redis\n            - db\n        volumes:\n            - ..:/workspace:z\n            - ${HOME_DIR}/.aws:${HOME_DIR}/.aws:z\n            - ${HOME_DIR}/.gitconfig:${HOME_DIR}/.gitconfig:z\n            - ${HOME_DIR}/.ssh:${HOME_DIR}/.ssh:z\n            - images_volume:/concordia_images\n        networks:\n            - default\n        ports:\n            - 80:8000\n        stdin_open: true\n        tty: true\n\n    importer:\n        container_name: concordia_importer\n        hostname: importer\n        restart: unless-stopped\n        build:\n            context: ..\n            dockerfile: importer/Dockerfile\n        environment: *django-environment\n        depends_on:\n            - redis\n            - db\n        networks:\n            - default\n        volumes:\n            - ..:/app:z\n            - ${HOME_DIR}/.aws:/root/.aws:z\n            - images_volume:/concordia_images\n\n    celerybeat:\n        container_name: concordia_celerybeat\n        hostname: celerybeat\n        restart: unless-stopped\n        build:\n            context: ..\n            dockerfile: celerybeat/Dockerfile\n        environment: *django-environment\n        depends_on:\n            - redis\n            - db\n        networks:\n            - default\n\nvolumes:\n    db_volume:\n    images_volume:\n    redis_volume:\n\nnetworks:\n    default:\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "version: '3.6'\nservices:\n    opensearch-node:\n        image: public.ecr.aws/opensearchproject/opensearch:1\n        container_name: opensearch-node\n        environment:\n            - cluster.name=opensearch-cluster\n            - node.name=opensearch-node\n            - discovery.type=single-node\n            - bootstrap.memory_lock=true # along with the memlock settings below, disables swapping\n            - OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m # minimum and maximum Java heap size, recommend setting both to 50% of system RAM\n            - 'DISABLE_INSTALL_DEMO_CONFIG=true' # Prevents execution of bundled demo script which installs demo certificates and security configurations to OpenSearch\n            - 'DISABLE_SECURITY_PLUGIN=true' # initial local setup - Disables security plugin\n        ulimits:\n            memlock:\n                soft: -1\n                hard: -1\n            nofile:\n                soft: 65536 # maximum number of open files for the OpenSearch user, set to at least 65536 on modern systems\n                hard: 65536\n        volumes:\n            - opensearch-data:/usr/share/opensearch/data\n        ports:\n            - 9200:9200 # REST API\n            - 9600:9600 # Performance Analyzer\n        networks:\n            - default\n\n    opensearch-dashboards:\n        image: public.ecr.aws/opensearchproject/opensearch-dashboards:1\n        container_name: opensearch-dashboards\n        ports:\n            - 5601:5601\n        expose:\n            - '5601'\n        environment:\n            - 'OPENSEARCH_HOSTS=http://opensearch-node:9200'\n            - 'DISABLE_SECURITY_DASHBOARDS_PLUGIN=true' # disables security dashboards plugin in OpenSearch Dashboards\n        networks:\n            - default\n\n    redis:\n        restart: unless-stopped\n        image: redis:latest\n        hostname: redis\n        ports:\n            - 6379:6379\n        volumes:\n            - redis_volume:/data\n\n    db:\n        restart: unless-stopped\n        image: postgres:15\n        environment:\n            POSTGRES_PASSWORD: ${POSTGRESQL_PW}\n            POSTGRES_USER: concordia\n            POSTGRES_MULTIPLE_DATABASES: test_concordia\n        ports:\n            - 5432:5432\n        volumes:\n            - ./postgresql:/docker-entrypoint-initdb.d\n            - db_volume:/var/lib/postgresql/data/\n\n    app:\n        restart: unless-stopped\n        build: .\n        env_file:\n            - .env\n        environment: &django-environment\n            WAIT_HOSTS: db:5432, redis:6379\n            POSTGRESQL_HOST: db\n            POSTGRESQL_PW: ${POSTGRESQL_PW}\n            CONCORDIA_ENVIRONMENT: development\n            DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-concordia.settings_docker}\n            DEBUG: ${DEBUG:-}\n            REDIS_ADDRESS: redis\n            REDIS_PORT: 6379\n        depends_on:\n            - redis\n            - db\n        volumes:\n            - .:/app\n            - images_volume:/concordia_images\n        networks:\n            - default\n        ports: # if running locally use 80:80, if running in local container use 8000:80\n            - 80:80\n\n    importer:\n        restart: unless-stopped\n        build:\n            context: .\n            dockerfile: importer/Dockerfile\n        environment: *django-environment\n        depends_on:\n            - redis\n            - db\n        networks:\n            - default\n        volumes:\n            - images_volume:/concordia_images\n\n    celerybeat:\n        restart: unless-stopped\n        build:\n            context: .\n            dockerfile: celerybeat/Dockerfile\n        environment: *django-environment\n        depends_on:\n            - redis\n            - db\n        networks:\n            - default\n\nvolumes:\n    db_volume:\n    images_volume:\n    redis_volume:\n    opensearch-data:\n\nnetworks:\n    default:\n"
  },
  {
    "path": "docs/accessibility-goals.md",
    "content": "# Accessibility Goals\n\n## Background Information\n\n-   Microsoft has a site with a number of resources detailing their “Inclusive Design” strategy: https://www.microsoft.com/design/inclusive/\n\n    Of note is the [“Inclusive 101” PDF](https://download.microsoft.com/download/b/0/d/b0d4bf87-09ce-4417-8f28-d60703d672ed/inclusive_toolkit_manual_final.pdf) covering the range of factors which designs should take into account and common strategies.\n\n    [Kill Your Personas: How persona spectrums champion real user needs](https://medium.com/microsoft-design/kill-your-personas-1c332d4908cc)\n    has a longer discussion about avoiding binary thinking when designing\n\n## Desired Outcomes by Activity\n\nWe have desired goals for each category of activity on the site\n\n### Transcription\n\n-   Users with mobility restrictions should be able to complete the process\n-   Users with color vision impairments should be able to complete the process except for specific pages where the content is inherently inaccessible until https://github.com/LibraryOfCongress/concordia/issues/666 is complete\n-   Users with significant vision impairments should be able to learn the nature of the task and why it is difficult to continue\n\n### Review\n\n-   Users with mobility restrictions should be able to complete the process\n-   Users with color vision impairments should be able to complete the process except for specific pages where the content is inherently inaccessible until https://github.com/LibraryOfCongress/concordia/issues/666 is complete\n-   Users with significant vision impairments should be able to learn the nature of the task and why it is difficult to continue\n\n### Final results\n\n-   The transcribed text should be accessible to everyone\n\n### Project Information\n\n-   Information about the project should be accessible to everyone\n-   Users should be able to easily learn the level of difficulty for their participation in particular activities\n"
  },
  {
    "path": "docs/accessibility-techniques.md",
    "content": "# Accessibility Techniques\n\n## Background Information\n\n-   18F has a detailed U.S. government-focused guide: https://accessibility.18f.gov/\n-   The BBC has their comprehensive guide: https://bbc.github.io/accessibility-news-and-you/\n\n## Assistive Technology Categories\n\n### Screen Readers\n\n#### Background information\n\n-   https://webaim.org/techniques/screenreader/\n-   The BBC has some guides for testing: https://bbc.github.io/accessibility-news-and-you/accessibility-and-testing-with-assistive-technology\n\n#### Resources for common screen readers\n\n##### JAWS\n\n-   Freedom Scientific has a detailed [JAWS HTML and ARIA support matrix](https://freedomscientific.github.io/VFO-standards-support/) and [a downloable version](https://www.freedomscientific.com/Downloads/JAWS) which may be used for up to 40 minutes without a license\n-   https://bbc.github.io/accessibility-news-and-you/accessibility-and-testing-with-jaws\n\n##### Windows Narrator\n\n-   https://support.microsoft.com/en-us/help/17173/windows-10-hear-text-read-aloud\n\n##### Apple (macOS and iOS) VoiceOver\n\n-   https://help.apple.com/voiceover/info/guide/\n-   https://bbc.github.io/accessibility-news-and-you/accessibility-and-testing-with-voiceover-ios\n-   https://bbc.github.io/accessibility-news-and-you/accessibility-and-testing-with-voiceover-os\n"
  },
  {
    "path": "docs/design-principles.md",
    "content": "## Design Principles for System Features &amp; Capabilities\n\nWe are collaborating across the Library of Congress to support this vision: _creating new pathways of engagement, scholarship, and serving the public—while making history with people, staff, collections and systems—by inviting public participation in a transformative project that improves discovery of the treasures of the Library of Congress._\n\nTwo principles guide these proposed features and the ways we can launch, manage, and sustain a crowdsourced transcription and tagging platform at the Library of Congress:\n\n**Trust** and **Approachability**\n\nThe system features and capabilities envisioned below will support a user-centered crowdsourcing initiative through functionality and a program of engagement.\n\n## **ENGAGE**\n\n### Participation by many audiences in the same place, at the same time\n\nWe have a unique opportunity to support many audiences including, but not limited to, a curious public, researchers, teachers, students, those seeking volunteer opportunities, and staff. This crowdsourcing platform presents a digital space in which audiences with different, but possibly overlapping interests, skill levels, and familiarity with the Library of Congress may gather together. This vision builds on the work Library of Congress staff have invested in crafting programs that serve tanging audiences based on their specific needs. We are likely to have a dynamic and constantly changing atmosphere of participants and collections; here are some essential approaches and features to accommodate many participants in the same place, at the same time.\n\n#### Lowest barrier to entry: No accounts necessary (but possible)\n\nTo ensure that every visitor can contribute and begin their Library of Congress crowdsourcing experience immediately, it would be best that accounts are not required to participate. Accounts may still be possible but not set as a prerequisite to contribute. Entries by &quot;anonymous&quot; users can be assessed separately or in a wider pool of contributions. As discussed below, it will be important to understand activity on the site; therefore, care should be taken to inform even anonymous users about the ways their behavior is captured. It is essential that these details are explained with clear language about the need for and use of that information.\n\n#### Accessibility\n\nLibrary of Congress audiences and visitors have a range of needs. This project should at all times meet the needs of all audiences. Examples of ways to create a welcoming and accommodating space include making it clear how to customize font sizes, interface adjustments, font styles and color choices, and plain language for instructions and project contexts. There are opportunities to invite participation in the system and use of the collections in many ways including reviewing and integrating voice to text capabilities, particularly for tagging. By meeting and thoughtfully surpassing compliance standards, the Library of Congress can create pathways for increased accessibility within the transcription workflow - such as the simple addition of a field to create alt text for the images. Captioned video content to support collections contexts, blog posts about discoveries and activity updates, and other featured content would also foreground that all audiences, even those who are not directly transcribing or tagging, are invited.\n\n#### Multiple entry points\n\nWe should anticipate visitors entering from many different paths: whether a visitor is routed through the homepage into a collection and then selects an individual page to transcribe and tag; or referred by a post on a social network; or uncovered in internet search engine results; or introduced via [https://loc.gov](https://loc.gov) or simply word of mouth; or returning to transcribe and tag again via their own account page. It should always be clear how a visitor would navigate to the instructions, to an overview of the crowdsourcing program, to move to one&#39;s account pages (if one has created an account), and to navigate &quot;up&quot; to the project or campaign page(s), if entering on an individual transcription or tagging page from persistent unique URL.\n\n#### Many Paths through Digital Collections\n\nHeterogeneous subjects, forms, stories, and tasks are key to sustaining a long-term and representative crowdsourcing project. It is likely that many visitors will follow the sequential presentation of images, just as they were reading a diary or letter, or following other documentation as it unfolds. However, other visitors may wish to explore pages across collections with minimal reorientation to the home or collection-level pages. Still other volunteers may wish to change tasks between transcribing and tagging, or performing review passes, while participating. Community managers may also wish to lead visitors to targeted activity. There are unique opportunities to use the tool with prepared and simultaneous programming around the collections and campaigns. It may be helpful to have &quot;tracks&quot; for the multiple audiences interacting in this dynamic space.\n\n#### Make Sense Quickly: Invitations to Contribute through Information Architecture\n\nUpon entering the platform, a visitor should understand what tasks are requested and the goals of the program. It should be easy to explore featured collections by tags identified by community managers and curators; it should also be possible to explore collections based on community or self-created tags. Some tag categories that might connect to audience interests include location, era, and event. It should also be easy to understand the historical contexts of the collections, including difficult topics and subject matter.\n\n#### Make it Easy to Participate\n\nFrom entry into the site, volunteers should feel welcomed and within only a few steps of getting started with contributing. Clear instructions that are accessible from many different points that incorporate illustrative examples; perhaps even instruction or participation support in varying formats, such as video. Helpful tips about material culture and practice, such as handwriting or paleography resources may further help. Technical features that support accuracy and ease of contribution include annotation tools and editors. Providing a mechanism for volunteers to elect to receive timely feedback on their efforts and provide similar to others are just some community management approaches that can support accuracy and ease of contribution.\n\n#### Serving Completed Data in Multiple Formats\n\nText and tags that are created by volunteers as they participate will be used to improve the ways collections can be found and connected. These forms of data should also be made available as project level JSON, CSV, and XML files, as well as a full corpus of completed transcription text. Ideally, these forms or data would be presented within the site on a &quot;data&quot; or research page. It should also be made clear the ways in which visitors might use the loc.gov interface to download individual images; and the loc.gov JSON API to download images and data from digital collections.\n\n#### Responsibly Share Code\n\nThoroughly documenting and sharing the design decisions that inform the crafting of the codebase will ensure that other libraries, cultural heritage organizations, and educational bodies can best decide if this tool is appropriate for their needs and matches the skills and resources they have to apply to a web-application based participatory project. We should appropriately document and make available the source code for the tool, as well as other technical considerations and design decisions. Furthermore, lessons learned in the process of developing and improving the underlying tool, as well as the program(s) of engagement made possible by the affordances of the tool, should be shared openly as supporting documentation and stand-alone considerations.\n\n#### Foregrounding Collaboration\n\nThere are many ways to signal respect for the contributions of volunteers while making the experience of participating most rewarding. One way is to maximize the extent of collaboration by better integrating opportunities to &quot;positively compound&quot; participation within the process of transcription, tagging, and review. Most existing tools use a blend of asynchronous transcription and either algorithmic review (matching) or volunteer review. Showcasing interpretation and activity by other volunteers can help participants craft a shared, agreed upon, and quality version of transcribed text. Displaying tags as categorization in the form of arranged and connected knowledge allows participants to crosswalk understanding, opening possibilities of new discovery and connection to the project mission and one another.\n\n## **UNDERSTAND**\n\n### Analyze activity &amp; assess participant motivations to improve experiences\n\nIt will be imperative to responsibly gather, analyze, and share information about activity in the crowdsourcing platform. This information will be used to best communicate, improve upon existing capabilities, and extend future possibilities of the tool and program of engagement. This section discusses features and capabilities to support better experiences for all participants, from volunteers to staff.\n\n#### Privacy &amp; Ethical Use of Data\n\nIt is imperative that we responsibly and carefully define the need to collect data - whether that is about activity, location, or other forms of information - and connect that explicitly to intended uses of that data to understand audience needs and improve service. However, it should not be required or possible to track users through their visits. Furthermore, it should be possible for users to have their accounts deleted and account information purged from the site, while retaining their anonymized contributions.\n\n#### No Accounts Necessary... but also creating an Account is possible\n\nAs noted above, it should be as easy as possible for visitors to become volunteers. However, creating an account within the system will allow participants to recall their activity, customize their experience, and fulfill needs for reporting should their motivations relate to formal volunteering or school assignments. We should explain to volunteers that creating an account affords additional possibilities including estimating participation. Accounts would also allow volunteers to experience other benefits: invitations to webinars with curators, alerts about new or related collections, and notifications of completion of projects or subjects of interest perhaps based on tags and other self-selection.\n\n#### Understanding for Community Management\n\nFor the health and future of the crowdsourcing initiative, gathering information about the efforts and communication of volunteers imperative. Presenting a clear snapshot of activity, recent discussion, possible roadblocks, and upcoming campaigns or communication would aid community management through quick assessment of the ecosystem&#39;s state. Easy access to &quot;live data,&quot; rapidly gathering the pulse on high traffic projects or energetic discussion, and performance of ongoing campaigns are key needs for community managers. This scope of activity information would also be useful for community managers in discreetly and sparingly offering volunteers opportunities to re-engage with new collections.\n\n#### Motivations meet Behavior\n\nWe can help identify needed capabilities or features by surfacing the reasons people wish to participate and mapping patterns of activity in the system to these goals. The experience of participating in this project will be dynamic. Our visitors will not cleanly map onto single personas because their reasons for participating may change from visit to visit and over time. Common motivations include pursuing personal learning objectives, contributing to something greater and access to open knowledge, and to fulfill course or volunteer requirements. In 2014, researchers and project managers from the Zooniverse described the benefits of designing digital citizen science projects for participants with limited time and commitment; Eveleigh et al termed this approach &quot;designing for dabblers.&quot; Crafting workflows that support bite-sized or small targeted tasks can meet the needs of this type of volunteer who may fit in participation in the crowdsourcing platform amid their other interests and activities. Other volunteers may seek more immersive experiences. Furthermore, students and other volunteers may have participation targets to meet during their time on site.\n\n#### Enabling Immersion\n\nFrom user interfaces to tracks, there are opportunities to design a site that enables immersive, deep engagement with stories in the collections, and to find flow in serialized tasks; yet also makes it possible to step out of the workflow quickly and with confidence that one&#39;s contributions will be retained and valued. Success may not always mean pace of transcription but rather the attainment of a visitor&#39;s goals, whether to learn, fulfill volunteering hours, or make a meaningful contribution to the Library of Congress.\n\n#### Volunteers with accounts should be able to understand their own activity\n\nParticipants in the crowdsourcing program who have created accounts should be able to quickly access, understand, filter or scope, print or download summaries of their activity. This information should be presented to them in a visually dynamic and customizable (or filtered) manner. It should also represent their cumulative activity as well as recent tasks, allow them to pick up where they left off, share or engage others with their own contributions, and perhaps suggest related content via tags or campaigns.\n\n#### Ability to gather feedback from participants about their needs\n\nResponses, questions, feedback, and other means of communication between participants and community managers will be essential to the health of the project. Beyond a feedback button or comment form, community managers will want to be able to assess participation activity data as feedback on complexities of the collections, spot barriers and drop offs, identify stickiness, and sight opportunities in content and behavior.\n\n#### Tell a Public Story by Displaying Activity\n\nPresenting information about system-wide activity, as well as individual activity, creates opportunities for shared understanding of the progress toward collective goals, as well as individualized approaches. This information can also be presented on an About or Homepage to quickly convey a sense of participation--via dynamic and perhaps interactive charts, torque maps, timelines, and more--and encourage visitors to join the activity.\n\n#### Reporting for Staff and Organization\n\nBeing able to provide reporting that integrates with or corresponds to staff workflows will help this project become more closely connected to regular activity in the organization. Information gathered about project performance might include the number of pages per collection, the number of volunteers that contributed, the number of views and engagement with the object in loc.gov/Project One, and details about visits including generalized time on site and return visits that engage with collections they&#39;ve shared. It should be possible to query this data for custom reports, and include filters or other bounding options for specific time frames. Careful consideration should be taken around visitor information; never achieving tracking of users, and at all times generalized and disconnected from any cross-walkable search.\n\n## **CONNECT**\n\n### Manage &amp; Match Collections to Tasks to Data and more\n\nThese activities focus on engaging audiences with Library of Congress collections. Simultaneously, we strive to design a series of tasks and support that result in data that may be applied to improve access to those Library collections. This section recommends tasks, design decisions, and processes to achieve these goals.\n\n#### Tasks: Transcription AND Tagging\n\nOf a range of possible tasks, we&#39;ve identified transcription and tagging to be most relevant to our goals to improve search and identification within Library of Congress collections. These two tasks can be mapped to the digital content lifecycle in the description phase. These tasks are envisioned as a means of creating asset or page level text that can enhance discovery of and access to the Library of Congress digital collections.\n\n#### Transcription\n\nBased on the goals of engaging audiences and creating useful text for discovery, legibility, and access, the recommended transcription process is one that facilitates asynchronous but rapid access transcription; asks for minimal interaction with the collection asset image; situates the transcription window adjacent to the collection asset image; offers an adjustable and immersive presentation, if desired; &quot;positively compounds&quot; the efforts of participants; honors the time and contributions of participants; visually supports identification of possible errors or areas needing additional work; and connects to the context of the object in focus, whether smooth transition between pages before and after or to the catalog information and project description.\n\n#### Tagging\n\nThe goals of tagging are to classify or categorize the content in such a way as to make it discoverable for future use. It is recommended that the tagging feature be built to accommodate set(s) of collection level and platform-wide controlled vocabularies, as well as to accept crowd-generated tags. The former set of tags would allow the platform to leverage existing subject headings, known and popular loc.gov search terms, and other forms of metadata already associated with the collections. The latter tagging capability would allow volunteers to customize their experiences with collections once these tags were made available to search or surface content across the platform; it would also allow community managers to organize campaigns at the point of import and feature this content both on the homepage and highlighted throughout the experience of transcription and tagging. Finally, a visual coding of the tags would offer an opportunity for a subtle contextualization of staff and Library of Congress generated tags and those created by participants.\n\n#### Task Ecosystem: Tasks in the Workflow\n\nProjects and pilots including [Beyond Words](https://labs.loc.gov/experiments/beyond-words/) have demonstrated that the transcription and tagging tasks may successful be presented together in the crowdsourced workflow; however it should be possible for participants to elect to only complete one task, if they prefer. It is also possible that these tasks might be best presented in separate interfaces or as distinct workflows. The tasks should also be introduced to participants by describing the goals of the information that is being created, examples of how and where it will be discoverable after volunteers have contributed. Specifically, care should be taken to communicate the ways that the information created will be used—whether for search, for research, for publication and display, or to improve the features of the system—as these details will shape the ways the tasks are undertaken by volunteers.\n\n#### Reaching Agreement\n\nAgreement around the final version of transcription text will be a process that should be achieved through displaying the work of volunteers and facilitating a process in which they can negotiate to reach consensus of the completeness and quality of the text. The collections we will ask the public to transcribe will be of varying format, even changing in format from asset image to asset image. Therefore, there are likely to many points at which interpretation will occur. As a result, workflows that allow participants to work together and create a shared understanding of the asset image (at object/collection level) are most likely to be successful when the opportunity to exchange interpretations are presented throughout the transcription and tagging processes. Features in support of negotiated consensus include an ability to see, mark, report, and/or correct errors in the text; to discuss an asset image or page in a forum; and generalized discussion at the collection level. Another way to permit negotiated consensus is to allow for a peer-review workflow.\n\n#### Complete the Cycle\n\nIt is imperative that the crowd-generated transcription text be returned and served with the [loc.gov](https://loc.gov) presentation of the object and at the asset image level. It may be possible to marry this information as metadata or perhaps as data supporting the collection or object. This urgency and responsibility in completing the cycle connects to a user-centered design; building trust with public participants by meeting motivations that relate to contributing to greater access to knowledge, while honoring their time and contribution.\n\n#### Connect Activity to Collections\n\nAt all times it should be possible to navigate from the crowdsourcing project, campaign, or transcription and tagging page to the source object. In the volunteer&#39;s account view, it should be possible to navigate from the project to which they contributed to the record of that collection in [loc.gov](https://loc.gov). Furthermore, there are opportunities to connect volunteers to the [Ask a Librarian](https://www.loc.gov/rr/askalib/) service from the crowdsourcing platform, such as in the discussion section, the project and collection pages, the homepage, and even in the footer.\n\n#### Managing Projects\n\nThese key features and approaches should be available for the long-term sustainability of the project:\n\n-   Continuity with existing staff workflows, including work within CTS (content transfer system)\n-   A pipeline for the queue of projects, as well as a monthly mechanism for proposing, identifying, and planning new collections for transcription including a recurring forum for nominating projects - perhaps even a sandbox or test space in which details of nominated collections may be stored or prepared\n-   Collections management in bulk to support creating an extensive queue; storing offline in advance of campaign, collection, or other programming needs\n-   Enabling Community Managers to post, launch and queue projects and campaigns via an administrative interface. Over time, this functionality can be expanded with a permissions-based self-service mechanism for curators and collections staff to identify, flag, or queue projects for transcription. This will likely require coherence with Library of Congress web services, Design &amp; Development, as well as the CTS workflow\n\n#### Applying our APIs\n\nAs with other features recommended here, it would be best to seize and map to existing workflows and technologies. A goal for the platform should be to identify and deliver collections via [loc.gov](https://loc.gov) API to become transcription projects. Furthermore, allowing the transcription results to be queried via the [loc.gov JSON API](https://libraryofcongress.github.io/data-exploration/) once associated with the source images or record would be valuable.\n\n#### Transparent Development\n\nThe greatest opportunity exists to develop this tool and platform in the open, as open source with appropriate licensing. Documenting development in a repository like GitHub is advisable.\n\n#### Identify, Articulate, Acknowledge Content Created by Volunteers\n\nIdentify content created by participants within loc.gov as volunteer-created. Allow volunteers with an account to retain a pointer to the work they created, with a persistent URL or URI. Actively acknowledge the contributions of volunteers in publications, presentations, social media, and communications outreach. License for content created in the transcription and tagging workflow should be public domain or CC0; it should also be explicitly stated and incorporated as metadata when presented as a dataset and in loc.gov. These contexts should be clearly communicated to volunteers contributing the content as well as researchers engaging with the content.\n\n#### Data Available for Download\n\nTo compliment research and exploration at the object and collection level, providing transcription and tagging results as a bulk data set would allow researchers in a range of disciplines to explore patterns and connections across collections. The transcription text and tags should be licensed as openly as possible and volunteers should be kept informed of the ways their efforts support discovery in the Library of Congress systems, as well as the role their work plays in other scholarly inquiry.\n\n#### Navigating options to get started\n\nVisitors and volunteers should be able to navigate the available collections in several ways. For example, presenting available projects as a list or set of tiles and incorporating filtering and sorting capability would allow volunteers to swiftly make sense of the active opportunities. Incorporating existing metadata offers other ways to orient volunteers to opportunities. For example, offering selection based on the object time period or era, location metadata, or subject heading. Finally, as participation increases, it may be possible to estimate time to complete a project based on participation data. This approach serves the needs of visitors who wish to dedicate a specific amount of time to their visit to the project.\n\n### **GROW**\n\n### Sustaining and Improving through Workflows and Spaces of Participation\n\nOnce the system has been designed, developed, and launched and once visitors become volunteers, there are endless opportunities to improve and sustain the platform and the program based on the ways people use it, their needs and challenges, and information gathered in the process of participation.\n\n#### Show the Work!\n\nDisplaying the work of others in the system allows participants, whether new or seasoned, to more quickly understand that others are contributing to shared goals. The visibility of content &amp; contribution also signals active collaboration. Furthermore, visual indications of collaboration, whether intended or even in conflict, can be reflected in an interface that shows the efforts of editing. Allowing content to be editable, then marking out those edits—with font, size or other indicator—allows individuals to better understand who and what has come before them on the page.\n\n#### Create an Atmosphere\n\nCreating a shared understanding of expectations around behavior and communication can help shape expectations of respect and civility in this space. Inviting participants to acknowledge and commit to a Code of Conduct provides them the opportunity to reflect on how they will engage with others, as well as offers them support for framing communication they receive. Examples of public community code of conduct include the Coral Project and Airbnb; the former articulates acceptable and unacceptable behavior and expectations, while the latter asks customers (guests) to sign a code of conduct pledge as part of the booking process.\n\n#### Clear Examples\n\nAs participants create transcription and tagged content and more collections are added, plenty of examples to best represent what is needed when encountering decision-making moments will emerge. In the interim, creating clear and annotated examples may be useful to those who are just getting started with transcription and tagging.\n\n#### Collate and Connect Extraneous Knowledge\n\nAs volunteers participate in the crowdsourcing initiative, they will acquire, recall, and perhaps seek to share information and knowledge with others; perhaps to be helpful to other volunteers and, at other times, as an expression of their interest and engagement with Library of Congress collections. They may wish to share examples and further non-Library of Congress resources. A discussion board or forum can help volunteers achieve their motivations of learning, contributing to wider knowledge (a greater good), and to build community or a sense of shared purpose through discussion. It may also be useful to create an asset level discussion for specific or nuanced questions about the asset image or page that is receiving transcription and tagging activity. Impressive examples of discussion spaces in crowdsourcing projects include the Zooniverse talk pages and the Discourse implementation in use by In the Spotlight at the British Library.\n\n#### Sustain Interest and Increase the Capability of Participants\n\nAs described above, visitors may have many different motivations when they first arrive at the crowdsourcing platform; they may also have different motivations each time they arrive on site. Their movement to becoming volunteers may be catalyzed by offering a range of tasks, heterogeneous collections, and oscillating levels of complexity. Furthermore, the system may be built with tracks that prompt a participant to continue in the next step, or asks them to contribute to a new task. It is also possible to blend these approaches and particularly helpful if these are cyclical or distributed opportunities - either naturally based on variety in the collections or designed by community management approaches. Furthermore, offering opportunities to problem-solve, support other volunteers in other roles, level up in transcription and tagging tasks, or self-select for more complex (or pilot) tasks can create a dynamic experience that is rewarding to volunteers and the program ecosystem.\n\n#### Build Knowledge for Outcomes\n\nIt is recommended that Community Managers and staff with collections expertise work together closely to build programming and engagement in relation to the transcription and tagging tasks. This programming could take the shape of essays, webinars, edited video content, chats hosted in discussion pages or social networks, and in-person events. In these activities, persistent URLs for the page, collection, and campaign (or collection of tags) would be required. In the previous section, recommendations were shared for connecting activity to collections. It is also possible to connect collections to catalyze activity; for example, a prompt or pathway from the collection or object Project One page to the crowdsourcing platform.\n\n#### Practice\n\nOne way to encourage activity is to offer a low risk point of entry in which a volunteer might practice and keep notes, such as in a sandbox. This space may also make it possible for a volunteer to elect to receive feedback that can increase their confidence, coherence and consistency in transcription and tagging, and connection between their goals and those of the program. Another means of training could be a practice page, potentially offered after a volunteer has made a few contributions or after they have created an account.\n\n#### Piloting\n\nThe Library of Congress Labs team is well-situated to partner with staff in the Office of the Chief Information Officer and Library Services to continue to run crowdsourcing experiments focused on tasks, cataloging and metadata, and machine learning. Examples include working with Optical Character Recognition (OCR) and Handwritten Text Recognition (HTR) outputs to assess quality of transcription &amp; assistance with tagging; parsing task workflows; repeated passes on the same material for thematic, semantic, or other tagging; named entity recognition and more. Furthermore, there are opportunities to integrate other collections and materials into a crowdsourced workflow including audio-visual and time-based media objects. Creating an extensible tool or system into which features that emerge from experiments could be added would support the evolution of the project, as well as create opportunities for volunteers to grow their skills and knowledge.\n"
  },
  {
    "path": "docs/for-developers.md",
    "content": "# For Developers\n\n## Prerequisites\n\nThis application can run on a single Docker host using docker-compose.\n(recommended for development environments). See the [development Readme](../development/README.md) for more information. Note that the instructions below assume you'll be developing on your host rather than in a container. The development Readme provides instructions on performing development in a purely containerized environment, without installing any dependencies (outside of git and your container tool of choice) on the host.\n\nFor production, see the\n[cloudformation](https://github.com/LibraryOfCongress/concordia/tree/master/cloudformation) directory for AWS Elastic Container Service\nstack templates.\n\n## Running Concordia\n\n### Docker Compose\n\n```bash\ngit clone https://github.com/LibraryOfCongress/concordia.git\n```\n\nIf you're intending to edit static resources, templates, etc. and would like to\nenable Django's DEBUG mode ensure that your environment has `DEBUG=true` set\nbefore you run `docker-compose up` for the `app` container. The easiest way to\ndo this permanently is to add it to the `.env` file:\n\n```bash\necho DEBUG=true >> .env\n```\n\n##### Install the application virtual environment\n\nThese steps only need to be performed the first time you setup a fresh\nvirtualenv environment:\n\n1. Ensure that you have the necessary C library dependencies available:\n\n    - `libmemcached`\n    - `postgresql`\n    - `node` & `npm` for the front-end tools\n\n1. Ensure that you have Python 3.8 or later installed\n\n1. Install [pipenv](https://docs.pipenv.org/) either using a tool like\n   [Homebrew](https://brew.sh) (`brew install pipenv`) or using `pip`:\n\n    ```bash\n    pip3 install pipenv\n    ```\n\n1. If you encounter errors installing psycopg, you may need to set LDFLAGS in your environment variables.\n\n1. Let Pipenv create the virtual environment and install all of the packages,\n   including our developer tools:\n\n    ```bash\n    pipenv install --dev\n    ```\n\n    n.b. if `libmemcached` is installed using Homebrew you will need to [set the CFLAGS long enough to build it](https://stackoverflow.com/questions/14803310/error-when-install-pylibmc-using-pip#comment94853072_19432949):\n\n    ```bash\n    CFLAGS=$(pkg-config --cflags libmemcached) LDFLAGS=$(pkg-config --libs libmemcached) pipenv install --dev\n    ```\n\n    Once it has been installed you will not need to repeat this process unless\n    you upgrade the version of libmemcached or Python installed on your system.\n\n1. Configure the Django settings module in the `.env` file which Pipenv will use\n   to automatically populate the environment for every command it runs:\n\n    ```bash\n    echo DJANGO_SETTINGS_MODULE=\"concordia.settings_dev\" >> .env\n    ```\n\n    You can use this to set any other values you want to customize, such as\n    `POSTGRESQL_PW` or `POSTGRESQL_HOST`.\n\n    n.b to allow a local server to connect to the dockerized db set `POSTGRESQL_PORT=54323` - the db containers external postgres port.\n\n1. Make sure that [redis](https://redis.io/docs/getting-started/) is installed and\n   running.\n\n1. Configure Turnstile in your `.env` file. Unless specifically testing Turnstile,\n   you'll probably want the following settings:\n\n    ```bash\n    echo TURNSTILE_SITEKEY=1x00000000000000000000BB >> .env\n    echo TURNSTILE_SECRET=1x0000000000000000000000000000000AA >> .env\n    ```\n\n    Those two settings ensure all Turnstile tests pass. See [Turnstile Testing](https://developers.cloudflare.com/turnstile/troubleshooting/testing/) for other options.\n\n### Local Development Environment\n\nYou will likely want to run the Django development server on your localhost\ninstead of within a Docker container if you are working on the backend. This is\nbest done using the same `pipenv`-based toolchain as the Docker deployments:\n\n#### Python Dependencies\n\nPython dependencies and virtual environment creation are handled by\n[pipenv](https://docs.pipenv.org/).\n\nIf you want to add a new Python package requirement to the application\nenvironment, it must be added to the Pipfile and the Pipfile.lock file.\nThis can be done with the command:\n\n```bash\npipenv install <package>\n```\n\nIf the dependency you are installing is only of use for developers, mark it as\nsuch using `--dev` so it will not be deployed to servers — for example:\n\n```bash\npipenv install --dev django-debug-toolbar\n```\n\nBoth the `Pipfile` and the `Pipfile.lock` files must be committed to the source\ncode repository any time you change them to ensure that all testing uses the\nsame package versions which you used during development.\n\n#### Launching the environnment\n\nIn order to successfully launch the environment, the environment variables\n`POSTGRESQL_PW` and `DJANGO_SETTINGS_MODULE` must be set. `POSTGRESQL_PW`\nmay be set to any value (which will become the database password for the\nenvironment), but `DJANGO_SETTINGS_MODULE` should be set to\n`concordia.settings_dev` to use the development settings file.\n\n```bash\nexport POSTGRESQL_PW=password\nexport DJANGO_SETTINGS_MODULE=concordia.settings_dev\n```\n\n```bash\ncd concordia\ndocker-compose up\n```\n\nBrowse to [localhost](http://localhost)\n\n#### Setting up a local development server\n\n##### See section - [Ensuring your work follows the Library's coding standards](https://github.com/LibraryOfCongress/concordia/blob/master/docs/how-we-work.md#ensuring-your-work-follows-the-librarys-coding-standards) in How We Work\n\n##### Start the support services\n\nInstead of doing `docker-compose up` as above, instead start everything except the app:\n\n```bash\ndocker-compose up -d db redis importer celerybeat\n```\n\nThis will run the database in a container to ensure that it always matches the\nexpected version and configuration. If you want to reset the database, simply\ndelete the local container so it will be rebuilt the next time you run\n`docker-compose up`: `docker-compose rm --stop db`.\n\n##### Install front end\n\n1. Install Node 20. If you're on MacOS, you can install it using brew:\n\n    ```bash\n    brew install node@12\n    ```\n\n1. Use NPM to install our development tools:\n\n    ```bash\n    npm install\n    ```\n\n1. In another terminal, start Vite to watch for changes to the SCSS files and\n   compile them to CSS, and changes in the bundled, hased and compressed js files:\n\n    ```bash\n    npx vite\n    ```\n\n    If you only want to build, bundle, and compress them a single time without live updates:\n\n    ```bash\n    npx vite build\n    ```\n\n1) You may need to manually create a logs directory.\n\n    ```bash\n    mkdir logs\n    ```\n\n1) Collect Django static files:\n\n    ```bash\n    pipenv run ./manage.py collectstatic --no-post-process\n    ```\n\n##### Start the application server\n\n1. Apply any database migrations:\n\n    ```bash\n    pipenv run ./manage.py migrate\n    ```\n\n1. Start the development server:\n\n    ```bash\n    pipenv run ./manage.py runserver\n    ```\n\n#### Running the unit tests\n\nUse the `settings_local_test` Django settings in your environment. Your `.env` file should look something like:\n\n```bash\nPOSTGRESQL_PW=password\nDJANGO_SETTINGS_MODULE=concordia.settings_local_test\n```\n\nBring up the docker database and redis servers:\n\n```bash\ndocker-compose up -d db redis\n```\n\nThen execute the tests:\n\n```bash\npipenv run ./manage.py test\n```\n\n#### Import Data\n\nOnce the database, redis service, importer and the application\nare running, you're ready to import data.\nFirst, [create a Django admin user](https://docs.djangoproject.com/en/2.1/intro/tutorial02/#creating-an-admin-user)\nand log in as that user.\nThen, go to the Admin area (under Account) and click \"Bulk Import Items\".\nUpload a spreadsheet populated according to the instructions. Once all the import\njobs are complete, publish the Campaigns, Projects, Items and Assets that you\nwish to make available.\n\n#### Data Model Graph\n\nTo generate a model graph, make sure that you have [GraphViz](https://graphviz.org/doc/info/command.html) installed (e.g.\n`brew install graphviz` or `apt-get install graphviz`) and use the\n[django-extensions `graph_models`](https://django-extensions.readthedocs.io/en/latest/graph_models.html) command:\n\n```bash\ndot -Tsvg <(pipenv run ./manage.py graph_models concordia importer) -o concordia.svg\n```\n\n## Other Front-End Tools\n\n### Public-facing URLs\n\n1. If you need a list of public-facing URLs for testing, there's a management\n   command which may be helpful:\n\n    ```bash\n    pipenv run ./manage.py print_frontend_test_urls\n    ```\n\n### Accessibility testing using aXe\n\nAutomated tools such as [aXe](https://www.deque.com/axe/) are useful for\ncatching low-hanging fruit and regressions. You run aXe against a development\nserver by giving it one or more URLs:\n\n```bash\nnpx axe-cli --show-errors http://localhost:8000/\npipenv run ./manage.py print_frontend_test_urls | xargs npx axe-cli --show-errors\n```\n\n### Static Image Compression\n\nWhen you update any of the files under `concordia/static/img`, please use an\noptimizer such as [ImageOptim](https://imageoptim.com) or [Caesium](https://caesium.app/)\nto **losslessly** compress JPEG, PNG, SVG, etc. files.\n\n```bash\nbrew cask install imageoptim\nopen -a ImageOptim concordia/static/img/\n```\n"
  },
  {
    "path": "docs/how-we-work.md",
    "content": "# How We Work\n\n## Principles\n\nOur basic principles are:\n\n-   We produce open source software, shared in repositories where it may be inspected by the public who places their trust in it and copied for use by other agencies or institutions.\n-   We adhere to the basic practices of agile software development, using the Scrum development framework.\n-   We practice human-centered design. Everything that we produce is highly accessible, per [WCAG 2.1](https://www.w3.org/TR/WCAG21/).\n-   Finally, we believe in having the relevant decision-makers at the table during all meetings, to maximize efficiency and maintain momentum.\n\n## Product Team\n\nThis is a cross functional product team for Concordia made up of members across the Library who are working together. This product team will be comprised of the following roles:\n\n-   Product owner\n-   Product manager (Scrum master)\n-   Technical lead\n-   User Experience designer\n-   Developers (Front-end, Back-end, Full-stack)\n-   QA Tester\n-   Community Managers (content writers, administrators)\n\nThis team participates in stand ups, product alignment, backlog grooming and retrospectives in service of prioritizing, defining and delivering value to the department and the public it serves.\n\n## Sprint Organization and Meetings\n\nEach sprint is three weeks long. We have a sprint kick off the first day of the new sprint. There are five basic meeting rhythms:\n\n-   **Backlog grooming and Sprint Planning, every 2 weeks**\n    -   Structure: tickets in the backlog are sorted by priority, the team adds acceptance criteria, estimates size, and assigns the tasks to a team member.\n-   **Demo and retrospectives, every 2 weeks**\n    -   At the end of each sprint, Developers or content writers demo completed work in the sprint for the larger library stakeholders. During demo, we will confirm if the user acceptance criteria is met and moved to be tested. Following the demo, the team will go through a retrospective. These are held back-to-back, on the same day\n\n## Definition of Done\n\nSo that we can work more efficiently and be confident in the quality of the work we are delivering, we have a clear definition of what it means for a user story to be done, or production-ready.\n\n**For delivering a user story to the product team:**\n\n-   Story needs to be written in a way that is clear from both a development and a testing standpoint. Stories will need to be reviewed by the product team during creation.\n-   Acceptance criteria should include the relevant tests needed (unit, security, performance, acceptance, etc)\n-   Acceptance criteria should include the objective of the story, for use in approval by PO or tech team or both\n-   The delivered functionality should match the acceptance criteria of the user story\n\n**for product team to accept the user story and ship it:**\n\n-   The functionality meets the acceptance criteria\n-   The product team has verified the functionality in staging\n-   All tests must pass in the the stage environment (unit, integration, feature)\n-   The delivered functionality should be 508 compliant\n-   Security requirements must be met\n-   All documentation must be up to date (diagrams, training documentation, API documentation, help text, etc)\n-   The delivered functionality should be compatible with the latest versions of Firefox, Chrome and Safari\n\n## Processes\n\n### Testing Strategy\n\nWe practice testing at three levels: unit tests, integration tests, and feature tests. For details about how we create and maintain unit, integration and feature tests.\n\n-   Unit - Unit tests must be created for all new code, during the sprint in which the code is written, with coverage of at least 90%.\n-   Integration - Code must include tests that verify that interfaces are functioning as designed.\n-   Feature - New features must have functional definitions of the thing that they are to perform, and a description of human-performable actions to verify that they perform that thing.\n\n#### Testing new code\n\nEach ticket will include acceptance tests, especially for new user facing functionality. When the developer signals that the ticket is ready for test, they will move it to the `Needs test` column and pings the tester in the comment of the ticket.\n\nTesters will identify issues - HIGH, LOW, or NONE. Here are the criteria for each of the levels:\n\n-   FAIL: Does not meet the acceptace criteria and can not complete the acceptance test.\n-   PASS: Does meet both acceptace criteria and and acceptance tests. When a ticket passes but there are noticable opportunities for improvements or enhancements, close the ticket and create a new ticket and add to the backlog.\n\nIf all user acceptance criteria has been met, the ticket will be closed and moved to Done column.\n\nFinal step: Technical lead will create a release with all tickets that are done.\n\n**How to provide feedback**\n\nIf feature testers find issues to address:\n\n-   FAIL: comment and @ the developer in the feature ticket\n-   Enhancements or Improvements: open a new FEATURE ticket. This ticket will be added to the backlog and up for priortization in alignment meeting and backlog grooming. Link to related ticket by adding the issue #.\n\nIf a FAIL issue needs to be addressed, tester should expect to be available to respond to developers questions and retest until acceptance criteria is met.\n\n### Ticket Movement in a Sprint\n\n**For a new feature ticket:**\n\n1. Ticket is generated as an issue and placed in the backlog\n2. Product owner will place priority tickets in a sprint\n3. Product Manager will ensure all acceptance criteria has been articulated and assign developer\n4. Developer will move to In Progress when ticket is being worked on\n5. When Developer has completed initial code will move code into crowd-test.loc.gov\n6. Developer moves ticket into test assigned Product Owner to review feature needs\n7. Product Owner test the feature and test if Acceptance Criteria is met, provides feedback to devs if needed\n8. Product Owner approves feature then assigns to QA\n9. QA will affirm AC and test broader functionality and accessibility. Will pass/fail ticket. Outcome of testing will be written in comments.\n    - If pass, QA will close ticket and move to Done.\n    - If fail, QA will move ticket back into In Progress and assigned back to developer\n10. If additional issues are found in testing that are not related to the feature of functionality, new tickets will be written by QA and added to the backlog. Ticket will be assigned to PO for further ticket development and grooming.\n\n**For system wide upgrades**\n\n1. Ticket is generated as an issue and placed in the backlog in GitHub\n2. Technical Lead will place priority tickets in a sprint\n3. Product Manager will ensure all acceptance criteria has been articualted and assign developer\n4. Developer will move to In Progress when ticket is being worked on\n5. Unit testing incorporated into the code pipeline\n    - Manually run unit tests\n    - CI/CD integration\n6. Move ticket into test and assign peer review testing by developer\n    - If fail, developer provides feedback, moves ticket back to In Progress and assigns to the original dev\n    - If pass, assign to QA for regression testing\n7. Tester will copy the [Regression Testing Checklist](https://staff.loc.gov/wikis/x/UomCBQ) as a comment in the ticket\n8. Tester will go through all the functionality described in the checklist\n    - If fail, provides feedback in the comments, moves ticket back to In Progress and assigns to dev\n    - If pass, checks boxes to show that all main functionality are working and moves ticket to done\n\n### Branch strategy and Pull Request Process\n\n#### Git branching strategy\n\nWe have two long-lived git branches, `master` and `release`.\n\nThe `master` branch continuously deploys to our development environment.\n\nThe `release` branch continuously deploys to our staging environment.\nOur development and staging environments are on AWS and only accessible through the Library's network.\n\n##### Starting new work\n\nWhen someone begins new work, they cut a new branch from `master` and name it after their work, perhaps `feature1`. New changes are pushed to the feature branch origin.\n\n##### Merging to `master`\n\nWhen new work is complete, we set up a Pull Request (PR) from `feature1` to `master`. Discussion about, and approval of changes by either the Technical Lead, Product Owner or both happens in the PR interface in GitHub.\n\nOnce this new work is approved we merge the code, which closes the PR.\nFrom here, our CI pipeline will build the new changes on the `master` branch. Next, our CD pipeline will deploy the new work to our development environment.\n\n##### Merging to `release`\n\nOnce the development work on a sprint is completed, we set up a PR from `master` to `release`.\n\nThis constitutes a new release candidate. Any last-minute discussion, as well as approval happens in the PR interface. Once approved by the Technical Lead, Product Owner or both and merged, CI runs for `release` branch to the staging environment.\n\n##### Tagging and deploying to production\n\nWhen the `release` branch has been fully tested in the staging environment, we create a GitHub release with a tag on the `release` branch.\n\nEither trigger a Jenkins build manually or wait for continuous integration for the `release` branch to kick in. This will build a cleanly tagged versioned release candidate and upload the docker images to Amazon Elastic Container Registry.\n\nTo deploy to production, create a new task revision for the `concordia-prod` task which updates the version numbers of the docker containers to match the recently built cleanly tagged release candidate. Update the production service to use the new task definition revision. Monitor the health check endpoint to ensure the service is updated to the proper version.\n\n##### Patching production mid-sprint\n\nIf a problem is identified in production that needs a quick fix, we code the fix to production in a new branch cut from `release`, maybe called `prod_fix`. We set up a PR against `release` for review and discussion.\n\nAny QA or manual testing will take place in the staging environment deployed from the `release` branch. Once the release is tagged and deployed to production, we have to bring those new changes in release back into master. We use rebase again: `git rebase master release`.\n\n### Code quality and review process\n\nCode reviews are about keeping code clean and limiting technical debt. We will look for things that increase technical debt or create an environment where technical debt can be introduced easily later. Each pull request will be reviewed by the technical lead or assigned reviewer. As a reviewer, they will look closely for untested code, if there are tests that they are testing what they're supposed to, that they are following the Library's code standards.\n\n### Ensuring your work follows the Library's coding standards\n\nThe project extends the standard Django settings model for project configuration and the Django test framework for unit tests.\n\n#### Configuring your virtual env\n\nThe easiest way to install the site is using [Pipenv](https://pipenv.readthedocs.io/) to manage the virtual environment and install dependencies.\n\n#### Configure your local checkout with code-quality hooks\n\n1.  Install [pre-commit](https://pre-commit.com/)\n1.  Run `pre-commit install`\n\nNow every time you make a commit in Git the various tools listed in the next\nsection will automatically run and report any problems.\n\nn.b. Each time you check out a new copy of this Git repository, run `pre-commit install`.\n\n#### Configure your editor with helpful tools:\n\n[setup.cfg](https://github.com/LibraryOfCongress/concordia/blob/master/setup.cfg) contains configuration for [pycodestyle](https://pypi.org/project/pycodestyle/), [isort](https://pypi.org/project/isort/) and [flake8](https://pypi.org/project/flake8/).\n\nConfigure your editor to run black and isort on each file at save time.\n\n1.  Install [black](https://pypi.org/project/black/) and integrate it with your editor of choice.\n2.  Run [flake8](http://flake8.pycqa.org/en/latest/) to ensure you don't increase the warning count or introduce errors with your commits.\n3.  This project uses [EditorConfig](https://editorconfig.org) for code consistency.\n\nIf you can't modify your editor, here is how to run the code quality\ntools manually:\n\n```\n    $ black .\n    $ isort --recursive\n```\n\nBlack should be run prior to isort. It's recommended to commit your code\nbefore running black, after running black, and after running isort so\nthe changes from each step are visible.\n\n## Tools we use\n\n-   GitHub - We use our GitHub organization for storing both software and collaboratively-maintained text.\n-   Slack - We use the Slack for communication that falls outside of the structure of Jira or GitHub, but that doesn’t rise to the level of email, or for communication that it’s helpful for everybody else to be able to observe.\n-   WebEx - We use WebEx for video conferencing in all our meetings\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/bash\n\nset -e -u # Exit immediately for unhandled errors or undefined variables\n\nmkdir -p /app/logs\ntouch /app/logs/concordia.log\n\necho \"Running makemigrations\"\n./manage.py makemigrations --merge --noinput\n\necho \"Running migrations\"\n./manage.py migrate\n\necho \"Ensuring our base configuration is present in the database\"\n./manage.py ensure_initial_site_configuration\n\nif [ -v SENTRY_BACKEND_DSN ]; then\n    echo \"Testing Sentry configuration\"\n    echo \"from sentry_sdk import capture_message;capture_message('This is a test event');\" | ./manage.py shell\nfi\n\necho \"Running Django ASGI server\"\ndaphne -b 0.0.0.0 -p 80 concordia.asgi:application\n"
  },
  {
    "path": "exporter/__init__.py",
    "content": ""
  },
  {
    "path": "exporter/admin.py",
    "content": "# Register your models here.\n"
  },
  {
    "path": "exporter/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass ExporterConfig(AppConfig):\n    name = \"exporter\"\n"
  },
  {
    "path": "exporter/exceptions.py",
    "content": "from typing import List, Tuple\n\n\nclass UnacceptableCharacterError(ValueError):\n    \"\"\"\n    Raised when unacceptable characters are discovered in text to be exported.\n\n    Each violation is stored so that callers can inspect which line / column held\n    the character.\n\n    Args:\n        violations: A list of `(line, column, character)` triples representing\n            every disallowed character found.  Line and column numbers are both\n            **1-based** so they can be reported directly to users.\n    \"\"\"\n\n    def __init__(self, violations: List[Tuple[int, int, str]]):\n        self.violations: List[Tuple[int, int, str]] = violations\n        details = \", \".join(\n            f\"line {ln} col {col} -> {ch!r}\" for ln, col, ch in violations\n        )\n        super().__init__(f\"Unacceptable characters found: {details}\")\n"
  },
  {
    "path": "exporter/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "exporter/models.py",
    "content": "# Create your models here.\n"
  },
  {
    "path": "exporter/tabular_export/admin.py",
    "content": "# encoding: utf-8\n\"\"\"\nHelpers for exporting Django admin querysets as Excel or CSV files.\n\nUsage in a ModelAdmin:\n\n    actions = (export_to_excel_action, export_to_csv_action)\n\nThese actions take the current queryset and export it using the same field\nselection you would get from `values()` by default. The download filename is\nderived from the `ModelAdmin.model._meta.verbose_name_plural` unless a custom\nfilename is passed.\n\nThese helpers are adapted from the original django-tabular-export implementation:\nhttps://github.com/LibraryOfCongress/django-tabular-export/blob/master/tabular_export/admin.py\n\"\"\"\n\nfrom functools import wraps\nfrom typing import Any, Callable, Iterable\n\nfrom django.contrib.admin import ModelAdmin\nfrom django.db.models import QuerySet\nfrom django.http import HttpRequest, HttpResponse\nfrom django.utils.encoding import force_str as force_text\nfrom django.utils.translation import gettext_lazy as _\n\nfrom .core import (\n    export_to_csv_response,\n    export_to_excel_response,\n    flatten_queryset,\n)\n\n\ndef ensure_filename(suffix: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:\n    \"\"\"\n    Decorator factory to ensure a default filename for export admin actions.\n\n    If the wrapped action is called with ``filename=None``, the filename is\n    built from ``modeladmin.model._meta.verbose_name_plural`` plus the given\n    suffix.\n\n    Args:\n        suffix (str): File extension to append (for example, ``\"csv\"`` or\n            ``\"xlsx\"``).\n\n    Returns:\n        Callable[[Callable[..., Any]], Callable[..., Any]]: A decorator that\n        wraps an admin action and injects a default filename when needed.\n    \"\"\"\n\n    def outer(f: Callable[..., Any]) -> Callable[..., Any]:\n        @wraps(f)\n        def inner(\n            modeladmin: ModelAdmin,\n            request: HttpRequest,\n            queryset: QuerySet[Any],\n            filename: str | None = None,\n            *args: Any,\n            **kwargs: Any,\n        ) -> HttpResponse:\n            if filename is None:\n                filename = \"%s.%s\" % (\n                    force_text(modeladmin.model._meta.verbose_name_plural),\n                    suffix,\n                )\n            return f(\n                modeladmin,\n                request,\n                queryset,\n                *args,\n                filename=filename,\n                **kwargs,\n            )\n\n        return inner\n\n    return outer\n\n\n@ensure_filename(\"xlsx\")\ndef export_to_excel_action(\n    modeladmin: ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet[Any],\n    filename: str | None = None,\n    field_names: Iterable[str] | None = None,\n    extra_verbose_names: dict[str, str] | None = None,\n) -> HttpResponse:\n    \"\"\"\n    Django admin action that exports selected records as an Excel XLSX download.\n\n    The queryset is first flattened via :func:`flatten_queryset`, optionally\n    restricted to the provided ``field_names`` and ``extra_verbose_names``,\n    then returned as an XLSX file response.\n\n    Args:\n        modeladmin (ModelAdmin): The Django admin class that owns this action.\n        request (HttpRequest): The current admin request.\n        queryset (QuerySet[Any]): The selected objects to export.\n        filename (str | None): Optional download filename. When omitted, a\n            name is generated from the model's ``verbose_name_plural`` and the\n            ``\"xlsx\"`` suffix.\n        field_names (Iterable[str] | None): Optional iterable of field names to\n            include in the export. When omitted, the default flattening logic\n            is used.\n        extra_verbose_names (dict[str, str] | None): Optional mapping of field\n            names to custom column headers.\n\n    Returns:\n        HttpResponse: A response containing the XLSX file.\n    \"\"\"\n    headers, rows = flatten_queryset(\n        queryset,\n        field_names=field_names,\n        extra_verbose_names=extra_verbose_names,\n    )\n    return export_to_excel_response(filename, headers, rows)\n\n\nexport_to_excel_action.short_description = _(\"Export to Excel\")\n\n\n@ensure_filename(\"csv\")\ndef export_to_csv_action(\n    modeladmin: ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet[Any],\n    filename: str | None = None,\n    field_names: Iterable[str] | None = None,\n    extra_verbose_names: dict[str, str] | None = None,\n) -> HttpResponse:\n    \"\"\"\n    Django admin action that exports selected records as a CSV download.\n\n    The queryset is first flattened via :func:`flatten_queryset`, optionally\n    restricted to the provided ``field_names`` and ``extra_verbose_names``,\n    then returned as a CSV file response.\n\n    Args:\n        modeladmin (ModelAdmin): The Django admin class that owns this action.\n        request (HttpRequest): The current admin request.\n        queryset (QuerySet[Any]): The selected objects to export.\n        filename (str | None): Optional download filename. When omitted, a\n            name is generated from the model's ``verbose_name_plural`` and the\n            ``\"csv\"`` suffix.\n        field_names (Iterable[str] | None): Optional iterable of field names to\n            include in the export. When omitted, the default flattening logic\n            is used.\n        extra_verbose_names (dict[str, str] | None): Optional mapping of field\n            names to custom column headers.\n\n    Returns:\n        HttpResponse: A response containing the CSV file.\n    \"\"\"\n    headers, rows = flatten_queryset(\n        queryset,\n        field_names=field_names,\n        extra_verbose_names=extra_verbose_names,\n    )\n    return export_to_csv_response(filename, headers, rows)\n\n\nexport_to_csv_action.short_description = _(\"Export to CSV\")\n"
  },
  {
    "path": "exporter/tabular_export/core.py",
    "content": "# encoding: utf-8\n\"\"\"Exports to tabular (2D) formats\n\nThis module contains functions which take (headers, rows) pairs and return\nHttpResponses with either XLSX or CSV downloads\n\nThe ``export_to_FORMAT_response`` functions accept a ``filename``, and\n``headers`` and ``rows``. This allows full control over the data using\nnon-database data-sources, the Django ORM's various aggregations and\noptimization methods, generators for large responses, control over the\ncolumn names, or post-processing using methods like ``get_FOO_display()``\nto format the data for display.\n\nThe ``flatten_queryset`` utility used to generate lists from QuerySets\nintentionally does not attempt to handle foreign-key fields to avoid\nperformance issues. If you need to include such data, prepare it in advance\nusing whatever optimizations are possible and pass the data in directly.\n\nIf your Django settings module sets ``TABULAR_RESPONSE_DEBUG`` to ``True``\nthe data will be dumped as an HTML table and will not be delivered as a\ndownload.\n\nOriginally from\nhttps://github.com/LibraryOfCongress/django-tabular-export/blob/master/tabular_export/core.py\n\"\"\"\n\nimport csv\nimport datetime\nfrom functools import wraps\nfrom itertools import chain\nfrom typing import Any, Callable, Iterable, Mapping, Sequence\nfrom urllib.parse import quote\n\nimport xlsxwriter\nfrom django.conf import settings\nfrom django.db.models import QuerySet\nfrom django.http import HttpResponse, StreamingHttpResponse\nfrom django.utils.encoding import force_str\n\nResponseType = HttpResponse | StreamingHttpResponse\n\n\ndef get_field_names_from_queryset(qs: QuerySet[Any]) -> list[str]:\n    \"\"\"\n    Derive field names from a queryset, including extra and aggregate columns.\n\n    The queryset is first coerced to a ``values()`` queryset so that extra\n    selects and annotations appear with the same names Django would use for\n    ``values()`` results.\n\n    Args:\n        qs: QuerySet to introspect.\n\n    Returns:\n        List of field and annotation names in the order they will appear in\n        ``qs.values()``.\n    \"\"\"\n\n    # We'll set the queryset to include all fields including calculated\n    # aggregates using the same names which a values() queryset would return:\n    v_qs = qs.values()\n\n    field_names: list[str] = []\n    field_names.extend(i.target.name for i in v_qs.query.select)\n    field_names.extend(v_qs.query.extra_select.keys())\n    field_names.extend(v_qs.query.annotation_select.keys())\n\n    return field_names\n\n\ndef flatten_queryset(\n    qs: QuerySet[Any],\n    field_names: Iterable[str] | None = None,\n    extra_verbose_names: Mapping[str, str] | None = None,\n) -> tuple[list[str], Iterable[Sequence[Any]]]:\n    \"\"\"\n    Convert a queryset into headers and row tuples for tabular export.\n\n    By default the column headers are derived from the queryset's field\n    names (as returned by ``get_field_names_from_queryset``) and the rows\n    use ``values_list()`` for efficient iteration.\n\n    If ``field_names`` is provided, only those fields are included and they\n    are used to order both headers and row values.\n\n    The ``extra_verbose_names`` mapping can override the verbose names for\n    specific fields, including related lookups or calculated values.\n\n    Args:\n        qs: Base queryset to flatten.\n        field_names: Optional explicit list of field names to include.\n        extra_verbose_names: Optional mapping of field names to friendly\n            column labels. This can be used to provide proper names for\n            related lookups (for example,\n            ``{\"institution__title\": \"Institution\"}``) or calculated values\n            (for example, ``{\"items__count\": \"Item Count\"}``).\n\n    Returns:\n        A 2-tuple of ``(headers, rows)`` where ``headers`` is a list of\n        column labels and ``rows`` is an iterable of sequences of values.\n    \"\"\"\n\n    if field_names is None:\n        field_names = get_field_names_from_queryset(qs)\n\n    # Headers will use the verbose names where available and fall back to the\n    # field name if not (e.g. custom aggregate or extra fields):\n    verbose_names = {i.name: i.verbose_name for i in qs.model._meta.fields}\n    if extra_verbose_names is not None:\n        verbose_names.update(extra_verbose_names)\n\n    headers = [verbose_names.get(i, i) for i in field_names]\n\n    return headers, qs.values_list(*field_names)\n\n\ndef convert_value_to_unicode(v: Any) -> str:\n    \"\"\"\n    Convert a value to a display-safe string for tabular export.\n\n    ``None`` is rendered as an empty string. ``date`` and ``datetime``\n    instances are converted using ``isoformat()``. All other values are\n    coerced via ``force_str``.\n\n    Args:\n        v: Value to convert.\n\n    Returns:\n        String representation suitable for CSV, HTML, or XLSX output.\n    \"\"\"\n\n    if v is None:\n        return \"\"\n    elif hasattr(v, \"isoformat\"):\n        return v.isoformat()\n    else:\n        return force_str(v)\n\n\ndef set_content_disposition(\n    f: Callable[..., ResponseType],\n) -> Callable[..., ResponseType]:\n    \"\"\"\n    Decorator that applies a Content-Disposition header using the filename.\n\n    The wrapped function must accept ``filename`` as its first positional\n    argument and return an ``HttpResponse`` (or subclass). The decorator\n    sets the ``Content-Disposition`` header using RFC 5987 encoding for the\n    provided filename.\n\n    Args:\n        f: Callable that builds the HTTP response for a given filename.\n\n    Returns:\n        Wrapped callable that always sets ``Content-Disposition`` on the\n        response.\n    \"\"\"\n\n    @wraps(f)\n    def inner(filename: str, *args: Any, **kwargs: Any) -> ResponseType:\n        response = f(filename, *args, **kwargs)\n        # See RFC 5987 for the filename* spec:\n        response[\"Content-Disposition\"] = \"attachment; filename*=UTF-8''%s\" % quote(\n            filename\n        )\n        return response\n\n    return inner\n\n\ndef return_debug_reponse(\n    f: Callable[..., ResponseType],\n) -> Callable[..., ResponseType]:\n    \"\"\"\n    Decorator to swap export responses for an HTML debug table.\n\n    When the ``TABULAR_RESPONSE_DEBUG`` setting is truthy, the wrapped\n    function is not called. Instead ``export_to_debug_html_response`` is\n    used and the ``Content-Disposition`` header is removed so the browser\n    renders the table inline.\n\n    Args:\n        f: Export callable to wrap.\n\n    Returns:\n        Wrapped callable that either returns the original export response or\n        an HTML debug response, depending on settings.\n    \"\"\"\n\n    @wraps(f)\n    def inner(filename: str, *args: Any, **kwargs: Any) -> ResponseType:\n        if not getattr(settings, \"TABULAR_RESPONSE_DEBUG\", False):\n            return f(filename, *args, **kwargs)\n        else:\n            resp = export_to_debug_html_response(filename, *args, **kwargs)\n            del resp[\"Content-Disposition\"]  # Don't trigger a download\n            return resp\n\n    return inner\n\n\ndef export_to_debug_html_response(\n    filename: str,\n    headers: Iterable[Any],\n    rows: Iterable[Sequence[Any]],\n) -> StreamingHttpResponse:\n    \"\"\"\n    Build an HTML table response for inspection of tabular export data.\n\n    This is used when ``TABULAR_RESPONSE_DEBUG`` is enabled. It renders the\n    headers and rows into a simple Bootstrap-styled HTML table and returns a\n    ``StreamingHttpResponse``.\n\n    Args:\n        filename: Suggested filename for the export (kept for API parity).\n        headers: Iterable of header labels.\n        rows: Iterable of row sequences.\n\n    Returns:\n        StreamingHttpResponse streaming the HTML document.\n    \"\"\"\n\n    def output_generator():\n        # Note the use of bytestrings to avoid unnecessary Unicode-bytes cycles:\n        yield b\"<!DOCTYPE html><html>\"\n        yield b'<head><meta charset=\"utf-8\"><title>TABULAR DEBUG</title>'\n        yield b'<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\">'  # noqa\n        yield b\"</head>\"\n        yield b'<body class=\"container-fluid\"><div class=\"table-responsive\"><table class=\"table table-striped\">'  # noqa\n        yield b\"<thead><tr><th>\"\n        yield b\"</th><th>\".join(\n            convert_value_to_unicode(i).encode(\"utf-8\") for i in headers\n        )\n        yield b\"</th></tr></thead>\"\n\n        yield b\"<tbody>\"\n        for row in rows:\n            values = map(convert_value_to_unicode, row)\n            values = [i.encode(\"utf-8\").replace(b\"\\n\", b\"<br>\") for i in values]\n            yield b\"<tr><td>%s</td></tr>\" % b\"</td><td>\".join(values)\n        yield b\"</tbody>\"\n        yield b\"</table></div></body></html>\"\n\n    return StreamingHttpResponse(\n        output_generator(), content_type=\"text/html; charset=UTF-8\"\n    )\n\n\n@return_debug_reponse\n@set_content_disposition\ndef export_to_excel_response(\n    filename: str,\n    headers: Iterable[Any],\n    rows: Iterable[Sequence[Any]],\n) -> HttpResponse:\n    \"\"\"\n    Return an XLSX ``HttpResponse`` for the given headers and rows.\n\n    The payload is constructed using ``xlsxwriter`` with a constant-memory\n    workbook and a default ``yyyy-mm-dd`` date format. ``datetime`` and\n    ``date`` values are written with Excel date formatting; all other values\n    are coerced to strings.\n\n    Args:\n        filename: Download filename used in the ``Content-Disposition``\n            header.\n        headers: Iterable of header labels for the first row.\n        rows: Iterable of row sequences.\n\n    Returns:\n        HttpResponse containing the XLSX file.\n    \"\"\"\n\n    # See http://technet.microsoft.com/en-us/library/ee309278%28office.12%29.aspx\n    content_type = \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\"\n\n    # This cannot be a StreamingHttpResponse because XLSX files are .zip format and\n    # the Python ZipFile library doesn't offer a generator form (which would also\n    # not be called per-row but per-chunk)\n\n    resp = HttpResponse(content_type=content_type)\n\n    workbook = xlsxwriter.Workbook(\n        resp,\n        {\n            \"constant_memory\": True,\n            \"in_memory\": True,\n            \"default_date_format\": \"yyyy-mm-dd\",\n        },\n    )\n\n    date_format = workbook.add_format({\"num_format\": \"yyyy-mm-dd\"})\n\n    worksheet = workbook.add_worksheet()\n\n    for y, row in enumerate(chain((headers,), rows)):\n        for x, col in enumerate(row):\n            if isinstance(col, datetime.datetime):\n                # xlsxwriter cannot handle timezones:\n                worksheet.write_datetime(y, x, col.replace(tzinfo=None), date_format)\n            elif isinstance(col, datetime.date):\n                worksheet.write_datetime(y, x, col, date_format)\n            else:\n                worksheet.write(y, x, force_str(col, strings_only=True))\n\n    workbook.close()\n\n    return resp\n\n\nclass Echo(object):\n    # See\n    # https://docs.djangoproject.com/en/1.8/howto/outputting-csv/#streaming-csv-files\n\n    def write(self, value: str) -> str:\n        return value\n\n\n@return_debug_reponse\n@set_content_disposition\ndef export_to_csv_response(\n    filename: str,\n    headers: Iterable[Any],\n    rows: Iterable[Sequence[Any]],\n) -> StreamingHttpResponse:\n    \"\"\"\n    Return a CSV ``StreamingHttpResponse`` for the given headers and rows.\n\n    Values are converted to strings via ``convert_value_to_unicode`` and\n    written using the standard library ``csv`` module. The response streams\n    each rendered row to avoid holding the entire CSV in memory.\n\n    Args:\n        filename: Download filename used in the ``Content-Disposition``\n            header.\n        headers: Iterable of header labels for the header row.\n        rows: Iterable of row sequences.\n\n    Returns:\n        StreamingHttpResponse streaming the CSV content.\n    \"\"\"\n    pseudo_buffer = Echo()\n\n    writer = csv.writer(pseudo_buffer)\n\n    def row_generator() -> Iterable[Iterable[str]]:\n        yield map(convert_value_to_unicode, headers)\n\n        for row in rows:\n            yield map(convert_value_to_unicode, row)\n\n    # This works because csv.writer.writerow calls the underlying\n    # file-like .write method *and* returns the result. We cannot\n    # use the same approach for Excel because xlsxwriter doesn't\n    # have a way to emit chunks from ZipFile and StreamingHttpResponse\n    # does not offer a file-like handle.\n\n    return StreamingHttpResponse(\n        (writer.writerow(row) for row in row_generator()),\n        content_type=\"text/csv; charset=utf-8\",\n    )\n\n\ndef force_utf8_encoding(\n    f: Callable[[], Iterable[Sequence[Any]]],\n) -> Callable[[], Iterable[Sequence[bytes]]]:\n    \"\"\"\n    Decorator that forces all values yielded by a row generator to UTF-8 bytes.\n\n    The wrapped callable must return an iterable of row sequences. Each value\n    in each row is encoded as UTF-8 bytes.\n\n    Args:\n        f: Callable returning an iterable of rows.\n\n    Returns:\n        Callable that yields rows with all values encoded as UTF-8 bytes.\n    \"\"\"\n\n    @wraps(f)\n    def inner() -> Iterable[Sequence[bytes]]:\n        for row in f():\n            yield [i.encode(\"utf-8\") for i in row]\n\n    return inner\n"
  },
  {
    "path": "exporter/templates/admin/exporter/unacceptable_character_report.html",
    "content": "{% extends \"admin/base.html\" %}\n\n{% load concordia_text_tags %}\n\n{% comment %}\nDisplays per-asset lists of unacceptable characters detected during export.\nEach error entry provides a link to the asset's admin change page.\n{% endcomment %}\n\n{% block messages %}\n    {# Messages are rendered elsewhere in the admin; suppress duplicate view #}\n{% endblock messages %}\n\n{% block extrahead %}\n    {{ block.super }}\n    <style>\n        .char-error-table th {\n            text-align: left;\n        }\n\n        .char-error-table td,\n        .char-error-table th {\n            padding: 0.25rem 0.75rem;\n        }\n\n        .char-error-table tr:nth-child(even) {\n            background: #f9f9f9;\n        }\n\n        .char-error-table code {\n            font-weight: bold;\n            color: #dc3545; /* bootstrap danger */\n            background: transparent;\n        }\n    </style>\n{% endblock extrahead %}\n\n{% block content %}\n    <div id=\"content-main\">\n        <h2>Unacceptable Characters Report</h2>\n\n        {% if errors %}\n            <table class=\"char-error-table\">\n                <thead>\n                    <tr>\n                        <th>Asset</th>\n                        <th>Violations&nbsp;(line, column, char)</th>\n                    </tr>\n                </thead>\n                <tbody>\n                    {% for entry in errors %}\n                        <tr>\n                            <td>\n                                <a href=\"{% url 'admin:concordia_asset_change' entry.asset.pk %}\">\n                                    {{ entry.asset }}\n                                </a>\n                            </td>\n                            <td>\n                                <ul>\n                                    {% for v in entry.violations %}\n                                        <li>\n                                            Line&nbsp;{{ v.0 }},&nbsp;Col&nbsp;{{ v.1 }}:\n                                            <code>{{ v.2|reprchar }}</code>\n                                        </li>\n                                    {% endfor %}\n                                </ul>\n                            </td>\n                        </tr>\n                    {% endfor %}\n                </tbody>\n            </table>\n        {% else %}\n            <p>No unacceptable characters were found.</p>\n        {% endif %}\n    </div>\n{% endblock content %}\n"
  },
  {
    "path": "exporter/tests/__init__.py",
    "content": ""
  },
  {
    "path": "exporter/tests/test_exceptions.py",
    "content": "from django.test import TestCase\n\nfrom exporter.exceptions import UnacceptableCharacterError\n\n\nclass UnacceptableCharacterErrorTests(TestCase):\n    def test_violations_are_stored(self):\n        \"\"\"The `violations` list passed to `__init__` is stored unmodified.\"\"\"\n        violations = [(2, 3, \"\\u200b\"), (4, 1, \"\\x00\")]\n        err = UnacceptableCharacterError(violations)\n        self.assertEqual(err.violations, violations)\n\n    def test_message_contains_formatted_details(self):\n        \"\"\"The exception message should embed a human-readable summary.\"\"\"\n        violations = [(1, 1, \"\\x00\")]\n        err = UnacceptableCharacterError(violations)\n        msg = str(err)\n        self.assertIn(\"line 1 col 1\", msg)\n        # The backslash in \"\\\\x00\" is escaped once by repr() and once in the\n        # string literal, so we search for the double-escaped form.\n        self.assertIn(\"\\\\x00\", msg)\n"
  },
  {
    "path": "exporter/tests/test_tabular_export.py",
    "content": "import datetime\nfrom unittest.mock import Mock\n\nfrom django.db import models\nfrom django.http import HttpResponse, StreamingHttpResponse\nfrom django.test import TestCase, override_settings\n\nfrom exporter.tabular_export.admin import (\n    export_to_csv_action,\n    export_to_excel_action,\n)\nfrom exporter.tabular_export.core import (\n    Echo,\n    convert_value_to_unicode,\n    export_to_csv_response,\n    export_to_debug_html_response,\n    export_to_excel_response,\n    flatten_queryset,\n    force_utf8_encoding,\n    get_field_names_from_queryset,\n    set_content_disposition,\n)\n\n\nclass DummyModel(models.Model):\n    name = models.CharField(max_length=255, verbose_name=\"Name\")\n    created = models.DateField(null=True, verbose_name=\"Created\")\n\n    class Meta:\n        app_label = \"tests\"\n\n\nclass DummyQuerySet:\n    def __init__(self, data, field_names):\n        self._data = data\n        self._field_names = field_names\n        self.model = DummyModel\n\n    def values_list(self, *args):\n        return self._data\n\n    def values(self):\n        return self\n\n    @property\n    def query(self):\n        class Query:\n            select = [\n                type(\"Field\", (), {\"target\": type(\"Target\", (), {\"name\": fn})()})\n                for fn in self._field_names\n            ]\n            extra_select = {\"extra\": \"value\"}\n            annotation_select = {\"annotate\": \"value\"}\n\n        return Query()\n\n\nclass CoreTests(TestCase):\n    def test_convert_value_to_unicode(self):\n        self.assertEqual(convert_value_to_unicode(None), \"\")\n        self.assertEqual(convert_value_to_unicode(\"abc\"), \"abc\")\n        dt = datetime.datetime(2020, 1, 1, 12, 0)\n        self.assertEqual(convert_value_to_unicode(dt), \"2020-01-01T12:00:00\")\n        d = datetime.date(2020, 1, 1)\n        self.assertEqual(convert_value_to_unicode(d), \"2020-01-01\")\n\n    def test_echo_write(self):\n        echo = Echo()\n        self.assertEqual(echo.write(\"abc\"), \"abc\")\n\n    def test_get_field_names_from_queryset(self):\n        qs = DummyQuerySet([], [\"name\", \"created\"])\n        self.assertEqual(\n            get_field_names_from_queryset(qs),\n            [\"name\", \"created\", \"extra\", \"annotate\"],\n        )\n\n    def test_flatten_queryset_defaults(self):\n        qs = DummyQuerySet([(\"abc\", datetime.date(2020, 1, 1))], [\"name\", \"created\"])\n        headers, rows = flatten_queryset(qs)\n        self.assertEqual(headers, [\"Name\", \"Created\", \"extra\", \"annotate\"])\n        self.assertEqual(list(rows), [(\"abc\", datetime.date(2020, 1, 1))])\n\n    def test_flatten_queryset_with_custom_headers(self):\n        qs = DummyQuerySet([(\"abc\",)], [\"name\"])\n        headers, rows = flatten_queryset(\n            qs, field_names=[\"name\"], extra_verbose_names={\"name\": \"Full Name\"}\n        )\n        self.assertEqual(headers, [\"Full Name\"])\n        self.assertEqual(list(rows), [(\"abc\",)])\n\n    def test_force_utf8_encoding(self):\n        def rows():\n            yield [\"ü\", \"æ\"]\n\n        out = list(force_utf8_encoding(rows)())\n        self.assertEqual(out, [[b\"\\xc3\\xbc\", b\"\\xc3\\xa6\"]])\n\n    def test_set_content_disposition(self):\n        @set_content_disposition\n        def dummy(filename):\n            return StreamingHttpResponse()\n\n        resp = dummy(\"test.csv\")\n        self.assertIn(\n            \"attachment; filename*=UTF-8''test.csv\", resp[\"Content-Disposition\"]\n        )\n\n    def test_export_to_debug_html_response(self):\n        headers = [\"h1\", \"h2\"]\n        rows = [[\"val1\", \"val2\"], [\"val3\", \"val4\"]]\n        resp = export_to_debug_html_response(\"test.html\", headers, rows)\n        self.assertIsInstance(resp, StreamingHttpResponse)\n        content = b\"\".join(resp.streaming_content)\n        self.assertIn(b\"<table\", content)\n        self.assertIn(b\"val1\", content)\n        self.assertIn(b\"val4\", content)\n\n    @override_settings(TABULAR_RESPONSE_DEBUG=False)\n    def test_export_to_csv_response(self):\n        headers = [\"h1\", \"h2\"]\n        rows = [[\"a\", \"b\"]]\n        resp = export_to_csv_response(\"test.csv\", headers, rows)\n        self.assertIsInstance(resp, StreamingHttpResponse)\n        content = b\"\".join(resp.streaming_content)\n        self.assertIn(b\"a\", content)\n\n    @override_settings(TABULAR_RESPONSE_DEBUG=True)\n    def test_export_to_csv_response_debug(self):\n        headers = [\"h1\"]\n        rows = [[\"b\"]]\n        resp = export_to_csv_response(\"debug.csv\", headers, rows)\n        self.assertIsInstance(resp, StreamingHttpResponse)\n        content = b\"\".join(resp.streaming_content)\n        self.assertIn(b\"<table\", content)\n        self.assertNotIn(\"Content-Disposition\", resp)\n\n    @override_settings(TABULAR_RESPONSE_DEBUG=False)\n    def test_export_to_excel_response(self):\n        headers = [\"h1\", \"h2\"]\n        rows = [[\"x\", datetime.date(2022, 1, 1)]]\n        resp = export_to_excel_response(\"file.xlsx\", headers, rows)\n        self.assertIsInstance(resp, HttpResponse)\n        self.assertEqual(\n            resp[\"Content-Type\"],\n            \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n        )\n        self.assertEqual(\n            \"attachment; filename*=UTF-8''file.xlsx\", resp[\"Content-Disposition\"]\n        )\n\n    @override_settings(TABULAR_RESPONSE_DEBUG=False)\n    def test_export_to_excel_response_with_datetime(self):\n        headers = [\"h1\"]\n        rows = [[datetime.datetime(2022, 1, 1, 12, 0)]]\n        resp = export_to_excel_response(\"datetime.xlsx\", headers, rows)\n        self.assertIsInstance(resp, HttpResponse)\n        self.assertEqual(\n            resp[\"Content-Type\"],\n            \"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet\",\n        )\n        self.assertEqual(\n            \"attachment; filename*=UTF-8''datetime.xlsx\", resp[\"Content-Disposition\"]\n        )\n\n    @override_settings(TABULAR_RESPONSE_DEBUG=True)\n    def test_export_to_excel_response_debug(self):\n        headers = [\"h1\"]\n        rows = [[\"x\"]]\n        resp = export_to_excel_response(\"debug.xlsx\", headers, rows)\n        self.assertIsInstance(resp, StreamingHttpResponse)\n        content = b\"\".join(resp.streaming_content)\n        self.assertIn(b\"<table\", content)\n\n\nclass AdminTests(TestCase):\n    def setUp(self):\n        self.queryset = DummyQuerySet(\n            data=[(\"val1\", \"val2\"), (\"val3\", \"val4\")],\n            field_names=[\"name\", \"created\"],\n        )\n        self.modeladmin = Mock()\n        self.modeladmin.model = DummyModel\n        self.request = Mock()\n\n    def test_export_to_excel_action_default_filename(self):\n        response = export_to_excel_action(self.modeladmin, self.request, self.queryset)\n        self.assertIsInstance(response, HttpResponse)\n        self.assertIn(\n            \"application/vnd.openxmlformats-officedocument\", response[\"Content-Type\"]\n        )\n        self.assertEqual(\n            \"attachment; filename*=UTF-8''dummy%20models.xlsx\",\n            response[\"Content-Disposition\"],\n        )\n\n    def test_export_to_excel_action_with_custom_filename_and_fields(self):\n        response = export_to_excel_action(\n            self.modeladmin,\n            self.request,\n            self.queryset,\n            filename=\"custom.xlsx\",\n            field_names=[\"name\"],\n            extra_verbose_names={\"name\": \"Custom Name\"},\n        )\n        self.assertIsInstance(response, HttpResponse)\n        self.assertIn(\n            \"application/vnd.openxmlformats-officedocument\", response[\"Content-Type\"]\n        )\n        self.assertEqual(\n            \"attachment; filename*=UTF-8''custom.xlsx\", response[\"Content-Disposition\"]\n        )\n\n    def test_export_to_csv_action_default_filename(self):\n        response = export_to_csv_action(self.modeladmin, self.request, self.queryset)\n        self.assertIsInstance(response, StreamingHttpResponse)\n        content = b\"\".join(response.streaming_content)\n        self.assertIn(b\"val1\", content)\n        self.assertIn(b\"val4\", content)\n\n    def test_export_to_csv_action_with_custom_filename_and_fields(self):\n        response = export_to_csv_action(\n            self.modeladmin,\n            self.request,\n            self.queryset,\n            filename=\"custom.csv\",\n            field_names=[\"name\"],\n            extra_verbose_names={\"name\": \"Custom Name\"},\n        )\n        self.assertIsInstance(response, StreamingHttpResponse)\n        content = b\"\".join(response.streaming_content)\n        self.assertIn(b\"val1\", content)\n"
  },
  {
    "path": "exporter/tests/test_utils.py",
    "content": "from django.test import TestCase\n\nfrom exporter.exceptions import UnacceptableCharacterError\nfrom exporter.utils import (\n    find_unacceptable_characters,\n    is_acceptable_character,\n    remove_unacceptable_characters,\n    validate_text_for_export,\n)\n\n\nclass UtilsValidationTests(TestCase):\n    def test_printable_ascii_is_acceptable(self):\n        self.assertTrue(is_acceptable_character(\"A\"))\n        self.assertTrue(is_acceptable_character(\"9\"))\n        self.assertTrue(is_acceptable_character(\" \"))\n\n    def test_whitelisted_nonprintable_is_acceptable(self):\n        # Tab (\\t) and NBSP (\\xa0) are explicitly whitelisted\n        self.assertTrue(is_acceptable_character(\"\\t\"))\n        self.assertTrue(is_acceptable_character(\"\\xa0\"))\n\n    def test_control_char_is_rejected(self):\n        self.assertFalse(is_acceptable_character(\"\\x00\"))\n        self.assertFalse(is_acceptable_character(\"\\x1f\"))\n\n    def test_find_unacceptable_characters_returns_positions(self):\n        sample = \"ok\\nBad\\x00line\\nnext\\tgood\"\n        violations = find_unacceptable_characters(sample)\n        # Expect the single null-byte at line 2, column 4 (1-based)\n        self.assertEqual(violations, [(2, 4, \"\\x00\")])\n\n    def test_duplicate_violations_are_recorded(self):\n        sample = \"a\\x00b\\x00\"  # two null bytes same line\n        violations = find_unacceptable_characters(sample)\n        self.assertEqual(violations, [(1, 2, \"\\x00\"), (1, 4, \"\\x00\")])\n\n    def test_validate_text_for_export_passes_clean_text(self):\n        clean = \"Hello world!\\nThis\\u3000is ok.\"\n        # \\u3000 (ideographic space) is whitelisted\n        self.assertTrue(validate_text_for_export(clean))\n\n    def test_validate_text_for_export_raises_on_bad_text(self):\n        bad = \"Bad\\u200bText\"  # zero-width space is not allowed\n        with self.assertRaises(UnacceptableCharacterError) as cm:\n            validate_text_for_export(bad)\n        err = cm.exception\n        self.assertEqual(err.violations, [(1, 4, \"\\u200b\")])\n\n    def test_remove_unacceptable_characters_removes_disallowed_chars(self):\n        # Mix of unacceptable characters across positions.\n        sample = \"\\x00Start\\u200bMiddleEnd\\x1f\"\n        cleaned = remove_unacceptable_characters(sample)\n        self.assertEqual(cleaned, \"StartMiddleEnd\")\n\n    def test_remove_unacceptable_characters_keeps_whitelisted_chars(self):\n        # Ensure whitelist is honored: \\t, NBSP, ideographic space, em space.\n        sample = \"A\\tB\\xa0C\\u3000D\\u2003E\"\n        cleaned = remove_unacceptable_characters(sample)\n        self.assertEqual(cleaned, sample)\n\n    def test_remove_unacceptable_characters_preserves_newlines_and_crlf(self):\n        # Preserve exact newline forms while removing bad chars within lines.\n        sample = \"one\\r\\ntwo\\nthree\\rfour\"\n        # Insert a zero-width space in \"two\" and a NUL at end of \"three\".\n        sample_with_bad = \"one\\r\\nt\\u200bwo\\nthree\\x00\\rfour\"\n        cleaned = remove_unacceptable_characters(sample_with_bad)\n        self.assertEqual(cleaned, sample)\n\n    def test_remove_unacceptable_characters_noop_on_clean_text(self):\n        clean = \"Line 1\\nLine 2\\tTabbed\\u3000Ideographic\\u2003Em\"\n        cleaned = remove_unacceptable_characters(clean)\n        self.assertEqual(cleaned, clean)\n\n    def test_remove_unacceptable_characters_handles_multiple_lines(self):\n        # Multiple lines with several unacceptable chars per line.\n        sample = (\n            \"ok line\\n\"\n            \"bad\\x00line\\x00with\\x00many\\n\"\n            \"zero\\u200bwidth\\u200bspaces\\n\"\n            \"\\x00\\x00start and end\\u200b\"\n        )\n        cleaned = remove_unacceptable_characters(sample)\n        self.assertEqual(\n            cleaned, \"ok line\\n\" \"badlinewithmany\\n\" \"zerowidthspaces\\n\" \"start and end\"\n        )\n\n    def test_remove_unacceptable_characters_preserves_carriage_return_alone(self):\n        # Some inputs may include bare '\\r' (classic Mac, or copy/paste artifacts).\n        sample = \"a\\rb\\rc\"\n        # Add disallowed chars around to ensure we only drop them, not '\\r'.\n        sample_with_bad = \"a\\x00\\rb\\u200b\\rc\\x1f\"\n        cleaned = remove_unacceptable_characters(sample_with_bad)\n        self.assertEqual(cleaned, sample)\n"
  },
  {
    "path": "exporter/tests/test_views.py",
    "content": "import io\nimport tempfile\nimport zipfile\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nfrom django.http import HttpResponse, HttpResponseRedirect\nfrom django.test import TestCase, override_settings\nfrom django.urls import reverse\nfrom django.utils import timezone\n\nfrom concordia.models import (\n    Asset,\n    Item,\n    MediaType,\n    Transcription,\n    TranscriptionStatus,\n    User,\n)\nfrom concordia.tests.utils import (\n    create_asset,\n    create_campaign,\n    create_item,\n    create_project,\n)\nfrom exporter.views import (\n    ExportProjectToCSV,\n    do_bagit_export,\n    get_latest_transcription_data,\n    get_original_asset_id,\n    get_tag_values,\n    remove_incomplete_items,\n    write_distinct_asset_resource_file,\n)\n\nDOWNLOAD_URL = (\n    \"http://tile.loc.gov/image-services/iiif/\"\n    \"service:mss:mal:003:0036300:002/full/pct:25/0/default.jpg\"\n)\n\nRESOURCE_URL = \"https://www.loc.gov/resource/mal.0043300/\"\n\n\nclass ViewTests(TestCase):\n    def setUp(self):\n        self.user = User.objects.create(\n            username=\"tester\", email=\"tester@example.com\", is_staff=True\n        )\n        self.user.set_password(\"top_secret\")\n        self.user.save()\n        self.assertTrue(\n            self.client.login(username=\"tester\", password=\"top_secret\")  # nosec\n        )\n\n        self.campaign = create_campaign(published=True)\n        self.project = create_project(campaign=self.campaign, published=True)\n        self.item = create_item(project=self.project, published=True)\n\n        self.asset = create_asset(\n            item=self.item,\n            title=\"TestAsset\",\n            description=\"Asset Description\",\n            download_url=DOWNLOAD_URL,\n            resource_url=RESOURCE_URL,\n            media_type=MediaType.IMAGE,\n            sequence=1,\n        )\n\n        transcription = Transcription(\n            asset=self.asset,\n            user=self.user,\n            text=\"Sample\",\n            submitted=timezone.now(),\n            accepted=timezone.now(),\n        )\n        transcription.full_clean()\n        transcription.save()\n\n        # Create another project with the same slug in a different campaign\n        # to ensure this does not cause issues with any exports\n        campaign2 = create_campaign(published=True, slug=\"test-campaign-2\")\n        create_project(campaign=campaign2, published=True, slug=self.project.slug)\n\n    def test_csv_export(self):\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-export-csv\", args=(self.campaign.slug,))\n        )\n        self.assertEqual(response.status_code, 200)\n        response_content = \"\".join(map(str, response.streaming_content))\n        self.assertIn(\n            \"Campaign,Project,Item,ItemId,Asset,AssetId,AssetStatus\", response_content\n        )\n        self.assertIn(\"TestAsset\", response_content)\n        self.assertIn(\"Sample\", response_content)\n\n    def test_campaign_bagit_export(self):\n        response = self.client.get(\n            reverse(\"transcriptions:campaign-export-bagit\", args=(self.campaign.slug,))\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"Content-Disposition\", response)\n\n        f = io.BytesIO(response.content)\n        zipped = zipfile.ZipFile(f, \"r\")\n        self.assertIn(\"bagit.txt\", zipped.namelist())\n        self.assertIn(\"data/mss/mal/003/0036300/002.txt\", zipped.namelist())\n\n    def test_project_bagit_export(self):\n        response = self.client.get(\n            reverse(\n                \"transcriptions:project-export-bagit\",\n                args=(self.campaign.slug, self.project.slug),\n            )\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"Content-Disposition\", response)\n\n        f = io.BytesIO(response.content)\n        zipped = zipfile.ZipFile(f, \"r\")\n        self.assertIn(\"bagit.txt\", zipped.namelist())\n        self.assertIn(\"data/mss/mal/003/0036300/002.txt\", zipped.namelist())\n\n    def test_project_csv_export(self):\n        request = self.client.get(\"/\").wsgi_request\n        request.user = self.user\n        request.user.is_staff = True\n\n        response = ExportProjectToCSV.as_view()(\n            request, campaign_slug=self.campaign.slug, project_slug=self.project.slug\n        )\n\n        self.assertEqual(response.status_code, 200)\n        response_content = b\"\".join(response.streaming_content).decode()\n        self.assertIn(\"TestAsset\", response_content)\n        self.assertIn(\"Sample\", response_content)\n\n    def test_item_bagit_export(self):\n        response = self.client.get(\n            reverse(\n                \"transcriptions:item-export-bagit\",\n                args=(self.campaign.slug, self.project.slug, self.item.item_id),\n            )\n        )\n        self.assertEqual(response.status_code, 200)\n        self.assertIn(\"Content-Disposition\", response)\n        f = io.BytesIO(response.content)\n        zipped = zipfile.ZipFile(f, \"r\")\n        self.assertIn(\"bagit.txt\", zipped.namelist())\n        self.assertIn(\"data/mss/mal/003/0036300/002.txt\", zipped.namelist())\n\n    def test_get_original_asset_id_fallback(self):\n        fallback_url = \"http://example.com/image.jpg\"\n        self.assertEqual(get_original_asset_id(fallback_url), fallback_url)\n\n    def test_get_original_asset_id_service_match(self):\n        url = \"http://tile.loc.gov/image-services/iiif/service:mss:mss38299:mss38299_016:0588/full/pct:100/0/default.jpg\"\n        result = get_original_asset_id(url)\n        self.assertEqual(result, \"mss:mss38299:mss38299_016:0588\")\n\n    def test_get_original_asset_id_master_match(self):\n        # This is a made-up URL because no current Assets\n        # have a \"master\" URL to test against\n        url = \"http://tile.loc.gov/image-services/iiif/master/mus/123/456/mus123456.002/full/pct:100/0/default.jpg\"\n        result = get_original_asset_id(url)\n        self.assertEqual(result, \"mus/123/456/mus123456\")\n\n    def test_get_original_asset_id_public_match(self):\n        url = \"https://tile.loc.gov/image-services/iiif/public:music:musbernstein-100020080:musbernstein-100020080.0021/full/pct:100.0/0/default.jpg\"\n        result = get_original_asset_id(url)\n        self.assertEqual(result, \"musbernstein-100020080:musbernstein-100020080.0021\")\n\n    def test_get_original_asset_id_failure(self):\n        # This tests if a URL doesn't match any of the patterns\n        invalid_url = \"http://tile.loc.gov/image-services/iiif/master/foo/bar/baz/full/pct:100/0/default.jpg\"\n        with self.assertRaises(ValueError):\n            get_original_asset_id(invalid_url)\n\n    def test_write_distinct_asset_resource_file_missing_url(self):\n        self.asset.resource_url = \"\"\n        self.asset.save()\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            with self.assertRaises(AssertionError):\n                write_distinct_asset_resource_file([self.asset.pk], tmpdir)\n\n    @override_settings(EXPORT_S3_BUCKET_NAME=None)\n    @patch(\"exporter.views.logger\")\n    def test_do_bagit_export_no_s3(self, mock_logger):\n        assets = get_latest_transcription_data(Asset.objects.filter(pk=self.asset.pk))\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            response = do_bagit_export(assets, tmpdir, \"sample-bagit\")\n            self.assertEqual(response.status_code, 200)\n            self.assertIn(\"application/zip\", response[\"Content-Type\"])\n\n    def test_remove_incomplete_items(self):\n        item2 = create_item(\n            project=self.project, published=True, item_id=\"different-id\"\n        )\n        create_asset(item=item2, transcription_status=TranscriptionStatus.NOT_STARTED)\n\n        asset_qs = remove_incomplete_items(Item.objects.filter(project=self.project))\n        self.assertEqual(asset_qs.count(), 1)\n        self.assertEqual(asset_qs.first(), self.asset)\n\n    def test_get_tag_values_empty(self):\n        assets = get_tag_values(Asset.objects.filter(pk=self.asset.pk))\n        self.assertEqual(list(assets.values_list(\"tag_values\", flat=True))[0], \"\")\n\n    def test_get_latest_transcription_data(self):\n        assets = get_latest_transcription_data(Asset.objects.filter(pk=self.asset.pk))\n        self.assertEqual(list(assets)[0].latest_transcription, \"Sample\")\n\n    @override_settings(EXPORT_S3_BUCKET_NAME=\"fake-bucket\")\n    @patch(\"exporter.views.boto3.resource\")\n    @patch(\"exporter.views.logger\")\n    def test_do_bagit_export_with_s3(self, mock_logger, mock_boto):\n        assets = get_latest_transcription_data(Asset.objects.filter(pk=self.asset.pk))\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            response = do_bagit_export(assets, tmpdir, \"sample-bagit\")\n            self.assertIsInstance(response, HttpResponseRedirect)\n            self.assertIn(\"fake-bucket.s3.amazonaws.com\", response[\"Location\"])\n            mock_boto().Bucket().upload_file.assert_called()\n\n    @override_settings(EXPORT_S3_BUCKET_NAME=None)\n    @patch(\"exporter.views.logger\")\n    def test_do_bagit_export_without_transcription(self, mock_logger):\n        asset = create_asset(\n            item=self.item,\n            sequence=99,\n            title=\"No Transcription\",\n            download_url=DOWNLOAD_URL,\n            resource_url=RESOURCE_URL,\n        )\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            assets = get_latest_transcription_data(Asset.objects.filter(pk=asset.pk))\n            response = do_bagit_export(assets, tmpdir, \"sample-bagit-no-txt\")\n\n            self.assertEqual(response.status_code, 200)\n            self.assertIn(\"application/zip\", response[\"Content-Type\"])\n\n            # Read contents of the zip\n            zip_bytes = io.BytesIO(response.content)\n            with zipfile.ZipFile(zip_bytes, \"r\") as zip_file:\n                file_list = zip_file.namelist()\n\n            # There should be no .txt transcription files\n            transcription_files = [\n                f\n                for f in file_list\n                if f.endswith(\".txt\")\n                and f.startswith(\"data/\")\n                and not f.endswith(\"item-resource-urls.txt\")\n            ]\n            self.assertEqual(\n                transcription_files,\n                [],\n                f\"Unexpected transcription files: {transcription_files}\",\n            )\n\n    @override_settings(EXPORT_S3_BUCKET_NAME=None)\n    @patch(\"exporter.views.render\")  # <- patch render itself\n    @patch(\"exporter.views.shutil.rmtree\")\n    def test_do_bagit_export_validation_errors_render(self, mock_rmtree, mock_render):\n        bad_asset = create_asset(\n            item=self.item,\n            sequence=42,\n            title=\"BadAsset\",\n            download_url=DOWNLOAD_URL,\n            resource_url=RESOURCE_URL,\n        )\n        Transcription.objects.create(\n            asset=bad_asset,\n            user=self.user,\n            text=\"Bad\\u200bText\",  # invalid char\n            submitted=timezone.now(),\n        )\n        assets = get_latest_transcription_data(\n            Asset.objects.filter(pk__in=[self.asset.pk, bad_asset.pk])\n        )\n\n        request = self.client.get(\"/dummy\").wsgi_request\n        request.user = self.user\n        request.user.is_staff = True\n\n        # make render return a simple HttpResponse we can ignore\n        mock_render.return_value = HttpResponse(\"dummy\")\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            response = do_bagit_export(\n                assets,\n                tmpdir,\n                \"bad-bagit\",\n                request=request,\n            )\n\n            mock_render.assert_called_once()\n            args, kwargs = mock_render.call_args\n            template_name = args[1]  # args[0] is the request\n            context = args[2]  # third positional arg\n            self.assertEqual(\n                template_name,\n                \"admin/exporter/unacceptable_character_report.html\",\n            )\n            self.assertIn(\"errors\", context)\n            self.assertEqual(len(context[\"errors\"]), 1)\n            self.assertEqual(context[\"errors\"][0][\"asset\"], bad_asset)\n\n            mock_rmtree.assert_called_once_with(Path(tmpdir), ignore_errors=True)\n\n            self.assertEqual(response.content, b\"dummy\")\n\n    @override_settings(EXPORT_S3_BUCKET_NAME=None)\n    @patch(\"exporter.views.shutil.rmtree\")\n    def test_do_bagit_export_validation_errors_no_request(self, mock_rmtree):\n        \"\"\"\n        When called without a request, do_bagit_export should return the raw\n        error list.\n        \"\"\"\n        bad_asset = create_asset(\n            item=self.item,\n            sequence=99,\n            title=\"BadAssetNoRequest\",\n            download_url=DOWNLOAD_URL,\n            resource_url=RESOURCE_URL,\n        )\n        Transcription.objects.create(\n            asset=bad_asset,\n            user=self.user,\n            text=\"Invisible\\u200eText\",  # LEFT-TO-RIGHT MARK -> invalid\n            submitted=timezone.now(),\n        )\n\n        assets = get_latest_transcription_data(\n            Asset.objects.filter(pk__in=[self.asset.pk, bad_asset.pk])\n        )\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            errors = do_bagit_export(assets, tmpdir, \"bad-bagit-no-request\")\n\n            # helper should return a list, not an HttpResponse\n            self.assertIsInstance(errors, list)\n            self.assertEqual(len(errors), 1)\n            self.assertEqual(errors[0][\"asset\"], bad_asset)\n\n            # directory should have been removed\n            mock_rmtree.assert_called_once_with(Path(tmpdir), ignore_errors=True)\n\n    @override_settings(EXPORT_S3_BUCKET_NAME=None)\n    @patch(\"exporter.views.shutil.rmtree\")\n    @patch(\n        \"pathlib.Path.exists\", return_value=False\n    )  # force the exists() check to fail\n    def test_do_bagit_export_validation_errors_no_export_dir(\n        self,\n        mock_exists,\n        mock_rmtree,\n    ):\n        \"\"\"\n        If validation fails and the export directory no longer exists,\n        do_bagit_export should NOT attempt to call shutil.rmtree.\n        \"\"\"\n        bad_asset = create_asset(\n            item=self.item,\n            sequence=77,\n            title=\"BadAssetNoDir\",\n            download_url=DOWNLOAD_URL,\n            resource_url=RESOURCE_URL,\n        )\n        Transcription.objects.create(\n            asset=bad_asset,\n            user=self.user,\n            text=\"Oops\\u200b\",  # zero-width space -> invalid\n            submitted=timezone.now(),\n        )\n\n        assets = get_latest_transcription_data(\n            Asset.objects.filter(pk__in=[self.asset.pk, bad_asset.pk])\n        )\n\n        with tempfile.TemporaryDirectory() as tmpdir:\n            # invoke without request -> should return raw errors list\n            errors = do_bagit_export(assets, tmpdir, \"bad-bagit-no-dir\")\n\n            # exists() forced to False -> rmtree must not be called\n            mock_rmtree.assert_not_called()\n            self.assertIsInstance(errors, list)\n            self.assertEqual(len(errors), 1)\n"
  },
  {
    "path": "exporter/utils.py",
    "content": "\"\"\"\nUtility helpers for validating outgoing text.\n\nThe primary public entry-point is `exporter.utils.validate_text_for_export`,\nwhich raises an `exporter.exceptions.UnacceptableCharacterError` when any\nnon-printable Unicode character is detected.  Validation is performed per\nline so the caller receives the exact location of every problem character.\n\"\"\"\n\nfrom typing import List, Tuple\n\nfrom exporter.exceptions import UnacceptableCharacterError\n\n_WHITELIST = [\n    \"\\t\",  # Tab\n    \"\\xa0\",  # Non-breaking space\n    \"\\u3000\",  # Ideographic space; used in Chinese/Japanese/Korean\n    \"\\u2003\",  # em space (space the width of the 'm' character)\n]\n\n\ndef is_acceptable_character(character: str) -> bool:\n    \"\"\"\n    Return `True` when `character` is considered printable.\n\n    The function simply wraps `str.isprintable()` so behaviour stays in sync\n    with the official Unicode definition of *printable*.\n\n    Args:\n        character: A single Unicode character.\n\n    Returns:\n        `True` if the character is printable; otherwise `False`.\n    \"\"\"\n\n    return character.isprintable() or character in _WHITELIST\n\n\ndef find_unacceptable_characters(text: str) -> List[Tuple[int, int, str]]:\n    \"\"\"\n    Locate every non-printable character in *text*.\n\n    The scan is performed line by line so that the exact position (line and\n    column) of each offending character can be fed back to the caller.\n\n    Args:\n        text: The string to validate.\n\n    Returns:\n        A list of `(line_number, column_number, character)` triples.  The list\n        may contain duplicates because each instance of an invalid character is\n        recorded individually.\n    \"\"\"\n\n    violations: List[Tuple[int, int, str]] = []\n\n    for line_no, line in enumerate(text.splitlines(), start=1):\n        for col_no, ch in enumerate(line, start=1):\n            if not is_acceptable_character(ch):\n                violations.append((line_no, col_no, ch))\n\n    return violations\n\n\ndef validate_text_for_export(text: str) -> bool:\n    \"\"\"\n    Validate `text` and raise if it contains any unacceptable characters.\n\n    Args:\n        text: The text destined for export.\n\n    Returns:\n        `True` if the text is valid for export\n\n    Raises:\n        UnacceptableCharacterError: If at least one non-printable character is\n        found.\n    \"\"\"\n\n    violations = find_unacceptable_characters(text)\n    if violations:\n        raise UnacceptableCharacterError(violations)\n    return True\n\n\ndef remove_unacceptable_characters(text: str) -> str:\n    \"\"\"\n    Produce a copy of `text` with all non-printable characters removed.\n\n    The removal uses the same acceptability rules as validation, ensuring the\n    behaviour stays consistent with `is_acceptable_character()` and the shared\n    whitelist.  Standard line breaks are preserved.  Characters considered\n    unacceptable (i.e., not printable and not in the whitelist) are omitted\n    from the result.\n\n    Args:\n        text: The input string to sanitize.\n\n    Returns:\n        A new string with all unacceptable characters removed.\n\n    Notes:\n        The scan mirrors `find_unacceptable_characters()` by operating\n        line-by-line.  Unlike validation, there is no error raised; the\n        offending characters are dropped from the output.  Newline characters\n        (``\\\\n`` and ``\\\\r``) are preserved so the original line structure is\n        maintained.\n    \"\"\"\n\n    cleaned_parts: List[str] = []\n    for line in text.splitlines(keepends=True):\n        # Keepends means any trailing '\\n'/'\\r\\n' is part of `line`.\n        out_line_chars: List[str] = []\n        for ch in line:\n            # Preserve standard line breaks exactly as seen.\n            if ch == \"\\n\" or ch == \"\\r\":\n                out_line_chars.append(ch)\n                continue\n            if is_acceptable_character(ch):\n                out_line_chars.append(ch)\n        cleaned_parts.append(\"\".join(out_line_chars))\n    return \"\".join(cleaned_parts)\n"
  },
  {
    "path": "exporter/views.py",
    "content": "import os\nimport re\nimport shutil\nimport tempfile\nfrom collections.abc import Iterable\nfrom logging import getLogger\nfrom pathlib import Path\nfrom typing import Any, List\n\nimport bagit\nimport boto3\nfrom django.conf import settings\nfrom django.contrib.admin.views.decorators import staff_member_required\nfrom django.contrib.postgres.aggregates.general import StringAgg\nfrom django.db.models import OuterRef, Subquery, TextField, Value\nfrom django.db.models.functions import Coalesce\nfrom django.db.models.query import QuerySet\nfrom django.http import (\n    HttpRequest,\n    HttpResponse,\n    HttpResponseRedirect,\n)\nfrom django.shortcuts import render\nfrom django.utils.decorators import method_decorator\nfrom django.views.generic import TemplateView\n\nfrom concordia.models import (\n    Asset,\n    Campaign,\n    Item,\n    Project,\n    Transcription,\n    TranscriptionStatus,\n)\nfrom exporter.exceptions import UnacceptableCharacterError\nfrom exporter.tabular_export.core import export_to_csv_response, flatten_queryset\nfrom exporter.utils import validate_text_for_export\n\nlogger = getLogger(__name__)\n\n\ndef get_latest_transcription_data(\n    asset_qs: QuerySet[Asset],\n) -> QuerySet[Asset]:\n    \"\"\"\n    Annotate each asset with its latest transcription text.\n\n    The annotation is named ``latest_transcription`` and is derived from the\n    most recent ``Transcription`` by primary key.\n\n    Args:\n        asset_qs:\n            QuerySet[Asset] to annotate.\n\n    Returns:\n        QuerySet[Asset]: The input queryset annotated with\n        ``latest_transcription``.\n    \"\"\"\n    latest_trans_subquery = (\n        Transcription.objects.filter(asset=OuterRef(\"pk\"))\n        .order_by(\"-pk\")\n        .values(\"text\")\n    )\n\n    assets = asset_qs.annotate(\n        latest_transcription=Coalesce(\n            Subquery(latest_trans_subquery[:1]),\n            Value(\"\", output_field=TextField()),\n        )\n    )\n    return assets\n\n\ndef get_tag_values(asset_qs: QuerySet[Asset]) -> QuerySet[Asset]:\n    \"\"\"\n    Annotate each asset with a semicolon-joined string of tag values.\n\n    The annotation is named ``tag_values`` and aggregates related tag text from\n    ``userassettagcollection__tags__value``.\n\n    Args:\n        asset_qs:\n            QuerySet[Asset] to annotate.\n\n    Returns:\n        QuerySet[Asset]: The input queryset annotated with ``tag_values``.\n    \"\"\"\n    assets = asset_qs.annotate(\n        tag_values=StringAgg(\n            \"userassettagcollection__tags__value\",\n            \"; \",\n            default=Value(\"\", output_field=TextField()),\n        )\n    )\n    return assets\n\n\ndef remove_incomplete_items(item_qs: QuerySet[Item]) -> QuerySet[Asset]:\n    \"\"\"\n    Filter out items that are not fully completed and return their assets.\n\n    An item is considered incomplete if any of its assets have a status of\n    NOT_STARTED, IN_PROGRESS or SUBMITTED.\n\n    Args:\n        item_qs:\n            QuerySet[Item] to check for completeness.\n\n    Returns:\n        QuerySet[Asset]: Assets belonging to completed items, ordered by\n        project, item and sequence.\n    \"\"\"\n    incomplete_item_assets = Asset.objects.filter(\n        item__in=item_qs,\n        transcription_status__in=(\n            TranscriptionStatus.NOT_STARTED,\n            TranscriptionStatus.IN_PROGRESS,\n            TranscriptionStatus.SUBMITTED,\n        ),\n    )\n    item_qs = item_qs.exclude(asset__in=incomplete_item_assets)\n    asset_qs = Asset.objects.filter(item__in=item_qs).order_by(\n        \"item__project\", \"item\", \"sequence\"\n    )\n    return asset_qs\n\n\ndef get_original_asset_id(download_url: str) -> str:\n    \"\"\"\n    Derive a stable external asset identifier from a LOC download URL.\n\n    For ``tile.loc.gov`` URLs a best-effort pattern is used to extract the\n    identifier. Non-matching URLs are returned unchanged.\n\n    Args:\n        download_url:\n            The asset's download URL.\n\n    Returns:\n        str: Identifier suitable for naming files inside the BagIt payload.\n\n    Raises:\n        ValueError: If the URL looks like ``tile.loc.gov`` but no ID is found.\n    \"\"\"\n    download_url = download_url.replace(\"https\", \"http\")\n    if download_url.startswith(\"http://tile.loc.gov/\"):\n        pattern = (\n            r\"/service:([A-Za-z0-9:.\\-\\_]+)/\"\n            + r\"|/master/([A-Za-z0-9/]+)([0-9.]+)\"\n            + r\"|/public:[A-Za-z0-9]+:([A-Za-z0-9:.\\-_]+?)/\"\n        )\n        asset_id = re.search(pattern, download_url)\n        if not asset_id:\n            logger.error(\n                \"Could not find a matching asset ID in download URL %s\",\n                download_url,\n            )\n            raise ValueError(\n                f\"Could not find a matching asset ID in download URL {download_url}\"\n            )\n        matching_asset_id = next((group for group in asset_id.groups() if group))\n        logger.debug(\n            \"Found asset ID %s in download URL %s\",\n            matching_asset_id,\n            download_url,\n        )\n        return matching_asset_id\n\n    logger.warning(\n        \"Download URL does not start with tile.loc.gov: %s\",\n        download_url,\n    )\n    return download_url\n\n\ndef write_distinct_asset_resource_file(\n    assets: Iterable[Any], export_base_dir: str | Path\n) -> None:\n    \"\"\"\n    Write a unique list of resource URLs for the provided assets.\n\n    The file is named ``item-resource-urls.txt`` and written at the export\n    root. Each line contains one distinct ``Asset.resource_url``.\n\n    Args:\n        assets:\n            Iterable of asset identifiers or a QuerySet[Asset]. Passed to\n            ``Asset.objects.filter(pk__in=assets)``.\n        export_base_dir:\n            Directory where the file should be created.\n\n    Raises:\n        AssertionError: If an asset has no ``resource_url``.\n    \"\"\"\n    asset_resource_file = os.path.join(export_base_dir, \"item-resource-urls.txt\")\n\n    with open(asset_resource_file, \"a\") as f:\n        distinct_resource_urls = (\n            Asset.objects.filter(pk__in=assets)\n            .order_by(\"resource_url\")\n            .values_list(\"resource_url\", \"title\")\n            .distinct(\"resource_url\")\n        )\n\n        for url, title in distinct_resource_urls:\n            if url:\n                f.write(url)\n                f.write(\"\\n\")\n            else:\n                logger.error(\"No resource URL found for asset %s\", title)\n                raise AssertionError\n\n\ndef do_bagit_export(\n    assets: Iterable[Asset] | QuerySet[Asset],\n    export_base_dir: str | Path,\n    export_filename_base: str,\n    request: HttpRequest | None = None,\n) -> HttpResponse | HttpResponseRedirect | List[dict[str, Any]]:\n    \"\"\"\n    Build and deliver a BagIt package for ``assets`` or report invalid chars.\n\n    The function validates every asset's ``latest_transcription``. For each\n    unacceptable character it records:\n    - the asset ID\n    - the 1-based line number\n    - the 1-based column number\n    - the offending character\n\n    Behaviour:\n    1. Validation pass: files are written only after a transcription passes\n       validation.\n    2. Failure(s): any files already written are removed. If ``request`` is\n       supplied a template is rendered, otherwise the raw error list is\n       returned.\n    3. All clear: a BagIt structure is created, zipped and either returned as a\n       download or uploaded to S3.\n\n    Args:\n        assets:\n            Iterable or QuerySet of ``Asset`` to export. Each must have\n            ``download_url`` and ``latest_transcription``.\n        export_base_dir:\n            Temporary directory into which the BagIt hierarchy is built.\n        export_filename_base:\n            Base name (without ``.zip``) for the archive.\n        request:\n            Current request. If provided, an error template will be rendered\n            when validation fails.\n\n    Returns:\n        HttpResponse | HttpResponseRedirect | list[dict[str, Any]]:\n\n        - a download response when packaging locally\n        - a redirect to the uploaded archive when S3 is configured\n        - a list of validation errors when ``request`` is ``None`` and errors\n          were found\n    \"\"\"\n    export_base_dir = Path(export_base_dir)\n    errors: List[dict[str, Any]] = []\n\n    # Validate every transcription before writing anything to disk\n    for asset in assets:\n        asset_id = get_original_asset_id(asset.download_url)\n        asset_id = asset_id.replace(\":\", \"/\")  # BagIt-safe path fragment\n\n        transcription = asset.latest_transcription or \"\"\n        try:\n            validate_text_for_export(transcription)\n        except UnacceptableCharacterError as err:\n            errors.append({\"asset\": asset, \"violations\": err.violations})\n            continue\n\n        # Passed validation -> write the transcription file\n        asset_path, asset_filename = os.path.split(asset_id)\n        asset_dest_path = export_base_dir / asset_path\n        asset_dest_path.mkdir(parents=True, exist_ok=True)\n\n        if transcription:\n            with open(asset_dest_path / f\"{asset_filename}.txt\", \"w\") as fp:\n                fp.write(transcription)\n\n    # If any errors -> cleanup and report\n    if errors:\n        if export_base_dir.exists():\n            shutil.rmtree(export_base_dir, ignore_errors=True)\n\n        if request is not None:\n            return render(\n                request,\n                \"admin/exporter/unacceptable_character_report.html\",\n                {\"errors\": errors},\n            )\n        return errors\n\n    # All assets valid -> create BagIt, zip, upload / return\n    write_distinct_asset_resource_file(assets, export_base_dir)\n\n    bagit.make_bag(\n        export_base_dir,\n        {\n            \"Content-Access\": \"web\",\n            \"Content-Custodian\": \"dcms\",\n            \"Content-Process\": \"crowdsourced\",\n            \"Content-Type\": \"textual\",\n            \"LC-Bag-Id\": export_filename_base,\n            \"LC-Items\": f\"{len(assets)} transcriptions\",\n            \"LC-Project\": \"gdccrowd\",\n            \"License-Information\": \"Public domain\",\n        },\n    )\n\n    archive_name = shutil.make_archive(\n        str(export_base_dir), \"zip\", root_dir=export_base_dir\n    )\n    export_filename = f\"{export_filename_base}.zip\"\n\n    s3_bucket = getattr(settings, \"EXPORT_S3_BUCKET_NAME\", None)\n    if s3_bucket:\n        logger.debug(\"Uploading exported bag to S3 bucket %s\", s3_bucket)\n        s3 = boto3.resource(\"s3\")\n        s3.Bucket(s3_bucket).upload_file(archive_name, export_filename)\n\n        return HttpResponseRedirect(\n            f\"https://{s3_bucket}.s3.amazonaws.com/{export_filename}\"\n        )\n\n    # Local download\n    with open(archive_name, \"rb\") as zip_file:\n        response = HttpResponse(zip_file, content_type=\"application/zip\")\n    response[\"Content-Disposition\"] = f\"attachment; filename={export_filename}\"\n    return response\n\n\nclass ExportCampaignToCSV(TemplateView):\n    \"\"\"\n    Stream a CSV of the most recent transcription for each asset in a campaign.\n\n    Only the latest transcription text per asset is included. Tag values\n    are aggregated into a semicolon-delimited string.\n    \"\"\"\n\n    @method_decorator(staff_member_required)\n    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:\n        \"\"\"\n        Return a CSV response for the requested campaign.\n\n        Args:\n            request: Current HTTP request.\n\n        Returns:\n            HttpResponse: CSV content for the campaign.\n        \"\"\"\n        asset_qs: QuerySet[Asset] = Asset.objects.filter(\n            item__project__campaign__slug=self.kwargs[\"campaign_slug\"]\n        )\n        assets: QuerySet[Asset] = get_latest_transcription_data(asset_qs)\n        assets = get_tag_values(assets)\n\n        headers, data = flatten_queryset(\n            assets,\n            field_names=[\n                \"item__project__campaign__title\",\n                \"item__project__title\",\n                \"item__title\",\n                \"item__item_id\",\n                \"title\",\n                \"id\",\n                \"transcription_status\",\n                \"download_url\",\n                \"latest_transcription\",\n                \"tag_values\",\n            ],\n            extra_verbose_names={\n                \"item__project__campaign__title\": \"Campaign\",\n                \"item__project__title\": \"Project\",\n                \"item__title\": \"Item\",\n                \"item__item_id\": \"ItemId\",\n                \"item_id\": \"ItemId\",\n                \"title\": \"Asset\",\n                \"id\": \"AssetId\",\n                \"transcription_status\": \"AssetStatus\",\n                \"download_url\": \"DownloadUrl\",\n                \"latest_transcription\": \"Transcription\",\n                \"tag_values\": \"Tags\",\n            },\n        )\n\n        logger.info(\"Exporting %s to csv\", self.kwargs[\"campaign_slug\"])\n        return export_to_csv_response(\n            \"%s.csv\" % self.kwargs[\"campaign_slug\"], headers, data\n        )\n\n\nclass ExportItemToBagIt(TemplateView):\n    \"\"\"\n    Build a BagIt archive for a single item consisting of completed assets.\n\n    Only assets with ``TranscriptionStatus.COMPLETED`` are included.\n    \"\"\"\n\n    @method_decorator(staff_member_required)\n    def get(\n        self, request: HttpRequest, *args, **kwargs\n    ) -> HttpResponse | HttpResponseRedirect:\n        \"\"\"\n        Create and return a BagIt archive for the requested item.\n\n        Args:\n            request: Current HTTP request.\n\n        Returns:\n            HttpResponse | HttpResponseRedirect: Local zip download or redirect\n            to S3.\n        \"\"\"\n        campaign_slug = self.kwargs[\"campaign_slug\"]\n        project_slug = self.kwargs[\"project_slug\"]\n        item_id = self.kwargs[\"item_id\"]\n\n        asset_qs: QuerySet[Asset] = Asset.objects.filter(\n            item__project__campaign__slug=campaign_slug,\n            item__project__slug=project_slug,\n            item__item_id=item_id,\n            transcription_status=TranscriptionStatus.COMPLETED,\n        ).order_by(\"sequence\")\n\n        assets: QuerySet[Asset] = get_latest_transcription_data(asset_qs)\n\n        campaign = Campaign.objects.get(slug__exact=campaign_slug)\n        campaign_slug_dbv = campaign.slug\n        project = Project.objects.get(campaign=campaign, slug__exact=project_slug)\n        project_slug_dbv = project.slug\n        item_id_dbv = Item.objects.get(item_id__exact=item_id).item_id\n\n        export_filename_base = \"%s-%s-%s\" % (\n            campaign_slug_dbv,\n            project_slug_dbv,\n            item_id_dbv,\n        )\n\n        with tempfile.TemporaryDirectory(\n            prefix=export_filename_base\n        ) as export_base_dir:\n            return do_bagit_export(\n                assets, export_base_dir, export_filename_base, request\n            )\n\n\nclass ExportProjectToBagIt(TemplateView):\n    \"\"\"\n    Build a BagIt archive for a project consisting of completed items only.\n    \"\"\"\n\n    @method_decorator(staff_member_required)\n    def get(\n        self, request: HttpRequest, *args, **kwargs\n    ) -> HttpResponse | HttpResponseRedirect:\n        \"\"\"\n        Create and return a BagIt archive for the requested project.\n\n        Args:\n            request: Current HTTP request.\n\n        Returns:\n            HttpResponse | HttpResponseRedirect: Local zip download or redirect\n            to S3.\n        \"\"\"\n        campaign_slug = self.kwargs[\"campaign_slug\"]\n        project_slug = self.kwargs[\"project_slug\"]\n\n        item_qs: QuerySet[Item] = Item.objects.filter(\n            project__campaign__slug=campaign_slug, project__slug=project_slug\n        )\n        asset_qs: QuerySet[Asset] = remove_incomplete_items(item_qs)\n        assets: QuerySet[Asset] = get_latest_transcription_data(asset_qs)\n\n        campaign = Campaign.objects.get(slug__exact=campaign_slug)\n        campaign_slug_dbv = campaign.slug\n        project = Project.objects.get(campaign=campaign, slug__exact=project_slug)\n        project_slug_dbv = project.slug\n\n        export_filename_base = \"%s-%s\" % (campaign_slug_dbv, project_slug_dbv)\n\n        with tempfile.TemporaryDirectory(\n            prefix=export_filename_base\n        ) as export_base_dir:\n            return do_bagit_export(\n                assets, export_base_dir, export_filename_base, request\n            )\n\n\nclass ExportCampaignToBagIt(TemplateView):\n    \"\"\"\n    Build a BagIt archive for a campaign consisting of completed items only.\n    \"\"\"\n\n    @method_decorator(staff_member_required)\n    def get(\n        self, request: HttpRequest, *args, **kwargs\n    ) -> HttpResponse | HttpResponseRedirect:\n        \"\"\"\n        Create and return a BagIt archive for the requested campaign.\n\n        Args:\n            request: Current HTTP request.\n\n        Returns:\n            HttpResponse | HttpResponseRedirect: Local zip download or redirect\n            to S3.\n        \"\"\"\n        campaign_slug = self.kwargs[\"campaign_slug\"]\n\n        item_qs: QuerySet[Item] = Item.objects.filter(\n            project__campaign__slug=campaign_slug\n        )\n        asset_qs: QuerySet[Asset] = remove_incomplete_items(item_qs)\n        assets: QuerySet[Asset] = get_latest_transcription_data(asset_qs)\n\n        campaign_slug_dbv = Campaign.objects.get(slug__exact=campaign_slug).slug\n\n        export_filename_base = \"%s\" % (campaign_slug_dbv,)\n\n        with tempfile.TemporaryDirectory(\n            prefix=export_filename_base\n        ) as export_base_dir:\n            return do_bagit_export(\n                assets, export_base_dir, export_filename_base, request\n            )\n\n\nclass ExportProjectToCSV(TemplateView):\n    \"\"\"\n    Stream a CSV of the most recent transcription for each asset in a project.\n\n    Only the latest transcription text per asset is included. Tag values\n    are aggregated into a semicolon-delimited string.\n    \"\"\"\n\n    @method_decorator(staff_member_required)\n    def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:\n        \"\"\"\n        Return a CSV response for the requested project.\n\n        Args:\n            request: Current HTTP request.\n\n        Returns:\n            HttpResponse: CSV content for the project.\n        \"\"\"\n        campaign_slug = self.kwargs[\"campaign_slug\"]\n        project_slug = self.kwargs[\"project_slug\"]\n\n        campaign = Campaign.objects.get(slug__exact=campaign_slug)\n        project = Project.objects.get(campaign=campaign, slug__exact=project_slug)\n\n        asset_qs: QuerySet[Asset] = Asset.objects.filter(item__project=project)\n        assets: QuerySet[Asset] = get_latest_transcription_data(asset_qs)\n        assets = get_tag_values(assets)\n\n        headers, data = flatten_queryset(\n            assets,\n            field_names=[\n                \"item__project__campaign__title\",\n                \"item__project__title\",\n                \"item__title\",\n                \"item__item_id\",\n                \"title\",\n                \"id\",\n                \"transcription_status\",\n                \"download_url\",\n                \"latest_transcription\",\n                \"tag_values\",\n            ],\n            extra_verbose_names={\n                \"item__project__campaign__title\": \"Campaign\",\n                \"item__project__title\": \"Project\",\n                \"item__title\": \"Item\",\n                \"item__item_id\": \"ItemId\",\n                \"item_id\": \"ItemId\",\n                \"title\": \"Asset\",\n                \"id\": \"AssetId\",\n                \"transcription_status\": \"AssetStatus\",\n                \"download_url\": \"DownloadUrl\",\n                \"latest_transcription\": \"Transcription\",\n                \"tag_values\": \"Tags\",\n            },\n        )\n\n        logger.info(\"Exporting %s to csv\", self.kwargs[\"project_slug\"])\n        return export_to_csv_response(\n            f\"{campaign.slug}-{project.slug}.csv\", headers, data\n        )\n"
  },
  {
    "path": "fixtures/original-static-pages.json",
    "content": "[\n    {\n        \"fields\": {\n            \"body\": \"crowd.loc.gov provides students of all ages with opportunities to explore unique historical documents from the collections of the Library of Congress.\\n\\nDeciphering and transcribing these documents can build students\\u2019 skills in close reading, examining historical context, and building interpretive consensus.\\n\\n## Teaching Ideas\\n\\n- As students transcribe a document, urge them to pay close attention to the language used in the document. What does that language tell them about who the audience for the document was? Was it a close friend or family member? A powerful person? A complete stranger? If the document is a letter, what clues can they find in the greeting and closing?\\n\\n- Prompt students to speculate about what the document\\u2019s author was trying to accomplish. What strategies, persuasive or otherwise, did the author use to accomplish their goal?\\n\\n- Challenge students to recreate the document in their own words, using today\\u2019s communication tools. What did your students say differently? What did they say that was the same?\\n\\n- Ask students to identify clues about the time and place in which the document was created. What do they know about what was going on at the time? How can they find out more? \\n\\n- Ask students to research and describe what happened as a result of this document being created. If they can\\u2019t identify what happened, ask them to speculate about what might have happened as a result of the document.\\n\\n- As students complete the transcription-reconciliation process, encourage them to take note of the ways they compared the differences between different transcriptions of the same document. What similarities and differences can they find between this process and the process of evaluating sources for a class project or research paper?\\n\\n## Primary Source Analysis\\n\\nThe documents in crowd.loc.gov are _primary sources_ \\u2014 the raw materials of history and culture. Analyzing primary sources like these can give students a powerful sense of the complexity of the past, and can guide them toward higher-order thinking and better critical thinking skills.\\n\\nUse the Library\\u2019s [primary source analysis tool and teacher\\u2019s guide to analyzing manuscripts](http://www.loc.gov/teachers/usingprimarysources/guides.html) to guide your students through an analysis of any of the documents in crowd.loc.gov.\\n\\n**A note about content**: The historical documents in the Library\\u2019s collections may include language or topics that aren\\u2019t appropriate for your students, or that your students might find especially difficult to engage with. You may want to review documents before assigning them, or to use some of the strategies explored here.\\n\\n## Campaign Specific Resources\\n\\n### Letters to Lincoln (Featured Challenge for 2018!)\\n\\n- [\\\"I Do Solemnly Swear...\\\"  Presidential Inaugurations](https://www.loc.gov/rr/program/bib/inaugurations/index.html)\\n- [The Library of Congress Celebrates the Songs of America](https://www.loc.gov/collections/songs-of-america/about-this-collection/)\\n- [Additional Primary Materials](http://www.loc.gov/teachers/classroommaterials/primarysourcesets/lincoln/)\\n\\n### Branch Rickey: Changing the Game\\n\\n- Lesson Plan: [Baseball, Race and Ethnicity: Rounding the Bases](http://www.loc.gov/teachers/classroommaterials/lessons/bases/)\\n- Lesson Plan: [Baseball, Race Relations and Jackie Robinson](http://www.loc.gov/teachers/classroommaterials/lessons/robinson/)\\n- Primary Source Set: [Baseball Across a Divided Society](http://www.loc.gov/teachers/classroommaterials/primarysourcesets/baseball/)\\n\\n### Civil War Soldiers: \\\"Disabled but not disheartened\\\"\\n\\n- Lesson plan: [The Civil War Through a Child's Eye](http://www.loc.gov/teachers/classroommaterials/lessons/childs-eye/)\\n- Teacher's guide: [The Civil War: The Nation Moves Towards War, 1850-61](http://www.loc.gov/teachers/classroommaterials/primarysourcesets/civil-war-approach/)\\n\\n### Clara Barton: \\\"Angel of the Battlefield\\\"\\n\\n- [Clara Barton Missing Soldiers Office Museum External additional resources](http://www.clarabartonmuseum.org/learn/studentresearch/)\\n\\n### Mary Church Terrell: Advocate for African Americans and Women\\n\\n- Lesson plan: [Suffrage Strategies: Voices for Votes](http://www.loc.gov/teachers/classroommaterials/lessons/suffrage/)\\n- [http://www.loc.gov/teachers/classroommaterials/lessons/strivings/](http://www.loc.gov/teachers/classroommaterials/lessons/strivings/)\\n- [Votes for Women: Selections from the National American Woman Suffrage Association Collection, 1848 to 1921](https://www.loc.gov/teachers/classroommaterials/connections/votes-women/)\\n- [Civil Rights, information for Students](http://www.loc.gov/teachers/classroommaterials/themes/civil-rights/students.html)\\n- [From Slavery to Civil Rights: A Timeline of African American History](https://www.loc.gov/teachers/classroommaterials/presentationsandactivities/presentations/civil-rights/) \\n- [Segregation: From Jim Crow to Linda Brown](http://www.loc.gov/teachers/classroommaterials/lessons/jimcrow/)\",\n            \"created_on\": \"2018-11-26T22:00:43.688Z\",\n            \"path\": \"/for-educators/\",\n            \"title\": \"Resources for Educators\",\n            \"updated_on\": \"2018-11-26T22:00:43.696Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 1\n    },\n    {\n        \"fields\": {\n            \"body\": \"## Join us at the Library for the 155th Anniversary of the Gettysburg Address\\n\\n---\\n\\nOn November 19, 1863, Abraham Lincoln delivered the Gettysburg Address in Gettysburg, Pennsylvania. The Library of Congress will mark the 155th anniversary of this historic speech with a one-day celebration, featuring an exhibition of the earliest known draft of the speech, and a Letters to Lincoln Challenge transcribe-a-thon for volunteers on and off site!\\n\\n### Schedule\\n\\n-   08:30am Jefferson Building doors open!\\n-   10:00am Welcome remarks by Dr. Carla Hayden, 14th Librarian of Congress\\n-   10:05am Special talk about the background of the Gettysburg Address by historian and curator Michelle Krowl\\n-   10:15am Gettysburg Address delivered by student orator Christian Melgar\\n-   10:25am Details of the Letters to Lincoln Challenge for onsite and remote participants\\n-   10:30am Nicolay copy of the Gettysburg Address unveiled \\n-   10:35am Letters to Lincoln Challenge transcribe-a-thon begins \\n-   10:35am-1:30pm Letters to Lincoln Challenge Transcribe-a-thon\\n-   4:30pm Jefferson Building doors close for the day \\n\\n#### In the area on November 19th?\\n\\nJoin us in the Great Hall of the Jefferson Building (Library of Congress) to have the rare opportunity to see the Nicolay copy of the Gettysburg Address in person. Dr. Hayden will kick things off at 10am, followed by a reading of the Address by a student orator, and a special talk about Lincoln and the Gettysburg Address, by historian and curator Michelle Krowl. After the Gettysburg Address is revealed at 10:30 various hands on transcription and learning opportunities will be available. \\n\\n#### Off site but online?\\n\\nWe\\u2019ll livestream the Librarian and curator\\u2019s talks, and the reading of the Gettysburg address from 10:00-10:30 [via this link](https://www.youtube.com/loc). After that people at the Library and online can participate in the #LettersToLincoln challenge right here on [crowd.loc.gov](/campaigns/letters-to-lincoln/)! Choose an item from the Letters to Lincoln Campaign to transcribe, review, and/or tag. So far we've made material from the 1830s through 1850s available, but for our transcribe-a-thon we'll release materials from 1860, 1861, and 1862, spanning part of the Civil War and important moments in Lincoln's career and the history of the nation. Join in the discussion on [History Hub](https://historyhub.history.gov/community/crowd-loc) and [Twitter](https://twitter.com/Crowd_LOC)! \\n\\nYou can [check in live](https://historyhub.history.gov/community/crowd-loc/blog/2018/11/14/transcribe-with-us-nov-19-the-155th-anniversary-of-the-gettysburg-address) with a Community Manager on History Hub throughout the transcribe-a-thon from 10:30am to 1:30pm EST. \\n\\n#### Opportunities for students near and far\\n\\nTune in at 10:00am for the livestream or join us in person to see the Gettysburg address, hear talks, and do some hands on activities and online transcriptions. Students onsite or in their own classrooms will be invited to transcribe, tag, and review documents received by Abraham Lincoln throughout his career. You can transcribe as a group or challenge your students to transcribe on their own or in pairs. All transcriptions are reviewed by at least one other volunteer, so don't be shy to try! If you or your students are finding it hard to read something, try finding something to review first. This is a great way to \\\"get your eye in\\\" and learn from others how to read these original documents.\\n\\nTo confirm participation for your class or students, please e-mail the Community Managers at [crowd@loc.gov](mailto:crowd@loc.gov) for further instructions. [Visit History Hub](https://historyhub.history.gov/community/crowd-loc/blog/2018/11/14/transcribe-with-us-nov-19-the-155th-anniversary-of-the-gettysburg-address) for your transcribe-a-thon pack including details on how to take part. \\n\\n## Letters to Lincoln Challenge\\n\\n---\\n\\n![Cover of yellow envelope with drawn portrait of Lincoln in the top left corner and a depiction of Lincoln chopping wood in the middle](/static/img/LincolnCampaign.jpg)\\n\\n### Help us transcribe 10,000+ items from the Abraham Lincoln Papers by the end of 2018!\\n\\n#### A grand challenge: why we're asking you to join us\\n\\nAround half of the digitized Abraham Lincoln Papers, primarily materials written by Lincoln, have been transcribed by other volunteers at Knox College and elsewhere, and are already keyword searchable at loc.gov. However, there remain 10,000+ items including letters and other materials sent to him that are not yet keyword searchable. Completing the [Letters to Lincoln Challenge](https://crowd.loc.gov/campaigns/letters-to-lincoln/) will make all of the digital Lincoln Papers word-searchable and accessible to future readers. Just imagine the possibilities--from new research to local connections--that will be possible once we've achieved this goal. Thank you in advance for sharing your time with us. Your Community Managers, reference librarians and curatorial staff here at the Library of Congress will be cheering you on with bonus historical context and resources all along the way, as well as some special rewards for goals met!\\n\\n#### So, what’s the challenge? Our first milestone was completion of all the material in the first three Campaign projects: \\\"1830-1839, first forays in politics and law,\\\" \\\"1840-1849, marriage, election to Congress,\\\" and \\\"1850-1857: death and birth of children, and re-entry to politics\\\" by November 1st. Our next *updated* challenge to you is to transcribe and review all 646 pages in the \\\"1858-1859 Presidential Nomination\\\" project by midnight on November 6th, election day!\\n\\nCan you transcribe even just one letter and share the challenge with one friend to help push toward our goal?  When the project completes we’ll move onto the next exciting decade of Lincoln's life, the 1850s when he returned to politics.\\n\\n#### What are the Letters to Lincoln?\\n\\nYou might guess that the Abraham Lincoln Papers include materials written in his own hand, but did you know the collection contains correspondence sent to Abraham Lincoln throughout his life and political career? Here's a taste of what you'll find: a range of materials by writers ranging from friends and associates from Lincoln’s Springfield days, well-known political figures and reformers, constituents writing to their President, and even the occasional document in Lincoln's own hand. Read the concerns and requests of nineteenth-century Americans and international correspondents.\\n\\n## Library of Congress News\\n\\n---\\n\\nEnabling Discovery of unique treasures at the Library of Congress [Press release](https://www.loc.gov/item/prn-18-134/crowdsourcing-tool-enables-discovery-of-unique-treasures-at-the-library-of-congress/2018-10-24/)\\n\\nAnnouncing crowd.loc.gov: [Here we go!](https://blogs.loc.gov/thesignal/2018/10/lets-go-explore-transcribe-and-tag-at-crowd-loc-gov/)\\n\\n[Connecting crowdsourcing](https://blogs.loc.gov/thesignal/2018/10/new-strategy-new-crowd-new-team/) to the new Library of Congress 2019-2023 Strategic Plan and Digital Strategy\\n\\n## Press coverage\\n\\n---\\n\\nMental Floss - 28 October 2018 - [The Library of Congress Needs Help Transcribing Lincoln's Letters and Other Historic Documents](http://mentalfloss.com/article/561842/library-congress-needs-help-transcribing-lincolns-letters-and-other-historic)\",\n            \"created_on\": \"2018-11-26T22:00:43.700Z\",\n            \"path\": \"/latest/\",\n            \"title\": \"Latest News\",\n            \"updated_on\": \"2018-11-26T22:00:43.702Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 2\n    },\n    {\n        \"fields\": {\n            \"body\": \"<div class=\\\"row my-default\\\">\\n    <div class=\\\"col-sm-12\\\">\\n        <div class=\\\"card bg-dark text-white text center mx-default\\\">\\n        <img src=\\\"/static/img/help-center.jpg\\\" alt=\\\"Spread of Branch Rickey papers\\\">\\n          <div class=\\\"card-body pxy-default card-img-overlay\\\">\\n            <h2 class=\\\"card-title text-white\\\">Welcome to crowd.loc.gov</h2>\\n            <h5 class=\\\"card-text\\\"> Here you will find a guide to help you get started. </h5>\\n            <a href=\\\"welcome-guide/\\\" class=\\\"btn btn-primary\\\">Learn more &raquo;</a>\\n          </div>\\n        </div>\\n    </div>\\n</div>\\n\\n# Guides\\n\\n<div class=\\\"help-center-cards text-white row my-default\\\">\\n  <div class=\\\"col-sm-4\\\">\\n    <div class=\\\"help-center-card card text center mx-default\\\">\\n      <div class=\\\"card-body pxy-default\\\">\\n        <h4 class=\\\"card-title\\\"><a href=\\\"how-to-transcribe/\\\">How to Transcribe &raquo;</a></h4>\\n      </div>\\n    </div>\\n  </div>\\n  <div class=\\\"col-sm-4\\\">\\n    <div class=\\\"help-center-card card text center mx-default\\\">\\n      <div class=\\\"card-body pxy-default\\\">\\n        <h4 class=\\\"card-title\\\"><a href=\\\"how-to-review/\\\">How to Review &raquo;</a></h4>\\n      </div>\\n    </div>\\n  </div>\\n  <div class=\\\"col-sm-4\\\">\\n    <div class=\\\"help-center-card card text center mx-default\\\">\\n      <div class=\\\"card-body pxy-default\\\">\\n        <h4 class=\\\"card-title\\\"><a href=\\\"how-to-tag/\\\">How to Tag &raquo;</a></h4>\\n      </div>\\n    </div>\\n  </div>\\n</div>\\n\\n# FAQs\\n\\n<div class=\\\"accordion\\\" id=\\\"faqAccordion\\\">\\n  <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"whatIsCrowdHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#whatIsCrowd\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"whatIsCrowd\\\">\\n          What is crowd.loc.gov and what is crowdsourcing?\\n        </button>\\n      </h5>\\n    </div>\\n\\n    <div id=\\\"whatIsCrowd\\\" class=\\\"collapse\\\" aria-labelledby=\\\"whatIsCrowdHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n      <p>\\n       crowd.loc.gov is an online transcription platform where anyone with an internet connection can transcribe documents from the Library of Congress\\u2019 digitized collections. We welcome anyone interested in making non-machine readable resources fully word searchable to contribute.\\n      </p>\\n      <p>\\n      Crowdsourcing invites members of the public, non-specialists and specialists alike, to help make data more usable and discoverable. Crowdsourcing at the Library of Congress invites unpaid volunteers to explore collections while gaining new skills, for example, learning to read older forms of handwriting such as cursive.\\n      </p>\\n      </div>\\n    </div>\\n\\n  </div>\\n  <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"takePartHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#takePart\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"takePart\\\">\\n          Who can take part?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"takePart\\\" class=\\\"collapse\\\" aria-labelledby=\\\"takePartHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n        Anyone who wants to help the Library make its collections more discoverable online. Anyone who is interested in history, cultural heritage, literature, languages, art, sciences, and much more. Anyone who wants to be a virtual volunteer, exploring collections and transcribing at their own pace and at times that are convenient for them. Students of all ages who want to help the Library and learn new skills.\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n  <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"volunteersHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#volunteersTogether\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"volunteersTogether\\\">\\n          How do volunteers work together?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"volunteersTogether\\\" class=\\\"collapse\\\" aria-labelledby=\\\"volunteersHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n       You'll collaborate with other volunteers to transcribe and review collections. We ask for at least one person to transcribe and a different person to review each transcription. If you find that a transcription needs a few corrections while you're reviewing, you can edit that page. Another person will then review your new edits. Sometimes more than one person will contribute to transcribe an image; such as if you find an image with a transcription that needs more work. We think of this negotiated editing process as a way to get the best version of a transcription and help solve different challenges for each image.\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"materialsHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#materials\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"materials\\\">\\n          What kinds of materials can I transcribe?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"materials\\\" class=\\\"collapse\\\" aria-labelledby=\\\"materialsHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n       Materials in crowd.loc.gov represent the diversity of the Library\\u2019s collections and are selected from across the Library of Congress\\u2019s curatorial divisions. You\\u2019ll encounter presidential papers, materials from the women's suffrage, abolition and other movements, the work of American poets, such as Walt Whitman, and much more. We will add new content regularly: <a href=\\\"https://updates.loc.gov/accounts/USLOC/subscriber/new?topic_id=USLOC_175\\\">sign up for our newsletter</a> to hear about new Campaigns and Challenges.\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"transcriptionGoalsHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#transcriptionGoals\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"transcriptionGoals\\\">\\n          How do I know if I\\u2019m transcribing, tagging or reviewing correctly?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"transcriptionGoals\\\" class=\\\"collapse\\\" aria-labelledby=\\\"transcriptionGoalsHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n       Follow the transcribe, review, or tag links at the top of the page. Our goal is to make transcriptions that are readable to computers and humans, with minimal markup, not attempting to recreate the layout of the original images. Quick tips are available within the transcription interface.\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"registerHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#register\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"register\\\">\\n          Do I have to register for an account to participate or join in the discussion?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"register\\\" class=\\\"collapse\\\" aria-labelledby=\\\"registerHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n      \\nRegistering for an account is optional, but gives you access to the tagging and reviewing features of crowd.loc.gov. Go to Register at the top of the homepage to make an account. Create a username, which will be visible to other volunteers and users of the site. Enter your email address in the \\u201cEmail\\u201d field (this will not be visible to or shared with other users), and in the password field create a unique password with a combination of capital and lowercase letters, numbers and special characters such as #, $, !, or %.\\n\\nYou can also register for a separate account on the <a href=\\\"https://historyhub.history.gov/community/crowd-loc\\\">History Hub discussion forum</a>, where you can discuss the material you are transcribing or your experience of encountering primary sources. Feel free to raise questions or concerns, especially if you think other volunteers might be able to respond or benefit from your post. History Hub is a moderated forum. The Community Managers will check in regularly to approve comments and engage in discussion, and will try to answer questions about the project or the collections within 3-5 business days. Reference Librarians and curators will also answer some questions.\\n\\n</div>\\n</div>\\n\\n  </div>\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"forgotPasswordHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#forgotPassword\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"forgotPassword\\\">\\n          What if I forget my password?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"forgotPassword\\\" class=\\\"collapse\\\" aria-labelledby=\\\"forgotPasswordHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n      \\nIf you've forgotten your password click Login, then \\\"Forgot my password\\\". You will receive an email with a link asking you to reset your password. You may also change your password within your profile.\\n      </div>\\n    </div>\\n  </div>\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"whyEmailHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#whyEmail\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"whyEmail\\\">\\n          Why do we ask for your email?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"whyEmail\\\" class=\\\"collapse\\\" aria-labelledby=\\\"whyEmailHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n      \\nYour email address gives us the ability to support you. Community managers are here to help with account administration like changes to your profile, troubleshooting any issues with contributing to a transcription, or to answer general questions. We will never share your information with other institutions or individuals. At registration you can opt in to receive email updates on crowd.loc.gov campaigns and features \\u2013 you can also <a href=\\\"https://updates.loc.gov/accounts/USLOC/subscriber/new?topic_id=USLOC_175\\\">register for emails from us here</a>.\\n      </div>\\n    </div>\\n  </div>\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"transcriptionUseHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#transcriptionUse\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"transcriptionUse\\\">\\n          How will the transcriptions I create be used?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"transcriptionUse\\\" class=\\\"collapse\\\" aria-labelledby=\\\"transcriptionUseHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n      \\nThe data contributed by volunteers like you can be used in many different ways. We are giving back to our community by making this data public. All contributions to this application are released into the public domain. Anyone is free to use this data set in any way they want. The data produced by volunteers is also free to reuse. If you need help accessing the data or want to share news of your research with the crowd.loc.gov community, please contact the Community Managers at <a href=\\\"mailto:crowd@loc.gov\\\">crowd@loc.gov</a>. The transcriptions produced on crowd.loc.gov will typically be published in the Library catalog on loc.gov within a year of a Campaign's completion. \\n      </div>\\n    </div>\\n  </div>\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"tagUseHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#tagUse\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"tagUse\\\">\\n          What are tags for?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"tagUse\\\" class=\\\"collapse\\\" aria-labelledby=\\\"tagUseHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n Tags are an experimental feature. Tags can be used to identify people, places or things in documents that are not already identified in the page or asset\\u2019s metadata on <a href=\\\"https://loc.gov\\\">loc.gov</a>. We want to understand how volunteers like to use tags. We also want to understand whether tags can someday be included in the metadata on the Library catalog to make items discoverable through search terms that are not represented in the existing metadata or the transcriptions we will produce on crowd.loc.gov.\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"offensiveContentHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#offensiveContent\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"offensiveContent\\\">\\n          What if I find offensive content while I\\u2019m transcribing and reviewing?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"offensiveContent\\\" class=\\\"collapse\\\" aria-labelledby=\\\"offensiveContentHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\nThe language and terminology used in the historical materials on this site reflect the context and culture of their creators, and may include words, phrases, and attitudes that would now be deemed insensitive, inappropriate or factually inaccurate, or may not be appropriate for all ages. Views expressed in historical documents do not reflect the views of the Library of Congress. Because the purpose of crowd.loc.gov is to make the Library\\u2019s collections searchable, we ask that all original content be transcribed as it appears in the original material. If you find some material offensive or upsetting, please choose something else to transcribe. If you have questions or comments regarding the material you encounter during your participation here, please contact a Community Manager via <a href=\\\"mailto:crowd.loc.gov@loc.gov\\\">crowd.loc.gov@loc.gov</a>, the <a href=\\\"/contact/\\\">Contact Us</a> or join and start a new conversation on the <a href=\\\"https://historyhub.history.gov/community/crowd-loc\\\">History Hub discussion forum</a>.\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n\\n <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"browserSupportHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#browserSupport\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"browserSupport\\\">\\n         What devices and browsers are supported by crowd.loc.gov?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"browserSupport\\\" class=\\\"collapse\\\" aria-labelledby=\\\"browserSupportHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\nBecause crowd.loc.gov invites you to transcribe documents, it is best experienced on a device with a large or full sized keyboard. A desktop computer or laptop is best; a tablet with keyboard should work. Unfortunately, phones are not yet supported. We recommend an external mouse for most precise zoom. We support the two most recent versions of major browsers. You\\u2019ll have the best experience if you use Chrome, Firefox, Edge, and Safari browsers. The site will not work as designed on the Internet Explorer browser.\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n  <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"technologyHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#technology\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"technology\\\">\\n         What is the technology behind crowd.loc.gov?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"technology\\\" class=\\\"collapse\\\" aria-labelledby=\\\"technologyHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\nCrowd.loc.gov runs on Concordia, new open source software developed by the Library of Congress to power crowdsourced transcription projects. The code is visible and free to reuse: <a href=\\\"https://github.com/LibraryOfCongress/concordia\\\">Visit our Github repository</a> for more information. The platform was built utilizing user-centered design principles based around building trust and approachability. This project is a partnership between the Library and a growing community of volunteers who help us to iteratively improve the platform. Everyone is welcome to take part in transcription and tagging and to give feedback about how we can improve the code base and the project itself. Be in touch!\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n  <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"privacyHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#privacy\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"privacy\\\">\\n         How do you protect my privacy?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"privacy\\\" class=\\\"collapse\\\" aria-labelledby=\\\"privacyHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\n<p>\\nA detailed explanation of the Library\\u2019s <a href=\\\"https://www.loc.gov/legal/\\\">Privacy Policy</a> including what kinds of data we collect and store, and what we use to track your session while you are on a Library website is available at this link, and in the footer of this page under the \\u201cLegal\\u201d link button.\\n</p>\\n<p>\\nYou do not have to register an account on crowd.loc.gov in order to transcribe, but you can register if you would like to review or tag. In order to make sure a transcription is submitted by a real human, anonymous users will be prompted to fill in a captcha before their first submission will be accepted. The Library\\u2019s captcha is an image of a few letters and numbers that you need to transcribe into the box below the image.\\n</p>\\n<p>\\nA session cookie will be used in your browser while you are transcribing so that you do not need to enter a captcha every time you work on a page. Session cookies for anonymous users are limited to 24 hours, so you will only be prompted to enter a captcha once a session.\\n</p>\\n<p>\\nSession cookies are used for registered users too, so that your contributions can be saved to your account. Check out your user profile to see how many pages you have transcribed, tagged and reviewed. Registered user session cookies last two weeks.\\n</p>\\n</div>\\n</div>\\n\\n  </div>\\n    <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"pastProjectsHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#pastProjects\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"pastProjects\\\">\\n         What are past crowdsourcing projects at the Library?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"pastProjects\\\" class=\\\"collapse\\\" aria-labelledby=\\\"pastProjectsHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\nThe Library of Congress has long invested in building digitized collections and making them searchable. The Library\\u2019s first attempt recruiting members of the public to increase findability on our website began in 2008 when the Photography and Prints Division published thousands of photographs on <a href=\\\"https://www.flickr.com/photos/library_of_congress\\\">Flickr Commons</a>. This long-running project invites visitors to help identify people and places in the photographs and, once verified, this rich information is used to enhance the online catalog and improve access for all users. Two additional crowdsourcing efforts within the Library include <a href=\\\"https://www.zooniverse.org/projects/sroosa/roll-the-credits\\\">Roll the Credits</a> and <a href=\\\"http://beyondwords.labs.loc.gov/\\\">Beyond Words</a>, projects that invited people to transcribe credit captions from television programs, and identify cartoons and photographs in the Library's historic newspaper collections respectively.\\n\\n      </div>\\n    </div>\\n\\n  </div>\\n    <div class=\\\"card\\\">\\n    <div class=\\\"card-header\\\" id=\\\"otherProjectsHeader\\\">\\n      <h5 class=\\\"mb-0\\\">\\n        <button class=\\\"btn btn-link collapsed\\\" type=\\\"button\\\" data-toggle=\\\"collapse\\\" data-target=\\\"#otherProjects\\\" aria-expanded=\\\"false\\\" aria-controls=\\\"otherProjects\\\">\\n         Do other libraries and cultural institutions have crowdsourcing projects?\\n        </button>\\n      </h5>\\n    </div>\\n    <div id=\\\"otherProjects\\\" class=\\\"collapse\\\" aria-labelledby=\\\"otherProjectsHeader\\\" data-parent=\\\"#faqAccordion\\\">\\n      <div class=\\\"card-body\\\">\\nIn addition to the Library of Congress\\u2019 own history of varied participatory projects, other cultural heritage institutions with established transcription programs have paved the way for crowd.loc.gov. Projects at the National Archives and Records Administration (NARA), the Smithsonian, the New York Public Library, Zooniverse.org, From the Page, and others have developed workflows and user engagement strategies that this platform leverages and builds upon. Concordia deploys a different architecture to these existing models of crowdsourced transcription, and aims to provide simple data structures and easier project implementation for cultural heritage institutions and other people who want to set up their own crowdsourced transcription projects.\\n      </div>\\n    </div>\\n  </div>\\n</div>\\n\\n_Concordia and crowd.loc.gov are supported by the National Digital Library Trust Fund. They are the result of collaboration between numerous divisions, expertise, and teams at the Library of Congress._\",\n            \"created_on\": \"2018-11-26T22:00:43.705Z\",\n            \"path\": \"/help-center/\",\n            \"title\": \"Help Center\",\n            \"updated_on\": \"2018-11-26T22:00:43.708Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 3\n    },\n    {\n        \"fields\": {\n            \"body\": \"<div class=\\\"help-center-cards text-white row my-default\\\">\\n<div class=\\\"col-sm-4\\\">\\n    <div class=\\\"help-center-card card text center mx-default\\\">\\n      <div class=\\\"card-body pxy-default\\\">\\n        <h3 class=\\\"card-title\\\"><a href=\\\"/help-center/\\\">Help Center &raquo;</a></h3>\\n        <h5 class=\\\"card-text\\\">Find guides to help you tag, transcribe, and review.</h5>\\n      </div>\\n    </div>\\n  </div>\\n  <div class=\\\"col-sm-4\\\">\\n    <div class=\\\"help-center-card card text center mx-default\\\">\\n      <div class=\\\"card-body pxy-default\\\">\\n        <h4 class=\\\"card-title\\\"><a target=\\\"_blank\\\" href=\\\"https://historyhub.history.gov/community/crowd-loc\\\">Discuss &raquo;</a></h4>\\n        <h5 class=\\\"card-text\\\"> Join the conversation on History Hub. We'll take you there in a new tab.</h5>\\n      </div>\\n    </div>\\n  </div>\\n  <div class=\\\"col-sm-4\\\">\\n    <div class=\\\"help-center-card card text center mx-default\\\">\\n      <div class=\\\"card-body pxy-default\\\">\\n        <h4 class=\\\"card-title\\\"><a href=\\\"/contact/\\\">Technical issue? &raquo;</a></h4>\\n        <h5 class=\\\"card-text\\\"> Have a problem with a page or an image? Want to see a new feature? Send us a message.</h5>\\n      </div>\\n    </div>\\n  </div>\\n</div>\",\n            \"created_on\": \"2018-11-26T22:00:43.712Z\",\n            \"path\": \"/questions/\",\n            \"title\": \"Have a question? We're here to help!\",\n            \"updated_on\": \"2018-11-26T22:00:43.715Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 4\n    },\n    {\n        \"fields\": {\n            \"body\": \"The Library of Congress launched crowd.loc.gov in the autumn of 2018. The application asks people to transcribe and tag digitized images of manuscripts and typed materials from the Library\\u2019s collections. Everyone is welcome to take part! You don't even need to create an account, but if you do you'll have access to additional features such as tagging and reviewing other people's transcriptions. All transcriptions are made by volunteers and reviewed by volunteers before they are returned to [loc.gov](https://loc.gov/), the Library's catalog. These transcriptions will improve search, readability, and access to handwritten and typed documents for those who are not fully sighted or cannot read the handwriting of the original documents. Check out the [FAQs section](/help-center/) in our Help Center for more detailed information.\\n\\nCrowd.loc.gov runs on Concordia, new open source software developed by the Library of Congress to power crowdsourced transcription projects. The code is visible and free to reuse: [visit our Github repository](https://github.com/LibraryOfCongress/concordia) for more information. The platform was built utilizing user-centered design principles based around building trust and approachability. This project is a partnership between the Library and a growing community of volunteers who help us to iteratively improve the platform. Everyone is welcome to take part in transcription and tagging and to give feedback about how we can improve the code base and the project itself. Be in touch!\\n\\nThis program is generously supported by the National Digital Library Trust Fund. This application is the result of collaboration between numerous divisions and teams at the Library of Congress.\",\n            \"created_on\": \"2018-11-26T22:00:43.718Z\",\n            \"path\": \"/about/\",\n            \"title\": \"About crowd.loc.gov\",\n            \"updated_on\": \"2018-11-26T22:00:43.720Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 5\n    },\n    {\n        \"fields\": {\n            \"body\": \"<div class=\\\"row\\\">\\n  <div class=\\\"col-3\\\">\\n    <div class=\\\"nav flex-column help-center\\\">\\n    <h4>Instructions</h4>\\n     <a class=\\\"nav-link\\\" href=\\\"/help-center/welcome-guide/\\\">Welcome to crowd.loc.gov</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-transcribe/\\\">How to transcribe</a>\\n  <a class=\\\"nav-link active\\\" href=\\\"/help-center/how-to-review/\\\">How to review</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-tag\\\">How to tag</a>\\n    </div>\\n  </div>\\n  <div class=\\\"col-9\\\">\\n\\n  <p>\\nIn addition to transcribing, you can review and edit transcriptions created by your fellow volunteers. A reviewer\\u2019s task is to read the entire transcription and carefully compare it against the image of the document. If you want to become a reviewer, please register an account.\\n</p>\\n\\n<h2>A good transcription:</h2>\\n\\n<p>\\nWhen a transcription is accurate and you do not need to make any changes, click the \\\"Approve\\\" button to mark the page as complete. The page will no longer be available for transcription, but you and other volunteers can still read the document and add tags.\\n</p>\\n\\n<h2>\\nA transcription that needs more work:\\n</h2>\\n<p>\\nWhile reviewing, you can change a transcription to fix errors or add missing material. Remember, do not edit the spelling and grammar of the original document, but do correct any spelling errors or misreadings created by the transcriber. When you’re done, click the \\\"Save\\\" and \\\"Submit\\\" buttons. Another volunteer will then need to review the page. A page is complete when a reviewer clicks \\\"Accept\\\" without making changes.\\n</p>\\n  </div>\\n</div>\",\n            \"created_on\": \"2018-11-26T22:00:43.723Z\",\n            \"path\": \"/help-center/how-to-review/\",\n            \"title\": \"How to Review\",\n            \"updated_on\": \"2018-11-26T22:00:43.725Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 6\n    },\n    {\n        \"fields\": {\n            \"body\": \"<div class=\\\"row\\\">\\n  <div class=\\\"col-3\\\">\\n    <div class=\\\"nav flex-column help-center\\\">\\n   <h4>Instructions</h4>\\n        <a class=\\\"nav-link\\\" href=\\\"/help-center/welcome-guide/\\\">Welcome to crowd.loc.gov</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-transcribe/\\\">How to transcribe</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-review/\\\">How to review</a>\\n  <a class=\\\"nav-link active\\\" href=\\\"/help-center/how-to-tag\\\">How to tag</a>\\n\\n    </div>\\n\\n  </div>\\n  <div class=\\\"col-9\\\">\\n<p>\\nYou can use tags however you like, so long as you avoid using offensive, degrading or hurtful language about other individuals or groups of people. Read the <a href=\\\"https://www.loc.gov/legal/comment-and-posting-policy/\\\">Library\\u2019s commenting policy here</a>. Here are some ways you might consider tagging:\\n<p>\\n<ul>\\n<li>If you transcribe an important word in a document, such as somebody\\u2019s name, and the original author spelled the name incorrectly, you can add a tag to provide the correct name using the \\u201cTag\\u201d button.</li>\\n<li>\\nSometimes writers use nicknames or code words. If you know or can correctly identify the full name or subject using contextual information from the larger document or collection, please tag this information using the \\u201cTag\\u201d button.\\n</li>\\n<li>  Are you interested in documents mentioning cats? Use the \\u201cTag\\u201d button to tag all pages that mention cats. Other examples include \\u201cCivil War\\u201d, \\u201cCooking\\u201d, \\u201cSports\\u201d. You can apply whatever tags you like. </li>\\n<li>  Keep tags as short as you can and use whole words instead of abbreviations. This will make it easier for other people to understand your tags and to reuse them elsewhere in the collection. </li>\\n<li> Tagging is an experimental feature. Tags might one day go back into the Library website, but for now they will only be used as a method to explore items within crowd.loc.gov.\\n</li>\\n</ul>\\n</div>\\n</div>\",\n            \"created_on\": \"2018-11-26T22:00:43.728Z\",\n            \"path\": \"/help-center/how-to-tag/\",\n            \"title\": \"How to Tag\",\n            \"updated_on\": \"2018-11-26T22:00:43.731Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 7\n    },\n    {\n        \"fields\": {\n            \"body\": \"<div class=\\\"row\\\">\\n  <div class=\\\"col-3\\\">\\n    <div class=\\\"nav flex-column help-center\\\">\\n   <h4>Instructions</h4>\\n  <a class=\\\"nav-link active\\\" href=\\\"/help-center/welcome-guide/\\\">Welcome to crowd.loc.gov</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-transcribe/\\\">How to transcribe</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-review/\\\">How to review</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-tag\\\">How to tag</a>\\n    </div>\\n  </div>\\n  <div class=\\\"col-9\\\">\\n<p>\\nWelcome to crowd.loc.gov! Our goal is to make documents word-searchable in the Library of Congress catalog. Help us transcribe original items, many of which have never been transcribed before. Anyone can transcribe, you don\\u2019t need an account, but registered volunteers can access the review and tag features. You can choose which tasks you want to do, and what pages you want to work on. To create an account, <a href=\\\"/account/register/\\\">go to our registration page</a>. Let's get started!\\n</p>\\n<ol>\\n<li>Choose what to transcribe. You can either click the \\\"Let's Go\\\" button on the <a href=\\\"/\\\">homepage</a> or <a href=\\\"/campaigns/\\\">explore our various campaigns</a>.\\n</li>\\n<li>\\nTranscribe what you can into the white box on the right. Transcribe lines in the order they appear and preserve line breaks. This will make the transcriptions useful for researchers. If you see multiple pages, such as an open notebook with two pages, just transcribe the content in the order it appears.\\n</li>\\n<li>\\nDon\\u2019t reproduce features such as underlining, bold text, line breaks or indentation in your transcription. Only words will be searchable in these transcriptions--not formatting. See the <a href=\\\"../how-to-transcribe/\\\">How to transcribe</a> section for more information.\\n</li>\\n<li>\\nClick \\u201cSave\\u201d as you go to save your work in progress. Always click \\u201cSave\\u201d before you move from transcribing to tagging or moving to another page.\\n</li>\\n<li>\\nClick \\\"Save\\\" and \\u201cSubmit for review\\u201d when you are done transcribing and are ready to move on to the next page or to reviewing or tagging. Anonymous users will be prompted to submit a captcha.\\n</li>\\n<li>\\nYou can explore collections by clicking the <a href=\\\"/campaigns/\\\">Campaigns</a> link at the top of the screen.\\n</li>\\n<li>\\n<a href=\\\"/account/register\\\">Registered volunteers</a> can review other people\\u2019s transcriptions. To start reviewing pages, select an item and click an image titled \\u201cReview\\u201d or filter the images by \\\"Review\\\". Read a page carefully and decide if the transcription looks correct. When a transcription is accurate and you do not need to make any changes, click the \\\"Approve\\\" button. The page will now be marked as complete. If you need to make changes start editing the page by clicking \\\"Edit\\\". Be sure to click \\u201cSave\\u201d periodically, and then click \\u201cFinish\\u201d. Changes you submit will be reviewed by another volunteer. You cannot review your own work. This process continues until someone Completes a transcription by clicking \\u201cApprove\\u201d without making changes.\\n</li>\\n<li>\\nIf you have a question or comment about how crowd.loc.gov works, or about a Campaign, item or page, connect with our team and other volunteers on <a href=\\\"https://historyhub.history.gov/community/crowd-loc\\\">History Hub</a>, a public online forum where everyone can join in the discussion. If you would prefer to email a Community Manager send us a message on our <a href=\\\"/contact/\\\">Contact Us</a> page. \\n</li>\\n</div>\\n</div>\",\n            \"created_on\": \"2018-11-26T22:00:43.734Z\",\n            \"path\": \"/help-center/welcome-guide/\",\n            \"title\": \"Welcome Guide\",\n            \"updated_on\": \"2018-11-26T22:00:43.736Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 8\n    },\n    {\n        \"fields\": {\n            \"body\": \"<div class=\\\"row\\\">\\n  <div class=\\\"col-3\\\">\\n    <div class=\\\"nav flex-column help-center\\\">\\n      <h4>Instructions</h4>\\n     <a class=\\\"nav-link\\\" href=\\\"/help-center/welcome-guide/\\\">Welcome to crowd.loc.gov</a>\\n  <a class=\\\"nav-link active\\\" href=\\\"/help-center/how-to-transcribe/\\\">How to transcribe</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-review/\\\">How to review</a>\\n  <a class=\\\"nav-link\\\" href=\\\"/help-center/how-to-tag\\\">How to tag</a>\\n    </div>\\n  </div>\\n  <div class=\\\"col-9\\\">\\n<p>\\nWe transcribe to improve search functionality. Our goal is to make documents word-searchable in the Library of Congress catalog, which means typing transcriptions that can be read by that computer system, as well as by humans. Most handwriting and some typed text cannot be automatically and accurately translated into machine-readable text using current technologies -- that\\u2019s why we need your help!\\n</p>\\n<p>\\nWe ask you to transcribe a document roughly as it appears on the page. Preserve line breaks, except in cases where words are broken over two lines. Our main goal is to capture all of the words on these pages, so broken words are not helpful for search. Using line breaks to roughly capture the layout of the page helps reviewers (other volunteers) check transcriptions. These will be viewable beside the original images in the catalog, so anyone who is interested in the physical layout of the original document will be able to see it.\\n</p>\\n\\n<h3>All in order:</h3>\\n<p>\\nTranscribe text in the order it appears.\\n</p>\\n<h3>\\nSpelling:\\n</h3>\\n<p>\\nPreserve original spelling unless the author seems to have made a minor error, such as writing \\u201cteh\\u201d instead of \\u201cthe\\u201d. If a misspelling will impact the searchability of the document, use a tag to add the correct spelling. Example:\\n<ul>\\n<li>  An author wrote \\u201cWilla Kather\\u201d instead of \\u201cWilla Cather\\u201d. Transcribe Willa Kather, and tag \\u201cWilla Cather\\u201d. </li>\\n</ul>\\n</p>\\n\\n<h3>Insertions:</h3>\\n<p>\\nWhen text has been inserted or added later, but should be read as part of a sentence, bring it down into the line and type it in the order you would read it aloud.\\n</p>\\n<h3>\\nLine-breaks:</h3>\\n<p>\\nDo not reproduce words broken between two lines. Write library rather than li-brary, kitten rather than kit-ten. Otherwise hit \\\"enter\\\" or \\\"return\\\" at the end of a line, to roughly mirror the layout of the original document. This will help reviewers in crowd.loc.gov to easily compare your transcription with the image.\\n</p>\\n<h3>\\nEmphasis:\\n</h3>\\n<p>\\nThe Library catalog cannot search for bold, italic, underlined or superscript text, so even when you see these features please transcribe the words without any styling.\\n</p>\\n<h3>\\nIllegible:\\n</h3>\\n<p>\\nIllegible text is anything you can\\u2019t read because a page is damaged, text is heavily crossed out or you can\\u2019t tell what the author has written. If there is a word or a string of words you cannot read use a pair of square brackets around an empty space [ ]. Example:\\n</p>\\n<ul>\\n<li>  \\\"I have [ ] loved coffee ice cream\\\" </li>\\n</ul>\\n<p>\\nIf you can read any letters or parts of words transcribe what you can and use question marks for the remaining letters or words. Examples:\\n</p>\\n<ul>\\n<li>  \\\"I have [a?????] loved coffee ice cream\\\"</li>\\n<li>   \\\"I have [a?] loved coffee ice cream\\\"</li>\\n</ul>\\n<p>\\nIf you cannot read a word or phrase that\\u2019s ok. Another volunteer may be able to identify the missing letters and update your transcription. If there is a lot of text you cannot read consider looking for another page that you can decipher better.\\n</p>\\n<h3>Deletions:</h3>\\n<p>\\nIf you can read crossed out or otherwise deleted text, transcribe the deleted words within a pair of square brackets. Example:\\n</p>\\n<ul><li>  \\u201cI have always loved [vanilla] coffee ice cream.\\u201d </li></ul>\\n\\n<h3>Marginalia:</h3>\\n\\n<p>\\nMarginalia is text written in the space around the main block of text. It is usually a comment on the main body text but can also be unrelated. It is different from an insertion, because it cannot be directly inserted into the main text and still make sense when read aloud. Use a pair of square brackets and asterisk [*] around marginalia text and order it within the transcription where it makes the most sense (or at the end of the transcription if it appears unrelated). Example:\\n</p>\\n<ul><li>\\n   I have always loved coffee ice cream. Last summer I made my own using a recipe from the 1970s. It was the creamiest coffee ice cream I ever ate. No one else in my family likes that flavor. Oh well, more for me! [*Brazil was the largest coffee producing country in the world in 2017*]</li>\\n</ul>\\n\\n<h3>Printed and typed text:</h3>\\n<p>\\nSome material in crowd.loc.gov was created on a typewriter or printed. If we have included it here it is because the text is not machine-readable. A computer using Optical Character Recognition (OCR) technology cannot create an accurate word-searchable transcription. Examples include the scouting reports of Branch Rickey, which are typed on thin paper and are often too fuzzy for successful OCR. Similarly, mixed materials containing manuscript and print have not been run through OCR, so please transcribe letterhead and any other printed features that will shed light on where a document was created.\\n</p>\\n\\n<h3>When not to transcribe printed text:</h3>\\n<p>\\nSome calendars and diaries contain many pages of pre-printed almanacs or other text that is probably machine-readable and therefore should not be transcribed as part of this project. It might be interesting to copy the first page from a repeating template in a diary or journal, but this is not the core text we are aiming to capture. However, if you want to transcribe it, feel free. Alternatively, click \\\"Nothing to transcribe\\\". This button should also be used for archival folders, blank pages, and pictorial images.\\n</p>\\n    \\n<h3>Tables, graphics, images:</h3>\\n<p>\\nSome documents will contain tables. Transcribe these in a way that will make them relatively easy for a reviewer to check over, but don't try to capture the exact layout of the data. The material will go back into the catalog without styling. Don't include notes or descriptions of visual features. If you would like to describe images, watermarks, stamps, or any other non-text features, feel free to use the tagging function. Register for an account if you want to add tags!\\n    </p>\\n</div>\\n</div>\",\n            \"created_on\": \"2018-11-26T22:00:43.740Z\",\n            \"path\": \"/help-center/how-to-transcribe/\",\n            \"title\": \"How to transcribe\",\n            \"updated_on\": \"2018-11-26T22:00:43.742Z\"\n        },\n        \"model\": \"concordia.simplepage\",\n        \"pk\": 9\n    }\n]\n"
  },
  {
    "path": "frontend/.gitignore",
    "content": "# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\ndist-ssr\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "frontend/README.md",
    "content": "# Frontend\n\nThis is the React frontend for the transcription page\n\n## Building\n\nTo build the app, run `npm run build` in this directory. The app will then be available at /transcription/ (if you have DEBUG=True).\n"
  },
  {
    "path": "frontend/eslint.config.js",
    "content": "import js from '@eslint/js';\nimport globals from 'globals';\nimport reactHooks from 'eslint-plugin-react-hooks';\nimport reactRefresh from 'eslint-plugin-react-refresh';\n\nexport default [\n    {ignores: ['dist']},\n    {\n        files: ['**/*.{js,jsx}'],\n        languageOptions: {\n            ecmaVersion: 2020,\n            globals: globals.browser,\n            parserOptions: {\n                ecmaVersion: 'latest',\n                ecmaFeatures: {jsx: true},\n                sourceType: 'module',\n            },\n        },\n        plugins: {\n            'react-hooks': reactHooks,\n            'react-refresh': reactRefresh,\n        },\n        rules: {\n            ...js.configs.recommended.rules,\n            ...reactHooks.configs.recommended.rules,\n            'no-unused-vars': ['error', {varsIgnorePattern: '^[A-Z_]'}],\n            'react-refresh/only-export-components': [\n                'warn',\n                {allowConstantExport: true},\n            ],\n        },\n    },\n];\n"
  },
  {
    "path": "frontend/index.html",
    "content": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <link rel=\"icon\" type=\"image/svg+xml\" href=\"/vite.svg\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n    <title>Vite + React</title>\n  </head>\n  <body>\n    <div id=\"root\"></div>\n    <script type=\"module\" src=\"/src/main.jsx\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "frontend/package.json",
    "content": "{\n    \"name\": \"frontend\",\n    \"private\": true,\n    \"version\": \"0.0.0\",\n    \"type\": \"module\",\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"build\": \"vite build\",\n        \"lint\": \"eslint .\",\n        \"preview\": \"vite preview\"\n    },\n    \"dependencies\": {\n        \"@reduxjs/toolkit\": \"^2.8.2\",\n        \"lodash.debounce\": \"^4.0.8\",\n        \"openseadragon\": \"^5.0.1\",\n        \"openseadragon-filters\": \"^2.2.0\",\n        \"react\": \"^19.1.0\",\n        \"react-bootstrap\": \"^2.10.10\",\n        \"react-dom\": \"^19.1.0\",\n        \"react-redux\": \"^9.2.0\",\n        \"react-router-dom\": \"^6.30.3\",\n        \"screenfull\": \"^6.0.2\",\n        \"split.js\": \"^1.6.5\"\n    },\n    \"devDependencies\": {\n        \"@eslint/js\": \"^9.25.0\",\n        \"@types/react\": \"^19.1.2\",\n        \"@types/react-dom\": \"^19.1.2\",\n        \"@vitejs/plugin-react\": \"^4.4.1\",\n        \"eslint\": \"^9.25.0\",\n        \"eslint-plugin-react-hooks\": \"^5.2.0\",\n        \"eslint-plugin-react-refresh\": \"^0.4.19\",\n        \"globals\": \"^16.0.0\",\n        \"vite\": \"^7.3.2\",\n        \"vite-plugin-static-copy\": \"^3.1.2\"\n    }\n}\n"
  },
  {
    "path": "frontend/src/App.jsx",
    "content": "import React, {useEffect, useState} from 'react';\nimport {HashRouter, Routes, Route, Link, useParams} from 'react-router-dom';\nimport ViewerSplit from './ViewerSplit';\n\n/**\n * Fetches a JSON endpoint and displays the response.\n *\n * Useful as a temporary inspector while APIs and UI are evolving.\n *\n * @param {Object} props\n * @param {string} props.endpoint\n *   Relative or absolute API URL to request.\n * @param {string} [props.method=\"GET\"]\n *   HTTP method to use.\n * @returns {JSX.Element}\n */\nfunction FetchAndDisplay({endpoint, method = 'GET'}) {\n    const [data, setData] = useState(null);\n    const [error, setError] = useState(null);\n\n    useEffect(() => {\n        fetch(endpoint, {\n            method,\n            headers: {'Content-Type': 'application/json'},\n        })\n            .then((response) => {\n                if (!response.ok) throw new Error(response.statusText);\n                return response.json();\n            })\n            .then(setData)\n            .catch((err) => setError(err.toString()));\n    }, [endpoint, method]);\n\n    return (\n        <div>\n            <h2>\n                {method} {endpoint}\n            </h2>\n            {error && <div style={{color: 'red'}}>Error: {error}</div>}\n            <pre>{JSON.stringify(data, null, 2)}</pre>\n        </div>\n    );\n}\n\n/**\n * Loads asset JSON by id or by slugs, then renders children with the data.\n *\n * Route params are read from the current URL. If params describe either\n * `/assets/:assetId` or the slug form, the component fetches the asset\n * from the API and passes results to a render prop.\n *\n * Children receive an object with:\n *   - `assetData`: the latest asset payload\n *   - `handleTranscriptionUpdate`: callback to merge a server response from\n *     a transcription action back into `assetData`\n *\n * @param {Object} props\n * @param {Function} props.children\n *   Render prop called as `children({ assetData, handleTranscriptionUpdate })`.\n * @returns {JSX.Element}\n */\nfunction AssetLoader({children}) {\n    const params = useParams();\n    const [assetData, setAssetData] = useState(null);\n    const [error, setError] = useState(null);\n\n    useEffect(() => {\n        let endpoint;\n        if (params.assetId) {\n            endpoint = `/api/assets/${params.assetId}`;\n        } else if (\n            params.campaignSlug &&\n            params.projectSlug &&\n            params.itemId &&\n            params.assetSlug\n        ) {\n            endpoint = `/api/assets/${params.campaignSlug}/${params.projectSlug}/${params.itemId}/${params.assetSlug}/`;\n        } else {\n            setError('Missing asset parameters');\n            return;\n        }\n\n        fetch(endpoint, {\n            method: 'GET',\n            headers: {'Content-Type': 'application/json'},\n        })\n            .then((response) => {\n                if (!response.ok) throw new Error(response.statusText);\n                return response.json();\n            })\n            .then(setAssetData)\n            .catch((err) => setError(err.toString()));\n    }, [params]);\n\n    /**\n     * Merge a transcription API response back into local asset state.\n     *\n     * Expects the server to return `{ id, text, ..., asset: <AssetOut> }`.\n     * If the response is missing `asset`, the update is skipped.\n     *\n     * @param {Object} updatedTranscription\n     */\n    const handleTranscriptionUpdate = (updatedTranscription) => {\n        if (!updatedTranscription?.asset) {\n            console.error(\n                'Missing asset on updatedTranscription:',\n                updatedTranscription,\n            );\n            return;\n        }\n\n        setAssetData({\n            ...updatedTranscription.asset,\n            transcription: updatedTranscription,\n            transcriptionStatus: updatedTranscription.asset.transcriptionStatus,\n        });\n    };\n\n    if (error) return <div style={{color: 'red'}}>Error: {error}</div>;\n    if (!assetData) return <div>Loading asset data...</div>;\n\n    return children({assetData, handleTranscriptionUpdate});\n}\n\n/**\n * Defines nested routes for a single asset view.\n *\n * Renders the split viewer by default and wires routes for supporting\n * actions like OCR, rollback, rollforward, submit and review.\n *\n * @param {Object} props\n * @param {Object} props.assetData\n * @param {Function} props.handleTranscriptionUpdate\n * @returns {JSX.Element}\n */\nfunction AssetRoutes({assetData, handleTranscriptionUpdate}) {\n    return (\n        <>\n            <NavLinks assetData={assetData} />\n            <Routes>\n                <Route\n                    path=\"\"\n                    element={\n                        <ViewerSplit\n                            assetData={assetData}\n                            onTranscriptionUpdate={handleTranscriptionUpdate}\n                        />\n                    }\n                />\n                <Route path=\"transcriptions\" element={<Transcriptions />} />\n                <Route path=\"ocr\" element={<OCRTranscription />} />\n                <Route path=\"rollback\" element={<Rollback />} />\n                <Route path=\"rollforward\" element={<Rollforward />} />\n                <Route path=\"submit/:transcriptionId\" element={<Submit />} />\n                <Route path=\"review/:transcriptionId\" element={<Review />} />\n                <Route path=\"*\" element={<NotFound />} />\n            </Routes>\n        </>\n    );\n}\n\n/**\n * Renders navigation links for the current asset and optional\n * links for a specific transcription.\n *\n * @param {Object} props\n * @param {Object} props.assetData\n * @returns {JSX.Element|null}\n */\nfunction NavLinks({assetData}) {\n    if (!assetData) return null;\n\n    const currentAssetId = assetData.id;\n    const transcriptionId = assetData.transcription?.id;\n\n    return (\n        <nav>\n            <Link to={`/${currentAssetId}`}>Asset</Link> |{' '}\n            <Link to={`/${currentAssetId}/transcriptions`}>Transcriptions</Link>{' '}\n            | <Link to={`/${currentAssetId}/ocr`}>OCR</Link> |{' '}\n            <Link to={`/${currentAssetId}/rollback`}>Rollback</Link> |{' '}\n            <Link to={`/${currentAssetId}/rollforward`}>Rollforward</Link>\n            {transcriptionId && (\n                <>\n                    {' | '}\n                    <Link to={`/${currentAssetId}/submit/${transcriptionId}`}>\n                        Submit\n                    </Link>{' '}\n                    |{' '}\n                    <Link to={`/${currentAssetId}/review/${transcriptionId}`}>\n                        Review\n                    </Link>\n                </>\n            )}\n        </nav>\n    );\n}\n\n/**\n * Fallback route for unknown paths.\n *\n * @returns {JSX.Element}\n */\nfunction NotFound() {\n    return <h2 style={{color: 'red'}}>404 Not Found</h2>;\n}\n\n/**\n * Debug route: show transcriptions list payload for an asset.\n *\n * @returns {JSX.Element}\n */\nfunction Transcriptions() {\n    const {assetId} = useParams();\n    const endpoint = `/api/assets/${assetId}/transcriptions`;\n    return <FetchAndDisplay endpoint={endpoint} />;\n}\n\n/**\n * Debug route: trigger OCR transcription endpoint for an asset.\n *\n * @returns {JSX.Element}\n */\nfunction OCRTranscription() {\n    const {assetId} = useParams();\n    const endpoint = `/api/assets/${assetId}/transcriptions/ocr`;\n    return <FetchAndDisplay endpoint={endpoint} />;\n}\n\n/**\n * Debug route: call rollback endpoint for an asset.\n *\n * @returns {JSX.Element}\n */\nfunction Rollback() {\n    const {assetId} = useParams();\n    const endpoint = `/api/assets/${assetId}/transcriptions/rollback`;\n    return <FetchAndDisplay endpoint={endpoint} />;\n}\n\n/**\n * Debug route: call rollforward endpoint for an asset.\n *\n * @returns {JSX.Element}\n */\nfunction Rollforward() {\n    const {assetId} = useParams();\n    const endpoint = `/api/assets/${assetId}/transcriptions/rollforward`;\n    return <FetchAndDisplay endpoint={endpoint} />;\n}\n\n/**\n * Debug route: submit a transcription by id.\n *\n * @returns {JSX.Element}\n */\nfunction Submit() {\n    const {transcriptionId} = useParams();\n    const endpoint = `/api/transcriptions/${transcriptionId}/submit`;\n    return <FetchAndDisplay endpoint={endpoint} />;\n}\n\n/**\n * Debug route: review a transcription by id.\n *\n * @returns {JSX.Element}\n */\nfunction Review() {\n    const {transcriptionId} = useParams();\n    const endpoint = `/api/transcriptions/${transcriptionId}/review`;\n    return <FetchAndDisplay endpoint={endpoint} />;\n}\n\n/**\n * Application router for the React transcription UI.\n *\n * Supports two entry patterns:\n *   1) `/:assetId/*` -- load by numeric id\n *   2) `/:campaignSlug/:projectSlug/:itemId/:assetSlug/*` -- load by slugs\n *\n * Both patterns use `AssetLoader`, which fetches JSON then renders nested\n * routes with `AssetRoutes`.\n *\n * @returns {JSX.Element}\n */\nexport default function App() {\n    return (\n        <HashRouter>\n            <Routes>\n                <Route\n                    path=\"/:assetId/*\"\n                    element={\n                        <AssetLoader>\n                            {({assetData, handleTranscriptionUpdate}) => (\n                                <AssetRoutes\n                                    assetData={assetData}\n                                    handleTranscriptionUpdate={\n                                        handleTranscriptionUpdate\n                                    }\n                                />\n                            )}\n                        </AssetLoader>\n                    }\n                />\n                <Route\n                    path=\"/:campaignSlug/:projectSlug/:itemId/:assetSlug/*\"\n                    element={\n                        <AssetLoader>\n                            {({assetData, handleTranscriptionUpdate}) => (\n                                <AssetRoutes\n                                    assetData={assetData}\n                                    handleTranscriptionUpdate={\n                                        handleTranscriptionUpdate\n                                    }\n                                />\n                            )}\n                        </AssetLoader>\n                    }\n                />\n                <Route path=\"*\" element={<NotFound />} />\n            </Routes>\n        </HashRouter>\n    );\n}\n"
  },
  {
    "path": "frontend/src/ViewerSplit.jsx",
    "content": "import React, {useLayoutEffect, useRef, useState} from 'react';\nimport Split from 'split.js';\n\nimport Editor from './editor/Editor';\nimport Viewer from './viewer/Viewer';\nimport OcrSection from './ocr/Section';\n\n/**\n * @typedef {Object} AssetData\n * @property {number} id\n * @property {string} imageUrl\n * @property {Object} [transcription]\n *   Latest transcription object for the asset, or null if none.\n * @property {string} transcriptionStatus\n *   One of the server statuses used to choose UI state.\n * @property {number} registeredContributors\n * @property {Array<[string,string]>} languages\n *   Array of [isoCode, languageName] pairs used by OCR.\n * @property {boolean} undoAvailable\n * @property {boolean} redoAvailable\n */\n\n/**\n * Split-pane layout for the transcription UI.\n *\n * Renders the image viewer on one side and the editor on the other,\n * with a draggable gutter. The split direction and pane sizes persist\n * to localStorage. When the direction changes, the viewer is nudged to\n * re-fit the image.\n *\n * Local storage keys:\n * - \"transcription-split-sizes-vertical\" for vertical sizes\n * - \"transcription-split-sizes-horizontal\" for horizontal sizes\n * - \"transcription-split-direction\" for the active direction\n *\n * @param {{assetData: AssetData, onTranscriptionUpdate?: (t: Object) => void}} props\n *   assetData: Data for the current asset.\n *   onTranscriptionUpdate: Callback when a new transcription is saved\n *   or loaded.\n */\nexport default function ViewerSplit({assetData, onTranscriptionUpdate}) {\n    const contributeContainerRef = useRef(null);\n    const editorColumnRef = useRef(null);\n\n    const verticalKey = 'transcription-split-sizes-vertical';\n    const horizontalKey = 'transcription-split-sizes-horizontal';\n    const directionKey = 'transcription-split-direction';\n\n    const [splitDirection, setSplitDirection] = useState(\n        JSON.parse(localStorage.getItem(directionKey)) || 'h',\n    );\n\n    const [transcription, setTranscription] = useState(assetData.transcription);\n\n    /**\n     * Handle an updated transcription payload from child components.\n     * Updates local state and forwards to the optional parent callback.\n     *\n     * @param {Object} updated\n     */\n    const handleTranscriptionUpdate = (updated) => {\n        if (!updated?.text) {\n            console.warn(\n                'handleTranscriptionUpdate called with malformed object:',\n                updated,\n            );\n            return;\n        }\n        setTranscription(updated);\n        if (onTranscriptionUpdate) onTranscriptionUpdate(updated);\n    };\n\n    /**\n     * Update only the transcription text field as the user types.\n     *\n     * @param {string} newText\n     */\n    const handleTranscriptionTextChange = (newText) => {\n        setTranscription((prev) => ({\n            ...prev,\n            text: newText,\n        }));\n    };\n\n    /**\n     * Read persisted Split.js sizes or return the provided defaults.\n     *\n     * @param {string} key\n     * @param {number[]} defaultSizes\n     * @returns {number[]}\n     */\n    const getSizes = (key, defaultSizes) => {\n        const sizes = localStorage.getItem(key);\n        return sizes ? JSON.parse(sizes) : defaultSizes;\n    };\n\n    /**\n     * Persist pane sizes for the current direction.\n     *\n     * @param {number[]} sizes\n     */\n    const saveSizes = (sizes) => {\n        const key = splitDirection === 'h' ? horizontalKey : verticalKey;\n        localStorage.setItem(key, JSON.stringify(sizes));\n    };\n\n    /**\n     * Persist the active split direction.\n     *\n     * @param {'h'|'v'} dir\n     */\n    const saveDirection = (dir) => {\n        localStorage.setItem(directionKey, JSON.stringify(dir));\n    };\n\n    /**\n     * Create or recreate the Split.js instance whenever direction changes.\n     * Cleans up on unmount. Uses flex-basis so panes respect the gutter size.\n     */\n    useLayoutEffect(() => {\n        const sizes =\n            splitDirection === 'h'\n                ? getSizes(horizontalKey, [50, 50])\n                : getSizes(verticalKey, [50, 50]);\n\n        const splitInstance = Split(['#viewer-column', '#editor-column'], {\n            sizes,\n            minSize: 100,\n            gutterSize: 8,\n            direction: splitDirection === 'h' ? 'horizontal' : 'vertical',\n            elementStyle: (dimension, size, gutterSize) => ({\n                flexBasis: `calc(${size}% - ${gutterSize}px)`,\n            }),\n            gutterStyle: (dimension, gutterSize) => ({\n                flexBasis: `${gutterSize}px`,\n            }),\n            onDragEnd: saveSizes,\n        });\n\n        return () => {\n            splitInstance.destroy();\n        };\n    }, [splitDirection]);\n\n    /**\n     * Toggle between horizontal and vertical layouts.\n     * Saves direction then requests the OpenSeadragon viewer to re-fit.\n     *\n     * @param {'h'|'v'} dir\n     */\n    const handleToggle = (dir) => {\n        if (dir !== splitDirection) {\n            setSplitDirection(dir);\n            saveDirection(dir);\n            setTimeout(() => {\n                if (window.seadragonViewer?.viewport) {\n                    window.seadragonViewer.viewport.zoomTo(1, undefined, true);\n                }\n            }, 10);\n        }\n    };\n\n    return (\n        <div className=\"viewer-split\">\n            <div\n                id=\"contribute-container\"\n                ref={contributeContainerRef}\n                className={`d-flex ${\n                    splitDirection === 'h' ? 'flex-row' : 'flex-column'\n                }`}\n                style={{height: '100vh'}}\n            >\n                <div\n                    id=\"viewer-column\"\n                    className=\"ps-0 d-flex align-items-stretch bg-dark d-print-block flex-column\"\n                >\n                    <Viewer\n                        imageUrl={assetData.imageUrl}\n                        onLayoutHorizontal={() => handleToggle('h')}\n                        onLayoutVertical={() => handleToggle('v')}\n                    />\n                    <OcrSection\n                        assetId={assetData.id}\n                        transcription={transcription}\n                        onTranscriptionUpdate={handleTranscriptionUpdate}\n                        languages={assetData.languages}\n                    />\n                </div>\n                <div id=\"editor-column\" ref={editorColumnRef}>\n                    <Editor\n                        assetId={assetData.id}\n                        transcription={transcription}\n                        transcriptionStatus={assetData.transcriptionStatus}\n                        registeredContributors={\n                            assetData.registeredContributors\n                        }\n                        undoAvailable={assetData.undoAvailable}\n                        redoAvailable={assetData.redoAvailable}\n                        onTranscriptionUpdate={handleTranscriptionUpdate}\n                        onTranscriptionTextChange={\n                            handleTranscriptionTextChange\n                        }\n                    />\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/config.js",
    "content": "/**\n * Viewer configuration helpers for the React transcription UI.\n *\n * Reads optional values from a DOM element with id \"viewer-data\" and exposes\n * resolved settings as named exports. Falls back to safe defaults when the\n * element is missing or its dataset values are empty.\n *\n * Expected dataset attributes on #viewer-data:\n *   - data-prefix-url: string URL prefix for OpenSeadragon control images\n *   - data-contact-url: string URL for the \"contact us\" link\n *\n * No runtime side effects beyond a DOM lookup and a console warning when the\n * element is not present.\n */\n\n/** Default OpenSeadragon image prefix if none is provided via #viewer-data. */\nconst DEFAULT_PREFIX_URL = '/static/openseadragon-images/';\n\n/** Default contact URL if none is provided via #viewer-data. */\nconst DEFAULT_CONTACT_URL = 'https://ask.loc.gov/crowd';\n\n/**\n * Shape of the viewer configuration object.\n *\n * @typedef {Object} ViewerConfig\n * @property {string} prefixUrl\n *   Base URL where OpenSeadragon looks for its control images.\n * @property {string} contactUrl\n *   Absolute URL used by \"contact us\" or help links.\n */\n\n/**\n * Resolve viewer configuration from the DOM with fallbacks.\n *\n * Looks for an element with id \"viewer-data\". If found, reads the\n * `data-prefix-url` and `data-contact-url` attributes. Empty strings are\n * treated as missing and replaced by defaults.\n *\n * @returns {ViewerConfig}\n */\nfunction getViewerConfig() {\n    const viewerDataElement = document.getElementById('viewer-data');\n\n    if (!viewerDataElement) {\n        console.warn('viewer-data element not found');\n        return {\n            prefixUrl: DEFAULT_PREFIX_URL,\n            contactUrl: DEFAULT_CONTACT_URL,\n        };\n    }\n\n    const {prefixUrl, contactUrl} = viewerDataElement.dataset;\n\n    return {\n        prefixUrl: prefixUrl || DEFAULT_PREFIX_URL,\n        contactUrl: contactUrl || DEFAULT_CONTACT_URL,\n    };\n}\n\n/**\n * Resolved configuration values for consumers.\n *\n * `prefixUrl` is used by OpenSeadragon for control image paths.\n * `contactUrl` is used by UI links that route users to support.\n *\n * @type {string}\n * @name prefixUrl\n *\n * @type {string}\n * @name contactUrl\n */\nexport const {prefixUrl, contactUrl} = getViewerConfig();\n"
  },
  {
    "path": "frontend/src/editor/Buttons.jsx",
    "content": "import React from 'react';\nimport EditableButtons from './buttons/Editable';\nimport SubmitButton from './buttons/Submit';\nimport ReviewButton from './buttons/Review';\n\n/**\n * Render the editor button row.\n *\n * Shows:\n * - <EditableButtons> when `isEditable` is true\n * - <SubmitButton> when `submitVisible` is true\n * - <ReviewButton> when `inReview` is true\n *\n * If none of the sections are visible, the component returns null.\n *\n * Layout: a centered flex container with wrap to handle narrow viewports.\n *\n * @param {Object} props\n * @param {boolean} props.isEditable\n *   Whether the draft editing controls should be shown.\n * @param {boolean} props.submitVisible\n *   Whether the submit control should be shown.\n * @param {boolean} props.inReview\n *   Whether accept and reject controls should be shown.\n * @param {boolean} props.undoAvailable\n *   Whether undo is available for the current asset.\n * @param {boolean} props.redoAvailable\n *   Whether redo is available for the current asset.\n * @param {string} props.text\n *   Current transcription text, passed to <EditableButtons>.\n * @param {boolean} props.isSaving\n *   True while a save is in flight.\n * @param {boolean} props.isSubmitting\n *   True while a submit is in flight.\n * @param {boolean} props.isReviewing\n *   True while a review action is in flight.\n * @param {boolean} props.submitEnabled\n *   Whether the submit button should be enabled.\n * @param {() => void} props.onSave\n *   Handler for saving a draft transcription.\n * @param {() => void} props.onSubmit\n *   Handler for submitting a transcription for review.\n * @param {() => void} props.onAccept\n *   Handler for accepting a submitted transcription.\n * @param {() => void} props.onReject\n *   Handler for rejecting a submitted transcription.\n * @param {() => void} props.onUndo\n *   Handler to trigger an undo action.\n * @param {() => void} props.onRedo\n *   Handler to trigger a redo action.\n */\nexport default function EditorButtons({\n    isEditable,\n    submitVisible,\n    inReview,\n    undoAvailable,\n    redoAvailable,\n    text,\n    isSaving,\n    isSubmitting,\n    isReviewing,\n    submitEnabled,\n    onSave,\n    onSubmit,\n    onAccept,\n    onReject,\n    onUndo,\n    onRedo,\n}) {\n    if (!isEditable && !submitVisible && !inReview) return null;\n\n    return (\n        <div className=\"d-flex justify-content-center mt-3 flex-wrap\">\n            {isEditable && (\n                <EditableButtons\n                    onSave={onSave}\n                    isSaving={isSaving}\n                    text={text}\n                    undoAvailable={undoAvailable}\n                    redoAvailable={redoAvailable}\n                    onUndo={onUndo}\n                    onRedo={onRedo}\n                />\n            )}\n            {submitVisible && (\n                <SubmitButton\n                    onSubmit={onSubmit}\n                    isSubmitting={isSubmitting}\n                    submitEnabled={submitEnabled}\n                />\n            )}\n            {inReview && (\n                <ReviewButton\n                    onAccept={onAccept}\n                    onReject={onReject}\n                    isReviewing={isReviewing}\n                />\n            )}\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/Editor.jsx",
    "content": "import React from 'react';\nimport EditorHeader from './Header';\nimport TranscriptionTextarea from './TranscriptionTextarea';\nimport EditorStatusMessages from './StatusMessages';\nimport EditorButtons from './Buttons';\n\n/**\n * Editor panel for the React transcription page.\n *\n * Renders the header, textarea and action buttons. Manages save, submit,\n * accept, reject, undo and redo flows against the API, then emits updates\n * to the parent via `onTranscriptionUpdate`.\n *\n * Status mapping:\n * - \"not_started\" or \"in_progress\" -> editable with submit option visible\n * - \"submitted\" -> review controls visible\n *\n * This code is functional but not final. The API surface and UX may change.\n */\n\n/**\n * Submit a draft transcription for review.\n *\n * @param {number} transcriptionId\n * @returns {Promise<Object>} JSON payload from the API\n * @throws {Error} when the response is not OK\n */\nasync function submitTranscription(transcriptionId) {\n    const response = await fetch(\n        `/api/transcriptions/${transcriptionId}/submit`,\n        {\n            method: 'POST',\n            headers: {'Content-Type': 'application/json'},\n        },\n    );\n    if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.detail || 'Failed to submit transcription');\n    }\n    return await response.json();\n}\n\n/**\n * Review a submitted transcription.\n *\n * @param {number} transcriptionId\n * @param {'accept'|'reject'} action\n * @returns {Promise<Object>} JSON payload from the API\n * @throws {Error} when the response is not OK\n */\nasync function reviewTranscription(transcriptionId, action) {\n    const response = await fetch(\n        `/api/transcriptions/${transcriptionId}/review`,\n        {\n            method: 'PATCH',\n            headers: {'Content-Type': 'application/json'},\n            body: JSON.stringify({action}),\n        },\n    );\n    if (!response.ok) {\n        const error = await response.json();\n        throw new Error(error.detail || 'Failed to review transcription');\n    }\n    return await response.json();\n}\n\n/**\n * Accept helper.\n *\n * @param {number} transcriptionId\n * @returns {Promise<Object>}\n */\nasync function acceptTranscription(transcriptionId) {\n    return await reviewTranscription(transcriptionId, 'accept');\n}\n\n/**\n * Reject helper.\n *\n * @param {number} transcriptionId\n * @returns {Promise<Object>}\n */\nasync function rejectTranscription(transcriptionId) {\n    return await reviewTranscription(transcriptionId, 'reject');\n}\n\n/**\n * Editor container component.\n *\n * Orchestrates UI state, calls API endpoints for save, submit, accept,\n * reject, undo and redo, then forwards the updated payload upstream.\n *\n * @param {Object} props\n * @param {number} props.assetId\n *   Asset id used for API calls.\n * @param {Object|null} props.transcription\n *   Current transcription object, or null when none exists.\n * @param {'not_started'|'in_progress'|'submitted'} props.transcriptionStatus\n *   Current workflow status for the asset.\n * @param {number} props.registeredContributors\n *   Count of registered contributors for the asset.\n * @param {boolean} props.undoAvailable\n *   Whether an undo target exists.\n * @param {boolean} props.redoAvailable\n *   Whether a redo target exists.\n * @param {(updated:Object) => void} props.onTranscriptionUpdate\n *   Callback fired with the API response after any change.\n * @param {(text:string) => void} props.onTranscriptionTextChange\n *   Callback fired when the textarea value changes.\n */\nexport default function Editor(props) {\n    const {\n        assetId,\n        transcription,\n        transcriptionStatus,\n        registeredContributors,\n        undoAvailable,\n        redoAvailable,\n        onTranscriptionUpdate,\n        onTranscriptionTextChange,\n    } = props;\n\n    const [isSaving, setIsSaving] = React.useState(false);\n    const [isSubmitting, setIsSubmitting] = React.useState(false);\n    const [isReviewing, setIsReviewing] = React.useState(false);\n    const [error, setError] = React.useState(null);\n    const [success, setSuccess] = React.useState(false);\n    const [submitSuccess, setSubmitSuccess] = React.useState(false);\n\n    const status = transcriptionStatus;\n    const isEditable = ['not_started', 'in_progress'].includes(status);\n    const submitVisible = ['not_started', 'in_progress'].includes(status);\n    const submitEnabled = status === 'in_progress' && transcription?.id;\n    const inReview = status === 'submitted';\n    const supersedes = transcription?.id;\n    const text = transcription?.text || '';\n\n    const handleSave = async () => {\n        setIsSaving(true);\n        setError(null);\n        setSuccess(false);\n\n        try {\n            const response = await fetch(\n                `/api/assets/${assetId}/transcriptions`,\n                {\n                    method: 'POST',\n                    headers: {'Content-Type': 'application/json'},\n                    body: JSON.stringify({\n                        text,\n                        ...(supersedes ? {supersedes} : {}),\n                    }),\n                },\n            );\n\n            if (!response.ok) {\n                const data = await response.json();\n                throw new Error(data.error || response.statusText);\n            }\n\n            const updated = await response.json();\n            setSuccess(true);\n            if (onTranscriptionUpdate) onTranscriptionUpdate(updated);\n        } catch (err) {\n            setError(err.message);\n        } finally {\n            setIsSaving(false);\n        }\n    };\n\n    const handleSubmit = async () => {\n        if (!transcription?.id) return;\n        setIsSubmitting(true);\n        setError(null);\n        setSubmitSuccess(false);\n\n        try {\n            const updated = await submitTranscription(transcription.id);\n            setSubmitSuccess(true);\n            if (onTranscriptionUpdate) onTranscriptionUpdate(updated);\n        } catch (err) {\n            setError(err.message);\n        } finally {\n            setIsSubmitting(false);\n        }\n    };\n\n    const handleAccept = async () => {\n        if (!transcription?.id) return;\n        setIsReviewing(true);\n        setError(null);\n\n        try {\n            const updated = await acceptTranscription(transcription.id);\n            if (onTranscriptionUpdate) onTranscriptionUpdate(updated);\n        } catch (err) {\n            setError(err.message);\n        } finally {\n            setIsReviewing(false);\n        }\n    };\n\n    const handleReject = async () => {\n        if (!transcription?.id) return;\n        setIsReviewing(true);\n        setError(null);\n\n        try {\n            const updated = await rejectTranscription(transcription.id);\n            if (onTranscriptionUpdate) onTranscriptionUpdate(updated);\n        } catch (err) {\n            setError(err.message);\n        } finally {\n            setIsReviewing(false);\n        }\n    };\n\n    const handleUndo = async () => {\n        setIsSaving(true);\n        setError(null);\n        try {\n            const response = await fetch(\n                `/api/assets/${assetId}/transcriptions/rollback`,\n                {\n                    method: 'POST',\n                    headers: {'Content-Type': 'application/json'},\n                },\n            );\n            if (!response.ok) {\n                const data = await response.json();\n                throw new Error(data.detail || data.error || 'Undo failed');\n            }\n            const updated = await response.json();\n            if (onTranscriptionUpdate) onTranscriptionUpdate(updated);\n        } catch (err) {\n            setError(err.message);\n        } finally {\n            setIsSaving(false);\n        }\n    };\n\n    const handleRedo = async () => {\n        setIsSaving(true);\n        setError(null);\n        try {\n            const response = await fetch(\n                `/api/assets/${assetId}/transcriptions/rollforward`,\n                {\n                    method: 'POST',\n                    headers: {'Content-Type': 'application/json'},\n                },\n            );\n            if (!response.ok) {\n                const data = await response.json();\n                throw new Error(data.detail || data.error || 'Redo failed');\n            }\n            const updated = await response.json();\n            if (onTranscriptionUpdate) onTranscriptionUpdate(updated);\n        } catch (err) {\n            setError(err.message);\n        } finally {\n            setIsSaving(false);\n        }\n    };\n\n    return (\n        <div className=\"editor p-3 d-flex flex-column flex-grow-1\">\n            <EditorHeader\n                status={status}\n                registeredContributors={registeredContributors}\n            />\n\n            <TranscriptionTextarea\n                value={text}\n                onChange={onTranscriptionTextChange}\n                editable={isEditable}\n            />\n\n            <EditorStatusMessages\n                error={error}\n                success={success}\n                submitSuccess={submitSuccess}\n            />\n\n            <EditorButtons\n                isEditable={isEditable}\n                submitVisible={submitVisible}\n                inReview={inReview}\n                undoAvailable={undoAvailable}\n                redoAvailable={redoAvailable}\n                text={text}\n                isSaving={isSaving}\n                isSubmitting={isSubmitting}\n                isReviewing={isReviewing}\n                submitEnabled={submitEnabled}\n                onSave={handleSave}\n                onSubmit={handleSubmit}\n                onAccept={handleAccept}\n                onReject={handleReject}\n                onUndo={handleUndo}\n                onRedo={handleRedo}\n            />\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/Header.jsx",
    "content": "import React from 'react';\n\n/**\n * Editor header for the React transcription page.\n *\n * Shows a human friendly status label, task instructions and, when applicable,\n * the count of registered contributors. This module is functional but in flux.\n * It is part of the React transcription UI and may change as the API and UX\n * are refined.\n */\n\n/**\n * Maps workflow status codes to display labels.\n * Keys must match backend status values.\n * @type {Record<'submitted'|'completed'|'not_started'|'in_progress', string>}\n */\nconst statusMap = {\n    submitted: 'Needs review',\n    completed: 'Completed',\n    not_started: 'Not started',\n    in_progress: 'In progress',\n};\n\n/**\n * Maps workflow status codes to short user instructions.\n * Copy is provisional and may change.\n * @type {Record<'submitted'|'completed'|'not_started'|'in_progress', string>}\n */\nconst instructionsMap = {\n    not_started: 'Transcribe this page.',\n    in_progress: 'Someone started this transcription. Can you finish it?',\n    submitted: 'Check this transcription thoroughly. Accept if correct!',\n    completed: 'This transcription is finished! You can read and add tags.',\n};\n\n/**\n * Header section for the editor column.\n *\n * @param {Object} props\n * @param {'not_started'|'in_progress'|'submitted'|'completed'} props.status\n *   Current workflow status for the asset.\n * @param {number} props.registeredContributors\n *   Count of registered contributors. Shown for all states except not_started.\n */\nexport default function EditorHeader({status, registeredContributors}) {\n    const statusLabel = statusMap[status] || 'Unknown status';\n    const instructions = instructionsMap[status] || '';\n\n    return (\n        <div className=\"mb-2\">\n            <h2>{statusLabel}</h2>\n            {status !== 'not_started' && (\n                <h2>\n                    Registered Contributors:{' '}\n                    <span className=\"fw-normal\">{registeredContributors}</span>\n                </h2>\n            )}\n            <p>{instructions}</p>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/StatusMessages.jsx",
    "content": "import React from 'react';\n\n/**\n * Status message area for the React transcription editor.\n *\n * Displays inline feedback for error, save success and submit success.\n * This module is part of the transcription UI and is in flux as the app\n * evolves.\n *\n * @param {Object} props\n * @param {string|null} props.error\n *   Error message to display. When truthy shows \"Error: <message>\".\n * @param {boolean} props.success\n *   When true shows \"Transcription saved.\"\n * @param {boolean} props.submitSuccess\n *   When true shows \"Transcription submitted.\"\n */\nexport default function EditorStatusMessages({error, success, submitSuccess}) {\n    return (\n        <>\n            {error && <div className=\"text-danger\">Error: {error}</div>}\n            {success && (\n                <div className=\"text-success\">Transcription saved.</div>\n            )}\n            {submitSuccess && (\n                <div className=\"text-success\">Transcription submitted.</div>\n            )}\n        </>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/TranscriptionTextarea.jsx",
    "content": "import React from 'react';\n\n/**\n * Multiline textarea for transcription input.\n *\n * Renders a Bootstrap styled `<textarea>` bound to `value`, `onChange` and\n * an `editable` flag. When `editable` is false the field is readOnly and a\n * non editing placeholder is shown.\n *\n * @param {Object} props\n * @param {string} props.value\n *   Current transcription text.\n * @param {(value: string) => void} props.onChange\n *   Callback invoked with the updated text.\n * @param {boolean} props.editable\n *   When true the textarea is editable, otherwise it is readOnly.\n */\nexport default function TranscriptionTextarea({value, onChange, editable}) {\n    return (\n        <textarea\n            className=\"form-control flex-grow-1 mb-3\"\n            value={value}\n            onChange={(e) => onChange(e.target.value)}\n            readOnly={!editable}\n            placeholder={\n                editable\n                    ? 'Go ahead, start typing. You got this!'\n                    : 'Nothing to transcribe'\n            }\n            aria-label=\"Transcription input\"\n            style={{minHeight: '200px'}}\n        />\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/buttons/Editable.jsx",
    "content": "import React from 'react';\nimport EditorButtonSave from './Save';\nimport EditorButtonUndo from './Undo';\nimport EditorButtonRedo from './Redo';\n\n/**\n * Button cluster for editable transcription state.\n *\n * Renders Save, Undo and Redo controls. Each child button receives only\n * the props it needs. This component does not manage any state.\n *\n * @param {Object} props\n * @param {boolean} props.isSaving\n *   True while a save request is in flight.\n * @param {string} props.text\n *   Current transcription text to validate save availability.\n * @param {boolean} props.undoAvailable\n *   True when an undo operation is possible.\n * @param {boolean} props.redoAvailable\n *   True when a redo operation is possible.\n * @param {() => void} props.onSave\n *   Called when the Save button is clicked.\n * @param {() => void} props.onUndo\n *   Called when the Undo button is clicked.\n * @param {() => void} props.onRedo\n *   Called when the Redo button is clicked.\n */\nexport default function EditorButtonsEditable({\n    isSaving,\n    text,\n    undoAvailable,\n    redoAvailable,\n    onSave,\n    onUndo,\n    onRedo,\n}) {\n    return (\n        <>\n            <EditorButtonSave isSaving={isSaving} text={text} onSave={onSave} />\n            <EditorButtonUndo undoAvailable={undoAvailable} onClick={onUndo} />\n            <EditorButtonRedo redoAvailable={redoAvailable} onClick={onRedo} />\n        </>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/buttons/Redo.jsx",
    "content": "import React from 'react';\n\n/**\n * Redo button for the transcription editor.\n *\n * Presentational only. Disabled when no redo is available.\n *\n * @param {Object} props\n * @param {boolean} props.redoAvailable\n *   True when a redo operation can be performed.\n * @param {() => void} props.onClick\n *   Click handler invoked to trigger redo.\n */\nexport default function EditorButtonRedo({redoAvailable, onClick}) {\n    return (\n        <button\n            className=\"btn btn-outline-primary mx-1 mb-2\"\n            disabled={!redoAvailable}\n            onClick={onClick}\n        >\n            Redo <span className=\"fas fa-redo\"></span>\n        </button>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/buttons/Review.jsx",
    "content": "import React from 'react';\n\n/**\n * Review action buttons for the transcription editor.\n *\n * Renders two primary buttons:\n * - \"Edit\" triggers the reject flow so a reviewer can make changes\n * - \"Accept\" confirms the transcription is accurate\n *\n * @param {Object} props\n * @param {boolean} props.isReviewing\n *   True while a review API call is active which disables the buttons.\n * @param {() => void} props.onAccept\n *   Handler to accept the current transcription.\n * @param {() => void} props.onReject\n *   Handler to send the transcription back for edits.\n */\nexport default function EditorButtonsReview({isReviewing, onAccept, onReject}) {\n    return (\n        <>\n            <button\n                className=\"btn btn-primary mx-1 mb-2\"\n                onClick={onReject}\n                disabled={isReviewing}\n                title=\"Correct errors you see in the text\"\n            >\n                Edit\n            </button>\n            <button\n                className=\"btn btn-primary mx-1 mb-2\"\n                onClick={onAccept}\n                disabled={isReviewing}\n                title=\"Confirm that the text is accurately transcribed\"\n            >\n                Accept\n            </button>\n        </>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/buttons/Save.jsx",
    "content": "import React from 'react';\n\n/**\n * Save button for the transcription editor.\n *\n * Renders a primary button that calls `onSave`. The button is disabled while a\n * save is in progress or when the current text is empty after trimming.\n *\n * @param {Object} props\n * @param {() => void} props.onSave - Click handler to persist the draft.\n * @param {boolean} props.isSaving - True while a save request is in flight.\n * @param {string} props.text - Current transcription text used to gate enable state.\n * @returns {JSX.Element}\n */\nexport default function EditorButtonSave({onSave, isSaving, text}) {\n    return (\n        <button\n            className=\"btn btn-primary mx-1 mb-2\"\n            onClick={onSave}\n            disabled={isSaving || !text.trim()}\n        >\n            Save\n        </button>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/buttons/Submit.jsx",
    "content": "import React from 'react';\n\n/**\n * Submit button for the transcription editor.\n *\n * Renders a primary button that calls `onSubmit`. The button is disabled\n * while a submit request is in flight or when submission is not allowed.\n *\n * @param {Object} props\n * @param {() => void} props.onSubmit - Click handler to submit the draft for review.\n * @param {boolean} props.isSubmitting - True while a submit request is in flight.\n * @param {boolean} props.submitEnabled - True when the current draft can be submitted.\n * @returns {JSX.Element}\n */\nexport default function EditorButtonSubmit({\n    onSubmit,\n    isSubmitting,\n    submitEnabled,\n}) {\n    return (\n        <button\n            className=\"btn btn-primary mx-1 mb-2\"\n            onClick={onSubmit}\n            disabled={!submitEnabled || isSubmitting}\n            title=\"Request another volunteer to review the text you entered above\"\n        >\n            {isSubmitting ? 'Submitting...' : 'Submit for Review'}\n        </button>\n    );\n}\n"
  },
  {
    "path": "frontend/src/editor/buttons/Undo.jsx",
    "content": "import React from 'react';\n\n/**\n * Undo button for the transcription editor.\n *\n * Renders an outline button that calls `onClick`. The button is disabled\n * when `undoAvailable` is false.\n *\n * @param {Object} props\n * @param {boolean} props.undoAvailable - True if a prior version exists to undo to.\n * @param {() => void} props.onClick - Click handler to perform the undo action.\n * @returns {JSX.Element}\n */\nexport default function EditorButtonUndo({undoAvailable, onClick}) {\n    return (\n        <button\n            className=\"btn btn-outline-primary mx-1 mb-2\"\n            disabled={!undoAvailable}\n            onClick={onClick}\n        >\n            <span className=\"fas fa-undo\"></span> Undo\n        </button>\n    );\n}\n"
  },
  {
    "path": "frontend/src/main.jsx",
    "content": "/**\n * Application entry point for the React transcription UI.\n *\n * Mounts <App /> into the DOM element with id \"app\" and enables React\n * StrictMode.\n *\n * Behavior notes:\n * - StrictMode turns on extra checks in development and may invoke some\n *   render effects twice -- this is expected.\n * - No globals are exported. Side effects are limited to mounting React.\n */\n\nimport React from 'react';\nimport ReactDOM from 'react-dom/client';\nimport App from './App';\n\nReactDOM.createRoot(document.getElementById('app')).render(\n    <React.StrictMode>\n        <App />\n    </React.StrictMode>,\n);\n"
  },
  {
    "path": "frontend/src/ocr/Button.jsx",
    "content": "import React from 'react';\nimport Button from 'react-bootstrap/Button';\n\n/**\n * OCR action UI: a help link that opens the OCR help modal\n * and a primary button that starts OCR transcription.\n *\n * Notes:\n * - The help link triggers the Bootstrap modal with id \"#ocr-help-modal\"\n *   via data attributes. Ensure that modal exists in the DOM.\n * - Requires Bootstrap's modal JavaScript to be loaded.\n *\n * @param {Object} props\n * @param {() => void} props.onClick - Handler to begin the OCR flow.\n * @returns {JSX.Element}\n */\nexport default function OcrButton({onClick}) {\n    return (\n        <div className=\"d-flex flex-row align-items-center justify-content-end mt-1\">\n            <a\n                tabIndex={0}\n                className=\"btn btn-link d-inline p-0\"\n                role=\"button\"\n                data-bs-placement=\"top\"\n                data-bs-trigger=\"focus click hover\"\n                title=\"When to use OCR\"\n                data-bs-toggle=\"modal\"\n                data-bs-target=\"#ocr-help-modal\"\n            >\n                <span className=\"underline-link fw-bold\">What is OCR</span>{' '}\n                <span\n                    className=\"fas fa-question-circle\"\n                    aria-label=\"When to use OCR\"\n                ></span>\n            </a>\n            <Button className=\"mx-1\" variant=\"primary\" onClick={onClick}>\n                Transcribe with OCR\n            </Button>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/ocr/ConfirmModal.jsx",
    "content": "import React from 'react';\nimport Modal from 'react-bootstrap/Modal';\nimport Button from 'react-bootstrap/Button';\n\n/**\n * Confirmation modal shown before running OCR that would replace the current\n * transcription text with machine generated text.\n *\n * Behavior:\n * - Appears when `show` is true. Hides when the backdrop or close button is\n *   activated or when `onClose` is called.\n * - Clicking \"Yes, Select Language\" calls `onConfirm`, which should advance to\n *   language selection and OCR.\n * - Uses react-bootstrap Modal. Ensure Bootstrap's JS bundle is loaded.\n *\n * Accessibility:\n * - Modal is centered and managed by react-bootstrap which handles focus trap\n *   and aria attributes.\n *\n * Design note:\n * - The potential destructive action is on a link styled as a button for visual\n *   prominence while \"Cancel\" is a primary button.\n *\n * @param {Object} props\n * @param {boolean} props.show - Whether the modal is visible.\n * @param {() => void} props.onClose - Called when the user cancels or closes.\n * @param {() => void} props.onConfirm - Called to proceed to OCR language select.\n * @returns {JSX.Element}\n */\nexport default function OcrConfirmModal({show, onClose, onConfirm}) {\n    return (\n        <Modal show={show} onHide={onClose} centered>\n            <Modal.Header closeButton />\n            <Modal.Body>\n                <div className=\"bg-light p-3\">\n                    <h5 className=\"modal-title mb-3\">Are you sure?</h5>\n                    <p>\n                        Clicking \"Transcribe with OCR\" will remove all existing\n                        transcription text and replace it with automatically\n                        generated text. Use the \"Undo\" button to restore\n                        previous text.\n                    </p>\n                </div>\n            </Modal.Body>\n            <Modal.Footer>\n                <Button variant=\"primary\" onClick={onClose}>\n                    Cancel\n                </Button>\n                <Button\n                    variant=\"link\"\n                    className=\"underline-link fw-bold\"\n                    onClick={onConfirm}\n                >\n                    Yes, Select Language\n                </Button>\n            </Modal.Footer>\n        </Modal>\n    );\n}\n"
  },
  {
    "path": "frontend/src/ocr/Handler.jsx",
    "content": "import React, {useState} from 'react';\n\nimport OcrButton from './Button';\nimport OcrConfirmModal from './ConfirmModal';\nimport OcrLanguageModal from './LanguageModal';\nimport OcrHelpModal from './HelpModal';\n\n/**\n * Orchestrates the OCR flow for the transcription editor.\n *\n * Flow:\n * 1) User clicks \"Transcribe with OCR\" button which opens a confirm modal.\n * 2) Confirm opens a language selection modal.\n * 3) Submit posts to `/api/assets/{assetId}/transcriptions/ocr` with\n *    `{language, supersedes}` then calls `onTranscriptionUpdate` with\n *    the server response.\n *\n * State:\n * - showConfirm: controls the confirm modal.\n * - showLanguage: controls the language modal.\n * - selectedLang: ISO 639-3 code, defaults to \"eng\".\n * - isSubmitting: disables inputs during the request.\n * - error: displays server or network errors in the language modal.\n *\n * Notes:\n * - OCR replaces existing text.\n * - Expects the API to return a TranscriptionOut payload with an `asset`\n *   object used to refresh editor state.\n *\n * Accessibility:\n * - Modals come from react-bootstrap which handles focus and aria attributes.\n *\n * @param {Object} props\n * @param {number} props.assetId - Asset primary key for API calls.\n * @param {Object|null} props.transcription - Current transcription or null.\n * @param {Array<[string,string]>} props.languages - OCR languages as\n *   `[code, label]`.\n * @param {(updated: Object) => void} props.onTranscriptionUpdate - Called with\n *   the API response after a successful OCR request.\n * @returns {JSX.Element}\n */\nexport default function OcrHandler({\n    assetId,\n    transcription,\n    languages,\n    onTranscriptionUpdate,\n}) {\n    const [showConfirm, setShowConfirm] = useState(false);\n    const [showLanguage, setShowLanguage] = useState(false);\n    const [selectedLang, setSelectedLang] = useState('eng');\n    const [isSubmitting, setIsSubmitting] = useState(false);\n    const [error, setError] = useState(null);\n\n    const handleOcrClick = () => {\n        setShowConfirm(true);\n    };\n\n    const handleConfirm = () => {\n        setShowConfirm(false);\n        setShowLanguage(true);\n    };\n\n    const handleCancelLanguage = () => {\n        setShowLanguage(false);\n        setSelectedLang('eng');\n    };\n\n    const handleLanguageChange = (lang) => {\n        setSelectedLang(lang);\n    };\n\n    const handleLanguageSubmit = async () => {\n        setIsSubmitting(true);\n        setError(null);\n        try {\n            const response = await fetch(\n                `/api/assets/${assetId}/transcriptions/ocr`,\n                {\n                    method: 'POST',\n                    headers: {'Content-Type': 'application/json'},\n                    body: JSON.stringify({\n                        language: selectedLang,\n                        supersedes: transcription?.id || null,\n                    }),\n                },\n            );\n\n            if (!response.ok) {\n                const data = await response.json();\n                throw new Error(data.detail || data.error || 'OCR failed');\n            }\n\n            const updated = await response.json();\n            setShowLanguage(false);\n            if (onTranscriptionUpdate) onTranscriptionUpdate(updated);\n        } catch (err) {\n            setError(err.message);\n        } finally {\n            setIsSubmitting(false);\n        }\n    };\n\n    return (\n        <>\n            <OcrHelpModal />\n            <OcrButton onClick={handleOcrClick} />\n            <OcrConfirmModal\n                show={showConfirm}\n                onClose={() => setShowConfirm(false)}\n                onConfirm={handleConfirm}\n            />\n            <OcrLanguageModal\n                show={showLanguage}\n                selectedLang={selectedLang}\n                onChange={handleLanguageChange}\n                onClose={handleCancelLanguage}\n                onSubmit={handleLanguageSubmit}\n                disabled={isSubmitting}\n                error={error}\n                languages={languages}\n            />\n        </>\n    );\n}\n"
  },
  {
    "path": "frontend/src/ocr/HelpModal.jsx",
    "content": "import React from 'react';\n\n/**\n * Bootstrap modal explaining the \"Transcribe with OCR\" feature.\n *\n * Behavior:\n * - Static content only. Shown and hidden by Bootstrap via data attributes.\n * - Triggered by an element using `data-bs-target=\"#ocr-help-modal\"`.\n *\n * Accessibility:\n * - Uses Bootstrap modal roles and close button. Container has `role=\"dialog\"`.\n *\n * @returns {JSX.Element}\n */\nexport default function OcrHelpModal() {\n    return (\n        <div id=\"ocr-help-modal\" className=\"modal\" tabIndex={-1} role=\"dialog\">\n            <div className=\"modal-dialog modal-dialog-centered\" role=\"document\">\n                <div className=\"modal-content\">\n                    <div className=\"modal-header\">\n                        <h5 className=\"modal-title\">\n                            About Transcribe with OCR\n                        </h5>\n                        <button\n                            type=\"button\"\n                            className=\"btn-close\"\n                            data-bs-dismiss=\"modal\"\n                            aria-label=\"Close\"\n                        ></button>\n                    </div>\n                    <div className=\"modal-body\">\n                        <h6 className=\"modal-title\">What is OCR?</h6>\n                        <p>\n                            OCR stands for Optical Character Recognition. OCR is\n                            a software tool that can extract print text from\n                            some documents.\n                        </p>\n\n                        <h6>When will OCR work well?</h6>\n                        <p>\n                            OCR does not work on handwriting. It only works for\n                            printed or typed text, meaning text created by a\n                            typewriter, printing press or other mechanical\n                            means. OCR will do best on consistent and clear\n                            images of modern typefaces.\n                        </p>\n\n                        <h6>\n                            Do I still need to review pages started with OCR?\n                        </h6>\n                        <p>\n                            Yes. OCR is imperfect. It may not work well for some\n                            or all parts of a typed page, but it can be a great\n                            starting point. If you start a page with OCR you\n                            should read the text closely before submitting. If\n                            you are reviewing an OCR-ed page you still need to\n                            review.\n                        </p>\n\n                        <h6>Who can use \"Transcribe with OCR\"?</h6>\n                        <p>\n                            <a href=\"/account/register/\">\n                                Register for an account\n                            </a>{' '}\n                            and <a href=\"/account/login/\">log in</a> to use this\n                            feature.\n                        </p>\n\n                        <h6>\n                            Why does{' '}\n                            <span className=\"fst-italic\">By the People</span>{' '}\n                            have this feature?\n                        </h6>\n                        <p>\n                            We always want to use volunteer time effectively.\n                            When the Library of Congress digitizes a large group\n                            of printed pages it will usually OCR them. The\n                            materials in By the People campaigns are not good\n                            candidates for applying OCR at scale either because\n                            they are handwritten, a mixed collection of\n                            handwritten and print materials or printed on paper\n                            or in a typeface that does not produce accurate OCR\n                            results. However, OCR can still be a useful starting\n                            point for some typed pages. Use it if you like it or\n                            skip it if you do not.\n                        </p>\n                    </div>\n                    <div className=\"modal-footer justify-content-center\">\n                        <button\n                            type=\"button\"\n                            className=\"btn btn-primary\"\n                            data-bs-dismiss=\"modal\"\n                        >\n                            Close\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/ocr/LanguageModal.jsx",
    "content": "import React, {useState} from 'react';\nimport Modal from 'react-bootstrap/Modal';\nimport Button from 'react-bootstrap/Button';\n\n/**\n * Language picker modal for OCR.\n *\n * Behavior:\n * - Presents a scrollable list of languages from `languages`.\n * - Calls `onSubmit({ language, supersedes })` when the user confirms.\n *\n * Accessibility:\n * - Uses react-bootstrap Modal roles. The select has an associated label.\n *\n * Props:\n * @param {boolean} show\n *   Whether the modal is visible.\n * @param {function} onClose\n *   Called to dismiss the modal.\n * @param {function} onSubmit\n *   Called on confirmation. Receives `{ language, supersedes }`.\n * @param {Array<[string,string]>} languages\n *   Array of `[code, label]` tuples, e.g. `[[\"eng\",\"English\"], ...]`.\n * @param {string} [supersedes]\n *   Transcription id that the OCR result will supersede.\n * @param {string} [selectedLang]\n *   Controlled selected language code.\n * @param {function} [onChange]\n *   Controlled change handler: `onChange(code)`.\n * @param {boolean} [disabled]\n *   Disable the confirm button while submitting.\n * @param {string|null} [error]\n *   Optional error message to display.\n *\n * Usage:\n * <OcrLanguageModal\n *   show={show}\n *   languages={languages}\n *   selectedLang={selectedLang}\n *   onChange={setSelectedLang}\n *   onClose={handleClose}\n *   onSubmit={handleSubmit}\n *   disabled={isSubmitting}\n *   error={error}\n * />\n */\nexport default function OcrLanguageModal({\n    show,\n    onClose,\n    onSubmit,\n    languages,\n    supersedes,\n    selectedLang,\n    onChange,\n    disabled = false,\n    error = null,\n}) {\n    // Uncontrolled fallback state\n    const [localLang, setLocalLang] = useState(() => {\n        const eng = languages.find(([code]) => code === 'eng')?.[0];\n        return eng || languages[0]?.[0] || '';\n    });\n\n    const isControlled =\n        typeof selectedLang === 'string' && typeof onChange === 'function';\n    const value = isControlled ? selectedLang : localLang;\n\n    const handleChange = (e) => {\n        const code = e.target.value;\n        if (isControlled) {\n            onChange(code);\n        } else {\n            setLocalLang(code);\n        }\n    };\n\n    const handleSubmit = () => {\n        if (!value) return;\n        onSubmit({language: value, supersedes});\n    };\n\n    return (\n        <Modal show={show} onHide={onClose} centered>\n            <Modal.Header closeButton />\n            <Modal.Body>\n                <div className=\"bg-light p-3\">\n                    <h5 className=\"modal-title mb-3\">Select language</h5>\n                    <p>\n                        Select the language of the transcription from the list\n                        below.\n                    </p>\n\n                    {error && (\n                        <div className=\"alert alert-danger\" role=\"alert\">\n                            {error}\n                        </div>\n                    )}\n\n                    <div className=\"text-center pb-1\">\n                        <label htmlFor=\"language\" className=\"form-label\">\n                            Language\n                        </label>\n                        <select\n                            id=\"language\"\n                            name=\"language\"\n                            size={7}\n                            className=\"form-select\"\n                            value={value}\n                            onChange={handleChange}\n                            aria-label=\"Select OCR language\"\n                        >\n                            {languages.map(([code, label]) => (\n                                <option key={code} value={code}>\n                                    {label}\n                                </option>\n                            ))}\n                        </select>\n                    </div>\n                </div>\n            </Modal.Body>\n            <Modal.Footer>\n                <Button variant=\"primary\" onClick={onClose}>\n                    Cancel\n                </Button>\n                <Button\n                    className=\"underline-link fw-bold\"\n                    variant=\"link\"\n                    disabled={!value || disabled}\n                    onClick={handleSubmit}\n                >\n                    Replace Text\n                </Button>\n            </Modal.Footer>\n        </Modal>\n    );\n}\n"
  },
  {
    "path": "frontend/src/ocr/Section.jsx",
    "content": "/**\n * OCR section wrapper for the viewer column.\n *\n * Purpose:\n * - Hosts the OCR entrypoint UI.\n * - Forwards asset context and handlers to the OCR flow.\n *\n * Integration:\n * - Rendered by ViewerSplit alongside the image viewer.\n * - Delegates all OCR actions to OcrHandler.\n *\n * Usage:\n * <OcrSection\n *   assetId={asset.id}\n *   transcription={asset.transcription}\n *   onTranscriptionUpdate={handleTranscriptionUpdate}\n *   languages={asset.languages}\n * />\n */\n\nimport React from 'react';\n\nimport OcrHandler from './Handler';\n\n/**\n * Lightweight container that places the OCR controls in the viewer column.\n *\n * Props:\n * @param {Object} props\n * @param {number} props.assetId\n *   The current asset id used by API calls.\n * @param {{ id?: number, text?: string } | null} props.transcription\n *   The current transcription object or null if none exists.\n * @param {function(Object):void} props.onTranscriptionUpdate\n *   Callback invoked with the API response after OCR creates a transcription.\n * @param {Array<[string,string]>} props.languages\n *   Array of [code, label] language tuples for OCR selection.\n *\n * Returns:\n * @returns {JSX.Element}\n */\nexport default function OcrSection({\n    assetId,\n    transcription,\n    onTranscriptionUpdate,\n    languages,\n}) {\n    return (\n        <div id=\"ocr-section\" className=\"row ps-3 pb-4 bg-white print-none\">\n            <div className=\"d-flex flex-row align-items-center justify-content-end mt-1\">\n                <OcrHandler\n                    assetId={assetId}\n                    transcription={transcription}\n                    onTranscriptionUpdate={onTranscriptionUpdate}\n                    languages={languages}\n                />\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/Controls.jsx",
    "content": "/**\n * Viewer toolbar with layout, zoom, rotate, flip, filters, help and fullscreen.\n *\n * Purpose:\n * - Provide a consistent control strip for the image viewer.\n * - Emit layout events to the parent container.\n * - Expose stable button ids so external code can bind OpenSeadragon actions.\n *\n * Integration:\n * - The parent supplies handlers for layout changes and fullscreen:\n *   onLayoutHorizontal, onLayoutVertical, toggleFullscreen.\n * - Other buttons are bound by id at runtime by OpenSeadragon:\n *   #viewer-home, #viewer-zoom-in, #viewer-zoom-out,\n *   #viewer-rotate-left, #viewer-rotate-right, #viewer-flip.\n * - Bootstrap attributes handle the filters collapse and keyboard help modal.\n *\n * Accessibility:\n * - Buttons include title text. Icons add aria-label where needed.\n *\n * Usage:\n * <ViewerControls\n *   onLayoutHorizontal={() => setLayout('h')}\n *   onLayoutVertical={() => setLayout('v')}\n *   toggleFullscreen={handleFullscreen}\n * />\n */\n\nimport React from 'react';\n\n/**\n * @param {Object} props\n * @param {function():void} props.onLayoutHorizontal\n *   Switch to horizontal layout.\n * @param {function():void} props.onLayoutVertical\n *   Switch to vertical layout.\n * @param {function():void} props.toggleFullscreen\n *   Enter or exit fullscreen mode for the viewer.\n * @returns {JSX.Element}\n */\nexport default function ViewerControls({\n    onLayoutHorizontal,\n    onLayoutVertical,\n    toggleFullscreen,\n}) {\n    return (\n        <div id=\"viewer-controls\" className=\"m-1 text-center d-print-none\">\n            <div className=\"d-inline-flex justify-content-between\">\n                <div className=\"d-flex btn-group m-1\">\n                    <button\n                        id=\"viewer-layout-vertical\"\n                        className=\"btn btn-dark viewer-control-button\"\n                        title=\"Vertical Layout\"\n                        onClick={onLayoutVertical}\n                    >\n                        <span className=\"fas fa-grip-lines\"></span>\n                    </button>\n                    <button\n                        id=\"viewer-layout-horizontal\"\n                        className=\"btn btn-dark\"\n                        title=\"Horizontal Layout\"\n                        onClick={onLayoutHorizontal}\n                    >\n                        <span className=\"fas fa-grip-lines-vertical\"></span>\n                    </button>\n                </div>\n\n                <div className=\"d-flex btn-group m-1\">\n                    <button\n                        type=\"button\"\n                        id=\"viewer-home\"\n                        className=\"btn btn-dark viewer-control-button\"\n                        title=\"Fit Image to Viewport\"\n                    >\n                        <span className=\"fas fa-compress\"></span>\n                    </button>\n                </div>\n\n                <div className=\"d-flex btn-group m-1\">\n                    <button\n                        id=\"viewer-zoom-in\"\n                        className=\"btn btn-dark viewer-control-button\"\n                        title=\"Zoom In\"\n                    >\n                        <span className=\"fas fa-search-plus\"></span>\n                    </button>\n                    <button\n                        id=\"viewer-zoom-out\"\n                        className=\"btn btn-dark\"\n                        title=\"Zoom Out\"\n                    >\n                        <span className=\"fas fa-search-minus\"></span>\n                    </button>\n                </div>\n\n                <div className=\"d-flex btn-group m-1\">\n                    <button\n                        id=\"viewer-rotate-left\"\n                        className=\"btn btn-dark viewer-control-button\"\n                        title=\"Rotate Left\"\n                    >\n                        <span className=\"fas fa-undo\"></span>\n                    </button>\n                    <button\n                        id=\"viewer-rotate-right\"\n                        className=\"btn btn-dark viewer-control-button\"\n                        title=\"Rotate Right\"\n                    >\n                        <span className=\"fas fa-redo\"></span>\n                    </button>\n                </div>\n\n                <div className=\"d-flex btn-group m-1\">\n                    <button\n                        id=\"viewer-flip\"\n                        className=\"btn btn-dark viewer-control-button\"\n                        title=\"Flip\"\n                    >\n                        <span className=\"fas fa-exchange-alt\"></span>\n                    </button>\n                </div>\n\n                <div className=\"d-flex btn-group m-1\">\n                    <button\n                        type=\"button\"\n                        className=\"btn btn-dark extra-control-button\"\n                        title=\"Image Filters\"\n                        data-bs-toggle=\"collapse\"\n                        data-bs-target=\"#image-filters\"\n                    >\n                        <span\n                            className=\"fas fa-sliders-h\"\n                            aria-label=\"Image Filters\"\n                        ></span>\n                    </button>\n                </div>\n\n                <div className=\"d-flex btn-group m-1\">\n                    <button\n                        type=\"button\"\n                        id=\"viewer-fullscreen\"\n                        className=\"btn btn-dark extra-control-button\"\n                        title=\"View Full Screen\"\n                        onClick={toggleFullscreen}\n                    >\n                        <span className=\"fas fa-expand\"></span>\n                    </button>\n                </div>\n\n                <div className=\"d-flex btn-group m-1\">\n                    <button\n                        type=\"button\"\n                        className=\"btn btn-dark extra-control-button\"\n                        title=\"Viewer keyboard shortcuts\"\n                        data-bs-toggle=\"modal\"\n                        data-bs-target=\"#keyboard-help-modal\"\n                    >\n                        <span\n                            className=\"fas fa-question-circle\"\n                            aria-label=\"Viewer keyboard shortcuts\"\n                        ></span>\n                    </button>\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/FilterTabNav.jsx",
    "content": "/**\n * Tab navigation for image filters: Brightness, Invert, Contrast.\n *\n * Purpose:\n * - Provide three Bootstrap tab buttons that toggle filter panes.\n * - Expose stable button ids for external binding:\n *   #viewer-gamma, #viewer-invert, #viewer-threshold.\n *\n * Integration:\n * - Buttons use data-bs-toggle=\"tab\" and data-bs-target to switch panes:\n *   #gamma-filter, #invert-filter, #threshold-filter.\n * - Parent markup must include a .tab-content with matching pane ids.\n *\n * Accessibility:\n * - role=\"tablist\" on the <ul>, role=\"presentation\" on list items, role=\"tab\" on\n *   buttons.\n * - The first tab is marked active by default.\n *\n * Usage:\n * <FilterTabNav />\n */\n\n/**\n * @returns {JSX.Element}\n */\nexport default function FilterTabNav() {\n    return (\n        <ul\n            className=\"d-inline-flex mt-1 btn-group nav nav-tabs\"\n            role=\"tablist\"\n        >\n            <li className=\"nav-item\" role=\"presentation\">\n                <button\n                    id=\"viewer-gamma\"\n                    className=\"btn btn-dark nav-link active\"\n                    title=\"Adjust gamma\"\n                    data-bs-toggle=\"tab\"\n                    data-bs-target=\"#gamma-filter\"\n                    role=\"tab\"\n                >\n                    Brightness\n                </button>\n            </li>\n            <li className=\"nav-item\" role=\"presentation\">\n                <button\n                    id=\"viewer-invert\"\n                    className=\"btn btn-dark nav-link\"\n                    title=\"Invert colors\"\n                    data-bs-toggle=\"tab\"\n                    data-bs-target=\"#invert-filter\"\n                    role=\"tab\"\n                >\n                    Invert\n                </button>\n            </li>\n            <li className=\"nav-item\" role=\"presentation\">\n                <button\n                    id=\"viewer-threshold\"\n                    className=\"btn btn-dark nav-link\"\n                    title=\"Adjust threshold\"\n                    data-bs-toggle=\"tab\"\n                    data-bs-target=\"#threshold-filter\"\n                    role=\"tab\"\n                >\n                    Contrast\n                </button>\n            </li>\n        </ul>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/GammaFilterForm.jsx",
    "content": "/**\n * Gamma filter controls for the viewer.\n *\n * Purpose:\n * - Provide synchronized number and range inputs to adjust gamma.\n * - Offer step up/down buttons and a Reset filter control.\n *\n * Behavior:\n * - Value is clamped to [0, 5] and rounded to two decimals.\n * - onSubmit is prevented; onReset sets gamma to 1.0.\n * - Exposed ids for external hooks:\n *   #gamma-filter, #gamma-form, #gamma, #gamma-range, #gamma-up, #gamma-down.\n *\n * Accessibility:\n * - Visually hidden labels for inputs.\n * - Buttons include hidden Increase and Decrease text.\n *\n * Props:\n * @param {number} gamma - Current gamma value.\n * @param {(value:number)=>void} setGamma - Setter invoked on change.\n * @returns {JSX.Element}\n */\nexport default function GammaFilterForm({gamma, setGamma}) {\n    const handleNumberChange = (e) => {\n        const value = parseFloat(e.target.value);\n        if (!isNaN(value)) setGamma(value);\n    };\n\n    const handleRangeChange = (e) => {\n        const value = parseFloat(e.target.value);\n        if (!isNaN(value)) setGamma(value);\n    };\n\n    const stepUp = () => {\n        const newValue = Math.min(5, gamma + 0.01);\n        setGamma(parseFloat(newValue.toFixed(2)));\n    };\n\n    const stepDown = () => {\n        const newValue = Math.max(0, gamma - 0.01);\n        setGamma(parseFloat(newValue.toFixed(2)));\n    };\n\n    const handleReset = () => {\n        setGamma(1.0);\n    };\n\n    return (\n        <div\n            id=\"gamma-filter\"\n            className=\"tab-pane pt-1 ps-3 show active\"\n            role=\"tabpanel\"\n        >\n            <form\n                id=\"gamma-form\"\n                className=\"d-flex align-items-center\"\n                onSubmit={(e) => e.preventDefault()}\n                onReset={handleReset}\n            >\n                <div className=\"row ms-0 me-3 number-input\">\n                    <div className=\"col p-1\">\n                        <input\n                            type=\"number\"\n                            id=\"gamma\"\n                            name=\"gamma\"\n                            min=\"0\"\n                            max=\"5\"\n                            step=\"0.01\"\n                            value={gamma}\n                            onChange={handleNumberChange}\n                        />\n                        <label className=\"visually-hidden\" htmlFor=\"gamma\">\n                            Gamma\n                        </label>\n                    </div>\n                    <div className=\"col p-0 filter-buttons\">\n                        <div className=\"row m-0\">\n                            <button\n                                id=\"gamma-up\"\n                                type=\"button\"\n                                className=\"arrow-button\"\n                                onClick={stepUp}\n                            >\n                                <span className=\"fas fa-chevron-up\" />\n                                <span className=\"visually-hidden\">\n                                    Increase\n                                </span>\n                            </button>\n                        </div>\n                        <div className=\"row m-0\">\n                            <button\n                                id=\"gamma-down\"\n                                type=\"button\"\n                                className=\"arrow-button\"\n                                onClick={stepDown}\n                            >\n                                <span className=\"fas fa-chevron-down\" />\n                                <span className=\"visually-hidden\">\n                                    Decrease\n                                </span>\n                            </button>\n                        </div>\n                    </div>\n                </div>\n                <input\n                    type=\"range\"\n                    id=\"gamma-range\"\n                    name=\"gamma-range\"\n                    min=\"0\"\n                    max=\"5\"\n                    step=\"0.01\"\n                    value={gamma}\n                    onChange={handleRangeChange}\n                    className=\"filter-slider flex-grow-1\"\n                />\n                <label className=\"visually-hidden\" htmlFor=\"gamma-range\">\n                    Gamma\n                </label>\n                <input\n                    type=\"reset\"\n                    className=\"btn btn-link underline-link fw-bold\"\n                    value=\"Reset filter\"\n                />\n            </form>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/ImageFilters.jsx",
    "content": "/**\n * UI for per-viewer image filters backed by OpenSeadragon. Exposes gamma,\n * invert and threshold controls, and applies them to the active viewer via\n * `setFilterOptions`. Updates are debounced to reduce reflow and redraw churn.\n *\n * Dependencies: OpenSeadragon Filters, lodash.debounce, Bootstrap.\n *\n * Behavior:\n * - Builds a processors array from the current control values and sends it to\n *   the viewer with `setFilterOptions({ filters: { processors } })`.\n * - Debounces updates by 100ms.\n * - Resets all filters to defaults with the \"Reset All\" button.\n *\n * Side effects:\n * - Reads `osdViewerRef.current` and calls `setFilterOptions` if present.\n * - Cancels the debounced updater on unmount or dependency change.\n */\n\nimport {useState, useEffect} from 'react';\nimport OpenSeadragon from 'openseadragon';\nimport {GAMMA, INVERT, THRESHOLDING} from 'openseadragon-filters';\nimport debounce from 'lodash.debounce';\n\nimport FilterTabNav from './FilterTabNav';\nimport GammaFilterForm from './GammaFilterForm';\nimport InvertFilterForm from './InvertFilterForm';\nimport ThresholdFilterForm from './ThresholdFilterForm';\n\n/**\n * ImageFilters\n *\n * Controls gamma, invert and threshold, and pushes changes to an\n * OpenSeadragon viewer instance.\n *\n * @component\n * @param {Object} props\n * @param {React.MutableRefObject<OpenSeadragon.Viewer|null>} props.osdViewerRef\n *   A ref to the active OpenSeadragon viewer. Must expose `setFilterOptions`.\n *\n * @example\n *   <ImageFilters filterPluginRef={viewerRef} />\n */\nexport default function ImageFilters({filterPluginRef}) {\n    const [gamma, setGamma] = useState(1.0);\n    const [invert, setInvert] = useState(false);\n    const [threshold, setThreshold] = useState(0);\n\n    // Debounced bridge to OSD filter pipeline\n    const updateFilters = debounce(() => {\n        // Get the plugin instance from the ref\n        const plugin = filterPluginRef.current;\n        if (!plugin) return;\n\n        const processors = [];\n\n        if (gamma !== 1 && gamma >= 0 && gamma <= 5) {\n            processors.push(GAMMA(gamma));\n        }\n        if (invert) {\n            processors.push(INVERT());\n        }\n        if (threshold > 0 && threshold <= 255) {\n            processors.push(THRESHOLDING(threshold));\n        }\n\n        //Call setFilterOptions on the PLUGIN\n        plugin.setFilterOptions({\n            filters: {processors},\n        });\n    }, 100);\n\n    // Apply filters when any control changes\n    useEffect(() => {\n        updateFilters();\n        return updateFilters.cancel; // cleanup debounce\n    }, [gamma, invert, threshold]); // eslint-disable-line react-hooks/exhaustive-deps\n\n    const handleReset = () => {\n        setGamma(1.0);\n        setInvert(false);\n        setThreshold(0);\n    };\n\n    return (\n        <div\n            id=\"image-filters\"\n            className=\"m-1 text-center d-print-none collapse\"\n        >\n            <hr className=\"m-0\" />\n            <FilterTabNav />\n            <div className=\"btn-group m-1\">\n                <button\n                    id=\"viewer-reset\"\n                    className=\"btn\"\n                    title=\"Reset all filters\"\n                    onClick={handleReset}\n                >\n                    Reset All\n                </button>\n            </div>\n            <div id=\"filter-tabs\" className=\"tab-content\">\n                <GammaFilterForm gamma={gamma} setGamma={setGamma} />\n                <InvertFilterForm invert={invert} setInvert={setInvert} />\n                <ThresholdFilterForm\n                    threshold={threshold}\n                    setThreshold={setThreshold}\n                />\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/InvertFilterForm.jsx",
    "content": "/**\n * InvertFilterForm\n *\n * Simple on/off control for an invert color filter. Parent manages state and\n * passes the current value plus a setter.\n *\n * @component\n * @param {Object} props\n * @param {boolean} props.invert - Current invert state\n * @param {(value: boolean) => void} props.setInvert - Setter for invert state\n */\nexport default function InvertFilterForm({invert, setInvert}) {\n    const handleChange = (e) => {\n        setInvert(e.target.checked);\n    };\n\n    const handleReset = () => {\n        setInvert(false);\n    };\n\n    return (\n        <div\n            id=\"invert-filter\"\n            className=\"tab-pane pt-2\"\n            role=\"tabpanel\"\n            style={{backgroundColor: 'white'}}\n        >\n            <form\n                id=\"invert-form\"\n                onSubmit={(e) => e.preventDefault()}\n                onReset={handleReset}\n                className=\"d-flex justify-content-center\"\n            >\n                <label className=\"ms-2 align-middle\">Off</label>\n                <div className=\"form-check form-switch custom-control-inline\">\n                    <input\n                        type=\"checkbox\"\n                        id=\"invert\"\n                        name=\"invert\"\n                        className=\"form-check-input\"\n                        role=\"switch\"\n                        checked={invert}\n                        onChange={handleChange}\n                    />\n                    <label className=\"form-check-label\" htmlFor=\"invert\">\n                        <span className=\"visually-hidden\">Invert</span>\n                    </label>\n                </div>\n                <label className=\"align-middle\">On</label>\n            </form>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/KeyboardHelpModal.jsx",
    "content": "import KeyboardShortcutRow from './KeyboardShortcutRow';\n\n/*\nKeyboardHelpModal\n\nBootstrap modal that lists viewer keyboard shortcuts. Rows are rendered\nwith KeyboardShortcutRow.\n\nUsage:\n- Trigger with data-bs-target=\"#keyboard-help-modal\"\n- Presentational only\n\nAccessibility:\n- Uses role=\"dialog\" and Bootstrap aria attributes\n- Close button has aria-label\n*/\nexport default function KeyboardHelpModal() {\n    return (\n        <div\n            id=\"keyboard-help-modal\"\n            className=\"modal\"\n            tabIndex={-1}\n            role=\"dialog\"\n        >\n            <div className=\"modal-dialog modal-dialog-centered\" role=\"document\">\n                <div className=\"modal-content\">\n                    <div className=\"modal-header\">\n                        <h5 className=\"modal-title\">Keyboard Shortcuts</h5>\n                        <button\n                            type=\"button\"\n                            className=\"btn-close\"\n                            data-bs-dismiss=\"modal\"\n                            aria-label=\"Close\"\n                        ></button>\n                    </div>\n                    <div className=\"modal-body\">\n                        <h6>Viewer Shortcuts</h6>\n                        <table className=\"table table-compact table-responsive\">\n                            <tbody>\n                                <KeyboardShortcutRow\n                                    keys={[\n                                        {text: 'w', wrap: true},\n                                        {text: 'up arrow', wrap: false},\n                                    ]}\n                                    description=\"Scroll the viewport up\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[\n                                        {text: 's', wrap: true},\n                                        {text: 'down arrow', wrap: false},\n                                    ]}\n                                    description=\"Scroll the viewport down\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[\n                                        {text: 'a', wrap: true},\n                                        {text: 'left arrow', wrap: false},\n                                    ]}\n                                    description=\"Scroll the viewport left\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[\n                                        {text: 'd', wrap: true},\n                                        {text: 'right arrow', wrap: false},\n                                    ]}\n                                    description=\"Scroll the viewport right\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[{text: '0', wrap: true}]}\n                                    description=\"Fit the entire image to the viewport\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[\n                                        {text: '-', wrap: true},\n                                        {text: '_', wrap: true},\n                                        {text: 'Shift+W', wrap: false},\n                                        {text: 'Shift+Up arrow', wrap: false},\n                                    ]}\n                                    description=\"Zoom the viewport out\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[\n                                        {text: '=', wrap: true},\n                                        {text: '+', wrap: true},\n                                        {text: 'Shift+S', wrap: false},\n                                        {text: 'Shift+Down arrow', wrap: false},\n                                    ]}\n                                    description=\"Zoom the viewport in\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[{text: 'r', wrap: true}]}\n                                    description=\"Rotate the viewport clockwise\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[{text: 'R', wrap: true}]}\n                                    description=\"Rotate the viewport counterclockwise\"\n                                />\n                                <KeyboardShortcutRow\n                                    keys={[{text: 'f', wrap: true}]}\n                                    description=\"Flip the viewport horizontally\"\n                                />\n                            </tbody>\n                        </table>\n                    </div>\n                    <div className=\"modal-footer\">\n                        <button\n                            type=\"button\"\n                            className=\"btn btn-primary\"\n                            data-bs-dismiss=\"modal\"\n                        >\n                            Close\n                        </button>\n                    </div>\n                </div>\n            </div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/KeyboardShortcutRow.jsx",
    "content": "import React from 'react';\n\n/**\n * KeyboardShortcutRow\n *\n * Renders one table row for a keyboard shortcut. Shows the key sequence in a\n * row header cell and the action description in an adjacent cell.\n *\n * Rendering:\n * - Keys are comma separated with a space\n * - Keys are placed in a <th>, description in a <td>\n *\n * Accessibility:\n * - <kbd> provides semantic markup for key names\n * - Consumers should ensure the surrounding table has proper headers or caption\n *\n * @param {Array<{text: string, wrap: boolean}>} keys - Ordered keys to display.\n *   When wrap is true the key is wrapped in <kbd>, otherwise rendered as plain\n *   text.\n * @param {string} description - Human readable description of the shortcut\n *   action.\n * @returns {JSX.Element}\n */\nexport default function KeyboardShortcutRow({keys, description}) {\n    return (\n        <tr>\n            <th>\n                {keys.map((key, i) => (\n                    <React.Fragment key={i}>\n                        {key.wrap ? <kbd>{key.text}</kbd> : key.text}\n                        {i < keys.length - 1 && ', '}\n                    </React.Fragment>\n                ))}\n            </th>\n            <td>{description}</td>\n        </tr>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/ThresholdFilterForm.jsx",
    "content": "/**\n * Controls the binarization threshold used by the image viewer filter.\n *\n * Behavior:\n * - Number input and range slider stay in sync.\n * - Up and down arrow buttons change the value by 1 within 0-255.\n * - Reset sets the threshold to 0.\n *\n * Accessibility:\n * - Inputs have associated labels with visually hidden text.\n * - Increment and decrement buttons include hidden text for screen readers.\n *\n * @param {number} threshold - Current threshold value in the range 0-255.\n * @param {Function} setThreshold - Setter to update the threshold.\n * @returns {JSX.Element}\n */\nexport default function ThresholdFilterForm({threshold, setThreshold}) {\n    const handleNumberChange = (e) => {\n        setThreshold(parseInt(e.target.value, 10));\n    };\n\n    const handleRangeChange = (e) => {\n        setThreshold(parseInt(e.target.value, 10));\n    };\n\n    const handleReset = () => {\n        setThreshold(0);\n    };\n\n    const stepUp = () => {\n        setThreshold((prev) => Math.min(prev + 1, 255));\n    };\n\n    const stepDown = () => {\n        setThreshold((prev) => Math.max(prev - 1, 0));\n    };\n\n    return (\n        <div\n            id=\"threshold-filter\"\n            className=\"tab-pane pt-1 ps-3\"\n            role=\"tabpanel\"\n        >\n            <form\n                id=\"threshold-form\"\n                className=\"d-flex align-items-center\"\n                onSubmit={(e) => e.preventDefault()}\n                onReset={handleReset}\n            >\n                <div className=\"row ms-0 me-3 number-input\">\n                    <div className=\"col p-1\">\n                        <input\n                            type=\"number\"\n                            id=\"threshold\"\n                            name=\"threshold\"\n                            min=\"0\"\n                            max=\"255\"\n                            step=\"1\"\n                            value={threshold}\n                            onChange={handleNumberChange}\n                        />\n                        <label className=\"visually-hidden\" htmlFor=\"threshold\">\n                            Threshold\n                        </label>\n                    </div>\n                    <div className=\"col p-0 filter-buttons\">\n                        <div className=\"row m-0\">\n                            <button\n                                id=\"threshold-up\"\n                                type=\"button\"\n                                className=\"arrow-button\"\n                                onClick={stepUp}\n                            >\n                                <span className=\"fas fa-chevron-up\" />\n                                <span className=\"visually-hidden\">\n                                    Increase\n                                </span>\n                            </button>\n                        </div>\n                        <div className=\"row m-0\">\n                            <button\n                                id=\"threshold-down\"\n                                type=\"button\"\n                                className=\"arrow-button\"\n                                onClick={stepDown}\n                            >\n                                <span className=\"fas fa-chevron-down\" />\n                                <span className=\"visually-hidden\">\n                                    Decrease\n                                </span>\n                            </button>\n                        </div>\n                    </div>\n                </div>\n                <input\n                    type=\"range\"\n                    id=\"threshold-range\"\n                    name=\"threshold-range\"\n                    min=\"0\"\n                    max=\"255\"\n                    step=\"1\"\n                    value={threshold}\n                    onChange={handleRangeChange}\n                    className=\"filter-slider flex-grow-1\"\n                />\n                <label className=\"visually-hidden\" htmlFor=\"threshold-range\">\n                    Threshold\n                </label>\n                <input\n                    type=\"reset\"\n                    className=\"btn btn-link underline-link fw-bold\"\n                    value=\"Reset filter\"\n                />\n            </form>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/src/viewer/Viewer.jsx",
    "content": "import React, {useEffect, useRef, useState} from 'react';\nimport OpenSeadragon from 'openseadragon';\nimport {initializeFiltering} from 'openseadragon-filters';\nimport screenfull from 'screenfull';\n\nimport {prefixUrl, contactUrl} from '../config.js';\nimport ViewerControls from './Controls';\nimport ImageFilters from './ImageFilters';\nimport KeyboardHelpModal from './KeyboardHelpModal';\n\n/**\n * Viewer\n *\n * Mounts an OpenSeadragon instance, wires up UI controls and filter panels,\n * and exposes a fullscreen toggle. Cleans up the viewer on unmount.\n *\n * Behavior:\n * - Initializes OpenSeadragon with filtering support and common UI buttons\n * - On \"open\" event, recenters via viewport.goHome(true)\n * - On \"open-failed\", logs an error and shows an alert with a contact URL\n * - Stores the live OSD instance on window.seadragonViewer for external use\n * - Destroys the OSD instance during cleanup to avoid leaks\n *\n * Dependencies:\n * - Requires the \"openseadragon-filters\" plugin to be imported once\n * - Uses the \"screenfull\" library for fullscreen where available\n *\n * @param {string} imageUrl - Source image URL used by OpenSeadragon.\n * @param {Function} onLayoutHorizontal - Callback to switch to horizontal layout.\n * @param {Function} onLayoutVertical - Callback to switch to vertical layout.\n * @returns {JSX.Element}\n */\nexport default function Viewer({\n    imageUrl,\n    onLayoutHorizontal,\n    onLayoutVertical,\n}) {\n    const viewerRef = useRef(null); // For OSD\n    const containerRef = useRef(null); // For Fullscreen wrapper\n    const osdViewerRef = useRef(null);\n    const filterPluginRef = useRef(null);\n\n    // State to track fullscreen changes\n    const [isFullscreen, setIsFullscreen] = useState(false);\n\n    // Add listener for fullscreen changes\n    useEffect(() => {\n        const handler = () => {\n            setIsFullscreen(screenfull.isFullscreen);\n        };\n\n        if (screenfull.isEnabled) {\n            screenfull.on('change', handler);\n        }\n\n        return () => {\n            if (screenfull.isEnabled) {\n                screenfull.off('change', handler);\n            }\n        };\n    }, []);\n\n    useEffect(() => {\n        if (!viewerRef.current || !imageUrl) return;\n\n        osdViewerRef.current = OpenSeadragon({\n            element: viewerRef.current,\n            prefixUrl: prefixUrl,\n            tileSources: {\n                type: 'image',\n                url: `${imageUrl}?canvas`,\n            },\n            gestureSettingsTouch: {\n                pinchRotate: true,\n            },\n            showNavigator: true,\n            showRotationControl: true,\n            showFlipControl: true,\n            zoomInButton: 'viewer-zoom-in',\n            zoomOutButton: 'viewer-zoom-out',\n            homeButton: 'viewer-home',\n            rotateLeftButton: 'viewer-rotate-left',\n            rotateRightButton: 'viewer-rotate-right',\n            flipButton: 'viewer-flip',\n            crossOriginPolicy: 'Anonymous',\n            drawer: 'canvas',\n            defaultZoomLevel: 0,\n            homeFillsView: false,\n        });\n\n        window.seadragonViewer = osdViewerRef.current;\n\n        osdViewerRef.current.addHandler('open', () => {\n            setTimeout(() => {\n                osdViewerRef.current.viewport.goHome(true);\n            }, 0);\n        });\n\n        osdViewerRef.current.addHandler('open-failed', () => {\n            console.error('Unable to display image');\n            alert(`Unable to display image. Contact us at ${contactUrl}`);\n        });\n\n        // Initialize the plugin instance - filtering using the ESM method\n        filterPluginRef.current = initializeFiltering(osdViewerRef.current);\n\n        return () => {\n            if (osdViewerRef.current) {\n                osdViewerRef.current.destroy();\n                osdViewerRef.current = null;\n            }\n            // Clear the plugin ref on unmount\n            filterPluginRef.current = null;\n        };\n    }, [imageUrl]);\n\n    const toggleFullscreen = (e) => {\n        e.preventDefault();\n        if (!screenfull.isEnabled) return;\n        if (screenfull.isFullscreen) {\n            screenfull.exit();\n        } else {\n            // Request fullscreen on the wrapper, not just the image\n            screenfull.request(containerRef.current);\n        }\n    };\n\n    return (\n        <div\n            ref={containerRef}\n            className={`d-flex flex-column h-100 w-100 ${\n                isFullscreen ? 'is-fullscreen' : ''\n            }`}\n            style={isFullscreen ? {backgroundColor: '#212529'} : {}}\n        >\n            <ViewerControls\n                onLayoutHorizontal={onLayoutHorizontal}\n                onLayoutVertical={onLayoutVertical}\n                toggleFullscreen={toggleFullscreen}\n            />\n            <ImageFilters filterPluginRef={filterPluginRef} />\n            <KeyboardHelpModal />\n            <div\n                id=\"asset-image\"\n                ref={viewerRef}\n                className=\"flex-grow-1 bg-dark d-print-none w-100\"\n            ></div>\n        </div>\n    );\n}\n"
  },
  {
    "path": "frontend/vite.config.js",
    "content": "import {defineConfig} from 'vite';\nimport react from '@vitejs/plugin-react';\nimport {viteStaticCopy} from 'vite-plugin-static-copy';\n\nexport default defineConfig({\n    base: '/static/frontend/',\n    plugins: [\n        react(),\n        viteStaticCopy({\n            targets: [\n                {\n                    src: 'node_modules/openseadragon/build/openseadragon/images/*',\n                    dest: 'openseadragon-images',\n                },\n            ],\n        }),\n    ],\n    build: {\n        outDir: '../static/frontend',\n        minify: false,\n        emptyOutDir: true,\n        rollupOptions: {\n            output: {\n                entryFileNames: 'js/[name].js',\n                chunkFileNames: 'js/[name].js',\n                assetFileNames: ({name}) =>\n                    name && name.endsWith('.css')\n                        ? 'css/[name][extname]'\n                        : 'assets/[name][extname]',\n            },\n        },\n    },\n});\n"
  },
  {
    "path": "importer/Dockerfile",
    "content": "FROM python:3.12-slim-bookworm\n\n## Add the wait script to the image\nADD https://github.com/ufoscout/docker-compose-wait/releases/download/2.2.1/wait /wait\nRUN chmod +x /wait\n\nENV DEBIAN_FRONTEND=\"noninteractive\"\n\nRUN apt-get update -qy && apt-get install -qy curl\n\n# Ensure that the Library's certificate authority is trusted so the tampering\n# proxy will not break TLS validation. See\n# https://staff.loc.gov/wikis/display/SE/Configuring+HTTPS+clients+for+the+HTTPS+tampering+proxy.\n\nRUN curl -fso /etc/ssl/certs/LOC-ROOT-CA-1.crt http://crl.loc.gov/LOC-ROOT-CA-1.crt && openssl x509 -inform der -in /etc/ssl/certs/LOC-ROOT-CA-1.crt -outform pem -out /etc/ssl/certs/LOC-ROOT-CA-1.pem && c_rehash\n\nRUN apt-get update -qy && apt-get dist-upgrade -qy && apt-get install -o Dpkg::Options::='--force-confnew' -qy \\\n    git \\\n    libmemcached-dev \\\n    # Pillow/Imaging: https://pillow.readthedocs.io/en/latest/installation.html#external-libraries\n    libz-dev libfreetype6-dev \\\n    libtiff-dev libjpeg-dev libopenjp2-7-dev libwebp-dev zlib1g-dev \\\n    # Postgres client library to build psycopg\n    libpq-dev \\\n    locales \\\n    # Weasyprint requirements\n    libpango-1.0-0 libharfbuzz0b libpangoft2-1.0-0 \\\n    gcc && apt-get -qy autoremove && apt-get -qy autoclean\n\nRUN locale-gen en_US.UTF-8\nENV LC_ALL=en_US.UTF-8\nENV LANG=en_US.UTF-8\nENV LANGUAGE=en_US.UTF-8\n\nENV PYTHONUNBUFFERED=1 \\\n    PYTHONPATH=/app\n\nENV DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-concordia.settings_docker}\n\nRUN pip install --upgrade pip\nRUN pip install --no-cache-dir pipenv\n\nWORKDIR /app\nCOPY . /app\n\nRUN pipenv install --system --dev --deploy && rm -rf ~/.cache/\n\nCMD /wait && ./importer/entrypoint.sh\n"
  },
  {
    "path": "importer/README.md",
    "content": "# Importer\n\nThis is a Django app which uses celery to download images from a\ncollection on loc.gov. It also uploads those images to an S3 bucket.\n\n## Prerequisites\n\n1. If uploading to S3 bucket, AWS S3 bucket created and your environment is configured for the awscli tool\n1. If running in dev mode, HTTP access to tile-dev.loc.gov and dev.loc.gov\n\n## Usage\n\n1. Start the Python shell:\n\n    ```bash\n    $ docker-compose up\n    $ docker exec -it concordia_importer_1 bash\n    root@62e3ebef4de2:/app# python3 ./manage.py shell\n    ```\n\n1. Run some test imports:\n\n    ```Python console\n    Python 3.6.5rc1 (default, Mar 14 2018, 06:54:23) [GCC 7.3.0] on linux\n    Type \"help\", \"copyright\", \"credits\" or \"license\" for more information.\n    >>> from importer.importer.tasks import download_async_campaign, check_completeness\n    >>> result = download_async_campaign.delay(\"https://www.loc.gov/collections/clara-barton-papers/?fa=partof:clara+barton+papers:++diaries+and+journals,+1849-1911\")\n    >>> result.ready()\n    >>> result.get()\n    >>> result2 = check_completeness.delay()\n    >>> result2.ready()\n    >>> result2.get()\n    ```\n\nTo count the files and check disk usage in `/concordia_images` after download is\ncomplete:\n\n```console\n$ docker exec -it concordia_app_1 bash\n$ find /concordia_images -type f | wc -l\n$ df -kh\n```\n\n## Integration\n\nAfter the images have been downloaded in the docker environment:\n\n1. Copy the images from the docker volume to the running docker app container.\n\n    ```bash\n    $ ubuntu@ip-172-31-94-65:~/concordia$ sudo docker exec -it concordia_app_1 bash\n    $ root@6eca4f3cd16d:/app# cp -R /concordia_images/mss* concordia/static/img/\n    ```\n\n1. Run the migrations in the docker app to load Clara Barton Diaries and Branch\n   Rickey collections to concordia.\n\n    ```bash\n    $ root@6eca4f3cd16d:/app# python3 ./manage.py migrate\n    ```\n"
  },
  {
    "path": "importer/__init__.py",
    "content": "\"\"\"\nDesign\n======\n\nThe importer currently only supports loading items from www.loc.gov\n\nGeneral goals:\n\n* All state is stored in the database and visible for reporting\n* Celery tasks are ephemeral and while they may be configured to retry they will\n  always check the database to avoid conflicts and use transactions to prevent\n  race conditions\n\nThe import process works like this:\n\n1. A user submits a request to import a URL. This can be an item page, a\n   collection page, or an arbitrary search result set.\n2. An ImportJob is created which records that request and a background Celery\n   task is launched to determine what items it contains (this can potentially be\n   well into the thousands)\n3. For collection and search URLs (which share a common data format) the task\n   loads the JSON representation and queues item import tasks for each item. For\n   item URLs, the item import task is directly queued.\n4. When the item import task runs it creates an ImportItem record, loads the\n   item metadata, and creates ImportItem and ImportItemAsset records to track\n   subsequent import work. It creates the Item and Asset records which will hold\n   the actual item data as well because this allows review while a large import\n   is in progress and our community managers quality review items before making\n   them visible to the community. The asset import tasks are queued at the end\n   of this step.\n5. When the asset import task runs, it downloads the remote file and saves it in\n   Concordia's working storage. Each asset is processed independently so\n   completed downloads will not consume local storage until the [potentially\n   very large] item has completely downloaded, which could potentially take\n   hours or days if there are service availability issues requiring retries.\n6. When all of the asset tasks are completed the item will be marked as\n   completed.\n7. When all of the item tasks are completed the job will be marked as completed.\n\"\"\"\n"
  },
  {
    "path": "importer/admin.py",
    "content": "from django.contrib import admin, messages\nfrom django.contrib.humanize.templatetags.humanize import naturaltime\nfrom django.db.models import Count, F, Max, Q, QuerySet\nfrom django.http import HttpRequest\nfrom django.utils.translation import gettext_lazy as _\n\nfrom concordia.admin.filters import (\n    CampaignListFilter,\n    CampaignProjectListFilter,\n    NullableTimestampFilter,\n)\nfrom concordia.models import Campaign\nfrom importer.tasks.assets import download_asset_task\n\nfrom .models import (\n    DownloadAssetImageJob,\n    ImportItem,\n    ImportItemAsset,\n    ImportJob,\n    VerifyAssetImageJob,\n)\n\n\n@admin.action(description=\"Retry import\")\ndef retry_download_task(\n    modeladmin: admin.ModelAdmin,\n    request: HttpRequest,\n    queryset: QuerySet[ImportItemAsset],\n) -> None:\n    \"\"\"\n    Queue the asset download Celery task again for selected rows.\n\n    Args:\n        modeladmin (admin.ModelAdmin): Admin class invoking the action.\n        request (HttpRequest): Current admin request.\n        queryset (QuerySet[ImportItemAsset]): Selected ImportItemAsset rows.\n\n    Returns:\n        None\n    \"\"\"\n    pks = queryset.values_list(\"pk\", flat=True)\n    for pk in pks:\n        download_asset_task.delay(pk)\n    messages.add_message(request, messages.INFO, \"Queued %d tasks\" % len(pks))\n\n\nclass LastStartedFilter(NullableTimestampFilter):\n    \"\"\"Filter by whether a task has a 'last_started' timestamp.\"\"\"\n\n    title = \"Last Started\"\n    parameter_name = \"last_started\"\n    lookup_labels = (\"Unstarted\", \"Started\")\n\n\nclass CompletedFilter(NullableTimestampFilter):\n    \"\"\"Filter by whether a task has a 'completed' timestamp.\"\"\"\n\n    title = \"Completed\"\n    parameter_name = \"completed\"\n    lookup_labels = (\"Incomplete\", \"Completed\")\n\n\nclass FailedFilter(NullableTimestampFilter):\n    \"\"\"Filter by whether a task has a 'failed' timestamp.\"\"\"\n\n    title = \"Failed\"\n    parameter_name = \"failed\"\n    lookup_labels = (\"Has not failed\", \"Has failed\")\n\n\nclass ImportJobProjectListFilter(CampaignProjectListFilter):\n    \"\"\"Project filter for ImportJob rows.\"\"\"\n\n    parameter_name = \"project__in\"\n    related_filter_parameter = \"project__campaign__id__exact\"\n    project_ref = \"project_id\"\n\n\nclass ImportJobItemProjectListFilter(CampaignProjectListFilter):\n    \"\"\"Project filter for ImportItem rows (via job).\"\"\"\n\n    parameter_name = \"job__project__in\"\n    related_filter_parameter = \"job__project__campaign__id__exact\"\n    project_ref = \"job__project_id\"\n\n\nclass ImportJobAssetProjectListFilter(CampaignProjectListFilter):\n    \"\"\"Project filter for ImportItemAsset rows (via job).\"\"\"\n\n    parameter_name = \"import_item__job__project__in\"\n    related_filter_parameter = \"import_item__job__project__campaign__id__exact\"\n    project_ref = \"import_item__job__project_id\"\n\n\nclass ImportCampaignListFilter(CampaignListFilter):\n    \"\"\"Campaign filter that excludes retired campaigns.\"\"\"\n\n    def lookups(\n        self,\n        request: HttpRequest,\n        model_admin: admin.ModelAdmin,\n    ) -> list[tuple[int | str, str]]:\n        \"\"\"\n        Provide (id, title) choices for non-retired campaigns.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            model_admin (admin.ModelAdmin): Admin class in use.\n\n        Returns:\n            list[tuple[int | str, str]]: Campaign id/title pairs.\n        \"\"\"\n        queryset = Campaign.objects.exclude(status=Campaign.Status.RETIRED)\n        return list(queryset.values_list(\"id\", \"title\").order_by(\"title\"))\n\n\nclass ImportJobCampaignListFilter(ImportCampaignListFilter):\n    \"\"\"Campaign filter for ImportJob rows.\"\"\"\n\n    parameter_name = \"project__campaign\"\n    status_filter_parameter = \"project__campaign__status\"\n\n\nclass ImportItemCampaignListFilter(ImportCampaignListFilter):\n    \"\"\"Campaign filter for ImportItem rows (via job).\"\"\"\n\n    parameter_name = \"job__project__campaign\"\n    status_filter_parameter = \"job__project__campaign__status\"\n\n\nclass ImportItemAssetCampaignListFilter(ImportCampaignListFilter):\n    \"\"\"Campaign filter for ImportItemAsset rows (via job).\"\"\"\n\n    parameter_name = \"import_item__job__project__campaign\"\n    status_filter_parameter = \"import_item__job__project__campaign__status\"\n\n\nclass BatchFilter(admin.SimpleListFilter):\n    \"\"\"Compact batch filter showing recent/incomplete and last complete batches.\"\"\"\n\n    title = _(\"Batch\")\n    parameter_name = \"batch\"\n\n    def lookups(\n        self,\n        request: HttpRequest,\n        model_admin: admin.ModelAdmin,\n    ) -> list[tuple[str, str]]:\n        \"\"\"\n        Show up to five batches with incomplete jobs, plus the currently filtered\n        batch, and the most recent fully complete batch. Fill with more completed\n        batches if there are fewer than five batches shown.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            model_admin (admin.ModelAdmin): Admin class in use.\n\n        Returns:\n            list[tuple[str, str]]: (value, label) pairs for batch selection.\n        \"\"\"\n        queryset = model_admin.get_queryset(request)\n\n        # Get up to 5 batches with incomplete jobs\n        incomplete_batches = (\n            queryset.filter(completed__isnull=True)\n            .exclude(batch__isnull=True)\n            .values(\"batch\")\n            .annotate(latest_created=Max(\"created\"))\n            .order_by(\"-latest_created\")[:5]\n        )\n\n        batch_choices = {str(batch[\"batch\"]) for batch in incomplete_batches}\n\n        # Ensure the currently filtered batch is included\n        current_batch = self.value()\n        if current_batch:\n            batch_choices.add(current_batch)\n\n        # Fetch the most recent fully completed batch\n        most_recent_complete_batch = (\n            queryset.filter(batch__isnull=False)\n            .values(\"batch\")\n            .annotate(\n                latest_created=Max(\"created\"),\n                total_jobs=Count(\"id\"),\n                completed_jobs=Count(\"id\", filter=Q(completed__isnull=False)),\n            )\n            .filter(total_jobs=F(\"completed_jobs\"))  # Only fully completed batches\n            .order_by(\"-latest_created\")\n            .first()\n        )\n\n        if most_recent_complete_batch:\n            batch_choices.add(str(most_recent_complete_batch[\"batch\"]))\n\n        # If we still have fewer than 5, add more completed batches\n        if len(batch_choices) < 5:\n            additional_complete_batches = (\n                queryset.filter(~Q(batch__in=batch_choices), batch__isnull=False)\n                .values(\"batch\")\n                .annotate(\n                    latest_created=Max(\"created\"),\n                    total_jobs=Count(\"id\"),\n                    completed_jobs=Count(\"id\", filter=Q(completed__isnull=False)),\n                )\n                .filter(total_jobs=F(\"completed_jobs\"))  # Only fully completed batches\n                .order_by(\"-latest_created\")\n            )\n\n            for batch in additional_complete_batches:\n                if len(batch_choices) >= 5:\n                    break\n                batch_choices.add(str(batch[\"batch\"]))\n\n        return [(batch, batch[:12] + \"...\") for batch in batch_choices]\n\n    def queryset(\n        self,\n        request: HttpRequest,\n        queryset: QuerySet,\n    ) -> QuerySet:\n        \"\"\"\n        Filter the queryset to a specific batch when a value is selected.\n\n        Args:\n            request (HttpRequest): Current admin request.\n            queryset (QuerySet): Base queryset for the changelist.\n\n        Returns:\n            QuerySet: Filtered queryset limited to the chosen batch.\n        \"\"\"\n        batch_value = self.value()\n        if batch_value:\n            return queryset.filter(batch=batch_value)\n        return queryset\n\n\nclass TaskStatusModelAdmin(admin.ModelAdmin):\n    \"\"\"\n    Base ModelAdmin for task-like models with standard readonly fields.\n\n    Also adds human-friendly timestamp display properties (e.g., \"3 minutes\n    ago\") for common lifecycle fields.\n    \"\"\"\n\n    readonly_fields = (\n        \"created\",\n        \"modified\",\n        \"last_started\",\n        \"completed\",\n        \"failed\",\n        \"status\",\n        \"task_id\",\n        \"failure_reason\",\n        \"retry_count\",\n        \"failure_history\",\n        \"status_history\",\n    )\n\n    @staticmethod\n    def generate_natural_timestamp_display_property(field_name: str):\n        \"\"\"\n        Build a `naturaltime` display function for a timestamp field.\n\n        The returned function is suitable for inclusion in `list_display`.\n        It sets `short_description` and `admin_order_field` to match the\n        provided field.\n\n        Args:\n            field_name (str): Name of the timestamp field on the model.\n\n        Returns:\n            callable: A function that takes an object and returns a\n            human-readable string (or `None` when unset).\n        \"\"\"\n\n        def inner(obj):\n            try:\n                value = getattr(obj, field_name)\n            except AttributeError:\n                return None\n            if value:\n                return naturaltime(value)\n            else:\n                return value\n\n        inner.short_description = field_name.replace(\"_\", \" \").title()\n        inner.admin_order_field = field_name\n        return inner\n\n    def __init__(self, *args, **kwargs):\n        \"\"\"\n        Initialize and attach dynamic display_* timestamp helpers.\n\n        For each known timestamp field, a `display_<field>` method is created\n        that renders a human-friendly relative time and can be used in\n        `list_display`.\n        \"\"\"\n        for field_name in (\n            \"created\",\n            \"modified\",\n            \"last_started\",\n            \"completed\",\n            \"failed\",\n        ):\n            setattr(\n                self,\n                f\"display_{field_name}\",\n                self.generate_natural_timestamp_display_property(field_name),\n            )\n\n        super().__init__(*args, **kwargs)\n\n\n@admin.register(ImportJob)\nclass ImportJobAdmin(TaskStatusModelAdmin):\n    \"\"\"Admin configuration for `ImportJob`.\"\"\"\n\n    readonly_fields = TaskStatusModelAdmin.readonly_fields + (\n        \"project\",\n        \"created_by\",\n        \"url\",\n    )\n    list_display = (\n        \"display_created\",\n        \"display_modified\",\n        \"display_last_started\",\n        \"display_completed\",\n        \"url\",\n        \"status\",\n    )\n    list_filter = (\n        LastStartedFilter,\n        CompletedFilter,\n        FailedFilter,\n        (\"created_by\", admin.RelatedOnlyFieldListFilter),\n        ImportJobCampaignListFilter,\n        ImportJobProjectListFilter,\n    )\n    search_fields = (\"url\", \"status\")\n\n\n@admin.register(ImportItem)\nclass ImportItemAdmin(TaskStatusModelAdmin):\n    \"\"\"Admin configuration for `ImportItem`.\"\"\"\n\n    readonly_fields = TaskStatusModelAdmin.readonly_fields + (\"job\", \"item\")\n\n    list_display = (\n        \"display_created\",\n        \"display_modified\",\n        \"display_last_started\",\n        \"display_completed\",\n        \"url\",\n        \"status\",\n    )\n    list_filter = (\n        LastStartedFilter,\n        CompletedFilter,\n        FailedFilter,\n        (\"job__created_by\", admin.RelatedOnlyFieldListFilter),\n        ImportItemCampaignListFilter,\n        ImportJobItemProjectListFilter,\n    )\n    search_fields = (\"url\", \"status\")\n\n\n@admin.register(ImportItemAsset)\nclass ImportItemAssetAdmin(TaskStatusModelAdmin):\n    \"\"\"Admin configuration for `ImportItemAsset`.\"\"\"\n\n    readonly_fields = TaskStatusModelAdmin.readonly_fields + (\n        \"import_item\",\n        \"asset\",\n        \"sequence_number\",\n    )\n\n    list_display = (\n        \"display_created\",\n        \"display_last_started\",\n        \"display_completed\",\n        \"url\",\n        \"failure_reason\",\n        \"status\",\n    )\n    list_filter = (\n        LastStartedFilter,\n        CompletedFilter,\n        FailedFilter,\n        \"failure_reason\",\n        (\"import_item__job__created_by\", admin.RelatedOnlyFieldListFilter),\n        ImportItemAssetCampaignListFilter,\n        ImportJobAssetProjectListFilter,\n    )\n    search_fields = (\"url\", \"status\")\n    actions = (retry_download_task,)\n\n\n@admin.register(VerifyAssetImageJob)\nclass VerifyAssetImageJobAdmin(TaskStatusModelAdmin):\n    \"\"\"Admin configuration for `VerifyAssetImageJob`.\"\"\"\n\n    readonly_fields = TaskStatusModelAdmin.readonly_fields + (\"asset\", \"batch\")\n    list_display = (\n        \"display_created\",\n        \"display_last_started\",\n        \"asset\",\n        \"batch\",\n        \"failure_reason\",\n        \"status\",\n    )\n    list_filter = (\n        LastStartedFilter,\n        CompletedFilter,\n        FailedFilter,\n        \"failure_reason\",\n        BatchFilter,\n    )\n    search_fields = (\"status\",)\n\n\n@admin.register(DownloadAssetImageJob)\nclass DownloadAssetImageJobAdmin(TaskStatusModelAdmin):\n    \"\"\"Admin configuration for `DownloadAssetImageJob`.\"\"\"\n\n    readonly_fields = TaskStatusModelAdmin.readonly_fields + (\"asset\", \"batch\")\n    list_display = (\n        \"display_created\",\n        \"display_last_started\",\n        \"asset\",\n        \"batch\",\n        \"failure_reason\",\n        \"status\",\n    )\n    list_filter = (\n        LastStartedFilter,\n        CompletedFilter,\n        FailedFilter,\n        \"failure_reason\",\n        BatchFilter,\n    )\n    search_fields = (\"status\",)\n"
  },
  {
    "path": "importer/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass ImporterAppConfig(AppConfig):\n    name = \"importer\"\n"
  },
  {
    "path": "importer/celery.py",
    "content": "import importlib\nimport pkgutil\n\nfrom celery import Celery\n\napp = Celery(\"importer\")\n\n# Using a string here means the worker doesn't have to serialize\n# the configuration object to child processes.\n# - namespace='CELERY' means all celery-related configuration keys\n#   should have a `CELERY_` prefix.\napp.config_from_object(\"django.conf:settings\", namespace=\"CELERY\")\n\n# Load task modules from all registered Django app configs.\napp.autodiscover_tasks()\n\n\ndef import_all_submodules(package_name: str):\n    \"\"\"\n    Import a package and recursively import all submodules.\n    Used sparingly at Celery startup to ensure all task modules are loaded.\n    \"\"\"\n    pkg = importlib.import_module(package_name)\n    if not hasattr(pkg, \"__path__\"):\n        return\n    for mod in pkgutil.walk_packages(pkg.__path__, pkg.__name__ + \".\"):\n        importlib.import_module(mod.name)\n\n\n# Import all task modules under these packages\n# We do this because celery autodiscovery won't\n# find anything not in tasks.py or tasks/__init__.py\n# We need to defer this until after Django is fully loaded\n@app.on_after_finalize.connect\ndef _load_all_task_modules(sender, **kwargs):\n    import_all_submodules(\"concordia.tasks\")\n    import_all_submodules(\"importer.tasks\")\n"
  },
  {
    "path": "importer/config.py",
    "content": ""
  },
  {
    "path": "importer/entrypoint.sh",
    "content": "#!/bin/bash\n\nset -e -u # Exit immediately for unhandled errors or undefined variables\n\nmkdir -p /app/logs\ntouch /app/logs/concordia.log\n\n#  To avoid trace and reporting of errors in the X-Ray SDK\nexport AWS_XRAY_CONTEXT_MISSING=LOG_ERROR\n\necho \"Running celery worker\"\ncelery -A concordia worker -l info -c 10\n"
  },
  {
    "path": "importer/exceptions.py",
    "content": "class ImageImportFailure(Exception):\n    \"\"\"\n    Raised when an image import operation fails.\n\n    This exception signals a failure while importing or downloading an asset\n    image. Callers should include a concise human-readable reason in the\n    exception message to aid in debugging and logging.\n    \"\"\"\n\n    pass\n"
  },
  {
    "path": "importer/migrations/0001_initial.py",
    "content": "# Generated by Django 2.0.7 on 2018-07-09 08:02\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    initial = True\n\n    dependencies = []\n\n    operations = [\n        migrations.CreateModel(\n            name=\"CampaignItemAssetCount\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"campaign_slug\", models.SlugField()),\n                (\"campaign_item_identifier\", models.CharField(max_length=50)),\n                (\"campaign_item_asset_count\", models.IntegerField()),\n            ],\n        ),\n        migrations.CreateModel(\n            name=\"CampaignTaskDetails\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"campaign_name\", models.CharField(max_length=50)),\n                (\"campaign_slug\", models.SlugField(unique=True)),\n                (\"campaign_page_count\", models.IntegerField()),\n                (\"campaign_item_count\", models.IntegerField()),\n                (\"campaign_asset_count\", models.IntegerField()),\n                (\"campaign_task_id\", models.CharField(max_length=100)),\n            ],\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0001_squashed_0015_auto_20180925_1851.py",
    "content": "# Generated by Django 2.0.9 on 2018-10-04 15:00\n\nimport django.core.validators\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    replaces = [\n        (\"importer\", \"0001_initial\"),\n        (\"importer\", \"0002_auto_20180709_0833\"),\n        (\"importer\", \"0003_auto_20180709_0933\"),\n        (\"importer\", \"0004_auto_20180812_1007\"),\n        (\"importer\", \"0005_auto_20180816_1702\"),\n        (\"importer\", \"0006_auto_20180912_0229\"),\n        (\"importer\", \"0007_auto_20180917_1654\"),\n        (\"importer\", \"0008_campaigntaskdetails_project\"),\n        (\"importer\", \"0009_convert_project_text_to_keys\"),\n        (\"importer\", \"0010_auto_20180920_2013\"),\n        (\"importer\", \"0011_auto_20180922_0208\"),\n        (\"importer\", \"0012_auto_20180923_0231\"),\n        (\"importer\", \"0013_auto_20180924_1318\"),\n        (\"importer\", \"0014_auto_20180924_1943\"),\n        (\"importer\", \"0015_auto_20180925_1851\"),\n    ]\n\n    initial = True\n\n    dependencies = [\n        (\"concordia\", \"0019_auto_20180920_1503\"),\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"concordia\", \"0021_auto_20180922_0202\"),\n        (\"concordia\", \"0024_auto_20180924_1529\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"ImportItem\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\"modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"last_started\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Last time when a worker started processing this job\",  # NOQA\n                    ),\n                ),\n                (\n                    \"completed\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Time when the job completed processing\",\n                    ),\n                ),\n                (\n                    \"failed\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Time when the job failed and will not be restarted\",  # NOQA\n                    ),\n                ),\n                (\n                    \"status\",\n                    models.TextField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Status message, if any, from the last worker\",\n                    ),\n                ),\n                (\n                    \"task_id\",\n                    models.UUIDField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"UUID of the last Celery task to process this record\",  # NOQA\n                    ),\n                ),\n                (\"url\", models.URLField()),\n                (\n                    \"item\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE, to=\"concordia.Item\"\n                    ),\n                ),\n            ],\n            options={\"abstract\": False},\n        ),\n        migrations.CreateModel(\n            name=\"ImportItemAsset\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\"modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"last_started\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Last time when a worker started processing this job\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"completed\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Time when the job completed without error\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"failed\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Time when the job failed due to an error\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"status\",\n                    models.TextField(\n                        blank=True,\n                        default=\"\",\n                        help_text=\"Status message, if any, from the last worker\",\n                    ),\n                ),\n                (\n                    \"task_id\",\n                    models.UUIDField(\n                        blank=True,\n                        help_text=\"UUID of the last Celery task to process this record\",\n                        null=True,\n                    ),\n                ),\n                (\"url\", models.URLField()),\n                (\n                    \"sequence_number\",\n                    models.PositiveIntegerField(\n                        validators=[django.core.validators.MinValueValidator(1)]\n                    ),\n                ),\n                (\n                    \"asset\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Asset\",\n                    ),\n                ),\n                (\n                    \"import_item\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        related_name=\"assets\",\n                        to=\"importer.ImportItem\",\n                    ),\n                ),\n            ],\n            options={\"abstract\": False},\n        ),\n        migrations.CreateModel(\n            name=\"ImportJob\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\"modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"last_started\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Last time when a worker started processing this job\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"completed\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Time when the job completed without error\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"failed\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Time when the job failed due to an error\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"status\",\n                    models.TextField(\n                        blank=True,\n                        default=\"\",\n                        help_text=\"Status message, if any, from the last worker\",\n                    ),\n                ),\n                (\n                    \"task_id\",\n                    models.UUIDField(\n                        blank=True,\n                        help_text=\"UUID of the last Celery task to process this record\",\n                        null=True,\n                    ),\n                ),\n                (\"url\", models.URLField(verbose_name=\"Source URL for the entire job\")),\n                (\n                    \"created_by\",\n                    models.ForeignKey(\n                        null=True,\n                        on_delete=django.db.models.deletion.SET_NULL,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n                (\n                    \"project\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Project\",\n                    ),\n                ),\n            ],\n            options={\"abstract\": False},\n        ),\n        migrations.AddField(\n            model_name=\"importitem\",\n            name=\"job\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE,\n                related_name=\"items\",\n                to=\"importer.ImportJob\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"status\",\n            field=models.TextField(\n                blank=True,\n                default=\"\",\n                verbose_name=\"Status message, if any, from the last worker\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"completed\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Time when the job completed without error\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"failed\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Time when the job failed due to an error\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"last_started\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Last time when a worker started processing this job\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"status\",\n            field=models.TextField(\n                blank=True,\n                default=\"\",\n                help_text=\"Status message, if any, from the last worker\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"task_id\",\n            field=models.UUIDField(\n                blank=True,\n                help_text=\"UUID of the last Celery task to process this record\",\n                null=True,\n            ),\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"importitem\", unique_together={(\"job\", \"item\")}\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"importitemasset\",\n            unique_together={\n                (\"import_item\", \"asset\"),\n                (\"import_item\", \"sequence_number\"),\n            },\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0002_auto_20180709_0833.py",
    "content": "# Generated by Django 2.0.7 on 2018-07-09 08:33\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0001_initial\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaignitemassetcount\",\n            name=\"campaign_item_asset_count\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_asset_count\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_item_count\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_page_count\",\n            field=models.IntegerField(blank=True, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_task_id\",\n            field=models.CharField(blank=True, max_length=100, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0003_auto_20180709_0933.py",
    "content": "# Generated by Django 2.0.7 on 2018-07-09 09:33\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0002_auto_20180709_0833\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaignitemassetcount\",\n            name=\"campaign_item_asset_count\",\n            field=models.IntegerField(blank=True, default=0, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_asset_count\",\n            field=models.IntegerField(blank=True, default=0, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_item_count\",\n            field=models.IntegerField(blank=True, default=0, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_page_count\",\n            field=models.IntegerField(blank=True, default=0, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0004_auto_20180812_1007.py",
    "content": "# Generated by Django 2.0.8 on 2018-08-12 10:07\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0003_auto_20180709_0933\")]\n\n    operations = [\n        migrations.RemoveField(\n            model_name=\"campaignitemassetcount\", name=\"campaign_slug\"\n        ),\n        migrations.AddField(\n            model_name=\"campaignitemassetcount\",\n            name=\"campaign_task\",\n            field=models.ForeignKey(\n                default=1,\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"importer.CampaignTaskDetails\",\n            ),\n            preserve_default=False,\n        ),\n        migrations.AddField(\n            model_name=\"campaignitemassetcount\",\n            name=\"item_task_id\",\n            field=models.CharField(blank=True, max_length=100, null=True),\n        ),\n        migrations.AddField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project_name\",\n            field=models.CharField(default=1, max_length=250),\n            preserve_default=False,\n        ),\n        migrations.AddField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project_slug\",\n            field=models.SlugField(default=1, max_length=250, unique=True),\n            preserve_default=False,\n        ),\n        migrations.RemoveField(\n            model_name=\"campaigntaskdetails\", name=\"campaign_page_count\"\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"campaigntaskdetails\",\n            unique_together={(\"campaign_slug\", \"project_slug\")},\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0005_auto_20180816_1702.py",
    "content": "# Generated by Django 2.0.8 on 2018-08-16 17:02\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0004_auto_20180812_1007\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_slug\",\n            field=models.SlugField(),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project_slug\",\n            field=models.SlugField(max_length=250),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0006_auto_20180912_0229.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-12 02:29\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0005_auto_20180816_1702\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaignitemassetcount\",\n            name=\"campaign_item_identifier\",\n            field=models.CharField(max_length=500),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_name\",\n            field=models.CharField(max_length=500),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_slug\",\n            field=models.SlugField(max_length=500),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project_name\",\n            field=models.CharField(max_length=500),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project_slug\",\n            field=models.SlugField(max_length=500),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0007_auto_20180917_1654.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-17 16:54\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0006_auto_20180912_0229\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaignitemassetcount\",\n            name=\"campaign_item_identifier\",\n            field=models.CharField(max_length=80),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_name\",\n            field=models.CharField(max_length=80),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"campaign_slug\",\n            field=models.SlugField(max_length=80),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project_name\",\n            field=models.CharField(max_length=250),\n        ),\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project_slug\",\n            field=models.SlugField(max_length=250),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0008_campaigntaskdetails_project.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-20 20:05\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0007_auto_20180917_1654\")]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project\",\n            field=models.ForeignKey(\n                null=True,\n                on_delete=django.db.models.deletion.CASCADE,\n                to=\"concordia.Project\",\n            ),\n        )\n    ]\n"
  },
  {
    "path": "importer/migrations/0009_convert_project_text_to_keys.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-20 20:06\nimport logging\n\nfrom django.db import migrations\n\n\ndef convert_slugs_to_references(apps, schema_editor):\n    Project = apps.get_model(\"concordia\", \"Project\")\n    CampaignTaskDetails = apps.get_model(\"importer\", \"CampaignTaskDetails\")\n\n    for ctd in CampaignTaskDetails.objects.all():\n        try:\n            ctd.project = Project.objects.get(\n                slug=ctd.project_slug, campaign__slug=ctd.campaign_slug\n            )\n            ctd.save()\n        except Project.DoesNotExist:\n            logging.error(\"%s references a non-existent project! Deleting it!\", ctd)\n            ctd.delete()\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0008_campaigntaskdetails_project\")]\n\n    operations = [migrations.RunPython(convert_slugs_to_references)]\n"
  },
  {
    "path": "importer/migrations/0010_auto_20180920_2013.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-20 20:13\n\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0009_convert_project_text_to_keys\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"campaigntaskdetails\",\n            name=\"project\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE, to=\"concordia.Project\"\n            ),\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"campaigntaskdetails\", unique_together=set()\n        ),\n        migrations.RemoveField(model_name=\"campaigntaskdetails\", name=\"campaign_name\"),\n        migrations.RemoveField(model_name=\"campaigntaskdetails\", name=\"campaign_slug\"),\n        migrations.RemoveField(model_name=\"campaigntaskdetails\", name=\"project_name\"),\n        migrations.RemoveField(model_name=\"campaigntaskdetails\", name=\"project_slug\"),\n    ]\n"
  },
  {
    "path": "importer/migrations/0011_auto_20180922_0208.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-22 02:08\n\nimport django.core.validators\nimport django.db.models.deletion\nfrom django.conf import settings\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0021_auto_20180922_0202\"),\n        migrations.swappable_dependency(settings.AUTH_USER_MODEL),\n        (\"importer\", \"0010_auto_20180920_2013\"),\n    ]\n\n    operations = [\n        migrations.CreateModel(\n            name=\"ImportItem\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\"modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"last_started\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Last time when a worker started processing this job\",  # NOQA\n                    ),\n                ),\n                (\n                    \"completed\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Time when the job completed processing\",\n                    ),\n                ),\n                (\n                    \"failed\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Time when the job failed and will not be restarted\",  # NOQA\n                    ),\n                ),\n                (\n                    \"status\",\n                    models.TextField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Status message, if any, from the last worker\",\n                    ),\n                ),\n                (\n                    \"task_id\",\n                    models.UUIDField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"UUID of the last Celery task to process this record\",  # NOQA\n                    ),\n                ),\n                (\"url\", models.URLField()),\n                (\n                    \"item\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE, to=\"concordia.Item\"\n                    ),\n                ),\n            ],\n            options={\"abstract\": False},\n        ),\n        migrations.CreateModel(\n            name=\"ImportItemAsset\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\"modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"last_started\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Last time when a worker started processing this job\",  # NOQA\n                    ),\n                ),\n                (\n                    \"completed\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Time when the job completed processing\",\n                    ),\n                ),\n                (\n                    \"failed\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Time when the job failed and will not be restarted\",  # NOQA\n                    ),\n                ),\n                (\n                    \"status\",\n                    models.TextField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Status message, if any, from the last worker\",\n                    ),\n                ),\n                (\n                    \"task_id\",\n                    models.UUIDField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"UUID of the last Celery task to process this record\",  # NOQA\n                    ),\n                ),\n                (\"url\", models.URLField()),\n                (\n                    \"sequence_number\",\n                    models.PositiveIntegerField(\n                        validators=[django.core.validators.MinValueValidator(1)]\n                    ),\n                ),\n                (\n                    \"asset\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Asset\",\n                    ),\n                ),\n                (\n                    \"import_item\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        related_name=\"assets\",\n                        to=\"importer.ImportItem\",\n                    ),\n                ),\n            ],\n            options={\"abstract\": False},\n        ),\n        migrations.CreateModel(\n            name=\"ImportJob\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\"modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"last_started\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Last time when a worker started processing this job\",  # NOQA\n                    ),\n                ),\n                (\n                    \"completed\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Time when the job completed processing\",\n                    ),\n                ),\n                (\n                    \"failed\",\n                    models.DateTimeField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Time when the job failed and will not be restarted\",  # NOQA\n                    ),\n                ),\n                (\n                    \"status\",\n                    models.TextField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"Status message, if any, from the last worker\",\n                    ),\n                ),\n                (\n                    \"task_id\",\n                    models.UUIDField(\n                        blank=True,\n                        null=True,\n                        verbose_name=\"UUID of the last Celery task to process this record\",  # NOQA\n                    ),\n                ),\n                (\n                    \"source_url\",\n                    models.URLField(verbose_name=\"Source URL for the entire job\"),\n                ),\n                (\n                    \"created_by\",\n                    models.ForeignKey(\n                        null=True,\n                        on_delete=django.db.models.deletion.SET_NULL,\n                        to=settings.AUTH_USER_MODEL,\n                    ),\n                ),\n                (\n                    \"project\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.Project\",\n                    ),\n                ),\n            ],\n            options={\"abstract\": False},\n        ),\n        migrations.RemoveField(\n            model_name=\"campaignitemassetcount\", name=\"campaign_task\"\n        ),\n        migrations.RemoveField(model_name=\"campaigntaskdetails\", name=\"project\"),\n        migrations.DeleteModel(name=\"CampaignItemAssetCount\"),\n        migrations.DeleteModel(name=\"CampaignTaskDetails\"),\n        migrations.AddField(\n            model_name=\"importitem\",\n            name=\"job\",\n            field=models.ForeignKey(\n                on_delete=django.db.models.deletion.CASCADE,\n                related_name=\"items\",\n                to=\"importer.ImportJob\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0012_auto_20180923_0231.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-23 02:31\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0011_auto_20180922_0208\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"status\",\n            field=models.TextField(\n                blank=True,\n                default=\"\",\n                verbose_name=\"Status message, if any, from the last worker\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"status\",\n            field=models.TextField(\n                blank=True,\n                default=\"\",\n                verbose_name=\"Status message, if any, from the last worker\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"status\",\n            field=models.TextField(\n                blank=True,\n                default=\"\",\n                verbose_name=\"Status message, if any, from the last worker\",\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0013_auto_20180924_1318.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-24 13:18\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0012_auto_20180923_0231\")]\n\n    operations = [\n        migrations.RenameField(\n            model_name=\"importjob\", old_name=\"source_url\", new_name=\"url\"\n        )\n    ]\n"
  },
  {
    "path": "importer/migrations/0014_auto_20180924_1943.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-24 19:43\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n    dependencies = [\n        (\"concordia\", \"0024_auto_20180924_1529\"),\n        (\"importer\", \"0013_auto_20180924_1318\"),\n    ]\n\n    operations = [\n        migrations.AlterUniqueTogether(\n            name=\"importitem\", unique_together={(\"job\", \"item\")}\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"importitemasset\",\n            unique_together={\n                (\"import_item\", \"sequence_number\"),\n                (\"import_item\", \"asset\"),\n            },\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0015_auto_20180925_1851.py",
    "content": "# Generated by Django 2.0.8 on 2018-09-25 18:51\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n    dependencies = [(\"importer\", \"0014_auto_20180924_1943\")]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"completed\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Time when the job completed without error\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"failed\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Time when the job failed due to an error\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"last_started\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Last time when a worker started processing this job\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"status\",\n            field=models.TextField(\n                blank=True,\n                default=\"\",\n                help_text=\"Status message, if any, from the last worker\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"task_id\",\n            field=models.UUIDField(\n                blank=True,\n                help_text=\"UUID of the last Celery task to process this record\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"completed\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Time when the job completed without error\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"failed\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Time when the job failed due to an error\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"last_started\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Last time when a worker started processing this job\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"status\",\n            field=models.TextField(\n                blank=True,\n                default=\"\",\n                help_text=\"Status message, if any, from the last worker\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"task_id\",\n            field=models.UUIDField(\n                blank=True,\n                help_text=\"UUID of the last Celery task to process this record\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"completed\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Time when the job completed without error\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"failed\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Time when the job failed due to an error\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"last_started\",\n            field=models.DateTimeField(\n                blank=True,\n                help_text=\"Last time when a worker started processing this job\",\n                null=True,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"status\",\n            field=models.TextField(\n                blank=True,\n                default=\"\",\n                help_text=\"Status message, if any, from the last worker\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"task_id\",\n            field=models.UUIDField(\n                blank=True,\n                help_text=\"UUID of the last Celery task to process this record\",\n                null=True,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0016_importitem_failure_reason_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-02-20 16:30\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"importer\", \"0001_squashed_0015_auto_20180925_1851\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"importitem\",\n            name=\"failure_reason\",\n            field=models.CharField(\n                blank=True, choices=[(\"Image\", \"Image\")], default=\"\", max_length=50\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importitemasset\",\n            name=\"failure_reason\",\n            field=models.CharField(\n                blank=True, choices=[(\"Image\", \"Image\")], default=\"\", max_length=50\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importjob\",\n            name=\"failure_reason\",\n            field=models.CharField(\n                blank=True, choices=[(\"Image\", \"Image\")], default=\"\", max_length=50\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0017_importitem_failure_history_importitem_retry_count_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-03-03 20:49\n\nimport django.core.serializers.json\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"importer\", \"0016_importitem_failure_reason_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"importitem\",\n            name=\"failure_history\",\n            field=models.JSONField(\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Information about previous failures of the task, if any\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importitem\",\n            name=\"retry_count\",\n            field=models.IntegerField(\n                default=0, help_text=\"Number of times the task was retried\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importitemasset\",\n            name=\"failure_history\",\n            field=models.JSONField(\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Information about previous failures of the task, if any\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importitemasset\",\n            name=\"retry_count\",\n            field=models.IntegerField(\n                default=0, help_text=\"Number of times the task was retried\"\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importjob\",\n            name=\"failure_history\",\n            field=models.JSONField(\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Information about previous failures of the task, if any\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importjob\",\n            name=\"retry_count\",\n            field=models.IntegerField(\n                default=0, help_text=\"Number of times the task was retried\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"failure_reason\",\n            field=models.CharField(\n                blank=True,\n                choices=[(\"Image\", \"Image\"), (\"Retries\", \"Retries\")],\n                default=\"\",\n                help_text=\"Reason the task failed, if one was provided\",\n                max_length=50,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"failure_reason\",\n            field=models.CharField(\n                blank=True,\n                choices=[(\"Image\", \"Image\"), (\"Retries\", \"Retries\")],\n                default=\"\",\n                help_text=\"Reason the task failed, if one was provided\",\n                max_length=50,\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"failure_reason\",\n            field=models.CharField(\n                blank=True,\n                choices=[(\"Image\", \"Image\"), (\"Retries\", \"Retries\")],\n                default=\"\",\n                help_text=\"Reason the task failed, if one was provided\",\n                max_length=50,\n            ),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0018_importitem_status_history_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-03-06 16:04\n\nimport django.core.serializers.json\nimport django.db.models.deletion\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0103_alter_item_title\"),\n        (\"importer\", \"0017_importitem_failure_history_importitem_retry_count_and_more\"),\n    ]\n\n    operations = [\n        migrations.AddField(\n            model_name=\"importitem\",\n            name=\"status_history\",\n            field=models.JSONField(\n                blank=True,\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Previous statuses on the task, if any\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importitemasset\",\n            name=\"status_history\",\n            field=models.JSONField(\n                blank=True,\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Previous statuses on the task, if any\",\n            ),\n        ),\n        migrations.AddField(\n            model_name=\"importjob\",\n            name=\"status_history\",\n            field=models.JSONField(\n                blank=True,\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Previous statuses on the task, if any\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"failure_history\",\n            field=models.JSONField(\n                blank=True,\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Information about previous failures of the task, if any\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitem\",\n            name=\"retry_count\",\n            field=models.IntegerField(\n                blank=True, default=0, help_text=\"Number of times the task was retried\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"failure_history\",\n            field=models.JSONField(\n                blank=True,\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Information about previous failures of the task, if any\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importitemasset\",\n            name=\"retry_count\",\n            field=models.IntegerField(\n                blank=True, default=0, help_text=\"Number of times the task was retried\"\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"failure_history\",\n            field=models.JSONField(\n                blank=True,\n                default=list,\n                encoder=django.core.serializers.json.DjangoJSONEncoder,\n                help_text=\"Information about previous failures of the task, if any\",\n            ),\n        ),\n        migrations.AlterField(\n            model_name=\"importjob\",\n            name=\"retry_count\",\n            field=models.IntegerField(\n                blank=True, default=0, help_text=\"Number of times the task was retried\"\n            ),\n        ),\n        migrations.CreateModel(\n            name=\"VerifyAssetImageJob\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\"modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"last_started\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Last time when a worker started processing this job\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"completed\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Time when the job completed without error\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"failed\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Time when the job failed due to an error\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"status\",\n                    models.TextField(\n                        blank=True,\n                        default=\"\",\n                        help_text=\"Status message, if any, from the last worker\",\n                    ),\n                ),\n                (\n                    \"task_id\",\n                    models.UUIDField(\n                        blank=True,\n                        help_text=\"UUID of the last Celery task to process this record\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"failure_reason\",\n                    models.CharField(\n                        blank=True,\n                        choices=[(\"Image\", \"Image\"), (\"Retries\", \"Retries\")],\n                        default=\"\",\n                        help_text=\"Reason the task failed, if one was provided\",\n                        max_length=50,\n                    ),\n                ),\n                (\n                    \"retry_count\",\n                    models.IntegerField(\n                        blank=True,\n                        default=0,\n                        help_text=\"Number of times the task was retried\",\n                    ),\n                ),\n                (\n                    \"failure_history\",\n                    models.JSONField(\n                        blank=True,\n                        default=list,\n                        encoder=django.core.serializers.json.DjangoJSONEncoder,\n                        help_text=\"Information about previous failures of the task, if any\",\n                    ),\n                ),\n                (\n                    \"status_history\",\n                    models.JSONField(\n                        blank=True,\n                        default=list,\n                        encoder=django.core.serializers.json.DjangoJSONEncoder,\n                        help_text=\"Previous statuses on the task, if any\",\n                    ),\n                ),\n                (\"batch\", models.UUIDField(blank=True, editable=False)),\n                (\n                    \"asset\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.asset\",\n                    ),\n                ),\n            ],\n            options={\n                \"abstract\": False,\n            },\n        ),\n        migrations.CreateModel(\n            name=\"DownloadAssetImageJob\",\n            fields=[\n                (\n                    \"id\",\n                    models.AutoField(\n                        auto_created=True,\n                        primary_key=True,\n                        serialize=False,\n                        verbose_name=\"ID\",\n                    ),\n                ),\n                (\"created\", models.DateTimeField(auto_now_add=True)),\n                (\"modified\", models.DateTimeField(auto_now=True)),\n                (\n                    \"last_started\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Last time when a worker started processing this job\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"completed\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Time when the job completed without error\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"failed\",\n                    models.DateTimeField(\n                        blank=True,\n                        help_text=\"Time when the job failed due to an error\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"status\",\n                    models.TextField(\n                        blank=True,\n                        default=\"\",\n                        help_text=\"Status message, if any, from the last worker\",\n                    ),\n                ),\n                (\n                    \"task_id\",\n                    models.UUIDField(\n                        blank=True,\n                        help_text=\"UUID of the last Celery task to process this record\",\n                        null=True,\n                    ),\n                ),\n                (\n                    \"failure_reason\",\n                    models.CharField(\n                        blank=True,\n                        choices=[(\"Image\", \"Image\"), (\"Retries\", \"Retries\")],\n                        default=\"\",\n                        help_text=\"Reason the task failed, if one was provided\",\n                        max_length=50,\n                    ),\n                ),\n                (\n                    \"retry_count\",\n                    models.IntegerField(\n                        blank=True,\n                        default=0,\n                        help_text=\"Number of times the task was retried\",\n                    ),\n                ),\n                (\n                    \"failure_history\",\n                    models.JSONField(\n                        blank=True,\n                        default=list,\n                        encoder=django.core.serializers.json.DjangoJSONEncoder,\n                        help_text=\"Information about previous failures of the task, if any\",\n                    ),\n                ),\n                (\n                    \"status_history\",\n                    models.JSONField(\n                        blank=True,\n                        default=list,\n                        encoder=django.core.serializers.json.DjangoJSONEncoder,\n                        help_text=\"Previous statuses on the task, if any\",\n                    ),\n                ),\n                (\"batch\", models.UUIDField(blank=True, editable=False)),\n                (\n                    \"asset\",\n                    models.ForeignKey(\n                        on_delete=django.db.models.deletion.CASCADE,\n                        to=\"concordia.asset\",\n                    ),\n                ),\n            ],\n            options={\n                \"abstract\": False,\n            },\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0019_alter_downloadassetimagejob_batch_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-03-07 19:19\n\nfrom django.db import migrations, models\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"importer\", \"0018_importitem_status_history_and_more\"),\n    ]\n\n    operations = [\n        migrations.AlterField(\n            model_name=\"downloadassetimagejob\",\n            name=\"batch\",\n            field=models.UUIDField(blank=True, editable=False, null=True),\n        ),\n        migrations.AlterField(\n            model_name=\"verifyassetimagejob\",\n            name=\"batch\",\n            field=models.UUIDField(blank=True, editable=False, null=True),\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/0020_alter_downloadassetimagejob_unique_together_and_more.py",
    "content": "# Generated by Django 4.2.16 on 2025-03-18 20:01\n\nfrom django.db import migrations\n\n\nclass Migration(migrations.Migration):\n\n    dependencies = [\n        (\"concordia\", \"0103_alter_item_title\"),\n        (\"importer\", \"0019_alter_downloadassetimagejob_batch_and_more\"),\n    ]\n\n    operations = [\n        migrations.AlterUniqueTogether(\n            name=\"downloadassetimagejob\",\n            unique_together={(\"asset\", \"batch\")},\n        ),\n        migrations.AlterUniqueTogether(\n            name=\"verifyassetimagejob\",\n            unique_together={(\"asset\", \"batch\")},\n        ),\n    ]\n"
  },
  {
    "path": "importer/migrations/__init__.py",
    "content": ""
  },
  {
    "path": "importer/models.py",
    "content": "from logging import getLogger\nfrom uuid import UUID\n\nfrom django.core.serializers.json import DjangoJSONEncoder\nfrom django.core.validators import MinValueValidator\nfrom django.db import models\nfrom django.urls import reverse\nfrom django.utils import timezone\n\nfrom configuration.utils import configuration_value\nfrom importer import tasks\n\nlogger = getLogger(__name__)\n\n\nclass TaskStatusModel(models.Model):\n    \"\"\"\n    Abstract base model that tracks task lifecycle and outcomes.\n\n    Subclasses get standard timestamp fields, a free-form status, failure\n    bookkeeping (reason, history, retry count), and the last Celery task ID\n    that processed the record.\n    \"\"\"\n\n    class FailureReason(models.TextChoices):\n        IMAGE = \"Image\"\n        RETRIES = \"Retries\"\n\n    created = models.DateTimeField(auto_now_add=True)\n    modified = models.DateTimeField(auto_now=True)\n\n    last_started = models.DateTimeField(\n        help_text=\"Last time when a worker started processing this job\",\n        null=True,\n        blank=True,\n    )\n    completed = models.DateTimeField(\n        help_text=\"Time when the job completed without error\", null=True, blank=True\n    )\n    failed = models.DateTimeField(\n        help_text=\"Time when the job failed due to an error\", null=True, blank=True\n    )\n\n    status = models.TextField(\n        help_text=\"Status message, if any, from the last worker\", blank=True, default=\"\"\n    )\n\n    task_id = models.UUIDField(\n        help_text=\"UUID of the last Celery task to process this record\",\n        null=True,\n        blank=True,\n    )\n\n    failure_reason = models.CharField(\n        help_text=\"Reason the task failed, if one was provided\",\n        max_length=50,\n        blank=True,\n        default=\"\",\n        choices=FailureReason.choices,\n    )\n\n    retry_count = models.IntegerField(\n        help_text=\"Number of times the task was retried\", default=0, blank=True\n    )\n\n    failure_history = models.JSONField(\n        help_text=\"Information about previous failures of the task, if any\",\n        encoder=DjangoJSONEncoder,\n        default=list,\n        blank=True,\n    )\n\n    status_history = models.JSONField(\n        help_text=\"Previous statuses on the task, if any\",\n        encoder=DjangoJSONEncoder,\n        default=list,\n        blank=True,\n    )\n\n    class Meta:\n        abstract = True\n\n    def retry_if_possible(self) -> bool:\n        \"\"\"\n        Attempt to schedule a retry for this task if policy allows.\n\n        Subclasses should override this to implement their own logic.\n\n        Returns:\n            bool: True if a retry was scheduled, otherwise False.\n        \"\"\"\n        return False\n\n    def update_failure_history(self, do_save: bool = True) -> None:\n        \"\"\"\n        Append the current failure details to the failure history.\n\n        Args:\n            do_save (bool): If True, save the model after updating.\n        \"\"\"\n        self.failure_history.append(\n            {\n                \"failed\": self.failed,\n                \"failure_reason\": self.failure_reason,\n                \"status\": self.status,\n            }\n        )\n        if do_save:\n            self.save()\n\n    def update_status(self, status: str, do_save: bool = True) -> None:\n        \"\"\"\n        Append the previous status to the history and set a new status.\n\n        Args:\n            status (str): The new status value to set.\n            do_save (bool): If True, save the model after updating.\n        \"\"\"\n        self.status_history.append(\n            {\n                \"status\": self.status,\n                \"timestamp\": self.modified,\n            }\n        )\n        self.status = status\n        if do_save:\n            self.save()\n\n    def reset_for_retry(self) -> bool:\n        \"\"\"\n        Reset failure fields and prepare the record for retry.\n\n        When the instance is currently marked as failed, move the failure\n        details into history, clear failure markers, increment retry count,\n        and set a transitional status.\n\n        Returns:\n            bool: True if the record was reset, otherwise False.\n        \"\"\"\n        if self.failed:\n            logger.info(\n                \"Resetting task %s for retrying\",\n                self,\n            )\n            self.update_failure_history(do_save=False)\n            self.failed = None\n            self.failure_reason = \"\"\n            self.update_status(\"Retrying\", do_save=False)\n            self.retry_count += 1\n            self.save()\n            return True\n        else:\n            new_status = (\n                \"Task was not marked as failed, so it will \"\n                \"not be reset for retrying.\"\n            )\n            self.update_status(new_status)\n            logger.warning(\n                \"Task %s was not marked as failed, so it will not be \"\n                \"reset for retrying\",\n                self,\n            )\n            return False\n\n\nclass BatchedJob(TaskStatusModel):\n    \"\"\"\n    Abstract base model for jobs grouped into batches.\n\n    The optional `batch` UUID groups related jobs for scheduling and\n    admin filtering. Use `batch_admin_url` or `get_batch_admin_url`\n    to link to the admin list filtered by the batch.\n    \"\"\"\n\n    # Allows grouping jobs by batch.\n    # `batch` is used by the task system to group jobs\n    # and run them in smaller groups rather than spawning\n    # an arbitrarily large number at once\n    # It's also used to group jobs in the admin, allowing\n    # filtering to see all the jobs spawned by a particular\n    # action\n    batch = models.UUIDField(blank=True, null=True, editable=False)\n\n    class Meta:\n        abstract = True\n\n    @classmethod\n    def get_batch_admin_url(cls, batch: UUID | str | None) -> str:\n        \"\"\"\n        Build the admin changelist URL filtered to the provided batch.\n\n        Args:\n            batch (UUID | str | None): Batch identifier to filter by. Must be\n                provided.\n\n        Returns:\n            str: Admin changelist URL with the batch query string applied.\n\n        Raises:\n            ValueError: If `batch` is falsy.\n        \"\"\"\n        if not batch:\n            raise ValueError(\"A batch value must be provided.\")\n\n        app_label = cls._meta.app_label\n        model_name = cls._meta.model_name\n\n        admin_url = reverse(f\"admin:{app_label}_{model_name}_changelist\")\n\n        return f\"{admin_url}?batch={batch}\"\n\n    @property\n    def batch_admin_url(self) -> str | None:\n        \"\"\"\n        Convenience property to get the admin URL for this instance's batch.\n\n        Returns:\n            str | None: Admin URL filtered by the instance's batch, or None\n            when no batch is set.\n        \"\"\"\n        # Allows getting the batch url from an instance, automatically\n        # using self.batch rather than needing to call the class method\n        # get_batch_admin_url if you have an instance\n        return self.__class__.get_batch_admin_url(self.batch) if self.batch else None\n\n\nclass ImportJob(TaskStatusModel):\n    \"\"\"\n    Represents a request by a user to import item(s) from a remote URL.\n    \"\"\"\n\n    created_by = models.ForeignKey(\"auth.User\", null=True, on_delete=models.SET_NULL)\n\n    project = models.ForeignKey(\"concordia.Project\", on_delete=models.CASCADE)\n\n    url = models.URLField(verbose_name=\"Source URL for the entire job\")\n\n    def __str__(self) -> str:\n        return \"ImportJob(created_by=%s, project=%s, url=%s)\" % (\n            self.created_by.username if self.created_by else None,\n            self.project.title,\n            self.url,\n        )\n\n\nclass ImportItem(TaskStatusModel):\n    \"\"\"\n    Record of the task status for each Item being imported.\n    \"\"\"\n\n    job = models.ForeignKey(ImportJob, on_delete=models.CASCADE, related_name=\"items\")\n\n    url = models.URLField()\n\n    item = models.ForeignKey(\"concordia.Item\", on_delete=models.CASCADE)\n\n    class Meta:\n        unique_together = ((\"job\", \"item\"),)\n\n    def __str__(self) -> str:\n        return \"ImportItem(job=%s, url=%s)\" % (self.job, self.url)\n\n\nclass ImportItemAsset(TaskStatusModel):\n    \"\"\"\n    Record of the task status for each Asset being imported.\n    \"\"\"\n\n    import_item = models.ForeignKey(\n        ImportItem, on_delete=models.CASCADE, related_name=\"assets\"\n    )\n\n    url = models.URLField()\n    sequence_number = models.PositiveIntegerField(validators=[MinValueValidator(1)])\n\n    asset = models.ForeignKey(\"concordia.Asset\", on_delete=models.CASCADE)\n\n    class Meta:\n        unique_together = ((\"import_item\", \"sequence_number\"), (\"import_item\", \"asset\"))\n\n    def __str__(self) -> str:\n        return \"ImportItemAsset(import_item=%s, url=%s)\" % (self.import_item, self.url)\n\n    def retry_if_possible(self) -> bool:\n        \"\"\"\n        Attempt to schedule a retry when the failure was an image error.\n\n        Uses two configuration values:\n\n        - `asset_image_import_max_retries`: Maximum number of retries allowed.\n        - `asset_image_import_max_retry_delay`: Delay (minutes) before retry.\n\n        When eligible and reset succeeds, schedules a Celery task via\n        `download_asset_task.apply_async(...)`.\n\n        Returns:\n            bool: True if a retry was scheduled, otherwise False.\n        \"\"\"\n        if self.failure_reason == TaskStatusModel.FailureReason.IMAGE:\n            max_retries = configuration_value(\"asset_image_import_max_retries\")\n            retry_delay = configuration_value(\"asset_image_import_max_retry_delay\")\n            if self.retry_count < max_retries and retry_delay > 0:\n                if self.reset_for_retry():\n                    return bool(\n                        tasks.assets.download_asset_task.apply_async(\n                            (self.pk,), countdown=retry_delay * 60\n                        )\n                    )\n                else:\n                    logger.warning(\n                        \"Task %s was not reset for retrying, so it will not be retried\",\n                        self,\n                    )\n                    return False\n            else:\n                logger.warning(\n                    \"Task %s has reached the maximum number of retries (%s) \"\n                    \"and will not be repeated\",\n                    self,\n                    max_retries,\n                )\n                self.update_failure_history(do_save=False)\n                self.failed = timezone.now()\n                new_status = (\n                    \"Maximum number of retries reached while retrying \"\n                    \"image download for asset. The failure reason before retrying \"\n                    f\"was {self.failure_reason} and the status was {self.status}\"\n                )\n                self.update_status(new_status, do_save=False)\n                self.failure_reason = TaskStatusModel.FailureReason.RETRIES\n                self.save()\n                return False\n        return False\n\n\nclass VerifyAssetImageJob(BatchedJob):\n    \"\"\"\n    Job that verifies a previously downloaded asset image.\n    \"\"\"\n\n    asset = models.ForeignKey(\"concordia.Asset\", on_delete=models.CASCADE)\n\n    def __str__(self) -> str:\n        return f\"VerifyAssetImageJob for {self.asset}\"\n\n    class Meta:\n        unique_together = ((\"asset\", \"batch\"),)\n\n\nclass DownloadAssetImageJob(BatchedJob):\n    \"\"\"\n    Job that downloads an asset image for later verification.\n    \"\"\"\n\n    asset = models.ForeignKey(\"concordia.Asset\", on_delete=models.CASCADE)\n\n    def __str__(self) -> str:\n        return f\"DownloadAssetImageJob for {self.asset}\"\n\n    class Meta:\n        unique_together = ((\"asset\", \"batch\"),)\n"
  },
  {
    "path": "importer/setup.py",
    "content": "#!/usr/bin/env python\nfrom setuptools import find_packages, setup\n\nVERSION = __import__(\"importer\").get_version()\nINSTALL_REQUIREMENTS = [\"boto3\", \"celery\", \"requests\", \"Django>=2.1.5\", \"Pillow\"]\nDESCRIPTION = \"Download collections of images from loc.gov\"\nCLASSIFIERS = \"\"\"\nEnvironment :: Web Environment\nFramework :: Django :: 2.0\nDevelopment Status :: 2 - Pre-Alpha\nProgramming Language :: Python\nProgramming Language :: Python :: 3.12\n\"\"\".splitlines()\n\nwith open(\"README.rst\", \"r\") as f:\n    LONG_DESCRIPTION = f.read()\n\n\nsetup(\n    name=\"importer\",\n    version=VERSION,\n    description=DESCRIPTION,\n    long_description=LONG_DESCRIPTION,\n    packages=find_packages(),\n    include_package_data=True,\n    install_requires=INSTALL_REQUIREMENTS,\n    classifiers=CLASSIFIERS,\n)\n"
  },
  {
    "path": "importer/tasks/__init__.py",
    "content": "import concurrent.futures\nfrom logging import getLogger\nfrom typing import Iterable\n\nfrom .items import import_item_count_from_url\n\nlogger = getLogger(__name__)\n\n\ndef fetch_all_urls(items: Iterable[str]) -> tuple[list[str], int]:\n    \"\"\"\n    Fetch counts for many item URLs concurrently.\n\n    Uses a thread pool to call ``import_item_count_from_url`` for each input\n    URL. Aggregates the returned values and the total score.\n\n    Args:\n        items: Iterable of item URLs.\n\n    Returns:\n        A 2-tuple of:\n            - list of values returned for each URL, in the map order\n            - integer sum of all scores\n    \"\"\"\n    with concurrent.futures.ThreadPoolExecutor(max_workers=25) as executor:\n        result = executor.map(import_item_count_from_url, items)\n\n    finals: list[str] = []\n    totals: int = 0\n\n    for value, score in result:\n        totals += score\n        finals.append(value)\n\n    return finals, totals\n"
  },
  {
    "path": "importer/tasks/assets.py",
    "content": "import hashlib\nimport os\nfrom logging import getLogger\nfrom tempfile import NamedTemporaryFile\nfrom urllib.parse import urlparse\n\nimport boto3\nimport requests\nfrom celery import Task\nfrom django.conf import settings\nfrom flags.state import flag_enabled\nfrom requests.exceptions import HTTPError\n\nfrom concordia.storage import ASSET_STORAGE\nfrom importer import models\nfrom importer.celery import app\nfrom importer.exceptions import ImageImportFailure\n\nfrom .decorators import update_task_status\n\nlogger = getLogger(__name__)\n\n\n@app.task(\n    bind=True,\n    autoretry_for=(HTTPError,),\n    retry_backoff=60 * 60,\n    retry_backoff_max=8 * 60 * 60,\n    retry_jitter=True,\n    retry_kwargs={\"max_retries\": 3},\n    rate_limit=1,\n)\ndef download_asset_task(self: Task, import_asset_pk: int) -> None:\n    \"\"\"\n    Download and persist an asset image for the given ImportItemAsset.\n\n    Looks up the ImportItemAsset with related objects to reduce queries, then\n    delegates to ``download_asset``. Retries on ``HTTPError`` per task config.\n\n    Args:\n        import_asset_pk: Primary key of the ImportItemAsset to process.\n\n    Raises:\n        models.ImportItemAsset.DoesNotExist: If the job row does not exist.\n        ImageImportFailure: If the download or verification fails.\n    \"\"\"\n    # Use select_related since slugs from the container objects form the path.\n    qs = models.ImportItemAsset.objects.select_related(\n        \"import_item__item__project__campaign\"\n    )\n    try:\n        import_asset = qs.get(pk=import_asset_pk)\n    except models.ImportItemAsset.DoesNotExist:\n        logger.exception(\n            \"ImportItemAsset %s could not be found while attempting to \"\n            \"spawn download_asset task\",\n            import_asset_pk,\n        )\n        raise\n\n    download_asset(self, import_asset)\n\n\n@update_task_status\ndef download_asset(self: Task, job: \"models.ImportItemAsset\") -> None:\n    \"\"\"\n    Download the image for the given job and save it to working storage.\n\n    The URL is taken from ``job.url`` when present, otherwise from\n    ``job.asset.download_url``. The extension is inferred from the URL path\n    and normalized so ``jpeg`` becomes ``jpg``. On success the asset's\n    ``storage_image`` field is updated.\n\n    Args:\n        job: ImportItemAsset containing the target asset and optional URL.\n\n    Raises:\n        ImageImportFailure: If the download, upload or checksum check fails.\n    \"\"\"\n    asset = job.asset\n    download_url: str = job.url if hasattr(job, \"url\") else asset.download_url\n\n    file_extension = (\n        os.path.splitext(urlparse(download_url).path)[1].lstrip(\".\").lower()\n    )\n    if not file_extension or file_extension == \"jpeg\":\n        file_extension = \"jpg\"\n\n    asset_image_filename = asset.get_asset_image_filename(file_extension)\n\n    storage_image = download_and_store_asset_image(download_url, asset_image_filename)\n    logger.info(\n        \"Download and storage of asset image %s complete. Setting \"\n        \"storage_image on asset %s (%s)\",\n        storage_image,\n        asset,\n        asset.id,\n    )\n    asset.storage_image = storage_image\n    asset.save()\n\n\ndef download_and_store_asset_image(download_url: str, asset_image_filename: str) -> str:\n    \"\"\"\n    Stream a remote image to a temp file, upload it to storage, then verify.\n\n    The file is streamed and hashed with MD5, uploaded to ``ASSET_STORAGE``,\n    then the object metadata is fetched via S3 ``head_object`` and the ETag is\n    compared to the computed MD5. When the ``IMPORT_IMAGE_CHECKSUM`` flag is\n    enabled a mismatch raises ``ImageImportFailure``. When disabled a warning\n    is logged.\n\n    Args:\n        download_url: HTTP(S) URL of the image to fetch.\n        asset_image_filename: Destination key or path in storage.\n\n    Returns:\n        The storage key that was written.\n\n    Raises:\n        ImageImportFailure: On HTTP errors, I/O errors or checksum mismatch.\n    \"\"\"\n    try:\n        hasher = hashlib.md5(usedforsecurity=False)\n        # Download the remote file to a temp file then upload to storage.\n        with NamedTemporaryFile(mode=\"x+b\") as temp_file:\n            resp = requests.get(download_url, stream=True, timeout=30)\n            resp.raise_for_status()\n\n            for chunk in resp.iter_content(chunk_size=256 * 1024):\n                temp_file.write(chunk)\n                hasher.update(chunk)\n\n            temp_file.flush()\n            temp_file.seek(0)\n            ASSET_STORAGE.save(asset_image_filename, temp_file)\n    except Exception as exc:\n        logger.exception(\n            \"Unable to download %s to %s\", download_url, asset_image_filename\n        )\n        raise ImageImportFailure(\n            f\"Unable to download {download_url} to {asset_image_filename}\"\n        ) from exc\n\n    filehash = hasher.hexdigest()\n    response = boto3.client(\"s3\").head_object(\n        Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=asset_image_filename\n    )\n    etag = response.get(\"ETag\")[1:-1]  # trim quotes around hash\n\n    if filehash != etag:\n        if flag_enabled(\"IMPORT_IMAGE_CHECKSUM\"):\n            logger.error(\n                \"ETag (%s) for %s did not match calculated md5 hash (%s) and \"\n                \"the IMPORT_IMAGE_CHECKSUM flag is enabled\",\n                etag,\n                asset_image_filename,\n                filehash,\n            )\n            raise ImageImportFailure(\n                f\"ETag {etag} for {asset_image_filename} did not match \"\n                f\"calculated md5 hash {filehash}\"\n            )\n        else:\n            logger.warning(\n                \"ETag (%s) for %s did not match calculated md5 hash (%s) but \"\n                \"the IMPORT_IMAGE_CHECKSUM flag is disabled\",\n                etag,\n                asset_image_filename,\n                filehash,\n            )\n    else:\n        logger.info(\n            \"Checksums for %s matched. Upload successful.\",\n            asset_image_filename,\n        )\n\n    return asset_image_filename\n"
  },
  {
    "path": "importer/tasks/collections.py",
    "content": "from logging import getLogger\nfrom typing import Optional\nfrom urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit\n\nimport requests\nfrom celery import Task\nfrom django.core.cache import cache\nfrom requests import Session\nfrom requests.adapters import HTTPAdapter\nfrom requests.packages.urllib3.util.retry import Retry\n\nfrom importer import models\nfrom importer.celery import app\n\nfrom .decorators import update_task_status\nfrom .items import create_item_import_task, get_item_info_from_result\n\nlogger = getLogger(__name__)\n\n# Tasks\n\n\n@app.task(bind=True)\ndef import_collection_task(\n    self: Task, import_job_pk: int, redownload: bool = False\n) -> None:\n    \"\"\"\n    Celery entrypoint to import all items from a P1 collection or search URL.\n\n    Looks up the ``ImportJob`` and delegates to ``import_collection``.\n\n    Args:\n        import_job_pk: Primary key of the ImportJob.\n        redownload: If true, force re-download of assets when creating tasks.\n    \"\"\"\n    import_job = models.ImportJob.objects.get(pk=import_job_pk)\n    import_collection(self, import_job, redownload)\n\n\n@update_task_status\ndef import_collection(\n    self: Task, import_job: models.ImportJob, redownload: bool = False\n) -> None:\n    \"\"\"\n    Enqueue item import tasks for every item in a normalized collection URL.\n\n    Args:\n        import_job: The ImportJob that initiated the collection import.\n        redownload: If true, force re-download of assets.\n    \"\"\"\n    item_info = get_collection_items(normalize_collection_url(import_job.url))\n    for _, item_url in item_info:\n        create_item_import_task.delay(import_job.pk, item_url, redownload)\n\n\n# End tasks\n\n\ndef requests_retry_session(\n    retries: int = 3,\n    backoff_factor: float = 60 * 60,\n    status_forcelist: tuple[int, ...] = (429, 500, 502, 503, 504),\n    session: Optional[Session] = None,\n) -> Session:\n    \"\"\"\n    Build a ``requests.Session`` with retry behavior for transient failures.\n\n    Args:\n        retries: Total number of retry attempts.\n        backoff_factor: Multiplier for exponential backoff in seconds.\n        status_forcelist: HTTP status codes that trigger a retry.\n        session: Optional existing session to configure.\n\n    Returns:\n        A ``requests.Session`` with retry adapters mounted.\n    \"\"\"\n    sess = session or requests.Session()\n    retry = Retry(\n        total=retries,\n        read=retries,\n        connect=retries,\n        backoff_factor=backoff_factor,\n        status_forcelist=status_forcelist,\n    )\n    adapter = HTTPAdapter(max_retries=retry)\n    sess.mount(\"http://\", adapter)\n    sess.mount(\"https://\", adapter)\n    return sess\n\n\ndef normalize_collection_url(original_url: str) -> str:\n    \"\"\"\n    Normalize a P1 collection or search URL for import.\n\n    Rewrites query params needed for JSON output and pagination. Leaves other\n    filters intact.\n\n    Args:\n        original_url: The source collection or search URL.\n\n    Returns:\n        A normalized URL with ``fo=json`` and without conflicting params.\n    \"\"\"\n    parsed_url = urlsplit(original_url)\n\n    new_qs = [(\"fo\", \"json\")]\n\n    for k, v in parse_qsl(parsed_url.query):\n        if k not in (\"fo\", \"at\", \"sp\"):\n            new_qs.append((k, v))\n\n    return urlunsplit(\n        (parsed_url.scheme, parsed_url.netloc, parsed_url.path, urlencode(new_qs), None)\n    )\n\n\ndef get_collection_items(collection_url: str) -> list[tuple[str, str]]:\n    \"\"\"\n    Walk a P1 collection or search endpoint and collect item IDs and URLs.\n\n    Caches each page response for 48 hours to reduce repeated network calls.\n\n    Args:\n        collection_url: URL of a loc.gov collection or search results page.\n\n    Returns:\n        A list of ``(item_id, item_url)`` tuples discovered across pages.\n    \"\"\"\n    items: list[tuple[str, str]] = []\n    current_page_url: Optional[str] = collection_url\n\n    while current_page_url:\n        resp = cache.get(current_page_url)\n        if resp is None:\n            resp = requests_retry_session().get(current_page_url)\n            # 48-hour timeout\n            cache.set(current_page_url, resp, timeout=(3600 * 48))\n\n        data = resp.json()\n\n        results = data.get(\"results\", None)\n        if results:\n            for result in results:\n                try:\n                    item_info = get_item_info_from_result(result)\n                    if item_info:\n                        items.append(item_info)\n                except Exception:\n                    logger.warning(\n                        \"Skipping result from %s which did not match expected format:\",\n                        current_page_url,\n                        exc_info=True,\n                        extra={\"data\": {\"result\": result, \"url\": current_page_url}},\n                    )\n        else:\n            logger.error('Expected URL %s to include \"results\"', current_page_url)\n\n        current_page_url = data.get(\"pagination\", {}).get(\"next\", None)\n\n    if not items:\n        logger.warning(\"No valid items found for collection url: %s\", collection_url)\n\n    return items\n"
  },
  {
    "path": "importer/tasks/decorators.py",
    "content": "from functools import wraps\nfrom logging import getLogger\nfrom typing import Any, Callable, Concatenate, ParamSpec, TypeVar\n\nfrom celery import Task\nfrom django.utils.timezone import now\n\nfrom importer import models\nfrom importer.exceptions import ImageImportFailure\n\nlogger = getLogger(__name__)\n\nP = ParamSpec(\"P\")\nR = TypeVar(\"R\")\n\n\ndef update_task_status(\n    f: Callable[Concatenate[Task, Any, P], R],\n) -> Callable[Concatenate[Task, Any, P], R]:\n    \"\"\"\n    Decorator to track lifecycle and failure state for a task-like function.\n\n    The wrapped function must take the Celery task self as the first argument\n    and a TaskStatusModel instance as the second argument. On entry records\n    last_started and task_id. On success sets completed and clears failure\n    fields. On exception updates status, marks failed, sets failure_reason for\n    known error types, saves the model, then attempts retry_if_possible before\n    re-raising.\n\n    Also guards against re-running a task already marked completed.\n\n    Args:\n        f: The function to wrap. Must accept\n           ``(self, task_status_object, *args, **kwargs)``.\n\n    Returns:\n        A callable with the same signature as ``f``.\n    \"\"\"\n\n    @wraps(f)\n    def inner(\n        self: Task, task_status_object: Any, *args: P.args, **kwargs: P.kwargs\n    ) -> R:\n        # Sanity guard: if another worker already completed this task, skip work.\n        guard_qs = task_status_object.__class__._default_manager.filter(\n            pk=task_status_object.pk, completed__isnull=False\n        )\n        if guard_qs.exists():\n            logger.warning(\n                \"Task %s was already completed and will not be repeated\",\n                task_status_object,\n                extra={\n                    \"data\": {\n                        \"object\": task_status_object,\n                        \"args\": args,\n                        \"kwargs\": kwargs,\n                    }\n                },\n            )\n            return  # noqa: RET504\n\n        task_status_object.last_started = now()\n        task_status_object.task_id = self.request.id\n        task_status_object.save()\n        try:\n            result = f(self, task_status_object, *args, **kwargs)\n            task_status_object.completed = now()\n            task_status_object.failed = None\n            task_status_object.failure_reason = \"\"\n            task_status_object.update_status(\"Completed\")\n            return result\n        except Exception as exc:\n            new_status = \"{}\\n\\nUnhandled exception: {}\".format(\n                task_status_object.status, exc\n            ).strip()\n            task_status_object.update_status(new_status, do_save=False)\n            task_status_object.failed = now()\n            if isinstance(exc, ImageImportFailure):\n                task_status_object.failure_reason = (\n                    models.TaskStatusModel.FailureReason.IMAGE\n                )\n            task_status_object.save()\n\n            retry_result = task_status_object.retry_if_possible()\n            if retry_result:\n                task_status_object.last_started = now()\n                task_status_object.task_id = retry_result.id\n                task_status_object.save()\n            else:\n                logger.info(\"Retrying task %s was not possible\", task_status_object)\n            raise\n\n    return inner\n"
  },
  {
    "path": "importer/tasks/images.py",
    "content": "from logging import getLogger\nfrom typing import Any, Optional, Sequence\nfrom uuid import UUID\n\nfrom celery import Task, chord\nfrom PIL import Image\nfrom requests.exceptions import HTTPError\n\nfrom concordia.models import Asset\nfrom concordia.storage import ASSET_STORAGE\nfrom importer import models\nfrom importer.celery import app\n\nfrom .assets import download_asset\nfrom .decorators import update_task_status\n\nlogger = getLogger(__name__)\n\n\n@app.task(\n    bind=True,\n    autoretry_for=(HTTPError,),\n    retry_backoff=60 * 60,\n    retry_backoff_max=8 * 60 * 60,\n    retry_jitter=True,\n    retry_kwargs={\"max_retries\": 3},\n    rate_limit=1,\n)\ndef redownload_image_task(self: Task, asset_pk: int) -> None:\n    \"\"\"\n    Re-download an asset's image and persist it to storage, replacing any\n    existing image.\n\n    Looks up the Asset, creates a DownloadAssetImageJob to track work, then\n    delegates to download_asset.\n\n    Args:\n        asset_pk: Primary key of the Asset to re-download.\n    \"\"\"\n    asset = Asset.objects.get(pk=asset_pk)\n    logger.info(\"Redownloading %s to %s\", asset.download_url, asset.get_absolute_url())\n\n    # Create a tracking job so download_asset can run under update_task_status.\n    job = models.DownloadAssetImageJob.objects.create(asset=asset, batch=None)\n    download_asset(self, job)\n\n\n@app.task()\ndef batch_verify_asset_images_task_callback(\n    results: Sequence[bool],\n    batch: UUID,\n    concurrency: int,\n    failures_detected: bool,\n) -> None:\n    \"\"\"\n    Callback after a chord of VerifyAssetImageJobs completes.\n\n    If no prior failure was noted and any result is False, mark failures as\n    detected. In all cases enqueue the next verification batch.\n\n    Args:\n        results: Verification outcomes for this chord (True or False).\n        batch: Identifier for the active batch.\n        concurrency: Number of jobs to run in the next batch.\n        failures_detected: Whether a failure was already seen.\n    \"\"\"\n    # We only care if there are any failures, not exactly which or how many, since we\n    # automatically create a DownliadImageAssetJob for each failure already, so here\n    # we skip this check if we already have a detected failure\n    if not failures_detected:\n        # No failures so far, so we need to check the results from this latest\n        # chord of tasks\n        if any(result is False for result in results):\n            logger.info(\n                \"At least one verification failure detected for batch %s\", batch\n            )\n            failures_detected = True\n\n    batch_verify_asset_images_task.delay(batch, concurrency, failures_detected)\n\n\n@app.task(bind=True)\ndef batch_verify_asset_images_task(\n    self: Task, batch: UUID, concurrency: int = 2, failures_detected: bool = False\n) -> None:\n    \"\"\"\n    Process VerifyAssetImageJobs in groups of size concurrency.\n\n    After processing:\n    - If any failure was detected, start a DownloadAssetImageJob batch.\n    - Otherwise, end cleanly.\n\n    Args:\n        batch: Identifier for the batch to process.\n        concurrency: Number of jobs to process at once. Defaults to 2.\n        failures_detected: Whether earlier groups reported a failure.\n            Defaults to False.\n    \"\"\"\n    logger.info(\n        \"Processing next %s VerifyAssetImageJobs for batch %s\", concurrency, batch\n    )\n\n    jobs_to_process = models.VerifyAssetImageJob.objects.filter(\n        batch=batch, completed__isnull=True, failed__isnull=True\n    ).order_by(\"created\")\n\n    if not jobs_to_process.exists():\n        logger.info(\"No VerifyAssetImageJobs remain for batch %s\", batch)\n        if failures_detected:\n            logger.info(\n                \"Failures in VerifyAssetImageJobs in batch %s detected, so \"\n                \"starting DownloadAssetImageJob batch\",\n                batch,\n            )\n            batch_download_asset_images_task(batch, concurrency)\n        else:\n            logger.info(\n                \"No failures in VerifyAssetImageJob batch %s. Ending task.\", batch\n            )\n        return\n\n    task_group = [\n        verify_asset_image_task.s(job.asset_id, batch)\n        for job in jobs_to_process[:concurrency]\n    ]\n\n    chord(task_group)(\n        batch_verify_asset_images_task_callback.s(batch, concurrency, failures_detected)\n    )\n\n\n@app.task(\n    bind=True,\n    autoretry_for=(HTTPError,),\n    retry_backoff=60 * 60,\n    retry_backoff_max=8 * 60 * 60,\n    retry_jitter=True,\n    retry_kwargs={\"max_retries\": 3},\n    rate_limit=1,\n)\ndef verify_asset_image_task(\n    self: Task, asset_pk: int, batch: Optional[UUID] = None, create_job: bool = False\n) -> bool:\n    \"\"\"\n    Verify that an asset's storage image exists and is readable.\n\n    Creates or retrieves a VerifyAssetImageJob, runs verification and updates\n    status. Retries on HTTPError using exponential backoff.\n\n    Args:\n        asset_pk: Primary key of the Asset to verify.\n        batch: Identifier for the verification batch, if any.\n        create_job: If True, create a new job; otherwise fetch an existing one.\n\n    Returns:\n        True if verification succeeds, False otherwise.\n\n    Raises:\n        Asset.DoesNotExist: When the Asset cannot be found.\n        models.VerifyAssetImageJob.DoesNotExist: When fetching a job that does\n            not exist.\n    \"\"\"\n    try:\n        asset = Asset.objects.get(pk=asset_pk)\n    except Asset.DoesNotExist:\n        logger.exception(\n            \"Asset %s could not be found while attempting to \"\n            \"spawn verify_asset_image task\",\n            asset_pk,\n        )\n        raise\n\n    if create_job:\n        job = models.VerifyAssetImageJob.objects.create(asset=asset, batch=batch)\n    else:\n        try:\n            job = models.VerifyAssetImageJob.objects.get(\n                asset=asset, batch=batch, completed=None\n            )\n        except models.VerifyAssetImageJob.DoesNotExist:\n            logger.exception(\n                \"Uncompleted VerifyAssetImageJob for asset %s and batch %s could not \"\n                \"be found while attempting to spawn verify_asset_image task\",\n                asset,\n                batch,\n            )\n            raise\n\n    result = verify_asset_image(self, job)\n    if result is True:\n        job.update_status(\"Storage image verified\")\n    return result\n\n\ndef create_download_asset_image_job(asset: Asset, batch: Optional[UUID]) -> None:\n    \"\"\"\n    Ensure a DownloadAssetImageJob exists for the given asset and batch.\n\n    Args:\n        asset: Asset to download.\n        batch: Batch identifier or None.\n    \"\"\"\n    existing_job = models.DownloadAssetImageJob.objects.filter(\n        asset=asset, batch=batch\n    ).first()\n\n    if not existing_job:\n        models.DownloadAssetImageJob.objects.create(asset=asset, batch=batch)\n\n\n@update_task_status\ndef verify_asset_image(task: Task, job: Any) -> bool:\n    \"\"\"\n    Verify the presence and integrity of an Asset's storage image.\n\n    Checks that a storage image is set, the object exists in storage, and that\n    the image bytes are not corrupt. On failure, updates job status and creates\n    a DownloadAssetImageJob.\n\n    Args:\n        task: Celery task instance.\n        job: VerifyAssetImageJob instance.\n\n    Returns:\n        True if verification succeeds, False otherwise.\n    \"\"\"\n    asset = job.asset\n\n    if not asset.storage_image or not asset.storage_image.name:\n        status = f\"No storage image set on {asset} ({asset.id})\"\n        logger.info(status)\n        job.update_status(status)\n        create_download_asset_image_job(asset, job.batch)\n        return False\n    else:\n        logger.info(\"Storage image set on %s (%s)\", asset, asset.id)\n\n    if not ASSET_STORAGE.exists(asset.storage_image.name):\n        status = f\"Storage image for {asset} ({asset.id}) missing from storage\"\n        logger.info(status)\n        job.update_status(status)\n        create_download_asset_image_job(asset, job.batch)\n        return False\n    else:\n        logger.info(\"Storage image for %s (%s) found in storage\", asset, asset.id)\n\n    try:\n        with ASSET_STORAGE.open(asset.storage_image.name, \"rb\") as image_file:\n            with Image.open(image_file) as image:\n                image.verify()\n        logger.info(\"Storage image for %s (%s) is not corrupt\", asset, asset.id)\n    except Exception as exc:\n        status = (\n            f\"Storage image for {asset} ({asset.id}), {asset.storage_image.name}, \"\n            f\"is corrupt. The exception raised was Type: {type(exc).__name__}, \"\n            f\"Message: {exc}\"\n        )\n        logger.info(status)\n        job.update_status(status)\n        create_download_asset_image_job(asset, job.batch)\n        return False\n\n    logger.info(\n        \"Storage image for %s (%s), %s, verified successfully\",\n        asset,\n        asset.id,\n        asset.storage_image.name,\n    )\n    return True\n\n\n@app.task()\ndef batch_download_asset_images_task_callback(\n    results: Sequence[Any], batch: UUID, concurrency: int\n) -> None:\n    \"\"\"\n    Callback after a chord of DownloadAssetImageJobs completes.\n\n    Results are ignored. Enqueue the next download batch.\n\n    Args:\n        results: Ignored chord results.\n        batch: Identifier for the batch.\n        concurrency: Number of jobs to run in the next batch.\n    \"\"\"\n    # We do not care about the results of these tasks, so we simply call the\n    # original task again to continue processing the batch.\n    batch_download_asset_images_task.delay(batch, concurrency)\n\n\n@app.task(bind=True)\ndef batch_download_asset_images_task(\n    self: Task, batch: UUID, concurrency: int = 10\n) -> None:\n    \"\"\"\n    Process DownloadAssetImageJobs in groups of size concurrency.\n\n    Retrieves pending jobs for the batch, runs up to concurrency tasks, then\n    schedules the next group via a chord callback until none remain.\n\n    Args:\n        batch: Identifier for the batch to process.\n        concurrency: Number of concurrent tasks per group. Defaults to 10.\n    \"\"\"\n    logger.info(\n        \"Processing next %s DownloadAssetImageJobs for batch %s\", concurrency, batch\n    )\n\n    jobs_to_process = models.DownloadAssetImageJob.objects.filter(\n        batch=batch, completed__isnull=True, failed__isnull=True\n    ).order_by(\"created\")\n\n    if not jobs_to_process.exists():\n        logger.info(\"No DownloadAssetImageJobs found for batch %s\", batch)\n        return\n\n    task_groups = [\n        download_asset_image_task.s(job.asset.pk, batch)\n        for job in jobs_to_process[:concurrency]\n    ]\n\n    # Use a chord so when the tasks finish it calls the callback to start the\n    # remaining jobs until no more remain. The callback just re-invokes this\n    # task with the same batch and concurrency.\n    chord(task_groups)(batch_download_asset_images_task_callback.s(batch, concurrency))\n\n\n@app.task(\n    bind=True,\n    autoretry_for=(HTTPError,),\n    retry_backoff=60 * 60,\n    retry_backoff_max=8 * 60 * 60,\n    retry_jitter=True,\n    retry_kwargs={\"max_retries\": 3},\n    rate_limit=1,\n)\ndef download_asset_image_task(\n    self: Task, asset_pk: int, batch: Optional[UUID] = None, create_job: bool = False\n) -> None:\n    \"\"\"\n    Download an asset's image and track it via DownloadAssetImageJob.\n\n    Creates or retrieves a job and delegates to download_asset. Retries on\n    HTTPError using exponential backoff.\n\n    Args:\n        asset_pk: Primary key of the Asset to download.\n        batch: Identifier for the batch, if any.\n        create_job: If True, create a new job; otherwise fetch an existing one.\n\n    Raises:\n        Asset.DoesNotExist: When the Asset cannot be found.\n        models.DownloadAssetImageJob.DoesNotExist: When fetching a job that\n            does not exist.\n    \"\"\"\n    try:\n        asset = Asset.objects.get(pk=asset_pk)\n    except Asset.DoesNotExist:\n        logger.exception(\n            \"Asset %s could not be found while attempting to \"\n            \"spawn verify_asset_image task\",\n            asset_pk,\n        )\n        raise\n\n    if create_job:\n        job = models.DownloadAssetImageJob.objects.create(asset=asset, batch=batch)\n    else:\n        try:\n            job = models.DownloadAssetImageJob.objects.get(\n                asset=asset, batch=batch, completed=None\n            )\n        except models.DownloadAssetImageJob.DoesNotExist:\n            logger.exception(\n                \"Uncompleted DownloadAssetImageJob for asset %s and batch %s could not \"\n                \"be found while attempting to spawn download_asset_image task\",\n                asset,\n                batch,\n            )\n            raise\n\n    return download_asset(self, job)\n"
  },
  {
    "path": "importer/tasks/items.py",
    "content": "import io\nimport mimetypes\nimport os\nimport re\nfrom logging import getLogger\nfrom typing import Any, List, Optional, Tuple\nfrom urllib.parse import urljoin, urlparse\n\nimport requests\nfrom celery import Task, group\nfrom django.core.exceptions import ValidationError\nfrom django.core.files.base import ContentFile\nfrom django.db import transaction\nfrom django.utils.text import slugify\nfrom django.utils.timezone import now\nfrom PIL import Image, UnidentifiedImageError\nfrom requests.exceptions import HTTPError\n\nfrom concordia.models import Asset, Item, MediaType\nfrom importer import models\nfrom importer.celery import app\n\nfrom .assets import download_asset_task\nfrom .decorators import update_task_status\n\n#: P1 has generic search / item pages and a number of top-level format-specific\n#: \"context portals\" which expose the same JSON interface.\n#: jq 'to_entries[] | select(.value.type == \"context-portal\") | .key' < manifest.json\nACCEPTED_P1_URL_PREFIXES = [\n    \"collections\",\n    \"search\",\n    \"item\",\n    \"audio\",\n    \"books\",\n    \"film-and-videos\",\n    \"manuscripts\",\n    \"maps\",\n    \"newspapers\",\n    \"notated-music\",\n    \"photos\",\n    \"websites\",\n]\n\nlogger = getLogger(__name__)\n\n# Tasks\n\n\n@app.task(\n    bind=True,\n    autoretry_for=(HTTPError,),\n    retry_backoff=60 * 60,\n    retry_backoff_max=8 * 60 * 60,\n    retry_jitter=True,\n    retry_kwargs={\"max_retries\": 3},\n    rate_limit=2,\n)\ndef create_item_import_task(\n    self: Task, import_job_pk: int, item_url: str, redownload: bool = False\n) -> Any:\n    \"\"\"\n    Create an ImportItem for the given job and item URL, then enqueue its\n    import.\n\n    Fetches item metadata from the remote URL, ensures the Item and\n    ImportItem exist, skips fully-imported items when not redownloading, and\n    finally schedules ``import_item_task``.\n\n    Args:\n        import_job_pk: Primary key of the ImportJob.\n        item_url: Absolute item URL on loc.gov.\n        redownload: Reprocess an existing item even if it has all assets.\n\n    Returns:\n        The AsyncResult returned by ``import_item_task.delay``.\n    \"\"\"\n    import_job = models.ImportJob.objects.get(pk=import_job_pk)\n\n    # Load the Item record with metadata from the remote URL:\n    resp = requests.get(item_url, params={\"fo\": \"json\"}, timeout=30)\n    resp.raise_for_status()\n    item_data = resp.json()\n\n    item, item_created = Item.objects.get_or_create(\n        item_id=get_item_id_from_item_url(item_data[\"item\"][\"id\"]),\n        defaults={\"item_url\": item_url, \"project\": import_job.project},\n    )\n\n    import_item, import_item_created = import_job.items.get_or_create(\n        url=item_url, item=item\n    )\n\n    if not item_created and redownload is False:\n        # Item has already been imported and we are not redownloading all items.\n        asset_urls, item_resource_url = get_asset_urls_from_item_resources(\n            item.metadata.get(\"resources\", [])\n        )\n        if item.asset_set.count() >= len(asset_urls):\n            # The item has all of its assets, so we can skip it.\n            logger.warning(\"Not reprocessing existing item with all assets: %s\", item)\n            import_item.update_status(\n                f\"Not reprocessing existing item with all assets: {item}\",\n                do_save=False,\n            )\n            import_item.completed = import_item.last_started = now()\n            import_item.task_id = self.request.id\n            import_item.full_clean()\n            import_item.save()\n            return\n        else:\n            # The item is missing one or more of its assets, so reprocess it.\n            logger.warning(\"Reprocessing existing item %s that is missing assets\", item)\n\n    import_item.item.metadata.update(item_data)\n    thumbnail_url = populate_item_from_data(import_item.item, item_data[\"item\"])\n\n    try:\n        item.full_clean()\n        item.save()\n    except Exception as exc:\n        # We create the import jobs here, so we cannot rely on the decorator to\n        # update status. Update the ImportItem status manually then re-raise.\n        logger.exception(\"Unhandled exception when importing item %s\", item)\n        new_status = \"{}\\n\\nUnhandled exception: {}\".format(\n            import_item.status, exc\n        ).strip()\n        import_item.update_status(new_status, do_save=False)\n        import_item.failed = now()\n        import_item.task_id = self.request.id\n        import_item.save()\n        raise\n\n    download_and_set_item_thumbnail(item, thumbnail_url)\n\n    return import_item_task.delay(import_item.pk)\n\n\n@app.task(bind=True)\ndef import_item_task(self: Task, import_item_pk: int) -> Any:\n    \"\"\"\n    Enqueue downloads for all assets of a previously created ImportItem.\n\n    Args:\n        import_item_pk: Primary key of the ImportItem to process.\n\n    Returns:\n        The result of the celery group that downloads assets.\n    \"\"\"\n    i = models.ImportItem.objects.select_related(\"item\").get(pk=import_item_pk)\n    return import_item(self, i)\n\n\n@update_task_status\ndef import_item(self: Task, import_item: Any) -> Any:\n    \"\"\"\n    Create Asset rows for an ImportItem, create ImportItemAsset rows, then\n    enqueue downloads for all assets.\n\n    Wrapped with ``update_task_status`` to keep job fields updated.\n\n    Args:\n        self: Celery Task instance.\n        import_item: ImportItem instance being processed.\n\n    Returns:\n        A celery group result for the scheduled download tasks.\n    \"\"\"\n    # Using transaction.atomic here ensures the data is available in the\n    # database for the download_asset_task calls. If we do not do this some\n    # tasks could execute before the transaction is committed, causing failures.\n    with transaction.atomic():\n        item_assets: List[Asset] = []\n        import_assets: List[Any] = []\n        item_resource_url: Optional[str] = None\n\n        asset_urls, item_resource_url = get_asset_urls_from_item_resources(\n            import_item.item.metadata.get(\"resources\", [])\n        )\n        relative_asset_file_path = \"/\".join(\n            [\n                import_item.item.project.campaign.slug,\n                import_item.item.project.slug,\n                import_item.item.item_id,\n            ]\n        )\n\n        for sequence, asset_url in enumerate(asset_urls, start=1):\n            asset_title = f\"{import_item.item.item_id}-{sequence}\"\n            file_extension = (\n                os.path.splitext(urlparse(asset_url).path)[1].lstrip(\".\").lower()\n            )\n            item_asset = Asset(\n                item=import_item.item,\n                campaign=import_item.item.project.campaign,\n                title=asset_title,\n                slug=slugify(asset_title, allow_unicode=True),\n                sequence=sequence,\n                media_type=MediaType.IMAGE,\n                download_url=asset_url,\n                resource_url=item_resource_url,\n                storage_image=\"/\".join(\n                    [relative_asset_file_path, f\"{sequence}.{file_extension}\"]\n                ),\n            )\n            # Previously any asset that raised a validation error was ignored.\n            # We want validation errors to fail the import.\n            try:\n                item_asset.full_clean()\n            except ValidationError as exc:\n                raise ValidationError(\n                    f\"Importing asset with slug '{item_asset.slug}' for \"\n                    f\"item '{item_asset.item}' with resource URL \"\n                    f\"'{item_asset.resource_url}' failed with the following \"\n                    f\"exception: {exc}\"\n                ) from exc\n            item_assets.append(item_asset)\n\n        Asset.objects.bulk_create(item_assets)\n\n        for asset in item_assets:\n            import_asset = models.ImportItemAsset(\n                import_item=import_item,\n                asset=asset,\n                url=asset.download_url,\n                sequence_number=asset.sequence,\n            )\n            import_asset.full_clean()\n            import_assets.append(import_asset)\n\n        import_item.assets.bulk_create(import_assets)\n\n        import_item.full_clean()\n        import_item.save()\n\n    download_asset_group = group(download_asset_task.s(i.pk) for i in import_assets)\n    return download_asset_group()\n\n\n# End tasks\n\n\ndef import_item_count_from_url(import_url: str) -> Tuple[str, int]:\n    \"\"\"\n    Return a tuple of status string and asset count for a loc.gov item URL.\n\n    Args:\n        import_url: Absolute item URL.\n\n    Returns:\n        A pair of ``(status_message, count)``. On error returns a message and\n        count 0.\n    \"\"\"\n    try:\n        resp = requests.get(import_url, params={\"fo\": \"json\"}, timeout=30)\n        resp.raise_for_status()\n        item_data = resp.json()\n        output = len(item_data[\"resources\"][0][\"files\"])\n        return f\"{import_url} - Asset Count: {output}\", output\n    except Exception as exc:\n        return f\"Unhandled exception importing {import_url} {exc}\", 0\n\n\ndef get_item_info_from_result(\n    result: dict,\n) -> Optional[Tuple[str, str]]:\n    \"\"\"\n    Extract an item_id and item_url from a P1 search result.\n\n    Skips results with unsupported formats or without an image_url.\n\n    Args:\n        result: A single result object from the P1 JSON response.\n\n    Returns:\n        ``(item_id, item_url)`` when supported, otherwise None.\n    \"\"\"\n    ignored_formats = {\"collection\", \"web page\"}\n\n    item_id = result[\"id\"]\n    original_format = result[\"original_format\"]\n\n    if ignored_formats.intersection(original_format):\n        logger.info(\n            \"Skipping result %s because it contains an unsupported format: %s\",\n            item_id,\n            original_format,\n            extra={\"data\": {\"result\": result}},\n        )\n        return None\n\n    image_url = result.get(\"image_url\")\n    if not image_url:\n        logger.info(\n            \"Skipping result %s because it lacks an image_url\",\n            item_id,\n            extra={\"data\": {\"result\": result}},\n        )\n        return None\n\n    item_url = result[\"url\"]\n\n    m = re.search(r\"loc.gov/item/([^/]+)\", item_url)\n    if not m:\n        logger.info(\n            \"Skipping %s because the URL %s doesn't appear to be an item!\",\n            item_id,\n            item_url,\n            extra={\"data\": {\"result\": result}},\n        )\n        return None\n\n    return m.group(1), item_url\n\n\ndef get_item_id_from_item_url(item_url: str) -> str:\n    \"\"\"\n    Extract the item_id component from a loc.gov item URL.\n\n    Args:\n        item_url: Absolute item URL.\n\n    Returns:\n        The item_id string.\n    \"\"\"\n    if item_url.endswith(\"/\"):\n        item_id = item_url.split(\"/\")[-2]\n    else:\n        item_id = item_url.split(\"/\")[-1]\n    return item_id\n\n\ndef import_items_into_project_from_url(\n    requesting_user: Any, project: Any, import_url: str, redownload: bool = False\n) -> Any:\n    \"\"\"\n    Create an ImportJob for the given URL and enqueue item or collection import.\n\n    Determines whether the URL is an item or a collection/search URL and\n    schedules the appropriate task.\n\n    Args:\n        requesting_user: User creating the ImportJob.\n        project: Project that will own the imported Items.\n        import_url: loc.gov item or collection/search URL.\n        redownload: Reprocess existing items even if they have all assets.\n\n    Returns:\n        The created ImportJob instance.\n    \"\"\"\n    parsed_url = urlparse(import_url)\n\n    m = re.match(\n        r\"^/(%s)/\" % \"|\".join(map(re.escape, ACCEPTED_P1_URL_PREFIXES)), parsed_url.path\n    )\n    if not m:\n        raise ValueError(\n            f\"{import_url} doesn't match one of the known importable patterns\"\n        )\n    url_type = m.group(1)\n\n    import_job = models.ImportJob(\n        project=project, created_by=requesting_user, url=import_url\n    )\n    import_job.full_clean()\n    import_job.save()\n\n    if url_type == \"item\":\n        create_item_import_task.delay(import_job.pk, import_url, redownload)\n    else:\n        # Both collections and search results return the same format JSON\n        # response so we can use the same code to process them.\n        from .collections import import_collection_task\n\n        import_collection_task.delay(import_job.pk, redownload)\n\n    return import_job\n\n\ndef populate_item_from_data(item: Item, item_info: dict) -> Optional[str]:\n    \"\"\"\n    Populate an Item from a loc.gov item JSON fragment.\n\n    Sets title and description when present. Chooses a JPG thumbnail URL if\n    available, stores it on the Item, and returns the resolved URL.\n\n    Args:\n        item: The Item instance to update.\n        item_info: The ``item`` object from the P1 response.\n\n    Returns:\n        The resolved thumbnail URL when found, otherwise None.\n    \"\"\"\n    for k in (\"title\", \"description\"):\n        v = item_info.get(k)\n        if v:\n            setattr(item, k, v)\n\n    # FIXME: this was never set before so we do not have selection logic.\n    thumb_urls = [i for i in item_info[\"image_url\"] if \".jpg\" in i]\n    if thumb_urls:\n        item.thumbnail_url = urljoin(item.item_url, thumb_urls[0])\n    try:\n        image_urls = item_info.get(\"image_url\") or []\n        thumb_urls = [u for u in image_urls if \".jpg\" in u]\n    except Exception:\n        thumb_urls = []\n\n    if thumb_urls:\n        resolved = urljoin(item.item_url, thumb_urls[0])\n        # TODO: remove setting thumbnail_url once field is removed.\n        item.thumbnail_url = resolved\n        return resolved\n    return None\n\n\ndef get_asset_urls_from_item_resources(\n    resources: List[dict],\n) -> Tuple[List[str], str]:\n    \"\"\"\n    From a P1 resources list, pick best image URL per file.\n\n    Prefers the largest JPEG variant per file. If no JPEGs exist, falls back\n    to the largest GIF. Also returns the item resource URL when available.\n\n    Args:\n        resources: The ``resources`` array from the P1 response.\n\n    Returns:\n        A tuple of ``(asset_urls, item_resource_url)``.\n    \"\"\"\n    assets: List[str] = []\n    try:\n        item_resource_url = resources[0][\"url\"] or \"\"\n    except (IndexError, KeyError):\n        item_resource_url = \"\"\n\n    for resource in resources:\n        # Each \"file\" contains a set of variants. Select the largest preferred\n        # type per file.\n        for item_file in resource.get(\"files\", []):\n            candidates: List[Tuple[str, int]] = []\n            backup_candidates: List[Tuple[str, int]] = []\n\n            for variant in item_file:\n                if any(i for i in (\"url\", \"height\", \"width\") if i not in variant):\n                    continue\n\n                url = variant[\"url\"]\n                height = variant[\"height\"]\n                width = variant[\"width\"]\n                mimetype = variant.get(\"mimetype\")\n\n                # Prefer JPEG; if none exist use GIF.\n                if mimetype == \"image/jpeg\":\n                    candidates.append((url, height * width))\n                elif mimetype == \"image/gif\":\n                    backup_candidates.append((url, height * width))\n\n            if candidates:\n                candidates.sort(key=lambda i: i[1], reverse=True)\n                assets.append(candidates[0][0])\n            elif backup_candidates:\n                backup_candidates.sort(key=lambda i: i[1], reverse=True)\n                assets.append(backup_candidates[0][0])\n\n    return assets, item_resource_url\n\n\ndef _guess_extension(content_type: Optional[str], url_path: str) -> str:\n    \"\"\"Guess a safe extension from Content-Type or URL, defaulting to .bin.\"\"\"\n    if content_type:\n        ext = mimetypes.guess_extension(content_type.split(\";\")[0].strip())\n        if ext:\n            return ext\n    _, ext = os.path.splitext(url_path)\n    if ext:\n        return ext.lower()\n    return \".bin\"\n\n\ndef _safe_filename(item: Item, ext: str) -> str:\n    \"\"\"Build a filename for the item's thumbnail.\"\"\"\n    base = slugify(item.item_id or f\"item-{item.pk}\") or f\"item-{item.pk}\"\n    return f\"{base}{ext}\"\n\n\ndef download_and_set_item_thumbnail(\n    item: Item,\n    url: str,\n    force: bool = False,\n    connect_timeout: float = 5.0,\n    read_timeout: float = 30.0,\n) -> str:\n    \"\"\"\n    Download an image from url and save it to item.thumbnail_image.\n\n    The image is validated with Pillow. The function will not set a new\n    thumbnail_image if one already exists unless ``force=True``. Filename is\n    stable per item and inferred from Content-Type or URL with a safe fallback.\n\n    Args:\n        item: The Item instance to modify and save.\n        url: Absolute URL for the image to download.\n        force: Overwrite an existing thumbnail if True.\n        connect_timeout: Requests connect timeout in seconds.\n        read_timeout: Requests read timeout in seconds.\n\n    Returns:\n        The storage path of the saved image, or a message if skipped.\n\n    Raises:\n        ValueError: If the image is invalid.\n        requests.RequestException: Network errors during download.\n    \"\"\"\n    # Lock the row briefly to avoid pointless work if someone else is writing.\n    with transaction.atomic():\n        locked = (\n            Item.objects.select_for_update(of=(\"self\",))\n            .only(\"id\", \"thumbnail_image\")\n            .get(pk=item.pk)\n        )\n        if locked.thumbnail_image and not force:\n            msg = \"Thumbnail already exists; skipping (use force=True to overwrite).\"\n            logger.warning(\n                \"download_and_set_item_thumbnail: %s item_pk=%s\", msg, item.pk\n            )\n            return msg\n\n    timeout = (connect_timeout, read_timeout)\n    logger.info(\n        \"download_and_set_item_thumbnail: downloading url=%s item_pk=%s\",\n        url,\n        item.pk,\n    )\n\n    with requests.get(url, stream=True, timeout=timeout) as resp:\n        resp.raise_for_status()\n        content_type = (resp.headers.get(\"Content-Type\") or \"\").lower()\n\n        buf = io.BytesIO()\n        for chunk in resp.iter_content(chunk_size=64 * 1024):\n            if not chunk:\n                continue\n            buf.write(chunk)\n\n    # Validate image integrity with Pillow.\n    try:\n        buf.seek(0)\n        with Image.open(buf) as img:\n            img.verify()\n    except UnidentifiedImageError as exc:\n        raise ValueError(\"Downloaded file is not a valid image.\") from exc\n\n    # Decide file extension. Try header, URL, then Pillow.\n    url_path = urlparse(url).path\n    ext = _guess_extension(content_type, url_path)\n    # If we got a blank or .bin extension we could not infer it from headers\n    # or URL. Inspect bytes with Pillow, default to jpg.\n    if ext in (\".bin\", \"\"):\n        try:\n            buf.seek(0)\n            with Image.open(buf) as probe:\n                fmt = (probe.format or \"\").lower()\n            ext = {\n                \"jpeg\": \".jpg\",\n                \"jpg\": \".jpg\",\n                \"png\": \".png\",\n                \"gif\": \".gif\",\n                \"webp\": \".webp\",\n                \"tiff\": \".tif\",\n                \"bmp\": \".bmp\",\n            }.get(fmt, \".jpg\")\n        finally:\n            buf.seek(0)\n\n    filename = _safe_filename(item, ext)\n    content = ContentFile(buf.getvalue())\n\n    with transaction.atomic():\n        locked = Item.objects.select_for_update(of=(\"self\",)).get(pk=item.pk)\n        if locked.thumbnail_image and not force:\n            msg = (\n                \"Thumbnail already present after download; skipping save. \"\n                \"Use force=True to overwrite.\"\n            )\n            logger.warning(\n                \"download_and_set_item_thumbnail: %s item_id=%s\", msg, item.pk\n            )\n            return msg\n        locked.thumbnail_image.save(filename, content, save=True)\n        logger.info(\n            \"download_and_set_item_thumbnail: saved as %s item_id=%s\",\n            locked.thumbnail_image.name,\n            locked.pk,\n        )\n    return locked.thumbnail_image.name\n"
  },
  {
    "path": "importer/tests/README.md",
    "content": "# Importer Tests\n\nThis directory contains tests for the importer application. It has a\ncombination of Django TestCases (which will create a test database\nbefore running each test), and pyunit tests.\n\n## Pre-requisites\n\n-   Regarding Django TestCases, since these tests create a test database, the docker container with the db must be running — for example:\n\n    ```console\n    $ docker-compose up -d db\n    ```\n\n-   Use the settings module with defaults appropriate for testing:\n\n    ```console\n    $ export DJANGO_SETTINGS_MODULE=concordia.settings_test\n    ```\n\n    or\n\n    ```console\n    $ pipenv run manage.py test --settings=concordia.settings_test\n    ```\n\n## Running the tests\n\n-   To run all tests:\n\n    ```console\n    $ python manage.py test importer\n    ```\n\n-   To run a single unittest module:\n\n    ```console\n    $ python manage.py test importer.tests.test_importer\n    ```\n\n-   To run a single unittest in a django unittest module:\n\n    ```console\n    $ python manage.py test\n    importer.tests.test_importer.CreateCampaignViewTest.test_create_item_campaign\n    ```\n"
  },
  {
    "path": "importer/tests/__init__.py",
    "content": ""
  },
  {
    "path": "importer/tests/test_admin.py",
    "content": "import uuid\nfrom unittest import mock\n\nfrom django.contrib import messages\nfrom django.test import RequestFactory, TestCase\nfrom django.utils import timezone\n\nfrom concordia.models import Campaign\nfrom concordia.tests.utils import create_asset, create_campaign\nfrom importer.admin import (\n    BatchFilter,\n    ImportCampaignListFilter,\n    TaskStatusModelAdmin,\n    retry_download_task,\n)\nfrom importer.models import ImportItemAsset, VerifyAssetImageJob\n\nfrom .utils import create_import_asset, create_verify_asset_image_job\n\n\n@mock.patch(\"importer.admin.download_asset_task.delay\", autospec=True)\n@mock.patch(\"importer.admin.messages.add_message\", autospec=True)\nclass ActionTests(TestCase):\n    def test_retry_download_task(self, messages_mock, task_mock):\n        import_asset1 = create_import_asset(0)\n        import_assets = [import_asset1] + [\n            create_import_asset(i, import_item=import_asset1.import_item)\n            for i in range(1, 10)\n        ]\n        import_asset_count = len(import_assets)\n        import_asset_args = [(import_asset.pk,) for import_asset in import_assets]\n        modeladmin_mock = mock.MagicMock()\n        request = RequestFactory().get(\"/\")\n\n        retry_download_task(modeladmin_mock, request, ImportItemAsset.objects.all())\n        args_list = [arg for arg, kwargs in task_mock.call_args_list]\n\n        self.assertEqual(task_mock.call_count, import_asset_count)\n        self.assertEqual(args_list, import_asset_args)\n        self.assertEqual(messages_mock.call_count, 1)\n        self.assertEqual(\n            messages_mock.call_args.args,\n            (request, messages.INFO, f\"Queued {import_asset_count} tasks\"),\n        )\n\n\nclass ImportCampaignListFilterTest(TestCase):\n    def test_lookups(self):\n        class TestImportCampaignListFilter(ImportCampaignListFilter):\n            # We need a subclass because ImportCampaignListFilter itself\n            # isn't meant to be used directly, and can't be due\n            # to not having a parameter_name configured\n            parameter_name = \"campaign\"\n\n        campaigns = [create_campaign(slug=f\"test-campaign-{i}\") for i in range(5)]\n        campaigns += [\n            create_campaign(\n                slug=\"test-campaign-completed\", status=Campaign.Status.COMPLETED\n            )\n        ]\n        retired_campaign = create_campaign(\n            slug=\"test-campaign-retired\",\n            title=\"Retired Campaign\",\n            status=Campaign.Status.RETIRED,\n        )\n\n        philter = TestImportCampaignListFilter(\n            None, {}, mock.MagicMock(), mock.MagicMock()\n        )\n        values_list = philter.lookups(mock.MagicMock(), mock.MagicMock())\n\n        self.assertEqual(len(values_list), len(campaigns))\n        for idx, title in values_list:\n            self.assertNotEqual(idx, retired_campaign.id)\n            self.assertNotIn(\"Retired\", title)\n\n\n@mock.patch(\"importer.admin.naturaltime\")\nclass TaskStatusModelAdminTest(TestCase):\n    def test_generate_natural_timestamp_display_property(self, naturaltime_mock):\n        inner = TaskStatusModelAdmin.generate_natural_timestamp_display_property(\n            \"test_field\"\n        )\n\n        obj = mock.MagicMock()\n        value = inner(obj)\n        self.assertTrue(naturaltime_mock.called)\n\n        naturaltime_mock.reset_mock()\n        obj = mock.MagicMock(spec=[\"test_field\"])\n        obj.test_field = None\n        value = inner(obj)\n        self.assertEqual(value, None)\n        self.assertFalse(naturaltime_mock.called)\n\n        naturaltime_mock.reset_mock()\n        # Passing an empty list to spec means there are no\n        # attributes on the mock, so accessing any attribute\n        # will raise an AttributeError\n        obj = mock.MagicMock(spec=[])\n        value = inner(obj)\n        self.assertEqual(value, None)\n        self.assertFalse(naturaltime_mock.called)\n\n\nclass BatchFilterTests(TestCase):\n    def setUp(self):\n        self.request = mock.MagicMock()\n        self.model_admin = mock.MagicMock()\n        self.filter = BatchFilter(\n            self.request, {}, VerifyAssetImageJob, self.model_admin\n        )\n        self.batch1 = str(uuid.uuid4())\n        self.batch2 = str(uuid.uuid4())\n        self.batch3 = str(uuid.uuid4())\n        self.batch4 = str(uuid.uuid4())\n        self.batch5 = str(uuid.uuid4())\n        self.batch6 = str(uuid.uuid4())\n\n        asset1 = create_asset()\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n        asset3 = create_asset(item=asset1.item, slug=\"test-asset-3\")\n\n        create_verify_asset_image_job(asset=asset1, batch=self.batch1, completed=None)\n        create_verify_asset_image_job(asset=asset2, batch=self.batch2, completed=None)\n        create_verify_asset_image_job(asset=asset3, batch=self.batch3, completed=None)\n        create_verify_asset_image_job(asset=asset3, batch=self.batch4, completed=None)\n        create_verify_asset_image_job(asset=asset3, batch=self.batch5, completed=None)\n        create_verify_asset_image_job(asset=asset3, batch=self.batch6, completed=None)\n\n    @mock.patch(\"importer.admin.BatchFilter.value\", return_value=None)\n    def test_lookups_incomplete_batches(self, mock_value):\n        self.model_admin.get_queryset.return_value = VerifyAssetImageJob.objects.all()\n        lookups = self.filter.lookups(self.request, self.model_admin)\n        self.assertEqual(len(lookups), 5)\n\n    @mock.patch(\"importer.admin.BatchFilter.value\", return_value=None)\n    def test_lookups_includes_current_batch(self, mock_value):\n        mock_value.return_value = self.batch2\n        self.model_admin.get_queryset.return_value = VerifyAssetImageJob.objects.all()\n        lookups = self.filter.lookups(self.request, self.model_admin)\n        batch_ids = [batch[0] for batch in lookups]\n        self.assertIn(self.batch2, batch_ids)\n\n    @mock.patch(\"importer.admin.BatchFilter.value\", return_value=None)\n    def test_lookups_includes_recent_completed_batch(self, mock_value):\n        VerifyAssetImageJob.objects.filter(batch=self.batch6).update(\n            completed=timezone.now()\n        )\n        self.model_admin.get_queryset.return_value = VerifyAssetImageJob.objects.all()\n        lookups = self.filter.lookups(self.request, self.model_admin)\n        batch_ids = [batch[0] for batch in lookups]\n        self.assertIn(self.batch6, batch_ids)\n\n    @mock.patch(\"importer.admin.BatchFilter.value\", return_value=None)\n    def test_lookups_fills_with_completed_batches(self, mock_value):\n        batch_list = [self.batch1, self.batch2, self.batch3, self.batch4, self.batch5]\n        VerifyAssetImageJob.objects.filter(batch__in=batch_list).update(\n            completed=timezone.now()\n        )\n        self.model_admin.get_queryset.return_value = VerifyAssetImageJob.objects.all()\n        lookups = self.filter.lookups(self.request, self.model_admin)\n        self.assertEqual(len(lookups), 5)\n\n    @mock.patch(\"importer.admin.BatchFilter.value\", return_value=None)\n    def test_queryset_filters_correctly(self, mock_value):\n        mock_value.return_value = self.batch1\n        queryset = self.filter.queryset(self.request, VerifyAssetImageJob.objects.all())\n        batch_ids = queryset.values_list(\"batch\", flat=True)\n        self.assertTrue(all(str(batch) == self.batch1 for batch in batch_ids))\n\n    @mock.patch(\"importer.admin.BatchFilter.value\", return_value=None)\n    def test_queryset_returns_all_when_no_batch_selected(self, mock_value):\n        mock_value.return_value = None\n        queryset = self.filter.queryset(self.request, VerifyAssetImageJob.objects.all())\n        self.assertEqual(queryset.count(), VerifyAssetImageJob.objects.count())\n"
  },
  {
    "path": "importer/tests/test_celery.py",
    "content": "import tempfile\nfrom types import SimpleNamespace\nfrom unittest import mock\n\nfrom django.test import TestCase\n\nimport importer.celery as celery_mod\nfrom importer.celery import import_all_submodules\n\n\nclass ImporterCeleryTests(TestCase):\n    def test_returns_early_for_non_package(self):\n        mock_pkg = SimpleNamespace(__name__=\"not_a_pkg\")  # no __path__\n\n        with (\n            mock.patch.object(\n                celery_mod.importlib, \"import_module\", return_value=mock_pkg\n            ) as mock_import,\n            mock.patch.object(celery_mod.pkgutil, \"walk_packages\") as mock_walk,\n        ):\n            import_all_submodules(\"not_a_pkg\")\n\n        mock_import.assert_called_once_with(\"not_a_pkg\")\n        mock_walk.assert_not_called()\n\n    def test_imports_all_submodules_for_package(self):\n        sub1 = SimpleNamespace(name=\"dummy_pkg.sub1\")\n        sub2 = SimpleNamespace(name=\"dummy_pkg.sub2\")\n\n        with tempfile.TemporaryDirectory() as td:\n            mock_pkg = SimpleNamespace(__name__=\"dummy_pkg\", __path__=[td])\n\n            with (\n                mock.patch.object(celery_mod.importlib, \"import_module\") as mock_import,\n                mock.patch.object(\n                    celery_mod.pkgutil, \"walk_packages\", return_value=[sub1, sub2]\n                ) as mock_walk,\n            ):\n\n                def side_effect(name):\n                    if name == \"dummy_pkg\":\n                        return mock_pkg\n                    return SimpleNamespace(__name__=name)\n\n                mock_import.side_effect = side_effect\n                import_all_submodules(\"dummy_pkg\")\n\n        mock_walk.assert_called_once()\n        args, _kwargs = mock_walk.call_args\n        self.assertEqual(args[0], mock_pkg.__path__)\n        self.assertEqual(args[1], mock_pkg.__name__ + \".\")\n\n        self.assertIn(mock.call(\"dummy_pkg\"), mock_import.mock_calls)\n        self.assertIn(mock.call(\"dummy_pkg.sub1\"), mock_import.mock_calls)\n        self.assertIn(mock.call(\"dummy_pkg.sub2\"), mock_import.mock_calls)\n\n    def test_package_with_no_submodules(self):\n        with tempfile.TemporaryDirectory() as td:\n            mock_pkg = SimpleNamespace(__name__=\"empty_pkg\", __path__=[td])\n\n            with (\n                mock.patch.object(celery_mod.importlib, \"import_module\") as mock_import,\n                mock.patch.object(\n                    celery_mod.pkgutil, \"walk_packages\", return_value=[]\n                ) as mock_walk,\n            ):\n\n                mock_import.side_effect = lambda name: (\n                    mock_pkg if name == \"empty_pkg\" else SimpleNamespace(__name__=name)\n                )\n                import_all_submodules(\"empty_pkg\")\n\n        mock_walk.assert_called_once()\n        mock_import.assert_called_once_with(\"empty_pkg\")\n\n    def test__load_all_task_modules_invokes_imports(self):\n        with mock.patch.object(celery_mod, \"import_all_submodules\") as mock_import_all:\n            celery_mod._load_all_task_modules(sender=celery_mod.app)\n\n        mock_import_all.assert_has_calls(\n            [\n                mock.call(\"concordia.tasks\"),\n                mock.call(\"importer.tasks\"),\n            ],\n            any_order=False,\n        )\n\n    def test_on_after_finalize_signal_triggers_handler(self):\n        with mock.patch.object(celery_mod, \"import_all_submodules\") as mock_import_all:\n            celery_mod.app.on_after_finalize.send(sender=celery_mod.app)\n\n        mock_import_all.assert_has_calls(\n            [mock.call(\"concordia.tasks\"), mock.call(\"importer.tasks\")],\n            any_order=False,\n        )\n        self.assertEqual(mock_import_all.call_count, 2)\n"
  },
  {
    "path": "importer/tests/test_models.py",
    "content": "import uuid\n\nfrom django.test import TestCase\nfrom django.urls import reverse\nfrom django.utils import timezone\n\nfrom concordia.tests.utils import CreateTestUsers, create_asset, create_project\nfrom importer.models import TaskStatusModel\n\nfrom .utils import (\n    create_download_asset_image_job,\n    create_import_asset,\n    create_import_item,\n    create_import_job,\n    create_verify_asset_image_job,\n)\n\n\nclass ImportJobTests(TestCase, CreateTestUsers):\n    def test_str(self):\n        user = self.create_test_user()\n        project = create_project()\n        url = \"http://example.com\"\n\n        job = create_import_job(project=project)\n\n        self.assertEqual(\n            str(job), f\"ImportJob(created_by=None, project={project.title}, url=)\"\n        )\n\n        job.created_by = user\n        job.url = url\n\n        self.assertEqual(\n            str(job),\n            f\"ImportJob(created_by={user.username}, \"\n            f\"project={project.title}, url={url})\",\n        )\n\n    def test_retry_if_possible(self):\n        # This method is just a placeholder for this model,\n        # so we're just testing to make sure it doesn't error\n        # and returns False, since any other value will cause issues\n        job = create_import_job()\n\n        self.assertFalse(job.retry_if_possible())\n\n    def test_update_failure_history(self):\n        job = create_import_job()\n        job.failed = timezone.now()\n        job.failure_reason = TaskStatusModel.FailureReason.IMAGE\n        job.status = \"Test failure status\"\n        job.failure_history = []\n        job.save()\n        job.update_failure_history()\n\n        failure_history = job.failure_history\n        self.assertEqual(len(failure_history), 1)\n        self.assertNotEqual(failure_history[0][\"failed\"], \"\")\n        self.assertEqual(\n            failure_history[0][\"failure_reason\"], TaskStatusModel.FailureReason.IMAGE\n        )\n        self.assertEqual(failure_history[0][\"status\"], \"Test failure status\")\n\n\nclass ImportItemTests(TestCase, CreateTestUsers):\n    def test_str(self):\n        job = create_import_job()\n        url = \"http://example.com\"\n\n        item = create_import_item(import_job=job)\n\n        self.assertEqual(str(item), f\"ImportItem(job={job}, url=)\")\n\n        item.url = url\n\n        self.assertEqual(str(item), f\"ImportItem(job={job}, url={url})\")\n\n    def test_retry_if_possible(self):\n        # This method is just a placeholder for this model,\n        # so we're just testing to make sure it doesn't error\n        # and returns False, since any other value will cause issues\n        item = create_import_item()\n\n        self.assertFalse(item.retry_if_possible())\n\n\nclass ImportItemAssetTests(TestCase, CreateTestUsers):\n    def test_str(self):\n        item = create_import_item()\n        url = \"http://example.com\"\n\n        asset = create_import_asset(import_item=item)\n\n        self.assertEqual(str(asset), f\"ImportItemAsset(import_item={item}, url=)\")\n\n        asset.url = url\n\n        self.assertEqual(str(asset), f\"ImportItemAsset(import_item={item}, url={url})\")\n\n\nclass VerifyAssetImageJobTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.batch_id = uuid.uuid4()\n        self.job = create_verify_asset_image_job(asset=self.asset, batch=self.batch_id)\n\n    def test_str_representation(self):\n        self.assertEqual(str(self.job), f\"VerifyAssetImageJob for {self.asset}\")\n\n    def test_batch_admin_url(self):\n        expected_url = (\n            reverse(\"admin:importer_verifyassetimagejob_changelist\")\n            + f\"?batch={self.batch_id}\"\n        )\n        self.assertEqual(self.job.batch_admin_url, expected_url)\n\n    def test_get_batch_admin_url(self):\n        expected_url = (\n            reverse(\"admin:importer_verifyassetimagejob_changelist\")\n            + f\"?batch={self.batch_id}\"\n        )\n        url = self.job.__class__.get_batch_admin_url(self.batch_id)\n        self.assertEqual(url, expected_url)\n\n    def test_get_batch_admin_url_error(self):\n        with self.assertRaises(ValueError):\n            self.job.__class__.get_batch_admin_url(\"\")\n\n    def test_update_failure_history(self):\n        self.job.failed = timezone.now()\n        self.job.failure_reason = \"Image\"\n        self.job.status = \"Failed due to image error\"\n        self.job.update_failure_history()\n        self.assertEqual(len(self.job.failure_history), 1)\n        self.assertEqual(self.job.failure_history[0][\"failure_reason\"], \"Image\")\n\n    def test_update_status(self):\n        self.job.update_status(\"Processing\")\n        self.assertEqual(self.job.status, \"Processing\")\n        self.assertEqual(len(self.job.status_history), 1)\n        self.assertEqual(self.job.status_history[0][\"status\"], \"\")\n\n    def test_reset_for_retry(self):\n        self.job.failed = timezone.now()\n        self.assertTrue(self.job.reset_for_retry())\n        self.assertIsNone(self.job.failed)\n        self.assertEqual(self.job.retry_count, 1)\n\n    def test_reset_for_retry_when_not_failed(self):\n        self.assertFalse(self.job.reset_for_retry())\n        self.assertEqual(\n            self.job.status,\n            \"Task was not marked as failed, so it will not be reset for retrying.\",\n        )\n\n\nclass DownloadAssetImageJobTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.batch_id = uuid.uuid4()\n        self.job = create_download_asset_image_job(\n            asset=self.asset, batch=self.batch_id\n        )\n\n    def test_str_representation(self):\n        self.assertEqual(str(self.job), f\"DownloadAssetImageJob for {self.asset}\")\n\n    def test_batch_admin_url(self):\n        expected_url = (\n            reverse(\"admin:importer_downloadassetimagejob_changelist\")\n            + f\"?batch={self.batch_id}\"\n        )\n        self.assertEqual(self.job.batch_admin_url, expected_url)\n\n    def test_get_batch_admin_url(self):\n        expected_url = (\n            reverse(\"admin:importer_downloadassetimagejob_changelist\")\n            + f\"?batch={self.batch_id}\"\n        )\n        url = self.job.__class__.get_batch_admin_url(self.batch_id)\n        self.assertEqual(url, expected_url)\n\n    def test_get_batch_admin_url_error(self):\n        with self.assertRaises(ValueError):\n            self.job.__class__.get_batch_admin_url(\"\")\n\n    def test_update_failure_history(self):\n        self.job.failed = timezone.now()\n        self.job.failure_reason = \"Image\"\n        self.job.status = \"Failed due to image error\"\n        self.job.update_failure_history()\n        self.assertEqual(len(self.job.failure_history), 1)\n        self.assertEqual(self.job.failure_history[0][\"failure_reason\"], \"Image\")\n\n    def test_update_status(self):\n        self.job.update_status(\"Processing\")\n        self.assertEqual(self.job.status, \"Processing\")\n        self.assertEqual(len(self.job.status_history), 1)\n        self.assertEqual(self.job.status_history[0][\"status\"], \"\")\n\n    def test_reset_for_retry(self):\n        self.job.failed = timezone.now()\n        self.assertTrue(self.job.reset_for_retry())\n        self.assertIsNone(self.job.failed)\n        self.assertEqual(self.job.retry_count, 1)\n\n    def test_reset_for_retry_when_not_failed(self):\n        self.assertFalse(self.job.reset_for_retry())\n        self.assertEqual(\n            self.job.status,\n            \"Task was not marked as failed, so it will not be reset for retrying.\",\n        )\n"
  },
  {
    "path": "importer/tests/test_tasks_assets.py",
    "content": "import uuid\nfrom unittest import mock\n\nimport requests\nfrom django.core.cache import caches\nfrom django.db.models import Max\nfrom django.test import TestCase, override_settings\nfrom django.utils import timezone\nfrom PIL import UnidentifiedImageError\n\nfrom concordia.models import Asset\nfrom concordia.tests.utils import create_asset\nfrom configuration.models import Configuration\nfrom importer import exceptions, tasks\nfrom importer.models import (\n    DownloadAssetImageJob,\n    ImportItemAsset,\n    TaskStatusModel,\n    VerifyAssetImageJob,\n)\n\nfrom .utils import (\n    create_download_asset_image_job,\n    create_import_asset,\n    create_verify_asset_image_job,\n)\n\n\nclass RedownloadImageTaskTests(TestCase):\n    @mock.patch(\"importer.tasks.images.download_asset\")\n    def test_redownload_image_task(self, mock_download):\n        tasks.images.redownload_image_task(create_asset().pk)\n        self.assertTrue(mock_download.called)\n\n\nclass AssetImportTests(TestCase):\n    def setUp(self):\n        for cache in caches.all():\n            cache.clear()\n\n        self.import_asset = create_import_asset(url=\"http://example.com\")\n        self.asset = self.import_asset.asset\n        self.job = create_download_asset_image_job(asset=self.asset)\n\n        # It's difficult/impossible to cleanly mock a decorator due to the way\n        # they're applied when the decorated object/function is evaluated on\n        # import, so we unfortunately have to handle the update_task_status\n        # decorator, so we need a mock object that can pass for a Celery task\n        # object so update_task_status doesn't error during the test\n        self.task_mock = mock.MagicMock()\n        self.task_mock.request.id = \"f81d4fae-7dec-11d0-a765-00a0c91e6bf6\"\n\n        self.get_return_value = [b\"chunk1\", b\"chunk2\"]\n\n        self.valid_hash = \"097c42989a9e5d9dcced7b35ec4b0486\"\n        self.invalid_hash = \"bad-hash\"\n\n        self.filename = self.asset.get_asset_image_filename()\n\n        self.head_object_mock = mock.MagicMock()\n        self.s3_client_mock = mock.MagicMock()\n        self.s3_client_mock.head_object = self.head_object_mock\n\n    def tearDown(self):\n        for cache in caches.all():\n            cache.clear()\n\n    def test_get_asset_urls_from_item_resources_empty(self):\n        self.assertEqual(tasks.items.get_asset_urls_from_item_resources([]), ([], \"\"))\n\n    def test_get_asset_urls_from_item_resources_url_only(self):\n        results = tasks.items.get_asset_urls_from_item_resources(\n            [{\"url\": \"http://example.com\"}]\n        )\n        self.assertEqual(results, ([], \"http://example.com\"))\n\n    def test_get_asset_urls_from_item_resources_valid(self):\n        results = tasks.items.get_asset_urls_from_item_resources(\n            [\n                {\n                    \"url\": \"http://example.com\",\n                    \"files\": [\n                        [\n                            {\n                                \"url\": \"http://example.com/1.jpg\",\n                                \"height\": 1,\n                                \"width\": 1,\n                                \"mimetype\": \"image/jpeg\",\n                            },\n                            {\"url\": \"http://example.com/2.jpg\"},\n                            {\n                                \"url\": \"http://example.com/3.jpg\",\n                                \"height\": 2,\n                                \"width\": 2,\n                                \"mimetype\": \"image/jpeg\",\n                            },\n                            {\n                                \"url\": \"http://example.com/4.jpg\",\n                                \"height\": 100,\n                                \"width\": 100,\n                                \"mimetype\": \"image/gif\",\n                            },\n                        ]\n                    ],\n                }\n            ]\n        )\n        self.assertEqual(results, ([\"http://example.com/3.jpg\"], \"http://example.com\"))\n\n    def test_get_asset_urls_from_item_resource_no_valid(self):\n        results = tasks.items.get_asset_urls_from_item_resources(\n            [\n                {\n                    \"url\": \"http://example.com\",\n                    \"files\": [\n                        [\n                            {\n                                \"url\": \"http://example.com/1.jpg\",\n                                \"height\": 1,\n                                \"width\": 1,\n                                \"mimetype\": \"file/pdf\",\n                            },\n                            {\"url\": \"http://example.com/2.jpg\"},\n                            {\n                                \"url\": \"http://example.com/3.jpg\",\n                                \"height\": 2,\n                                \"width\": 2,\n                                \"mimetype\": \"video/mov\",\n                            },\n                            {\n                                \"url\": \"http://example.com/4.jpg\",\n                                \"height\": 100,\n                                \"width\": 100,\n                                \"mimetype\": \"image/tiff\",\n                            },\n                        ]\n                    ],\n                }\n            ]\n        )\n        self.assertEqual(results, ([], \"http://example.com\"))\n\n    def test_get_asset_urls_from_item_resource_no_jpgs(self):\n        results = tasks.items.get_asset_urls_from_item_resources(\n            [\n                {\n                    \"url\": \"http://example.com\",\n                    \"files\": [\n                        [\n                            {\n                                \"url\": \"http://example.com/1.jpg\",\n                                \"height\": 1,\n                                \"width\": 1,\n                                \"mimetype\": \"file/pdf\",\n                            },\n                            {\"url\": \"http://example.com/2.jpg\"},\n                            {\n                                \"url\": \"http://example.com/3.gif\",\n                                \"height\": 2,\n                                \"width\": 2,\n                                \"mimetype\": \"image/gif\",\n                            },\n                            {\n                                \"url\": \"http://example.com/4.gif\",\n                                \"height\": 100,\n                                \"width\": 100,\n                                \"mimetype\": \"image/gif\",\n                            },\n                        ]\n                    ],\n                }\n            ]\n        )\n        self.assertEqual(results, ([\"http://example.com/4.gif\"], \"http://example.com\"))\n\n    def test_download_asset_task(self):\n        with mock.patch(\"importer.tasks.assets.download_asset\") as task_mock:\n            tasks.assets.download_asset_task(self.import_asset.pk)\n            self.assertTrue(task_mock.called)\n            task, called_import_asset = task_mock.call_args.args\n            self.assertTrue(called_import_asset, self.import_asset)\n\n            # Test sending a bad pk\n            task_mock.reset_mock()\n            max_pk = ImportItemAsset.objects.aggregate(Max(\"pk\"))[\"pk__max\"]\n            with self.assertRaises(ImportItemAsset.DoesNotExist):\n                tasks.assets.download_asset_task(max_pk + 1)\n            self.assertFalse(task_mock.called)\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_valid(self):\n        with (\n            mock.patch(\"importer.tasks.assets.requests.get\") as get_mock,\n            mock.patch(\"importer.tasks.assets.boto3.client\") as boto_mock,\n            mock.patch(\"importer.tasks.assets.flag_enabled\") as flag_mock,\n        ):\n            get_mock.return_value.iter_content.return_value = self.get_return_value\n            boto_mock.return_value = self.s3_client_mock\n            flag_mock.return_value = True\n            self.head_object_mock.return_value = {\"ETag\": f'\"{self.valid_hash}\"'}\n\n            tasks.assets.download_asset(self.task_mock, self.import_asset)\n\n            self.assertEqual(get_mock.call_args[0], (\"http://example.com\",))\n            self.assertTrue(get_mock.call_args[1][\"stream\"])\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_valid_checksum_fail(self):\n        with (\n            mock.patch(\"importer.tasks.assets.requests.get\") as get_mock,\n            mock.patch(\"importer.tasks.assets.boto3.client\") as boto_mock,\n            mock.patch(\"importer.tasks.assets.flag_enabled\") as flag_mock,\n        ):\n            get_mock.return_value.iter_content.return_value = self.get_return_value\n            boto_mock.return_value = self.s3_client_mock\n            flag_mock.return_value = True\n            self.head_object_mock.return_value = {\"ETag\": f'\"{self.invalid_hash}\"'}\n\n            with self.assertRaises(Exception) as assertion:\n                tasks.assets.download_asset(self.task_mock, self.import_asset)\n\n            self.assertEqual(\n                str(assertion.exception),\n                f\"ETag {self.invalid_hash} for {self.filename} did not match \"\n                f\"calculated md5 hash {self.valid_hash}\",\n            )\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_valid_checksum_fail_without_flag(self):\n        with (\n            mock.patch(\"importer.tasks.assets.requests.get\") as get_mock,\n            mock.patch(\"importer.tasks.assets.boto3.client\") as boto_mock,\n            self.assertLogs(\"importer.tasks\", level=\"WARN\") as log,\n        ):\n            get_mock.return_value.iter_content.return_value = self.get_return_value\n            boto_mock.return_value = self.s3_client_mock\n            self.head_object_mock.return_value = {\"ETag\": f'\"{self.invalid_hash}\"'}\n\n            tasks.assets.download_asset(self.task_mock, self.import_asset)\n            self.assertEqual(\n                log.output[0],\n                f\"WARNING:importer.tasks.assets:ETag ({self.invalid_hash}) for \"\n                f\"{self.filename} did not match calculated md5 hash \"\n                f\"({self.valid_hash}) but the IMPORT_IMAGE_CHECKSUM flag is disabled\",\n            )\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_invalid(self):\n        with (\n            mock.patch(\"importer.tasks.assets.requests.get\") as get_mock,\n            self.assertLogs(\"importer.tasks\", level=\"ERROR\") as log,\n        ):\n            get_mock.return_value.raise_for_status.side_effect = AttributeError\n            with self.assertRaises(exceptions.ImageImportFailure):\n                tasks.assets.download_asset(self.task_mock, self.import_asset)\n            # Since the logging includes a stacktrace, we just check the\n            # beginning of the log entry with assertIn\n            self.assertIn(\n                \"ERROR:importer.tasks.assets:\"\n                \"Unable to download http://example.com to \"\n                \"test-campaign/test-project/testitem.0123456789/1.jpg\",\n                log.output[0],\n            )\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_retry_success(self):\n        import_asset = self.import_asset\n        import_asset.failed = timezone.now()\n        import_asset.completed = None\n        import_asset.failure_reason = TaskStatusModel.FailureReason.IMAGE\n        import_asset.status = \"Test failed status\"\n        import_asset.retry_count = 0\n        import_asset.failure_history = []\n        import_asset.save()\n\n        with mock.patch(\n            \"importer.models.tasks.assets.download_asset_task\"\n        ) as task_mock:\n            response = import_asset.retry_if_possible()\n\n            self.assertNotEqual(response, False)\n            self.assertTrue(task_mock.apply_async.called)\n            self.assertEqual(len(import_asset.failure_history), 1)\n            self.assertEqual(import_asset.failed, None)\n            self.assertEqual(import_asset.retry_count, 1)\n            self.assertEqual(import_asset.failure_reason, \"\")\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_retry_maximum_exceeded(self):\n        try:\n            config = Configuration.objects.get(key=\"asset_image_import_max_retries\")\n            config.value = \"1\"\n            config.data_type = Configuration.DataType.NUMBER\n            config.save()\n        except Configuration.DoesNotExist:\n            Configuration.objects.create(\n                key=\"asset_image_import_max_retries\",\n                value=\"1\",\n                data_type=Configuration.DataType.NUMBER,\n            )\n\n        import_asset = self.import_asset\n        import_asset.failed = timezone.now()\n        import_asset.completed = None\n        import_asset.failure_reason = TaskStatusModel.FailureReason.IMAGE\n        import_asset.status = \"Test failed status\"\n        import_asset.retry_count = 1\n        import_asset.failure_history = []\n        import_asset.save()\n\n        with mock.patch(\n            \"importer.models.tasks.assets.download_asset_task\"\n        ) as task_mock:\n            response = import_asset.retry_if_possible()\n\n            self.assertFalse(response)\n            self.assertFalse(task_mock.apply_async.called)\n            self.assertEqual(len(import_asset.failure_history), 1)\n            self.assertNotEqual(import_asset.failed, None)\n            self.assertEqual(\n                import_asset.status,\n                \"Maximum number of retries reached while retrying image download \"\n                \"for asset. The failure reason before retrying was Image and the \"\n                \"status was Test failed status\",\n            )\n            self.assertEqual(import_asset.retry_count, 1)\n            self.assertEqual(\n                import_asset.failure_reason, TaskStatusModel.FailureReason.RETRIES\n            )\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_retry_cant_reset(self):\n        import_asset = self.import_asset\n        import_asset.completed = None\n        import_asset.failure_reason = TaskStatusModel.FailureReason.IMAGE\n        import_asset.status = \"Test failed status\"\n        import_asset.retry_count = 0\n        import_asset.failure_history = []\n        import_asset.save()\n\n        with mock.patch(\n            \"importer.models.tasks.assets.download_asset_task\"\n        ) as task_mock:\n            response = import_asset.retry_if_possible()\n\n            self.assertFalse(response)\n            self.assertFalse(task_mock.apply_async.called)\n            self.assertNotEqual(import_asset.status, \"Test failed status\")\n            self.assertEqual(len(import_asset.failure_history), 0)\n            self.assertEqual(import_asset.failed, None)\n            self.assertEqual(import_asset.retry_count, 0)\n            self.assertEqual(\n                import_asset.failure_reason, TaskStatusModel.FailureReason.IMAGE\n            )\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_retry_invalid_failure_reason(self):\n        import_asset = self.import_asset\n        import_asset.failed = timezone.now()\n        import_asset.completed = None\n        import_asset.failure_reason = \"\"\n        import_asset.status = \"Test failed status\"\n        import_asset.retry_count = 0\n        import_asset.failure_history = []\n        import_asset.save()\n\n        with mock.patch(\n            \"importer.models.tasks.assets.download_asset_task\"\n        ) as task_mock:\n            response = import_asset.retry_if_possible()\n\n            self.assertFalse(response)\n            self.assertFalse(task_mock.apply_async.called)\n            self.assertEqual(import_asset.status, \"Test failed status\")\n            self.assertEqual(len(import_asset.failure_history), 0)\n            self.assertNotEqual(import_asset.failed, None)\n            self.assertEqual(import_asset.retry_count, 0)\n            self.assertEqual(import_asset.failure_reason, \"\")\n\n    @override_settings(\n        STORAGES={\n            \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n            \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        },\n        AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n    )\n    def test_download_asset_manual_retry_success(self):\n        # This mimics an admin manually retrying the task, rather than\n        # the automatic retry system (such as through an admin action).\n        # We want to be sure the failure information is correctly reset.\n        import_asset = self.import_asset\n        import_asset.failed = timezone.now()\n        import_asset.completed = None\n        import_asset.failure_reason = \"\"\n        import_asset.status = \"Test failed status\"\n        import_asset.retry_count = 0\n        import_asset.failure_history = []\n        import_asset.save()\n\n        with mock.patch(\n            \"importer.models.tasks.assets.download_and_store_asset_image\"\n        ) as download_mock:\n            download_mock.return_value = \"image.jpg\"\n\n            tasks.assets.download_asset_task.delay(import_asset.pk)\n            import_asset.refresh_from_db()\n            self.assertTrue(download_mock.called)\n            self.assertEqual(import_asset.status, \"Completed\")\n            self.assertEqual(len(import_asset.failure_history), 0)\n            self.assertEqual(import_asset.failed, None)\n            self.assertEqual(import_asset.retry_count, 0)\n            self.assertEqual(import_asset.failure_reason, \"\")\n\n    @mock.patch(\"importer.tasks.assets.download_and_store_asset_image\")\n    @mock.patch(\"importer.tasks.assets.logger.info\")\n    def test_download_url_from_asset(self, mock_logger, mock_download):\n        self.asset.download_url = \"https://example.com/image.png\"\n        self.asset.save()\n        self.job.refresh_from_db()\n\n        mock_download.return_value = \"stored_image.png\"\n\n        tasks.assets.download_asset(self.task_mock, self.job)\n\n        mock_download.assert_called_once_with(self.asset.download_url, mock.ANY)\n        self.asset.refresh_from_db()\n        self.assertEqual(self.asset.storage_image, \"stored_image.png\")\n        mock_logger.assert_any_call(\n            \"Download and storage of asset image %s complete. Setting storage_image \"\n            \"on asset %s (%s)\",\n            \"stored_image.png\",\n            self.asset,\n            self.asset.id,\n        )\n\n    @mock.patch(\"importer.tasks.assets.download_and_store_asset_image\")\n    @mock.patch(\"importer.tasks.assets.logger.info\")\n    def test_valid_file_extension(self, mock_logger, mock_download):\n        self.asset.download_url = \"https://example.com/image.png\"\n        self.asset.save()\n        self.job.refresh_from_db()\n\n        mock_download.return_value = \"stored_image.png\"\n        tasks.assets.download_asset(self.task_mock, self.job)\n\n        asset_image_filename = self.asset.get_asset_image_filename(\"png\")\n        mock_download.assert_called_once_with(\n            self.asset.download_url, asset_image_filename\n        )\n\n        self.asset.refresh_from_db()\n        self.assertEqual(self.asset.storage_image, \"stored_image.png\")\n        mock_logger.assert_any_call(\n            \"Download and storage of asset image %s complete. Setting storage_image \"\n            \"on asset %s (%s)\",\n            \"stored_image.png\",\n            self.asset,\n            self.asset.id,\n        )\n\n\nclass BatchVerifyAssetImagesTaskCallbackTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.concurrency = 5\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_no_failures_detected_no_failures_in_results(self, mock_task):\n        results = [True, True, True]\n        tasks.images.batch_verify_asset_images_task_callback(\n            results, self.batch_id, self.concurrency, False\n        )\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency, False)\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_no_failures_detected_some_failures_in_results(self, mock_task):\n        results = [True, False, True]\n        with self.assertLogs(\"importer.tasks\", level=\"INFO\") as log:\n            tasks.images.batch_verify_asset_images_task_callback(\n                results, self.batch_id, self.concurrency, False\n            )\n            self.assertIn(\n                \"INFO:importer.tasks.images:At least one verification \"\n                f\"failure detected for batch {self.batch_id}\",\n                log.output,\n            )\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency, True)\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_failures_already_detected(self, mock_task):\n        results = [True, False, True]\n        tasks.images.batch_verify_asset_images_task_callback(\n            results, self.batch_id, self.concurrency, True\n        )\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency, True)\n\n\nclass BatchVerifyAssetImagesTaskTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.concurrency = 2\n        asset1 = create_asset()\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n        self.job1 = create_verify_asset_image_job(batch=self.batch_id, asset=asset1)\n        self.job2 = create_verify_asset_image_job(batch=self.batch_id, asset=asset2)\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.batch_download_asset_images_task\")\n    def test_no_jobs_remaining_with_failures(self, mock_batch_download, mock_logger):\n        VerifyAssetImageJob.objects.all().delete()\n        tasks.images.batch_verify_asset_images_task(\n            self.batch_id, self.concurrency, True\n        )\n        mock_logger.assert_any_call(\n            \"Failures in VerifyAssetImageJobs in batch %s detected, so starting \"\n            \"DownloadAssetImageJob batch\",\n            self.batch_id,\n        )\n        mock_batch_download.assert_called_once_with(self.batch_id, self.concurrency)\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    def test_no_jobs_remaining_no_failures(self, mock_logger):\n        VerifyAssetImageJob.objects.all().delete()\n        tasks.images.batch_verify_asset_images_task(\n            self.batch_id, self.concurrency, False\n        )\n        mock_logger.assert_any_call(\n            \"No failures in VerifyAssetImageJob batch %s. Ending task.\", self.batch_id\n        )\n\n    @mock.patch(\"importer.tasks.images.chord\")\n    @mock.patch(\"importer.tasks.images.verify_asset_image_task.s\")\n    def test_jobs_remaining(self, mock_task_s, mock_chord):\n        tasks.images.batch_verify_asset_images_task(\n            self.batch_id, self.concurrency, False\n        )\n        self.assertEqual(mock_task_s.call_count, 2)\n        mock_chord.assert_called()\n\n\nclass VerifyAssetImageTaskTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.batch_id = uuid.uuid4()\n\n    @mock.patch(\"importer.tasks.images.logger.exception\")\n    def test_asset_not_found(self, mock_logger):\n        with self.assertRaises(Asset.DoesNotExist):\n            tasks.images.verify_asset_image_task(999)\n        mock_logger.assert_called()\n\n    @mock.patch(\"importer.tasks.images.logger.exception\")\n    def test_verify_job_not_found(self, mock_logger):\n        with self.assertRaises(VerifyAssetImageJob.DoesNotExist):\n            tasks.images.verify_asset_image_task(\n                self.asset.pk, self.batch_id, create_job=False\n            )\n        mock_logger.assert_called()\n\n    @mock.patch(\"importer.tasks.images.verify_asset_image\")\n    def test_verify_asset_image_task_success(self, mock_verify):\n        job = create_verify_asset_image_job(asset=self.asset, batch=self.batch_id)\n        mock_verify.return_value = True\n\n        result = tasks.images.verify_asset_image_task(self.asset.pk, self.batch_id)\n        self.assertTrue(result)\n        job.refresh_from_db()\n        self.assertEqual(job.status, \"Storage image verified\")\n\n    @mock.patch(\"importer.tasks.images.verify_asset_image\")\n    def test_verify_asset_image_task_failure(self, mock_verify):\n        job = create_verify_asset_image_job(asset=self.asset, batch=self.batch_id)\n        mock_verify.return_value = False\n\n        result = tasks.images.verify_asset_image_task(self.asset.pk, self.batch_id)\n        self.assertFalse(result)\n        job.refresh_from_db()\n        self.assertNotEqual(job.status, \"Storage image verified\")\n\n    @mock.patch(\"importer.tasks.images.verify_asset_image\")\n    def test_create_verify_asset_image_job(self, mock_verify):\n        mock_verify.return_value = True\n        result = tasks.images.verify_asset_image_task(\n            self.asset.pk, self.batch_id, create_job=True\n        )\n        self.assertTrue(result)\n        self.assertTrue(\n            VerifyAssetImageJob.objects.filter(\n                asset=self.asset, batch=self.batch_id\n            ).exists()\n        )\n\n    @mock.patch(\"importer.tasks.images.verify_asset_image\")\n    def test_http_error_retries(self, mock_verify):\n        create_verify_asset_image_job(asset=self.asset, batch=self.batch_id)\n        mock_verify.side_effect = requests.exceptions.HTTPError(\"HTTP Error Occurred\")\n        with self.assertRaises(requests.exceptions.HTTPError):\n            tasks.images.verify_asset_image_task(self.asset.pk, self.batch_id)\n\n\nclass CreateDownloadAssetImageJobTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.batch_id = uuid.uuid4()\n\n    def test_create_new_job(self):\n        tasks.images.create_download_asset_image_job(self.asset, self.batch_id)\n        self.assertTrue(\n            DownloadAssetImageJob.objects.filter(\n                asset=self.asset, batch=self.batch_id\n            ).exists()\n        )\n\n    def test_existing_uncompleted_job_not_duplicated(self):\n        create_download_asset_image_job(asset=self.asset, batch=self.batch_id)\n        tasks.images.create_download_asset_image_job(self.asset, self.batch_id)\n        job_count = DownloadAssetImageJob.objects.filter(\n            asset=self.asset, batch=self.batch_id\n        ).count()\n        self.assertEqual(job_count, 1)\n\n    def test_create_new_job_if_previous_failed(self):\n        failed_job = create_download_asset_image_job(\n            asset=self.asset, batch=self.batch_id\n        )\n        failed_job.failed = timezone.now()\n        failed_job.save()\n\n        new_batch = uuid.uuid4()\n\n        tasks.images.create_download_asset_image_job(self.asset, new_batch)\n        job_count = DownloadAssetImageJob.objects.filter(asset=self.asset).count()\n        self.assertEqual(job_count, 2)\n\n\nclass VerifyAssetImageTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.job = create_verify_asset_image_job(asset=self.asset)\n        self.mock_task = mock.MagicMock()\n        self.mock_task.request.id = uuid.uuid4()\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.create_download_asset_image_job\")\n    def test_no_storage_image(self, mock_create_job, mock_logger):\n        # Use update in order to avoid the validation of storage_image, since this is\n        # an invalid value, but we have to account for it\n        Asset.objects.filter(id=self.asset.id).update(storage_image=\"\")\n        # We need to update the job from the database to get rid of the cached asset\n        self.job.refresh_from_db()\n\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertFalse(result)\n        mock_create_job.assert_called_once_with(self.asset, self.job.batch)\n        mock_logger.assert_any_call(\n            f\"No storage image set on {self.asset} ({self.asset.id})\"\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.create_download_asset_image_job\")\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.exists\", return_value=False)\n    def test_storage_image_missing(self, mock_exists, mock_create_job, mock_logger):\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertFalse(result)\n        mock_create_job.assert_called_once_with(self.asset, self.job.batch)\n        mock_logger.assert_any_call(\n            f\"Storage image for {self.asset} ({self.asset.id}) missing from storage\"\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.create_download_asset_image_job\")\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.exists\", return_value=True)\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.open\")\n    @mock.patch(\n        \"importer.tasks.images.Image.open\",\n        side_effect=UnidentifiedImageError(\"Invalid image format\"),\n    )\n    def test_storage_image_invalid(\n        self, mock_image_open, mock_open, mock_exists, mock_create_job, mock_logger\n    ):\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertFalse(result)\n        mock_create_job.assert_called_once_with(self.asset, self.job.batch)\n        mock_logger.assert_any_call(\n            f\"Storage image for {self.asset} ({self.asset.id}), \"\n            f\"{self.asset.storage_image.name}, is corrupt. The exception \"\n            \"raised was Type: UnidentifiedImageError, Message: Invalid image format\"\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.create_download_asset_image_job\")\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.exists\", return_value=True)\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.open\")\n    @mock.patch(\"importer.tasks.images.Image.open\")\n    def test_storage_image_verify_fail(\n        self, mock_image_open, mock_open, mock_exists, mock_create_job, mock_logger\n    ):\n        mock_image = mock.MagicMock()\n        mock_image.verify.side_effect = UnidentifiedImageError(\"Invalid image format\")\n        mock_image_open.return_value.__enter__.return_value = mock_image\n\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertFalse(result)\n        mock_create_job.assert_called_once_with(self.asset, self.job.batch)\n        mock_logger.assert_any_call(\n            f\"Storage image for {self.asset} ({self.asset.id}), \"\n            f\"{self.asset.storage_image.name}, is corrupt. The exception \"\n            \"raised was Type: UnidentifiedImageError, Message: Invalid image format\"\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.exists\", return_value=True)\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.open\")\n    @mock.patch(\"importer.tasks.images.Image.open\")\n    def test_storage_image_verification_success(\n        self, mock_image_open, mock_open, mock_exists, mock_logger\n    ):\n        mock_image = mock.MagicMock()\n        mock_image.verify.return_value = None\n        mock_image_open.return_value.__enter__.return_value = mock_image\n\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertTrue(result)\n        mock_logger.assert_any_call(\n            \"Storage image for %s (%s), %s, verified successfully\",\n            self.asset,\n            self.asset.id,\n            self.asset.storage_image.name,\n        )\n\n\nclass BatchDownloadAssetImagesTaskCallbackTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.concurrency = 5\n\n    @mock.patch(\"importer.tasks.images.batch_download_asset_images_task.delay\")\n    def test_callback_triggers_next_batch(self, mock_task):\n        results = [True, False, True]\n\n        tasks.images.batch_download_asset_images_task_callback(\n            results, self.batch_id, self.concurrency\n        )\n\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency)\n\n    @mock.patch(\"importer.tasks.images.batch_download_asset_images_task.delay\")\n    def test_callback_with_no_results(self, mock_task):\n        results = []\n\n        tasks.images.batch_download_asset_images_task_callback(\n            results, self.batch_id, self.concurrency\n        )\n\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency)\n\n    @mock.patch(\"importer.tasks.images.batch_download_asset_images_task.delay\")\n    def test_callback_with_all_successful_results(self, mock_task):\n        results = [True, True, True]\n\n        tasks.images.batch_download_asset_images_task_callback(\n            results, self.batch_id, self.concurrency\n        )\n\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency)\n\n\nclass BatchDownloadAssetImagesTaskTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.concurrency = 3\n        asset1 = create_asset()\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n        asset3 = create_asset(item=asset1.item, slug=\"test-asset-3\")\n        self.job1 = create_download_asset_image_job(batch=self.batch_id, asset=asset1)\n        self.job2 = create_download_asset_image_job(batch=self.batch_id, asset=asset2)\n        self.job3 = create_download_asset_image_job(batch=self.batch_id, asset=asset3)\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.chord\")\n    @mock.patch(\"importer.tasks.images.download_asset_image_task.s\")\n    def test_jobs_remaining(self, mock_task_s, mock_chord, mock_logger):\n        tasks.images.batch_download_asset_images_task(self.batch_id, self.concurrency)\n        self.assertEqual(mock_task_s.call_count, 3)\n        mock_chord.assert_called()\n        mock_logger.assert_any_call(\n            \"Processing next %s DownloadAssetImageJobs for batch %s\",\n            self.concurrency,\n            self.batch_id,\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    def test_no_jobs_remaining(self, mock_logger):\n        DownloadAssetImageJob.objects.all().delete()\n        tasks.images.batch_download_asset_images_task(self.batch_id, self.concurrency)\n        mock_logger.assert_any_call(\n            \"No DownloadAssetImageJobs found for batch %s\", self.batch_id\n        )\n\n\nclass DownloadAssetImageTaskTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.batch_id = uuid.uuid4()\n\n    @mock.patch(\"importer.tasks.images.logger.exception\")\n    def test_asset_not_found(self, mock_logger):\n        with self.assertRaises(Asset.DoesNotExist):\n            tasks.images.download_asset_image_task(999)\n        mock_logger.assert_called()\n\n    @mock.patch(\"importer.tasks.images.logger.exception\")\n    def test_download_job_not_found(self, mock_logger):\n        with self.assertRaises(DownloadAssetImageJob.DoesNotExist):\n            tasks.images.download_asset_image_task(\n                self.asset.pk, self.batch_id, create_job=False\n            )\n        mock_logger.assert_called()\n\n    @mock.patch(\"importer.tasks.images.download_asset\")\n    def test_download_asset_image_task_success(self, mock_download):\n        create_download_asset_image_job(asset=self.asset, batch=self.batch_id)\n        mock_download.return_value = \"Download successful\"\n\n        result = tasks.images.download_asset_image_task(self.asset.pk, self.batch_id)\n        self.assertEqual(result, \"Download successful\")\n\n    @mock.patch(\"importer.tasks.images.download_asset\")\n    def test_create_download_asset_image_job(self, mock_download):\n        mock_download.return_value = \"Download successful\"\n        result = tasks.images.download_asset_image_task(\n            self.asset.pk, self.batch_id, create_job=True\n        )\n        self.assertEqual(result, \"Download successful\")\n        self.assertTrue(\n            DownloadAssetImageJob.objects.filter(\n                asset=self.asset, batch=self.batch_id\n            ).exists()\n        )\n\n    @mock.patch(\"importer.tasks.images.download_asset\")\n    def test_http_error_retries(self, mock_download):\n        mock_download.side_effect = requests.exceptions.HTTPError(\"HTTP Error Occurred\")\n        with self.assertRaises(requests.exceptions.HTTPError):\n            tasks.images.download_asset_image_task(\n                self.asset.pk, self.batch_id, create_job=True\n            )\n"
  },
  {
    "path": "importer/tests/test_tasks_collections.py",
    "content": "import sys\nfrom unittest import mock\n\nimport requests\nfrom django.core.cache.backends.base import BaseCache\nfrom django.test import TestCase, override_settings\n\nfrom concordia.tests.utils import CreateTestUsers\nfrom importer import tasks\nfrom importer.tasks.collections import (\n    import_collection_task,\n    normalize_collection_url,\n)\nfrom importer.tests.utils import create_import_job\n\n\nclass MockResponse:\n    def __init__(self, original_format=\"item\"):\n        self.original_format = original_format\n\n    def json(self):\n        url = \"https://www.loc.gov/item/%s/\" % \"mss859430021\"\n        return {\n            \"results\": [\n                {\n                    \"id\": 1,\n                    \"image_url\": \"https://www.loc.gov/resource/mss85943.000212/\",\n                    \"original_format\": {self.original_format},\n                    \"url\": url,\n                },\n            ],\n            \"pagination\": {},\n        }\n\n\nclass MockCache(BaseCache):\n    def __init__(self, host, *args, **kwargs):\n        params = {}\n        super().__init__(params, **kwargs)\n\n    def get(self, key, default=None, version=None):\n        resp = MockResponse()\n        return resp\n\n\n# Ensure dotted path used in override_settings still resolves after splitting.\n# The original tests referenced \"importer.tests.test_tasks.MockCache\".\n# Point that module name at this module so the cache backend can import it.\nsys.modules.setdefault(\"importer.tests.test_tasks\", sys.modules[__name__])\n\n\nclass GetCollectionItemsTests(TestCase):\n    @mock.patch.object(requests.Session, \"get\")\n    @override_settings(\n        CACHES={\n            \"default\": {\n                \"BACKEND\": \"django.core.cache.backends.dummy.DummyCache\",\n            }\n        }\n    )\n    def test_cache_miss(self, mock_get):\n        mock_get.return_value = MockResponse()\n        mock_get.return_value.url = \"https://www.loc.gov/collections/example/\"\n        items = tasks.collections.get_collection_items(\n            \"https://www.loc.gov/collections/example/\"\n        )\n        self.assertEqual(len(items), 1)\n\n    @override_settings(\n        CACHES={\n            \"default\": {\n                \"BACKEND\": \"importer.tests.test_tasks.MockCache\",\n            }\n        }\n    )\n    def test_cache_hit(self):\n        items = tasks.collections.get_collection_items(\n            \"https://www.loc.gov/collections/example/\"\n        )\n        self.assertEqual(len(items), 1)\n\n    @mock.patch.object(requests.Session, \"get\")\n    @override_settings(\n        CACHES={\n            \"default\": {\n                \"BACKEND\": \"django.core.cache.backends.dummy.DummyCache\",\n            }\n        }\n    )\n    def test_ignored_format(self, mock_get):\n        mock_get.return_value = MockResponse(original_format=\"collection\")\n        mock_get.return_value.url = \"https://www.loc.gov/collections/example/\"\n        with self.assertLogs(\"importer.tasks\", level=\"INFO\") as log:\n            items = tasks.collections.get_collection_items(\n                \"https://www.loc.gov/collections/example/\"\n            )\n\n            self.assertEqual(\n                log.output[0],\n                \"INFO:importer.tasks.items:\"\n                \"Skipping result 1 because it contains an \"\n                \"unsupported format: {'collection'}\",\n            )\n        self.assertEqual(len(items), 0)\n\n    def test_multiple_items(self):\n        with (\n            mock.patch(\"importer.tasks.collections.cache\") as cache_mock,\n            mock.patch(\n                \"importer.tasks.collections.requests_retry_session\"\n            ) as requests_mock,\n            mock.patch(\n                \"importer.tasks.collections.get_item_info_from_result\"\n            ) as result_mock,\n        ):\n            cache_mock.get.return_value = None\n            requests_mock.return_value.get.return_value.json.return_value = {\n                \"results\": [1, 2, 3]\n            }\n            # Each time this mock is called, the next value in the list\n            # is returned\n            result_mock.side_effect = [4, 5, None]\n\n            items = tasks.collections.get_collection_items(\"http://example.com\")\n\n            self.assertEqual(items, [4, 5])\n            self.assertEqual(result_mock.call_count, 3)\n\n    def test_no_results(self):\n        with (\n            mock.patch(\"importer.tasks.collections.cache\") as cache_mock,\n            mock.patch(\n                \"importer.tasks.collections.requests_retry_session\"\n            ) as requests_mock,\n            self.assertLogs(\"importer.tasks\", level=\"ERROR\") as log,\n        ):\n            cache_mock.get.return_value = None\n            requests_mock.return_value.get.return_value.json.return_value = {}\n            items = tasks.collections.get_collection_items(\"http://example.com\")\n            self.assertEqual(items, [])\n            self.assertEqual(\n                log.output,\n                [\n                    \"ERROR:importer.tasks.collections:\"\n                    'Expected URL http://example.com to include \"results\"'\n                ],\n            )\n\n    def test_get_info_exception(self):\n        with (\n            mock.patch(\"importer.tasks.collections.cache\") as cache_mock,\n            mock.patch(\n                \"importer.tasks.collections.requests_retry_session\"\n            ) as requests_mock,\n            mock.patch(\"importer.tasks.items.get_item_info_from_result\") as result_mock,\n            self.assertLogs(\"importer.tasks\", level=\"WARNING\") as log,\n        ):\n            cache_mock.get.return_value = None\n            requests_mock.return_value.get.return_value.json.return_value = {\n                \"results\": [1]\n            }\n            result_mock.side_effect = AttributeError\n\n            items = tasks.collections.get_collection_items(\"http://example.com\")\n\n            self.assertEqual(items, [])\n            # The first log entry contains a stack trace, so we use assertIn\n            # rather than assertEqual here\n            self.assertIn(\n                \"WARNING:importer.tasks.collections:\"\n                \"Skipping result from http://example.com which did not match \"\n                \"expected format:\",\n                log.output[0],\n            )\n            self.assertEqual(\n                log.output[1],\n                \"WARNING:importer.tasks.collections:\"\n                \"No valid items found for collection url: http://example.com\",\n            )\n\n\nclass ImportCollectionTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.login_user()\n\n    @mock.patch(\"importer.tasks.collections.get_collection_items\")\n    @mock.patch(\"importer.tasks.collections.normalize_collection_url\")\n    def test_import_collection(self, mock_get, mock_normalize):\n        magic_mock = mock.MagicMock()\n        magic_mock.request = mock.MagicMock()\n        magic_mock.request.id = 1\n        import_job = create_import_job(created_by=self.user)\n        mock_get.return_value = ((None, None),)\n        import_collection_task(import_job.pk)\n        self.assertTrue(mock_get.called)\n\n    @mock.patch(\"importer.tasks.collections.create_item_import_task.delay\")\n    @mock.patch(\"importer.tasks.collections.get_collection_items\")\n    @mock.patch(\"importer.tasks.collections.normalize_collection_url\")\n    def test_import_collection_enqueues_item_tasks(\n        self, mock_normalize, mock_get, mock_delay\n    ):\n        import_job = create_import_job(created_by=self.user)\n        mock_normalize.return_value = \"https://www.loc.gov/collections/example/?fo=json\"\n        mock_get.return_value = [\n            (\"mss1\", \"https://www.loc.gov/item/mss1/\"),\n            (\"mss2\", \"https://www.loc.gov/item/mss2/\"),\n        ]\n\n        # redownload=True so we can assert the third arg is propagated\n        import_collection_task(import_job.pk, redownload=True)\n\n        self.assertEqual(mock_delay.call_count, 2)\n        self.assertEqual(\n            mock_delay.call_args_list,\n            [\n                mock.call(import_job.pk, \"https://www.loc.gov/item/mss1/\", True),\n                mock.call(import_job.pk, \"https://www.loc.gov/item/mss2/\", True),\n            ],\n        )\n\n\nclass CollectionURLNormalizationTests(TestCase):\n    def test_basic_normalization(self):\n        self.assertEqual(\n            normalize_collection_url(\n                \"https://www.loc.gov/collections/branch-rickey-papers/\"\n            ),\n            \"https://www.loc.gov/collections/branch-rickey-papers/?fo=json\",\n        )\n\n    def test_extra_querystring_parameters(self):\n        self.assertEqual(\n            normalize_collection_url(\n                \"https://www.loc.gov/collections/branch-rickey-papers/?foo=bar\"\n            ),\n            \"https://www.loc.gov/collections/branch-rickey-papers/?fo=json&foo=bar\",\n        )\n\n    def test_conflicting_querystring_parameters(self):\n        self.assertEqual(\n            normalize_collection_url(\n                \"https://www.loc.gov/collections/branch-rickey-papers/?foo=bar&fo=xml&sp=99&at=item\"  # NOQA\n            ),\n            \"https://www.loc.gov/collections/branch-rickey-papers/?fo=json&foo=bar\",\n        )\n"
  },
  {
    "path": "importer/tests/test_tasks_core.py",
    "content": "import concurrent.futures\nfrom unittest import mock\n\nfrom django.test import TestCase\n\nfrom importer.tasks import fetch_all_urls\n\n\nclass FetchAllUrlsTests(TestCase):\n    @mock.patch.object(concurrent.futures.ThreadPoolExecutor, \"map\")\n    def test_fetch_all_urls(self, mock_map):\n        output = \"https://www.loc.gov/item/mss859430021/ - Asset Count: 0\"\n        mock_map.return_value = ((output, 0),)\n        finals, totals = fetch_all_urls(\n            [\n                \"https://www.loc.gov/item/mss859430021/\",\n            ]\n        )\n        self.assertEqual(finals, [output])\n        self.assertEqual(totals, 0)\n"
  },
  {
    "path": "importer/tests/test_tasks_decorators.py",
    "content": "from unittest import mock\n\nfrom django.test import TestCase\nfrom django.utils import timezone\n\nfrom importer.exceptions import ImageImportFailure\nfrom importer.models import ImportJob, TaskStatusModel\nfrom importer.tasks.decorators import update_task_status\nfrom importer.tests.utils import create_import_job\n\n\nclass TaskDecoratorTests(TestCase):\n    def test_update_task_status(self):\n        def test_function(self, task_status_object, raise_exception=False):\n            task_status_object.test_function_ran = True\n            if raise_exception:\n                raise Exception(\"Test Exception\")\n            task_status_object.test_function_finished = True\n\n        wrapped_test_function = update_task_status(test_function)\n\n        # We create this non-mocked completed job here to use in a later test\n        # because we can't easily do this once we mock ImportJob.save\n        test_job = create_import_job(completed=timezone.now())\n\n        # We can't just mock the entire model here or use easily use a custom\n        # class because update_task_status depends on Django model internals,\n        # particularly __class__._default_manager. __class__ cannot be overriden\n        # (it points to MagicMock), Model._default_manager cannot be set directly\n        # and mocking Model.objects does not cause called on Model._default_manager\n        # to properly use the mock--it continues to use the actual Model.objects\n        with mock.patch.multiple(\n            ImportJob,\n            save=mock.MagicMock(),\n            __str__=mock.MagicMock(return_value=\"Mock Job\"),\n        ):\n            job = ImportJob()\n            wrapped_test_function(mock.MagicMock(), job)\n            self.assertTrue(hasattr(job, \"test_function_ran\"))\n            self.assertTrue(job.test_function_ran)\n            self.assertTrue(hasattr(job, \"test_function_finished\"))\n            self.assertTrue(job.test_function_finished)\n            self.assertNotEqual(job.last_started, None)\n            self.assertNotEqual(job.task_id, None)\n            self.assertTrue(job.completed)\n            self.assertTrue(job.save.called)\n\n            ImportJob.save.reset_mock()\n            job2 = ImportJob()\n            job2.status = \"Original Status\"\n            with self.assertRaisesRegex(Exception, \"Test Exception\"):\n                wrapped_test_function(mock.MagicMock(), job2, True)\n            self.assertTrue(hasattr(job2, \"test_function_ran\"))\n            self.assertTrue(job2.test_function_ran)\n            self.assertFalse(hasattr(job2, \"test_function_finished\"))\n            self.assertNotEqual(job2.last_started, None)\n            self.assertNotEqual(job2.task_id, None)\n            self.assertFalse(job2.completed)\n            self.assertTrue(job2.save.called)\n            self.assertEqual(\n                job2.status, \"Original Status\\n\\nUnhandled exception: Test Exception\"\n            )\n\n            ImportJob.save.reset_mock()\n            job3 = ImportJob()\n            job3.id = test_job.id\n            with self.assertLogs(\"importer.tasks\", level=\"WARNING\") as log:\n                wrapped_test_function(mock.MagicMock(), job3)\n                self.assertEqual(\n                    log.output,\n                    [\n                        \"WARNING:importer.tasks.decorators:Task Mock Job was \"\n                        \"already completed and will not be repeated\"\n                    ],\n                )\n            self.assertFalse(hasattr(job3, \"test_function_ran\"))\n            self.assertFalse(hasattr(job3, \"test_function_finished\"))\n            self.assertEqual(job3.last_started, None)\n            self.assertEqual(job3.task_id, None)\n            self.assertFalse(job3.completed)\n            self.assertFalse(job3.save.called)\n\n    @mock.patch.multiple(\n        ImportJob,\n        save=mock.MagicMock(),\n        __str__=mock.MagicMock(return_value=\"Mock Job\"),\n        retry_if_possible=mock.MagicMock(),\n    )\n    def test_update_task_status_retry_path_sets_last_started_and_task_id(self):\n        def test_function(self, task_status_object):\n            raise Exception(\"boom\")\n\n        wrapped = update_task_status(test_function)\n\n        job = ImportJob()\n        # Simulate Celery task self with a request.id\n        task_self = mock.MagicMock()\n        task_self.request.id = \"orig-task-id\"\n\n        # Make retry_if_possible return an object with an id, like an AsyncResult\n        retry_result = mock.MagicMock()\n        retry_result.id = \"retry-123\"\n        ImportJob.retry_if_possible.return_value = retry_result\n\n        with self.assertRaisesRegex(Exception, \"boom\"):\n            wrapped(task_self, job)\n\n        # After a retriable exception, the decorator should set these from retry_result\n        self.assertEqual(job.task_id, \"retry-123\")\n        self.assertIsNotNone(job.last_started)\n\n        # Saves: one before calling f(), one after exception handling, one after retry\n        self.assertGreaterEqual(ImportJob.save.call_count, 3)\n        ImportJob.retry_if_possible.assert_called_once_with()\n\n    @mock.patch.multiple(\n        ImportJob,\n        save=mock.MagicMock(),\n        __str__=mock.MagicMock(return_value=\"Mock Job\"),\n        retry_if_possible=mock.MagicMock(return_value=False),\n    )\n    def test_update_task_status_sets_image_failure_reason(self):\n        def test_function(self, task_status_object):\n            # Raising ImageImportFailure should set failure_reason to IMAGE.\n            raise ImageImportFailure(\"bad image\")\n\n        wrapped = update_task_status(test_function)\n\n        job = ImportJob()\n        task_self = mock.MagicMock()\n        task_self.request.id = \"task-123\"\n\n        with self.assertRaises(ImageImportFailure):\n            wrapped(task_self, job)\n\n        self.assertEqual(job.failure_reason, TaskStatusModel.FailureReason.IMAGE)\n        self.assertIsNotNone(job.failed)\n        # save() should have been called at least twice (pre & post exception path)\n        self.assertGreaterEqual(ImportJob.save.call_count, 2)\n"
  },
  {
    "path": "importer/tests/test_tasks_images.py",
    "content": "import uuid\nfrom unittest import mock\n\nimport requests\nfrom django.test import TestCase\nfrom django.utils import timezone\nfrom PIL import Image\n\nfrom concordia.models import Asset\nfrom concordia.tests.utils import (\n    create_asset,\n)\nfrom importer import tasks\nfrom importer.models import (\n    DownloadAssetImageJob,\n    VerifyAssetImageJob,\n)\nfrom importer.tasks.images import redownload_image_task\n\nfrom .utils import (\n    create_download_asset_image_job,\n    create_verify_asset_image_job,\n)\n\n\nclass RedownloadImageTaskTests(TestCase):\n    @mock.patch(\"importer.tasks.images.download_asset\")\n    def test_redownload_image_task(self, mock_download):\n        redownload_image_task(create_asset().pk)\n        self.assertTrue(mock_download.called)\n\n\nclass BatchVerifyAssetImagesTaskCallbackTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.concurrency = 5\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_no_failures_detected_no_failures_in_results(self, mock_task):\n        results = [True, True, True]\n        tasks.images.batch_verify_asset_images_task_callback(\n            results, self.batch_id, self.concurrency, False\n        )\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency, False)\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_no_failures_detected_some_failures_in_results(self, mock_task):\n        results = [True, False, True]\n        with self.assertLogs(\"importer.tasks\", level=\"INFO\") as log:\n            tasks.images.batch_verify_asset_images_task_callback(\n                results, self.batch_id, self.concurrency, False\n            )\n            self.assertIn(\n                \"INFO:importer.tasks.images:At least one verification \"\n                f\"failure detected for batch {self.batch_id}\",\n                log.output,\n            )\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency, True)\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_failures_already_detected(self, mock_task):\n        results = [True, False, True]\n        tasks.images.batch_verify_asset_images_task_callback(\n            results, self.batch_id, self.concurrency, True\n        )\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency, True)\n\n\nclass BatchVerifyAssetImagesTaskTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.concurrency = 2\n        asset1 = create_asset()\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n        self.job1 = create_verify_asset_image_job(batch=self.batch_id, asset=asset1)\n        self.job2 = create_verify_asset_image_job(batch=self.batch_id, asset=asset2)\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.batch_download_asset_images_task\")\n    def test_no_jobs_remaining_with_failures(self, mock_batch_download, mock_logger):\n        VerifyAssetImageJob.objects.all().delete()\n        tasks.images.batch_verify_asset_images_task(\n            self.batch_id, self.concurrency, True\n        )\n        mock_logger.assert_any_call(\n            \"Failures in VerifyAssetImageJobs in batch %s detected, so starting \"\n            \"DownloadAssetImageJob batch\",\n            self.batch_id,\n        )\n        mock_batch_download.assert_called_once_with(self.batch_id, self.concurrency)\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    def test_no_jobs_remaining_no_failures(self, mock_logger):\n        VerifyAssetImageJob.objects.all().delete()\n        tasks.images.batch_verify_asset_images_task(\n            self.batch_id, self.concurrency, False\n        )\n        mock_logger.assert_any_call(\n            \"No failures in VerifyAssetImageJob batch %s. Ending task.\", self.batch_id\n        )\n\n    @mock.patch(\"importer.tasks.images.chord\")\n    @mock.patch(\"importer.tasks.images.verify_asset_image_task.s\")\n    def test_jobs_remaining(self, mock_task_s, mock_chord):\n        tasks.images.batch_verify_asset_images_task(\n            self.batch_id, self.concurrency, False\n        )\n        self.assertEqual(mock_task_s.call_count, 2)\n        mock_chord.assert_called()\n\n\nclass VerifyAssetImageTaskTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.batch_id = uuid.uuid4()\n\n    @mock.patch(\"importer.tasks.images.logger.exception\")\n    def test_asset_not_found(self, mock_logger):\n        with self.assertRaises(Asset.DoesNotExist):\n            tasks.images.verify_asset_image_task(999)\n        mock_logger.assert_called()\n\n    @mock.patch(\"importer.tasks.images.logger.exception\")\n    def test_verify_job_not_found(self, mock_logger):\n        with self.assertRaises(VerifyAssetImageJob.DoesNotExist):\n            tasks.images.verify_asset_image_task(\n                self.asset.pk, self.batch_id, create_job=False\n            )\n        mock_logger.assert_called()\n\n    @mock.patch(\"importer.tasks.images.verify_asset_image\")\n    def test_verify_asset_image_task_success(self, mock_verify):\n        job = create_verify_asset_image_job(asset=self.asset, batch=self.batch_id)\n        mock_verify.return_value = True\n\n        result = tasks.images.verify_asset_image_task(self.asset.pk, self.batch_id)\n        self.assertTrue(result)\n        job.refresh_from_db()\n        self.assertEqual(job.status, \"Storage image verified\")\n\n    @mock.patch(\"importer.tasks.images.verify_asset_image\")\n    def test_verify_asset_image_task_failure(self, mock_verify):\n        job = create_verify_asset_image_job(asset=self.asset, batch=self.batch_id)\n        mock_verify.return_value = False\n\n        result = tasks.images.verify_asset_image_task(self.asset.pk, self.batch_id)\n        self.assertFalse(result)\n        job.refresh_from_db()\n        self.assertNotEqual(job.status, \"Storage image verified\")\n\n    @mock.patch(\"importer.tasks.images.verify_asset_image\")\n    def test_create_verify_asset_image_job(self, mock_verify):\n        mock_verify.return_value = True\n        result = tasks.images.verify_asset_image_task(\n            self.asset.pk, self.batch_id, create_job=True\n        )\n        self.assertTrue(result)\n        self.assertTrue(\n            VerifyAssetImageJob.objects.filter(\n                asset=self.asset, batch=self.batch_id\n            ).exists()\n        )\n\n    @mock.patch(\"importer.tasks.images.verify_asset_image\")\n    def test_http_error_retries(self, mock_verify):\n        create_verify_asset_image_job(asset=self.asset, batch=self.batch_id)\n        mock_verify.side_effect = requests.exceptions.HTTPError(\"HTTP Error Occurred\")\n        with self.assertRaises(requests.exceptions.HTTPError):\n            tasks.images.verify_asset_image_task(self.asset.pk, self.batch_id)\n\n\nclass CreateDownloadAssetImageJobTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.batch_id = uuid.uuid4()\n\n    def test_create_new_job(self):\n        tasks.images.create_download_asset_image_job(self.asset, self.batch_id)\n        self.assertTrue(\n            DownloadAssetImageJob.objects.filter(\n                asset=self.asset, batch=self.batch_id\n            ).exists()\n        )\n\n    def test_existing_uncompleted_job_not_duplicated(self):\n        create_download_asset_image_job(asset=self.asset, batch=self.batch_id)\n        tasks.images.create_download_asset_image_job(self.asset, self.batch_id)\n        job_count = DownloadAssetImageJob.objects.filter(\n            asset=self.asset, batch=self.batch_id\n        ).count()\n        self.assertEqual(job_count, 1)\n\n    def test_create_new_job_if_previous_failed(self):\n        failed_job = create_download_asset_image_job(\n            asset=self.asset, batch=self.batch_id\n        )\n        failed_job.failed = timezone.now()\n        failed_job.save()\n\n        new_batch = uuid.uuid4()\n\n        tasks.images.create_download_asset_image_job(self.asset, new_batch)\n        job_count = DownloadAssetImageJob.objects.filter(asset=self.asset).count()\n        self.assertEqual(job_count, 2)\n\n\nclass VerifyAssetImageTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.job = create_verify_asset_image_job(asset=self.asset)\n        self.mock_task = mock.MagicMock()\n        self.mock_task.request.id = uuid.uuid4()\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.create_download_asset_image_job\")\n    def test_no_storage_image(self, mock_create_job, mock_logger):\n        # Use update to avoid validation of storage_image with invalid value\n        Asset.objects.filter(id=self.asset.id).update(storage_image=\"\")\n        self.job.refresh_from_db()\n\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertFalse(result)\n        mock_create_job.assert_called_once_with(self.asset, self.job.batch)\n        mock_logger.assert_any_call(\n            f\"No storage image set on {self.asset} ({self.asset.id})\"\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.create_download_asset_image_job\")\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.exists\", return_value=False)\n    def test_storage_image_missing(self, mock_exists, mock_create_job, mock_logger):\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertFalse(result)\n        mock_create_job.assert_called_once_with(self.asset, self.job.batch)\n        mock_logger.assert_any_call(\n            f\"Storage image for {self.asset} ({self.asset.id}) missing from storage\"\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.create_download_asset_image_job\")\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.exists\", return_value=True)\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.open\")\n    @mock.patch(\n        \"importer.tasks.images.Image.open\",\n        side_effect=Image.UnidentifiedImageError(\"Invalid image format\"),\n    )\n    def test_storage_image_invalid(\n        self, mock_image_open, mock_open, mock_exists, mock_create_job, mock_logger\n    ):\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertFalse(result)\n        mock_create_job.assert_called_once_with(self.asset, self.job.batch)\n        mock_logger.assert_any_call(\n            f\"Storage image for {self.asset} ({self.asset.id}), \"\n            f\"{self.asset.storage_image.name}, is corrupt. The exception \"\n            \"raised was Type: UnidentifiedImageError, Message: Invalid image format\"\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.create_download_asset_image_job\")\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.exists\", return_value=True)\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.open\")\n    @mock.patch(\"importer.tasks.images.Image.open\")\n    def test_storage_image_verify_fail(\n        self, mock_image_open, mock_open, mock_exists, mock_create_job, mock_logger\n    ):\n        mock_image = mock.MagicMock()\n        mock_image.verify.side_effect = Image.UnidentifiedImageError(\n            \"Invalid image format\"\n        )\n        mock_image_open.return_value.__enter__.return_value = mock_image\n\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertFalse(result)\n        mock_create_job.assert_called_once_with(self.asset, self.job.batch)\n        mock_logger.assert_any_call(\n            f\"Storage image for {self.asset} ({self.asset.id}), \"\n            f\"{self.asset.storage_image.name}, is corrupt. The exception \"\n            \"raised was Type: UnidentifiedImageError, Message: Invalid image format\"\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.exists\", return_value=True)\n    @mock.patch(\"importer.tasks.images.ASSET_STORAGE.open\")\n    @mock.patch(\"importer.tasks.images.Image.open\")\n    def test_storage_image_verification_success(\n        self, mock_image_open, mock_open, mock_exists, mock_logger\n    ):\n        mock_image = mock.MagicMock()\n        mock_image.verify.return_value = None\n        mock_image_open.return_value.__enter__.return_value = mock_image\n\n        result = tasks.images.verify_asset_image(self.mock_task, self.job)\n        self.assertTrue(result)\n        mock_logger.assert_any_call(\n            \"Storage image for %s (%s), %s, verified successfully\",\n            self.asset,\n            self.asset.id,\n            self.asset.storage_image.name,\n        )\n\n\nclass BatchDownloadAssetImagesTaskCallbackTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.concurrency = 5\n\n    @mock.patch(\"importer.tasks.images.batch_download_asset_images_task.delay\")\n    def test_callback_triggers_next_batch(self, mock_task):\n        results = [True, False, True]\n\n        tasks.images.batch_download_asset_images_task_callback(\n            results, self.batch_id, self.concurrency\n        )\n\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency)\n\n    @mock.patch(\"importer.tasks.images.batch_download_asset_images_task.delay\")\n    def test_callback_with_no_results(self, mock_task):\n        results = []\n\n        tasks.images.batch_download_asset_images_task_callback(\n            results, self.batch_id, self.concurrency\n        )\n\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency)\n\n    @mock.patch(\"importer.tasks.images.batch_download_asset_images_task.delay\")\n    def test_callback_with_all_successful_results(self, mock_task):\n        results = [True, True, True]\n\n        tasks.images.batch_download_asset_images_task_callback(\n            results, self.batch_id, self.concurrency\n        )\n\n        mock_task.assert_called_once_with(self.batch_id, self.concurrency)\n\n\nclass BatchDownloadAssetImagesTaskTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.concurrency = 3\n        asset1 = create_asset()\n        asset2 = create_asset(item=asset1.item, slug=\"test-asset-2\")\n        asset3 = create_asset(item=asset1.item, slug=\"test-asset-3\")\n        self.job1 = create_download_asset_image_job(batch=self.batch_id, asset=asset1)\n        self.job2 = create_download_asset_image_job(batch=self.batch_id, asset=asset2)\n        self.job3 = create_download_asset_image_job(batch=self.batch_id, asset=asset3)\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    @mock.patch(\"importer.tasks.images.chord\")\n    @mock.patch(\"importer.tasks.images.download_asset_image_task.s\")\n    def test_jobs_remaining(self, mock_task_s, mock_chord, mock_logger):\n        tasks.images.batch_download_asset_images_task(self.batch_id, self.concurrency)\n        self.assertEqual(mock_task_s.call_count, 3)\n        mock_chord.assert_called()\n        mock_logger.assert_any_call(\n            \"Processing next %s DownloadAssetImageJobs for batch %s\",\n            self.concurrency,\n            self.batch_id,\n        )\n\n    @mock.patch(\"importer.tasks.images.logger.info\")\n    def test_no_jobs_remaining(self, mock_logger):\n        DownloadAssetImageJob.objects.all().delete()\n        tasks.images.batch_download_asset_images_task(self.batch_id, self.concurrency)\n        mock_logger.assert_any_call(\n            \"No DownloadAssetImageJobs found for batch %s\", self.batch_id\n        )\n\n\nclass DownloadAssetImageTaskTests(TestCase):\n    def setUp(self):\n        self.asset = create_asset()\n        self.batch_id = uuid.uuid4()\n\n    @mock.patch(\"importer.tasks.images.logger.exception\")\n    def test_asset_not_found(self, mock_logger):\n        with self.assertRaises(Asset.DoesNotExist):\n            tasks.images.download_asset_image_task(999)\n        mock_logger.assert_called()\n\n    @mock.patch(\"importer.tasks.images.logger.exception\")\n    def test_download_job_not_found(self, mock_logger):\n        with self.assertRaises(DownloadAssetImageJob.DoesNotExist):\n            tasks.images.download_asset_image_task(\n                self.asset.pk, self.batch_id, create_job=False\n            )\n        mock_logger.assert_called()\n\n    @mock.patch(\"importer.tasks.images.download_asset\")\n    def test_download_asset_image_task_success(self, mock_download):\n        create_download_asset_image_job(asset=self.asset, batch=self.batch_id)\n        mock_download.return_value = \"Download successful\"\n\n        result = tasks.images.download_asset_image_task(self.asset.pk, self.batch_id)\n        self.assertEqual(result, \"Download successful\")\n\n    @mock.patch(\"importer.tasks.images.download_asset\")\n    def test_create_download_asset_image_job(self, mock_download):\n        mock_download.return_value = \"Download successful\"\n        result = tasks.images.download_asset_image_task(\n            self.asset.pk, self.batch_id, create_job=True\n        )\n        self.assertEqual(result, \"Download successful\")\n        self.assertTrue(\n            DownloadAssetImageJob.objects.filter(\n                asset=self.asset, batch=self.batch_id\n            ).exists()\n        )\n\n    @mock.patch(\"importer.tasks.images.download_asset\")\n    def test_http_error_retries(self, mock_download):\n        mock_download.side_effect = requests.exceptions.HTTPError(\"HTTP Error Occurred\")\n        with self.assertRaises(requests.exceptions.HTTPError):\n            tasks.images.download_asset_image_task(\n                self.asset.pk, self.batch_id, create_job=True\n            )\n"
  },
  {
    "path": "importer/tests/test_tasks_items.py",
    "content": "import io\nimport shutil\nimport tempfile\nfrom unittest import mock\n\nimport requests\nfrom django.core.exceptions import ValidationError\nfrom django.core.files.base import ContentFile\nfrom django.core.files.storage import default_storage\nfrom django.test import TestCase, override_settings\nfrom PIL import Image\n\nfrom concordia.models import Item\nfrom concordia.tests.utils import (\n    CreateTestUsers,\n    create_asset,\n    create_item,\n    create_project,\n)\nfrom importer import tasks\nfrom importer.models import ImportItem\nfrom importer.tasks.items import (\n    _guess_extension,\n    download_and_set_item_thumbnail,\n    get_item_id_from_item_url,\n    get_item_info_from_result,\n    import_items_into_project_from_url,\n)\nfrom importer.tests.utils import (\n    create_import_item,\n    create_import_job,\n)\n\n\nclass ImportItemCountFromUrlTests(TestCase):\n    def mocked_requests_get(*args, **kwargs):\n        class MockResponse:\n            def json(self):\n                item_data = {\n                    \"resources\": [\n                        {\"files\": []},\n                    ]\n                }\n                return item_data\n\n            def raise_for_status(self):\n                pass\n\n        return MockResponse()\n\n    @mock.patch(\"requests.get\", side_effect=mocked_requests_get)\n    @override_settings(\n        CACHES={\n            \"default\": {\n                \"BACKEND\": \"django.core.cache.backends.dummy.DummyCache\",\n            }\n        }\n    )\n    def test_import_item_count_from_url(self, mock_get):\n        self.assertEqual(\n            tasks.items.import_item_count_from_url(None),\n            (\"None - Asset Count: 0\", 0),\n        )\n\n    def test_unhandled_exception_importing(self):\n        with mock.patch(\"importer.tasks.items.requests.get\") as get_mock:\n            get_mock.side_effect = AttributeError(\"Error message\")\n            self.assertEqual(\n                tasks.items.import_item_count_from_url(\"http://example.com\"),\n                (\n                    \"Unhandled exception importing http://example.com \" \"Error message\",\n                    0,\n                ),\n            )\n\n\n@override_settings(\n    STORAGES={\n        \"default\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n        \"assets\": {\"BACKEND\": \"django.core.files.storage.InMemoryStorage\"},\n    },\n    AWS_STORAGE_BUCKET_NAME=\"test-bucket\",\n)\nclass ImportItemsIntoProjectFromUrlTests(CreateTestUsers, TestCase):\n    def setUp(self):\n        self.login_user()\n        self.project = create_project()\n\n    @mock.patch(\"importer.tasks.items.create_item_import_task.delay\")\n    def test_no_match(self, mock_task):\n        with self.assertRaises(ValueError):\n            import_items_into_project_from_url(\n                None, None, \"https://www.loc.gov/resource/mss859430021/\"\n            )\n        self.assertFalse(mock_task.called)\n\n    @mock.patch(\"importer.tasks.items.create_item_import_task.delay\")\n    def test_item(self, mock_task):\n        import_job = import_items_into_project_from_url(\n            self.user, self.project, \"https://www.loc.gov/item/mss859430021/\"\n        )\n        self.assertEqual(import_job.project, self.project)\n        self.assertTrue(mock_task.called)\n\n    @mock.patch(\"importer.tasks.collections.import_collection_task.delay\")\n    def test_other_url_type(self, mock_task):\n        import_job = import_items_into_project_from_url(\n            self.user,\n            self.project,\n            \"https://www.loc.gov/collections/branch-rickey-papers/\",\n        )\n        self.assertEqual(import_job.project, self.project)\n        self.assertTrue(mock_task.called)\n        mock_task.assert_called_with(import_job.pk, False)\n\n\nclass GetItemIdFromItemURLTests(TestCase):\n    def test_get_item_id_from_item_url_with_slash(self):\n        \"\"\"\n        Testing get item id from item url if ends with /\n        \"\"\"\n        url = \"https://www.loc.gov/item/mss859430021/\"\n        resp = get_item_id_from_item_url(url)\n        self.assertEqual(resp, \"mss859430021\")\n\n    def test_get_item_id_from_item_url_without_slash(self):\n        \"\"\"\n        Testing get item id from item url if ends without /\n        \"\"\"\n        url = \"https://www.loc.gov/item/mss859430021\"\n        resp = get_item_id_from_item_url(url)\n        self.assertEqual(resp, \"mss859430021\")\n\n\nclass GetItemInfoFromResultTests(TestCase):\n    def test_no_image_url(self):\n        item_info = get_item_info_from_result(\n            {\n                \"id\": 1,\n                \"image_url\": False,\n                \"original_format\": {\"item\"},\n            }\n        )\n        self.assertEqual(item_info, None)\n\n    def test_no_match(self):\n        item_info = get_item_info_from_result(\n            {\n                \"id\": 1,\n                \"image_url\": \"https://www.loc.gov/resource/mss85943.000212/\",\n                \"original_format\": {\"item\"},\n                \"url\": \"https://www.loc.com/item/mss859430021/\",\n            },\n        )\n        self.assertEqual(item_info, None)\n\n    def test_match(self):\n        url = \"https://www.loc.gov/item/%s/\" % \"mss859430021\"\n        item_info = get_item_info_from_result(\n            {\n                \"id\": 1,\n                \"image_url\": \"https://www.loc.gov/resource/mss85943.000212/\",\n                \"original_format\": {\"item\"},\n                \"url\": url,\n            },\n        )\n        self.assertEqual(item_info[0], \"mss859430021\")\n        self.assertEqual(item_info[1], url)\n\n    def test_ignored_format(self):\n        result = {\n            \"id\": 42,\n            \"image_url\": \"https://www.loc.gov/resource/foo/\",\n            \"original_format\": {\"collection\"},\n            \"url\": \"https://www.loc.gov/item/abc123/\",\n        }\n        with self.assertLogs(\"importer.tasks\", level=\"INFO\") as log:\n            out = get_item_info_from_result(result)\n        self.assertIsNone(out)\n        self.assertEqual(\n            log.output[0],\n            \"INFO:importer.tasks.items:Skipping result 42 because it contains an \"\n            \"unsupported format: {'collection'}\",\n        )\n\n\n@mock.patch(\"importer.tasks.items.requests.get\")\nclass CreateItemImportTaskTests(TestCase):\n    def setUp(self):\n        self.job = create_import_job()\n        self.item_url = \"http://example.com\"\n        self.response_mock = mock.MagicMock()\n        self.item_id = \"testid1\"\n        self.item_title = \"Test Title\"\n        self.image_url = []\n        self.item_data = {\n            \"item\": {\n                \"id\": self.item_id,\n                \"title\": self.item_title,\n                \"image_url\": self.image_url,\n            }\n        }\n\n    def test_create_item_import_task_http_error(self, get_mock):\n        get_mock.return_value = self.response_mock\n        self.response_mock.raise_for_status.side_effect = requests.exceptions.HTTPError\n\n        with self.assertRaises(requests.exceptions.HTTPError):\n            tasks.items.create_item_import_task(self.job.pk, self.item_url)\n\n    def test_create_item_import_task_new_item(self, get_mock):\n        get_mock.return_value = self.response_mock\n        self.response_mock.json.return_value = self.item_data\n\n        with (\n            mock.patch(\"importer.tasks.items.import_item_task.delay\") as task_mock,\n            mock.patch(\"importer.tasks.items.download_and_set_item_thumbnail\"),\n        ):\n            tasks.items.create_item_import_task(self.job.pk, self.item_url)\n            self.assertTrue(task_mock.called)\n            self.assertEqual(Item.objects.count(), 1)\n            self.assertTrue(Item.objects.filter(item_id=self.item_id).exists())\n\n    def test_create_item_import_task_existing_item_missing_assets(self, get_mock):\n        item = create_item(item_id=\"testid1\", project=self.job.project)\n        get_mock.return_value = self.response_mock\n        self.response_mock.json.return_value = self.item_data\n\n        with (\n            self.assertLogs(\"importer.tasks\", level=\"WARNING\") as log,\n            mock.patch(\n                \"importer.tasks.items.get_asset_urls_from_item_resources\"\n            ) as asset_url_mock,\n            mock.patch(\"importer.tasks.items.import_item_task.delay\") as task_mock,\n            mock.patch(\"importer.tasks.items.download_and_set_item_thumbnail\"),\n        ):\n            asset_url_mock.return_value = [\n                [\"http://example.com/test.jpg\"],\n                self.item_url,\n            ]\n            tasks.items.create_item_import_task(self.job.pk, self.item_url)\n            self.assertEqual(\n                log.output,\n                [\n                    f\"WARNING:importer.tasks.items:\"\n                    f\"Reprocessing existing item {item} that is missing assets\"\n                ],\n            )\n            self.assertEqual(Item.objects.count(), 1)\n            self.assertTrue(task_mock.called)\n\n    def test_create_item_import_task_existing_item_no_missing_assets(self, get_mock):\n        item = create_item(item_id=\"testid1\", project=self.job.project)\n        # Ensure at least one asset exists for the item\n        create_asset(item=item)\n        get_mock.return_value = self.response_mock\n        self.response_mock.json.return_value = self.item_data\n\n        with (\n            self.assertLogs(\"importer.tasks\", level=\"WARNING\") as log,\n            mock.patch(\n                \"importer.tasks.items.get_asset_urls_from_item_resources\"\n            ) as asset_url_mock,\n            mock.patch(\"importer.tasks.items.import_item_task.delay\") as task_mock,\n            mock.patch(\"importer.tasks.items.download_and_set_item_thumbnail\"),\n        ):\n            asset_url_mock.return_value = [\n                [\"http://example.com/test.jpg\"],\n                self.item_url,\n            ]\n            tasks.items.create_item_import_task(self.job.pk, self.item_url)\n\n            self.assertEqual(\n                log.output,\n                [\n                    f\"WARNING:importer.tasks.items:\"\n                    f\"Not reprocessing existing item with all assets: {item}\"\n                ],\n            )\n            self.assertEqual(\n                ImportItem.objects.get(item=item).status,\n                f\"Not reprocessing existing item with all assets: {item}\",\n            )\n            self.assertFalse(task_mock.called)\n\n    def test_create_item_import_task_existing_item_redownload(self, get_mock):\n        item = create_item(item_id=\"testid1\", project=self.job.project)\n        create_asset(item=item)\n        get_mock.return_value = self.response_mock\n        self.response_mock.json.return_value = {\n            \"item\": {\"id\": \"testid1\", \"title\": \"Test Title\", \"image_url\": []}\n        }\n\n        with (\n            mock.patch(\n                \"importer.tasks.items.get_asset_urls_from_item_resources\"\n            ) as asset_url_mock,\n            mock.patch(\"importer.tasks.items.import_item_task.delay\") as task_mock,\n            mock.patch(\"importer.tasks.items.download_and_set_item_thumbnail\"),\n        ):\n            asset_url_mock.return_value = [\n                [\"http://example.com/test.jpg\"],\n                self.item_url,\n            ]\n            tasks.items.create_item_import_task(\n                self.job.pk, self.item_url, redownload=True\n            )\n            self.assertTrue(task_mock.called)\n\n    def test_create_item_import_task_full_clean_exception_updates_status_and_reraises(\n        self, get_mock\n    ):\n        get_mock.return_value = self.response_mock\n        self.response_mock.json.return_value = self.item_data\n\n        with (\n            self.assertLogs(\"importer.tasks\", level=\"ERROR\") as log,\n            mock.patch(\"importer.tasks.items.Item.full_clean\") as full_clean_mock,\n            mock.patch(\"importer.tasks.items.import_item_task.delay\") as task_mock,\n            mock.patch(\n                \"importer.tasks.items.download_and_set_item_thumbnail\"\n            ) as thumb_mock,\n        ):\n            full_clean_mock.side_effect = RuntimeError(\"boom\")\n            with self.assertRaises(RuntimeError):\n                tasks.items.create_item_import_task(self.job.pk, self.item_url)\n\n            self.assertTrue(\n                any(\"Unhandled exception when importing item\" in m for m in log.output)\n            )\n            thumb_mock.assert_not_called()\n            task_mock.assert_not_called()\n\n        item = Item.objects.get(item_id=self.item_id)\n        import_item = ImportItem.objects.get(item=item)\n        self.assertIsNotNone(import_item.failed)\n        self.assertIn(\"Unhandled exception: boom\", import_item.status)\n\n    def test_create_item_import_task_save_exception_updates_status_and_reraises(\n        self, get_mock\n    ):\n        get_mock.return_value = self.response_mock\n        self.response_mock.json.return_value = self.item_data\n\n        # Grab the real save before patching so we can wrap it.\n        from importer.tasks.items import Item as _Item\n\n        real_save = _Item.save\n        call_count = {\"n\": 0}\n\n        def save_side_effect(self, *args, **kwargs):\n            call_count[\"n\"] += 1\n            # First call is from Item.objects.get_or_create(...) -> allow it to\n            # persist.\n            if call_count[\"n\"] == 1:\n                return real_save(self, *args, **kwargs)\n            # Second call is the one under test -> raise.\n            raise RuntimeError(\"save failed\")\n\n        with (\n            self.assertLogs(\"importer.tasks\", level=\"ERROR\") as log,\n            mock.patch(\"importer.tasks.items.Item.full_clean\") as full_clean_mock,\n            mock.patch(\n                \"importer.tasks.items.Item.save\",\n                side_effect=save_side_effect,\n                autospec=True,\n            ),\n            mock.patch(\"importer.tasks.items.import_item_task.delay\") as task_mock,\n            mock.patch(\n                \"importer.tasks.items.download_and_set_item_thumbnail\"\n            ) as thumb_mock,\n        ):\n            # Ensure full_clean does not fail so we reach save().\n            full_clean_mock.return_value = None\n\n            with self.assertRaises(RuntimeError):\n                tasks.items.create_item_import_task(self.job.pk, self.item_url)\n\n            self.assertTrue(\n                any(\"Unhandled exception when importing item\" in m for m in log.output)\n            )\n            thumb_mock.assert_not_called()\n            task_mock.assert_not_called()\n\n        item = Item.objects.get(item_id=self.item_id)\n        import_item = ImportItem.objects.get(item=item)\n        self.assertIsNotNone(import_item.failed)\n        self.assertIn(\"Unhandled exception: save failed\", import_item.status)\n\n\nclass ItemImportTests(TestCase):\n    def setUp(self):\n        self.item_url = \"http://example.com\"\n        self.job = create_import_job()\n        self.import_item = create_import_item(import_job=self.job, url=self.item_url)\n\n    def test_import_item_task(self):\n        with mock.patch(\"importer.tasks.items.import_item\") as task_mock:\n            tasks.items.import_item_task(self.import_item.pk)\n            self.assertTrue(task_mock.called)\n            task, called_import_item = task_mock.call_args.args\n            self.assertTrue(called_import_item, self.import_item)\n\n    def test_import_item(self):\n        with (\n            mock.patch(\n                \"importer.tasks.items.get_asset_urls_from_item_resources\"\n            ) as asset_url_mock,\n            mock.patch(\"importer.tasks.assets.download_asset_task.s\") as download_mock,\n            mock.patch(\"importer.tasks.items.group\") as group_mock,\n        ):\n            # It's difficult/impossible to cleanly mock a decorator due to the way\n            # they're applied when the decorated object/function is evaluated on\n            # import, so we unfortunately have to handle the update_task_status\n            # decorator, so we need a mock object that can pass for a Celery task\n            # object so update_task_status doesn't error during the test\n            task_mock = mock.MagicMock()\n            task_mock.request.id = \"f81d4fae-7dec-11d0-a765-00a0c91e6bf6\"\n\n            asset_url_mock.return_value = [\n                [\"http://example.com/test.jpg\"],\n                self.item_url,\n            ]\n\n            tasks.items.import_item(task_mock, self.import_item)\n            self.assertFalse(download_mock.called)\n            self.assertTrue(group_mock.called)\n\n            # Test that it properly errors if we try to import the same item again\n            self.import_item.completed = None\n            self.import_item.save()\n            with self.assertRaises(ValidationError):\n                tasks.items.import_item(task_mock, self.import_item)\n\n            asset_url_mock.return_value = [\n                [],\n                \"\",\n            ]\n\n            self.import_item.completed = None\n            self.import_item.save()\n            tasks.items.import_item(task_mock, self.import_item)\n            self.assertFalse(download_mock.called)\n            self.assertTrue(group_mock.called)\n\n    def test_populate_item_from_data(self):\n        item = Item(item_url=\"http://example.com\")\n        item_info = {\n            \"title\": \"Test Title\",\n            \"description\": \"Test description\",\n            \"image_url\": [\"image.gif\", \"image.jpg\", \"image2.jpg\"],\n        }\n\n        tasks.items.populate_item_from_data(item, item_info)\n\n        self.assertEqual(item.item_url, \"http://example.com\")\n        self.assertEqual(item.title, \"Test Title\")\n        self.assertEqual(item.description, \"Test description\")\n        self.assertEqual(item.thumbnail_url, \"http://example.com/image.jpg\")\n\n    def test_populate_item_from_data_handles_exception_and_returns_none(self):\n        # Proxy dict that explodes only when .get(\"image_url\") is called,\n        # but still works with indexing for the earlier code path.\n        class ExplodingImageInfo(dict):\n            def get(self, key, default=None):\n                if key == \"image_url\":\n                    raise RuntimeError(\"error\")\n                return super().get(key, default)\n\n        item = Item(item_url=\"http://example.com\")\n        info = ExplodingImageInfo(\n            {\n                \"title\": \"T\",\n                \"description\": \"D\",\n                \"image_url\": [\"image.jpg\"],  # used by the earlier indexing path\n            }\n        )\n\n        result = tasks.items.populate_item_from_data(item, info)\n        # Early indexing still sets thumbnail_url, but the try/except branch\n        # should swallow the error and return None.\n        self.assertIsNone(result)\n        self.assertEqual(item.thumbnail_url, \"http://example.com/image.jpg\")\n\n\n@override_settings(DEFAULT_FILE_STORAGE=\"django.core.files.storage.FileSystemStorage\")\nclass DownloadItemThumbnailTests(TestCase):\n    class FakeResponse:\n        \"\"\"Minimal streamable response for mocking requests.get(...).\"\"\"\n\n        def __init__(self, content, content_type=\"image/png\", on_iter=None):\n            self.headers = {\"Content-Type\": content_type} if content_type else {}\n            self._content = content\n            self._on_iter = on_iter\n            self._iter_called = False\n\n        def __enter__(self):\n            return self\n\n        def __exit__(self, exc_type, exc, tb):\n            return False\n\n        def raise_for_status(self):\n            return\n\n        def iter_content(self, chunk_size=64 * 1024):\n            if self._on_iter and not self._iter_called:\n                self._on_iter()\n                self._iter_called = True\n            yield self._content\n\n    def setUp(self):\n        self.temp_media = tempfile.mkdtemp(prefix=\"test-media-\")\n        self._override = override_settings(MEDIA_ROOT=self.temp_media)\n        self._override.enable()\n\n    def tearDown(self):\n        self._override.disable()\n        shutil.rmtree(self.temp_media, ignore_errors=True)\n\n    def make_image_bytes(self, fmt=\"PNG\", size=(2, 2), color=(1, 2, 3)):\n        buf = io.BytesIO()\n        img = Image.new(\"RGB\", size, color)\n        img.save(buf, format=fmt)\n        return buf.getvalue()\n\n    def test_skip_when_already_present_and_not_force(self):\n        item = create_item()\n        # Seed an existing thumbnail\n        item.thumbnail_image.save(\"existing.jpg\", ContentFile(b\"old\"), save=True)\n        with mock.patch(\"importer.tasks.items.requests.get\") as get_mock:\n            msg = download_and_set_item_thumbnail(item, \"https://example.com/test.jpg\")\n        self.assertIn(\"skipping\", msg.lower())\n        self.assertFalse(get_mock.called)\n        item.refresh_from_db()\n        self.assertTrue(item.thumbnail_image.name.endswith(\"existing.jpg\"))\n        self.assertTrue(default_storage.exists(item.thumbnail_image.name))\n\n    def test_success_with_content_type_extension(self):\n        item = create_item()\n        payload = self.make_image_bytes(fmt=\"PNG\")\n        url = \"https://example.com/path/name.png\"\n        with mock.patch(\n            \"importer.tasks.items.requests.get\",\n            return_value=type(self).FakeResponse(payload, \"image/png\"),\n        ):\n            saved = download_and_set_item_thumbnail(item, url)\n        item.refresh_from_db()\n        self.assertEqual(saved, item.thumbnail_image.name)\n        self.assertTrue(saved.endswith(\".png\"))\n        self.assertTrue(default_storage.exists(saved))\n        with default_storage.open(saved, \"rb\") as fh:\n            self.assertEqual(fh.read(), payload)\n\n    def test_fallback_extension_via_pillow_sniff_when_guess_is_bin(self):\n        item = create_item()\n        payload = self.make_image_bytes(fmt=\"PNG\")\n        url = \"https://example.com/noext\"  # no extension to force sniff path\n        with (\n            mock.patch(\"importer.tasks.items._guess_extension\", return_value=\".bin\"),\n            mock.patch(\n                \"importer.tasks.items.requests.get\",\n                return_value=type(self).FakeResponse(payload, content_type=\"\"),\n            ),\n        ):\n            saved = download_and_set_item_thumbnail(item, url)\n        item.refresh_from_db()\n        self.assertEqual(saved, item.thumbnail_image.name)\n        # Pillow sniff sees PNG, so .png via the mapping in the function\n        self.assertTrue(saved.endswith(\".png\"))\n        self.assertTrue(default_storage.exists(saved))\n\n    def test_invalid_image_raises_value_error(self):\n        item = create_item()\n        bad_bytes = b\"not-an-image\"\n        with mock.patch(\n            \"importer.tasks.items.requests.get\",\n            return_value=type(self).FakeResponse(bad_bytes, \"application/octet-stream\"),\n        ):\n            with self.assertRaises(ValueError):\n                download_and_set_item_thumbnail(item, \"https://example.com/notimg\")\n        item.refresh_from_db()\n        self.assertFalse(bool(item.thumbnail_image))\n\n    def test_requests_exception_propagates(self):\n        item = create_item()\n        with mock.patch(\n            \"importer.tasks.items.requests.get\",\n            side_effect=requests.RequestException(\"error\"),\n        ):\n            with self.assertRaises(requests.RequestException):\n                download_and_set_item_thumbnail(item, \"https://example.com/error\")\n\n    def test_race_present_after_download_skips_final_save(self):\n        \"\"\"Simulate another writer saving the thumbnail mid-download.\"\"\"\n        item = create_item()\n\n        def _concurrent_writer():\n            # Another process writes a thumbnail before the second transaction.\n            item.refresh_from_db()\n            item.thumbnail_image.save(\"pre.jpg\", ContentFile(b\"pre\"), save=True)\n\n        payload = self.make_image_bytes(fmt=\"PNG\")\n        with mock.patch(\n            \"importer.tasks.items.requests.get\",\n            return_value=type(self).FakeResponse(\n                payload, \"image/png\", on_iter=_concurrent_writer\n            ),\n        ):\n            msg = download_and_set_item_thumbnail(item, \"https://example.com/new.png\")\n        self.assertIn(\"skipping save\", msg.lower())\n        item.refresh_from_db()\n        self.assertTrue(item.thumbnail_image.name.endswith(\"pre.jpg\"))\n        self.assertTrue(default_storage.exists(item.thumbnail_image.name))\n\n    def test_force_overwrite_path_runs_and_sets_thumbnail(self):\n        item = create_item()\n        # Seed an existing thumbnail\n        item.thumbnail_image.save(\"existing.jpg\", ContentFile(b\"old\"), save=True)\n        payload = self.make_image_bytes(fmt=\"PNG\")\n        with mock.patch(\n            \"importer.tasks.items.requests.get\",\n            return_value=type(self).FakeResponse(payload, \"image/png\"),\n        ):\n            saved = download_and_set_item_thumbnail(\n                item, \"https://example.com/new.png\", force=True\n            )\n        item.refresh_from_db()\n        self.assertEqual(saved, item.thumbnail_image.name)\n        self.assertTrue(saved.endswith(\".png\"))\n        self.assertTrue(default_storage.exists(saved))\n\n    def test_stream_with_empty_chunk_is_skipped(self):\n        item = create_item()\n        payload = self.make_image_bytes(fmt=\"PNG\")\n        url = \"https://example.com/streamed.png\"\n\n        class TwoChunkResponse:\n            def __init__(self, content, content_type=\"image/png\"):\n                self.headers = {\"Content-Type\": content_type}\n                self._chunks = [b\"\", content]  # first empty, then real data\n\n            def __enter__(self):\n                return self\n\n            def __exit__(self, exc_type, exc, tb):\n                return False\n\n            def raise_for_status(self):\n                return\n\n            def iter_content(self, chunk_size=64 * 1024):\n                for c in self._chunks:\n                    yield c\n\n        with mock.patch(\n            \"importer.tasks.items.requests.get\",\n            return_value=TwoChunkResponse(payload, \"image/png\"),\n        ):\n            saved = download_and_set_item_thumbnail(item, url)\n\n        item.refresh_from_db()\n        self.assertEqual(saved, item.thumbnail_image.name)\n        self.assertTrue(saved.endswith(\".png\"))\n        with default_storage.open(saved, \"rb\") as fh:\n            self.assertEqual(fh.read(), payload)\n\n    def test_guess_extension_uses_url_path_extension_lowercases(self):\n        self.assertEqual(\n            _guess_extension(\"\", \"/path/TO/NAME.JPG\"),\n            \".jpg\",\n        )\n\n    def test_guess_extension_returns_bin_when_no_ext_and_no_content_type(self):\n        self.assertEqual(\n            _guess_extension(\"\", \"/noext\"),\n            \".bin\",\n        )\n\n    @mock.patch(\"importer.tasks.items.mimetypes.guess_extension\", return_value=None)\n    def test_header_guess_none_uses_url_extension(self, _guess):\n        item = create_item()\n        payload = self.make_image_bytes(fmt=\"JPEG\")\n        # Upper-case extension to assert lower-casing behavior\n        url = \"https://example.com/path/name.JPEG\"\n        with mock.patch(\n            \"importer.tasks.items.requests.get\",\n            return_value=type(self).FakeResponse(payload, \"image/unknown\"),\n        ):\n            saved = download_and_set_item_thumbnail(item, url)\n        item.refresh_from_db()\n        self.assertTrue(saved.endswith(\".jpeg\"))\n\n\nclass GetAssetUrlsFromItemResourcesTests(TestCase):\n    def test_empty_resources(self):\n        assets, resource_url = tasks.items.get_asset_urls_from_item_resources([])\n        self.assertEqual(assets, [])\n        self.assertEqual(resource_url, \"\")\n\n    def test_missing_item_resource_url_key(self):\n        resources = [\n            {\n                # 'url' intentionally omitted to hit KeyError path\n                \"files\": [\n                    [\n                        {\n                            \"url\": \"http://example.com/ok.jpg\",\n                            \"height\": 2,\n                            \"width\": 2,\n                            \"mimetype\": \"image/jpeg\",\n                        },\n                        {\"url\": \"http://example.com/missing_dims.jpg\"},  # skipped\n                    ]\n                ],\n            }\n        ]\n        assets, resource_url = tasks.items.get_asset_urls_from_item_resources(resources)\n        self.assertEqual(resource_url, \"\")\n        self.assertEqual(assets, [\"http://example.com/ok.jpg\"])\n\n    def test_files_key_missing(self):\n        resources = [{\"url\": \"http://example.com\"}]  # no 'files' key\n        assets, resource_url = tasks.items.get_asset_urls_from_item_resources(resources)\n        self.assertEqual(assets, [])\n        self.assertEqual(resource_url, \"http://example.com\")\n\n    def test_picks_largest_jpeg_when_present(self):\n        resources = [\n            {\n                \"url\": \"http://example.com\",\n                \"files\": [\n                    [\n                        {\n                            \"url\": \"http://example.com/small.jpg\",\n                            \"height\": 1,\n                            \"width\": 1,\n                            \"mimetype\": \"image/jpeg\",\n                        },\n                        {\n                            \"url\": \"http://example.com/large.jpg\",\n                            \"height\": 3,\n                            \"width\": 3,\n                            \"mimetype\": \"image/jpeg\",\n                        },\n                    ]\n                ],\n            }\n        ]\n        assets, resource_url = tasks.items.get_asset_urls_from_item_resources(resources)\n        self.assertEqual(resource_url, \"http://example.com\")\n        self.assertEqual(assets, [\"http://example.com/large.jpg\"])\n\n    def test_falls_back_to_largest_gif_when_no_jpeg(self):\n        resources = [\n            {\n                \"url\": \"http://example.com\",\n                \"files\": [\n                    [\n                        {\n                            \"url\": \"http://example.com/small.gif\",\n                            \"height\": 2,\n                            \"width\": 2,\n                            \"mimetype\": \"image/gif\",\n                        },\n                        {\n                            \"url\": \"http://example.com/large.gif\",\n                            \"height\": 5,\n                            \"width\": 5,\n                            \"mimetype\": \"image/gif\",\n                        },\n                        # unacceptable types are ignored\n                        {\n                            \"url\": \"http://example.com/file.tif\",\n                            \"height\": 100,\n                            \"width\": 100,\n                            \"mimetype\": \"image/tiff\",\n                        },\n                    ]\n                ],\n            }\n        ]\n        assets, resource_url = tasks.items.get_asset_urls_from_item_resources(resources)\n        self.assertEqual(resource_url, \"http://example.com\")\n        self.assertEqual(assets, [\"http://example.com/large.gif\"])\n\n    def test_variants_missing_required_keys_are_ignored(self):\n        resources = [\n            {\n                \"url\": \"http://example.com\",\n                \"files\": [\n                    [\n                        {\"url\": \"http://example.com/nw.jpg\", \"height\": 2},  # no width\n                        {\"height\": 2, \"width\": 2, \"mimetype\": \"image/jpeg\"},  # no url\n                        {\n                            \"url\": \"http://example.com/valid.jpg\",\n                            \"height\": 2,\n                            \"width\": 3,\n                            \"mimetype\": \"image/jpeg\",\n                        },\n                    ]\n                ],\n            }\n        ]\n        assets, resource_url = tasks.items.get_asset_urls_from_item_resources(resources)\n        self.assertEqual(resource_url, \"http://example.com\")\n        self.assertEqual(assets, [\"http://example.com/valid.jpg\"])\n\n    def test_no_candidates_or_backups_skips_appending(self):\n        resources = [\n            {\n                \"url\": \"http://example.com\",\n                \"files\": [\n                    [\n                        {\n                            \"url\": \"http://example.com/file1.tif\",\n                            \"height\": 10,\n                            \"width\": 10,\n                            \"mimetype\": \"image/tiff\",  # unsupported\n                        },\n                        {\n                            \"url\": \"http://example.com/file2\",\n                            \"height\": 5,\n                            \"width\": 5,\n                            # no mimetype -> not added to candidates/backups\n                        },\n                    ]\n                ],\n            }\n        ]\n        assets, resource_url = tasks.items.get_asset_urls_from_item_resources(resources)\n        self.assertEqual(resource_url, \"http://example.com\")\n        self.assertEqual(assets, [])\n"
  },
  {
    "path": "importer/tests/test_utils.py",
    "content": "import uuid\nfrom unittest import mock\n\nfrom django.test import TestCase\n\nfrom concordia.tests.utils import create_asset\nfrom importer.models import VerifyAssetImageJob\nfrom importer.utils import create_verify_asset_image_job_batch\nfrom importer.utils.excel import clean_cell_value, slurp_excel\n\n\nclass CreateVerifyAssetImageJobBatchTests(TestCase):\n    def setUp(self):\n        self.batch_id = uuid.uuid4()\n        self.asset = create_asset()\n        self.assets = [self.asset] + [\n            create_asset(item=self.asset.item, slug=f\"test-asset-{i}\")\n            for i in range(1, 5)\n        ]\n        self.asset_pks = [asset.pk for asset in self.assets]\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_create_jobs_single_batch(self, mock_task):\n        job_count, batch_url = create_verify_asset_image_job_batch(\n            self.asset_pks, self.batch_id\n        )\n\n        self.assertEqual(job_count, 5)\n        self.assertEqual(\n            VerifyAssetImageJob.objects.filter(batch=self.batch_id).count(), 5\n        )\n        mock_task.assert_called_once_with(batch=self.batch_id)\n        self.assertEqual(\n            batch_url, VerifyAssetImageJob.get_batch_admin_url(self.batch_id)\n        )\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_create_jobs_multiple_batches(self, mock_task):\n        asset_pks = self.asset_pks + [\n            asset.pk\n            for asset in [\n                create_asset(item=self.asset.item, slug=f\"test-asset-{i}\")\n                for i in range(5, 150)\n            ]\n        ]\n        job_count, _ = create_verify_asset_image_job_batch(asset_pks, self.batch_id)\n\n        self.assertEqual(job_count, 150)\n        self.assertEqual(\n            VerifyAssetImageJob.objects.filter(batch=self.batch_id).count(), 150\n        )\n        mock_task.assert_called_once_with(batch=self.batch_id)\n\n    @mock.patch(\"importer.tasks.images.batch_verify_asset_images_task.delay\")\n    def test_no_assets_provided(self, mock_task):\n        job_count, batch_url = create_verify_asset_image_job_batch([], self.batch_id)\n\n        self.assertEqual(job_count, 0)\n        self.assertEqual(\n            VerifyAssetImageJob.objects.filter(batch=self.batch_id).count(), 0\n        )\n        mock_task.assert_called_once_with(batch=self.batch_id)\n        self.assertEqual(\n            batch_url, VerifyAssetImageJob.get_batch_admin_url(self.batch_id)\n        )\n\n\nclass ExcelUtilsTests(TestCase):\n    class _Cell:\n        def __init__(self, data_type, value):\n            self.data_type = data_type\n            self.value = value\n\n    class _Worksheet:\n        def __init__(self, rows):\n            # rows is a list of tuples of _Cell\n            self._rows = rows\n\n        @property\n        def rows(self):\n            return iter(self._rows)\n\n    class _Workbook:\n        def __init__(self, worksheets):\n            self.worksheets = worksheets\n\n    @mock.patch(\"importer.utils.excel.load_workbook\")\n    def test_slurp_excel_single_worksheet_single_row(self, load_mock):\n        ws_rows = [\n            (\n                type(self)._Cell(\"s\", \" Name \"),\n                type(self)._Cell(\"s\", \"Age\"),\n            ),\n            (\n                type(self)._Cell(\"s\", \" Alice \"),\n                type(self)._Cell(\"n\", 30),\n            ),\n        ]\n        wb = type(self)._Workbook([type(self)._Worksheet(ws_rows)])\n        load_mock.return_value = wb\n\n        out = slurp_excel(\"ignored.xlsx\")\n\n        self.assertEqual(out, [{\"Name\": \"Alice\", \"Age\": 30}])\n\n    @mock.patch(\"importer.utils.excel.load_workbook\")\n    def test_slurp_excel_multiple_worksheets_multiple_rows(self, load_mock):\n        ws1_rows = [\n            (type(self)._Cell(\"s\", \"H1\"),),\n            (type(self)._Cell(\"s\", \"v1\"),),\n            (type(self)._Cell(\"s\", \" v2 \"),),\n        ]\n        ws2_rows = [\n            (\n                type(self)._Cell(\"s\", \" H2 \"),\n                type(self)._Cell(\"s\", \"H3\"),\n            ),\n            (\n                type(self)._Cell(\"n\", 1),\n                type(self)._Cell(\"s\", \"  x \"),\n            ),\n        ]\n        wb = type(self)._Workbook(\n            [type(self)._Worksheet(ws1_rows), type(self)._Worksheet(ws2_rows)]\n        )\n        load_mock.return_value = wb\n\n        out = slurp_excel(\"ignored.xlsx\")\n\n        # Order is by worksheet, then row order within each worksheet.\n        self.assertEqual(\n            out,\n            [\n                {\"H1\": \"v1\"},\n                {\"H1\": \"v2\"},\n                {\"H2\": 1, \"H3\": \"x\"},\n            ],\n        )\n\n    def test_clean_cell_value_trims_strings(self):\n        c = type(self)._Cell(\"s\", \"  padded  \")\n        self.assertEqual(clean_cell_value(c), \"padded\")\n\n    def test_clean_cell_value_passthrough_non_strings(self):\n        c_num = type(self)._Cell(\"n\", 42)\n        c_bool = type(self)._Cell(\"b\", True)\n        self.assertEqual(clean_cell_value(c_num), 42)\n        self.assertTrue(clean_cell_value(c_bool))\n"
  },
  {
    "path": "importer/tests/utils.py",
    "content": "from django.utils.text import slugify\n\nfrom concordia.tests.utils import create_asset, create_item, create_project\nfrom importer.models import (\n    DownloadAssetImageJob,\n    ImportItem,\n    ImportItemAsset,\n    ImportJob,\n    VerifyAssetImageJob,\n)\n\n\ndef create_import_job(*, project=None, **kwargs):\n    # project is a concordia.models.Project instance\n    if project is None:\n        project = create_project()\n    import_job = ImportJob(project=project, **kwargs)\n    import_job.save()\n    return import_job\n\n\ndef create_import_item(item=None, project=None, import_job=None, **kwargs):\n    # item is a concordia.models.Item instance\n    # project is a concordia.models.Project instance\n    # import_job is an importer.models.ImportJob instance\n    if import_job is None:\n        import_job = create_import_job(project=project)\n    if item is None:\n        item = create_item(project=import_job.project)\n    import_item = ImportItem(item=item, job=import_job, **kwargs)\n    import_item.save()\n    return import_item\n\n\ndef create_import_asset(\n    sequence_number=1,\n    asset=None,\n    item=None,\n    import_item=None,\n    project=None,\n    import_job=None,\n    **kwargs,\n):\n    # sequence_number has to be unique to a particular import_item\n    # asset is a concordia.models.Asset instance\n    # item is a concordia.models.Item instance\n    # import_item is an importer.models.ImportItem instance\n    # project is a concordia.models.Project instance\n    # import_job is an importer.models.ImportJob instance\n    if import_item is None:\n        import_item = create_import_item(\n            item=item, import_job=import_job, project=project\n        )\n    if asset is None:\n        item_slug = slugify(import_item.item.title)\n        slug = f\"{item_slug}-{sequence_number}\"\n        asset = create_asset(item=import_item.item, slug=slug)\n    import_asset = ImportItemAsset(\n        sequence_number=sequence_number, asset=asset, import_item=import_item, **kwargs\n    )\n    import_asset.save()\n    return import_asset\n\n\ndef create_verify_asset_image_job(asset=None, batch=None, **kwargs):\n    \"\"\"\n    Create a VerifyAssetImageJob instance.\n    If no asset is provided, a new one is created.\n    \"\"\"\n    if asset is None:\n        asset = create_asset()\n    job = VerifyAssetImageJob.objects.create(asset=asset, batch=batch, **kwargs)\n    return job\n\n\ndef create_download_asset_image_job(asset=None, batch=None, **kwargs):\n    \"\"\"\n    Create a DownloadAssetImageJob instance.\n    If no asset is provided, a new one is created.\n    \"\"\"\n    if asset is None:\n        asset = create_asset()\n    job = DownloadAssetImageJob.objects.create(asset=asset, batch=batch, **kwargs)\n    return job\n"
  },
  {
    "path": "importer/utils/__init__.py",
    "content": "from .excel import slurp_excel\nfrom .verify_images import create_verify_asset_image_job_batch\n\n__all__ = [\n    \"slurp_excel\",\n    \"create_verify_asset_image_job_batch\",\n]\n"
  },
  {
    "path": "importer/utils/excel.py",
    "content": "from typing import Any\n\nfrom openpyxl import load_workbook\nfrom openpyxl.cell.cell import Cell\n\n\ndef slurp_excel(filename: str) -> list[dict[str, Any]]:\n    \"\"\"\n    Parse an Excel workbook into a list of row dictionaries.\n\n    Each worksheet is read in order. The first row of each sheet is treated\n    as the header; subsequent rows become dictionaries mapping header names\n    to cell values (after basic cleaning via `clean_cell_value`).\n\n    Args:\n        filename (str): Path to the XLSX file.\n\n    Returns:\n        list[dict[str, Any]]: One dict per non-header row across all sheets.\n    \"\"\"\n    wb = load_workbook(filename=filename)\n\n    cells: list[dict[str, Any]] = []\n\n    for worksheet in wb.worksheets:\n        rows = worksheet.rows\n        headers = [clean_cell_value(i) for i in next(rows)]\n\n        for row in rows:\n            values: list[Any] = []\n            for cell in row:\n                values.append(clean_cell_value(cell))\n\n            cells.append(dict(zip(headers, values, strict=True)))\n\n    return cells\n\n\ndef clean_cell_value(cell: Cell) -> Any:\n    \"\"\"\n    Return a normalized Python value for an openpyxl cell.\n\n    If the cell is a string type ('s'), leading/trailing whitespace is stripped;\n    otherwise the raw value is returned.\n\n    Args:\n        cell (Cell): openpyxl cell to normalize.\n\n    Returns:\n        Any: Cleaned value suitable for serialization.\n    \"\"\"\n    if cell.data_type in (\"s\",):\n        return cell.value.strip()\n    else:\n        return cell.value\n"
  },
  {
    "path": "importer/utils/verify_images.py",
    "content": "from collections.abc import Iterable\nfrom itertools import islice\nfrom uuid import UUID\n\nfrom importer.models import VerifyAssetImageJob\nfrom importer.tasks.images import batch_verify_asset_images_task\n\nBATCH_SIZE: int = 100\n\n\ndef create_verify_asset_image_job_batch(\n    asset_pks: Iterable[int],\n    batch: UUID,\n) -> tuple[int, str]:\n    \"\"\"\n    Create verification jobs in chunks and enqueue a single batch task.\n\n    Iterates through the provided asset primary keys in chunks of\n    `BATCH_SIZE`, creating `VerifyAssetImageJob` rows via `bulk_create`.\n    After all jobs are created, schedules the Celery task that verifies\n    the images for the given batch. Returns the number of jobs created\n    and the admin URL prefiltered to the batch.\n\n    Args:\n        asset_pks (Iterable[int]): Asset primary keys to generate jobs for.\n        batch (UUID): Identifier to group jobs; unrelated to chunk size.\n\n    Returns:\n        tuple[int, str]: A pair of `(job_count, batch_admin_url)`.\n    \"\"\"\n\n    job_count = 0\n    # Make sure asset_pks is an iterator, for proper use with islice\n    # Not doing this causes an infinite loop if asset_pks is not an iterator/generator\n    asset_pks = iter(asset_pks)\n    while True:\n        asset_batch = list(islice(asset_pks, BATCH_SIZE))\n        if not asset_batch:\n            break\n        job_count += len(\n            VerifyAssetImageJob.objects.bulk_create(\n                [\n                    VerifyAssetImageJob(asset_id=asset_pk, batch=batch)\n                    for asset_pk in asset_batch\n                ],\n                batch_size=BATCH_SIZE,\n            )\n        )\n\n    batch_verify_asset_images_task.delay(batch=batch)\n\n    return job_count, VerifyAssetImageJob.get_batch_admin_url(batch)\n"
  },
  {
    "path": "load_test.sh",
    "content": "#!/bin/bash\nset -euo pipefail\n\nLOCUST_USERS=\"${LOCUST_USERS:-100}\"\nLOCUST_SPAWN_RATE=\"${LOCUST_SPAWN_RATE:-2}\"\nLOCUST_RUN_TIME=\"${LOCUST_RUN_TIME:-1m30s}\"\nLOCUST_HOST=\"${LOCUST_HOST:-https://crowd-dev.loc.gov}\"\n\nexec locust \\\n  --headless \\\n  -u \"${LOCUST_USERS}\" \\\n  -r \"${LOCUST_SPAWN_RATE}\" \\\n  --run-time \"${LOCUST_RUN_TIME}\" \\\n  --host \"${LOCUST_HOST}\"\n"
  },
  {
    "path": "locustfile.py",
    "content": "import logging\nimport random\nimport string\nimport time\nfrom html.parser import HTMLParser\nfrom pathlib import Path\nfrom urllib.parse import urlencode, urljoin, urlparse\n\nfrom gevent import sleep\nfrom gevent.event import Event\nfrom locust import HttpUser, between, events, runners, task\nfrom locust.exception import StopUser\n\nABORT_WHEN_NO_WORK = True  # stop the run if a next-* page has no work\nNO_WORK_DUMP_HTML = False  # set True to write an HTML dump for debugging\n\nHOMEPAGE_PATH = \"/\"\nNEXT_ASSET_PATH = \"/next-transcribable-asset/\"\nNEXT_REVIEWABLE_ASSET_PATH = \"/next-reviewable-asset/\"\nAJAX_STATUS_PATH = \"/account/ajax-status/\"\nAJAX_MSG_PATH = \"/account/ajax-messages/\"\nLOGIN_PATH = \"/account/login/\"\nCSRF_COOKIE_NAME = \"csrftoken\"\nSESSION_COOKIE_NAME = \"sessionid\"\nCSRF_SEED_PATH = HOMEPAGE_PATH\nPOST_FIELD_NAME = \"text\"\nPOST_MIN_CHARS = 10\nPOST_MAX_CHARS = 200\nSAME_PAGE_REPEAT_PROB = 0.75\nREDIRECT_RETRIES = 3\nREDIRECT_BACKOFF = 0.25\n\nTEST_USER_PREFIX = \"locusttest\"\nTEST_USER_DOMAIN = \"example.test\"\nTEST_USER_COUNT = 10_000\nTEST_USER_PASSWORD = \"locustpass123\"  # nosec B105\nLOGIN_BAD_PASSWORD_PROB = 0.10\nLOGIN_MAX_ATTEMPTS = 5\n\nREVIEWER_SHARE = 0.20\nREVIEW_EDIT_PROB = 0.50\n\nNO_WORK_ERROR_MESSAGE = (\n    \"Did you need to refresh the load test database? \"\n    \"Try running the 'prepare_load_test_db' command or \"\n    \"'create_load_test_fixtures' if you need fixtures first.\"\n)\n\nlogger = logging.getLogger(__name__)\n\n# ---------- global abort plumbing ----------\n\nGLOBAL_ABORT_EVENT: Event = Event()\nGLOBAL_ABORT_REASON: str | None = None\n\n\n@events.init.add_listener\ndef _on_locust_init(environment, **_):\n    # stop immediately; don’t wait for graceful wind down\n    environment.stop_timeout = 0\n\n    # Register a message handler so both master and workers react to global abort\n    runner = getattr(environment, \"runner\", None)\n    if not runner:\n        return\n\n    def _handle_global_abort(env, msg, **kwargs):\n        reason = \"\"\n        try:\n            data = getattr(msg, \"data\", {}) or {}\n            reason = data.get(\"reason\") or \"\"\n        except Exception:\n            pass\n        _trigger_global_abort(\n            env, f\"Global abort requested. {reason}\", dump_html=None, broadcast=True\n        )\n\n    try:\n        runner.register_message(\"global-abort\", _handle_global_abort)\n    except Exception as e:\n        logger.debug(\"register_message failed (non-distributed run is fine): %s\", e)\n\n\n@events.quitting.add_listener\ndef _on_quitting(environment, **_):\n    \"\"\"Print a final, unmissable banner at shutdown.\"\"\"\n    if not (GLOBAL_ABORT_EVENT.is_set() or GLOBAL_ABORT_REASON):\n        return\n    reason = GLOBAL_ABORT_REASON or \"Aborted\"\n    banner = (\n        \"\\n\" + \"=\" * 80 + \"\\n\"\n        \" LOAD TEST ABORTED\\n\" + \"-\" * 80 + \"\\n\"\n        f\"{reason}\\n\\n{NO_WORK_ERROR_MESSAGE}\\n\" + \"=\" * 80 + \"\\n\"\n    )\n    # Print to stdout and log as error so it's visible in any context\n    try:\n        print(banner, flush=True)\n    except Exception:\n        pass\n    logger.error(banner)\n\n\ndef _trigger_global_abort(\n    environment, reason: str, dump_html: str | None = None, *, broadcast: bool = True\n) -> None:\n    \"\"\"\n    Set a global flag so all users bail, set a failing exit code,\n    and in distributed mode coordinate master<->workers via custom messages.\n    \"\"\"\n    global GLOBAL_ABORT_REASON\n    if GLOBAL_ABORT_EVENT.is_set():\n        return\n\n    GLOBAL_ABORT_REASON = reason\n    GLOBAL_ABORT_EVENT.set()\n\n    logger.error(\"Aborting load test: %s\", reason)\n    logger.error(NO_WORK_ERROR_MESSAGE)\n\n    if dump_html:\n        try:\n            ts = int(time.time())\n            out = Path(f\"no_work_{ts}.html\").resolve()\n            out.write_text(dump_html, encoding=\"utf-8\")\n            logger.error(\"No-work HTML dumped to %s\", out)\n        except Exception as e:\n            logger.error(\"Failed to dump no-work HTML (%s)\", e)\n\n    try:\n        if hasattr(environment, \"process_exit_code\"):\n            environment.process_exit_code = 2\n    except Exception:\n        pass\n\n    runner = getattr(environment, \"runner\", None)\n    if not runner:\n        return\n\n    try:\n        # Worker that discovers the problem -> tell master\n        if isinstance(runner, runners.WorkerRunner):\n            runner.send_message(\"global-abort\", {\"reason\": reason})\n\n        # Master -> broadcast to all workers\n        if broadcast and isinstance(runner, runners.MasterRunner):\n            runner.send_message(\"global-abort\", {\"reason\": reason})\n\n        runner.quit()\n    except Exception as e:\n        logger.error(\"Error quitting runner: %s\", e)\n\n\n# ---------- helpers ----------\n\n\ndef _is_local(path_or_url: str, base: str) -> bool:\n    if not path_or_url:\n        return False\n    if path_or_url.startswith(\"/\"):\n        return True\n    parsed = urlparse(path_or_url)\n    if not parsed.scheme:\n        return True\n    return urlparse(base).netloc == parsed.netloc\n\n\nclass _ResourceParser(HTMLParser):\n    \"\"\"Extract local script and stylesheet URLs from the page.\"\"\"\n\n    def __init__(self, base_url: str):\n        super().__init__()\n        self.base_url = base_url\n        self.resources = []\n\n    def handle_starttag(self, tag, attrs):\n        attrs = dict(attrs)\n        if tag == \"script\":\n            src = attrs.get(\"src\")\n            if src and _is_local(src, self.base_url):\n                self.resources.append(urljoin(self.base_url, src))\n        elif tag == \"link\":\n            rel = (attrs.get(\"rel\") or \"\").lower()\n            href = attrs.get(\"href\")\n            if \"stylesheet\" in rel and href and _is_local(href, self.base_url):\n                self.resources.append(urljoin(self.base_url, href))\n\n\nclass _AssetPageParser(HTMLParser):\n    \"\"\"\n    Extract form action, supersedes, reserve URL\n    and review endpoints from an asset page.\n    \"\"\"\n\n    def __init__(self, base_url: str):\n        super().__init__()\n        self.base_url = base_url\n        self.in_transcription_form = False\n        self.form_action = None\n        self.supersedes = None\n        self.reserve_url = None\n        self.review_url = None\n        self.submit_url = None\n\n    def handle_starttag(self, tag, attrs):\n        a = dict(attrs)\n        if tag == \"form\":\n            if a.get(\"id\") == \"transcription-editor\":\n                self.in_transcription_form = True\n                action = a.get(\"action\")\n                if action is not None:\n                    resolved = (\n                        self.base_url\n                        if action.strip() == \"\"\n                        else urljoin(self.base_url, action)\n                    )\n                    self.form_action = resolved\n                review_attr = a.get(\"data-review-url\")\n                if review_attr:\n                    self.review_url = urljoin(self.base_url, review_attr)\n                submit_attr = a.get(\"data-submit-url\")\n                if submit_attr:\n                    self.submit_url = urljoin(self.base_url, submit_attr)\n        elif tag == \"input\":\n            if a.get(\"name\") == \"supersedes\" and a.get(\"value\"):\n                self.supersedes = a[\"value\"]\n        elif tag == \"script\":\n            if a.get(\"id\") == \"asset-reservation-data\":\n                reserve = a.get(\"data-reserve-asset-url\")\n                if reserve:\n                    self.reserve_url = urljoin(self.base_url, reserve)\n\n    def handle_endtag(self, tag):\n        if tag == \"form\" and self.in_transcription_form:\n            self.in_transcription_form = False\n\n\ndef _random_text(min_len=10, max_len=200) -> str:\n    n = random.randint(min_len, max_len)\n    alphabet = string.ascii_letters + string.digits + \"     \"\n    s = \"\".join(random.choice(alphabet) for _ in range(n))\n    return \" \".join(s.split())\n\n\n# ---------- users ----------\n\n\nclass BaseBrowsingUser(HttpUser):\n    \"\"\"\n    Shared browse/post behavior. Subclasses provide their own on_start.\n    \"\"\"\n\n    abstract = True\n    wait_time = between(3.0, 8.0)\n\n    current_target_path: str | None = None\n    current_form_action_path: str | None = None\n    current_supersedes: str | None = None\n    current_reserve_path: str | None = None\n    current_review_url_path: str | None = None\n    current_submit_url_path: str | None = None\n\n    next_redirect_path: str = NEXT_ASSET_PATH\n    next_redirect_label: str = \"next asset (redirect)\"\n\n    _fatal_already_triggered = False\n\n    def _fatal_dump_and_quit(self, page_url: str, html: str) -> None:\n        if self.__class__._fatal_already_triggered:\n            return\n        self.__class__._fatal_already_triggered = True\n\n        ts = int(time.time())\n        out = Path(f\"asset_parse_failure_{ts}.html\").resolve()\n        try:\n            out.write_text(html or \"\", encoding=\"utf-8\")\n            logger.error(\n                \"FATAL: transcription form not found. Page URL=%s ; HTML dumped to %s\",\n                page_url,\n                out,\n            )\n        except Exception as e:\n            logger.error(\n                \"FATAL: failed to write HTML dump (%s). Page URL=%s\", e, page_url\n            )\n\n        try:\n            self.environment.runner.quit()\n        except Exception as e:\n            logger.error(\"Error calling runner.quit(): %s\", e)\n\n    def _after_request_ajax(self):\n        # simulate normal page load\n        self.client.get(AJAX_STATUS_PATH, name=\"AJAX status\")\n        self.client.get(AJAX_MSG_PATH, name=\"AJAX messaging\")\n\n    def _get(self, path_or_url: str, *, page: bool = True, **kwargs):\n        r = self.client.get(path_or_url, **kwargs)\n        if page:\n            self._after_request_ajax()\n        return r\n\n    def _post(self, path_or_url: str, **kwargs):\n        return self.client.post(path_or_url, **kwargs)\n\n    def _load_homepage_and_resources(self, *, name_suffix: str = \"\"):\n        base = self.environment.host.rstrip(\"/\")\n        r_home = self._get(HOMEPAGE_PATH, page=True)\n\n        parser = _ResourceParser(base_url=base + \"/\")\n        try:\n            parser.feed(r_home.text or \"\")\n        except Exception:\n            parser.resources = []\n\n        for res_url in parser.resources:\n            label = \"resource \" + urlparse(res_url).path\n            if name_suffix:\n                label = f\"{label} {name_suffix}\"\n            self._get(res_url, name=label, page=False)\n\n    def _parse_asset_page_and_reserve(self, target_path: str) -> None:\n        base = self.environment.host.rstrip(\"/\")\n        r = self._get(target_path, name=\"target page\", page=True)\n\n        parser = _AssetPageParser(base_url=r.url)\n        try:\n            parser.feed(r.text or \"\")\n        except Exception:\n            self._fatal_dump_and_quit(r.url, r.text or \"\")\n            return\n\n        if parser.form_action:\n            fa = urlparse(parser.form_action)\n            self.current_form_action_path = fa.path + (\n                (\"?\" + fa.query) if fa.query else \"\"\n            )\n        else:\n            self.current_form_action_path = None\n\n        self.current_supersedes = parser.supersedes\n\n        if parser.reserve_url:\n            ru = urlparse(parser.reserve_url)\n            self.current_reserve_path = ru.path + ((\"?\" + ru.query) if ru.query else \"\")\n        else:\n            self.current_reserve_path = None\n\n        if parser.review_url:\n            rvu = urlparse(parser.review_url)\n            self.current_review_url_path = rvu.path + (\n                (\"?\" + rvu.query) if rvu.query else \"\"\n            )\n        else:\n            self.current_review_url_path = None\n\n        if parser.submit_url:\n            su = urlparse(parser.submit_url)\n            self.current_submit_url_path = su.path + (\n                (\"?\" + su.query) if su.query else \"\"\n            )\n        else:\n            self.current_submit_url_path = None\n\n        if not self.current_form_action_path:\n            if ABORT_WHEN_NO_WORK:\n                _trigger_global_abort(\n                    self.environment,\n                    f\"No work available (no transcription form) on {r.url}\",\n                    (r.text or \"\") if NO_WORK_DUMP_HTML else None,\n                    broadcast=True,\n                )\n            else:\n                logger.info(\"No transcription form on %s; treating as no work\", r.url)\n                self.current_target_path = None\n                self.current_review_url_path = None\n                self.current_submit_url_path = None\n            return\n\n        if self.current_reserve_path:\n            csrftoken = self.client.cookies.get(CSRF_COOKIE_NAME)\n            referer = urljoin(base + \"/\", target_path.lstrip(\"/\"))\n            self._post(\n                self.current_reserve_path,\n                headers={\"X-CSRFToken\": csrftoken or \"\", \"Referer\": referer},\n                name=\"reserve asset\",\n            )\n\n    def _ensure_csrf(self, target_path: str | None) -> str | None:\n        if not target_path:\n            return None\n\n        if (\n            self.current_form_action_path is None\n            and self.current_review_url_path is None\n        ):\n            self._parse_asset_page_and_reserve(target_path)\n\n        csrftoken = self.client.cookies.get(CSRF_COOKIE_NAME)\n\n        if not csrftoken and CSRF_SEED_PATH:\n            self._get(CSRF_SEED_PATH, name=\"csrf seed\", page=True)\n            self._parse_asset_page_and_reserve(target_path)\n            csrftoken = self.client.cookies.get(CSRF_COOKIE_NAME)\n\n        return csrftoken\n\n    def _follow_next(self, redirect_path: str, label: str) -> str | None:\n        \"\"\"\n        Follow the next-* redirect. If it lands on the homepage, treat that as no work.\n        \"\"\"\n        last_body = None\n        for attempt in range(1, REDIRECT_RETRIES + 1):\n            with self.client.get(\n                redirect_path,\n                name=label,\n                allow_redirects=True,\n                catch_response=True,\n            ) as resp:\n                try:\n                    last_body = (resp.text or \"\")[:10000]\n                except Exception:\n                    last_body = None\n\n                if 200 <= resp.status_code < 400:\n                    final_path = urlparse(resp.url).path or \"/\"\n                    if final_path == HOMEPAGE_PATH:\n                        msg = f\"{label} landed on homepage -> no work\"\n                        resp.failure(msg)\n                        logger.error(msg)\n                        if ABORT_WHEN_NO_WORK:\n                            _trigger_global_abort(\n                                self.environment,\n                                f\"No work available from {label} ({redirect_path})\",\n                                last_body if NO_WORK_DUMP_HTML else None,\n                                broadcast=True,\n                            )\n                        return None\n                    return final_path\n\n                msg = (\n                    f\"redirect failed (status={resp.status_code}) \"\n                    f\"attempt={attempt}/{REDIRECT_RETRIES}\"\n                )\n                resp.failure(msg)\n                logger.warning(\"%s retry: %s\", label, msg)\n\n            sleep(REDIRECT_BACKOFF * attempt)\n\n        logger.error(\"%s: all %d retries failed\", label, REDIRECT_RETRIES)\n        return None\n\n    def _post_then_get_same_page(\n        self, target_path: str | None, csrftoken: str, name_prefix: str\n    ):\n        if not target_path:\n            return\n        base = self.environment.host.rstrip(\"/\")\n        referer = urljoin(base + \"/\", target_path.lstrip(\"/\"))\n        post_path = self.current_form_action_path\n        if not post_path:\n            logger.warning(\"No form action parsed for %s; skipping POST\", target_path)\n            return\n\n        data = {POST_FIELD_NAME: _random_text(POST_MIN_CHARS, POST_MAX_CHARS)}\n        if self.current_supersedes:\n            data[\"supersedes\"] = self.current_supersedes\n\n        self._post(\n            post_path,\n            data=data,\n            headers={\"X-CSRFToken\": csrftoken, \"Referer\": referer},\n            name=f\"{name_prefix} POST\",\n        )\n        self._parse_asset_page_and_reserve(target_path)\n\n    def _review_decision(self, target_path: str, decision: str) -> None:\n        if not self.current_review_url_path:\n            return\n        base = self.environment.host.rstrip(\"/\")\n        referer = urljoin(base + \"/\", target_path.lstrip(\"/\"))\n        csrftoken = self.client.cookies.get(CSRF_COOKIE_NAME) or \"\"\n\n        form = {\"csrfmiddlewaretoken\": csrftoken, \"decision\": decision}\n        name = \"review accept\" if decision == \"accept\" else \"review reject\"\n        self._post(\n            self.current_review_url_path,\n            data=form,\n            headers={\"X-CSRFToken\": csrftoken, \"Referer\": referer},\n            name=name,\n        )\n\n    @task\n    def browse_and_submit(self):\n        # if someone already pulled the plug, stop this user\n        if GLOBAL_ABORT_EVENT.is_set():\n            raise StopUser()\n\n        if not self.current_target_path:\n            new_path = self._follow_next(\n                self.next_redirect_path, self.next_redirect_label\n            )\n            if new_path is None:\n                return\n            self.current_target_path = new_path\n            self.current_form_action_path = None\n            self.current_supersedes = None\n            self.current_reserve_path = None\n            self.current_review_url_path = None\n            self.current_submit_url_path = None\n        else:\n            maybe_switch = getattr(self, \"is_reviewer\", False) is False\n            if maybe_switch and random.random() >= SAME_PAGE_REPEAT_PROB:\n                new_path = self._follow_next(\n                    self.next_redirect_path, self.next_redirect_label\n                )\n                if new_path is None:\n                    return\n                self.current_target_path = new_path\n                self.current_form_action_path = None\n                self.current_supersedes = None\n                self.current_reserve_path = None\n                self.current_review_url_path = None\n                self.current_submit_url_path = None\n\n        csrftoken = self._ensure_csrf(self.current_target_path)\n        if not csrftoken:\n            if self.current_target_path:\n                self._get(\n                    self.current_target_path, name=\"target page (no CSRF)\", page=True\n                )\n            return\n\n        if getattr(self, \"is_reviewer\", False):\n            do_edit = random.random() < REVIEW_EDIT_PROB\n            if do_edit:\n                self._review_decision(self.current_target_path, \"reject\")\n                self._parse_asset_page_and_reserve(self.current_target_path)\n                csrftoken = self._ensure_csrf(self.current_target_path) or \"\"\n                if csrftoken:\n                    self._post_then_get_same_page(\n                        self.current_target_path, csrftoken, \"review edit save\"\n                    )\n            else:\n                self._review_decision(self.current_target_path, \"accept\")\n\n            self.current_target_path = None\n            self.current_form_action_path = None\n            self.current_supersedes = None\n            self.current_reserve_path = None\n            self.current_review_url_path = None\n            self.current_submit_url_path = None\n            return\n\n        # Transcriber branch\n        self._post_then_get_same_page(self.current_target_path, csrftoken, \"target\")\n\n        if random.random() < SAME_PAGE_REPEAT_PROB:\n            csrftoken = self.client.cookies.get(CSRF_COOKIE_NAME) or self._ensure_csrf(\n                self.current_target_path\n            )\n            if csrftoken:\n                self._post_then_get_same_page(\n                    self.current_target_path, csrftoken, \"target (repeat)\"\n                )\n\n\nclass AnonUser(BaseBrowsingUser):\n    \"\"\"Anonymous user flow.\"\"\"\n\n    def on_start(self):\n        self._load_homepage_and_resources()\n\n\nclass AuthUser(BaseBrowsingUser):\n    \"\"\"Authenticated user flow.\"\"\"\n\n    chosen_username: str | None = None\n    chosen_email: str | None = None\n    is_reviewer: bool = False\n\n    def _pick_fixture_user(self):\n        index = random.randint(1, TEST_USER_COUNT)\n        username = f\"{TEST_USER_PREFIX}{index:05d}\"\n        email = f\"{username}@{TEST_USER_DOMAIN}\"\n        self.chosen_username = username\n        self.chosen_email = email\n\n    def _login_once(self, login_url: str, referer: str) -> bool:\n        csrftoken = self.client.cookies.get(CSRF_COOKIE_NAME) or \"\"\n        if not csrftoken:\n            self._get(login_url, name=\"login page\", page=True)\n            csrftoken = self.client.cookies.get(CSRF_COOKIE_NAME) or \"\"\n\n        assert self.chosen_username and self.chosen_email\n        identifier = (\n            self.chosen_username if random.random() < 0.5 else self.chosen_email\n        )\n\n        wrong = random.random() < LOGIN_BAD_PASSWORD_PROB\n        password = TEST_USER_PASSWORD if not wrong else TEST_USER_PASSWORD + \"x\"\n\n        form = {\n            \"username\": identifier,\n            \"password\": password,\n            \"csrfmiddlewaretoken\": csrftoken,\n            \"next\": \"/\",\n        }\n\n        self._post(\n            login_url,\n            data=form,\n            headers={\"X-CSRFToken\": csrftoken, \"Referer\": referer},\n            name=\"login POST\",\n        )\n\n        has_session = bool(self.client.cookies.get(SESSION_COOKIE_NAME))\n        if has_session:\n            return True\n\n        self._get(\"/\", name=\"post-login home probe\", page=True)\n        has_session = bool(self.client.cookies.get(SESSION_COOKIE_NAME))\n        return has_session\n\n    def on_start(self):\n        self._get(HOMEPAGE_PATH, page=True)\n\n        self._pick_fixture_user()\n        query = urlencode({\"next\": \"/\"})\n        login_url = f\"{LOGIN_PATH}?{query}\"\n        base = self.environment.host.rstrip(\"/\")\n        referer = urljoin(base + \"/\", LOGIN_PATH.lstrip(\"/\"))\n\n        self._get(login_url, name=\"login page\", page=True)\n\n        success = False\n        for _ in range(LOGIN_MAX_ATTEMPTS):\n            if self._login_once(login_url, referer):\n                success = True\n                break\n            self._get(login_url, name=\"login page (retry)\", page=True)\n\n        if not success:\n            logger.error(\n                \"AuthUser failed to authenticate after %d attempts (user=%s / %s)\",\n                LOGIN_MAX_ATTEMPTS,\n                self.chosen_username,\n                self.chosen_email,\n            )\n\n        self.is_reviewer = random.random() < REVIEWER_SHARE\n        if self.is_reviewer:\n            self.next_redirect_path = NEXT_REVIEWABLE_ASSET_PATH\n            self.next_redirect_label = \"next reviewable (redirect)\"\n        else:\n            self.next_redirect_path = NEXT_ASSET_PATH\n            self.next_redirect_label = \"next asset (redirect)\"\n\n        self._load_homepage_and_resources(name_suffix=\"(authed)\")\n"
  },
  {
    "path": "manage.py",
    "content": "#!/usr/bin/env python3\nimport sys\n\nif __name__ == \"__main__\":\n    try:\n        from django.core.management import execute_from_command_line\n    except ImportError as exc:\n        raise ImportError(\n            \"Couldn't import Django. Are you sure it's installed and \"\n            \"available on your PYTHONPATH environment variable? Did you \"\n            \"forget to activate a virtual environment?\"\n        ) from exc\n    execute_from_command_line(sys.argv)\n"
  },
  {
    "path": "package.json",
    "content": "{\n    \"devDependencies\": {\n        \"@axe-core/cli\": \"^4.11.3\",\n        \"@puppeteer/browsers\": \"^2.10.13\",\n        \"child_process\": \"^1.0.2\",\n        \"sass-embedded\": \"^1.99.0\",\n        \"stylelint-value-no-unknown-custom-properties\": \"^6.1.1\",\n        \"vite\": \"^8.0.10\",\n        \"vite-plugin-compression2\": \"^2.5.2\"\n    },\n    \"dependencies\": {\n        \"@duetds/date-picker\": \"^1.4.0\",\n        \"@fortawesome/fontawesome-free\": \"^7.1.0\",\n        \"@popperjs/core\": \"^2.11.8\",\n        \"@sentry/browser\": \"^10.49.0\",\n        \"@sentry/core\": \"^10.48.0\",\n        \"@sentry/tracing\": \"^7.120.4\",\n        \"bootstrap\": \"^5.3.8\",\n        \"chart.js\": \"^4.5.1\",\n        \"chroma-js\": \"^3.2.0\",\n        \"codemirror\": \"^5.65.19\",\n        \"fancy-log\": \"^2.0.0\",\n        \"jquery\": \"^3.5.1\",\n        \"js-cookie\": \"^3.0.5\",\n        \"openseadragon\": \"^6.0.2\",\n        \"openseadragon-filters\": \"^2.2.0\",\n        \"prettier\": \"^2.8.8\",\n        \"remarkable\": \"^2.0.1\",\n        \"screenfull\": \"^6.0.0\",\n        \"split.js\": \"^1.6.2\",\n        \"urijs\": \"^1.19.11\"\n    },\n    \"name\": \"concordia\",\n    \"private\": true,\n    \"version\": \"1.0.0\",\n    \"directories\": {\n        \"doc\": \"docs\"\n    },\n    \"repository\": {\n        \"type\": \"git\",\n        \"url\": \"git+https://github.com/LibraryOfCongress/concordia.git\"\n    },\n    \"license\": \"CC0-1.0\",\n    \"bugs\": {\n        \"url\": \"https://github.com/LibraryOfCongress/concordia/issues\"\n    },\n    \"homepage\": \"https://github.com/LibraryOfCongress/concordia\",\n    \"scripts\": {\n        \"dev\": \"vite\",\n        \"copy-vendor\": \"mkdir -p concordia/static/openseadragon/images && cp -R node_modules/openseadragon/build/openseadragon/images/* concordia/static/openseadragon/images/\",\n        \"build\": \"npm run copy-vendor && vite build\",\n        \"preview\": \"vite preview\",\n        \"postinstall\": \"npm run copy-vendor\"\n    }\n}\n"
  },
  {
    "path": "postgresql/create-multiple-postgresql-databases.sh",
    "content": "#!/bin/bash\nset -e\nset -u\n\nfunction create_user_and_database() {\n\tlocal database=$1\n\techo \"  Creating user and database '$database'\"\n\tpsql -v ON_ERROR_STOP=1 --username \"$POSTGRES_USER\" <<-EOSQL\n\t    CREATE USER $database;\n\t    CREATE DATABASE $database;\n\t    GRANT ALL PRIVILEGES ON DATABASE $database TO $database;\nEOSQL\n}\n\nif [ -n \"$POSTGRES_MULTIPLE_DATABASES\" ]; then\n\techo \"Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES\"\n\tfor db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do\n\t\tcreate_user_and_database $db\n        if [ $db = \"sentry\" ]\n        then\n           echo \"  Giving sentry superuser powers!!\"\n           psql -v ON_ERROR_STOP=1 --username postgres  -c \"ALTER ROLE sentry superuser;\"\n        fi\n\tdone\n\techo \"Multiple databases created\"\nfi\n"
  },
  {
    "path": "prometheus_metrics/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2017 Jimdo GmbH\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "prometheus_metrics/__init__.py",
    "content": ""
  },
  {
    "path": "prometheus_metrics/apps.py",
    "content": "from django.apps import AppConfig\n\n\nclass PrometheusMetricsConfig(AppConfig):\n    name = \"prometheus_metrics\"\n    verbose_name = \"Prometheus Metrics\"\n"
  },
  {
    "path": "prometheus_metrics/middleware.py",
    "content": "from timeit import default_timer\n\nfrom django.utils.deprecation import MiddlewareMixin\nfrom prometheus_client import Counter, Histogram\n\nrequests_total = Counter(\n    \"django_http_requests_total\",\n    \"Total count of requests\",\n    [\"status_code\", \"method\", \"view\"],\n)\nrequests_latency = Histogram(\n    \"django_http_requests_latency_seconds\",\n    \"Histogram of requests processing time\",\n    [\"status_code\", \"method\", \"view\"],\n)\n\n\nclass PrometheusBeforeMiddleware(MiddlewareMixin):\n    def process_request(self, request):\n        request.prometheus_middleware_request_start = default_timer()\n\n    def process_response(self, request, response):\n        resolver_match = request.resolver_match\n        if resolver_match:\n            handler = resolver_match.url_name\n            if not handler:\n                handler = resolver_match.view_name\n            handler = handler.replace(\"-\", \"_\")\n        else:\n            handler = \"<unnamed view>\"\n\n        requests_total.labels(response.status_code, request.method, handler).inc()\n\n        if hasattr(request, \"prometheus_middleware_request_start\"):\n            requests_latency.labels(\n                response.status_code, request.method, handler\n            ).observe(default_timer() - request.prometheus_middleware_request_start)\n        return response\n"
  },
  {
    "path": "prometheus_metrics/models.py",
    "content": "from prometheus_client import Counter\n\nmodel_inserts_total = Counter(\n    \"django_model_inserts_total\", \"Number of inserts on a certain model\", [\"model\"]\n)\nmodel_updates_total = Counter(\n    \"django_model_updates_total\", \"Number of updates on a certain model\", [\"model\"]\n)\nmodel_deletes_total = Counter(\n    \"django_model_deletes_total\", \"Number of deletes on a certain model\", [\"model\"]\n)\n\n\ndef MetricsModelMixin(name):\n    class Mixin(object):\n        def _do_insert(self, *args, **kwargs):\n            model_inserts_total.labels(name).inc()\n            return super(Mixin, self)._do_insert(*args, **kwargs)\n\n        def _do_update(self, *args, **kwargs):\n            model_updates_total.labels(name).inc()\n            return super(Mixin, self)._do_update(*args, **kwargs)\n\n        def _do_delete(self, *args, **kwargs):\n            model_deletes_total.labels(name).inc()\n            return super(Mixin, self).delete(*args, **kwargs)\n\n    return Mixin\n"
  },
  {
    "path": "prometheus_metrics/views.py",
    "content": "import prometheus_client\nfrom django.http import HttpResponse\nfrom django.views import View\n\n\nclass MetricsView(View):\n    def get(self, request, *args, **kwargs):\n        metrics_page = prometheus_client.generate_latest()\n        return HttpResponse(\n            metrics_page, content_type=prometheus_client.CONTENT_TYPE_LATEST\n        )\n"
  },
  {
    "path": "pylenium.json",
    "content": "{\n    \"driver\": {\n        \"browser\": \"chrome\",\n        \"remote_url\": \"\",\n        \"wait_time\": 10,\n        \"page_load_wait_time\": 0,\n        \"options\": [\n            \"headless\",\n            \"no-sandbox\",\n            \"disable-gpu\"\n        ],\n        \"capabilities\": {},\n        \"experimental_options\": null,\n        \"extension_paths\": [],\n        \"webdriver_kwargs\": {},\n        \"seleniumwire_enabled\": false,\n        \"seleniumwire_options\": {},\n        \"local_path\": \"\"\n    },\n    \"logging\": {\n        \"screenshots_on\": true\n    },\n    \"viewport\": {\n        \"maximize\": true,\n        \"width\": 1440,\n        \"height\": 900,\n        \"orientation\": \"portrait\"\n    },\n    \"customer\": {}\n}\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[tool.black]\ntarget_version = [\"py312\"]\nexclude = '''\n(\n  /(\n    | \\.git\n    | node_modules\n  )/\n)\n'''\n\n[tool.ruff]\ntarget-version = \"py310\"\nselect = [\n    \"E\",\n    \"F\",\n    \"W\",\n    \"A\", # flake8-builtins\n    \"B\", # flake8-bugbear\n    \"C4\", # flake8-comprehensions\n    \"ERA\", # flake8-eradicate\n    \"G\", # flake8-logging-format\n    \"I\", # isort\n]\nignore-init-module-imports = true  # Prevents removing imports from __init__.py\n\nextend-exclude = [\n        \"concordia/settings_dev_*.py\"\n]\n\n# Ignore line length in migrations\n[tool.ruff.per-file-ignores]\n\"*/migrations/*\" = [\"E501\"]\n\n# v8.0.4 broke original setup config to produce git version - required configuration\n[tool.setuptools_scm]\n"
  },
  {
    "path": "setup.cfg",
    "content": "[pycodestyle]\nexclude = .venv,docs/conf.py\nignore =\nmax-line-length = 88\n\n[tool:pytest]\nDJANGO_SETTINGS_MODULE = concordia.settings_test\naddopts = -rf\n\n[isort]\ndefault_section = THIRDPARTY\nforce_grid_wrap = 0\ninclude_trailing_comma = True\nknown_first_party = concordia,importer,exporter\nline_length = 88\nmulti_line_output = 3\nskip = .venv\nuse_parentheses = True\n\n[flake8]\nexclude = .venv,node_modules,concordia/settings_dev_*.py\nmax-line-length = 88\nenable-extensions = G\nper-file-ignores =\n    */migrations/*:E501\n\n[readme_check]\nreadmes =\n    concordia/views/README.md\n"
  },
  {
    "path": "setup.py",
    "content": "#!/usr/bin/env python\nfrom setuptools import find_packages, setup\n\nVERSION = __import__(\"concordia\").get_version()\nINSTALL_REQUIREMENTS = [\"boto3\", \"Django>=4.2\"]\nSCRIPTS = [\"manage.py\"]\nDESCRIPTION = \"Transcription crowdsourcing\"\nCLASSIFIERS = \"\"\"\\\nEnvironment :: Web Environment\nFramework :: Django\nProgramming Language :: Python\nProgramming Language :: Python :: 3.12\n\"\"\".splitlines()\n\nwith open(\"README.md\", \"r\") as f:\n    LONG_DESCRIPTION = f.read()\n\n\nsetup(\n    name=\"concordia\",\n    version=VERSION,\n    description=DESCRIPTION,\n    long_description=LONG_DESCRIPTION,\n    packages=find_packages(),\n    include_package_data=True,\n    scripts=SCRIPTS,\n    install_requires=INSTALL_REQUIREMENTS,\n    classifiers=CLASSIFIERS,\n    use_scm_version={\n        \"write_to\": \"version.txt\",\n        \"tag_regex\": r\"^(?P<prefix>v)?(?P<version>[^\\+]+)(?P<suffix>.*)?$\",\n    },\n    setup_requires=[\"setuptools_scm\"],\n)\n"
  },
  {
    "path": "src/about.js",
    "content": "import '../concordia/static/js/src/modules/concordia-visualization.js';\nimport '../concordia/static/js/src/visualizations/asset-status-overview.js';\nimport '../concordia/static/js/src/visualizations/daily-activity.js';\n"
  },
  {
    "path": "src/main.js",
    "content": "import $ from 'jquery';\nwindow.$ = window.jQuery = $;\nimport 'bootstrap';\nimport 'bootstrap/dist/css/bootstrap.min.css';\n\n/* local scripts */\nimport '../concordia/static/js/src/about-accordions.js';\nimport '../concordia/static/js/src/asset-reservation.js';\nimport '../concordia/static/js/src/banner.js';\nimport '../concordia/static/js/src/contribute.js';\nimport '../concordia/static/js/src/filter-assets.js';\nimport '../concordia/static/js/src/guide.js';\nimport '../concordia/static/js/src/homepage-carousel.js';\nimport '../concordia/static/js/src/ocr.js';\nimport {setTutorialHeight} from '../concordia/static/js/src/modules/quick-tips.js';\nimport '../concordia/static/js/src/quick-tips-setup.js';\nimport '../concordia/static/js/src/viewer.js';\nimport '../concordia/static/js/src/viewer-split.js';\n\n/*- Third-party */\nimport OpenSeadragon from 'openseadragon';\n\nwindow.OpenSeadragon = OpenSeadragon;\n\nif (setTutorialHeight) {\n    window.setTutorialHeight = setTutorialHeight;\n}\n"
  },
  {
    "path": "src/profile.js",
    "content": "import '../concordia/static/js/src/campaign-selection.js';\nimport '../concordia/static/js/src/recent-pages.js';\nimport '../concordia/static/js/src/profile-fields.js';\n"
  },
  {
    "path": "static/.gitignore",
    "content": "css\njs\nsourcemaps\nfrontend\n"
  },
  {
    "path": "tools/readme_symbol_check.py",
    "content": "#!/usr/bin/env python3\n\"\"\"\nREADME Symbol Checker\n\nThis script verifies that all top-level class and function names defined in Python\nfiles under the directory containing a given README.md file are mentioned somewhere\nin the README.\n\nTo use it, configure `setup.cfg` with a [readme_check] section like:\n\n    [readme_check]\n    readmes =\n        concordia/views/README.md\n\nThis will recursively scan all `.py` files in `concordia/views/` and ensure every\nclass/function defined in them appears by name (case-sensitive) somewhere in the\ncorresponding README.md.\n\"\"\"\n\nimport ast\nimport configparser\nimport sys\nfrom pathlib import Path\nfrom typing import List\n\n\ndef collect_defined_symbols(py_path: Path) -> List[str]:\n    \"\"\"\n    Parse a Python file and return all top-level class and function names.\n    \"\"\"\n    with py_path.open(encoding=\"utf-8\") as f:\n        tree = ast.parse(f.read(), filename=str(py_path))\n    return [\n        node.name\n        for node in tree.body\n        if isinstance(node, (ast.FunctionDef, ast.ClassDef))\n    ]\n\n\ndef read_readme_text(readme_path: Path) -> str:\n    return readme_path.read_text(encoding=\"utf-8\")\n\n\ndef check_readme(readme_path: Path) -> int:\n    \"\"\"\n    Check that each symbol defined in the Python files under the same directory\n    as the README appears in the README text. Returns exit code (0 or 1).\n    \"\"\"\n    readme_text = read_readme_text(readme_path)\n    search_dir = readme_path.parent\n    exit_code = 0\n\n    for py_file in search_dir.rglob(\"*.py\"):\n        defined = collect_defined_symbols(py_file)\n        for name in defined:\n            if name not in readme_text:\n                print(f\"V001 Symbol '{name}' is not documented in {readme_path.name}\")\n                exit_code = 1\n\n    return exit_code\n\n\ndef load_readmes_from_config() -> List[Path]:\n    \"\"\"\n    Read the list of README.md files from setup.cfg under the [readme_check] section.\n    \"\"\"\n    cfg_path = Path(\"setup.cfg\")\n    if not cfg_path.exists():\n        sys.stderr.write(\"ERROR: setup.cfg not found\\n\")\n        sys.exit(2)\n\n    config = configparser.ConfigParser()\n    config.read(cfg_path)\n\n    try:\n        section = config[\"readme_check\"]\n        readmes = [\n            Path(p.strip())\n            for p in section.get(\"readmes\", \"\").splitlines()\n            if p.strip()\n        ]\n        if not readmes:\n            raise ValueError\n        return readmes\n    except (KeyError, ValueError):\n        sys.stderr.write(\"ERROR: No [readme_check] readmes configured in setup.cfg\\n\")\n        sys.exit(2)\n\n\ndef main() -> None:\n    exit_code = 0\n    readmes = load_readmes_from_config()\n\n    for readme in readmes:\n        if not readme.exists():\n            print(f\"ERROR: README file not found: {readme}\", file=sys.stderr)\n            exit_code = 2\n        else:\n            exit_code = max(exit_code, check_readme(readme))\n\n    sys.exit(exit_code)\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "vite.config.js",
    "content": "import {defineConfig} from 'vite';\nimport {compression} from 'vite-plugin-compression2';\nimport path from 'node:path';\nimport {fileURLToPath} from 'node:url';\n\n// Define __dirname for ES Modules\nconst __dirname = path.dirname(fileURLToPath(import.meta.url));\n\nexport default defineConfig({\n    base: '/static/',\n    resolve: {\n        alias: {\n            // Map the custom name to its actual directory\n            // Adjust the path below to where your visualization logic actually lives\n            'concordia-visualization': path.resolve(\n                __dirname,\n                './concordia/static/js/src/modules/concordia-visualization.js',\n            ),\n        },\n    },\n    optimizeDeps: {\n        include: ['openseadragon', 'openseadragon-filters'],\n    },\n    build: {\n        // collectstatic ignores hidden files - so 'true' not enough\n        manifest: 'manifest.json',\n        // Using 'dist' prevents Vite from writing into your source folders\n        outDir: 'concordia/static/dist', // where the compiled files go\n        emptyOutDir: true,\n        rollupOptions: {\n            input: {\n                // Existing entry points\n                main: './src/main.js',\n                about: './src/about.js',\n                profile: './src/profile.js',\n\n                // ADD the new standalone JS files\n                admin_custom: './concordia/static/admin/custom-inline.js',\n                admin_editor: './concordia/static/admin/editor-preview.js',\n                js_base: './concordia/static/js/src/base.js',\n                accessible_colors:\n                    './concordia/static/js/src/modules/accessible-colors.js',\n                chroma_esm: './concordia/static/js/src/modules/chroma-esm.js',\n                turnstile: './concordia/static/js/src/modules/turnstile.js',\n                viz_errors:\n                    './concordia/static/js/src/modules/visualization-errors.js',\n                password_validation:\n                    './concordia/static/js/src/password-validation.js',\n                viz_asset_status:\n                    './concordia/static/js/src/visualizations/asset-status-by-campaign.js',\n                jquery_cookie: './concordia/static/vendor/jquery.cookie.js',\n\n                // The SCSS entry point\n                base_styles: './concordia/static/scss/base.scss',\n            },\n            output: {\n                // 1. Enable hashing so Vite handles versioning\n                entryFileNames: 'js/[name]-[hash].js',\n                chunkFileNames: 'js/[name]-[hash].js',\n                assetFileNames: 'assets/[name]-[hash][extname]',\n            },\n        },\n    },\n    plugins: [\n        // 2. Pre-compress files so WhiteNoise doesn't have to at startup\n        compression({algorithm: 'gzip'}),\n        compression({algorithm: 'brotliCompress'}),\n    ],\n});\n"
  }
]