diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 48f1b9fd3..000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,116 +0,0 @@ -version: 2.1 - -steps: - - &setup_dependencies - run: - name: Install dependencies (required for fabric) - command: | - sudo apt-get update - sudo apt-get install libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev \ - libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk \ - libharfbuzz-dev libfribidi-dev libxcb1-dev - virtualenv --python=python3 .venv - source .venv/bin/activate - pip3 install Fabric3==1.14.post1 - - - &add_ssh_keys - add_ssh_keys: - fingerprints: - - "44:81:8d:36:86:55:fa:82:eb:97:65:f4:d7:9a:0b:fa" - - - &encrypt_and_save_backup - run: - name: Encrypt backup to shared GPG key, save to S3 - command: | - export BACKUP_FILENAME=$(ls ~/project/backup/backup-*.tar.gz) - - # Not using keyservers because they are hot garbage, pubkey is in env var - echo -e $GPG_KEY > OPENOVERSIGHT_GPG_PUB_KEY.asc - gpg --import OPENOVERSIGHT_GPG_PUB_KEY.asc - gpg --output backup.tar.gz.gpg --encrypt --trust-model always --batch --no-tty --recipient 0x3C4C259402A0E3B2 $BACKUP_FILENAME - - # Upload this encrypted backup to S3 - source .venv/bin/activate - pip3 install awscli - export TIMESTAMP=$(date +%s) - cp backup.tar.gz.gpg backup-$TIMESTAMP.tar.gz.gpg - aws s3 cp backup-$TIMESTAMP.tar.gz.gpg s3://openoversight-backups/ - - - &backup_production - run: - name: Backup production server - command: | - source .venv/bin/activate - fab production backup - -jobs: - staging_backup_and_deploy: - machine: - image: ubuntu-2004:202010-01 - steps: - - checkout - - *setup_dependencies - - *add_ssh_keys - - - run: - name: Backup staging server - command: | - source .venv/bin/activate - fab staging backup - - - *encrypt_and_save_backup - - - run: - name: Run migrations and deploy - command: | - source .venv/bin/activate - fab staging migrate - - production_deploy: - machine: - image: ubuntu-2004:202010-01 - steps: - - checkout - - *setup_dependencies - - *add_ssh_keys - - - run: - name: Run migrations and deploy - command: | - source .venv/bin/activate - fab production migrate - - production_backup_only: - docker: - - image: cimg/python:3.9 - steps: - - checkout - - *setup_dependencies - - *add_ssh_keys - - *backup_production - - *encrypt_and_save_backup - -workflows: - staging_cd: - jobs: - - staging_backup_and_deploy: - filters: - branches: - only: - - develop - production_cd: - jobs: - - production_deploy: - filters: - branches: - only: main - weekly: - triggers: - - schedule: - cron: "0 0 * * 0" - filters: - branches: - only: - - main - jobs: - - production_backup_only diff --git a/.github/issue_template.md b/.github/issue_template.md new file mode 100644 index 000000000..aa1b1a7e6 --- /dev/null +++ b/.github/issue_template.md @@ -0,0 +1,10 @@ + +## What issue are you seeing? diff --git a/PULL_REQUEST_TEMPLATE.md b/.github/pull_request_template.md similarity index 54% rename from PULL_REQUEST_TEMPLATE.md rename to .github/pull_request_template.md index aa65bf4cc..a741ae772 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/.github/pull_request_template.md @@ -1,36 +1,27 @@ - -## Status - -Ready for review / in progress +## Fixes issue + ## Description of Changes -Fixes # . - -Changes proposed in this pull request: - - - - - - - ## Notes for Deployment -## Screenshots (if appropriate) -## Tests and linting - - - [ ] I have rebased my changes on current `develop` +## Screenshots (if appropriate) - - [ ] pytests pass in the development environment on my local machine - - [ ] `flake8` checks pass +## Tests and linting + - [ ] This branch is up-to-date with the `develop` branch. + - [ ] `pytest` passes on my local development environment. + - [ ] `pre-commit` passes on my local development environment. diff --git a/.github/workflows/build_deploy.yml b/.github/workflows/build_deploy.yml new file mode 100644 index 000000000..01e810e69 --- /dev/null +++ b/.github/workflows/build_deploy.yml @@ -0,0 +1,74 @@ +name: Publish Docker image and deploy it + +on: + push: + branches: + - develop + - main + +env: + image_name: lucyparsons/openoversight + # env_mapping: {"develop": "staging", "main": "prod"} + env_name: ${{ fromJson(vars.env_mapping)[github.ref_name] }} + # docker_mapping: {"develop": "latest", "main": "stable"} + docker_name: ${{ fromJson(vars.docker_mapping)[github.ref_name] }} + python_version: "3.11" + +jobs: + push_to_registry: + name: Push Docker image to GitHub Packages + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Check out the repo + uses: actions/checkout@v3 + - name: Log in to GitHub Docker Registry + uses: docker/login-action@v2 + with: + registry: https://ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push image + uses: docker/build-push-action@v4 + with: + file: dockerfiles/web/Dockerfile-prod + push: true + build-args: PYTHON_VERSION=${{ env.python_version }} + # Build the slimmer production target + target: production + tags: | + ghcr.io/${{ env.image_name }}:${{ github.sha }} + ghcr.io/${{ env.image_name }}:${{ env.docker_name }} + ghcr.io/${{ env.image_name }}:${{ env.docker_name }}-py${{ env.python_version }} + deploy: + name: Deploy code to respective server + needs: push_to_registry + runs-on: ubuntu-latest + permissions: + packages: read + defaults: + run: + shell: bash + steps: + - name: Check out the repo + uses: actions/checkout@v3 + - name: install fabric + run: pip install fabric2 + - name: setup ssh connection + run: install -m 600 -D /dev/null ~/.ssh/id_ed25519 + # choose SSH key depending on which environment we want to deploy to + - name: ssh key staging + if: ${{ env.env_name == 'staging' }} + run: echo "${{secrets.SSH_STAGING_PRIVATE_KEY}}" > ~/.ssh/id_ed25519 + - name: ssh key prod + if: ${{ env.env_name == 'prod' }} + run: echo "${{secrets.SSH_PROD_PRIVATE_KEY}}" > ~/.ssh/id_ed25519 + # Next three steps use fabric2's "invoke" command which runs tasks defined in tasks.py + - name: backup db + run: invoke backup ${{ env.env_name }} + - name: deploy + run: invoke deploy ${{ env.env_name }} ${{ github.actor }} ${{ secrets.GITHUB_TOKEN }} + - name: cleanup + run: invoke cleanup ${{ env.env_name }} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index e41f872ae..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: CI - -# Controls when the action will run. -on: - # Triggers the workflow on push or pull request events but only for the develop branch - push: - branches: - - develop - - main - pull_request: - branches: - - develop - - main - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "build" - build: - # The type of runner that the job will run on - runs-on: ubuntu-20.04 - env: - FLASK_APP: OpenOversight.app - strategy: - matrix: - python-version: [3.5, 3.6, 3.7, 3.8] - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt -r dev-requirements.txt - - # Run flake8 - - name: Run flake8 - run: flake8 --ignore=E501,E722,W504,W503 - # Runs tests - - name: Run tests - run: FLASK_ENV=testing pytest --doctest-modules -n 4 --dist=loadfile -v OpenOversight/tests/ OpenOversight/app; diff --git a/.github/workflows/test_coverage.yml b/.github/workflows/test_coverage.yml new file mode 100644 index 000000000..e7e72a2c8 --- /dev/null +++ b/.github/workflows/test_coverage.yml @@ -0,0 +1,23 @@ +name: Test Coverage +on: + push: + branches: + - develop + +jobs: + test: + runs-on: ubuntu-latest + env: + FLASK_APP: OpenOversight.app + steps: + - uses: actions/checkout@v3 + with: + python-version: "3.11" + - name: Run tests + run: | + PYTHON_VERSION=${{ matrix.python-version }} make test_with_version + - name: Upload Coverage Results + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.github_token }} + path-to-lcov: OpenOversight/tests/coverage.xml diff --git a/.github/workflows/test_prs.yml b/.github/workflows/test_prs.yml new file mode 100644 index 000000000..976ba2166 --- /dev/null +++ b/.github/workflows/test_prs.yml @@ -0,0 +1,36 @@ +name: CI + +# Controls when the action will run. +on: + pull_request: + branches: + - develop + - main + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + build: + # The type of runner that the job will run on + runs-on: ubuntu-20.04 + env: + FLASK_APP: OpenOversight.app + strategy: + matrix: + python-version: ["3.11"] + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v3 + - name: Run tests + run: PYTHON_VERSION=${{ matrix.python-version }} make test_with_version + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: "3.11" + - uses: pre-commit/action@v3.0.0 diff --git a/.gitignore b/.gitignore index da5707c66..845e11a1d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Credentials dbcred* +service_account_key.json # Local database backups backup/* @@ -40,6 +41,7 @@ var/ .installed.cfg *.egg .mypy_cache +.python-version # PyInstaller # Usually these files are written by a python script from a template @@ -93,7 +95,9 @@ vagrant/puppet/.tmp .vscode/** *.code-workspace +# Operating system files +**/.DS_Store + node_modules/ OpenOverSight/app/static/dist/ - - +static/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..6b8b0b894 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,118 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: check-docstring-first + - id: check-executables-have-shebangs + - id: check-json + - id: check-case-conflict + - id: check-toml + - id: check-merge-conflict + - id: check-xml + - id: check-yaml + - id: end-of-file-fixer + - id: check-symlinks + - id: mixed-line-ending + - id: fix-encoding-pragma + args: + - --remove + - id: pretty-format-json + args: + - --autofix + - id: requirements-txt-fixer + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-check-blanket-noqa + - id: python-check-mock-methods + - id: python-no-eval + - id: python-no-log-warn + + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + name: Run isort to sort imports + files: \.py$ + # To keep consistent with the global isort skip config defined in setup.cfg + exclude: ^build/.*$|^.tox/.*$|^venv/.*$ + args: + - --lines-after-imports=2 + - --profile=black + + - repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + name: Run pydocstyle + args: + - --convention=pep257 + # Do not require docstrings, only check existing ones. (D1) + # Allow for a newline after a docstring. (D202) + # Don't require a summary line. (D205) + # Don't require a period on the first line. (D400) + - --add-ignore=D1,D202,D205,D400 + exclude: tests/ + + - repo: local + hooks: + - id: no-shebang + language: pygrep + name: Do not use shebangs in non-executable files + description: Only executable files should have shebangs (e.g. '#!/usr/bin/env python') + entry: "#!/" + pass_filenames: true + exclude: bin|cli|manage.py|app.py|setup.py|docs|test_data.py + files: \.py$ + + - repo: https://github.com/ikamensh/flynt/ + rev: '1.0.0' + hooks: + - id: flynt + exclude: ^build/.*$|^.tox/.*$|^venv/.*$ + files: \.py$ + args: + - --quiet + + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + args: + - --ignore=E203,E402,E501,E800,W503,W391,E261 + - --select=B,C,E,F,W,T4,B9 + + - repo: https://github.com/psf/black + rev: 23.7.0 + hooks: + - id: black + args: + - --safe + - "--target-version=py311" + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: 'v1.4.1' + hooks: + - id: mypy + + - repo: https://github.com/Riverside-Healthcare/djLint + rev: v1.32.1 + hooks: + - id: djlint-reformat + pass_filenames: false + args: + - OpenOversight/app/templates + - --format-css + - --profile=jinja + - --indent=2 + - --quiet + - id: djlint + require_serial: true + pass_filenames: false + args: + - OpenOversight/app/templates + - --profile=jinja + - --use-gitignore + - --ignore=H006,T028,H031,H021,H013,H011 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0e67882f3..000000000 --- a/.travis.yml +++ /dev/null @@ -1,27 +0,0 @@ -language: python -dist: xenial -sudo: required -python: - - "3.5" - - "3.6" - - "3.7" - - "3.8" -services: - - docker -env: - - DOCKER_COMPOSE_VERSION=1.24.0 -before_install: - - sudo rm /usr/local/bin/docker-compose - - curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` > docker-compose - - chmod +x docker-compose - - sudo mv docker-compose /usr/local/bin -install: - - true -before_script: - - pip install coveralls flake8==3.8.3 - - sudo service postgresql stop -script: - - sudo make test - - flake8 --ignore=E501,E722,W504,W503 -after_success: - - coveralls diff --git a/CONTRIB.md b/CONTRIB.md index 14f92168c..18d294f44 100644 --- a/CONTRIB.md +++ b/CONTRIB.md @@ -1,33 +1,29 @@ # Contributing Guide - First, thanks for being interested in helping us out! If you find an issue you're interested in, feel free to make a comment about how you're thinking of approaching implementing it in the issue and we can give you feedback. Please also read our [code of conduct](/CODE_OF_CONDUCT.md) before getting started. ## Submitting a Pull Request (PR) - When you come to implement your new feature, clone the repository and then create a branch off `develop` locally and add commits to implement your feature. If your git history is not so clean, please do rewrite before you submit your PR - if you're not sure if you need to do this, go ahead and submit and we can let you know when you submit. To submit your changes for review you have to fork the repository, push your new branch there and then create a Pull Request with `OpenOversight:develop` as the target. -Use [PULL_REQUEST_TEMPLATE.md](/PULL_REQUEST_TEMPLATE.md) to create the description for your PR! (The template should populate automatically when you go to open the pull request.) +Use [pull_request_template.md](/.github/pull_request_template.md) to create the description for your PR! (The template should populate automatically when you go to open the pull request.) ### Recommended privacy settings -Whenever you make a commit with `git` the name and email saved locally is stored with that commit and will become part of the public history of the project. This can be an unwanted, for example when using a work computer. We recommond changing the email-settings in the github account at https://github.com/settings/emails and selecting "Keep my email addresses private" as well as "Block command line pushes that expose my email". Also find your github-email address of the form `+@users.noreply.github.com` in that section. Then you can change the email and username stored with your commits by running the following commands -``` +Whenever you make a commit with `git` the name and email saved locally is stored with that commit and will become part of the public history of the project. This can be an unwanted, for example when using a work computer. We recommend changing the email-settings in the github account at https://github.com/settings/emails and selecting "Keep my email addresses private" as well as "Block command line pushes that expose my email". Also find your github-email address of the form `+@users.noreply.github.com` in that section. Then you can change the email and username stored with your commits by running the following commands +```shell git config user.email "" git config user.name "" ``` This will make sure that all commits you make locally are associated with your github account and do not contain any additional identifying information. More detailed information on this topic can be found [here](https://docs.github.com/en/free-pro-team@latest/github/setting-up-and-managing-your-github-user-account/setting-your-commit-email-address). ### Linting / Style Checks +We use [pre-commit](https://pre-commit.com/) for automated linting and style checks. Be sure to [install pre-commit](https://pre-commit.com/#installation) and run `pre-commit install` in your local version of the repository to install our pre-commit checks. This will make sure your commits are always formatted correctly. - `flake8` is a tool for automated linting and style checks. Be sure to run `flake8` and fix any errors before submitting a PR. - - You can run it with `make lint` to execute flake8 from the docker containers. +You can run `pre-commit run --all-files` or `make lint` to run pre-commit over your local codebase, or `pre-commit run` to run it only over the currently stages files. ## Development Environment - You can use our Docker-compose environment to stand up a development OpenOversight. You will need to have Docker installed in order to use the Docker development environment. @@ -38,8 +34,8 @@ Tests are executed via `make test`. If you're switching between the Docker and V To hop into the postgres container, you can do the following: -``` -$ docker exec -it openoversight_postgres_1 /bin/bash +```shell +$ docker exec -it openoversight-postgres-1 bash # psql -d openoversight-dev -U openoversight ``` @@ -47,21 +43,45 @@ or run `make attach`. Similarly to hop into the web container: -``` -$ docker exec -it openoversight_web_1 /bin/bash +```shell +$ docker exec -it openoversight-web-1 bash ``` Once you're done, `make stop` and `make clean` to stop and remove the containers respectively. -## Testing S3 Functionality +## Gmail Requirements +**NOTE:** If you are running on dev and do not currently have a `service_account_key.json` file, create one and leave it empty. The email client will then default to an empty object and simulate emails in the logs. + +For the application to work properly, you will need a [Google Cloud Platform service account](https://cloud.google.com/iam/docs/service-account-overview) that is attached to a GSuite email address. Here are some general tips for working with service accounts: [Link](https://support.google.com/a/answer/7378726?hl=en). +We would suggest that you do not use a personal email address, but instead one that is used strictly for sending out OpenOversight emails. + +You will need to do these two things for the service account to work as a Gmail bot: +1. Enable domain-wide delegation for the service account: [Link](https://support.google.com/a/answer/162106?hl=en) +2. Enable the `https://www.googleapis.com/auth/gmail.send` scope in the Gmail API for your service account: [Link](https://developers.google.com/gmail/api/auth/scopes#scopes) +3. Save the service account key file in OpenOversight's base folder as `service_account_key.json`. The file is in the `.gitignore` file GitHub will not allow you to save it, provided you've named it correctly. +4. For production, save the email address associated with your service account to a variable named `OO_SERVICE_EMAIL` in a `.env` file in the base directory of this repository. For development and testing, update the `OO_SERVICE_EMAIL` variable in the `docker-compose.yml` file. + +Example `.env` variable: +```shell +OO_SERVICE_EMAIL="sample_email@domain.com" +``` +In addition to needing a service account email, you also need an admin email address so that users have someone to reach out to if an action is taken on their account that needs to be reversed or addressed. +For production, save the email address associated with your admin account to a variable named `OO_HELP_EMAIL` in a `.env` file in the base directory of this repository. For development and testing, update the `OO_HELP_EMAIL` variable in the `docker-compose.yml` file. + +Example `.env` variable: +```shell +OO_HELP_EMAIL="sample_admin_email@domain.com" +``` + +## Testing S3 Functionality We use an S3 bucket for image uploads. If you are working on functionality involving image uploads, then you should follow the "S3 Image Hosting" section in [DEPLOY.md](/DEPLOY.md) to make a test S3 bucket on Amazon Web Services. Once you have done this, you can put your AWS credentials in the following environmental variables: -```sh +```shell $ export S3_BUCKET_NAME=openoversight-test $ export AWS_ACCESS_KEY_ID=testtest $ export AWS_SECRET_ACCESS_KEY=testtest @@ -72,12 +92,11 @@ Now when you run `make dev` as usual in the same session, you will be able to su your test bucket. ## Database commands - Running `make dev` will create the database and persist it into your local filesystem. You can access your PostgreSQL development database via psql using: -```sh +```shell psql -h localhost -d openoversight-dev -U openoversight --password ``` @@ -88,74 +107,91 @@ In the event that you need to create or delete the test data, you can do that wi or `$ python test_data.py --cleanup` to delete the data +Within the database we use [`timestamptz`](https://stackoverflow.com/a/48069726) fields for timestamps. To make sure that you are setting timestamps in the correct timezone, set the environment variable `TIMEZONE` to your respective [Olson-style timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#list) so that you can make sure any DST conversions are handled by PostgreSQL. + ### Migrating the Database +You'll first have to start the Docker instance for the OpenOversight app using the command `make start`. To do this, you'll need to be in the base folder of the repository (the one that houses the `Makefile`). + +```shell +$ make start +docker-compose build +... +docker-compose up -d +[+] Running 2/0 + ✔ Container openoversight-postgres-1 Running 0.0s + ✔ Container openoversight-web-1 Running +``` -If you e.g. add a new column or table, you'll need to migrate the database using the Flask CLI. First we need to 'stamp' the current version of the database: +From here on out, we'll be using the Flask CLI. First we need to 'stamp' the current version of the database: -```sh -$ cd OpenOversight/ # change directory to source dir +```shell +$ docker exec -it openoversight-web-1 bash # 'openoversight-web-1' is the name of the app container seen in the step above $ flask db stamp head +$ flask db migrate -m "[THE NAME OF YOUR MIGRATION]" # NOTE: Slugs are limited to 40 characters and will be truncated after the limit ``` (Hint: If you get errors when running `flask` commands, e.g. because of differing Python versions, you may need to run the commands in the docker container by prefacing them as so: `docker exec -it openoversight_web_1 flask db stamp head`) -Next make your changes to the database models in `models.py`. You'll then generate the migrations: +Next make your changes to the database models in `OpenOversight/app/models/database.py`. You'll then generate the migrations: -```sh +```shell $ flask db migrate ``` And then you should inspect/edit the migrations. You can then apply the migrations: -```sh +```shell $ flask db upgrade ``` -You can also downgrade the database using `flask db downgrade`. +You can also downgrade the database using: + +```shell +flask db downgrade +``` ## Using a Virtual Environment One way to avoid hitting version incompatibility errors when running `flask` commands is to use a virtualenv. See [Python Packaging user guidelines](https://packaging.python.org/guides/installing-using-pip-and-virtualenv/) for instructions on installing virtualenv. After installing virtualenv, you can create a virtual environment by navigating to the OpenOversight directory and running the below -```bash +```shell python3 -m virtualenv env ``` -Confirm you're in the virtualenv by running +Confirm you're in the virtualenv by running -```bash -which python +```shell +which python ``` -The response should point to your `env` directory. -If you want to exit the virtualenv, run +The response should point to your `env` directory. +If you want to exit the virtualenv, run -```bash +```shell deactivate ``` To reactivate the virtualenv, run -```bash +```shell source env/bin/activate ``` -While in the virtualenv, you can install project dependencies by running +While in the virtualenv, you can install project dependencies by running -```bash +```shell pip install -r requirements.txt ``` and -```bash +```shell pip install -r dev-requirements.txt ``` ## OpenOversight Management Interface - In addition to generating database migrations, the Flask CLI can be used to run additional commands: -```sh +```shell $ flask --help Usage: flask [OPTIONS] COMMAND [ARGS]... @@ -163,11 +199,11 @@ Usage: flask [OPTIONS] COMMAND [ARGS]... Provides commands from Flask, extensions, and the application. Loads the application defined in the FLASK_APP environment variable, or from a - wsgi.py file. Setting the FLASK_ENV environment variable to 'development' + wsgi.py file. Setting the ENV environment variable to 'development' will enable debug mode. $ export FLASK_APP=hello.py - $ export FLASK_ENV=development + $ export ENV=development $ flask run Options: @@ -187,7 +223,7 @@ Commands: In development, you can make an administrator account without having to confirm your email: -```sh +```shell $ flask make-admin-user Username: redshiftzero Email: jen@redshiftzero.com @@ -200,20 +236,20 @@ Administrator redshiftzero successfully added In `docker-compose.yml`, below the line specifying the port number, add the following lines to the `web` service: ```yml stdin_open: true - tty: true + tty: true ``` -Also in `docker-compose.yml`, below the line specifying the `FLASK_ENV`, add the following to the `environment` portion of the `web` service: +Also in `docker-compose.yml`, below the line specifying the `ENV`, add the following to the `environment` portion of the `web` service: ```yml FLASK_DEBUG: 0 ``` The above line disables the werkzeug reloader, which can otherwise cause a bug when you place a breakpoint in code that loads at import time, such as classes. The werkzeug reloader will start one pdb process at import time and one when you navigate to the class. This makes it impossible to interact with the pdb prompt, but we can fix it by disabling the reloader. - + To set a breakpoint in OpenOversight, first import the pdb module by adding `import pdb` to the file you want to debug. Call `pdb.set_trace()` on its own line wherever you want to break for debugging. Next, in your terminal run `docker ps` to find the container id of the `openoversight_web` image, then run `docker attach ${container_id}` to connect to the debugger in your terminal. You can now use pdb prompts to step through the app. ## Debugging OpenOversight - Use pdb with a test If you want to run an individual test in debug mode, use the below command. -```bash +```shell docker-compose run --rm web pytest --pdb -v tests/ -k ``` @@ -221,11 +257,11 @@ where `` is the name of a single test function, such as `test_ac Similarly, you can run all the tests in a file by specifying the file path: -```bash -docker-compose run --rm web pytest --pdb -v path/to/test/file +```shell +docker-compose run --rm web pytest --pdb -v path/to/test/file ``` -where `path/to/test/file` is the relative file path, minus the initial `OpenOversight`, such as +where `path/to/test/file` is the relative file path, minus the initial `OpenOversight`, such as `tests/routes/test_officer_and_department.py`. -Again, add `import pdb` to the file you want to debug, then write `pdb.set_trace()` wherever you want to drop a breakpoint. Once the test is up and running in your terminal, you can debug it using pdb prompts. \ No newline at end of file +Again, add `import pdb` to the file you want to debug, then write `pdb.set_trace()` wherever you want to drop a breakpoint. Once the test is up and running in your terminal, you can debug it using pdb prompts. diff --git a/Makefile b/Makefile index 29c195f58..e47bac6b7 100644 --- a/Makefile +++ b/Makefile @@ -3,11 +3,19 @@ export UID=$(shell id -u) default: build start create_db populate test stop clean .PHONY: build -build: ## Build containers +build: ## Build containers docker-compose build +.PHONY: build_with_version +build_with_version: create_empty_secret + docker-compose build --build-arg MAKE_PYTHON_VERSION=$(PYTHON_VERSION) + +.PHONY: test_with_version +test_with_version: build_with_version assets + ENV=testing docker-compose run --rm web pytest --cov=OpenOversight --cov-report xml:OpenOversight/tests/coverage.xml --doctest-modules -n 4 --dist=loadfile -v OpenOversight/tests/ + .PHONY: start -start: build ## Run containers +start: build ## Run containers docker-compose up -d .PHONY: create_db @@ -18,7 +26,7 @@ create_db: start done @echo "Postgres is up" ## Creating database - docker-compose run --rm web python ../create_db.py + docker-compose run --rm web alembic --config=./OpenOversight/migrations/alembic.ini stamp head .PHONY: assets assets: @@ -28,41 +36,37 @@ assets: dev: build start create_db populate .PHONY: populate -populate: create_db ## Build and run containers +populate: create_db ## Build and run containers @until docker-compose exec postgres psql -h localhost -U openoversight -c '\l' postgres &>/dev/null; do \ echo "Postgres is unavailable - sleeping..."; \ sleep 1; \ done @echo "Postgres is up" ## Populate database with test data - docker-compose run --rm web python ../test_data.py -p + docker-compose run --rm web python ./test_data.py -p .PHONY: test -test: start ## Run tests +test: start ## Run tests if [ -z "$(name)" ]; then \ - if [ "$$(uname)" == "Darwin" ]; then \ - FLASK_ENV=testing docker-compose run --rm web pytest --doctest-modules -n $$(sysctl -n hw.logicalcpu) --dist=loadfile -v tests/ app; \ - else \ - FLASK_ENV=testing docker-compose run --rm web pytest --doctest-modules -n $$(nproc --all) --dist=loadfile -v tests/ app; \ - fi; \ + ENV=testing docker-compose run --rm web pytest --cov --doctest-modules -n auto --dist=loadfile -v OpenOversight/tests/; \ else \ - FLASK_ENV=testing docker-compose run --rm web pytest --doctest-modules -v tests/ app -k $(name); \ + ENV=testing docker-compose run --rm web pytest --cov --doctest-modules -v OpenOversight/tests/ -k $(name); \ fi .PHONY: lint -lint: - docker-compose run --no-deps --rm web /bin/bash -c 'flake8; mypy app --config="../mypy.ini"' +lint: + pre-commit run --all-files -.PHONY: cleanassets -cleanassets: +.PHONY: clean_assets +clean_assets: rm -rf ./OpenOversight/app/static/dist/ .PHONY: stop -stop: ## Stop containers +stop: ## Stop containers docker-compose stop .PHONY: clean -clean: cleanassets stop ## Remove containers +clean: clean_assets stop ## Remove containers docker-compose rm -f .PHONY: clean_all @@ -81,5 +85,13 @@ help: ## Print this message and exit | sort \ | column -s ':' -t +.PHONY: attach attach: - docker-compose exec postgres psql -h localhost -U openoversight openoversight-dev \ No newline at end of file + docker-compose exec postgres psql -h localhost -U openoversight openoversight-dev + +.PHONY: create_empty_secret +create_empty_secret: ## This is needed to make sure docker doesn't create an empty directory, or delete that directory first + touch service_account_key.json || \ + (echo "Need to delete that empty directory first"; \ + sudo rm -d service_account_key.json/; \ + touch service_account_key.json) diff --git a/OpenOversight/app/__init__.py b/OpenOversight/app/__init__.py index ec02e540c..5d89e1028 100644 --- a/OpenOversight/app/__init__.py +++ b/OpenOversight/app/__init__.py @@ -1,114 +1,142 @@ -import datetime import logging -from logging.handlers import RotatingFileHandler import os +from http import HTTPStatus +from logging.handlers import RotatingFileHandler -import bleach -from bleach_allowlist import markdown_tags, markdown_attrs -from flask import Flask, render_template +from flask import Flask, jsonify, render_template, request from flask_bootstrap import Bootstrap +from flask_compress import Compress from flask_limiter import Limiter from flask_limiter.util import get_remote_address from flask_login import LoginManager -from flask_mail import Mail from flask_migrate import Migrate from flask_sitemap import Sitemap from flask_wtf.csrf import CSRFProtect -import markdown as _markdown -from markupsafe import Markup -from .config import config +from OpenOversight.app.email_client import EmailClient +from OpenOversight.app.filters import instantiate_filters +from OpenOversight.app.models.config import config +from OpenOversight.app.models.database import db +from OpenOversight.app.utils.constants import MEGABYTE, SERVICE_ACCOUNT_FILE bootstrap = Bootstrap() -mail = Mail() +compress = Compress() login_manager = LoginManager() -login_manager.session_protection = 'strong' -login_manager.login_view = 'auth.login' +login_manager.session_protection = "strong" +login_manager.login_view = "auth.login" -limiter = Limiter(key_func=get_remote_address, - default_limits=["100 per minute", "5 per second"]) +limiter = Limiter( + key_func=get_remote_address, default_limits=["100 per minute", "5 per second"] +) sitemap = Sitemap() csrf = CSRFProtect() -def create_app(config_name='default'): +def create_app(config_name="default"): app = Flask(__name__) + # Creates and adds the Config object of the correct type to app.config app.config.from_object(config[config_name]) - config[config_name].init_app(app) - from .models import db # noqa bootstrap.init_app(app) - mail.init_app(app) + csrf.init_app(app) db.init_app(app) - login_manager.init_app(app) + # This allows the application to run without creating an email client if it is + # in testing or dev mode and the service account file is empty. + service_account_file_size = os.path.getsize(SERVICE_ACCOUNT_FILE) + EmailClient( + config=app.config, + dev=app.debug and service_account_file_size == 0, + testing=app.testing, + ) limiter.init_app(app) + login_manager.init_app(app) sitemap.init_app(app) - csrf.init_app(app) + compress.init_app(app) + + from OpenOversight.app.main import main as main_blueprint - from .main import main as main_blueprint # noqa app.register_blueprint(main_blueprint) - from .auth import auth as auth_blueprint # noqa - app.register_blueprint(auth_blueprint, url_prefix='/auth') + from OpenOversight.app.auth import auth as auth_blueprint + + app.register_blueprint(auth_blueprint, url_prefix="/auth") - max_log_size = 10 * 1024 * 1024 # start new log file after 10 MB + max_log_size = 10 * MEGABYTE # start new log file after 10 MB num_logs_to_keep = 5 - file_handler = RotatingFileHandler('/tmp/openoversight.log', 'a', - max_log_size, num_logs_to_keep) + file_handler = RotatingFileHandler( + "/tmp/openoversight.log", "a", max_log_size, num_logs_to_keep + ) file_handler.setFormatter( logging.Formatter( - '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]' + "%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]" ) ) app.logger.setLevel(logging.INFO) file_handler.setLevel(logging.INFO) app.logger.addHandler(file_handler) - app.logger.info('OpenOversight startup') + app.logger.info("OpenOversight startup") # Also log when endpoints are getting hit hard limiter.logger.addHandler(file_handler) - @app.errorhandler(404) - def page_not_found(e): - return render_template('404.html'), 404 - - @app.errorhandler(403) - def forbidden(e): - return render_template('403.html'), 403 - - @app.errorhandler(500) - def internal_error(e): - return render_template('500.html'), 500 - - @app.errorhandler(429) - def rate_exceeded(e): - return render_template('429.html'), 429 - - # create jinja2 filter for titles with multiple capitals - @app.template_filter('capfirst') - def capfirst_filter(s): - return s[0].capitalize() + s[1:] # only change 1st letter - - @app.template_filter('get_age') - def get_age_from_birth_year(birth_year): - if birth_year: - return int(datetime.datetime.now().year - birth_year) - - @app.template_filter('markdown') - def markdown(text): - html = bleach.clean(_markdown.markdown(text), markdown_tags, markdown_attrs) - return Markup(html) + # Define error handlers + def create_errorhandler(code, error, template): + """ + Create an error handler that returns a JSON or a template response + based on the request "Accept" header. + :param code: status code to handle + :param error: response error message, if JSON + :param template: template response + """ + + def _handler_method(e): + if request.accept_mimetypes.best == "application/json": + return jsonify(error=error), code + return render_template(template), code + + return _handler_method + + error_handlers = [ + (HTTPStatus.FORBIDDEN, HTTPStatus.FORBIDDEN.phrase, "403.html"), + (HTTPStatus.NOT_FOUND, HTTPStatus.NOT_FOUND.phrase, "404.html"), + ( + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + HTTPStatus.REQUEST_ENTITY_TOO_LARGE.phrase, + "413.html", + ), + (HTTPStatus.TOO_MANY_REQUESTS, HTTPStatus.TOO_MANY_REQUESTS.phrase, "429.html"), + ( + HTTPStatus.INTERNAL_SERVER_ERROR, + HTTPStatus.INTERNAL_SERVER_ERROR.phrase, + "500.html", + ), + ] + for code, error, template in error_handlers: + # Pass generated errorhandler function to @app.errorhandler decorator + app.errorhandler(code)(create_errorhandler(code, error, template)) + + # Instantiate filters + instantiate_filters(app) # Add commands - Migrate(app, db, os.path.join(os.path.dirname(__file__), '..', 'migrations')) # Adds 'db' command - from .commands import (make_admin_user, link_images_to_department, - link_officers_to_department, bulk_add_officers, - add_department, add_job_title, advanced_csv_import) + Migrate( + app, db, os.path.join(os.path.dirname(__file__), "..", "migrations") + ) # Adds 'db' command + from OpenOversight.app.commands import ( + add_department, + add_job_title, + advanced_csv_import, + bulk_add_officers, + link_images_to_department, + link_officers_to_department, + make_admin_user, + ) + app.cli.add_command(make_admin_user) app.cli.add_command(link_images_to_department) app.cli.add_command(link_officers_to_department) diff --git a/OpenOversight/app/auth/__init__.py b/OpenOversight/app/auth/__init__.py index 888425d78..09585ca9a 100644 --- a/OpenOversight/app/auth/__init__.py +++ b/OpenOversight/app/auth/__init__.py @@ -1,5 +1,6 @@ from flask import Blueprint -auth = Blueprint('auth', __name__) # noqa -from . import views # noqa +auth = Blueprint("auth", __name__) + +from . import views # noqa: E402,F401 diff --git a/OpenOversight/app/auth/forms.py b/OpenOversight/app/auth/forms.py index 8924efb99..1aafd74bd 100644 --- a/OpenOversight/app/auth/forms.py +++ b/OpenOversight/app/auth/forms.py @@ -1,105 +1,146 @@ from flask_wtf import FlaskForm as Form -from wtforms import StringField, PasswordField, BooleanField, SubmitField -from wtforms.ext.sqlalchemy.fields import QuerySelectField -from wtforms.validators import DataRequired, Length, Email, Regexp, EqualTo, Optional -from wtforms import ValidationError +from wtforms import ( + BooleanField, + PasswordField, + StringField, + SubmitField, + ValidationError, +) +from wtforms.validators import DataRequired, Email, EqualTo, Length, Optional, Regexp +from wtforms_sqlalchemy.fields import QuerySelectField -from ..models import User -from ..utils import dept_choices +from OpenOversight.app.models.database import User +from OpenOversight.app.utils.db import dept_choices class LoginForm(Form): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) - password = PasswordField('Password', validators=[DataRequired()]) - remember_me = BooleanField('Keep me logged in') - submit = SubmitField('Log In') + email = StringField("Email", validators=[DataRequired(), Length(1, 64), Email()]) + password = PasswordField("Password", validators=[DataRequired()]) + remember_me = BooleanField("Keep me logged in") + submit = SubmitField("Log In") class RegistrationForm(Form): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) - username = StringField('Username', validators=[ - DataRequired(), Length(6, 64), Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, - 'Usernames must have only letters, ' - 'numbers, dots or underscores')]) - password = PasswordField('Password', validators=[ - DataRequired(), Length(8, 64), - EqualTo('password2', message='Passwords must match.')]) - password2 = PasswordField('Confirm password', validators=[DataRequired()]) - submit = SubmitField('Register') + email = StringField("Email", validators=[DataRequired(), Length(1, 64), Email()]) + username = StringField( + "Username", + validators=[ + DataRequired(), + Length(6, 64), + Regexp( + "^[A-Za-z][A-Za-z0-9_.]*$", + 0, + "Usernames must have only letters, " "numbers, dots or underscores", + ), + ], + ) + password = PasswordField( + "Password", + validators=[ + DataRequired(), + Length(8, 64), + EqualTo("password2", message="Passwords must match."), + ], + ) + password2 = PasswordField("Confirm password", validators=[DataRequired()]) + submit = SubmitField("Register") def validate_email(self, field): - if User.query.filter_by(email=field.data).first(): - raise ValidationError('Email already registered.') + if User.by_email(field.data).first(): + raise ValidationError("Email already registered.") def validate_username(self, field): - if User.query.filter_by(username=field.data).first(): - raise ValidationError('Username already in use.') + if User.by_username(field.data).first(): + raise ValidationError("Username already in use.") class ChangePasswordForm(Form): - old_password = PasswordField('Old password', validators=[DataRequired()]) - password = PasswordField('New password', validators=[ - DataRequired(), Length(8, 64), - EqualTo('password2', message='Passwords must match')]) - password2 = PasswordField('Confirm new password', validators=[DataRequired()]) - submit = SubmitField('Update Password') + old_password = PasswordField("Old password", validators=[DataRequired()]) + password = PasswordField( + "New password", + validators=[ + DataRequired(), + Length(8, 64), + EqualTo("password2", message="Passwords must match"), + ], + ) + password2 = PasswordField("Confirm new password", validators=[DataRequired()]) + submit = SubmitField("Update Password") class PasswordResetRequestForm(Form): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) - submit = SubmitField('Reset Password') + email = StringField("Email", validators=[DataRequired(), Length(1, 64), Email()]) + submit = SubmitField("Reset Password") class PasswordResetForm(Form): - email = StringField('Email', validators=[DataRequired(), Length(1, 64), - Email()]) - password = PasswordField('New Password', validators=[ - DataRequired(), EqualTo('password2', message='Passwords must match')]) - password2 = PasswordField('Confirm password', validators=[DataRequired()]) - submit = SubmitField('Reset Password') + email = StringField("Email", validators=[DataRequired(), Length(1, 64), Email()]) + password = PasswordField( + "New Password", + validators=[ + DataRequired(), + EqualTo("password2", message="Passwords must match"), + ], + ) + password2 = PasswordField("Confirm password", validators=[DataRequired()]) + submit = SubmitField("Reset Password") def validate_email(self, field): - if User.query.filter_by(email=field.data).first() is None: - raise ValidationError('Unknown email address.') + if User.by_email(field.data).first() is None: + raise ValidationError("Unknown email address.") class ChangeEmailForm(Form): - email = StringField('New Email', validators=[DataRequired(), Length(1, 64), - Email()]) - password = PasswordField('Password', validators=[DataRequired()]) - submit = SubmitField('Update Email Address') + email = StringField( + "New Email", validators=[DataRequired(), Length(1, 64), Email()] + ) + password = PasswordField("Password", validators=[DataRequired()]) + submit = SubmitField("Update Email Address") def validate_email(self, field): - if User.query.filter_by(email=field.data).first(): - raise ValidationError('Email already registered.') + if User.by_email(field.data).first(): + raise ValidationError("Email already registered.") class ChangeDefaultDepartmentForm(Form): - dept_pref = QuerySelectField('Default Department (Optional)', validators=[Optional()], - query_factory=dept_choices, get_label='name', allow_blank=True) - submit = SubmitField('Update Default') + dept_pref = QuerySelectField( + "Default Department (Optional)", + validators=[Optional()], + query_factory=dept_choices, + get_label="name", + allow_blank=True, + ) + submit = SubmitField("Update Default") class EditUserForm(Form): - is_area_coordinator = BooleanField('Is area coordinator?', false_values={'False', 'false', ''}) - ac_department = QuerySelectField('Department', validators=[Optional()], - query_factory=dept_choices, get_label='name', allow_blank=True) - is_administrator = BooleanField('Is administrator?', false_values={'False', 'false', ''}) - is_disabled = BooleanField('Disabled?', false_values={'False', 'false', ''}) - approved = BooleanField('Approved?', false_values={'False', 'false', ''}) - confirmed = BooleanField('Confirmed?', false_values={'False,', 'false', ''}) - submit = SubmitField(label='Update', false_values={'False', 'false', ''}) - resend = SubmitField(label='Resend', false_values={'False', 'false', ''}) - delete = SubmitField(label='Delete', false_values={'False', 'false', ''}) - - def validate(self): - success = super(EditUserForm, self).validate() + is_area_coordinator = BooleanField( + "Is area coordinator?", false_values={"False", "false", ""} + ) + ac_department = QuerySelectField( + "Department", + validators=[Optional()], + query_factory=dept_choices, + get_label="name", + allow_blank=True, + ) + is_administrator = BooleanField( + "Is administrator?", false_values={"False", "false", ""} + ) + is_disabled = BooleanField("Disabled?", false_values={"False", "false", ""}) + approved = BooleanField("Approved?", false_values={"False", "false", ""}) + confirmed = BooleanField("Confirmed?", false_values={"False", "false", ""}) + submit = SubmitField(label="Update", false_values={"False", "false", ""}) + resend = SubmitField(label="Resend", false_values={"False", "false", ""}) + delete = SubmitField(label="Delete", false_values={"False", "false", ""}) + + def validate(self, extra_validators=None): + success = super(EditUserForm, self).validate(extra_validators=None) if self.is_area_coordinator.data and not self.ac_department.data: self.is_area_coordinator.errors = list(self.is_area_coordinator.errors) - self.is_area_coordinator.errors.append('Area coordinators must have a department') + self.is_area_coordinator.errors.append( + "Area coordinators must have a department" + ) success = False return success diff --git a/OpenOversight/app/auth/views.py b/OpenOversight/app/auth/views.py index 85b779160..15d75d717 100644 --- a/OpenOversight/app/auth/views.py +++ b/OpenOversight/app/auth/views.py @@ -1,16 +1,44 @@ -from flask import render_template, redirect, request, url_for, flash, current_app -from flask_login import login_user, logout_user, login_required, \ - current_user -from . import auth -from .. import sitemap -from ..models import User, db -from ..email import send_email -from .forms import LoginForm, RegistrationForm, ChangePasswordForm,\ - PasswordResetRequestForm, PasswordResetForm, ChangeEmailForm, ChangeDefaultDepartmentForm, \ - EditUserForm -from .utils import admin_required -from ..utils import set_dynamic_default - +from http import HTTPMethod, HTTPStatus + +from flask import ( + current_app, + flash, + redirect, + render_template, + request, + session, + url_for, +) +from flask_login import current_user, login_required, login_user, logout_user + +from OpenOversight.app import sitemap +from OpenOversight.app.auth import auth +from OpenOversight.app.auth.forms import ( + ChangeDefaultDepartmentForm, + ChangeEmailForm, + ChangePasswordForm, + EditUserForm, + LoginForm, + PasswordResetForm, + PasswordResetRequestForm, + RegistrationForm, +) +from OpenOversight.app.email_client import EmailClient +from OpenOversight.app.models.database import User, db +from OpenOversight.app.models.emails import ( + AdministratorApprovalEmail, + ChangeEmailAddressEmail, + ChangePasswordEmail, + ConfirmAccountEmail, + ConfirmedUserEmail, + ResetPasswordEmail, +) +from OpenOversight.app.utils.auth import admin_required +from OpenOversight.app.utils.forms import set_dynamic_default +from OpenOversight.app.utils.general import validate_redirect_url + + +js_loads = ["js/zxcvbn.js", "js/password.js"] sitemap_endpoints = [] @@ -22,168 +50,182 @@ def sitemap_include(view): @sitemap.register_generator def static_routes(): for endpoint in sitemap_endpoints: - yield 'auth.' + endpoint, {} + yield "auth." + endpoint, {} @auth.before_app_request def before_request(): - if current_user.is_authenticated \ - and not current_user.confirmed \ - and request.endpoint \ - and request.endpoint[:5] != 'auth.' \ - and request.endpoint != 'static': - return redirect(url_for('auth.unconfirmed')) + if ( + current_user.is_authenticated + and not current_user.confirmed + and request.endpoint + and request.endpoint[:5] != "auth." + and request.endpoint != "static" + ): + return redirect(url_for("auth.unconfirmed")) -@auth.route('/unconfirmed') +@auth.route("/unconfirmed") def unconfirmed(): if current_user.is_anonymous or current_user.confirmed: - return redirect(url_for('main.index')) - if current_app.config['APPROVE_REGISTRATIONS']: - return render_template('auth/unapproved.html') + return redirect(url_for("main.index")) + if current_app.config["APPROVE_REGISTRATIONS"]: + return render_template("auth/unapproved.html") else: - return render_template('auth/unconfirmed.html') + return render_template("auth/unconfirmed.html") @sitemap_include -@auth.route('/login', methods=['GET', 'POST']) +@auth.route("/login", methods=[HTTPMethod.GET, HTTPMethod.POST]) def login(): form = LoginForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() + user = User.by_email(form.email.data).first() if user is not None and user.verify_password(form.password.data): - login_user(user, form.remember_me.data) - return redirect(request.args.get('next') or url_for('main.index')) - flash('Invalid username or password.') + if user.is_active: + login_user(user, form.remember_me.data) + next_url = validate_redirect_url(session.get("next")) + return redirect(next_url or url_for("main.index")) + else: + flash("User has been disabled.") + else: + flash("Invalid username or password.") else: current_app.logger.info(form.errors) - return render_template('auth/login.html', form=form) + return render_template("auth/login.html", form=form) -@auth.route('/logout') +@auth.route("/logout") @login_required def logout(): logout_user() - flash('You have been logged out.') - return redirect(url_for('main.index')) + flash("You have been logged out.") + return redirect(url_for("main.index")) @sitemap_include -@auth.route('/register', methods=['GET', 'POST']) +@auth.route("/register", methods=[HTTPMethod.GET, HTTPMethod.POST]) def register(): - jsloads = ['js/zxcvbn.js', 'js/password.js'] form = RegistrationForm() if form.validate_on_submit(): - user = User(email=form.email.data, - username=form.username.data, - password=form.password.data, - approved=False if current_app.config['APPROVE_REGISTRATIONS'] else True) + user = User( + email=form.email.data, + username=form.username.data, + password=form.password.data, + approved=False if current_app.config["APPROVE_REGISTRATIONS"] else True, + ) db.session.add(user) db.session.commit() - if current_app.config['APPROVE_REGISTRATIONS']: + if current_app.config["APPROVE_REGISTRATIONS"]: admins = User.query.filter_by(is_administrator=True).all() for admin in admins: - send_email(admin.email, 'New user registered', - 'auth/email/new_registration', user=user, admin=admin) - flash('Once an administrator approves your registration, you will ' - 'receive a confirmation email to activate your account.') + EmailClient.send_email( + AdministratorApprovalEmail(admin.email, user=user, admin=admin) + ) + flash( + "Once an administrator approves your registration, you will " + "receive a confirmation email to activate your account." + ) else: token = user.generate_confirmation_token() - send_email(user.email, 'Confirm Your Account', - 'auth/email/confirm', user=user, token=token) - flash('A confirmation email has been sent to you.') - return redirect(url_for('auth.login')) + EmailClient.send_email( + ConfirmAccountEmail(user.email, user=user, token=token) + ) + flash("A confirmation email has been sent to you.") + return redirect(url_for("auth.login")) else: current_app.logger.info(form.errors) - return render_template('auth/register.html', form=form, jsloads=jsloads) + return render_template("auth/register.html", form=form, jsloads=js_loads) -@auth.route('/confirm/', methods=['GET']) +@auth.route("/confirm/", methods=[HTTPMethod.GET]) @login_required def confirm(token): if current_user.confirmed: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) if current_user.confirm(token): admins = User.query.filter_by(is_administrator=True).all() for admin in admins: - send_email(admin.email, 'New user confirmed', - 'auth/email/new_confirmation', user=current_user, admin=admin) - flash('You have confirmed your account. Thanks!') + EmailClient.send_email( + ConfirmedUserEmail(admin.email, user=current_user, admin=admin) + ) + flash("You have confirmed your account. Thanks!") else: - flash('The confirmation link is invalid or has expired.') - return redirect(url_for('main.index')) + flash("The confirmation link is invalid or has expired.") + return redirect(url_for("main.index")) -@auth.route('/confirm') +@auth.route("/confirm") @login_required def resend_confirmation(): token = current_user.generate_confirmation_token() - send_email(current_user.email, 'Confirm Your Account', - 'auth/email/confirm', user=current_user, token=token) - flash('A new confirmation email has been sent to you.') - return redirect(url_for('main.index')) + EmailClient.send_email( + ConfirmAccountEmail(current_user.email, user=current_user, token=token) + ) + flash("A new confirmation email has been sent to you.") + return redirect(url_for("main.index")) -@auth.route('/change-password', methods=['GET', 'POST']) +@auth.route("/change-password", methods=[HTTPMethod.GET, HTTPMethod.POST]) @login_required def change_password(): - jsloads = ['js/zxcvbn.js', 'js/password.js'] form = ChangePasswordForm() if form.validate_on_submit(): if current_user.verify_password(form.old_password.data): current_user.password = form.password.data db.session.add(current_user) db.session.commit() - flash('Your password has been updated.') - return redirect(url_for('main.index')) + flash("Your password has been updated.") + EmailClient.send_email( + ChangePasswordEmail(current_user.email, user=current_user) + ) + return redirect(url_for("main.index")) else: - flash('Invalid password.') + flash("Invalid password.") else: current_app.logger.info(form.errors) - return render_template("auth/change_password.html", form=form, jsloads=jsloads) + return render_template("auth/change_password.html", form=form, jsloads=js_loads) -@auth.route('/reset', methods=['GET', 'POST']) +@auth.route("/reset", methods=[HTTPMethod.GET, HTTPMethod.POST]) def password_reset_request(): if not current_user.is_anonymous: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) form = PasswordResetRequestForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() + user = User.by_email(form.email.data).first() if user: token = user.generate_reset_token() - send_email(user.email, 'Reset Your Password', - 'auth/email/reset_password', - user=user, token=token, - next=request.args.get('next')) - flash('An email with instructions to reset your password has been ' - 'sent to you.') - return redirect(url_for('auth.login')) + EmailClient.send_email( + ResetPasswordEmail(user.email, user=user, token=token) + ) + flash("An email with instructions to reset your password has been sent to you.") + return redirect(url_for("auth.login")) else: current_app.logger.info(form.errors) - return render_template('auth/reset_password.html', form=form) + return render_template("auth/reset_password.html", form=form) -@auth.route('/reset/', methods=['GET', 'POST']) +@auth.route("/reset/", methods=[HTTPMethod.GET, HTTPMethod.POST]) def password_reset(token): if not current_user.is_anonymous: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) form = PasswordResetForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() + user = User.by_email(form.email.data).first() if user is None: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) if user.reset_password(token, form.password.data): - flash('Your password has been updated.') - return redirect(url_for('auth.login')) + flash("Your password has been updated.") + return redirect(url_for("auth.login")) else: - return redirect(url_for('main.index')) + return redirect(url_for("main.index")) else: current_app.logger.info(form.errors) - return render_template('auth/reset_password.html', form=form) + return render_template("auth/reset_password.html", form=form) -@auth.route('/change-email', methods=['GET', 'POST']) +@auth.route("/change-email", methods=[HTTPMethod.GET, HTTPMethod.POST]) @login_required def change_email_request(): form = ChangeEmailForm() @@ -191,30 +233,32 @@ def change_email_request(): if current_user.verify_password(form.password.data): new_email = form.email.data token = current_user.generate_email_change_token(new_email) - send_email(new_email, 'Confirm your email address', - 'auth/email/change_email', - user=current_user, token=token) - flash('An email with instructions to confirm your new email ' - 'address has been sent to you.') - return redirect(url_for('main.index')) + EmailClient.send_email( + ChangeEmailAddressEmail(new_email, user=current_user, token=token) + ) + flash( + "An email with instructions to confirm your new email " + "address has been sent to you." + ) + return redirect(url_for("main.index")) else: - flash('Invalid email or password.') + flash("Invalid email or password.") else: current_app.logger.info(form.errors) return render_template("auth/change_email.html", form=form) -@auth.route('/change-email/') +@auth.route("/change-email/") @login_required def change_email(token): if current_user.change_email(token): - flash('Your email address has been updated.') + flash("Your email address has been updated.") else: - flash('Invalid request.') - return redirect(url_for('main.index')) + flash("Invalid request.") + return redirect(url_for("main.index")) -@auth.route('/change-dept/', methods=['GET', 'POST']) +@auth.route("/change-dept/", methods=[HTTPMethod.GET, HTTPMethod.POST]) @login_required def change_dept(): form = ChangeDefaultDepartmentForm() @@ -227,85 +271,92 @@ def change_dept(): current_user.dept_pref = None db.session.add(current_user) db.session.commit() - flash('Updated!') - return redirect(url_for('main.index')) + flash("Updated!") + return redirect(url_for("main.index")) else: current_app.logger.info(form.errors) - return render_template('auth/change_dept_pref.html', form=form) + return render_template("auth/change_dept_pref.html", form=form) -@auth.route('/users/', methods=['GET']) +@auth.route("/users/", methods=[HTTPMethod.GET]) @admin_required def get_users(): - if request.args.get('page'): - page = int(request.args.get('page')) - else: - page = 1 - USERS_PER_PAGE = int(current_app.config['USERS_PER_PAGE']) - users = User.query.order_by(User.username) \ - .paginate(page, USERS_PER_PAGE, False) + page = int(request.args.get("page", 1)) + users = User.query.order_by(User.username).paginate( + page=page, per_page=current_app.config["USERS_PER_PAGE"], error_out=False + ) - return render_template('auth/users.html', objects=users) + return render_template("auth/users.html", objects=users) -@auth.route('/users/', methods=['GET', 'POST']) +@auth.route("/users/", methods=[HTTPMethod.GET, HTTPMethod.POST]) @admin_required def edit_user(user_id): user = User.query.get(user_id) if not user: - return render_template('404.html'), 404 + return render_template("404.html"), HTTPStatus.NOT_FOUND - if request.method == 'GET': + if request.method == HTTPMethod.GET: form = EditUserForm(obj=user) - return render_template('auth/user.html', user=user, form=form) - elif request.method == 'POST': + return render_template("auth/user.html", user=user, form=form) + elif request.method == HTTPMethod.POST: form = EditUserForm() if form.delete.data: # forward to confirm delete - return redirect(url_for('auth.delete_user', user_id=user.id)) + return redirect(url_for("auth.delete_user", user_id=user.id)) elif form.resend.data: return admin_resend_confirmation(user) elif form.submit.data: if form.validate_on_submit(): # prevent user from removing own admin rights (or disabling account) if user.id == current_user.id: - flash('You cannot edit your own account!') + flash("You cannot edit your own account!") form = EditUserForm(obj=user) - return render_template('auth/user.html', user=user, form=form) - if current_app.config['APPROVE_REGISTRATIONS'] and form.approved.data and not user.approved and not user.confirmed: - admin_resend_confirmation(user) + return render_template("auth/user.html", user=user, form=form) + already_approved = user.approved form.populate_obj(user) db.session.add(user) db.session.commit() - flash('{} has been updated!'.format(user.username)) - return redirect(url_for('auth.edit_user', user_id=user.id)) + + # automatically send a confirmation email when approving an + # unconfirmed user + if ( + current_app.config["APPROVE_REGISTRATIONS"] + and not already_approved + and user.approved + and not user.confirmed + ): + admin_resend_confirmation(user) + + flash(f"{user.username} has been updated!") + + return redirect(url_for("auth.edit_user", user_id=user.id)) else: - flash('Invalid entry') - return render_template('auth/user.html', user=user, form=form) + flash("Invalid entry") + return render_template("auth/user.html", user=user, form=form) -@auth.route('/users//delete', methods=['GET', 'POST']) +@auth.route("/users//delete", methods=[HTTPMethod.GET, HTTPMethod.POST]) @admin_required def delete_user(user_id): user = User.query.get(user_id) if not user or user.is_administrator: - return render_template('403.html'), 403 - if request.method == 'POST': + return render_template("403.html"), HTTPStatus.FORBIDDEN + if request.method == HTTPMethod.POST: username = user.username db.session.delete(user) db.session.commit() - flash('User {} has been deleted!'.format(username)) - return redirect(url_for('auth.get_users')) + flash(f"User {username} has been deleted!") + return redirect(url_for("auth.get_users")) - return render_template('auth/user_delete.html', user=user) + return render_template("auth/user_delete.html", user=user) def admin_resend_confirmation(user): if user.confirmed: - flash('User {} is already confirmed.'.format(user.username)) + flash(f"User {user.username} is already confirmed.") else: token = user.generate_confirmation_token() - send_email(user.email, 'Confirm Your Account', - 'auth/email/confirm', user=user, token=token) - flash('A new confirmation email has been sent to {}.'.format(user.email)) - return redirect(url_for('auth.get_users')) + EmailClient.send_email(ConfirmAccountEmail(user.email, user=user, token=token)) + flash(f"A new confirmation email has been sent to {user.email}.") + return redirect(url_for("auth.get_users")) diff --git a/OpenOversight/app/commands.py b/OpenOversight/app/commands.py index 52c50f92e..2ad99a6bb 100644 --- a/OpenOversight/app/commands.py +++ b/OpenOversight/app/commands.py @@ -1,30 +1,45 @@ -from __future__ import print_function - import csv import sys from builtins import input -from datetime import datetime, date -from dateutil.parser import parse +from datetime import date, datetime from getpass import getpass from typing import Dict, List import click +import us +from dateutil.parser import parse from flask import current_app from flask.cli import with_appcontext -from .models import db, Assignment, Department, Officer, User, Salary, Job -from .utils import get_officer, str_is_true, normalize_gender, prompt_yes_no - -from .csv_imports import import_csv_files +from OpenOversight.app.csv_imports import import_csv_files +from OpenOversight.app.models.database import ( + Assignment, + Department, + Image, + Job, + Officer, + Salary, + Unit, + User, + db, +) +from OpenOversight.app.utils.constants import ( + ENCODING_UTF_8, + KEY_ENV, + KEY_ENV_PROD, + KEY_ENV_TESTING, +) +from OpenOversight.app.utils.db import get_officer +from OpenOversight.app.utils.general import normalize_gender, prompt_yes_no, str_is_true @click.command() @with_appcontext def make_admin_user(): - "Add confirmed administrator account" + """Add confirmed administrator account.""" while True: username = input("Username: ") - user = User.query.filter_by(username=username).one_or_none() + user = User.by_username(username).one_or_none() if user: print("Username is already in use") else: @@ -32,7 +47,7 @@ def make_admin_user(): while True: email = input("Email: ") - user = User.query.filter_by(email=email).one_or_none() + user = User.by_email(email).one_or_none() if user: print("Email address already in use") else: @@ -46,20 +61,23 @@ def make_admin_user(): break print("Passwords did not match") - u = User(username=username, email=email, password=password, - confirmed=True, is_administrator=True) + u = User( + username=username, + email=email, + password=password, + confirmed=True, + is_administrator=True, + ) db.session.add(u) db.session.commit() - print("Administrator {} successfully added".format(username)) - current_app.logger.info('Administrator {} added with email {}'.format(username, - email)) + print(f"Administrator {username} successfully added") + current_app.logger.info(f"Administrator {username} added with email {email}") @click.command() @with_appcontext def link_images_to_department(): - """Link existing images to first department""" - from app.models import Image, db + """Link existing images to first department.""" images = Image.query.all() print("Linking images to first department:") for image in images: @@ -74,9 +92,7 @@ def link_images_to_department(): @click.command() @with_appcontext def link_officers_to_department(): - """Links officers and unit_ids to first department""" - from app.models import Officer, Unit, db - + """Links officers and unit_ids to first department.""" officers = Officer.query.all() units = Unit.query.all() @@ -91,8 +107,8 @@ def link_officers_to_department(): class ImportLog: - updated_officers = {} # type: Dict[int, List] - created_officers = {} # type: Dict[int, List] + updated_officers: Dict[int, List] = {} + created_officers: Dict[int, List] = {} @classmethod def log_change(cls, officer, msg): @@ -111,20 +127,22 @@ def log_new_officer(cls, officer): @classmethod def print_create_logs(cls): officers = Officer.query.filter( - Officer.id.in_(cls.created_officers.keys())).all() + Officer.id.in_(cls.created_officers.keys()) + ).all() for officer in officers: - print('Created officer {}'.format(officer)) + print(f"Created officer {officer}") for msg in cls.created_officers[officer.id]: - print(' --->', msg) + print(" --->", msg) @classmethod def print_update_logs(cls): officers = Officer.query.filter( - Officer.id.in_(cls.updated_officers.keys())).all() + Officer.id.in_(cls.updated_officers.keys()) + ).all() for officer in officers: - print('Updates to officer {}:'.format(officer)) + print(f"Updates to officer {officer}:") for msg in cls.updated_officers[officer.id]: - print(' --->', msg) + print(" --->", msg) @classmethod def print_logs(cls): @@ -150,85 +168,96 @@ def row_has_data(row, required_fields, optional_fields): return False -def set_field_from_row(row, obj, attribute, allow_blank=True, fieldname=None): - fieldname = fieldname or attribute - if fieldname in row and (row[fieldname] or allow_blank): +def set_field_from_row(row, obj, attribute, allow_blank=True, field_name=None): + field_name = field_name or attribute + if field_name in row and (row[field_name] or allow_blank): try: - val = datetime.strptime(row[fieldname], '%Y-%m-%d').date() + val = datetime.strptime(row[field_name], "%Y-%m-%d").date() except ValueError: - val = row[fieldname] - if attribute == 'gender': + val = row[field_name] + if attribute == "gender": val = normalize_gender(val) setattr(obj, attribute, val) def update_officer_from_row(row, officer, update_static_fields=False): - def update_officer_field(fieldname): - if fieldname not in row: + def update_officer_field(officer_field_name): + if officer_field_name not in row: return - if fieldname == 'gender': - row[fieldname] = normalize_gender(row[fieldname]) - - if row[fieldname] and getattr(officer, fieldname) != row[fieldname]: + if officer_field_name == "gender": + row[officer_field_name] = normalize_gender(row[officer_field_name]) + if ( + row[officer_field_name] + and getattr(officer, officer_field_name) != row[officer_field_name] + ): ImportLog.log_change( officer, - 'Updated {}: {} --> {}'.format( - fieldname, getattr(officer, fieldname), row[fieldname])) - setattr(officer, fieldname, row[fieldname]) + "Updated {}: {} --> {}".format( + officer_field_name, + getattr(officer, officer_field_name), + row[officer_field_name], + ), + ) + setattr(officer, officer_field_name, row[officer_field_name]) # Name and gender are the only potentially changeable fields, so update those - update_officer_field('last_name') - update_officer_field('first_name') - update_officer_field('middle_initial') + update_officer_field("last_name") + update_officer_field("first_name") + update_officer_field("middle_initial") - update_officer_field('suffix') - update_officer_field('gender') + update_officer_field("suffix") + update_officer_field("gender") # The rest should be static static_fields = [ - 'unique_internal_identifier', - 'race', - 'employment_date', - 'birth_year' + "unique_internal_identifier", + "race", + "employment_date", + "birth_year", ] - for fieldname in static_fields: - if fieldname in row: - if row[fieldname] == '': - row[fieldname] = None - old_value = getattr(officer, fieldname) - # If we're expecting a date type, attempt to parse row[fieldname] as a datetime - # This also normalizes all date formats, ensuring the following comparison works properly + for field_name in static_fields: + if field_name in row: + if row[field_name] == "": + row[field_name] = None + old_value = getattr(officer, field_name) + # If we're expecting a date type, attempt to parse row[field_name] as a + # datetime. This normalizes all date formats, ensuring the following + # comparison works properly if isinstance(old_value, (date, datetime)): try: - new_value = parse(row[fieldname]) + new_value = parse(row[field_name]) if isinstance(old_value, date): new_value = new_value.date() except Exception as e: - msg = 'Field {} is a date-type, but "{}" was specified for Officer {} {} and cannot be parsed as a date-type.\nError message from dateutil: {}'.format( - fieldname, - row[fieldname], - officer.first_name, - officer.last_name, - e + msg = ( + 'Field {} is a date-type, but "{}" was specified for ' + "Officer {} {} and cannot be parsed as a date-type.\nError " + "message from dateutil: {}".format( + field_name, + row[field_name], + officer.first_name, + officer.last_name, + e, + ) ) raise Exception(msg) else: - new_value = row[fieldname] + new_value = row[field_name] if old_value is None: - update_officer_field(fieldname) + update_officer_field(field_name) elif str(old_value) != str(new_value): - msg = 'Officer {} {} has differing {} field. Old: {}, new: {}'.format( + msg = "Officer {} {} has differing {} field. Old: {}, new: {}".format( officer.first_name, officer.last_name, - fieldname, + field_name, old_value, - new_value + new_value, ) if update_static_fields: print(msg) - update_officer_field(fieldname) + update_officer_field(field_name) else: raise Exception(msg) @@ -240,15 +269,15 @@ def create_officer_from_row(row, department_id): officer = Officer() officer.department_id = department_id - set_field_from_row(row, officer, 'last_name', allow_blank=False) - set_field_from_row(row, officer, 'first_name', allow_blank=False) - set_field_from_row(row, officer, 'middle_initial') - set_field_from_row(row, officer, 'suffix') - set_field_from_row(row, officer, 'race') - set_field_from_row(row, officer, 'gender') - set_field_from_row(row, officer, 'employment_date', allow_blank=False) - set_field_from_row(row, officer, 'birth_year') - set_field_from_row(row, officer, 'unique_internal_identifier') + set_field_from_row(row, officer, "last_name", allow_blank=False) + set_field_from_row(row, officer, "first_name", allow_blank=False) + set_field_from_row(row, officer, "middle_initial") + set_field_from_row(row, officer, "suffix") + set_field_from_row(row, officer, "race") + set_field_from_row(row, officer, "gender") + set_field_from_row(row, officer, "employment_date", allow_blank=False) + set_field_from_row(row, officer, "birth_year") + set_field_from_row(row, officer, "unique_internal_identifier") db.session.add(officer) db.session.flush() @@ -259,7 +288,8 @@ def create_officer_from_row(row, department_id): def is_equal(a, b): - """exhaustive equality checking, originally to compare a sqlalchemy result object of various types to a csv string + """Run an exhaustive equality check, originally to compare a sqlalchemy result + object of various types to a csv string. Note: Stringifying covers object cases (as in the datetime example below) >>> is_equal("1", 1) # string == int True @@ -272,6 +302,7 @@ def is_equal(a, b): >>> is_equal(datetime(2020, 1, 1), "2020-01-01 00:00:00") # datetime == string True """ + def try_else_false(comparable): try: return comparable(a, b) @@ -280,55 +311,68 @@ def try_else_false(comparable): except ValueError: return False - return any([ - try_else_false(lambda _a, _b: str(_a) == str(_b)), - try_else_false(lambda _a, _b: int(_a) == int(_b)), - try_else_false(lambda _a, _b: float(_a) == float(_b)) - ]) + return any( + [ + try_else_false(lambda _a, _b: str(_a) == str(_b)), + try_else_false(lambda _a, _b: int(_a) == int(_b)), + try_else_false(lambda _a, _b: float(_a) == float(_b)), + ] + ) def process_assignment(row, officer, compare=False): assignment_fields = { - 'required': [], - 'optional': [ - 'job_title', - 'star_no', - 'unit_id', - 'star_date', - 'resign_date'] + "required": [], + "optional": ["job_title", "star_no", "unit_id", "start_date", "resign_date"], } # See if the row has assignment data - if row_has_data(row, assignment_fields['required'], assignment_fields['optional']): + if row_has_data(row, assignment_fields["required"], assignment_fields["optional"]): add_assignment = True if compare: # Get existing assignments for officer and compare to row data - assignments = db.session.query(Assignment, Job)\ - .filter(Assignment.job_id == Job.id)\ - .filter_by(officer_id=officer.id)\ - .all() - for (assignment, job) in assignments: - assignment_fieldnames = ['star_no', 'unit_id', 'star_date', 'resign_date'] + assignments = ( + db.session.query(Assignment, Job) + .filter(Assignment.job_id == Job.id) + .filter_by(officer_id=officer.id) + .all() + ) + for assignment, job in assignments: + assignment_fieldnames = [ + "star_no", + "unit_id", + "start_date", + "resign_date", + ] i = 0 for fieldname in assignment_fieldnames: current = getattr(assignment, fieldname) # Test if fields match between row and existing assignment - if (current and fieldname in row and is_equal(row[fieldname], current)) or \ - (not current and (fieldname not in row or not row[fieldname])): + if ( + current + and fieldname in row + and is_equal(row[fieldname], current) + ) or (not current and (fieldname not in row or not row[fieldname])): i += 1 if i == len(assignment_fieldnames): job_title = job.job_title - if (job_title and row.get('job_title', 'Not Sure') == job_title) or \ - (not job_title and ('job_title' not in row or not row['job_title'])): + if ( + job_title and row.get("job_title", "Not Sure") == job_title + ) or ( + not job_title + and ("job_title" not in row or not row["job_title"]) + ): # Found match, so don't add new assignment add_assignment = False if add_assignment: - job = Job.query\ - .filter_by(job_title=row.get('job_title', 'Not Sure'), - department_id=officer.department_id)\ - .one_or_none() + job = Job.query.filter_by( + job_title=row.get("job_title", "Not Sure"), + department_id=officer.department_id, + ).one_or_none() if not job: - num_existing_ranks = len(Job.query.filter_by(department_id=officer.department_id).all()) + num_existing_ranks = len( + Job.query.filter_by(department_id=officer.department_id).all() + ) if num_existing_ranks > 0: auto_order = num_existing_ranks + 1 else: @@ -337,37 +381,34 @@ def process_assignment(row, officer, compare=False): job = Job( is_sworn_officer=False, department_id=officer.department_id, - order=auto_order + order=auto_order, ) - set_field_from_row(row, job, 'job_title', allow_blank=False) + set_field_from_row(row, job, "job_title", allow_blank=False) db.session.add(job) db.session.flush() # create new assignment assignment = Assignment() assignment.officer_id = officer.id assignment.job_id = job.id - set_field_from_row(row, assignment, 'star_no') - set_field_from_row(row, assignment, 'unit_id') - set_field_from_row(row, assignment, 'star_date', allow_blank=False) - set_field_from_row(row, assignment, 'resign_date', allow_blank=False) + set_field_from_row(row, assignment, "star_no") + set_field_from_row(row, assignment, "unit_id") + set_field_from_row(row, assignment, "start_date", allow_blank=False) + set_field_from_row(row, assignment, "resign_date", allow_blank=False) db.session.add(assignment) db.session.flush() - ImportLog.log_change(officer, 'Added assignment: {}'.format(assignment)) + ImportLog.log_change(officer, f"Added assignment: {assignment}") def process_salary(row, officer, compare=False): salary_fields = { - 'required': [ - 'salary', - 'salary_year', - 'salary_is_fiscal_year'], - 'optional': ['overtime_pay'] + "required": ["salary", "salary_year", "salary_is_fiscal_year"], + "optional": ["overtime_pay"], } # See if the row has salary data - if row_has_data(row, salary_fields['required'], salary_fields['optional']): - is_fiscal_year = str_is_true(row['salary_is_fiscal_year']) + if row_has_data(row, salary_fields["required"], salary_fields["optional"]): + is_fiscal_year = str_is_true(row["salary_is_fiscal_year"]) add_salary = True if compare: @@ -375,14 +416,27 @@ def process_salary(row, officer, compare=False): salaries = Salary.query.filter_by(officer_id=officer.id).all() for salary in salaries: from decimal import Decimal + print(vars(salary)) print(row) - if Decimal('%.2f' % salary.salary) == Decimal('%.2f' % float(row['salary'])) and \ - salary.year == int(row['salary_year']) and \ - salary.is_fiscal_year == is_fiscal_year and \ - ((salary.overtime_pay and 'overtime_pay' in row and - Decimal('%.2f' % salary.overtime_pay) == Decimal('%.2f' % float(row['overtime_pay']))) or - (not salary.overtime_pay and ('overtime_pay' not in row or not row['overtime_pay']))): + if ( + Decimal(f"{salary.salary:.2f}") + == Decimal(f"{float(row['salary']):.2f}") + and salary.year == int(row["salary_year"]) + and salary.is_fiscal_year == is_fiscal_year + and ( + ( + salary.overtime_pay + and "overtime_pay" in row + and Decimal(f"{salary.overtime_pay:.2f}") + == Decimal(f"{float(row['overtime_pay']):.2f}") + ) + or ( + not salary.overtime_pay + and ("overtime_pay" not in row or not row["overtime_pay"]) + ) + ) + ): # Found match, so don't add new salary add_salary = False @@ -390,82 +444,108 @@ def process_salary(row, officer, compare=False): # create new salary salary = Salary( officer_id=officer.id, - salary=float(row['salary']), - year=int(row['salary_year']), + salary=float(row["salary"]), + year=int(row["salary_year"]), is_fiscal_year=is_fiscal_year, ) - if 'overtime_pay' in row and row['overtime_pay']: - salary.overtime_pay = float(row['overtime_pay']) + if "overtime_pay" in row and row["overtime_pay"]: + salary.overtime_pay = float(row["overtime_pay"]) db.session.add(salary) db.session.flush() - ImportLog.log_change(officer, 'Added salary: {}'.format(salary)) + ImportLog.log_change(officer, f"Added salary: {salary}") @click.command() -@click.argument('filename') -@click.option('--no-create', is_flag=True, help='only update officers; do not create new ones') -@click.option('--update-by-name', is_flag=True, help='update officers by first and last name (useful when star_no or unique_internal_identifier are not available)') -@click.option('--update-static-fields', is_flag=True, help='allow updating normally-static fields like race, birth year, etc.') +@click.argument("filename") +@click.option( + "--no-create", is_flag=True, help="only update officers; do not create new ones" +) +@click.option( + "--update-by-name", + is_flag=True, + help="update officers by first and last name (useful when star_no or " + "unique_internal_identifier are not available)", +) +@click.option( + "--update-static-fields", + is_flag=True, + help="allow updating normally-static fields like race, birth year, etc.", +) @with_appcontext def bulk_add_officers(filename, no_create, update_by_name, update_static_fields): """Add or update officers from a CSV file.""" - encoding = 'utf-8' + encoding = ENCODING_UTF_8 # handles unicode errors that can occur when the file was made in Excel - with open(filename, 'r') as f: - if u'\ufeff' in f.readline(): - encoding = 'utf-8-sig' + with open(filename, "r") as f: + if "\ufeff" in f.readline(): + encoding = "utf-8-sig" - with open(filename, 'r', encoding=encoding) as f: + with open(filename, "r", encoding=encoding) as f: ImportLog.clear_logs() csvfile = csv.DictReader(f) departments = {} required_fields = [ - 'department_id', - 'first_name', - 'last_name', + "department_id", + "first_name", + "last_name", ] # Assert required fields are in CSV file for field in required_fields: if field not in csvfile.fieldnames: - raise Exception('Missing required field {}'.format(field)) - if (not update_by_name - and 'star_no' not in csvfile.fieldnames - and 'unique_internal_identifier' not in csvfile.fieldnames): - raise Exception('CSV file must include either badge numbers or unique identifiers for officers') + raise Exception(f"Missing required field {field}") + if ( + not update_by_name + and "star_no" not in csvfile.fieldnames + and "unique_internal_identifier" not in csvfile.fieldnames + ): + raise Exception( + "CSV file must include either badge numbers or unique identifiers for " + "officers" + ) for row in csvfile: - department_id = row['department_id'] + department_id = row["department_id"] department = departments.get(department_id) - if row['department_id'] not in departments: + if row["department_id"] not in departments: department = Department.query.filter_by(id=department_id).one_or_none() if department: departments[department_id] = department else: - raise Exception('Department ID {} not found'.format(department_id)) + raise Exception(f"Department ID {department_id} not found") if not update_by_name: - # check for existing officer based on unique ID or name/badge - if 'unique_internal_identifier' in csvfile.fieldnames and row['unique_internal_identifier']: + # Check for existing officer based on unique ID or name/badge + if ( + "unique_internal_identifier" in csvfile.fieldnames + and row["unique_internal_identifier"] + ): officer = Officer.query.filter_by( department_id=department_id, - unique_internal_identifier=row['unique_internal_identifier'] + unique_internal_identifier=row["unique_internal_identifier"], ).one_or_none() - elif 'star_no' in csvfile.fieldnames and row['star_no']: - officer = get_officer(department_id, row['star_no'], - row['first_name'], row['last_name']) + elif "star_no" in csvfile.fieldnames and row["star_no"]: + officer = get_officer( + department_id, + row["star_no"], + row["first_name"], + row["last_name"], + ) else: - raise Exception('Officer {} {} missing badge number and unique identifier'.format(row['first_name'], - row['last_name'])) + raise Exception( + "Officer {} {} missing badge number and unique identifier".format( + row["first_name"], row["last_name"] + ) + ) else: officer = Officer.query.filter_by( department_id=department_id, - last_name=row['last_name'], - first_name=row['first_name'] + last_name=row["last_name"], + first_name=row["first_name"], ).one_or_none() if officer: @@ -474,8 +554,10 @@ def bulk_add_officers(filename, no_create, update_by_name, update_static_fields) create_officer_from_row(row, department_id) ImportLog.print_logs() - if current_app.config['ENV'] == 'testing' or prompt_yes_no("Do you want to commit the above changes?"): - print("Commiting changes.") + if current_app.config[KEY_ENV] == KEY_ENV_TESTING or prompt_yes_no( + "Do you want to commit the above changes?" + ): + print("Committing changes.") db.session.commit() else: print("Aborting changes.") @@ -486,7 +568,12 @@ def bulk_add_officers(filename, no_create, update_by_name, update_static_fields) @click.command() -@click.argument("department-name") +@click.argument("department-name", required=True) +@click.argument( + "department-state", + type=click.Choice([state.abbr for state in us.STATES]), + required=True, +) @click.option("--officers-csv", type=click.Path(exists=True)) @click.option("--assignments-csv", type=click.Path(exists=True)) @click.option("--salaries-csv", type=click.Path(exists=True)) @@ -497,6 +584,7 @@ def bulk_add_officers(filename, no_create, update_by_name, update_static_fields) @with_appcontext def advanced_csv_import( department_name, + department_state, officers_csv, assignments_csv, salaries_csv, @@ -506,8 +594,8 @@ def advanced_csv_import( overwrite_assignments, ): """ - Add or update officers, assignments, salaries, links and incidents from csv - files in the department DEPARTMENT_NAME. + Add or update officers, assignments, salaries, links and incidents from + csv files in the department using the DEPARTMENT_NAME and DEPARTMENT_STATE. The csv files are treated as the source of truth. Existing entries might be overwritten as a result, backing up the @@ -515,45 +603,61 @@ def advanced_csv_import( See the documentation before running the command. """ - if force_create and current_app.config["ENV"] == "production": + if force_create and current_app.config[KEY_ENV] == KEY_ENV_PROD: raise Exception("--force-create cannot be used in production!") import_csv_files( department_name, + department_state, officers_csv, assignments_csv, salaries_csv, links_csv, incidents_csv, force_create, - overwrite_assignments + overwrite_assignments, ) @click.command() -@click.argument('name') -@click.argument('short_name') -@click.argument('unique_internal_identifier', required=False) +@click.argument("name", required=True) +@click.argument("short_name", required=True) +@click.argument( + "state", type=click.Choice([state.abbr for state in us.STATES]), required=True +) +@click.argument("unique_internal_identifier", required=False) @with_appcontext -def add_department(name, short_name, unique_internal_identifier): +def add_department(name, short_name, state, unique_internal_identifier): """Add a new department to OpenOversight.""" - dept = Department(name=name, short_name=short_name, unique_internal_identifier_label=unique_internal_identifier) + dept = Department( + name=name, + short_name=short_name, + state=state.upper(), + unique_internal_identifier_label=unique_internal_identifier, + ) db.session.add(dept) db.session.commit() - print("Department added with id {}".format(dept.id)) + print(f"Department added with id {dept.id}") @click.command() -@click.argument('department_id') -@click.argument('job_title') -@click.argument('is_sworn_officer', type=click.Choice(["true", "false"], case_sensitive=False)) -@click.argument('order', type=int) +@click.argument("department_id") +@click.argument("job_title") +@click.argument( + "is_sworn_officer", type=click.Choice(["true", "false"], case_sensitive=False) +) +@click.argument("order", type=int) @with_appcontext def add_job_title(department_id, job_title, is_sworn_officer, order): """Add a rank to a department.""" department = Department.query.filter_by(id=department_id).one_or_none() - is_sworn = (is_sworn_officer == "true") - job = Job(job_title=job_title, is_sworn_officer=is_sworn, order=order, department=department) + is_sworn = is_sworn_officer == "true" + job = Job( + job_title=job_title, + is_sworn_officer=is_sworn, + order=order, + department=department, + ) db.session.add(job) - print('Added {} to {}'.format(job.job_title, department.name)) + print(f"Added {job.job_title} to {department.name}") db.session.commit() diff --git a/OpenOversight/app/config.py b/OpenOversight/app/config.py deleted file mode 100644 index ad62a2971..000000000 --- a/OpenOversight/app/config.py +++ /dev/null @@ -1,82 +0,0 @@ -import os -from dotenv import load_dotenv, find_dotenv - -load_dotenv(find_dotenv()) - -basedir = os.path.abspath(os.path.dirname(__file__)) - - -class BaseConfig(object): - # DB SETUP - SQLALCHEMY_TRACK_MODIFICATIONS = False - - # pagination - OFFICERS_PER_PAGE = os.environ.get('OFFICERS_PER_PAGE', 20) - USERS_PER_PAGE = os.environ.get('USERS_PER_PAGE', 20) - - # Form Settings - WTF_CSRF_ENABLED = True - SECRET_KEY = os.environ.get('SECRET_KEY', 'changemeplzorelsehax') - - # Mail Settings - MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp.googlemail.com') - MAIL_PORT = 587 - MAIL_USE_TLS = True - MAIL_USERNAME = os.environ.get('MAIL_USERNAME') - MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') - OO_MAIL_SUBJECT_PREFIX = os.environ.get('OO_MAIL_SUBJECT_PREFIX', '[OpenOversight]') - OO_MAIL_SENDER = os.environ.get('OO_MAIL_SENDER', 'OpenOversight ') - # OO_ADMIN = os.environ.get('OO_ADMIN') - - # AWS Settings - AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') - AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') - AWS_DEFAULT_REGION = os.environ.get('AWS_DEFAULT_REGION') - S3_BUCKET_NAME = os.environ.get('S3_BUCKET_NAME') - - # Upload Settings - MAX_CONTENT_LENGTH = 50 * 1024 * 1024 - ALLOWED_EXTENSIONS = set(['jpeg', 'jpg', 'jpe', 'png', 'gif', 'webp']) - - # User settings - APPROVE_REGISTRATIONS = os.environ.get('APPROVE_REGISTRATIONS', False) - - SEED = 666 - - @staticmethod - def init_app(app): - pass - - -class DevelopmentConfig(BaseConfig): - DEBUG = True - SQLALCHEMY_ECHO = True - SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') - NUM_OFFICERS = 15000 - SITEMAP_URL_SCHEME = 'http' - - -class TestingConfig(BaseConfig): - TESTING = True - SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' - WTF_CSRF_ENABLED = False - NUM_OFFICERS = 120 - APPROVE_REGISTRATIONS = False - SITEMAP_URL_SCHEME = 'http' - - -class ProductionConfig(BaseConfig): - SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI') - SITEMAP_URL_SCHEME = 'https' - - @classmethod - def init_app(cls, app): # pragma: no cover - config.init_app(app) - - -config = { - 'development': DevelopmentConfig, - 'testing': TestingConfig, - 'production': ProductionConfig, -} -config['default'] = config.get(os.environ.get('FLASK_ENV', ""), DevelopmentConfig) diff --git a/OpenOversight/app/csv_imports.py b/OpenOversight/app/csv_imports.py index 9eb117a30..8b365dcc1 100644 --- a/OpenOversight/app/csv_imports.py +++ b/OpenOversight/app/csv_imports.py @@ -4,7 +4,18 @@ from sqlalchemy.exc import SQLAlchemyError -from .model_imports import ( +from OpenOversight.app.models.database import ( + Assignment, + Department, + Incident, + Job, + Link, + Officer, + Salary, + Unit, + db, +) +from OpenOversight.app.models.database_imports import ( create_assignment_from_dict, create_incident_from_dict, create_link_from_dict, @@ -18,17 +29,6 @@ update_officer_from_dict, update_salary_from_dict, ) -from .models import ( - Assignment, - Department, - Incident, - Job, - Link, - Officer, - Salary, - Unit, - db, -) def _create_or_update_model( @@ -58,18 +58,14 @@ def _check_provided_fields(dict_reader, required_fields, optional_fields, csv_na missing_required = set(required_fields) - set(dict_reader.fieldnames) if len(missing_required) > 0: raise Exception( - "Missing mandatory field(s) {} in {} csv.".format( - list(missing_required), csv_name - ) + f"Missing mandatory field(s) {list(missing_required)} in {csv_name} csv." ) unexpected_fields = set(dict_reader.fieldnames) - set( required_fields + optional_fields ) if len(unexpected_fields) > 0: raise Exception( - "Received unexpected field(s) {} in {} csv.".format( - list(unexpected_fields), csv_name - ) + f"Received unexpected field(s) {list(unexpected_fields)} in {csv_name} csv." ) @@ -94,6 +90,7 @@ def _csv_reader(csv_filename): def _handle_officers_csv( officers_csv: str, department_name: str, + department_state: str, department_id: int, id_to_officer, force_create, @@ -103,7 +100,9 @@ def _handle_officers_csv( with _csv_reader(officers_csv) as csv_reader: _check_provided_fields( csv_reader, - required_fields=["id", "department_name"] if not force_create else ["id"], + required_fields=["id", "department_name", "department_state"] + if not force_create + else ["id"], optional_fields=[ "last_name", "first_name", @@ -115,7 +114,9 @@ def _handle_officers_csv( "birth_year", "unique_internal_identifier", "department_name", - # the following are unused, but allowed since they are included in the csv output + "department_state", + # the following are unused, but allowed since they are included in the + # csv output "badge_number", "unique_identifier", "job_title", @@ -130,6 +131,7 @@ def _handle_officers_csv( # can only update department with given name if not force_create: assert row["department_name"] == department_name + assert row["department_state"] == department_state row["department_id"] = department_id connection_id = row["id"] if row["id"].startswith("#"): @@ -146,8 +148,8 @@ def _handle_officers_csv( new_officers[connection_id] = officer counter += 1 if counter % 1000 == 0: - print("Processed {} officers.".format(counter)) - print("Done with officers. Processed {} rows.".format(counter)) + print(f"Processed {counter} officers.") + print(f"Done with officers. Processed {counter} rows.") return new_officers @@ -162,7 +164,7 @@ def _handle_assignments_csv( with _csv_reader(assignments_csv) as csv_reader: field_names = csv_reader.fieldnames if "start_date" in field_names: - field_names[field_names.index("start_date")] = "star_date" + field_names[field_names.index("start_date")] = "start_date" if "badge_number" in field_names: field_names[field_names.index("badge_number")] = "star_no" if "end_date" in field_names: @@ -181,7 +183,7 @@ def _handle_assignments_csv( "star_no", "unit_id", "unit_name", - "star_date", + "start_date", "resign_date", "officer_unique_identifier", ], @@ -193,8 +195,8 @@ def _handle_assignments_csv( job_title_to_id = { job.job_title.strip().lower(): job.id for job in jobs_for_department } - unit_descrip_to_id = { - unit.descrip.strip().lower(): unit.id + unit_description_to_id = { + unit.description.strip().lower(): unit.id for unit in Unit.query.filter_by(department_id=department_id).all() } if overwrite_assignments: @@ -211,7 +213,8 @@ def _handle_assignments_csv( ) if len(wrong_department) > 0: raise Exception( - "Referenced {} officers in assignment csv that belong to different department. Example ids: {}".format( + "Referenced {} officers in assignment csv that belong to different " + "department. Example ids: {}".format( len(wrong_department), ", ".join(map(str, list(wrong_department)[:3])), ) @@ -231,7 +234,7 @@ def _handle_assignments_csv( csv_reader = rows else: existing_assignments = ( - Assignment.query.join(Assignment.baseofficer) + Assignment.query.join(Assignment.base_officer) .filter(Officer.department_id == department_id) .all() ) @@ -253,17 +256,17 @@ def _handle_assignments_csv( ) elif row.get("unit_name"): unit_name = row["unit_name"].strip() - descrip = unit_name.lower() - unit_id = unit_descrip_to_id.get(descrip) + description = unit_name.lower() + unit_id = unit_description_to_id.get(description) if unit_id is None: unit = Unit( - descrip=unit_name, + description=unit_name, department_id=officer.department_id, ) db.session.add(unit) db.session.flush() unit_id = unit.id - unit_descrip_to_id[descrip] = unit_id + unit_description_to_id[description] = unit_id row["unit_id"] = unit_id job_title = row["job_title"].strip().lower() job_id = job_title_to_id.get(job_title) @@ -300,8 +303,8 @@ def _handle_assignments_csv( ) counter += 1 if counter % 1000 == 0: - print("Processed {} assignments.".format(counter)) - print("Done with assignments. Processed {} rows.".format(counter)) + print(f"Processed {counter} assignments.") + print(f"Done with assignments. Processed {counter} rows.") def _handle_salaries( @@ -343,13 +346,14 @@ def _handle_salaries( ) counter += 1 if counter % 1000 == 0: - print("Processed {} salaries.".format(counter)) - print("Done with salaries. Processed {} rows.".format(counter)) + print(f"Processed {counter} salaries.") + print(f"Done with salaries. Processed {counter} rows.") def _handle_incidents_csv( incidents_csv: str, department_name: str, + department_state: str, department_id: int, all_officers: Dict[str, Officer], id_to_incident: Dict[int, Incident], @@ -360,7 +364,7 @@ def _handle_incidents_csv( with _csv_reader(incidents_csv) as csv_reader: _check_provided_fields( csv_reader, - required_fields=["id", "department_name"], + required_fields=["id", "department_name", "department_state"], optional_fields=[ "date", "time", @@ -372,8 +376,8 @@ def _handle_incidents_csv( "city", "state", "zip_code", - "creator_id", - "last_updated_id", + "created_by", + "last_updated_by", "officer_ids", "license_plates", ], @@ -382,6 +386,7 @@ def _handle_incidents_csv( for row in csv_reader: assert row["department_name"] == department_name + assert row["department_state"] == department_state row["department_id"] = department_id row["officers"] = _objects_from_split_field( row.get("officer_ids"), all_officers @@ -415,8 +420,8 @@ def _handle_incidents_csv( new_incidents[connection_id] = incident counter += 1 if counter % 1000 == 0: - print("Processed {} incidents.".format(counter)) - print("Done with incidents. Processed {} rows.".format(counter)) + print(f"Processed {counter} incidents.") + print(f"Done with incidents. Processed {counter} rows.") return new_incidents @@ -437,7 +442,7 @@ def _handle_links_csv( "link_type", "description", "author", - "creator_id", + "created_by", "officer_ids", "incident_ids", ], @@ -473,12 +478,13 @@ def _handle_links_csv( ) counter += 1 if counter % 1000 == 0: - print("Processed {} links.".format(counter)) - print("Done with links. Processed {} rows.".format(counter)) + print(f"Processed {counter} links.") + print(f"Done with links. Processed {counter} rows.") def import_csv_files( department_name: str, + department_state: str, officers_csv: Optional[str], assignments_csv: Optional[str], salaries_csv: Optional[str], @@ -487,10 +493,13 @@ def import_csv_files( force_create: bool = False, overwrite_assignments: bool = False, ): - department = Department.query.filter_by(name=department_name).one_or_none() + department = Department.query.filter_by( + name=department_name, state=department_state + ).one_or_none() if department is None: raise Exception( - "Department with name '{}' does not exist!".format(department_name) + f"Department with name '{department_name}' in {department_state} " + "does not exist!" ) department_id = department.id @@ -500,7 +509,12 @@ def import_csv_files( if officers_csv is not None: new_officers = _handle_officers_csv( - officers_csv, department_name, department_id, id_to_officer, force_create + officers_csv, + department_name, + department_state, + department_id, + id_to_officer, + force_create, ) all_officers.update(new_officers) @@ -525,6 +539,7 @@ def import_csv_files( new_incidents = _handle_incidents_csv( incidents_csv, department_name, + department_state, department_id, all_officers, id_to_incident, diff --git a/OpenOversight/app/email.py b/OpenOversight/app/email.py deleted file mode 100644 index 5ad8445ca..000000000 --- a/OpenOversight/app/email.py +++ /dev/null @@ -1,25 +0,0 @@ -from threading import Thread -from flask import current_app, render_template -from flask_mail import Message -from . import mail - - -def send_async_email(app, msg): - with app.app_context(): - mail.send(msg) - - -def send_email(to, subject, template, **kwargs): - app = current_app._get_current_object() - msg = Message(app.config['OO_MAIL_SUBJECT_PREFIX'] + ' ' + subject, - sender=app.config['OO_MAIL_SENDER'], recipients=[to]) - msg.body = render_template(template + '.txt', **kwargs) - msg.html = render_template(template + '.html', **kwargs) - # Only send email if we're in prod or staging, otherwise log it so devs can see it - if app.env in ("staging", "production"): - thr = Thread(target=send_async_email, args=[app, msg]) - app.logger.info("Sent email.") - thr.start() - return thr - else: - app.logger.info("simulated email:\n%s\n%s", subject, msg.body) diff --git a/OpenOversight/app/email_client.py b/OpenOversight/app/email_client.py new file mode 100644 index 000000000..045fbbcca --- /dev/null +++ b/OpenOversight/app/email_client.py @@ -0,0 +1,54 @@ +from apiclient import errors +from flask import current_app +from google.oauth2 import service_account +from googleapiclient.discovery import build + +from OpenOversight.app.models.emails import Email +from OpenOversight.app.utils.constants import SERVICE_ACCOUNT_FILE + + +class EmailClient(object): + """ + EmailClient is a Singleton class that is used for the Gmail client. + This can be fairly easily switched out with another email service, but it is + currently defaulted to Gmail. + """ + + SCOPES = ["https://www.googleapis.com/auth/gmail.send"] + + _instance = None + + def __new__(cls, config=None, dev=False, testing=False): + if (testing or dev) and cls._instance is None: + cls._instance = {} + + if cls._instance is None and config: + credentials = service_account.Credentials.from_service_account_file( + SERVICE_ACCOUNT_FILE, scopes=cls.SCOPES + ) + delegated_credentials = credentials.with_subject(config["OO_SERVICE_EMAIL"]) + cls.service = build("gmail", "v1", credentials=delegated_credentials) + cls._instance = super(EmailClient, cls).__new__(cls) + return cls._instance + + @classmethod + def send_email(cls, email: Email): + """ + Deliver the email from the parameter list using the Singleton client. + + :param email: the specific email to be delivered + """ + if not cls._instance: + current_app.logger.info( + "simulated email:\n%s\n%s", email.subject, email.body + ) + else: + try: + ( + cls.service.users() + .messages() + .send(userId="me", body=email.create_message()) + .execute() + ) + except errors.HttpError as error: + print(f"An error occurred: {error}") diff --git a/OpenOversight/app/filters.py b/OpenOversight/app/filters.py new file mode 100644 index 000000000..8f64f4d8e --- /dev/null +++ b/OpenOversight/app/filters.py @@ -0,0 +1,67 @@ +"""Contains all templates filters.""" +from datetime import datetime + +import bleach +import markdown as _markdown +import pytz as pytz +from bleach_allowlist import markdown_attrs, markdown_tags +from flask import Flask, session +from markupsafe import Markup + +from OpenOversight.app.utils.constants import KEY_TIMEZONE + + +def instantiate_filters(app: Flask): + """Instantiate all template filters""" + + def get_timezone() -> str: + """Return the applicable timezone for the filter.""" + return ( + session[KEY_TIMEZONE] + if KEY_TIMEZONE in session + else app.config.get(KEY_TIMEZONE) + ) + + @app.template_filter("capfirst") + def capfirst_filter(s: str) -> str: + return s[0].capitalize() + s[1:] # only change 1st letter + + @app.template_filter("get_age") + def get_age_from_birth_year(birth_year: int) -> int: + return int(datetime.now(pytz.timezone(get_timezone())).year - birth_year) + + @app.template_filter("field_in_query") + def field_in_query(form_data, field) -> str: + """ + Determine if a field is specified in the form data, and if so return a Bootstrap + class which will render the field accordion open. + """ + return " in " if form_data.get(field) else "" + + @app.template_filter("markdown") + def markdown(text: str) -> Markup: + text = text.replace("\n", " \n") # make markdown not ignore new lines. + html = bleach.clean(_markdown.markdown(text), markdown_tags, markdown_attrs) + return Markup(html) + + @app.template_filter("local_date") + def local_date(value: datetime) -> str: + """Convert UTC datetime.datetime into a localized date string.""" + return value.astimezone(pytz.timezone(get_timezone())).strftime("%b %d, %Y") + + @app.template_filter("local_date_time") + def local_date_time(value: datetime) -> str: + """Convert UTC datetime.datetime into a localized date time string.""" + return value.astimezone(pytz.timezone(get_timezone())).strftime( + "%I:%M %p on %b %d, %Y" + ) + + @app.template_filter("local_time") + def local_time(value: datetime) -> str: + """Convert UTC datetime.datetime into a localized time string.""" + return value.astimezone(pytz.timezone(get_timezone())).strftime("%I:%M %p") + + @app.template_filter("thousands_seperator") + def thousands_seperator(value: int) -> str: + """Convert int to string with the appropriately applied commas.""" + return f"{value:,}" diff --git a/OpenOversight/app/formfields.py b/OpenOversight/app/formfields.py index 3dcc2220e..abf96c17b 100644 --- a/OpenOversight/app/formfields.py +++ b/OpenOversight/app/formfields.py @@ -1,27 +1,29 @@ -from wtforms.widgets.html5 import TimeInput -from wtforms import StringField import datetime +from wtforms import StringField +from wtforms.widgets import TimeInput + class TimeField(StringField): """HTML5 time input.""" + widget = TimeInput() - def __init__(self, label=None, validators=None, format='%H:%M:%S', **kwargs): + def __init__(self, label=None, validators=None, format="%H:%M:%S", **kwargs): super(TimeField, self).__init__(label, validators, **kwargs) self.format = format def _value(self): if self.raw_data: - return ' '.join(self.raw_data) + return " ".join(self.raw_data) else: - return self.data and self.data.strftime(self.format) or '' + return self.data and self.data.strftime(self.format) or "" def process_formdata(self, valuelist): - if valuelist and valuelist != [u'']: - time_str = ' '.join(valuelist) + if valuelist and valuelist != [""]: + time_str = " ".join(valuelist) try: - components = time_str.split(':') + components = time_str.split(":") hour = 0 minutes = 0 seconds = 0 @@ -36,4 +38,4 @@ def process_formdata(self, valuelist): self.data = datetime.time(hour, minutes, seconds) except ValueError: self.data = None - raise ValueError(self.gettext('Not a valid time')) + raise ValueError(self.gettext("Not a valid time")) diff --git a/OpenOversight/app/main/__init__.py b/OpenOversight/app/main/__init__.py index eb5779d0d..34232f053 100644 --- a/OpenOversight/app/main/__init__.py +++ b/OpenOversight/app/main/__init__.py @@ -1,5 +1,6 @@ from flask import Blueprint -main = Blueprint('main', __name__) # noqa -from . import views # noqa +main = Blueprint("main", __name__) + +from OpenOversight.app.main import views # noqa: E402,F401 diff --git a/OpenOversight/app/main/choices.py b/OpenOversight/app/main/choices.py deleted file mode 100644 index 2f159eb00..000000000 --- a/OpenOversight/app/main/choices.py +++ /dev/null @@ -1,16 +0,0 @@ -from us import states - -# Choices are a list of (value, label) tuples -SUFFIX_CHOICES = [('', '-'), ('Jr', 'Jr'), ('Sr', 'Sr'), ('II', 'II'), - ('III', 'III'), ('IV', 'IV'), ('V', 'V')] -RACE_CHOICES = [('BLACK', 'Black'), ('WHITE', 'White'), ('ASIAN', 'Asian'), - ('HISPANIC', 'Hispanic'), - ('NATIVE AMERICAN', 'Native American'), - ('PACIFIC ISLANDER', 'Pacific Islander'), - ('Other', 'Other'), ('Not Sure', 'Not Sure')] - -GENDER_CHOICES = [('Not Sure', 'Not Sure'), ('M', 'Male'), ('F', 'Female'), ('Other', 'Other')] - -STATE_CHOICES = [('', '')] + [(state.abbr, state.name) for state in states.STATES] -LINK_CHOICES = [('', ''), ('link', 'Link'), ('video', 'YouTube Video'), ('other_video', 'Other Video')] -AGE_CHOICES = [(str(age), str(age)) for age in range(16, 101)] diff --git a/OpenOversight/app/main/downloads.py b/OpenOversight/app/main/downloads.py new file mode 100644 index 000000000..8aead13ce --- /dev/null +++ b/OpenOversight/app/main/downloads.py @@ -0,0 +1,166 @@ +import csv +import io +from datetime import date +from typing import Any, Callable, Dict, List, TypeVar + +from flask import Response, abort +from sqlalchemy.orm import Query + +from OpenOversight.app.models.database import ( + Assignment, + Department, + Description, + Incident, + Link, + Officer, + Salary, +) + + +T = TypeVar("T") +_Record = Dict[str, Any] + + +######################################################################################## +# Check util methods +######################################################################################## + + +def check_output(output_str): + if output_str == "Not Sure": + return "" + return output_str + + +######################################################################################## +# Route assistance function +######################################################################################## + + +def make_downloadable_csv( + query: Query, + department_id: int, + csv_suffix: str, + field_names: List[str], + record_maker: Callable[[T], _Record], +) -> Response: + department = Department.query.filter_by(id=department_id).first() + if not department: + abort(404) + + csv_output = io.StringIO() + csv_writer = csv.DictWriter(csv_output, fieldnames=field_names) + csv_writer.writeheader() + + for entity in query: + record = record_maker(entity) + csv_writer.writerow(record) + + dept_name = department.name.replace(" ", "_") + csv_name = dept_name + "_" + csv_suffix + ".csv" + + csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} + return Response(csv_output.getvalue(), mimetype="text/csv", headers=csv_headers) + + +######################################################################################## +# Record makers +######################################################################################## + + +def salary_record_maker(salary: Salary) -> _Record: + return { + "id": salary.id, + "officer id": salary.officer_id, + "first name": salary.officer.first_name, + "last name": salary.officer.last_name, + "salary": salary.salary, + "overtime_pay": salary.overtime_pay, + "year": salary.year, + "is_fiscal_year": salary.is_fiscal_year, + } + + +def officer_record_maker(officer: Officer) -> _Record: + if officer.assignments: + most_recent_assignment = max( + officer.assignments, key=lambda a: a.start_date or date.min + ) + most_recent_title = most_recent_assignment.job and check_output( + most_recent_assignment.job.job_title + ) + else: + most_recent_assignment = None + most_recent_title = None + if officer.salaries: + most_recent_salary = max(officer.salaries, key=lambda s: s.year) + else: + most_recent_salary = None + return { + "id": officer.id, + "unique identifier": officer.unique_internal_identifier, + "last name": officer.last_name, + "first name": officer.first_name, + "middle initial": officer.middle_initial, + "suffix": officer.suffix, + "gender": check_output(officer.gender), + "race": check_output(officer.race), + "birth year": officer.birth_year, + "employment date": officer.employment_date, + "badge number": most_recent_assignment and most_recent_assignment.star_no, + "job title": most_recent_title, + "most recent salary": most_recent_salary and most_recent_salary.salary, + } + + +def assignment_record_maker(assignment: Assignment) -> _Record: + officer = assignment.base_officer + return { + "id": assignment.id, + "officer id": assignment.officer_id, + "officer unique identifier": officer and officer.unique_internal_identifier, + "badge number": assignment.star_no, + "job title": assignment.job and check_output(assignment.job.job_title), + "start date": assignment.start_date, + "end date": assignment.resign_date, + "unit id": assignment.unit and assignment.unit.id, + "unit description": assignment.unit and assignment.unit.description, + } + + +def incidents_record_maker(incident: Incident) -> _Record: + return { + "id": incident.id, + "report_num": incident.report_number, + "date": incident.date, + "time": incident.time, + "description": incident.description, + "location": incident.address, + "licenses": " ".join(map(str, incident.license_plates)), + "links": " ".join(map(str, incident.links)), + "officers": " ".join(map(str, incident.officers)), + } + + +def links_record_maker(link: Link) -> _Record: + return { + "id": link.id, + "title": link.title, + "url": link.url, + "link_type": link.link_type, + "description": link.description, + "author": link.author, + "officers": [officer.id for officer in link.officers], + "incidents": [incident.id for incident in link.incidents], + } + + +def descriptions_record_maker(description: Description) -> _Record: + return { + "id": description.id, + "text_contents": description.text_contents, + "created_by": description.created_by, + "officer_id": description.officer_id, + "created_at": description.created_at, + "updated_at": description.updated_at, + } diff --git a/OpenOversight/app/main/forms.py b/OpenOversight/app/main/forms.py index 1e739237c..90c00e79c 100644 --- a/OpenOversight/app/main/forms.py +++ b/OpenOversight/app/main/forms.py @@ -1,29 +1,56 @@ -from flask_wtf import FlaskForm as Form -from wtforms.ext.sqlalchemy.fields import QuerySelectField -from wtforms import (StringField, DecimalField, TextAreaField, - SelectField, IntegerField, SubmitField, - HiddenField, FormField, FieldList, BooleanField) -from wtforms.fields.html5 import DateField - -from wtforms.validators import (DataRequired, InputRequired, AnyOf, NumberRange, Regexp, - Length, Optional, URL, ValidationError) -from flask_wtf.file import FileField, FileAllowed, FileRequired - -from ..utils import unit_choices, dept_choices -from .choices import SUFFIX_CHOICES, GENDER_CHOICES, RACE_CHOICES, STATE_CHOICES, LINK_CHOICES, AGE_CHOICES -from ..formfields import TimeField -from ..widgets import BootstrapListWidget, FormFieldWidget -from ..models import Officer import datetime import re +from flask_wtf import FlaskForm as Form +from flask_wtf.file import FileAllowed, FileField, FileRequired +from wtforms import ( + BooleanField, + DateField, + DecimalField, + FieldList, + FormField, + HiddenField, + IntegerField, + SelectField, + StringField, + SubmitField, + TextAreaField, +) +from wtforms.validators import ( + URL, + AnyOf, + DataRequired, + InputRequired, + Length, + NumberRange, + Optional, + Regexp, + ValidationError, +) +from wtforms_sqlalchemy.fields import QuerySelectField + +from OpenOversight.app.formfields import TimeField +from OpenOversight.app.models.database import Officer +from OpenOversight.app.utils.choices import ( + AGE_CHOICES, + DEPARTMENT_STATE_CHOICES, + GENDER_CHOICES, + LINK_CHOICES, + RACE_CHOICES, + STATE_CHOICES, + SUFFIX_CHOICES, +) +from OpenOversight.app.utils.db import dept_choices, unit_choices, unsorted_dept_choices +from OpenOversight.app.widgets import BootstrapListWidget, FormFieldWidget + + # Normalizes the "not sure" option to what it needs to be when writing to the database. # Note this should only be used for forms which save a record to the DB--not those that # are used to look up existing records. db_genders = list(GENDER_CHOICES) for index, choice in enumerate(db_genders): - if choice == ('Not Sure', 'Not Sure'): - db_genders[index] = (None, 'Not Sure') # type: ignore + if choice == ("Not Sure", "Not Sure"): + db_genders[index] = (None, "Not Sure") # type: ignore def allowed_values(choices, empty_allowed=True): @@ -31,317 +58,480 @@ def allowed_values(choices, empty_allowed=True): def validate_money(form, field): - if not re.fullmatch(r'\d+(\.\d\d)?0*', str(field.data)): - raise ValidationError('Invalid monetary value') + if not re.fullmatch(r"\d+(\.\d\d)?0*", str(field.data)): + raise ValidationError("Invalid monetary value") def validate_end_date(form, field): - if form.data["star_date"] and field.data: - if form.data["star_date"] > field.data: - raise ValidationError('End date must come after start date.') + if form.data["start_date"] and field.data: + if form.data["start_date"] > field.data: + raise ValidationError("End date must come after start date.") class HumintContribution(Form): photo = FileField( - 'image', validators=[FileRequired(message='There was no file!'), - FileAllowed(['png', 'jpg', 'jpeg'], - message='Images only!')] + "image", + validators=[ + FileRequired(message="There was no file!"), + FileAllowed(["png", "jpg", "jpeg"], message="Images only!"), + ], ) - submit = SubmitField(label='Upload') + submit = SubmitField(label="Upload") class FindOfficerForm(Form): - name = StringField( - 'name', default='', validators=[Regexp(r'\w*'), Length(max=50), - Optional()] - ) - badge = StringField('badge', default='', validators=[Regexp(r'\w*'), - Length(max=10)]) - unique_internal_identifier = StringField('unique_internal_identifier', default='', validators=[Regexp(r'\w*'), Length(max=55)]) - dept = QuerySelectField('dept', validators=[DataRequired()], - query_factory=dept_choices, get_label='name') - unit = StringField('unit', default='Not Sure', validators=[Optional()]) - rank = StringField('rank', default='Not Sure', validators=[Optional()]) # Gets rewritten by Javascript - race = SelectField('race', default='Not Sure', choices=RACE_CHOICES, - validators=[AnyOf(allowed_values(RACE_CHOICES))]) - gender = SelectField('gender', default='Not Sure', choices=GENDER_CHOICES, - validators=[AnyOf(allowed_values(GENDER_CHOICES))]) - min_age = IntegerField('min_age', default=16, validators=[ - NumberRange(min=16, max=100) - ]) - max_age = IntegerField('max_age', default=85, validators=[ - NumberRange(min=16, max=100) - ]) - latitude = DecimalField('latitude', default=False, validators=[ - NumberRange(min=-90, max=90) - ]) - longitude = DecimalField('longitude', default=False, validators=[ - NumberRange(min=-180, max=180) - ]) - - -class FindOfficerIDForm(Form): - name = StringField( - 'name', default='', validators=[ - Regexp(r'\w*'), Length(max=50), Optional() - ] + # Any fields added to this form should generally also be added to BrowseForm + first_name = StringField( + "first_name", + default="", + validators=[Regexp(r"\w*"), Length(max=50), Optional()], + ) + last_name = StringField( + "last_name", default="", validators=[Regexp(r"\w*"), Length(max=50), Optional()] ) badge = StringField( - 'badge', default='', validators=[Regexp(r'\w*'), Length(max=10)] + "badge", default="", validators=[Regexp(r"\w*"), Length(max=10)] + ) + unique_internal_identifier = StringField( + "unique_internal_identifier", + default="", + validators=[Regexp(r"\w*"), Length(max=55)], + ) + # TODO: Figure out why this test is failing when the departments are sorted using + # the dept_choices function. + dept = QuerySelectField( + "dept", + validators=[DataRequired()], + query_factory=unsorted_dept_choices, + get_label="display_name", + ) + unit = StringField("unit", default="Not Sure", validators=[Optional()]) + current_job = BooleanField("current_job", default=None, validators=[Optional()]) + rank = StringField( + "rank", default="Not Sure", validators=[Optional()] + ) # Gets rewritten by Javascript + race = SelectField( + "race", + default="Not Sure", + choices=RACE_CHOICES, + validators=[AnyOf(allowed_values(RACE_CHOICES))], + ) + gender = SelectField( + "gender", + default="Not Sure", + choices=GENDER_CHOICES, + validators=[AnyOf(allowed_values(GENDER_CHOICES))], + ) + min_age = IntegerField( + "min_age", default=16, validators=[NumberRange(min=16, max=100)] + ) + max_age = IntegerField( + "max_age", default=85, validators=[NumberRange(min=16, max=100)] + ) + require_photo = BooleanField( + "require_photo", default=False, validators=[Optional()] ) - dept = QuerySelectField('dept', validators=[Optional()], - query_factory=dept_choices, get_label='name') class FaceTag(Form): - officer_id = IntegerField('officer_id', validators=[DataRequired()]) - image_id = IntegerField('image_id', validators=[DataRequired()]) - dataX = IntegerField('dataX', validators=[InputRequired()]) - dataY = IntegerField('dataY', validators=[InputRequired()]) - dataWidth = IntegerField('dataWidth', validators=[InputRequired()]) - dataHeight = IntegerField('dataHeight', validators=[InputRequired()]) + officer_id = IntegerField("officer_id", validators=[DataRequired()]) + image_id = IntegerField("image_id", validators=[DataRequired()]) + dataX = IntegerField("dataX", validators=[InputRequired()]) + dataY = IntegerField("dataY", validators=[InputRequired()]) + dataWidth = IntegerField("dataWidth", validators=[InputRequired()]) + dataHeight = IntegerField("dataHeight", validators=[InputRequired()]) + created_by = HiddenField( + validators=[ + DataRequired(message="Face Tags must have a valid user ID for creating.") + ] + ) class AssignmentForm(Form): - star_no = StringField('Badge Number', default='', validators=[ - Regexp(r'\w*'), Length(max=50)]) - job_title = QuerySelectField('Job Title', validators=[DataRequired()], - get_label='job_title', get_pk=lambda x: x.id) # query set in view function - unit = QuerySelectField('Unit', validators=[Optional()], - query_factory=unit_choices, get_label='descrip', - allow_blank=True, blank_text=u'None') - star_date = DateField('Assignment start date', validators=[Optional()]) - resign_date = DateField('Assignment end date', validators=[Optional(), validate_end_date]) + star_no = StringField( + "Badge Number", default="", validators=[Regexp(r"\w*"), Length(max=50)] + ) + job_title = QuerySelectField( + "Job Title", + validators=[DataRequired()], + get_label="job_title", + get_pk=lambda x: x.id, + ) # query set in view function + unit = QuerySelectField( + "Unit", + validators=[Optional()], + query_factory=unit_choices, + get_label="description", + allow_blank=True, + blank_text="None", + ) + start_date = DateField("Assignment start date", validators=[Optional()]) + resign_date = DateField( + "Assignment end date", validators=[Optional(), validate_end_date] + ) + created_by = HiddenField( + validators=[ + DataRequired(message="Assignments must have a valid user ID for creating.") + ] + ) class SalaryForm(Form): - salary = DecimalField('Salary', validators=[ - NumberRange(min=0, max=1000000), validate_money - ]) - overtime_pay = DecimalField('Overtime Pay', validators=[ - NumberRange(min=0, max=1000000), validate_money - ]) - year = IntegerField('Year', default=datetime.datetime.now().year, validators=[ - NumberRange(min=1900, max=2100) - ]) - is_fiscal_year = BooleanField('Is fiscal year?', default=False) - - def validate(form, extra_validators=()): - if not form.data.get('salary') and not form.data.get('overtime_pay'): + salary = DecimalField( + "Salary", validators=[NumberRange(min=0, max=1000000), validate_money] + ) + overtime_pay = DecimalField( + "Overtime Pay", validators=[NumberRange(min=0, max=1000000), validate_money] + ) + year = IntegerField( + "Year", + default=datetime.datetime.now().year, + validators=[NumberRange(min=1900, max=2100)], + ) + is_fiscal_year = BooleanField("Is fiscal year?", default=False) + created_by = HiddenField( + validators=[ + DataRequired(message="Salaries must have a valid user ID for creating.") + ] + ) + + def validate(self, extra_validators=None): + if not self.data.get("salary") and not self.data.get("overtime_pay"): return True - return super(SalaryForm, form).validate() + return super(SalaryForm, self).validate(extra_validators=extra_validators) # def process(self, *args, **kwargs): - # raise Exception(args[0]) + # raise Exception(args[0]) class DepartmentForm(Form): name = StringField( - 'Full name of law enforcement agency, e.g. Chicago Police Department', - default='', validators=[Regexp(r'\w*'), Length(max=255), DataRequired()] + "Full name of law enforcement agency, e.g. Chicago Police Department", + default="", + validators=[Regexp(r"\w*"), Length(max=255), DataRequired()], ) short_name = StringField( - 'Shortened acronym for law enforcement agency, e.g. CPD', - default='', validators=[Regexp(r'\w*'), Length(max=100), DataRequired()] + "Shortened acronym for law enforcement agency, e.g. CPD", + default="", + validators=[Regexp(r"\w*"), Length(max=100), DataRequired()], + ) + state = SelectField( + "The law enforcement agency's home state", + choices=[("", "Please Select a State")] + DEPARTMENT_STATE_CHOICES, + default="", + validators=[AnyOf(allowed_values(DEPARTMENT_STATE_CHOICES))], + ) + jobs = FieldList( + StringField("Job", default="", validators=[Regexp(r"\w*")]), label="Ranks" + ) + created_by = HiddenField( + validators=[ + DataRequired(message="Departments must have a valid user ID for creating.") + ] ) - jobs = FieldList(StringField('Job', default='', validators=[ - Regexp(r'\w*')]), label='Ranks') - submit = SubmitField(label='Add') + submit = SubmitField(label="Add") class EditDepartmentForm(DepartmentForm): - submit = SubmitField(label='Update') + submit = SubmitField(label="Update") class LinkForm(Form): title = StringField( - validators=[Length(max=100, message='Titles are limited to 100 characters.')], - description='Text that will be displayed as the link.') + validators=[Length(max=100, message="Titles are limited to 100 characters.")], + description="Text that will be displayed as the link.", + ) description = TextAreaField( - validators=[Length(max=600, message='Descriptions are limited to 600 characters.')], - description='A short description of the link.') + validators=[ + Length(max=600, message="Descriptions are limited to 600 characters.") + ], + description="A short description of the link.", + ) author = StringField( - validators=[Length(max=255, message='Limit of 255 characters.')], - description='The source or author of the link.') - url = StringField(validators=[Optional(), URL(message='Not a valid URL')]) + validators=[Length(max=255, message="Limit of 255 characters.")], + description="The source or author of the link.", + ) + url = StringField(validators=[Optional(), URL(message="Not a valid URL")]) link_type = SelectField( - 'Link Type', + "Link Type", choices=LINK_CHOICES, - default='', - validators=[AnyOf(allowed_values(LINK_CHOICES))]) - creator_id = HiddenField(validators=[DataRequired(message='Not a valid user ID')]) + default="", + validators=[AnyOf(allowed_values(LINK_CHOICES))], + ) + created_by = HiddenField( + validators=[ + DataRequired(message="Links must have a valid user ID for creating.") + ] + ) - def validate(self): - success = super(LinkForm, self).validate() + def validate(self, extra_validators=None): + success = super(LinkForm, self).validate(extra_validators=extra_validators) if self.url.data and not self.link_type.data: self.url.errors = list(self.url.errors) - self.url.errors.append('Links must have a link type.') + self.url.errors.append("Links must have a link type.") success = False return success class OfficerLinkForm(LinkForm): - officer_id = HiddenField(validators=[DataRequired(message='Not a valid officer ID')]) - submit = SubmitField(label='Submit') + officer_id = HiddenField( + validators=[DataRequired(message="Not a valid officer ID")] + ) + submit = SubmitField(label="Submit") class BaseTextForm(Form): text_contents = TextAreaField() - description = "This information about the officer will be attributed to your username." + description = ( + "This information about the officer will be attributed to your username." + ) class EditTextForm(BaseTextForm): - submit = SubmitField(label='Submit') + submit = SubmitField(label="Submit") class TextForm(EditTextForm): - officer_id = HiddenField(validators=[DataRequired(message='Not a valid officer ID')]) - creator_id = HiddenField(validators=[DataRequired(message='Not a valid user ID')]) + officer_id = HiddenField( + validators=[DataRequired(message="Not a valid officer ID")] + ) + created_by = HiddenField( + validators=[ + DataRequired(message="Text fields must have a valid user ID for creating.") + ] + ) class AddOfficerForm(Form): - department = QuerySelectField('Department', validators=[DataRequired()], - query_factory=dept_choices, get_label='name') - first_name = StringField('First name', default='', validators=[ - Regexp(r'\w*'), Length(max=50), Optional()]) - last_name = StringField('Last name', default='', validators=[ - Regexp(r'\w*'), Length(max=50), DataRequired()]) - middle_initial = StringField('Middle initial', default='', validators=[ - Regexp(r'\w*'), Length(max=50), Optional()]) - suffix = SelectField('Suffix', default='', choices=SUFFIX_CHOICES, - validators=[AnyOf(allowed_values(SUFFIX_CHOICES))]) - race = SelectField('Race', default='WHITE', choices=RACE_CHOICES, - validators=[AnyOf(allowed_values(RACE_CHOICES))]) + department = QuerySelectField( + "Department", + validators=[DataRequired()], + query_factory=dept_choices, + get_label="display_name", + ) + first_name = StringField( + "First name", + default="", + validators=[Regexp(r"\w*"), Length(max=50), Optional()], + ) + last_name = StringField( + "Last name", + default="", + validators=[Regexp(r"\w*"), Length(max=50), DataRequired()], + ) + middle_initial = StringField( + "Middle initial", + default="", + validators=[Regexp(r"\w*"), Length(max=50), Optional()], + ) + suffix = SelectField( + "Suffix", + default="", + choices=SUFFIX_CHOICES, + validators=[AnyOf(allowed_values(SUFFIX_CHOICES))], + ) + race = SelectField( + "Race", + default="WHITE", + choices=RACE_CHOICES, + validators=[AnyOf(allowed_values(RACE_CHOICES))], + ) gender = SelectField( - 'Gender', + "Gender", choices=GENDER_CHOICES, - coerce=lambda x: None if x == 'Not Sure' else x, - validators=[AnyOf(allowed_values(db_genders))] - ) - star_no = StringField('Badge Number', default='', validators=[ - Regexp(r'\w*'), Length(max=50)]) - unique_internal_identifier = StringField('Unique Internal Identifier', default='', validators=[Regexp(r'\w*'), Length(max=50)]) - job_id = StringField('Job ID') # Gets rewritten by Javascript - unit = QuerySelectField('Unit', validators=[Optional()], - query_factory=unit_choices, get_label='descrip', - allow_blank=True, blank_text=u'None') - employment_date = DateField('Employment Date', validators=[Optional()]) - birth_year = IntegerField('Birth Year', validators=[Optional()]) - links = FieldList(FormField( - LinkForm, - widget=FormFieldWidget()), - description='Links to articles about or videos of the incident.', + coerce=lambda x: None if x == "Not Sure" else x, + validators=[AnyOf(allowed_values(db_genders))], + ) + star_no = StringField( + "Badge Number", default="", validators=[Regexp(r"\w*"), Length(max=50)] + ) + unique_internal_identifier = StringField( + "Unique Internal Identifier", + default="", + validators=[Regexp(r"\w*"), Length(max=50)], + ) + job_id = StringField("Job ID") # Gets rewritten by Javascript + unit = QuerySelectField( + "Unit", + validators=[Optional()], + query_factory=unit_choices, + get_label="description", + allow_blank=True, + blank_text="None", + ) + employment_date = DateField("Employment Date", validators=[Optional()]) + birth_year = IntegerField("Birth Year", validators=[Optional()]) + links = FieldList( + FormField(LinkForm, widget=FormFieldWidget()), + description="Links to articles about or videos of the incident.", min_entries=1, - widget=BootstrapListWidget()) - notes = FieldList(FormField( - BaseTextForm, - widget=FormFieldWidget()), - description='This note about the officer will be attributed to your username.', + widget=BootstrapListWidget(), + ) + notes = FieldList( + FormField(BaseTextForm, widget=FormFieldWidget()), + description="This note about the officer will be attributed to your username.", min_entries=1, - widget=BootstrapListWidget()) - descriptions = FieldList(FormField( - BaseTextForm, - widget=FormFieldWidget()), - description='This description of the officer will be attributed to your username.', + widget=BootstrapListWidget(), + ) + descriptions = FieldList( + FormField(BaseTextForm, widget=FormFieldWidget()), + description="This description of the officer will be attributed to your username.", min_entries=1, - widget=BootstrapListWidget()) - salaries = FieldList(FormField( - SalaryForm, - widget=FormFieldWidget()), - description='Officer salaries', + widget=BootstrapListWidget(), + ) + salaries = FieldList( + FormField(SalaryForm, widget=FormFieldWidget()), + description="Officer salaries", min_entries=1, - widget=BootstrapListWidget()) + widget=BootstrapListWidget(), + ) + created_by = HiddenField( + validators=[ + DataRequired(message="Officers must have a valid user ID for creating.") + ] + ) - submit = SubmitField(label='Add') + submit = SubmitField(label="Add") class EditOfficerForm(Form): - first_name = StringField('First name', - validators=[Regexp(r'\w*'), Length(max=50), - Optional()]) - last_name = StringField('Last name', - validators=[Regexp(r'\w*'), Length(max=50), - DataRequired()]) - middle_initial = StringField('Middle initial', - validators=[Regexp(r'\w*'), Length(max=50), - Optional()]) - suffix = SelectField('Suffix', choices=SUFFIX_CHOICES, default='', - validators=[AnyOf(allowed_values(SUFFIX_CHOICES))]) - race = SelectField('Race', choices=RACE_CHOICES, coerce=lambda x: x or None, - validators=[AnyOf(allowed_values(RACE_CHOICES))]) + first_name = StringField( + "First name", validators=[Regexp(r"\w*"), Length(max=50), Optional()] + ) + last_name = StringField( + "Last name", validators=[Regexp(r"\w*"), Length(max=50), DataRequired()] + ) + middle_initial = StringField( + "Middle initial", validators=[Regexp(r"\w*"), Length(max=50), Optional()] + ) + suffix = SelectField( + "Suffix", + choices=SUFFIX_CHOICES, + default="", + validators=[AnyOf(allowed_values(SUFFIX_CHOICES))], + ) + race = SelectField( + "Race", + choices=RACE_CHOICES, + coerce=lambda x: x or None, + validators=[AnyOf(allowed_values(RACE_CHOICES))], + ) gender = SelectField( - 'Gender', + "Gender", choices=GENDER_CHOICES, - coerce=lambda x: None if x == 'Not Sure' else x, - validators=[AnyOf(allowed_values(db_genders))] - ) - employment_date = DateField('Employment Date', validators=[Optional()]) - birth_year = IntegerField('Birth Year', validators=[Optional()]) - unique_internal_identifier = StringField('Unique Internal Identifier', - default='', - validators=[Regexp(r'\w*'), Length(max=50)], - filters=[lambda x: x or None]) + coerce=lambda x: None if x == "Not Sure" else x, + validators=[AnyOf(allowed_values(db_genders))], + ) + employment_date = DateField("Employment Date", validators=[Optional()]) + birth_year = IntegerField("Birth Year", validators=[Optional()]) + unique_internal_identifier = StringField( + "Unique Internal Identifier", + default="", + validators=[Regexp(r"\w*"), Length(max=50)], + filters=[lambda x: x or None], + ) department = QuerySelectField( - 'Department', + "Department", validators=[Optional()], query_factory=dept_choices, - get_label='name') - submit = SubmitField(label='Update') + get_label="display_name", + ) + submit = SubmitField(label="Update") class AddUnitForm(Form): - descrip = StringField('Unit name or description', default='', validators=[ - Regexp(r'\w*'), Length(max=120), DataRequired()]) + description = StringField( + "Unit name or description", + default="", + validators=[Regexp(r"\w*"), Length(max=120), DataRequired()], + ) department = QuerySelectField( - 'Department', + "Department", validators=[DataRequired()], query_factory=dept_choices, - get_label='name') - submit = SubmitField(label='Add') + get_label="display_name", + ) + created_by = HiddenField( + validators=[ + DataRequired(message="Units must have a valid user ID for creating.") + ] + ) + submit = SubmitField(label="Add") class AddImageForm(Form): department = QuerySelectField( - 'Department', + "Department", validators=[DataRequired()], query_factory=dept_choices, - get_label='name') + get_label="display_name", + ) class DateFieldForm(Form): - date_field = DateField('Date*', validators=[DataRequired()]) - time_field = TimeField('Time', validators=[Optional()]) + date_field = DateField("Date*", validators=[DataRequired()]) + time_field = TimeField("Time", validators=[Optional()]) def validate_time_field(self, field): if not type(field.data) == datetime.time: - raise ValidationError('Not a valid time.') + raise ValidationError("Not a valid time.") def validate_date_field(self, field): if field.data.year < 1900: - raise ValidationError('Incidents prior to 1900 not allowed.') + raise ValidationError("Incidents prior to 1900 not allowed.") class LocationForm(Form): - street_name = StringField(validators=[Optional()], description='Street on which incident occurred. For privacy reasons, please DO NOT INCLUDE street number.') - cross_street1 = StringField(validators=[Optional()], description='Closest cross street to where incident occurred.') + street_name = StringField( + validators=[Optional()], + description="Street on which incident occurred. For privacy reasons, please DO NOT INCLUDE street number.", + ) + cross_street1 = StringField( + validators=[Optional()], + description="Closest cross street to where incident occurred.", + ) cross_street2 = StringField(validators=[Optional()]) - city = StringField('City*', validators=[DataRequired()]) - state = SelectField('State*', choices=STATE_CHOICES, - validators=[AnyOf(allowed_values(STATE_CHOICES, False), message='Must select a state.')]) - zip_code = StringField('Zip Code', - validators=[Optional(), - Regexp(r'^\d{5}$', message='Zip codes must have 5 digits.')]) + city = StringField("City*", validators=[DataRequired()]) + state = SelectField( + "State*", + choices=STATE_CHOICES, + validators=[ + AnyOf(allowed_values(STATE_CHOICES, False), message="Must select a state.") + ], + ) + zip_code = StringField( + "Zip Code", + validators=[ + Optional(), + Regexp(r"^\d{5}$", message="Zip codes must have 5 digits."), + ], + ) + created_by = HiddenField( + validators=[ + DataRequired(message="Locations must have a valid user ID for creating.") + ] + ) class LicensePlateForm(Form): - number = StringField('Plate Number', validators=[]) - state = SelectField('State', choices=STATE_CHOICES, - validators=[AnyOf(allowed_values(STATE_CHOICES))]) + number = StringField("Plate Number", validators=[]) + state = SelectField( + "State", + choices=STATE_CHOICES, + validators=[AnyOf(allowed_values(STATE_CHOICES))], + ) + created_by = HiddenField( + validators=[ + DataRequired( + message="License Plates must have a valid user ID for creating." + ) + ] + ) def validate_state(self, field): - if self.number.data != '' and field.data == '': - raise ValidationError('Must also select a state.') + if self.number.data != "" and field.data == "": + raise ValidationError("Must also select a state.") class OfficerIdField(StringField): @@ -360,62 +550,121 @@ def validate_oo_id(self, field): # Sometimes we get a string in field.data with py.test, this parses it except ValueError: - officer_id = field.data.split("value=\"")[1][:-2] + officer_id = field.data.split('value="')[1][:-2] officer = Officer.query.get(officer_id) if not officer: - raise ValidationError('Not a valid officer id') + raise ValidationError("Not a valid officer id") class OOIdForm(Form): - oo_id = StringField('OO Officer ID', validators=[validate_oo_id]) + oo_id = StringField("OO Officer ID", validators=[validate_oo_id]) class IncidentForm(DateFieldForm): report_number = StringField( - validators=[Regexp(r'^[a-zA-Z0-9-]*$', message="Report numbers can contain letters, numbers, and dashes")], - description='Incident number for the organization tracking incidents') + validators=[ + Regexp( + r"^[a-zA-Z0-9- ]*$", + message="Report cannot contain special characters (dashes permitted)", + ) + ], + description="Incident number for the organization tracking incidents", + ) description = TextAreaField(validators=[Optional()]) department = QuerySelectField( - 'Department*', + "Department*", validators=[DataRequired()], query_factory=dept_choices, - get_label='name') + get_label="display_name", + ) address = FormField(LocationForm) - officers = FieldList(FormField( - OOIdForm, widget=FormFieldWidget()), - description='Officers present at the incident.', + officers = FieldList( + FormField(OOIdForm, widget=FormFieldWidget()), + description="Officers present at the incident.", min_entries=1, - widget=BootstrapListWidget()) - license_plates = FieldList(FormField( - LicensePlateForm, widget=FormFieldWidget()), - description='License plates of police vehicles at the incident.', + widget=BootstrapListWidget(), + ) + license_plates = FieldList( + FormField(LicensePlateForm, widget=FormFieldWidget()), + description="License plates of police vehicles at the incident.", min_entries=1, - widget=BootstrapListWidget()) - links = FieldList(FormField( - LinkForm, - widget=FormFieldWidget()), - description='Links to articles about or videos of the incident.', + widget=BootstrapListWidget(), + ) + links = FieldList( + FormField(LinkForm, widget=FormFieldWidget()), + description="Links to articles about or videos of the incident.", min_entries=1, - widget=BootstrapListWidget()) - creator_id = HiddenField(validators=[DataRequired(message='Incidents must have a creator id.')]) - last_updated_id = HiddenField(validators=[DataRequired(message='Incidents must have a user id for editing.')]) + widget=BootstrapListWidget(), + ) + created_by = HiddenField( + validators=[DataRequired(message="Incidents must have a user id for creating.")] + ) + last_updated_by = HiddenField( + validators=[DataRequired(message="Incidents must have a user ID for editing.")] + ) + last_updated_at = HiddenField( + validators=[ + DataRequired(message="Incidents must have a timestamp for editing.") + ] + ) - submit = SubmitField(label='Submit') + submit = SubmitField(label="Submit") class BrowseForm(Form): - rank = QuerySelectField('rank', validators=[Optional()], get_label='job_title', - get_pk=lambda job: job.job_title) # query set in view function - name = StringField('Last name') - badge = StringField('Badge number') - unique_internal_identifier = StringField('Unique ID') - race = SelectField('race', default='Not Sure', choices=RACE_CHOICES, - validators=[AnyOf(allowed_values(RACE_CHOICES))]) - gender = SelectField('gender', default='Not Sure', choices=GENDER_CHOICES, - validators=[AnyOf(allowed_values(GENDER_CHOICES))]) - min_age = SelectField('minimum age', default=16, choices=AGE_CHOICES, - validators=[AnyOf(allowed_values(AGE_CHOICES))]) - max_age = SelectField('maximum age', default=100, choices=AGE_CHOICES, - validators=[AnyOf(allowed_values(AGE_CHOICES))]) - submit = SubmitField(label='Submit') + # Any fields added to this form should generally also be added to FindOfficerForm + # query set in view function + rank = QuerySelectField( + "rank", + validators=[Optional()], + get_label="job_title", + get_pk=lambda job: job.job_title, + ) + # query set in view function + unit = QuerySelectField( + "unit", + validators=[Optional()], + get_label="description", + get_pk=lambda unit: unit.description, + ) + current_job = BooleanField("current_job", default=None, validators=[Optional()]) + name = StringField("Last name") + badge = StringField("Badge number") + unique_internal_identifier = StringField("Unique ID") + race = SelectField( + "race", + default="Not Sure", + choices=RACE_CHOICES, + validators=[AnyOf(allowed_values(RACE_CHOICES))], + ) + gender = SelectField( + "gender", + default="Not Sure", + choices=GENDER_CHOICES, + validators=[AnyOf(allowed_values(GENDER_CHOICES))], + ) + min_age = SelectField( + "minimum age", + default=16, + choices=AGE_CHOICES, + validators=[AnyOf(allowed_values(AGE_CHOICES))], + ) + max_age = SelectField( + "maximum age", + default=100, + choices=AGE_CHOICES, + validators=[AnyOf(allowed_values(AGE_CHOICES))], + ) + require_photo = BooleanField( + "require_photo", default=False, validators=[Optional()] + ) + submit = SubmitField(label="Submit") + + +class IncidentListForm(Form): + department_id = HiddenField("Department Id") + report_number = StringField("Report Number") + occurred_before = DateField("Occurred Before") + occurred_after = DateField("Occurred After") + submit = SubmitField(label="Submit") diff --git a/OpenOversight/app/main/model_view.py b/OpenOversight/app/main/model_view.py index afc68249b..9fe6008e4 100644 --- a/OpenOversight/app/main/model_view.py +++ b/OpenOversight/app/main/model_view.py @@ -1,114 +1,208 @@ import datetime -from flask_sqlalchemy.model import DefaultMeta -from flask_wtf import FlaskForm as Form +from http import HTTPMethod from typing import Callable, Union -from flask import render_template, redirect, request, url_for, flash, abort, current_app + +from flask import abort, current_app, flash, redirect, render_template, request, url_for from flask.views import MethodView -from flask_login import login_required, current_user -from ..auth.utils import ac_or_admin_required -from ..models import db -from ..utils import add_department_query, set_dynamic_default +from flask_login import current_user, login_required +from flask_sqlalchemy.model import DefaultMeta +from flask_wtf import Form + +from OpenOversight.app.models.database import ( + Department, + Incident, + Link, + Note, + Officer, + db, +) +from OpenOversight.app.utils.auth import ac_or_admin_required +from OpenOversight.app.utils.constants import ( + KEY_DEPT_ALL_INCIDENTS, + KEY_DEPT_ALL_LINKS, + KEY_DEPT_ALL_NOTES, + KEY_DEPT_TOTAL_INCIDENTS, +) +from OpenOversight.app.utils.db import add_department_query +from OpenOversight.app.utils.forms import set_dynamic_default class ModelView(MethodView): - model = None # type: DefaultMeta - model_name = '' + model: DefaultMeta = None + model_name: str = "" per_page = 20 - order_by = '' # this should be a field on the model + order_by: str = "" # this should be a field on the model descending = False # used for order_by - form = '' # type: Form - create_function = '' # type: Union[str, Callable] + form: Form = None + create_function: Union[str, Callable] = "" department_check = False def get(self, obj_id): if obj_id is None: - if request.args.get('page'): - page = int(request.args.get('page')) - else: - page = 1 + page = int(request.args.get("page", 1)) if self.order_by: if not self.descending: - objects = self.model.query.order_by(getattr(self.model, self.order_by)).paginate(page, self.per_page, False) - objects = self.model.query.order_by(getattr(self.model, self.order_by).desc()).paginate(page, self.per_page, False) + objects = self.model.query.order_by( + getattr(self.model, self.order_by) + ).paginate(page=page, per_page=self.per_page, error_out=False) + else: + objects = self.model.query.order_by( + getattr(self.model, self.order_by).desc() + ).paginate(page=page, per_page=self.per_page, error_out=False) else: - objects = self.model.query.paginate(page, self.per_page, False) - - return render_template('{}_list.html'.format(self.model_name), objects=objects, url='main.{}_api'.format(self.model_name)) + objects = self.model.query.paginate( + page=page, per_page=self.per_page, error_out=False + ) + + return render_template( + f"{self.model_name}_list.html", + objects=objects, + url=f"main.{self.model_name}_api", + ) else: obj = self.model.query.get_or_404(obj_id) - return render_template('{}_detail.html'.format(self.model_name), obj=obj, current_user=current_user) + return render_template( + f"{self.model_name}_detail.html", + obj=obj, + current_user=current_user, + ) @login_required @ac_or_admin_required def new(self, form=None): if not form: form = self.get_new_form() - if hasattr(form, 'department'): + if hasattr(form, "department"): add_department_query(form, current_user) - if getattr(current_user, 'dept_pref_rel', None): + if getattr(current_user, "dept_pref_rel", None): set_dynamic_default(form.department, current_user.dept_pref_rel) - if hasattr(form, 'creator_id') and not form.creator_id.data: - form.creator_id.data = current_user.get_id() - if hasattr(form, 'last_updated_id'): - form.last_updated_id.data = current_user.get_id() + if hasattr(form, "created_by") and not form.created_by.data: + form.created_by.data = current_user.get_id() + if hasattr(form, "last_updated_by"): + form.last_updated_by.data = current_user.get_id() + form.last_updated_at.data = datetime.datetime.now() if form.validate_on_submit(): new_obj = self.create_function(form) db.session.add(new_obj) db.session.commit() - flash('{} created!'.format(self.model_name)) + match self.model.__name__: + case Incident.__name__: + Department(id=new_obj.department_id).remove_database_cache_entries( + [KEY_DEPT_TOTAL_INCIDENTS, KEY_DEPT_ALL_INCIDENTS], + ) + case Note.__name__: + officer = Officer.query.filter_by( + department_id=new_obj.officer_id + ).first() + if officer: + Department( + id=officer.department_id + ).remove_database_cache_entries( + [KEY_DEPT_ALL_NOTES], + ) + flash(f"{self.model_name} created!") return self.get_redirect_url(obj_id=new_obj.id) else: current_app.logger.info(form.errors) - return render_template('{}_new.html'.format(self.model_name), form=form) + return render_template(f"{self.model_name}_new.html", form=form) @login_required @ac_or_admin_required def edit(self, obj_id, form=None): obj = self.model.query.get_or_404(obj_id) if self.department_check: - if not current_user.is_administrator and current_user.ac_department_id != self.get_department_id(obj): + if ( + not current_user.is_administrator + and current_user.ac_department_id != self.get_department_id(obj) + ): abort(403) if not form: form = self.get_edit_form(obj) - # if the object doesn't have a creator id set, st it to current user - if hasattr(obj, 'creator_id') and hasattr(form, 'creator_id') and getattr(obj, 'creator_id'): - form.creator_id.data = obj.creator_id - elif hasattr(form, 'creator_id'): - form.creator_id.data = current_user.get_id() + # if the object doesn't have a creator id set it to current user + if ( + hasattr(obj, "created_by") + and hasattr(form, "created_by") + and getattr(obj, "created_by") + ): + form.created_by.data = obj.created_by + elif hasattr(form, "created_by"): + form.created_by.data = current_user.get_id() # if the object keeps track of who updated it last, set to current user - if hasattr(form, 'last_updated_id'): - form.last_updated_id.data = current_user.get_id() + if hasattr(obj, "last_updated_by") and hasattr(form, "last_updated_by"): + form.last_updated_by.data = current_user.get_id() + form.last_updated_at.data = datetime.datetime.now() - if hasattr(form, 'department'): + if hasattr(form, "department"): add_department_query(form, current_user) if form.validate_on_submit(): self.populate_obj(form, obj) - flash('{} successfully updated!'.format(self.model_name)) + match self.model.__name__: + case Incident.__name__: + Department(id=obj.department_id).remove_database_cache_entries( + [KEY_DEPT_ALL_INCIDENTS], + ) + case Note.__name__: + officer = Officer.query.filter_by( + department_id=obj.officer_id + ).first() + if officer: + Department( + id=officer.department_id + ).remove_database_cache_entries( + [KEY_DEPT_ALL_NOTES], + ) + case Link.__name__: + officer = Officer.query.filter_by(id=obj.officer_id).first() + if officer: + Department( + id=officer.department_id + ).remove_database_cache_entries( + [KEY_DEPT_ALL_LINKS], + ) + flash(f"{self.model_name} successfully updated!") return self.get_redirect_url(obj_id=obj_id) - return render_template('{}_edit.html'.format(self.model_name), obj=obj, form=form) + return render_template(f"{self.model_name}_edit.html", obj=obj, form=form) @login_required @ac_or_admin_required def delete(self, obj_id): obj = self.model.query.get_or_404(obj_id) if self.department_check: - if not current_user.is_administrator and current_user.ac_department_id != self.get_department_id(obj): + if ( + not current_user.is_administrator + and current_user.ac_department_id != self.get_department_id(obj) + ): abort(403) - if request.method == 'POST': + if request.method == HTTPMethod.POST: db.session.delete(obj) db.session.commit() - flash('{} successfully deleted!'.format(self.model_name)) + match self.model.__name__: + case Incident.__name__: + Department(id=obj.department_id).remove_database_cache_entries( + [KEY_DEPT_TOTAL_INCIDENTS, KEY_DEPT_ALL_INCIDENTS], + ) + case Note.__name__: + officer = Officer.query.filter_by( + department_id=obj.officer_id + ).first() + if officer: + Department( + id=officer.department_id + ).remove_database_cache_entries( + [KEY_DEPT_ALL_NOTES], + ) + flash(f"{self.model_name} successfully deleted!") return self.get_post_delete_url() - return render_template('{}_delete.html'.format(self.model_name), obj=obj) + return render_template(f"{self.model_name}_delete.html", obj=obj) def get_edit_form(self, obj): form = self.form(obj=obj) @@ -119,19 +213,25 @@ def get_new_form(self): def get_redirect_url(self, *args, **kwargs): # returns user to the show view - return redirect(url_for('main.{}_api'.format(self.model_name), obj_id=kwargs['obj_id'], _method='GET')) + return redirect( + url_for( + f"main.{self.model_name}_api", + obj_id=kwargs["obj_id"], + _method=HTTPMethod.GET, + ) + ) def get_post_delete_url(self, *args, **kwargs): # returns user to the list view - return redirect(url_for('main.{}_api'.format(self.model_name))) + return redirect(url_for(f"main.{self.model_name}_api")) def get_department_id(self, obj): return obj.department_id def populate_obj(self, form, obj): form.populate_obj(obj) - if hasattr(obj, 'date_updated'): - obj.date_updated = datetime.datetime.now() + if hasattr(obj, "updated_at"): + obj.updated_at = datetime.datetime.now() db.session.add(obj) db.session.commit() @@ -140,15 +240,15 @@ def create_obj(self, form): def dispatch_request(self, *args, **kwargs): # isolate the method at the end of the url - end_of_url = request.url.split('/')[-1].split('?')[0] - endings = ['edit', 'new', 'delete'] + end_of_url = request.url.split("/")[-1].split("?")[0] + endings = ["edit", "new", "delete"] meth = None for ending in endings: if end_of_url == ending: meth = getattr(self, ending, None) if not meth: - if request.method == 'GET': - meth = getattr(self, 'get', None) + if request.method == HTTPMethod.GET: + meth = getattr(self, "get", None) else: - assert meth is not None, 'Unimplemented method %r' % request.method + assert meth is not None, f"Unimplemented method {request.method!r}" return meth(*args, **kwargs) diff --git a/OpenOversight/app/main/views.py b/OpenOversight/app/main/views.py index 65dcfed0e..d2258934e 100644 --- a/OpenOversight/app/main/views.py +++ b/OpenOversight/app/main/views.py @@ -1,42 +1,126 @@ -import csv -from datetime import date -import io +import datetime import os import re -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm.exc import NoResultFound -from sqlalchemy.orm import selectinload import sys +from http import HTTPMethod, HTTPStatus from traceback import format_exc -from flask import (abort, render_template, request, redirect, url_for, - flash, current_app, jsonify, Response) +from flask import ( + Response, + abort, + current_app, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) from flask_login import current_user, login_required, login_user +from flask_wtf import FlaskForm +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import contains_eager, joinedload, selectinload +from sqlalchemy.orm.exc import NoResultFound + +from OpenOversight.app import limiter, sitemap +from OpenOversight.app.auth.forms import LoginForm +from OpenOversight.app.main import main +from OpenOversight.app.main.downloads import ( + assignment_record_maker, + descriptions_record_maker, + incidents_record_maker, + links_record_maker, + make_downloadable_csv, + officer_record_maker, + salary_record_maker, +) +from OpenOversight.app.main.forms import ( + AddImageForm, + AddOfficerForm, + AddUnitForm, + AssignmentForm, + BrowseForm, + DepartmentForm, + EditDepartmentForm, + EditOfficerForm, + EditTextForm, + FaceTag, + FindOfficerForm, + IncidentForm, + IncidentListForm, + OfficerLinkForm, + SalaryForm, + TextForm, +) +from OpenOversight.app.main.model_view import ModelView +from OpenOversight.app.models.database import ( + Assignment, + Department, + Description, + Face, + Image, + Incident, + Job, + LicensePlate, + Link, + Location, + Note, + Officer, + Salary, + Unit, + User, + db, +) +from OpenOversight.app.models.database_cache import ( + get_database_cache_entry, + put_database_cache_entry, +) +from OpenOversight.app.utils.auth import ac_or_admin_required, admin_required +from OpenOversight.app.utils.choices import AGE_CHOICES, GENDER_CHOICES, RACE_CHOICES +from OpenOversight.app.utils.cloud import crop_image, save_image_to_s3_and_db +from OpenOversight.app.utils.constants import ( + ENCODING_UTF_8, + FLASH_MSG_PERMANENT_REDIRECT, + KEY_DEPT_ALL_ASSIGNMENTS, + KEY_DEPT_ALL_INCIDENTS, + KEY_DEPT_ALL_LINKS, + KEY_DEPT_ALL_NOTES, + KEY_DEPT_ALL_OFFICERS, + KEY_DEPT_ALL_SALARIES, + KEY_DEPT_TOTAL_ASSIGNMENTS, + KEY_DEPT_TOTAL_OFFICERS, + KEY_OFFICERS_PER_PAGE, + KEY_TIMEZONE, +) +from OpenOversight.app.utils.db import ( + add_department_query, + add_unit_query, + compute_leaderboard_stats, + unit_choices, + unsorted_dept_choices, +) +from OpenOversight.app.utils.forms import ( + add_new_assignment, + add_officer_profile, + create_description, + create_incident, + create_note, + edit_existing_assignment, + edit_officer_profile, + filter_by_form, + set_dynamic_default, +) +from OpenOversight.app.utils.general import ( + ac_can_edit_officer, + allowed_file, + get_or_create, + get_random_image, + replace_list, + serve_image, + validate_redirect_url, +) -from . import main -from .. import limiter, sitemap -from ..utils import (serve_image, compute_leaderboard_stats, get_random_image, - allowed_file, add_new_assignment, edit_existing_assignment, - add_officer_profile, edit_officer_profile, - ac_can_edit_officer, add_department_query, add_unit_query, - replace_list, create_note, set_dynamic_default, roster_lookup, - create_description, filter_by_form, - crop_image, create_incident, get_or_create, dept_choices, - upload_image_to_s3_and_store_in_db) - -from .forms import (FindOfficerForm, FindOfficerIDForm, AddUnitForm, - FaceTag, AssignmentForm, DepartmentForm, AddOfficerForm, - EditOfficerForm, IncidentForm, TextForm, EditTextForm, - AddImageForm, EditDepartmentForm, BrowseForm, SalaryForm, OfficerLinkForm) -from .model_view import ModelView -from .choices import GENDER_CHOICES, RACE_CHOICES, AGE_CHOICES -from ..models import (db, Image, User, Face, Officer, Assignment, Department, - Unit, Incident, Location, LicensePlate, Link, Note, - Description, Salary, Job) - -from ..auth.forms import LoginForm -from ..auth.utils import admin_required, ac_or_admin_required -from sqlalchemy.orm import contains_eager, joinedload # Ensure the file is read/write by the creator only SAVED_UMASK = os.umask(0o077) @@ -52,271 +136,433 @@ def sitemap_include(view): @sitemap.register_generator def static_routes(): for endpoint in sitemap_endpoints: - yield 'main.' + endpoint, {} + yield "main." + endpoint, {} -def redirect_url(default='index'): - return request.args.get('next') or request.referrer or url_for(default) +def redirect_url(default="main.index"): + return ( + validate_redirect_url(session.get("next")) + or request.referrer + or url_for(default) + ) @sitemap_include -@main.route('/') -@main.route('/index') +@main.route("/") +@main.route("/index") def index(): - return render_template('index.html') + return render_template("index.html") + + +@main.route("/timezone", methods=[HTTPMethod.POST]) +def set_session_timezone(): + if KEY_TIMEZONE not in session: + session.permanent = True + timezone = request.data.decode(ENCODING_UTF_8) + session[KEY_TIMEZONE] = ( + timezone if timezone != "" else current_app.config.get(KEY_TIMEZONE) + ) + return Response("User timezone saved", status=HTTPStatus.OK) @sitemap_include -@main.route('/browse', methods=['GET']) +@main.route("/browse", methods=[HTTPMethod.GET]) def browse(): - departments = Department.query.filter(Department.officers.any()) - return render_template('browse.html', departments=departments) + departments = Department.query.filter(Department.officers.any()).order_by( + Department.state.asc(), Department.name.asc() + ) + return render_template("browse.html", departments=departments) @sitemap_include -@main.route('/find', methods=['GET', 'POST']) +@main.route("/find", methods=[HTTPMethod.GET, HTTPMethod.POST]) def get_officer(): - jsloads = ['js/find_officer.js'] form = FindOfficerForm() - depts_dict = [dept_choice.toCustomDict() for dept_choice in dept_choices()] + # TODO: Figure out why this test is failing when the departments are sorted using + # the dept_choices function. + departments_dict = [ + dept_choice.to_custom_dict() for dept_choice in unsorted_dept_choices() + ] - if getattr(current_user, 'dept_pref_rel', None): + if getattr(current_user, "dept_pref_rel", None): set_dynamic_default(form.dept, current_user.dept_pref_rel) if form.validate_on_submit(): - return redirect(url_for( - 'main.list_officer', - department_id=form.data['dept'].id, - race=form.data['race'] if form.data['race'] != 'Not Sure' else None, - gender=form.data['gender'] if form.data['gender'] != 'Not Sure' else None, - rank=form.data['rank'] if form.data['rank'] != 'Not Sure' else None, - unit=form.data['unit'] if form.data['unit'] != 'Not Sure' else None, - min_age=form.data['min_age'], - max_age=form.data['max_age'], - name=form.data['name'], - badge=form.data['badge'], - unique_internal_identifier=form.data['unique_internal_identifier']), - code=302) + return redirect( + url_for( + "main.list_officer", + department_id=form.data["dept"].id, + race=form.data["race"] if form.data["race"] != "Not Sure" else None, + gender=form.data["gender"] + if form.data["gender"] != "Not Sure" + else None, + rank=form.data["rank"] if form.data["rank"] != "Not Sure" else None, + unit=form.data["unit"] if form.data["unit"] != "Not Sure" else None, + current_job=form.data["current_job"] or None, + # set to None if False + min_age=form.data["min_age"], + max_age=form.data["max_age"], + first_name=form.data["first_name"], + last_name=form.data["last_name"], + badge=form.data["badge"], + unique_internal_identifier=form.data["unique_internal_identifier"], + ), + code=HTTPStatus.FOUND, + ) else: current_app.logger.info(form.errors) - return render_template('input_find_officer.html', form=form, depts_dict=depts_dict, jsloads=jsloads) + return render_template( + "input_find_officer.html", + form=form, + depts_dict=departments_dict, + jsloads=["js/find_officer.js"], + ) -@main.route('/tagger_find', methods=['GET', 'POST']) -def get_ooid(): - form = FindOfficerIDForm() - if form.validate_on_submit(): - return redirect(url_for('main.get_tagger_gallery'), code=307) - else: - current_app.logger.info(form.errors) - return render_template('input_find_ooid.html', form=form) +@main.route("/label", methods=[HTTPMethod.GET, HTTPMethod.POST]) +def redirect_get_started_labeling(): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.get_started_labeling"), code=HTTPStatus.PERMANENT_REDIRECT + ) @sitemap_include -@main.route('/label', methods=['GET', 'POST']) +@main.route("/labels", methods=[HTTPMethod.GET, HTTPMethod.POST]) def get_started_labeling(): form = LoginForm() if form.validate_on_submit(): - user = User.query.filter_by(email=form.email.data).first() + user = User.by_email(form.email.data).first() if user is not None and user.verify_password(form.password.data): login_user(user, form.remember_me.data) - return redirect(request.args.get('next') or url_for('main.index')) - flash('Invalid username or password.') + return redirect(url_for("main.get_started_labeling")) + flash("Invalid username or password.") else: current_app.logger.info(form.errors) departments = Department.query.all() - return render_template('label_data.html', departments=departments, form=form) + return render_template("label_data.html", departments=departments, form=form) + + +@main.route( + "/sort/department/", methods=[HTTPMethod.GET, HTTPMethod.POST] +) +@login_required +def redirect_sort_images(department_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.sort_images", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) -@main.route('/sort/department/', methods=['GET', 'POST']) +@main.route( + "/sort/departments/", methods=[HTTPMethod.GET, HTTPMethod.POST] +) @login_required -def sort_images(department_id): +def sort_images(department_id: int): # Select a random unsorted image from the database - image_query = Image.query.filter_by(contains_cops=None) \ - .filter_by(department_id=department_id) + image_query = Image.query.filter_by(contains_cops=None).filter_by( + department_id=department_id + ) image = get_random_image(image_query) if image: proper_path = serve_image(image.filepath) else: proper_path = None - return render_template('sort.html', image=image, path=proper_path, - department_id=department_id) + return render_template( + "sort.html", image=image, path=proper_path, department_id=department_id + ) @sitemap_include -@main.route('/tutorial') +@main.route("/tutorial") def get_tutorial(): - return render_template('tutorial.html') + return render_template("tutorial.html") -@main.route('/user/') -def profile(username): - if re.search('^[A-Za-z][A-Za-z0-9_.]*$', username): - user = User.query.filter_by(username=username).one() +@main.route("/user/") +@login_required +def profile(username: str): + if re.search("^[A-Za-z][A-Za-z0-9_.]*$", username): + user = User.by_username(username).one() else: - abort(404) + abort(HTTPStatus.NOT_FOUND) try: pref = User.query.filter_by(id=current_user.get_id()).one().dept_pref department = Department.query.filter_by(id=pref).one().name except NoResultFound: department = None - return render_template('profile.html', user=user, department=department) + return render_template("profile.html", user=user, department=department) + +@main.route("/officer/", methods=[HTTPMethod.GET, HTTPMethod.POST]) +def redirect_officer_profile(officer_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.officer_profile", officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) -@main.route('/officer/', methods=['GET', 'POST']) -def officer_profile(officer_id): + +@main.route("/officers/", methods=[HTTPMethod.GET, HTTPMethod.POST]) +def officer_profile(officer_id: int): form = AssignmentForm() try: officer = Officer.query.filter_by(id=officer_id).one() except NoResultFound: - abort(404) - except: # noqa - exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error finding officer: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) - form.job_title.query = Job.query\ - .filter_by(department_id=officer.department_id)\ - .order_by(Job.order.asc())\ - .all() + abort(HTTPStatus.NOT_FOUND) + except: # noqa: E722 + exception_type, value, full_traceback = sys.exc_info() + error_str = " ".join([str(exception_type), str(value), format_exc()]) + current_app.logger.error(f"Error finding officer: {error_str}") + form.job_title.query = ( + Job.query.filter_by(department_id=officer.department_id) + .order_by(Job.order.asc()) + .all() + ) try: - faces = Face.query.filter_by(officer_id=officer_id).order_by(Face.featured.desc()).all() + faces = ( + Face.query.filter_by(officer_id=officer_id) + .order_by(Face.featured.desc()) + .all() + ) assignments = Assignment.query.filter_by(officer_id=officer_id).all() face_paths = [] for face in faces: face_paths.append(serve_image(face.image.filepath)) - except: # noqa - exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error loading officer profile: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) + if not face_paths: + # Add in the placeholder image if no faces are found + face_paths = [url_for("static", filename="images/placeholder.png")] + except: # noqa: E722 + exception_type, value, full_traceback = sys.exc_info() + error_str = " ".join([str(exception_type), str(value), format_exc()]) + current_app.logger.error(f"Error loading officer profile: {error_str}") if faces: officer.image_url = faces[0].image.filepath - if not officer.image_url.startswith('http'): - officer.image_url = url_for('static', filename=faces[0].image.filepath.replace('/static/', ''), _external=True) + if not officer.image_url.startswith("http"): + officer.image_url = url_for( + "static", + filename=faces[0].image.filepath.replace("/static/", ""), + ) if faces[0].face_width and faces[0].face_height: officer.image_width = faces[0].face_width officer.image_height = faces[0].face_height - return render_template('officer.html', officer=officer, paths=face_paths, - faces=faces, assignments=assignments, form=form) + return render_template( + "officer.html", + officer=officer, + paths=face_paths, + faces=faces, + assignments=assignments, + form=form, + ) @sitemap.register_generator def sitemap_officers(): for officer in Officer.query.all(): - yield 'main.officer_profile', {'officer_id': officer.id} + yield "main.officer_profile", {"officer_id": officer.id} -@main.route('/officer//assignment/new', methods=['POST']) +@main.route( + "/officer//assignment/new", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) @ac_or_admin_required -def add_assignment(officer_id): +def redirect_add_assignment(officer_id: int): + return redirect( + url_for("main.add_assignment", officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/officers//assignments/new", methods=[HTTPMethod.POST]) +@ac_or_admin_required +def add_assignment(officer_id: int): form = AssignmentForm() + form.created_by.data = current_user.get_id() officer = Officer.query.filter_by(id=officer_id).first() - form.job_title.query = Job.query\ - .filter_by(department_id=officer.department_id)\ - .order_by(Job.order.asc())\ - .all() + form.job_title.query = ( + Job.query.filter_by(department_id=officer.department_id) + .order_by(Job.order.asc()) + .all() + ) if not officer: - flash('Officer not found') - abort(404) + flash("Officer not found") + abort(HTTPStatus.NOT_FOUND) if form.validate_on_submit(): - if (current_user.is_administrator - or (current_user.is_area_coordinator and officer.department_id == current_user.ac_department_id)): + if current_user.is_administrator or ( + current_user.is_area_coordinator + and officer.department_id == current_user.ac_department_id + ): try: add_new_assignment(officer_id, form) - flash('Added new assignment!') + Department(id=officer.department_id).remove_database_cache_entries( + [KEY_DEPT_ALL_ASSIGNMENTS, KEY_DEPT_TOTAL_ASSIGNMENTS], + ) + flash("Added new assignment!") except IntegrityError: - flash('Assignment already exists') - return redirect(url_for('main.officer_profile', - officer_id=officer_id), code=302) - elif current_user.is_area_coordinator and not officer.department_id == current_user.ac_department_id: - abort(403) + flash("Assignment already exists") + return redirect( + url_for("main.officer_profile", officer_id=officer_id), + code=HTTPStatus.FOUND, + ) + elif ( + current_user.is_area_coordinator + and not officer.department_id == current_user.ac_department_id + ): + abort(HTTPStatus.FORBIDDEN) else: current_app.logger.info(form.errors) flash("Error: " + str(form.errors)) - return redirect(url_for('main.officer_profile', officer_id=officer_id)) + return redirect(url_for("main.officer_profile", officer_id=officer_id)) -@main.route('/officer//assignment/', - methods=['GET', 'POST']) +@main.route( + "/officer//assignment/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +@login_required +@ac_or_admin_required +def redirect_edit_assignment(officer_id: int, assignment_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for( + "main.edit_assignment", officer_id=officer_id, assignment_id=assignment_id + ), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/officers//assignments/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) @login_required @ac_or_admin_required -def edit_assignment(officer_id, assignment_id): +def edit_assignment(officer_id: int, assignment_id: int): officer = Officer.query.filter_by(id=officer_id).one() if current_user.is_area_coordinator and not current_user.is_administrator: if not ac_can_edit_officer(officer, current_user): - abort(403) + abort(HTTPStatus.FORBIDDEN) assignment = Assignment.query.filter_by(id=assignment_id).one() form = AssignmentForm(obj=assignment) - form.job_title.query = Job.query\ - .filter_by(department_id=officer.department_id)\ - .order_by(Job.order.asc())\ - .all() + form.job_title.query = ( + Job.query.filter_by(department_id=officer.department_id) + .order_by(Job.order.asc()) + .all() + ) form.job_title.data = Job.query.filter_by(id=assignment.job_id).one() + form.unit.query = unit_choices(officer.department_id) if form.unit.data and type(form.unit.data) == int: form.unit.data = Unit.query.filter_by(id=form.unit.data).one() if form.validate_on_submit(): - form.job_title.data = Job.query.filter_by(id=int(form.job_title.raw_data[0])).one() + form.job_title.data = Job.query.filter_by( + id=int(form.job_title.raw_data[0]) + ).one() assignment = edit_existing_assignment(assignment, form) - flash('Edited officer assignment ID {}'.format(assignment.id)) - return redirect(url_for('main.officer_profile', officer_id=officer_id)) + Department(id=officer.department_id).remove_database_cache_entries( + [KEY_DEPT_ALL_ASSIGNMENTS], + ) + flash(f"Edited officer assignment ID {assignment.id}") + return redirect(url_for("main.officer_profile", officer_id=officer_id)) else: current_app.logger.info(form.errors) - return render_template('edit_assignment.html', form=form) + return render_template("edit_assignment.html", form=form) -@main.route('/officer//salary/new', methods=['GET', 'POST']) +@main.route( + "/officer//salary/new", methods=[HTTPMethod.GET, HTTPMethod.POST] +) @ac_or_admin_required -def add_salary(officer_id): +def redirect_add_salary(officer_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.add_salary", officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/officers//salaries/new", methods=[HTTPMethod.GET, HTTPMethod.POST] +) +@ac_or_admin_required +def add_salary(officer_id: int): form = SalaryForm() + form.created_by.data = current_user.get_id() officer = Officer.query.filter_by(id=officer_id).first() if not officer: - flash('Officer not found') - abort(404) - - if form.validate_on_submit() and (current_user.is_administrator or - (current_user.is_area_coordinator and - officer.department_id == current_user.ac_department_id)): + flash("Officer not found") + abort(HTTPStatus.NOT_FOUND) + + if form.validate_on_submit() and ( + current_user.is_administrator + or ( + current_user.is_area_coordinator + and officer.department_id == current_user.ac_department_id + ) + ): try: new_salary = Salary( officer_id=officer_id, salary=form.salary.data, overtime_pay=form.overtime_pay.data, year=form.year.data, - is_fiscal_year=form.is_fiscal_year.data + is_fiscal_year=form.is_fiscal_year.data, ) db.session.add(new_salary) db.session.commit() - flash('Added new salary!') + Department(id=officer.department_id).remove_database_cache_entries( + [KEY_DEPT_ALL_SALARIES], + ) + flash("Added new salary!") except IntegrityError as e: db.session.rollback() - flash('Error adding new salary: {}'.format(e)) - return redirect(url_for('main.officer_profile', - officer_id=officer_id), code=302) - elif current_user.is_area_coordinator and not officer.department_id == current_user.ac_department_id: - abort(403) + flash(f"Error adding new salary: {e}") + return redirect( + url_for("main.officer_profile", officer_id=officer_id), + code=HTTPStatus.FOUND, + ) + elif ( + current_user.is_area_coordinator + and not officer.department_id == current_user.ac_department_id + ): + abort(HTTPStatus.FORBIDDEN) else: - return render_template('add_edit_salary.html', form=form) + return render_template("add_edit_salary.html", form=form) -@main.route('/officer//salary/', - methods=['GET', 'POST']) +@main.route( + "/officer//salary/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +@login_required +@ac_or_admin_required +def redirect_edit_salary(officer_id: int, salary_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.edit_salary", officer_id=officer_id, salary_id=salary_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/officers//salaries/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) @login_required @ac_or_admin_required -def edit_salary(officer_id, salary_id): +def edit_salary(officer_id: int, salary_id: int): + officer = Officer.query.filter_by(id=officer_id).one() if current_user.is_area_coordinator and not current_user.is_administrator: - officer = Officer.query.filter_by(id=officer_id).one() if not ac_can_edit_officer(officer, current_user): - abort(403) + abort(HTTPStatus.FORBIDDEN) salary = Salary.query.filter_by(id=salary_id).one() form = SalaryForm(obj=salary) @@ -324,332 +570,672 @@ def edit_salary(officer_id, salary_id): form.populate_obj(salary) db.session.add(salary) db.session.commit() - flash('Edited officer salary ID {}'.format(salary.id)) - return redirect(url_for('main.officer_profile', officer_id=officer_id)) + Department(id=officer.department_id).remove_database_cache_entries( + [KEY_DEPT_ALL_SALARIES], + ) + flash(f"Edited officer salary ID {salary.id}") + return redirect(url_for("main.officer_profile", officer_id=officer_id)) else: current_app.logger.info(form.errors) - return render_template('add_edit_salary.html', form=form, update=True) + return render_template("add_edit_salary.html", form=form, update=True) + + +@main.route("/image/") +@login_required +def redirect_display_submission(image_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.display_submission", image_id=image_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) -@main.route('/image/') +@main.route("/images/") @login_required -def display_submission(image_id): +def display_submission(image_id: int): try: image = Image.query.filter_by(id=image_id).one() proper_path = serve_image(image.filepath) except NoResultFound: - abort(404) - return render_template('image.html', image=image, path=proper_path) + abort(HTTPStatus.NOT_FOUND) + return render_template("image.html", image=image, path=proper_path) -@main.route('/tag/') -@login_required -def display_tag(tag_id): +@main.route("/tag/") +def redirect_display_tag(tag_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.display_tag", tag_id=tag_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/tags/") +def display_tag(tag_id: int): try: tag = Face.query.filter_by(id=tag_id).one() - proper_path = serve_image(tag.image.filepath) + proper_path = serve_image(tag.original_image.filepath) except NoResultFound: - abort(404) - return render_template('tag.html', face=tag, path=proper_path) + abort(HTTPStatus.NOT_FOUND) + return render_template( + "tag.html", face=tag, path=proper_path, jsloads=["js/tag.js"] + ) -@main.route('/image/classify//', - methods=['POST']) +@main.route( + "/image/classify//", methods=[HTTPMethod.POST] +) +@login_required +def redirect_classify_submission(image_id: int, contains_cops: int): + return redirect( + url_for( + "main.classify_submission", image_id=image_id, contains_cops=contains_cops + ), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/images/classify//", methods=[HTTPMethod.POST] +) @login_required -def classify_submission(image_id, contains_cops): +def classify_submission(image_id: int, contains_cops: int): try: image = Image.query.filter_by(id=image_id).one() if image.contains_cops is not None and not current_user.is_administrator: - flash('Only administrator can re-classify image') + flash("Only administrator can re-classify image") return redirect(redirect_url()) - image.user_id = current_user.get_id() + image.created_by = current_user.get_id() if contains_cops == 1: image.contains_cops = True elif contains_cops == 0: image.contains_cops = False db.session.commit() - flash('Updated image classification') - except: # noqa - flash('Unknown error occurred') - exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error classifying image: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) + flash("Updated image classification") + except: # noqa: E722 + flash("Unknown error occurred") + exception_type, value, full_traceback = sys.exc_info() + error_str = " ".join([str(exception_type), str(value), format_exc()]) + current_app.logger.error(f"Error classifying image: {error_str}") return redirect(redirect_url()) - # return redirect(url_for('main.display_submission', image_id=image_id)) -@main.route('/department/new', methods=['GET', 'POST']) +@main.route("/department/new", methods=[HTTPMethod.GET, HTTPMethod.POST]) +@login_required +@admin_required +def redirect_add_department(): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.add_department"), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/departments/new", methods=[HTTPMethod.GET, HTTPMethod.POST]) @login_required @admin_required def add_department(): - jsloads = ['js/jquery-ui.min.js', 'js/deptRanks.js'] form = DepartmentForm() + form.created_by = current_user.get_id() if form.validate_on_submit(): - departments = [x[0] for x in db.session.query(Department.name).all()] - - if form.name.data not in departments: - department = Department(name=form.name.data, - short_name=form.short_name.data) + department_does_not_exist = ( + Department.query.filter_by( + name=form.name.data, state=form.state.data + ).count() + == 0 + ) + + if department_does_not_exist: + department = Department( + name=form.name.data, + short_name=form.short_name.data, + state=form.state.data, + created_by=current_user.get_id(), + ) db.session.add(department) db.session.flush() - db.session.add(Job( - job_title='Not Sure', - order=0, - department_id=department.id - )) + db.session.add( + Job(job_title="Not Sure", order=0, department_id=department.id) + ) db.session.flush() if form.jobs.data: order = 1 - for job in form.data['jobs']: + for job in form.data["jobs"]: if job: - db.session.add(Job( - job_title=job, - order=order, - is_sworn_officer=True, - department_id=department.id - )) + db.session.add( + Job( + job_title=job, + order=order, + is_sworn_officer=True, + department_id=department.id, + ) + ) order += 1 db.session.commit() - flash('New department {} added to OpenOversight'.format(department.name)) + flash( + f"New department {department.name} in {department.state} added to OpenOversight" + ) else: - flash('Department {} already exists'.format(form.name.data)) - return redirect(url_for('main.get_started_labeling')) + flash(f"Department {form.name.data} in {form.state.data} already exists") + return redirect(url_for("main.get_started_labeling")) else: current_app.logger.info(form.errors) - return render_template('add_edit_department.html', form=form, jsloads=jsloads) + return render_template( + "department_add_edit.html", + form=form, + jsloads=["js/jquery-ui.min.js", "js/deptRanks.js"], + ) + + +@main.route( + "/department//edit", methods=[HTTPMethod.GET, HTTPMethod.POST] +) +@login_required +@admin_required +def redirect_edit_department(department_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.edit_department", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) -@main.route('/department//edit', methods=['GET', 'POST']) +@main.route( + "/departments//edit", methods=[HTTPMethod.GET, HTTPMethod.POST] +) @login_required @admin_required -def edit_department(department_id): - jsloads = ['js/jquery-ui.min.js', 'js/deptRanks.js'] +def edit_department(department_id: int): department = Department.query.get_or_404(department_id) previous_name = department.name form = EditDepartmentForm(obj=department) original_ranks = department.jobs + form.created_by.data = department.created_by if form.validate_on_submit(): - new_name = form.name.data - if new_name != previous_name: - if Department.query.filter_by(name=new_name).count() > 0: - flash('Department {} already exists'.format(new_name)) - return redirect(url_for('main.edit_department', - department_id=department_id)) - department.name = new_name + if form.name.data != previous_name: + does_already_department_exist = ( + Department.query.filter_by( + name=form.name.data, state=form.state.data + ).count() + > 0 + ) + + if does_already_department_exist: + flash( + f"Department {form.name.data} in {form.state.data} already exists" + ) + return redirect( + url_for("main.edit_department", department_id=department_id) + ) + + department.name = form.name.data department.short_name = form.short_name.data + department.state = form.state.data db.session.flush() if form.jobs.data: new_ranks = [] order = 1 - for rank in form.data['jobs']: + for rank in form.data["jobs"]: if rank: new_ranks.append((rank, order)) order += 1 updated_ranks = form.jobs.data if len(updated_ranks) < len(original_ranks): - deleted_ranks = [rank for rank in original_ranks if rank.job_title not in updated_ranks] - if Assignment.query.filter(Assignment.job_id.in_([rank.id for rank in deleted_ranks])).count() == 0: + deleted_ranks = [ + rank + for rank in original_ranks + if rank.job_title not in updated_ranks + ] + if ( + Assignment.query.filter( + Assignment.job_id.in_([rank.id for rank in deleted_ranks]) + ).count() + == 0 + ): for rank in deleted_ranks: db.session.delete(rank) else: failed_deletions = [] for rank in deleted_ranks: - if Assignment.query.filter(Assignment.job_id.in_([rank.id])).count() != 0: + if ( + Assignment.query.filter( + Assignment.job_id.in_([rank.id]) + ).count() + != 0 + ): failed_deletions.append(rank) for rank in failed_deletions: - flash('You attempted to delete a rank, {}, that is still in use'.format(rank)) - return redirect(url_for('main.edit_department', department_id=department_id)) - - for (new_rank, order) in new_ranks: - existing_rank = Job.query.filter_by(department_id=department_id, job_title=new_rank).one_or_none() + flash( + f"You attempted to delete a rank, {rank}, that is still in use" + ) + return redirect( + url_for("main.edit_department", department_id=department_id) + ) + + for new_rank, order in new_ranks: + existing_rank = Job.query.filter_by( + department_id=department_id, job_title=new_rank + ).one_or_none() if existing_rank: existing_rank.is_sworn_officer = True existing_rank.order = order else: - db.session.add(Job( - job_title=new_rank, - order=order, - is_sworn_officer=True, - department_id=department_id - )) + db.session.add( + Job( + job_title=new_rank, + order=order, + is_sworn_officer=True, + department_id=department_id, + ) + ) db.session.commit() - flash('Department {} edited'.format(department.name)) - return redirect(url_for('main.list_officer', department_id=department.id)) + flash(f"Department {department.name} in {department.state} edited") + return redirect(url_for("main.list_officer", department_id=department.id)) else: current_app.logger.info(form.errors) - return render_template('add_edit_department.html', form=form, update=True, jsloads=jsloads) - - -@main.route('/department/') -def list_officer(department_id, page=1, race=[], gender=[], rank=[], min_age='16', max_age='100', name=None, - badge=None, unique_internal_identifier=None, unit=None): + return render_template( + "department_add_edit.html", + form=form, + update=True, + jsloads=["js/jquery-ui.min.js", "js/deptRanks.js"], + ) + + +@main.route("/department/") +def redirect_list_officer( + department_id: int, + page: int = 1, + race=None, + gender=None, + rank=None, + min_age: str = "16", + max_age: str = "100", + last_name=None, + first_name=None, + badge=None, + unique_internal_identifier=None, + unit=None, + current_job=None, + require_photo: bool = False, +): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for( + "main.list_officer", + department_id=department_id, + page=page, + race=race, + gender=gender, + rank=rank, + min_age=min_age, + max_age=max_age, + last_name=last_name, + first_name=first_name, + badge=badge, + unique_internal_identifier=unique_internal_identifier, + unit=unit, + current_job=current_job, + require_photo=require_photo, + ), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/departments/") +def list_officer( + department_id: int, + page: int = 1, + race=None, + gender=None, + rank=None, + min_age="16", + max_age="100", + last_name=None, + first_name=None, + badge=None, + unique_internal_identifier=None, + unit=None, + current_job=None, + require_photo: bool = False, +): form = BrowseForm() - form.rank.query = Job.query.filter_by(department_id=department_id, is_sworn_officer=True).order_by(Job.order.asc()).all() + form.rank.query = ( + Job.query.filter_by(department_id=department_id, is_sworn_officer=True) + .order_by(Job.order.asc()) + .all() + ) form_data = form.data - form_data['race'] = race - form_data['gender'] = gender - form_data['rank'] = rank - form_data['min_age'] = min_age - form_data['max_age'] = max_age - form_data['name'] = name - form_data['badge'] = badge - form_data['unit'] = unit - form_data['unique_internal_identifier'] = unique_internal_identifier - - OFFICERS_PER_PAGE = int(current_app.config['OFFICERS_PER_PAGE']) + form_data["race"] = race or [] + form_data["gender"] = gender or [] + form_data["rank"] = rank or [] + form_data["min_age"] = min_age + form_data["max_age"] = max_age + form_data["last_name"] = last_name + form_data["first_name"] = first_name + form_data["badge"] = badge + form_data["unit"] = unit or [] + form_data["current_job"] = current_job + form_data["unique_internal_identifier"] = unique_internal_identifier + form_data["require_photo"] = require_photo + department = Department.query.filter_by(id=department_id).first() if not department: - abort(404) + abort(HTTPStatus.NOT_FOUND) + + age_range = {ac[0] for ac in AGE_CHOICES} # Set form data based on URL - if request.args.get('min_age') and request.args.get('min_age') in [ac[0] for ac in AGE_CHOICES]: - form_data['min_age'] = request.args.get('min_age') - if request.args.get('max_age') and request.args.get('max_age') in [ac[0] for ac in AGE_CHOICES]: - form_data['max_age'] = request.args.get('max_age') - if request.args.get('page'): - page = int(request.args.get('page')) - if request.args.get('name'): - form_data['name'] = request.args.get('name') - if request.args.get('badge'): - form_data['badge'] = request.args.get('badge') - if request.args.get('unit') and request.args.get('unit') != 'Not Sure': - form_data['unit'] = int(request.args.get('unit')) - if request.args.get('unique_internal_identifier'): - form_data['unique_internal_identifier'] = request.args.get('unique_internal_identifier') - if request.args.get('race') and all(race in [rc[0] for rc in RACE_CHOICES] for race in request.args.getlist('race')): - form_data['race'] = request.args.getlist('race') - if request.args.get('gender') and all(gender in [gc[0] for gc in GENDER_CHOICES] for gender in request.args.getlist('gender')): - form_data['gender'] = request.args.getlist('gender') - - unit_choices = [(unit.id, unit.descrip) for unit in Unit.query.filter_by(department_id=department_id).order_by(Unit.descrip.asc()).all()] - rank_choices = [jc[0] for jc in db.session.query(Job.job_title, Job.order).filter_by(department_id=department_id).order_by(Job.order).all()] - if request.args.get('rank') and all(rank in rank_choices for rank in request.args.getlist('rank')): - form_data['rank'] = request.args.getlist('rank') - - officers = filter_by_form( - form_data, Officer.query, department_id - ).filter(Officer.department_id == department_id) - officers = officers.options(selectinload(Officer.face)) + if (min_age_arg := request.args.get("min_age")) and min_age_arg in age_range: + form_data["min_age"] = min_age_arg + if (max_age_arg := request.args.get("max_age")) and max_age_arg in age_range: + form_data["max_age"] = max_age_arg + if page_arg := request.args.get("page"): + page = int(page_arg) + if last_name_arg := request.args.get("last_name"): + form_data["last_name"] = last_name_arg + if first_name_arg := request.args.get("first_name"): + form_data["first_name"] = first_name_arg + if badge_arg := request.args.get("badge"): + form_data["badge"] = badge_arg + if uid := request.args.get("unique_internal_identifier"): + form_data["unique_internal_identifier"] = uid + if (races := request.args.getlist("race")) and all( + race in [rc[0] for rc in RACE_CHOICES] for race in races + ): + form_data["race"] = races + if (genders := request.args.getlist("gender")) and all( + # Every time you complain we add a new gender + gender in [gc[0] for gc in GENDER_CHOICES] + for gender in genders + ): + form_data["gender"] = genders + if require_photo_arg := request.args.get("require_photo"): + form_data["require_photo"] = require_photo_arg + + unit_selections = ["Not Sure"] + [ + uc[0] + for uc in db.session.query(Unit.description) + .filter_by(department_id=department_id) + .order_by(Unit.description.asc()) + .all() + ] + rank_selections = [ + jc[0] + for jc in db.session.query(Job.job_title, Job.order) + .filter_by(department_id=department_id) + .order_by(Job.job_title) + .all() + ] + if (units := request.args.getlist("unit")) and all( + unit in unit_selections for unit in units + ): + form_data["unit"] = units + if (ranks := request.args.getlist("rank")) and all( + rank in rank_selections for rank in ranks + ): + form_data["rank"] = ranks + if current_job_arg := request.args.get("current_job"): + form_data["current_job"] = current_job_arg + + officers = filter_by_form(form_data, Officer.query, department_id).filter( + Officer.department_id == department_id + ) + + # Filter officers by presence of a photo + if form_data["require_photo"]: + officers = officers.join(Face) + else: + officers = officers.options(selectinload(Officer.face)) + officers = officers.order_by(Officer.last_name, Officer.first_name, Officer.id) - officers = officers.paginate(page, OFFICERS_PER_PAGE, False) + + officers = officers.paginate( + page=page, per_page=current_app.config[KEY_OFFICERS_PER_PAGE], error_out=False + ) + for officer in officers.items: officer_face = sorted(officer.face, key=lambda x: x.featured, reverse=True) - # could do some extra work to not lazy load images but load them all together - # but we would want to ensure to only load the first picture of each officer + # Could do some extra work to not lazy load images but load them all together. + # To do that properly we would want to ensure to only load the first picture of + # each officer. if officer_face and officer_face[0].image: officer.image = officer_face[0].image.filepath choices = { - 'race': RACE_CHOICES, - 'gender': GENDER_CHOICES, - 'rank': [(rc, rc) for rc in rank_choices], - 'unit': [('Not Sure', 'Not Sure')] + unit_choices + "race": RACE_CHOICES, + "gender": GENDER_CHOICES, + "rank": [(rc, rc) for rc in rank_selections], + "unit": [(uc, uc) for uc in unit_selections], } - next_url = url_for('main.list_officer', department_id=department.id, - page=officers.next_num, race=form_data['race'], gender=form_data['gender'], rank=form_data['rank'], - min_age=form_data['min_age'], max_age=form_data['max_age'], name=form_data['name'], badge=form_data['badge'], - unique_internal_identifier=form_data['unique_internal_identifier'], unit=form_data['unit']) - prev_url = url_for('main.list_officer', department_id=department.id, - page=officers.prev_num, race=form_data['race'], gender=form_data['gender'], rank=form_data['rank'], - min_age=form_data['min_age'], max_age=form_data['max_age'], name=form_data['name'], badge=form_data['badge'], - unique_internal_identifier=form_data['unique_internal_identifier'], unit=form_data['unit']) + next_url = url_for( + "main.list_officer", + department_id=department.id, + page=officers.next_num, + race=form_data["race"], + gender=form_data["gender"], + rank=form_data["rank"], + min_age=form_data["min_age"], + max_age=form_data["max_age"], + last_name=form_data["last_name"], + first_name=form_data["first_name"], + badge=form_data["badge"], + unique_internal_identifier=form_data["unique_internal_identifier"], + unit=form_data["unit"], + current_job=form_data["current_job"], + require_photo=form_data["require_photo"], + ) + prev_url = url_for( + "main.list_officer", + department_id=department.id, + page=officers.prev_num, + race=form_data["race"], + gender=form_data["gender"], + rank=form_data["rank"], + min_age=form_data["min_age"], + max_age=form_data["max_age"], + last_name=form_data["last_name"], + first_name=form_data["first_name"], + badge=form_data["badge"], + unique_internal_identifier=form_data["unique_internal_identifier"], + unit=form_data["unit"], + current_job=form_data["current_job"], + require_photo=form_data["require_photo"], + ) return render_template( - 'list_officer.html', + "list_officer.html", form=form, department=department, officers=officers, form_data=form_data, choices=choices, next_url=next_url, - prev_url=prev_url) - - -@main.route('/department//ranks') -@main.route('/ranks') -def get_dept_ranks(department_id=None, is_sworn_officer=None): + prev_url=prev_url, + jsloads=["js/select2.min.js", "js/list_officer.js"], + ) + + +@main.route("/department//ranks") +def redirect_get_dept_ranks(department_id: int = 0, is_sworn_officer: bool = False): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for( + "main.get_dept_ranks", + department_id=department_id, + is_sworn_officer=is_sworn_officer, + ), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/departments//ranks") +@main.route("/ranks") +def get_dept_ranks(department_id: int = 0, is_sworn_officer: bool = False): if not department_id: - department_id = request.args.get('department_id') - if request.args.get('is_sworn_officer'): - is_sworn_officer = request.args.get('is_sworn_officer') + department_id = request.args.get("department_id") + if request.args.get("is_sworn_officer"): + is_sworn_officer = request.args.get("is_sworn_officer") if department_id: ranks = Job.query.filter_by(department_id=department_id) if is_sworn_officer: ranks = ranks.filter_by(is_sworn_officer=True) - ranks = ranks.order_by(Job.order.asc()).all() + ranks = ranks.order_by(Job.job_title).all() rank_list = [(rank.id, rank.job_title) for rank in ranks] else: - ranks = Job.query.all() # Not filtering by is_sworn_officer - rank_list = list(set([(rank.id, rank.job_title) for rank in ranks])) # Prevent duplicate ranks + # Not filtering by is_sworn_officer + ranks = Job.query.all() + # Prevent duplicate ranks + rank_list = sorted( + set((rank.id, rank.job_title) for rank in ranks), + key=lambda x: x[1], + ) return jsonify(rank_list) -@main.route('/officer/new', methods=['GET', 'POST']) +@main.route("/department//units") +def redirect_get_dept_units(department_id: int = 0): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.get_dept_ranks", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/departments//units") +@main.route("/units") +def get_dept_units(department_id: int = 0): + if not department_id: + department_id = request.args.get("department_id") + + if department_id: + units = Unit.query.filter_by(department_id=department_id) + units = units.order_by(Unit.description).all() + unit_list = [(unit.id, unit.description) for unit in units] + else: + units = Unit.query.all() + # Prevent duplicate units + unit_list = sorted( + set((unit.id, unit.description) for unit in units), + key=lambda x: x[1], + ) + + return jsonify(unit_list) + + +@main.route("/officer/new", methods=[HTTPMethod.GET, HTTPMethod.POST]) +@login_required +@ac_or_admin_required +def redirect_add_officer(): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.add_officer"), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/officers/new", methods=[HTTPMethod.GET, HTTPMethod.POST]) @login_required @ac_or_admin_required def add_officer(): - jsloads = ['js/dynamic_lists.js', 'js/add_officer.js'] form = AddOfficerForm() + form.created_by.data = current_user.get_id() for link in form.links: - link.creator_id.data = current_user.id + link.created_by.data = current_user.id add_unit_query(form, current_user) add_department_query(form, current_user) set_dynamic_default(form.department, current_user.dept_pref_rel) - if form.validate_on_submit() and not current_user.is_administrator and form.department.data.id != current_user.ac_department_id: - abort(403) + if ( + form.validate_on_submit() + and not current_user.is_administrator + and form.department.data.id != current_user.ac_department_id + ): + abort(HTTPStatus.FORBIDDEN) if form.validate_on_submit(): # Work around for WTForms limitation with boolean fields in FieldList - new_formdata = request.form.copy() - for key in new_formdata.keys(): - if re.fullmatch(r'salaries-\d+-is_fiscal_year', key): - new_formdata[key] = 'y' - form = AddOfficerForm(new_formdata) + new_form_data = request.form.copy() + for key in new_form_data.keys(): + if re.fullmatch(r"salaries-\d+-is_fiscal_year", key): + new_form_data[key] = "y" + form = AddOfficerForm(new_form_data) officer = add_officer_profile(form, current_user) - flash('New Officer {} added to OpenOversight'.format(officer.last_name)) - return redirect(url_for('main.submit_officer_images', officer_id=officer.id)) + Department(id=officer.department_id).remove_database_cache_entries( + [KEY_DEPT_ALL_OFFICERS, KEY_DEPT_TOTAL_OFFICERS] + ) + flash(f"New Officer {officer.last_name} added to OpenOversight") + return redirect(url_for("main.submit_officer_images", officer_id=officer.id)) else: current_app.logger.info(form.errors) - return render_template('add_officer.html', form=form, jsloads=jsloads) + return render_template( + "add_officer.html", + form=form, + jsloads=["js/dynamic_lists.js", "js/add_officer.js"], + ) + +@main.route("/officer//edit", methods=[HTTPMethod.GET, HTTPMethod.POST]) +@login_required +@ac_or_admin_required +def redirect_edit_officer(officer_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.edit_officer", officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) -@main.route('/officer//edit', methods=['GET', 'POST']) + +@main.route( + "/officers//edit", methods=[HTTPMethod.GET, HTTPMethod.POST] +) @login_required @ac_or_admin_required -def edit_officer(officer_id): - jsloads = ['js/dynamic_lists.js'] +def edit_officer(officer_id: int): officer = Officer.query.filter_by(id=officer_id).one() form = EditOfficerForm(obj=officer) - if request.method == 'GET': + if request.method == HTTPMethod.GET: if officer.race is None: - form.race.data = 'Not Sure' + form.race.data = "Not Sure" if officer.gender is None: - form.gender.data = 'Not Sure' + form.gender.data = "Not Sure" if current_user.is_area_coordinator and not current_user.is_administrator: if not ac_can_edit_officer(officer, current_user): - abort(403) + abort(HTTPStatus.FORBIDDEN) add_department_query(form, current_user) if form.validate_on_submit(): officer = edit_officer_profile(officer, form) - flash('Officer {} edited'.format(officer.last_name)) - return redirect(url_for('main.officer_profile', officer_id=officer.id)) + Department(id=officer.department_id).remove_database_cache_entries( + [KEY_DEPT_TOTAL_OFFICERS] + ) + flash(f"Officer {officer.last_name} edited") + return redirect(url_for("main.officer_profile", officer_id=officer.id)) else: current_app.logger.info(form.errors) - return render_template('edit_officer.html', form=form, jsloads=jsloads) + return render_template( + "edit_officer.html", form=form, jsloads=["js/dynamic_lists.js"] + ) -@main.route('/unit/new', methods=['GET', 'POST']) +@main.route("/unit/new", methods=[HTTPMethod.GET, HTTPMethod.POST]) +@login_required +@ac_or_admin_required +def redirect_add_unit(): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.add_unit"), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/units/new", methods=[HTTPMethod.GET, HTTPMethod.POST]) @login_required @ac_or_admin_required def add_unit(): @@ -658,58 +1244,79 @@ def add_unit(): set_dynamic_default(form.department, current_user.dept_pref_rel) if form.validate_on_submit(): - unit = Unit(descrip=form.descrip.data, - department_id=form.department.data.id) + unit = Unit( + description=form.description.data, department_id=form.department.data.id + ) db.session.add(unit) db.session.commit() - flash('New unit {} added to OpenOversight'.format(unit.descrip)) - return redirect(url_for('main.get_started_labeling')) + flash(f"New unit {unit.description} added to OpenOversight") + return redirect(url_for("main.get_started_labeling")) else: current_app.logger.info(form.errors) - return render_template('add_unit.html', form=form) + return render_template("add_unit.html", form=form) + + +@main.route("/tag/delete/", methods=[HTTPMethod.POST]) +@login_required +@ac_or_admin_required +def redirect_delete_tag(tag_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.delete_tag", tag_id=tag_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) -@main.route('/tag/delete/', methods=['POST']) +@main.route("/tags/delete/", methods=[HTTPMethod.POST]) @login_required @ac_or_admin_required -def delete_tag(tag_id): +def delete_tag(tag_id: int): tag = Face.query.filter_by(id=tag_id).first() if not tag: - flash('Tag not found') - abort(404) + flash("Tag not found") + abort(HTTPStatus.NOT_FOUND) if not current_user.is_administrator and current_user.is_area_coordinator: if current_user.ac_department_id != tag.officer.department_id: - abort(403) + abort(HTTPStatus.FORBIDDEN) + officer_id = tag.officer_id try: db.session.delete(tag) db.session.commit() - flash('Deleted this tag') - except: # noqa - flash('Unknown error occurred') - exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error classifying image: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) - return redirect(url_for('main.index')) - - -@main.route('/tag/set_featured/', methods=['POST']) + flash("Deleted this tag") + except: # noqa: E722 + flash("Unknown error occurred") + current_app.logger.exception("Unknown error occurred") + + return redirect(url_for("main.officer_profile", officer_id=officer_id)) + + +@main.route("/tag/set_featured/", methods=[HTTPMethod.POST]) +@login_required +@ac_or_admin_required +def redirect_set_featured_tag(tag_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.set_featured_tag", tag_id=tag_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/tags/set_featured/", methods=[HTTPMethod.POST]) @login_required @ac_or_admin_required -def set_featured_tag(tag_id): +def set_featured_tag(tag_id: int): tag = Face.query.filter_by(id=tag_id).first() if not tag: - flash('Tag not found') - abort(404) + flash("Tag not found") + abort(HTTPStatus.NOT_FOUND) if not current_user.is_administrator and current_user.is_area_coordinator: if current_user.ac_department_id != tag.officer.department_id: - abort(403) + abort(HTTPStatus.FORBIDDEN) # Set featured=False on all other tags for the same officer for face in Face.query.filter_by(officer_id=tag.officer_id).all(): @@ -719,56 +1326,87 @@ def set_featured_tag(tag_id): try: db.session.commit() - flash('Successfully set this tag as featured') - except: # noqa - flash('Unknown error occurred') - exception_type, value, full_tback = sys.exc_info() - current_app.logger.error('Error setting featured tag: {}'.format( - ' '.join([str(exception_type), str(value), - format_exc()]) - )) - return redirect(url_for('main.officer_profile', officer_id=tag.officer_id)) - - -@main.route('/leaderboard') + flash("Successfully set this tag as featured") + except: # noqa: E722 + flash("Unknown error occurred") + exception_type, value, full_traceback = sys.exc_info() + error_str = " ".join([str(exception_type), str(value), format_exc()]) + current_app.logger.error(f"Error setting featured tag: {error_str}") + return redirect(url_for("main.officer_profile", officer_id=tag.officer_id)) + + +@main.route("/leaderboard") @login_required def leaderboard(): top_sorters, top_taggers = compute_leaderboard_stats() - return render_template('leaderboard.html', top_sorters=top_sorters, - top_taggers=top_taggers) - - -@main.route('/cop_face/department//image/', - methods=['GET', 'POST']) -@main.route('/cop_face/image/', methods=['GET', 'POST']) -@main.route('/cop_face/department/', methods=['GET', 'POST']) -@main.route('/cop_face/', methods=['GET', 'POST']) + return render_template( + "leaderboard.html", top_sorters=top_sorters, top_taggers=top_taggers + ) + + +@main.route( + "/cop_face/department//images/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +@main.route("/cop_face/image/", methods=[HTTPMethod.GET, HTTPMethod.POST]) +@main.route( + "/cop_face/department/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +@main.route("/cop_face/", methods=[HTTPMethod.GET, HTTPMethod.POST]) +@login_required +def redirect_label_data(department_id: int = 0, image_id: int = 0): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.label_data", department_id=department_id, image_id=image_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/cop_faces/departments//images/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +@main.route( + "/cop_faces/images/", methods=[HTTPMethod.GET, HTTPMethod.POST] +) +@main.route( + "/cop_faces/departments/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +@main.route("/cop_faces/", methods=[HTTPMethod.GET, HTTPMethod.POST]) @login_required -def label_data(department_id=None, image_id=None): - jsloads = ['js/cropper.js', 'js/tagger.js'] +def label_data(department_id: int = 0, image_id: int = 0): if department_id: department = Department.query.filter_by(id=department_id).one() if image_id: - image = Image.query.filter_by(id=image_id) \ - .filter_by(department_id=department_id).first() + image = ( + Image.query.filter_by(id=image_id) + .filter_by(department_id=department_id) + .first() + ) else: # Get a random image from that department - image_query = Image.query.filter_by(contains_cops=True) \ - .filter_by(department_id=department_id) \ - .filter_by(is_tagged=False) + image_query = ( + Image.query.filter_by(contains_cops=True) + .filter_by(department_id=department_id) + .filter_by(is_tagged=False) + ) image = get_random_image(image_query) else: department = None if image_id: image = Image.query.filter_by(id=image_id).one() - else: # Select a random untagged image from the entire database - image_query = Image.query.filter_by(contains_cops=True) \ - .filter_by(is_tagged=False) + else: + # Select a random untagged image from the entire database + image_query = Image.query.filter_by(contains_cops=True).filter_by( + is_tagged=False + ) image = get_random_image(image_query) if image: if image.is_tagged and not current_user.is_administrator: - flash('This image cannot be tagged anymore') - return redirect(url_for('main.label_data')) + flash("This image cannot be tagged anymore") + return redirect(url_for("main.label_data")) proper_path = serve_image(image.filepath) else: proper_path = None @@ -776,403 +1414,625 @@ def label_data(department_id=None, image_id=None): form = FaceTag() if form.validate_on_submit(): officer_exists = Officer.query.filter_by(id=form.officer_id.data).first() - existing_tag = db.session.query(Face) \ - .filter(Face.officer_id == form.officer_id.data) \ - .filter(Face.original_image_id == form.image_id.data).first() + existing_tag = ( + db.session.query(Face) + .filter(Face.officer_id == form.officer_id.data) + .filter(Face.original_image_id == form.image_id.data) + .first() + ) if not officer_exists: - flash('Invalid officer ID. Please select a valid OpenOversight ID!') + flash("Invalid officer ID. Please select a valid OpenOversight ID!") elif department and officer_exists.department_id != department_id: - flash('The officer is not in {}. Are you sure that is the correct OpenOversight ID?'.format(department.name)) + flash( + f"The officer is not in {department.name}, {department.state}. " + "Are you sure that is the correct OpenOversight ID?" + ) elif not existing_tag: left = form.dataX.data upper = form.dataY.data right = left + form.dataWidth.data lower = upper + form.dataHeight.data - cropped_image = crop_image(image, crop_data=(left, upper, right, lower), department_id=department_id) + cropped_image = crop_image( + image, + crop_data=(left, upper, right, lower), + department_id=department_id, + ) cropped_image.contains_cops = True cropped_image.is_tagged = True if cropped_image: - new_tag = Face(officer_id=form.officer_id.data, - img_id=cropped_image.id, - original_image_id=image.id, - face_position_x=left, - face_position_y=upper, - face_width=form.dataWidth.data, - face_height=form.dataHeight.data, - user_id=current_user.get_id()) + new_tag = Face( + officer_id=form.officer_id.data, + img_id=cropped_image.id, + original_image_id=image.id, + face_position_x=left, + face_position_y=upper, + face_width=form.dataWidth.data, + face_height=form.dataHeight.data, + created_by=current_user.get_id(), + ) db.session.add(new_tag) db.session.commit() - flash('Tag added to database') + flash("Tag added to database") else: - flash('There was a problem saving this tag. Please try again later.') + flash("There was a problem saving this tag. Please try again later.") else: - flash('Tag already exists between this officer and image! Tag not added.') + flash("Tag already exists between this officer and image! Tag not added.") else: current_app.logger.info(form.errors) - return render_template('cop_face.html', form=form, - image=image, path=proper_path, - department=department, jsloads=jsloads) + return render_template( + "cop_face.html", + form=form, + image=image, + path=proper_path, + department=department, + jsloads=["js/cropper.js", "js/tagger.js"], + ) -@main.route('/image/tagged/') +@main.route("/image/tagged/") @login_required -def complete_tagging(image_id): +def redirect_complete_tagging(image_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.complete_tagging", image_id=image_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/images/tagged/") +@login_required +def complete_tagging(image_id: int): # Select a random untagged image from the database image = Image.query.filter_by(id=image_id).first() if not image: - abort(404) + abort(HTTPStatus.NOT_FOUND) image.is_tagged = True db.session.commit() - flash('Marked image as completed.') - department_id = request.args.get('department_id') + flash("Marked image as completed.") + department_id = request.args.get("department_id") if department_id: - return redirect(url_for('main.label_data', department_id=department_id)) + return redirect(url_for("main.label_data", department_id=department_id)) else: - return redirect(url_for('main.label_data')) + return redirect(url_for("main.label_data")) -@main.route('/tagger_gallery/', methods=['GET', 'POST']) -@main.route('/tagger_gallery', methods=['GET', 'POST']) -def get_tagger_gallery(page=1): - form = FindOfficerIDForm() - if form.validate_on_submit(): - OFFICERS_PER_PAGE = int(current_app.config['OFFICERS_PER_PAGE']) - form_data = form.data - officers = roster_lookup(form_data).paginate(page, OFFICERS_PER_PAGE, False) - return render_template('tagger_gallery.html', - officers=officers, - form=form, - form_data=form_data) - else: - current_app.logger.info(form.errors) - return redirect(url_for('main.get_ooid'), code=307) +@main.route("/complaint", methods=[HTTPMethod.GET, HTTPMethod.POST]) +def redirect_submit_complaint(): + return redirect( + url_for("main.submit_complaint"), + code=HTTPStatus.PERMANENT_REDIRECT, + ) -@main.route('/complaint', methods=['GET', 'POST']) +@main.route("/complaints", methods=[HTTPMethod.GET, HTTPMethod.POST]) def submit_complaint(): - return render_template('complaint.html', - officer_first_name=request.args.get('officer_first_name'), - officer_last_name=request.args.get('officer_last_name'), - officer_middle_initial=request.args.get('officer_middle_name'), - officer_star=request.args.get('officer_star'), - officer_image=request.args.get('officer_image')) + return render_template( + "complaint.html", + officer_first_name=request.args.get("officer_first_name", ""), + officer_last_name=request.args.get("officer_last_name", ""), + officer_middle_initial=request.args.get("officer_middle_name", ""), + officer_star=request.args.get("officer_star", ""), + officer_image=request.args.get("officer_image", ""), + ) @sitemap_include -@main.route('/submit', methods=['GET', 'POST']) -@limiter.limit('5/minute') +@main.route("/submit", methods=[HTTPMethod.GET, HTTPMethod.POST]) +@limiter.limit("5/minute") def submit_data(): preferred_dept_id = Department.query.first().id # try to use preferred department if available try: if User.query.filter_by(id=current_user.get_id()).one().dept_pref: - preferred_dept_id = User.query.filter_by(id=current_user.get_id()).one().dept_pref + preferred_dept_id = ( + User.query.filter_by(id=current_user.get_id()).one().dept_pref + ) form = AddImageForm() else: form = AddImageForm() - return render_template('submit_image.html', form=form, preferred_dept_id=preferred_dept_id) + return render_template( + "submit_image.html", form=form, preferred_dept_id=preferred_dept_id + ) # that is, an anonymous user has no id attribute except (AttributeError, NoResultFound): preferred_dept_id = Department.query.first().id form = AddImageForm() - return render_template('submit_image.html', form=form, preferred_dept_id=preferred_dept_id) - - -def check_input(str_input): - if str_input is None or str_input == "Not Sure": - return "" - else: - return str(str_input).replace(",", " ") # no commas allowed - - -@main.route('/download/department/', methods=['GET']) -@limiter.limit('5/minute') -def deprecated_download_dept_csv(department_id): - department = Department.query.filter_by(id=department_id).first() - records = Officer.query.filter_by(department_id=department_id).all() - if not department or not records: - abort(404) - dept_name = records[0].department.name.replace(" ", "_") - first_row = "id, last, first, middle, suffix, gender, "\ - "race, born, employment_date, assignments\n" - - assign_dict = {} - assign_records = Assignment.query.all() - for r in assign_records: - if r.officer_id not in assign_dict: - assign_dict[r.officer_id] = [] - assign_dict[r.officer_id].append("(#%s %s %s %s %s)" % (check_input(r.star_no), check_input(r.job_id), check_input(r.unit_id), check_input(r.star_date), check_input(r.resign_date))) - - record_list = ["%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n" % - (str(record.id), - check_input(record.last_name), - check_input(record.first_name), - check_input(record.middle_initial), - check_input(record.suffix), - check_input(record.gender), - check_input(record.race), - check_input(record.birth_year), - check_input(record.employment_date), - " ".join(assign_dict.get(record.id, [])), - ) for record in records] - - csv_name = dept_name + "_Officers.csv" - csv = first_row + "".join(record_list) - csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} - return Response(csv, mimetype="text/csv", headers=csv_headers) - - -def check_output(output_str): - if output_str == "Not Sure": - return "" - return output_str - - -@main.route('/download/department//officers', methods=['GET']) -@limiter.limit('5/minute') -def download_dept_officers_csv(department_id): - department = Department.query.filter_by(id=department_id).first() - if not department: - abort(404) - - officers = (db.session.query(Officer) - .options(joinedload(Officer.assignments_lazy) - .joinedload(Assignment.job) - ) - .options(joinedload(Officer.salaries)) - .filter_by(department_id=department_id) - ) - - if not officers: - abort(404) - csv_output = io.StringIO() - csv_fieldnames = ["id", "unique identifier", "last name", "first name", "middle initial", "suffix", "gender", - "race", "birth year", "employment date", "badge number", "job title", "most recent salary"] - csv_writer = csv.DictWriter(csv_output, fieldnames=csv_fieldnames) - csv_writer.writeheader() - - for officer in officers: - if officer.assignments_lazy: - most_recent_assignment = max(officer.assignments_lazy, key=lambda a: a.star_date or date.min) - most_recent_title = most_recent_assignment.job and check_output(most_recent_assignment.job.job_title) - else: - most_recent_assignment = None - most_recent_title = None - if officer.salaries: - most_recent_salary = max(officer.salaries, key=lambda s: s.year) - else: - most_recent_salary = None - record = { - "id": officer.id, - "unique identifier": officer.unique_internal_identifier, - "last name": officer.last_name, - "first name": officer.first_name, - "middle initial": officer.middle_initial, - "suffix": officer.suffix, - "gender": check_output(officer.gender), - "race": check_output(officer.race), - "birth year": officer.birth_year, - "employment date": officer.employment_date, - "badge number": most_recent_assignment and most_recent_assignment.star_no, - "job title": most_recent_title, - "most recent salary": most_recent_salary and most_recent_salary.salary, - } - csv_writer.writerow(record) - - dept_name = department.name.replace(" ", "_") - csv_name = dept_name + "_Officers.csv" - - csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} - return Response(csv_output.getvalue(), mimetype="text/csv", headers=csv_headers) - - -@main.route('/download/department//assignments', methods=['GET']) -@limiter.limit('5/minute') -def download_dept_assignments_csv(department_id): - department = Department.query.filter_by(id=department_id).first() - if not department: - abort(404) - - assignments = (db.session.query(Assignment) - .join(Assignment.baseofficer) - .filter(Officer.department_id == department_id) - .options(contains_eager(Assignment.baseofficer)) - .options(joinedload(Assignment.unit)) - .options(joinedload(Assignment.job)) - ) - - csv_output = io.StringIO() - csv_fieldnames = ["id", "officer id", "officer unique identifier", "badge number", "job title", "start date", "end date", "unit id"] - csv_writer = csv.DictWriter(csv_output, fieldnames=csv_fieldnames) - csv_writer.writeheader() - - for assignment in assignments: - officer = assignment.baseofficer - record = { - "id": assignment.id, - "officer id": assignment.officer_id, - "officer unique identifier": officer and officer.unique_internal_identifier, - "badge number": assignment.star_no, - "job title": assignment.job and check_output(assignment.job.job_title), - "start date": assignment.star_date, - "end date": assignment.resign_date, - "unit id": assignment.unit and assignment.unit.id, - } - csv_writer.writerow(record) - - dept_name = department.name.replace(" ", "_") - csv_name = dept_name + "_Assignments.csv" - - csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} - return Response(csv_output.getvalue(), mimetype="text/csv", headers=csv_headers) - - -@main.route('/download/department//incidents', methods=['GET']) -@limiter.limit('5/minute') -def download_incidents_csv(department_id): - department = Department.query.filter_by(id=department_id).first() - records = Incident.query.filter_by(department_id=department.id).all() - if not department or not records: - abort(404) - dept_name = records[0].department.name.replace(" ", "_") - first_row = "id,report_num,date,time,description,location,licences,links,officers\n" - - record_list = ["%s,%s,%s,%s,%s,%s,%s,%s,%s\n" % - (str(record.id), - check_input(record.report_number), - check_input(record.date), - check_input(record.time), - check_input(record.description), - check_input(record.address), - " ".join(map(lambda x: str(x), record.license_plates)), - " ".join(map(lambda x: str(x), record.links)), - " ".join(map(lambda x: str(x), record.officers)), - ) for record in records] - - csv_name = dept_name + "_Incidents.csv" - csv = first_row + "".join(record_list) - csv_headers = {"Content-disposition": "attachment; filename=" + csv_name} - return Response(csv, mimetype="text/csv", headers=csv_headers) + return render_template( + "submit_image.html", form=form, preferred_dept_id=preferred_dept_id + ) + + +@main.route( + "/download/department//officers", methods=[HTTPMethod.GET] +) +def redirect_download_dept_officers_csv(department_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.download_dept_officers_csv", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/download/departments//officers", methods=[HTTPMethod.GET] +) +@limiter.limit("5/minute") +def download_dept_officers_csv(department_id: int): + cache_params = (Department(id=department_id), KEY_DEPT_ALL_OFFICERS) + officers = get_database_cache_entry(*cache_params) + if officers is None: + officers = ( + db.session.query(Officer) + .options(joinedload(Officer.assignments).joinedload(Assignment.job)) + .options(joinedload(Officer.salaries)) + .filter_by(department_id=department_id) + .all() + ) + put_database_cache_entry(*cache_params, officers) + + field_names = [ + "id", + "unique identifier", + "last name", + "first name", + "middle initial", + "suffix", + "gender", + "race", + "birth year", + "employment date", + "badge number", + "job title", + "most recent salary", + ] + return make_downloadable_csv( + officers, department_id, "Officers", field_names, officer_record_maker + ) + + +@main.route( + "/download/department//assignments", methods=[HTTPMethod.GET] +) +def redirect_download_dept_assignments_csv(department_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.download_dept_assignments_csv", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/download/departments//assignments", methods=[HTTPMethod.GET] +) +@limiter.limit("5/minute") +def download_dept_assignments_csv(department_id: int): + cache_params = Department(id=department_id), KEY_DEPT_ALL_ASSIGNMENTS + assignments = get_database_cache_entry(*cache_params) + if assignments is None: + assignments = ( + db.session.query(Assignment) + .join(Assignment.base_officer) + .filter(Officer.department_id == department_id) + .options(contains_eager(Assignment.base_officer)) + .options(joinedload(Assignment.unit)) + .options(joinedload(Assignment.job)) + .all() + ) + put_database_cache_entry(*cache_params, assignments) + + field_names = [ + "id", + "officer id", + "officer unique identifier", + "badge number", + "job title", + "start date", + "end date", + "unit id", + "unit description", + ] + return make_downloadable_csv( + assignments, + department_id, + "Assignments", + field_names, + assignment_record_maker, + ) + + +@main.route( + "/download/department//incidents", methods=[HTTPMethod.GET] +) +def redirect_download_incidents_csv(department_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.download_incidents_csv", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/download/departments//incidents", methods=[HTTPMethod.GET] +) +@limiter.limit("5/minute") +def download_incidents_csv(department_id: int): + cache_params = (Department(id=department_id), KEY_DEPT_ALL_INCIDENTS) + incidents = get_database_cache_entry(*cache_params) + if incidents is None: + incidents = Incident.query.filter_by(department_id=department_id).all() + put_database_cache_entry(*cache_params, incidents) + + field_names = [ + "id", + "report_num", + "date", + "time", + "description", + "location", + "licenses", + "links", + "officers", + ] + return make_downloadable_csv( + incidents, + department_id, + "Incidents", + field_names, + incidents_record_maker, + ) + + +@main.route( + "/download/department//salaries", methods=[HTTPMethod.GET] +) +def redirect_download_dept_salaries_csv(department_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.download_dept_salaries_csv", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/download/departments//salaries", methods=[HTTPMethod.GET] +) +@limiter.limit("5/minute") +def download_dept_salaries_csv(department_id: int): + cache_params = (Department(id=department_id), KEY_DEPT_ALL_SALARIES) + salaries = get_database_cache_entry(*cache_params) + if salaries is None: + salaries = ( + db.session.query(Salary) + .join(Salary.officer) + .filter(Officer.department_id == department_id) + .options(contains_eager(Salary.officer)) + .all() + ) + put_database_cache_entry(*cache_params, salaries) + + field_names = [ + "id", + "officer id", + "first name", + "last name", + "salary", + "overtime_pay", + "year", + "is_fiscal_year", + ] + return make_downloadable_csv( + salaries, department_id, "Salaries", field_names, salary_record_maker + ) + + +@main.route("/download/department//links", methods=[HTTPMethod.GET]) +def redirect_download_dept_links_csv(department_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.download_dept_links_csv", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/download/departments//links", methods=[HTTPMethod.GET]) +@limiter.limit("5/minute") +def download_dept_links_csv(department_id: int): + cache_params = (Department(id=department_id), KEY_DEPT_ALL_LINKS) + links = get_database_cache_entry(*cache_params) + if links is None: + links = ( + db.session.query(Link) + .join(Link.officers) + .filter(Officer.department_id == department_id) + .options(contains_eager(Link.officers)) + .all() + ) + put_database_cache_entry(*cache_params, links) + + field_names = [ + "id", + "title", + "url", + "link_type", + "description", + "author", + "officers", + "incidents", + ] + return make_downloadable_csv( + links, department_id, "Links", field_names, links_record_maker + ) + + +@main.route( + "/download/department//descriptions", methods=[HTTPMethod.GET] +) +def redirect_download_dept_descriptions_csv(department_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.download_dept_descriptions_csv", department_id=department_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/download/departments//descriptions", methods=[HTTPMethod.GET] +) +@limiter.limit("5/minute") +def download_dept_descriptions_csv(department_id: int): + cache_params = (Department(id=department_id), KEY_DEPT_ALL_NOTES) + notes = get_database_cache_entry(*cache_params) + if notes is None: + notes = ( + db.session.query(Description) + .join(Description.officer) + .filter(Officer.department_id == department_id) + .options(contains_eager(Description.officer)) + .all() + ) + put_database_cache_entry(*cache_params, notes) + + field_names = [ + "id", + "text_contents", + "created_by", + "officer_id", + "created_at", + "updated_at", + ] + return make_downloadable_csv( + notes, department_id, "Notes", field_names, descriptions_record_maker + ) @sitemap_include -@main.route('/download/all', methods=['GET']) +@main.route("/download/all", methods=[HTTPMethod.GET]) def all_data(): departments = Department.query.filter(Department.officers.any()) - return render_template('all_depts.html', departments=departments) + return render_template("departments_all.html", departments=departments) -@main.route('/submit_officer_images/officer/', methods=['GET', 'POST']) +@main.route( + "/submit_officer_images/officer/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +@login_required +@ac_or_admin_required +def redirect_submit_officer_images(officer_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.submit_officer_images", officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route( + "/submit_officer_images/officers/", + methods=[HTTPMethod.GET, HTTPMethod.POST], +) @login_required @ac_or_admin_required -def submit_officer_images(officer_id): +def submit_officer_images(officer_id: int): officer = Officer.query.get_or_404(officer_id) - return render_template('submit_officer_image.html', officer=officer) + return render_template("submit_officer_image.html", officer=officer) -@main.route('/upload/department/', methods=['POST']) -@main.route('/upload/department//officer/', methods=['POST']) -@limiter.limit('250/minute') -def upload(department_id, officer_id=None): +@main.route("/upload/department/", methods=[HTTPMethod.POST]) +@main.route( + "/upload/department//officer/", + methods=[HTTPMethod.POST], +) +@login_required +def redirect_upload(department_id: int = 0, officer_id: int = 0): + return redirect( + url_for("main.upload", department_id=department_id, officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@main.route("/upload/departments/", methods=[HTTPMethod.POST]) +@main.route( + "/upload/departments//officers/", + methods=[HTTPMethod.POST], +) +@login_required +@limiter.limit("250/minute") +def upload(department_id: int = 0, officer_id: int = 0): if officer_id: officer = Officer.query.filter_by(id=officer_id).first() if not officer: - return jsonify(error='This officer does not exist.'), 404 - if not (current_user.is_administrator or - (current_user.is_area_coordinator and officer.department_id == current_user.ac_department_id)): - return jsonify(error='You are not authorized to upload photos of this officer.'), 403 - file_to_upload = request.files['file'] + return jsonify(error="This officer does not exist."), HTTPStatus.NOT_FOUND + if not ( + current_user.is_administrator + or ( + current_user.is_area_coordinator + and officer.department_id == current_user.ac_department_id + ) + ): + return ( + jsonify( + error="You are not authorized to upload photos of this officer." + ), + HTTPStatus.FORBIDDEN, + ) + file_to_upload = request.files["file"] if not allowed_file(file_to_upload.filename): - return jsonify(error="File type not allowed!"), 415 - image = upload_image_to_s3_and_store_in_db(file_to_upload, current_user.get_id(), department_id=department_id) + return ( + jsonify(error="File type not allowed!"), + HTTPStatus.UNSUPPORTED_MEDIA_TYPE, + ) + + try: + image = save_image_to_s3_and_db( + file_to_upload, current_user.get_id(), department_id=department_id + ) + except ValueError: + # Raised if MIME type not allowed + return jsonify(error="Invalid data type!"), HTTPStatus.UNSUPPORTED_MEDIA_TYPE if image: db.session.add(image) if officer_id: image.is_tagged = True image.contains_cops = True - cropped_image = crop_image(image, department_id=department_id) - cropped_image.contains_cops = True - cropped_image.is_tagged = True - face = Face(officer_id=officer_id, - img_id=cropped_image.id, - original_image_id=image.id, - user_id=current_user.get_id()) + face = Face( + officer_id=officer_id, + # Assuming photos uploaded with an officer ID are already cropped, + # we set both images to the uploaded one + img_id=image.id, + original_image_id=image.id, + created_by=current_user.get_id(), + ) db.session.add(face) db.session.commit() - return jsonify(success='Success!'), 200 + return jsonify(success="Success!"), HTTPStatus.OK else: - return jsonify(error="Server error encountered. Try again later."), 500 + return ( + jsonify(error="Server error encountered. Try again later."), + HTTPStatus.INTERNAL_SERVER_ERROR, + ) @sitemap_include -@main.route('/about') +@main.route("/about") def about_oo(): - return render_template('about.html') + return render_template("about.html") @sitemap_include -@main.route('/privacy') +@main.route("/privacy") def privacy_oo(): - return render_template('privacy.html') + return render_template("privacy.html") -@main.route('/shutdown') # pragma: no cover -def server_shutdown(): # pragma: no cover +@main.route("/shutdown") # pragma: no cover +def server_shutdown(): # pragma: no cover if not current_app.testing: - abort(404) - shutdown = request.environ.get('werkzeug.server.shutdown') + abort(HTTPStatus.NOT_FOUND) + shutdown = request.environ.get("werkzeug.server.shutdown") if not shutdown: - abort(500) + abort(HTTPStatus.INTERNAL_SERVER_ERROR) shutdown() - return 'Shutting down...' + return "Shutting down..." class IncidentApi(ModelView): model = Incident - model_name = 'incident' - order_by = 'date' + model_name = "incident" + order_by = "date" descending = True form = IncidentForm create_function = create_incident department_check = True - def get(self, obj_id): - if request.args.get('page'): - page = int(request.args.get('page')) - else: - page = 1 - if request.args.get('department_id'): - department_id = request.args.get('department_id') - dept = Department.query.get_or_404(department_id) - obj = self.model.query.filter_by(department_id=department_id).order_by(getattr(self.model, self.order_by).desc()).paginate(page, self.per_page, False) - return render_template('{}_list.html'.format(self.model_name), objects=obj, url='main.{}_api'.format(self.model_name), department=dept) - else: + def get(self, obj_id: int): + if obj_id: + # Single-item view return super(IncidentApi, self).get(obj_id) + # List view + page = int(request.args.get("page", 1)) + + form = IncidentListForm() + incidents = self.model.query + + dept = None + if department_id := request.args.get("department_id"): + dept = Department.query.get_or_404(department_id) + form.department_id.data = department_id + incidents = incidents.filter_by(department_id=department_id) + + if report_number := request.args.get("report_number"): + form.report_number.data = report_number + incidents = incidents.filter( + Incident.report_number.contains(report_number.strip()) + ) + + if occurred_before := request.args.get("occurred_before"): + before_date = datetime.datetime.strptime(occurred_before, "%Y-%m-%d").date() + form.occurred_before.data = before_date + incidents = incidents.filter(self.model.date < before_date) + + if occurred_after := request.args.get("occurred_after"): + after_date = datetime.datetime.strptime(occurred_after, "%Y-%m-%d").date() + form.occurred_after.data = after_date + incidents = incidents.filter(self.model.date > after_date) + + incidents = incidents.order_by( + getattr(self.model, self.order_by).desc() + ).paginate(page=page, per_page=self.per_page, error_out=False) + + url = f"main.{self.model_name}_api" + next_url = url_for( + url, + page=incidents.next_num, + department_id=department_id, + report_number=report_number, + occurred_after=occurred_after, + occurred_before=occurred_before, + ) + prev_url = url_for( + url, + page=incidents.prev_num, + department_id=department_id, + report_number=report_number, + occurred_after=occurred_after, + occurred_before=occurred_before, + ) + + return render_template( + f"{self.model_name}_list.html", + form=form, + incidents=incidents, + url=url, + next_url=next_url, + prev_url=prev_url, + department=dept, + ) + def get_new_form(self): form = self.form() - if request.args.get('officer_id'): - form.officers[0].oo_id.data = request.args.get('officer_id') + if request.args.get("officer_id"): + form.officers[0].oo_id.data = request.args.get("officer_id") for link in form.links: - link.creator_id.data = current_user.id + link.created_by.data = current_user.id return form - def get_edit_form(self, obj): + def get_edit_form(self, obj: Incident): form = super(IncidentApi, self).get_edit_form(obj=obj) no_license_plates = len(obj.license_plates) no_links = len(obj.links) no_officers = len(obj.officers) for link in form.links: - if link.creator_id.data: + if link.created_by.data: continue else: - link.creator_id.data = current_user.id + link.created_by.data = current_user.id for officer_idx, officer in enumerate(obj.officers): form.officers[officer_idx].oo_id.data = officer.id @@ -1187,23 +2047,23 @@ def get_edit_form(self, obj): form.time_field.data = obj.time return form - def populate_obj(self, form, obj): + def populate_obj(self, form: FlaskForm, obj: Incident): # remove all fields not directly on the Incident model # use utils to add them to the current object - address = form.data.pop('address') + address = form.data.pop("address") del form.address - if address['city']: + if address["city"]: new_address, _ = get_or_create(db.session, Location, **address) obj.address = new_address else: obj.address = None - links = form.data.pop('links') + links = form.data.pop("links") del form.links - if links and links[0]['url']: - replace_list(links, obj, 'links', Link, db) + if links and links[0]["url"]: + replace_list(links, obj, "links", Link, db) - officers = form.data.pop('officers') + officers = form.data.pop("officers") del form.officers if officers: for officer in officers: @@ -1212,56 +2072,59 @@ def populate_obj(self, form, obj): of = Officer.query.filter_by(id=int(officer["oo_id"])).first() # Sometimes we get a string in officer["oo_id"], this parses it except ValueError: - our_id = officer["oo_id"].split("value=\"")[1][:-2] + our_id = officer["oo_id"].split('value="')[1][:-2] of = Officer.query.filter_by(id=int(our_id)).first() - if of: + if of and of not in obj.officers: obj.officers.append(of) - license_plates = form.data.pop('license_plates') + license_plates = form.data.pop("license_plates") del form.license_plates - if license_plates and license_plates[0]['number']: - replace_list(license_plates, obj, 'license_plates', LicensePlate, db) + if license_plates and license_plates[0]["number"]: + replace_list(license_plates, obj, "license_plates", LicensePlate, db) obj.date = form.date_field.data - if form.time_field.raw_data and form.time_field.raw_data != ['']: + if form.time_field.raw_data and form.time_field.raw_data != [""]: obj.time = form.time_field.data else: obj.time = None super(IncidentApi, self).populate_obj(form, obj) -incident_view = IncidentApi.as_view('incident_api') +incident_view = IncidentApi.as_view("incident_api") main.add_url_rule( - '/incidents/', - defaults={'obj_id': None}, + "/incidents/", + defaults={"obj_id": None}, view_func=incident_view, - methods=['GET']) + methods=[HTTPMethod.GET], +) main.add_url_rule( - '/incidents/new', + "/incidents/new", view_func=incident_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/incidents/', - view_func=incident_view, - methods=['GET']) + "/incidents/", view_func=incident_view, methods=[HTTPMethod.GET] +) main.add_url_rule( - '/incidents//edit', + "/incidents//edit", view_func=incident_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/incidents//delete', + "/incidents//delete", view_func=incident_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) @sitemap.register_generator def sitemap_incidents(): for incident in Incident.query.all(): - yield 'main.incident_api', {'obj_id': incident.id} + yield "main.incident_api", {"obj_id": incident.id} class TextApi(ModelView): - order_by = 'date_created' + order_by = "created_at" descending = True department_check = True form = TextForm @@ -1272,29 +2135,29 @@ def get_new_form(self): return form def get_redirect_url(self, *args, **kwargs): - return redirect(url_for('main.officer_profile', officer_id=self.officer_id)) + return redirect(url_for("main.officer_profile", officer_id=self.officer_id)) def get_post_delete_url(self, *args, **kwargs): return self.get_redirect_url() - def get_department_id(self, obj): + def get_department_id(self, obj: TextForm): return self.department_id - def get_edit_form(self, obj): + def get_edit_form(self, obj: TextForm): form = EditTextForm(obj=obj) return form def dispatch_request(self, *args, **kwargs): - if 'officer_id' in kwargs: - officer = Officer.query.get_or_404(kwargs['officer_id']) - self.officer_id = kwargs.pop('officer_id') + if "officer_id" in kwargs: + officer = Officer.query.get_or_404(kwargs["officer_id"]) + self.officer_id = kwargs.pop("officer_id") self.department_id = officer.department_id return super(TextApi, self).dispatch_request(*args, **kwargs) class NoteApi(TextApi): model = Note - model_name = 'note' + model_name = "note" form = TextForm create_function = create_note @@ -1304,7 +2167,7 @@ def dispatch_request(self, *args, **kwargs): class DescriptionApi(TextApi): model = Description - model_name = 'description' + model_name = "description" form = TextForm create_function = create_description @@ -1312,66 +2175,199 @@ def dispatch_request(self, *args, **kwargs): return super(DescriptionApi, self).dispatch_request(*args, **kwargs) -note_view = NoteApi.as_view('note_api') +@login_required +@ac_or_admin_required +def redirect_new_note(officer_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.note_api", officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +def redirect_get_notes(officer_id: int, obj_id=None): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.note_api", officer_id=officer_id, obj_id=obj_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@login_required +@ac_or_admin_required +def redirect_edit_note(officer_id: int, obj_id=None): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + f"{url_for('main.note_api', officer_id=officer_id, obj_id=obj_id)}/edit", + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@login_required +@ac_or_admin_required +def redirect_delete_note(officer_id: int, obj_id=None): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + f"{url_for('main.note_api', officer_id=officer_id, obj_id=obj_id)}/delete", + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +note_view = NoteApi.as_view("note_api") main.add_url_rule( - '/officer//note/new', + "/officers//notes/new", view_func=note_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/officer//note/', + "/officer//note/new", + view_func=redirect_new_note, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +main.add_url_rule( + "/officers//notes/", view_func=note_view, - methods=['GET']) + methods=[HTTPMethod.GET], +) +main.add_url_rule( + "/officer//note/", + view_func=redirect_get_notes, + methods=[HTTPMethod.GET], +) main.add_url_rule( - '/officer//note//edit', + "/officers//notes//edit", view_func=note_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/officer//note//delete', + "/officer//note//edit", + view_func=redirect_edit_note, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +main.add_url_rule( + "/officers//notes//delete", view_func=note_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +main.add_url_rule( + "/officer//note//delete", + view_func=redirect_delete_note, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) -description_view = DescriptionApi.as_view('description_api') + +@login_required +@ac_or_admin_required +def redirect_new_description(officer_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.description_api", officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +def redirect_get_description(officer_id: int, obj_id=None): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.description_api", officer_id=officer_id, obj_id=obj_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@login_required +@ac_or_admin_required +def redirect_edit_description(officer_id: int, obj_id=None): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + f"{url_for('main.description_api', officer_id=officer_id, obj_id=obj_id)}/edit", + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@login_required +@ac_or_admin_required +def redirect_delete_description(officer_id: int, obj_id=None): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + f"{url_for('main.description_api', officer_id=officer_id, obj_id=obj_id)}/delete", + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +description_view = DescriptionApi.as_view("description_api") main.add_url_rule( - '/officer//description/new', + "/officers//descriptions/new", view_func=description_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/officer//description/', + "/officer//description/new", + view_func=redirect_new_description, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +main.add_url_rule( + "/officers//descriptions/", view_func=description_view, - methods=['GET']) + methods=[HTTPMethod.GET], +) +main.add_url_rule( + "/officer//description/", + view_func=redirect_get_description, + methods=[HTTPMethod.GET], +) main.add_url_rule( - '/officer//description//edit', + "/officers//descriptions//edit", view_func=description_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/officer//description//delete', + "/officer//description//edit", + view_func=redirect_edit_description, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +main.add_url_rule( + "/officers//descriptions//delete", view_func=description_view, - methods=['GET', 'POST']) + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +main.add_url_rule( + "/officer//description//delete", + view_func=redirect_delete_description, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) class OfficerLinkApi(ModelView): - '''This API only applies to links attached to officer profiles, not links attached to incidents''' + """ + This API only applies to links attached to officer profiles, not links attached to + incidents. + """ model = Link - model_name = 'link' + model_name = "link" form = OfficerLinkForm department_check = True @property def officer(self): - if not hasattr(self, '_officer'): - self._officer = db.session.query(Officer).filter_by(id=self.officer_id).one() + if not hasattr(self, "_officer"): + self._officer = ( + db.session.query(Officer).filter_by(id=self.officer_id).one() + ) return self._officer @login_required @ac_or_admin_required - def new(self, form=None): - if not current_user.is_administrator and current_user.ac_department_id != self.officer.department_id: - abort(403) + def new(self, form: FlaskForm = None): + if ( + not current_user.is_administrator + and current_user.ac_department_id != self.officer.department_id + ): + abort(HTTPStatus.FORBIDDEN) if not form: form = self.get_new_form() - if hasattr(form, 'creator_id') and not form.creator_id.data: - form.creator_id.data = current_user.get_id() + if hasattr(form, "created_by") and not form.created_by.data: + form.created_by.data = current_user.get_id() if form.validate_on_submit(): link = Link( @@ -1380,29 +2376,43 @@ def new(self, form=None): link_type=form.link_type.data, description=form.description.data, author=form.author.data, - creator_id=form.creator_id.data) + created_by=form.created_by.data, + ) self.officer.links.append(link) db.session.add(link) db.session.commit() - flash('{} created!'.format(self.model_name)) + Department(id=self.officer.department_id).remove_database_cache_entries( + [KEY_DEPT_ALL_LINKS] + ) + flash(f"{self.model_name} created!") return self.get_redirect_url(obj_id=link.id) - return render_template('{}_new.html'.format(self.model_name), form=form) + return render_template(f"{self.model_name}_new.html", form=form) @login_required @ac_or_admin_required - def delete(self, obj_id): + def delete(self, obj_id: int): obj = self.model.query.get_or_404(obj_id) - if not current_user.is_administrator and current_user.ac_department_id != self.get_department_id(obj): - abort(403) + if ( + not current_user.is_administrator + and current_user.ac_department_id != self.get_department_id(obj) + ): + abort(HTTPStatus.FORBIDDEN) - if request.method == 'POST': + if request.method == HTTPMethod.POST: db.session.delete(obj) db.session.commit() - flash('{} successfully deleted!'.format(self.model_name)) + Department(id=self.officer.department_id).remove_database_cache_entries( + [KEY_DEPT_ALL_LINKS] + ) + flash(f"{self.model_name} successfully deleted!") return self.get_post_delete_url() - return render_template('{}_delete.html'.format(self.model_name), obj=obj, officer_id=self.officer_id) + return render_template( + f"{self.model_name}_delete.html", + obj=obj, + officer_id=self.officer_id, + ) def get_new_form(self): form = self.form() @@ -1415,7 +2425,7 @@ def get_edit_form(self, obj): return form def get_redirect_url(self, *args, **kwargs): - return redirect(url_for('main.officer_profile', officer_id=self.officer_id)) + return redirect(url_for("main.officer_profile", officer_id=self.officer_id)) def get_post_delete_url(self, *args, **kwargs): return self.get_redirect_url() @@ -1424,22 +2434,70 @@ def get_department_id(self, obj): return self.officer.department_id def dispatch_request(self, *args, **kwargs): - if 'officer_id' in kwargs: - officer = Officer.query.get_or_404(kwargs['officer_id']) - self.officer_id = kwargs.pop('officer_id') + if "officer_id" in kwargs: + officer = Officer.query.get_or_404(kwargs["officer_id"]) + self.officer_id = kwargs.pop("officer_id") self.department_id = officer.department_id return super(OfficerLinkApi, self).dispatch_request(*args, **kwargs) +@login_required +@ac_or_admin_required +def redirect_new_link(officer_id: int): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.link_api_new", officer_id=officer_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@login_required +@ac_or_admin_required +def redirect_edit_link(officer_id: int, obj_id=None): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.link_api_edit", officer_id=officer_id, obj_id=obj_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +@login_required +@ac_or_admin_required +def redirect_delete_link(officer_id: int, obj_id=None): + flash(FLASH_MSG_PERMANENT_REDIRECT) + return redirect( + url_for("main.link_api_delete", officer_id=officer_id, obj_id=obj_id), + code=HTTPStatus.PERMANENT_REDIRECT, + ) + + +main.add_url_rule( + "/officers//links/new", + view_func=OfficerLinkApi.as_view("link_api_new"), + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +main.add_url_rule( + "/officer//link/new", + view_func=redirect_new_link, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) +main.add_url_rule( + "/officers//links//edit", + view_func=OfficerLinkApi.as_view("link_api_edit"), + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/officer//link/new', - view_func=OfficerLinkApi.as_view('link_api_new'), - methods=['GET', 'POST']) + "/officer//link//edit", + view_func=redirect_edit_link, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/officer//link//edit', - view_func=OfficerLinkApi.as_view('link_api_edit'), - methods=['GET', 'POST']) + "/officers//links//delete", + view_func=OfficerLinkApi.as_view("link_api_delete"), + methods=[HTTPMethod.GET, HTTPMethod.POST], +) main.add_url_rule( - '/officer//link//delete', - view_func=OfficerLinkApi.as_view('link_api_delete'), - methods=['GET', 'POST']) + "/officer//link//delete", + view_func=redirect_delete_link, + methods=[HTTPMethod.GET, HTTPMethod.POST], +) diff --git a/OpenOversight/app/models.py b/OpenOversight/app/models.py deleted file mode 100644 index 4a2bdbbb0..000000000 --- a/OpenOversight/app/models.py +++ /dev/null @@ -1,488 +0,0 @@ -import re -from datetime import date - -from flask_sqlalchemy import SQLAlchemy -from flask_sqlalchemy.model import DefaultMeta -from sqlalchemy.orm import validates -from sqlalchemy import UniqueConstraint, CheckConstraint -from werkzeug.security import generate_password_hash, check_password_hash -from itsdangerous import TimedJSONWebSignatureSerializer as Serializer -from itsdangerous import BadSignature, BadData -from flask_login import UserMixin -from flask import current_app -from .validators import state_validator, url_validator -from . import login_manager - -db = SQLAlchemy() - -BaseModel = db.Model # type: DefaultMeta - -officer_links = db.Table('officer_links', - db.Column('officer_id', db.Integer, db.ForeignKey('officers.id'), primary_key=True), - db.Column('link_id', db.Integer, db.ForeignKey('links.id'), primary_key=True)) - -officer_incidents = db.Table('officer_incidents', - db.Column('officer_id', db.Integer, db.ForeignKey('officers.id'), primary_key=True), - db.Column('incident_id', db.Integer, db.ForeignKey('incidents.id'), primary_key=True)) - - -class Department(BaseModel): - __tablename__ = 'departments' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(255), index=True, unique=True, nullable=False) - short_name = db.Column(db.String(100), unique=False, nullable=False) - unique_internal_identifier_label = db.Column(db.String(100), unique=False, nullable=True) - - def __repr__(self): - return ''.format(self.id, self.name) - - def toCustomDict(self): - return {'id': self.id, - 'name': self.name, - 'short_name': self.short_name, - 'unique_internal_identifier_label': self.unique_internal_identifier_label - } - - -class Job(BaseModel): - __tablename__ = 'jobs' - - id = db.Column(db.Integer, primary_key=True) - job_title = db.Column(db.String(255), index=True, unique=False, nullable=False) - is_sworn_officer = db.Column(db.Boolean, index=True, default=True) - order = db.Column(db.Integer, index=True, unique=False, nullable=False) - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='jobs') - - __table_args__ = (UniqueConstraint('job_title', 'department_id', - name='unique_department_job_titles'), ) - - def __repr__(self): - return ''.format(self.id, self.job_title) - - def __str__(self): - return self.job_title - - -class Note(BaseModel): - __tablename__ = 'notes' - - id = db.Column(db.Integer, primary_key=True) - text_contents = db.Column(db.Text()) - creator_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL')) - creator = db.relationship('User', backref='notes') - officer_id = db.Column(db.Integer, db.ForeignKey('officers.id', ondelete='CASCADE')) - officer = db.relationship('Officer', back_populates='notes') - date_created = db.Column(db.DateTime) - date_updated = db.Column(db.DateTime) - - -class Description(BaseModel): - __tablename__ = 'descriptions' - - creator = db.relationship('User', backref='descriptions') - officer = db.relationship('Officer', back_populates='descriptions') - id = db.Column(db.Integer, primary_key=True) - text_contents = db.Column(db.Text()) - creator_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL')) - officer_id = db.Column(db.Integer, db.ForeignKey('officers.id', ondelete='CASCADE')) - date_created = db.Column(db.DateTime) - date_updated = db.Column(db.DateTime) - - -class Officer(BaseModel): - __tablename__ = 'officers' - - id = db.Column(db.Integer, primary_key=True) - last_name = db.Column(db.String(120), index=True, unique=False) - first_name = db.Column(db.String(120), index=True, unique=False) - middle_initial = db.Column(db.String(120), unique=False, nullable=True) - suffix = db.Column(db.String(120), index=True, unique=False) - race = db.Column(db.String(120), index=True, unique=False) - gender = db.Column(db.String(5), index=True, unique=False, nullable=True) - employment_date = db.Column(db.Date, index=True, unique=False, nullable=True) - birth_year = db.Column(db.Integer, index=True, unique=False, nullable=True) - assignments = db.relationship('Assignment', backref='officer', lazy='dynamic') - assignments_lazy = db.relationship('Assignment') - face = db.relationship('Face', backref='officer') - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='officers') - unique_internal_identifier = db.Column(db.String(50), index=True, unique=True, nullable=True) - - links = db.relationship( - 'Link', - secondary=officer_links, - backref=db.backref('officers', lazy=True)) - notes = db.relationship('Note', back_populates='officer', order_by='Note.date_created') - descriptions = db.relationship('Description', back_populates='officer', order_by='Description.date_created') - salaries = db.relationship('Salary', back_populates='officer', order_by='Salary.year.desc()') - - __table_args__ = ( - CheckConstraint("gender in ('M', 'F', 'Other')", name='gender_options'), - ) - - def full_name(self): - if self.middle_initial: - middle_initial = self.middle_initial + '.' if len(self.middle_initial) == 1 else self.middle_initial - if self.suffix: - return '{} {} {} {}'.format(self.first_name, middle_initial, self.last_name, self.suffix) - else: - return '{} {} {}'.format(self.first_name, middle_initial, self.last_name) - if self.suffix: - return '{} {} {}'.format(self.first_name, self.last_name, self.suffix) - return '{} {}'.format(self.first_name, self.last_name) - - def race_label(self): - from .main.choices import RACE_CHOICES - for race, label in RACE_CHOICES: - if self.race == race: - return label - - def gender_label(self): - if self.gender is None: - return 'Data Missing' - from .main.choices import GENDER_CHOICES - for gender, label in GENDER_CHOICES: - if self.gender == gender: - return label - - def job_title(self): - if self.assignments_lazy: - return max(self.assignments_lazy, key=lambda x: x.star_date or date.min).job.job_title - - def badge_number(self): - if self.assignments_lazy: - return max(self.assignments_lazy, key=lambda x: x.star_date or date.min).star_no - - def __repr__(self): - if self.unique_internal_identifier: - return ''.format(self.id, - self.first_name, - self.middle_initial, - self.last_name, - self.suffix, - self.unique_internal_identifier) - return ''.format(self.id, - self.first_name, - self.middle_initial, - self.last_name, - self.suffix) - - -class Salary(BaseModel): - __tablename__ = 'salaries' - - id = db.Column(db.Integer, primary_key=True) - officer_id = db.Column(db.Integer, db.ForeignKey('officers.id', ondelete='CASCADE')) - officer = db.relationship('Officer', back_populates='salaries') - salary = db.Column(db.Numeric, index=True, unique=False, nullable=False) - overtime_pay = db.Column(db.Numeric, index=True, unique=False, nullable=True) - year = db.Column(db.Integer, index=True, unique=False, nullable=False) - is_fiscal_year = db.Column(db.Boolean, index=False, unique=False, nullable=False) - - def __repr__(self): - return ''.format(self.officer_id, - self.star_no) - - -class Unit(BaseModel): - __tablename__ = 'unit_types' - - id = db.Column(db.Integer, primary_key=True) - descrip = db.Column(db.String(120), index=True, unique=False) - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='unit_types', order_by='Unit.descrip.asc()') - - def __repr__(self): - return 'Unit: {}'.format(self.descrip) - - -class Face(BaseModel): - __tablename__ = 'faces' - - id = db.Column(db.Integer, primary_key=True) - officer_id = db.Column(db.Integer, db.ForeignKey('officers.id')) - img_id = db.Column( - db.Integer, - db.ForeignKey( - 'raw_images.id', - ondelete='CASCADE', - onupdate='CASCADE', - name='fk_face_image_id', - use_alter=True), - ) - original_image_id = db.Column( - db.Integer, - db.ForeignKey( - 'raw_images.id', - ondelete='SET NULL', - onupdate='CASCADE', - use_alter=True, - name='fk_face_original_image_id') - ) - face_position_x = db.Column(db.Integer, unique=False) - face_position_y = db.Column(db.Integer, unique=False) - face_width = db.Column(db.Integer, unique=False) - face_height = db.Column(db.Integer, unique=False) - image = db.relationship('Image', backref='faces', foreign_keys=[img_id]) - original_image = db.relationship('Image', backref='tags', foreign_keys=[original_image_id], lazy=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - user = db.relationship('User', backref='faces') - featured = db.Column(db.Boolean, nullable=False, default=False, server_default='false') - - __table_args__ = (UniqueConstraint('officer_id', 'img_id', - name='unique_faces'), ) - - def __repr__(self): - return ''.format(self.id, self.officer_id, self.img_id) - - -class Image(BaseModel): - __tablename__ = 'raw_images' - - id = db.Column(db.Integer, primary_key=True) - filepath = db.Column(db.String(255), unique=False) - hash_img = db.Column(db.String(120), unique=False, nullable=True) - - # Track when the image was put into our database - date_image_inserted = db.Column(db.DateTime, index=True, unique=False, nullable=True) - - # We might know when the image was taken e.g. through EXIF data - date_image_taken = db.Column(db.DateTime, index=True, unique=False, nullable=True) - contains_cops = db.Column(db.Boolean, nullable=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - - user = db.relationship('User', backref='raw_images') - is_tagged = db.Column(db.Boolean, default=False, unique=False, nullable=True) - - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='raw_images') - - def __repr__(self): - return ''.format(self.id, self.filepath) - - -incident_links = db.Table( - 'incident_links', - db.Column('incident_id', db.Integer, db.ForeignKey('incidents.id'), primary_key=True), - db.Column('link_id', db.Integer, db.ForeignKey('links.id'), primary_key=True) -) - -incident_license_plates = db.Table( - 'incident_license_plates', - db.Column('incident_id', db.Integer, db.ForeignKey('incidents.id'), primary_key=True), - db.Column('license_plate_id', db.Integer, db.ForeignKey('license_plates.id'), primary_key=True) -) - -incident_officers = db.Table( - 'incident_officers', - db.Column('incident_id', db.Integer, db.ForeignKey('incidents.id'), primary_key=True), - db.Column('officers_id', db.Integer, db.ForeignKey('officers.id'), primary_key=True) -) - - -class Location(BaseModel): - __tablename__ = 'locations' - - id = db.Column(db.Integer, primary_key=True) - street_name = db.Column(db.String(100), index=True) - cross_street1 = db.Column(db.String(100), unique=False) - cross_street2 = db.Column(db.String(100), unique=False) - city = db.Column(db.String(100), unique=False, index=True) - state = db.Column(db.String(2), unique=False, index=True) - zip_code = db.Column(db.String(5), unique=False, index=True) - - @validates('zip_code') - def validate_zip_code(self, key, zip_code): - if zip_code: - zip_re = r'^\d{5}$' - if not re.match(zip_re, zip_code): - raise ValueError('Not a valid zip code') - return zip_code - - @validates('state') - def validate_state(self, key, state): - return state_validator(state) - - def __repr__(self): - if self.street_name and self.cross_street2: - return 'Intersection of {} and {}, {} {}'.format( - self.street_name, self.cross_street2, self.city, self.state) - elif self.street_name and self.cross_street1: - return 'Intersection of {} and {}, {} {}'.format( - self.street_name, self.cross_street1, self.city, self.state) - elif self.street_name and self.cross_street1 and self.cross_street2: - return 'Intersection of {} between {} and {}, {} {}'.format( - self.street_name, self.cross_street1, self.cross_street2, - self.city, self.state) - else: - return '{} {}'.format(self.city, self.state) - - -class LicensePlate(BaseModel): - __tablename__ = 'license_plates' - - id = db.Column(db.Integer, primary_key=True) - number = db.Column(db.String(8), nullable=False, index=True) - state = db.Column(db.String(2), index=True) - # for use if car is federal, diplomat, or other non-state - # non_state_identifier = db.Column(db.String(20), index=True) - - @validates('state') - def validate_state(self, key, state): - return state_validator(state) - - -class Link(BaseModel): - __tablename__ = 'links' - - id = db.Column(db.Integer, primary_key=True) - title = db.Column(db.String(100), index=True) - url = db.Column(db.Text(), nullable=False) - link_type = db.Column(db.String(100), index=True) - description = db.Column(db.Text(), nullable=True) - author = db.Column(db.String(255), nullable=True) - creator_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL')) - creator = db.relationship('User', backref='links', lazy=True) - - @validates('url') - def validate_url(self, key, url): - return url_validator(url) - - -class Incident(BaseModel): - __tablename__ = 'incidents' - - id = db.Column(db.Integer, primary_key=True) - date = db.Column(db.Date, unique=False, index=True) - time = db.Column(db.Time, unique=False, index=True) - report_number = db.Column(db.String(50), index=True) - description = db.Column(db.Text(), nullable=True) - address_id = db.Column(db.Integer, db.ForeignKey('locations.id')) - address = db.relationship('Location', backref='incidents') - license_plates = db.relationship('LicensePlate', secondary=incident_license_plates, lazy='subquery', backref=db.backref('incidents', lazy=True)) - links = db.relationship('Link', secondary=incident_links, lazy='subquery', backref=db.backref('incidents', lazy=True)) - officers = db.relationship( - 'Officer', - secondary=officer_incidents, - lazy='subquery', - backref=db.backref('incidents')) - department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - department = db.relationship('Department', backref='incidents', lazy=True) - creator_id = db.Column(db.Integer, db.ForeignKey('users.id')) - creator = db.relationship('User', backref='incidents_created', lazy=True, foreign_keys=[creator_id]) - last_updated_id = db.Column(db.Integer, db.ForeignKey('users.id')) - last_updated_by = db.relationship('User', backref='incidents_updated', lazy=True, foreign_keys=[last_updated_id]) - - -class User(UserMixin, BaseModel): - __tablename__ = 'users' - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(64), unique=True, index=True) - username = db.Column(db.String(64), unique=True, index=True) - password_hash = db.Column(db.String(128)) - confirmed = db.Column(db.Boolean, default=False) - approved = db.Column(db.Boolean, default=False) - is_area_coordinator = db.Column(db.Boolean, default=False) - ac_department_id = db.Column(db.Integer, db.ForeignKey('departments.id')) - ac_department = db.relationship('Department', backref='coordinators', foreign_keys=[ac_department_id]) - is_administrator = db.Column(db.Boolean, default=False) - is_disabled = db.Column(db.Boolean, default=False) - dept_pref = db.Column(db.Integer, db.ForeignKey('departments.id')) - dept_pref_rel = db.relationship('Department', foreign_keys=[dept_pref]) - classifications = db.relationship('Image', backref='users') - tags = db.relationship('Face', backref='users') - - @property - def password(self): - raise AttributeError('password is not a readable attribute') - - @password.setter - def password(self, password): - self.password_hash = generate_password_hash(password, - method='pbkdf2:sha256') - - def verify_password(self, password): - return check_password_hash(self.password_hash, password) - - def generate_confirmation_token(self, expiration=3600): - s = Serializer(current_app.config['SECRET_KEY'], expiration) - return s.dumps({'confirm': self.id}).decode('utf-8') - - def confirm(self, token): - s = Serializer(current_app.config['SECRET_KEY']) - try: - data = s.loads(token) - except (BadSignature, BadData) as e: - current_app.logger.warning("failed to decrypt token: %s", e) - return False - if data.get('confirm') != self.id: - current_app.logger.warning("incorrect id here, expected %s, got %s", data.get('confirm'), self.id) - return False - self.confirmed = True - db.session.add(self) - db.session.commit() - return True - - def generate_reset_token(self, expiration=3600): - s = Serializer(current_app.config['SECRET_KEY'], expiration) - return s.dumps({'reset': self.id}).decode('utf-8') - - def reset_password(self, token, new_password): - s = Serializer(current_app.config['SECRET_KEY']) - try: - data = s.loads(token) - except (BadSignature, BadData): - return False - if data.get('reset') != self.id: - return False - self.password = new_password - db.session.add(self) - return True - - def generate_email_change_token(self, new_email, expiration=3600): - s = Serializer(current_app.config['SECRET_KEY'], expiration) - return s.dumps({'change_email': self.id, 'new_email': new_email}).decode('utf-8') - - def change_email(self, token): - s = Serializer(current_app.config['SECRET_KEY']) - try: - data = s.loads(token) - except (BadSignature, BadData): - return False - if data.get('change_email') != self.id: - return False - new_email = data.get('new_email') - if new_email is None: - return False - if self.query.filter_by(email=new_email).first() is not None: - return False - self.email = new_email - db.session.add(self) - return True - - def __repr__(self): - return '' % self.username - - -@login_manager.user_loader -def load_user(user_id): - return User.query.get(int(user_id)) diff --git a/OpenOversight/app/models/__init__.py b/OpenOversight/app/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/OpenOversight/app/models/config.py b/OpenOversight/app/models/config.py new file mode 100644 index 000000000..ee97fb5af --- /dev/null +++ b/OpenOversight/app/models/config.py @@ -0,0 +1,99 @@ +import os + +from OpenOversight.app.utils.constants import ( + KEY_DATABASE_URI, + KEY_ENV, + KEY_ENV_DEV, + KEY_ENV_PROD, + KEY_ENV_TESTING, + KEY_OFFICERS_PER_PAGE, + KEY_OO_MAIL_SUBJECT_PREFIX, + KEY_S3_BUCKET_NAME, + KEY_TIMEZONE, + MEGABYTE, +) + + +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class BaseConfig: + def __init__(self): + # App Settings + self.DEBUG = False + self.ENV = os.environ.get(KEY_ENV, KEY_ENV_DEV) + self.SEED = 666 + self.TIMEZONE = os.environ.get(KEY_TIMEZONE, "America/Chicago") + self.TESTING = False + # Use session cookie to store URL to redirect to after login + # https://flask-login.readthedocs.io/en/latest/#customizing-the-login-process + self.USE_SESSION_FOR_NEXT = True + + # DB Settings + self.SQLALCHEMY_TRACK_MODIFICATIONS = False + self.SQLALCHEMY_DATABASE_URI = os.environ.get(KEY_DATABASE_URI) + + # Protocol Settings + self.SITEMAP_URL_SCHEME = "http" + + # Pagination Settings + self.OFFICERS_PER_PAGE = int(os.environ.get(KEY_OFFICERS_PER_PAGE, 20)) + self.USERS_PER_PAGE = int(os.environ.get("USERS_PER_PAGE", 20)) + + # Form Settings + self.SECRET_KEY = os.environ.get("SECRET_KEY", "changemeplzorelsehax") + self.WTF_CSRF_ENABLED = True + + # Mail Settings + self.OO_MAIL_SUBJECT_PREFIX = os.environ.get( + KEY_OO_MAIL_SUBJECT_PREFIX, "[OpenOversight]" + ) + self.OO_SERVICE_EMAIL = os.environ.get("OO_SERVICE_EMAIL") + # TODO: Remove the default once we are able to update the production .env file + # TODO: Once that is done, we can re-alpha sort these variables. + self.OO_HELP_EMAIL = os.environ.get("OO_HELP_EMAIL", self.OO_SERVICE_EMAIL) + + # AWS Settings + self.AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") + self.AWS_DEFAULT_REGION = os.environ.get("AWS_DEFAULT_REGION") + self.AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") + self.S3_BUCKET_NAME = os.environ.get(KEY_S3_BUCKET_NAME) + + # Upload Settings + self.ALLOWED_EXTENSIONS = set(["jpeg", "jpg", "jpe", "png", "gif", "webp"]) + self.MAX_CONTENT_LENGTH = 50 * MEGABYTE + + # User settings + self.APPROVE_REGISTRATIONS = os.environ.get("APPROVE_REGISTRATIONS", False) + + +class DevelopmentConfig(BaseConfig): + def __init__(self): + super(DevelopmentConfig, self).__init__() + self.DEBUG = True + self.SQLALCHEMY_ECHO = True + self.NUM_OFFICERS = 15000 + + +class TestingConfig(BaseConfig): + def __init__(self): + super(TestingConfig, self).__init__() + self.TESTING = True + self.WTF_CSRF_ENABLED = False + self.NUM_OFFICERS = 120 + self.RATELIMIT_ENABLED = False + self.SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:" + + +class ProductionConfig(BaseConfig): + def __init__(self): + super(ProductionConfig, self).__init__() + self.SITEMAP_URL_SCHEME = "https" + + +config = { + KEY_ENV_DEV: DevelopmentConfig(), + KEY_ENV_TESTING: TestingConfig(), + KEY_ENV_PROD: ProductionConfig(), +} +config["default"] = config.get(os.environ.get(KEY_ENV, ""), DevelopmentConfig()) diff --git a/OpenOversight/app/models/database.py b/OpenOversight/app/models/database.py new file mode 100644 index 000000000..35fd79afc --- /dev/null +++ b/OpenOversight/app/models/database.py @@ -0,0 +1,824 @@ +import re +import time +from datetime import date +from typing import List + +from authlib.jose import JoseError, JsonWebToken +from cachetools import cached +from flask import current_app +from flask_login import UserMixin +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import CheckConstraint, UniqueConstraint, func +from sqlalchemy.ext.declarative import DeclarativeMeta +from sqlalchemy.orm import validates +from sqlalchemy.sql import func as sql_func +from werkzeug.security import check_password_hash, generate_password_hash + +from OpenOversight.app.models.database_cache import ( + DB_CACHE, + model_cache_key, + remove_database_cache_entries, +) +from OpenOversight.app.utils.choices import GENDER_CHOICES, RACE_CHOICES +from OpenOversight.app.utils.constants import ( + ENCODING_UTF_8, + KEY_DEPT_TOTAL_ASSIGNMENTS, + KEY_DEPT_TOTAL_INCIDENTS, + KEY_DEPT_TOTAL_OFFICERS, +) +from OpenOversight.app.validators import state_validator, url_validator + + +db = SQLAlchemy() +jwt = JsonWebToken("HS512") +BaseModel: DeclarativeMeta = db.Model + + +officer_links = db.Table( + "officer_links", + db.Column("officer_id", db.Integer, db.ForeignKey("officers.id"), primary_key=True), + db.Column("link_id", db.Integer, db.ForeignKey("links.id"), primary_key=True), + db.Column( + "created_at", + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ), +) + +officer_incidents = db.Table( + "officer_incidents", + db.Column("officer_id", db.Integer, db.ForeignKey("officers.id"), primary_key=True), + db.Column( + "incident_id", db.Integer, db.ForeignKey("incidents.id"), primary_key=True + ), + db.Column( + "created_at", + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ), +) + + +class Department(BaseModel): + __tablename__ = "departments" + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), index=False, unique=False, nullable=False) + short_name = db.Column(db.String(100), unique=False, nullable=False) + state = db.Column(db.String(2), server_default="", nullable=False) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + created_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + + # See https://github.com/lucyparsons/OpenOversight/issues/462 + unique_internal_identifier_label = db.Column( + db.String(100), unique=False, nullable=True + ) + + __table_args__ = (UniqueConstraint("name", "state", name="departments_name_state"),) + + def __repr__(self): + return f"" + + def to_custom_dict(self): + return { + "id": self.id, + "name": self.name, + "short_name": self.short_name, + "state": self.state, + "unique_internal_identifier_label": self.unique_internal_identifier_label, + } + + @property + def display_name(self): + return self.name if not self.state else f"[{self.state}] {self.name}" + + @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_ASSIGNMENTS)) + def total_documented_assignments(self): + return ( + db.session.query(Assignment.id) + .join(Officer, Assignment.officer_id == Officer.id) + .filter(Officer.department_id == self.id) + .count() + ) + + @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_INCIDENTS)) + def total_documented_incidents(self): + return ( + db.session.query(Incident).filter(Incident.department_id == self.id).count() + ) + + @cached(cache=DB_CACHE, key=model_cache_key(KEY_DEPT_TOTAL_OFFICERS)) + def total_documented_officers(self): + return ( + db.session.query(Officer).filter(Officer.department_id == self.id).count() + ) + + def remove_database_cache_entries(self, update_types: List[str]) -> None: + """Remove the Department model key from the cache if it exists.""" + remove_database_cache_entries(self, update_types) + + +class Job(BaseModel): + __tablename__ = "jobs" + + id = db.Column(db.Integer, primary_key=True) + job_title = db.Column(db.String(255), index=True, unique=False, nullable=False) + is_sworn_officer = db.Column(db.Boolean, index=True, default=True) + order = db.Column(db.Integer, index=True, unique=False, nullable=False) + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship("Department", backref="jobs") + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + created_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + + __table_args__ = ( + UniqueConstraint( + "job_title", "department_id", name="unique_department_job_titles" + ), + ) + + def __repr__(self): + return f"" + + def __str__(self): + return self.job_title + + +class Note(BaseModel): + __tablename__ = "notes" + + id = db.Column(db.Integer, primary_key=True) + text_contents = db.Column(db.Text()) + created_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + creator = db.relationship("User", backref="notes") + officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) + officer = db.relationship("Officer", back_populates="notes") + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + updated_at = db.Column(db.DateTime(timezone=True), unique=False) + + +class Description(BaseModel): + __tablename__ = "descriptions" + + creator = db.relationship("User", backref="descriptions") + officer = db.relationship("Officer", back_populates="descriptions") + id = db.Column(db.Integer, primary_key=True) + text_contents = db.Column(db.Text()) + created_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + updated_at = db.Column(db.DateTime(timezone=True), unique=False) + + +class Officer(BaseModel): + __tablename__ = "officers" + + id = db.Column(db.Integer, primary_key=True) + last_name = db.Column(db.String(120), index=True, unique=False) + first_name = db.Column(db.String(120), index=True, unique=False) + middle_initial = db.Column(db.String(120), unique=False, nullable=True) + suffix = db.Column(db.String(120), index=True, unique=False) + race = db.Column(db.String(120), index=True, unique=False) + gender = db.Column(db.String(5), index=True, unique=False, nullable=True) + employment_date = db.Column(db.Date, index=True, unique=False, nullable=True) + birth_year = db.Column(db.Integer, index=True, unique=False, nullable=True) + assignments = db.relationship("Assignment", back_populates="base_officer") + face = db.relationship("Face", backref="officer") + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship("Department", backref="officers") + unique_internal_identifier = db.Column( + db.String(50), index=True, unique=True, nullable=True + ) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + created_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + + links = db.relationship( + "Link", secondary=officer_links, backref=db.backref("officers", lazy=True) + ) + notes = db.relationship( + "Note", back_populates="officer", order_by="Note.created_at" + ) + descriptions = db.relationship( + "Description", back_populates="officer", order_by="Description.created_at" + ) + salaries = db.relationship( + "Salary", back_populates="officer", order_by="Salary.year.desc()" + ) + + __table_args__ = ( + CheckConstraint("gender in ('M', 'F', 'Other')", name="gender_options"), + ) + + def full_name(self): + if self.middle_initial: + middle_initial = ( + self.middle_initial + "." + if len(self.middle_initial) == 1 + else self.middle_initial + ) + if self.suffix: + return ( + f"{self.first_name} {middle_initial} {self.last_name} {self.suffix}" + ) + else: + return f"{self.first_name} {middle_initial} {self.last_name}" + if self.suffix: + return f"{self.first_name} {self.last_name} {self.suffix}" + return f"{self.first_name} {self.last_name}" + + def race_label(self): + if self.race is None: + return "Data Missing" + + for race, label in RACE_CHOICES: + if self.race == race: + return label + + def gender_label(self): + if self.gender is None: + return "Data Missing" + + for gender, label in GENDER_CHOICES: + if self.gender == gender: + return label + + def job_title(self): + if self.assignments: + return max( + self.assignments, key=lambda x: x.start_date or date.min + ).job.job_title + + def unit_description(self): + if self.assignments: + unit = max(self.assignments, key=lambda x: x.start_date or date.min).unit + return unit.description if unit else None + + def badge_number(self): + if self.assignments: + return max(self.assignments, key=lambda x: x.start_date or date.min).star_no + + def currently_on_force(self): + if self.assignments: + most_recent = max(self.assignments, key=lambda x: x.start_date or date.min) + return "Yes" if most_recent.resign_date is None else "No" + return "Uncertain" + + def __repr__(self): + if self.unique_internal_identifier: + return ( + f"" + ) + return ( + f"" + ) + + +class Salary(BaseModel): + __tablename__ = "salaries" + + id = db.Column(db.Integer, primary_key=True) + officer_id = db.Column(db.Integer, db.ForeignKey("officers.id", ondelete="CASCADE")) + officer = db.relationship("Officer", back_populates="salaries") + salary = db.Column(db.Numeric, index=True, unique=False, nullable=False) + overtime_pay = db.Column(db.Numeric, index=True, unique=False, nullable=True) + year = db.Column(db.Integer, index=True, unique=False, nullable=False) + is_fiscal_year = db.Column(db.Boolean, index=False, unique=False, nullable=False) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + created_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + + def __repr__(self): + return f"" + + +class Unit(BaseModel): + __tablename__ = "unit_types" + + id = db.Column(db.Integer, primary_key=True) + description = db.Column(db.String(120), index=True, unique=False) + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship( + "Department", backref="unit_types", order_by="Unit.description.asc()" + ) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + created_by = db.Column( + db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), unique=False + ) + + def __repr__(self): + return f"Unit: {self.description}" + + +class Face(BaseModel): + __tablename__ = "faces" + + id = db.Column(db.Integer, primary_key=True) + officer_id = db.Column(db.Integer, db.ForeignKey("officers.id")) + img_id = db.Column( + db.Integer, + db.ForeignKey( + "raw_images.id", + ondelete="CASCADE", + onupdate="CASCADE", + name="fk_face_image_id", + use_alter=True, + ), + ) + original_image_id = db.Column( + db.Integer, + db.ForeignKey( + "raw_images.id", + ondelete="SET NULL", + onupdate="CASCADE", + use_alter=True, + name="fk_face_original_image_id", + ), + ) + face_position_x = db.Column(db.Integer, unique=False) + face_position_y = db.Column(db.Integer, unique=False) + face_width = db.Column(db.Integer, unique=False) + face_height = db.Column(db.Integer, unique=False) + image = db.relationship("Image", backref="faces", foreign_keys=[img_id]) + original_image = db.relationship( + "Image", backref="tags", foreign_keys=[original_image_id], lazy=True + ) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + featured = db.Column( + db.Boolean, nullable=False, default=False, server_default="false" + ) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + + __table_args__ = (UniqueConstraint("officer_id", "img_id", name="unique_faces"),) + + def __repr__(self): + return f"" + + +class Image(BaseModel): + __tablename__ = "raw_images" + + id = db.Column(db.Integer, primary_key=True) + filepath = db.Column(db.String(255), unique=False) + hash_img = db.Column(db.String(120), unique=False, nullable=True) + + created_at = db.Column( + db.DateTime(timezone=True), + index=True, + unique=False, + server_default=sql_func.now(), + ) + + # We might know when the image was taken e.g. through EXIF data + taken_at = db.Column( + db.DateTime(timezone=True), index=True, unique=False, nullable=True + ) + contains_cops = db.Column(db.Boolean, nullable=True) + created_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + unique=False, + ) + + user = db.relationship("User", back_populates="classifications") + is_tagged = db.Column(db.Boolean, default=False, unique=False, nullable=True) + + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship("Department", backref="raw_images") + + def __repr__(self): + return f"" + + +incident_links = db.Table( + "incident_links", + db.Column( + "incident_id", db.Integer, db.ForeignKey("incidents.id"), primary_key=True + ), + db.Column("link_id", db.Integer, db.ForeignKey("links.id"), primary_key=True), + db.Column( + "created_at", + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ), +) + +incident_license_plates = db.Table( + "incident_license_plates", + db.Column( + "incident_id", db.Integer, db.ForeignKey("incidents.id"), primary_key=True + ), + db.Column( + "license_plate_id", + db.Integer, + db.ForeignKey("license_plates.id"), + primary_key=True, + ), + db.Column( + "created_at", + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ), +) + +incident_officers = db.Table( + "incident_officers", + db.Column( + "incident_id", db.Integer, db.ForeignKey("incidents.id"), primary_key=True + ), + db.Column( + "officers_id", db.Integer, db.ForeignKey("officers.id"), primary_key=True + ), + db.Column( + "created_at", + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ), +) + + +class Location(BaseModel): + __tablename__ = "locations" + + id = db.Column(db.Integer, primary_key=True) + street_name = db.Column(db.String(100), index=True) + cross_street1 = db.Column(db.String(100), unique=False) + cross_street2 = db.Column(db.String(100), unique=False) + city = db.Column(db.String(100), unique=False, index=True) + state = db.Column(db.String(2), unique=False, index=True) + zip_code = db.Column(db.String(5), unique=False, index=True) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + created_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + unique=False, + ) + + @validates("zip_code") + def validate_zip_code(self, key, zip_code): + if zip_code: + zip_re = r"^\d{5}$" + if not re.match(zip_re, zip_code): + raise ValueError("Not a valid zip code") + return zip_code + + @validates("state") + def validate_state(self, key, state): + return state_validator(state) + + def __repr__(self): + if self.street_name and self.cross_street2: + return ( + f"Intersection of {self.street_name} and {self.cross_street2}, " + + f"{self.city} {self.state}" + ) + elif self.street_name and self.cross_street1: + return ( + f"Intersection of {self.street_name} and {self.cross_street1}, " + + f"{self.city} {self.state}" + ) + elif self.street_name and self.cross_street1 and self.cross_street2: + return ( + f"Intersection of {self.street_name} between {self.cross_street1} " + f"and {self.cross_street2}, {self.city} {self.state}" + ) + else: + return f"{self.city} {self.state}" + + +class LicensePlate(BaseModel): + __tablename__ = "license_plates" + + id = db.Column(db.Integer, primary_key=True) + number = db.Column(db.String(8), nullable=False, index=True) + state = db.Column(db.String(2), index=True) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + created_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + unique=False, + ) + + # for use if car is federal, diplomat, or other non-state + # non_state_identifier = db.Column(db.String(20), index=True) + + @validates("state") + def validate_state(self, key, state): + return state_validator(state) + + +class Link(BaseModel): + __tablename__ = "links" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(100), index=True) + url = db.Column(db.Text(), nullable=False) + link_type = db.Column(db.String(100), index=True) + description = db.Column(db.Text(), nullable=True) + author = db.Column(db.String(255), nullable=True) + created_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + unique=False, + ) + creator = db.relationship("User", backref="links", lazy=True) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + + @validates("url") + def validate_url(self, key, url): + return url_validator(url) + + +class Incident(BaseModel): + __tablename__ = "incidents" + + id = db.Column(db.Integer, primary_key=True) + date = db.Column(db.Date, unique=False, index=True) + time = db.Column(db.Time, unique=False, index=True) + report_number = db.Column(db.String(50), index=True) + description = db.Column(db.Text(), nullable=True) + address_id = db.Column(db.Integer, db.ForeignKey("locations.id")) + address = db.relationship("Location", backref="incidents") + license_plates = db.relationship( + "LicensePlate", + secondary=incident_license_plates, + lazy="subquery", + backref=db.backref("incidents", lazy=True), + ) + links = db.relationship( + "Link", + secondary=incident_links, + lazy="subquery", + backref=db.backref("incidents", lazy=True), + ) + officers = db.relationship( + "Officer", + secondary=officer_incidents, + lazy="subquery", + backref=db.backref("incidents"), + ) + department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + department = db.relationship("Department", backref="incidents", lazy=True) + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + created_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + unique=False, + ) + creator = db.relationship( + "User", backref="incidents_created", lazy=True, foreign_keys=[created_by] + ) + last_updated_by = db.Column( + db.Integer, + db.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + unique=False, + ) + last_updated_at = db.Column( + db.DateTime(timezone=True), + nullable=True, + unique=False, + ) + + +class User(UserMixin, BaseModel): + __tablename__ = "users" + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(64), unique=True, index=True) + username = db.Column(db.String(64), unique=True, index=True) + password_hash = db.Column(db.String(128)) + confirmed = db.Column(db.Boolean, default=False) + approved = db.Column(db.Boolean, default=False) + is_area_coordinator = db.Column(db.Boolean, default=False) + ac_department_id = db.Column(db.Integer, db.ForeignKey("departments.id")) + ac_department = db.relationship( + "Department", backref="coordinators", foreign_keys=[ac_department_id] + ) + is_administrator = db.Column(db.Boolean, default=False) + is_disabled = db.Column(db.Boolean, default=False) + dept_pref = db.Column(db.Integer, db.ForeignKey("departments.id")) + dept_pref_rel = db.relationship("Department", foreign_keys=[dept_pref]) + classifications = db.relationship("Image", back_populates="user") + tags = db.relationship("Face", backref="user") + created_at = db.Column( + db.DateTime(timezone=True), + nullable=False, + server_default=sql_func.now(), + unique=False, + ) + + def _jwt_encode(self, payload, expiration): + secret = current_app.config["SECRET_KEY"] + header = {"alg": "HS512"} + + now = int(time.time()) + payload["iat"] = now + payload["exp"] = now + expiration + + return jwt.encode(header, payload, secret) + + def _jwt_decode(self, token): + secret = current_app.config["SECRET_KEY"] + token = jwt.decode(token, secret) + token.validate() + return token + + @property + def password(self): + raise AttributeError("password is not a readable attribute") + + @password.setter + def password(self, password): + self.password_hash = generate_password_hash(password, method="pbkdf2:sha256") + + @staticmethod + def _case_insensitive_equality(field, value): + return User.query.filter(func.lower(field) == func.lower(value)) + + @staticmethod + def by_email(email): + return User._case_insensitive_equality(User.email, email) + + @staticmethod + def by_username(username): + return User._case_insensitive_equality(User.username, username) + + def verify_password(self, password): + return check_password_hash(self.password_hash, password) + + def generate_confirmation_token(self, expiration=3600): + payload = {"confirm": self.id} + return self._jwt_encode(payload, expiration).decode(ENCODING_UTF_8) + + def confirm(self, token): + try: + data = self._jwt_decode(token) + except JoseError as e: + current_app.logger.warning("failed to decrypt token: %s", e) + return False + if data.get("confirm") != self.id: + current_app.logger.warning( + "incorrect id here, expected %s, got %s", data.get("confirm"), self.id + ) + return False + self.confirmed = True + db.session.add(self) + db.session.commit() + return True + + def generate_reset_token(self, expiration=3600): + payload = {"reset": self.id} + return self._jwt_encode(payload, expiration).decode(ENCODING_UTF_8) + + def reset_password(self, token, new_password): + try: + data = self._jwt_decode(token) + except JoseError: + return False + if data.get("reset") != self.id: + return False + self.password = new_password + db.session.add(self) + db.session.commit() + return True + + def generate_email_change_token(self, new_email, expiration=3600): + payload = {"change_email": self.id, "new_email": new_email} + return self._jwt_encode(payload, expiration).decode(ENCODING_UTF_8) + + def change_email(self, token): + try: + data = self._jwt_decode(token) + except JoseError: + return False + if data.get("change_email") != self.id: + return False + new_email = data.get("new_email") + if new_email is None: + return False + if self.query.filter_by(email=new_email).first() is not None: + return False + self.email = new_email + db.session.add(self) + db.session.commit() + return True + + @property + def is_active(self): + """Override UserMixin.is_active to prevent disabled users from logging in.""" + return not self.is_disabled + + def __repr__(self): + return f"" diff --git a/OpenOversight/app/models/database_cache.py b/OpenOversight/app/models/database_cache.py new file mode 100644 index 000000000..63cf2421c --- /dev/null +++ b/OpenOversight/app/models/database_cache.py @@ -0,0 +1,62 @@ +from typing import Any, List + +from cachetools import TTLCache +from cachetools.keys import hashkey +from flask_sqlalchemy.model import Model + +from OpenOversight.app.utils.constants import HOUR + + +DB_CACHE = TTLCache(maxsize=1024, ttl=24 * HOUR) + + +def get_model_cache_key(model: Model, update_type: str): + """Create unique db.Model key.""" + return hashkey(model.id, update_type, model.__class__.__name__) + + +def model_cache_key(update_type: str): + """Return a key function to calculate the cache key for db.Model + methods using the db.Model id and a given update type. + + db.Model.id is used instead of a db.Model obj because the default Python + __hash__ is unique per obj instance, meaning multiple instances of the same + department will have different hashes. + + Update type is used in the hash to differentiate between the update types we compute + per department. + """ + + def _cache_key(model: Model): + return get_model_cache_key(model, update_type) + + return _cache_key + + +def get_database_cache_entry(model: Model, update_type: str) -> Any: + """Get db.Model entry for key in the cache.""" + key = get_model_cache_key(model, update_type) + if key in DB_CACHE.keys(): + return DB_CACHE.get(key) + else: + return None + + +def has_database_cache_entry(model: Model, update_type: str) -> bool: + """db.Model key exists in cache.""" + key = get_model_cache_key(model, update_type) + return key in DB_CACHE.keys() + + +def put_database_cache_entry(model: Model, update_type: str, data: Any) -> None: + """Put data in cache using the constructed key.""" + key = get_model_cache_key(model, update_type) + DB_CACHE[key] = data + + +def remove_database_cache_entries(model: Model, update_types: List[str]) -> None: + """Remove db.Model key from cache if it exists.""" + for update_type in update_types: + key = get_model_cache_key(model, update_type) + if key in DB_CACHE.keys(): + del DB_CACHE[key] diff --git a/OpenOversight/app/model_imports.py b/OpenOversight/app/models/database_imports.py similarity index 81% rename from OpenOversight/app/model_imports.py rename to OpenOversight/app/models/database_imports.py index 1f50c4d4b..7aeb57029 100644 --- a/OpenOversight/app/model_imports.py +++ b/OpenOversight/app/models/database_imports.py @@ -1,9 +1,10 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Tuple, Union +import datetime +from typing import Any, Dict, Optional, Sequence, Tuple, Union import dateutil.parser -from .main import choices -from .models import ( +from OpenOversight.app import login_manager +from OpenOversight.app.models.database import ( Assignment, Incident, LicensePlate, @@ -11,13 +12,17 @@ Location, Officer, Salary, + User, db, ) -from .utils import get_or_create, str_is_true -from .validators import state_validator, url_validator - -if TYPE_CHECKING: - import datetime # noqa +from OpenOversight.app.utils.choices import ( + GENDER_CHOICES, + LINK_CHOICES, + RACE_CHOICES, + SUFFIX_CHOICES, +) +from OpenOversight.app.utils.general import get_or_create, str_is_true +from OpenOversight.app.validators import state_validator, url_validator def validate_choice( @@ -27,17 +32,17 @@ def validate_choice( for label, choice_value in given_choices: if value.lower() in [choice_value.lower(), label.lower()]: return label - print("'{}' no valid choice of {}".format(value, str(given_choices))) + print(f"'{value}' no valid choice of {str(given_choices)}") return None -def parse_date(date_str: Optional[str]) -> Optional["datetime.date"]: +def parse_date(date_str: Optional[str]) -> Optional[datetime.date]: if date_str: return dateutil.parser.parse(date_str).date() return None -def parse_time(time_str: Optional[str]) -> Optional["datetime.time"]: +def parse_time(time_str: Optional[str]) -> Optional[datetime.time]: if time_str: return dateutil.parser.parse(time_str).time() return None @@ -68,15 +73,14 @@ def parse_str(value: Optional[str], default: Optional[str] = "") -> Optional[str def create_officer_from_dict(data: Dict[str, Any], force_id: bool = False) -> Officer: - officer = Officer( department_id=int(data["department_id"]), last_name=parse_str(data.get("last_name", "")), first_name=parse_str(data.get("first_name", "")), middle_initial=parse_str(data.get("middle_initial", "")), - suffix=validate_choice(data.get("suffix", ""), choices.SUFFIX_CHOICES), - race=validate_choice(data.get("race"), choices.RACE_CHOICES), - gender=validate_choice(data.get("gender"), choices.GENDER_CHOICES), + suffix=validate_choice(data.get("suffix", ""), SUFFIX_CHOICES), + race=validate_choice(data.get("race"), RACE_CHOICES), + gender=validate_choice(data.get("gender"), GENDER_CHOICES), employment_date=parse_date(data.get("employment_date")), birth_year=parse_int(data.get("birth_year")), unique_internal_identifier=parse_str( @@ -92,7 +96,6 @@ def create_officer_from_dict(data: Dict[str, Any], force_id: bool = False) -> Of def update_officer_from_dict(data: Dict[str, Any], officer: Officer) -> Officer: - if "department_id" in data.keys(): officer.department_id = int(data["department_id"]) if "last_name" in data.keys(): @@ -102,11 +105,11 @@ def update_officer_from_dict(data: Dict[str, Any], officer: Officer) -> Officer: if "middle_initial" in data.keys(): officer.middle_initial = parse_str(data.get("middle_initial", "")) if "suffix" in data.keys(): - officer.suffix = validate_choice(data.get("suffix", ""), choices.SUFFIX_CHOICES) + officer.suffix = validate_choice(data.get("suffix", ""), SUFFIX_CHOICES) if "race" in data.keys(): - officer.race = validate_choice(data.get("race"), choices.RACE_CHOICES) + officer.race = validate_choice(data.get("race"), RACE_CHOICES) if "gender" in data.keys(): - officer.gender = validate_choice(data.get("gender"), choices.GENDER_CHOICES) + officer.gender = validate_choice(data.get("gender"), GENDER_CHOICES) if "employment_date" in data.keys(): officer.employment_date = parse_date(data.get("employment_date")) if "birth_year" in data.keys(): @@ -122,13 +125,12 @@ def update_officer_from_dict(data: Dict[str, Any], officer: Officer) -> Officer: def create_assignment_from_dict( data: Dict[str, Any], force_id: bool = False ) -> Assignment: - assignment = Assignment( officer_id=int(data["officer_id"]), star_no=parse_str(data.get("star_no"), None), job_id=int(data["job_id"]), unit_id=parse_int(data.get("unit_id")), - star_date=parse_date(data.get("star_date")), + start_date=parse_date(data.get("start_date")), resign_date=parse_date(data.get("resign_date")), ) if force_id and data.get("id"): @@ -149,8 +151,8 @@ def update_assignment_from_dict( assignment.job_id = int(data["job_id"]) if "unit_id" in data.keys(): assignment.unit_id = parse_int(data.get("unit_id")) - if "star_date" in data.keys(): - assignment.star_date = parse_date(data.get("star_date")) + if "start_date" in data.keys(): + assignment.start_date = parse_date(data.get("start_date")) if "resign_date" in data.keys(): assignment.resign_date = parse_date(data.get("resign_date")) db.session.flush() @@ -193,10 +195,10 @@ def create_link_from_dict(data: Dict[str, Any], force_id: bool = False) -> Link: link = Link( title=data.get("title", ""), url=url_validator(data["url"]), - link_type=validate_choice(data.get("link_type"), choices.LINK_CHOICES), + link_type=validate_choice(data.get("link_type"), LINK_CHOICES), description=parse_str(data.get("description"), None), author=parse_str(data.get("author"), None), - creator_id=parse_int(data.get("creator_id")), + created_by=parse_int(data.get("created_by")), ) if force_id and data.get("id"): @@ -216,13 +218,13 @@ def update_link_from_dict(data: Dict[str, Any], link: Link) -> Link: if "url" in data: link.url = url_validator(data["url"]) if "link_type" in data: - link.link_type = validate_choice(data.get("link_type"), choices.LINK_CHOICES) + link.link_type = validate_choice(data.get("link_type"), LINK_CHOICES) if "description" in data: link.description = parse_str(data.get("description"), None) if "author" in data: link.author = parse_str(data.get("author"), None) - if "creator_id" in data: - link.creator_id = parse_int(data.get("creator_id")) + if "created_by" in data: + link.created_by = parse_int(data.get("created_by")) if "officers" in data: link.officers = data.get("officers") or [] if "incidents" in data: @@ -238,7 +240,12 @@ def get_or_create_license_plate_from_dict( number = data["number"] state = parse_str(data.get("state"), None) state_validator(state) - return get_or_create(db.session, LicensePlate, number=number, state=state,) + return get_or_create( + db.session, + LicensePlate, + number=number, + state=state, + ) def get_or_create_location_from_dict( @@ -275,8 +282,9 @@ def create_incident_from_dict(data: Dict[str, Any], force_id: bool = False) -> I description=parse_str(data.get("description"), None), address_id=data.get("address_id"), department_id=parse_int(data.get("department_id")), - creator_id=parse_int(data.get("creator_id")), - last_updated_id=parse_int(data.get("last_updated_id")), + created_by=parse_int(data.get("created_by")), + last_updated_by=parse_int(data.get("last_updated_by")), + last_updated_at=datetime.datetime.now(), ) incident.officers = data.get("officers", []) @@ -303,13 +311,19 @@ def update_incident_from_dict(data: Dict[str, Any], incident: Incident) -> Incid incident.address_id = data.get("address_id") if "department_id" in data: incident.department_id = parse_int(data.get("department_id")) - if "creator_id" in data: - incident.creator_id = parse_int(data.get("creator_id")) - if "last_updated_id" in data: - incident.last_updated_id = parse_int(data.get("last_updated_id")) + if "created_by" in data: + incident.created_by = parse_int(data.get("created_by")) + if "last_updated_by" in data: + incident.last_updated_by = parse_int(data.get("last_updated_by")) + incident.last_updated_at = datetime.datetime.now() if "officers" in data: incident.officers = data["officers"] or [] if "license_plate_objects" in data: incident.license_plates = data["license_plate_objects"] or [] db.session.flush() return incident + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) diff --git a/OpenOversight/app/models/emails.py b/OpenOversight/app/models/emails.py new file mode 100644 index 000000000..90efc8672 --- /dev/null +++ b/OpenOversight/app/models/emails.py @@ -0,0 +1,81 @@ +import base64 +from email.mime.text import MIMEText + +from flask import current_app, render_template + +from OpenOversight.app.utils.constants import KEY_OO_MAIL_SUBJECT_PREFIX + + +class Email: + """Base class for all emails.""" + + def __init__(self, body: str, subject: str, receiver: str): + self.body = body + self.receiver = receiver + self.subject = subject + + def create_message(self): + message = MIMEText(self.body, "html") + message["to"] = self.receiver + message["from"] = current_app.config["OO_SERVICE_EMAIL"] + message["subject"] = self.subject + return {"raw": base64.urlsafe_b64encode(message.as_bytes()).decode()} + + +class AdministratorApprovalEmail(Email): + def __init__(self, receiver: str, user, admin): + subject = ( + f"{current_app.config[KEY_OO_MAIL_SUBJECT_PREFIX]} New User Registered" + ) + body = render_template( + "auth/email/new_registration.html", user=user, admin=admin + ) + super().__init__(body, subject, receiver) + + +class ChangeEmailAddressEmail(Email): + def __init__(self, receiver: str, user, token: str): + subject = ( + f"{current_app.config[KEY_OO_MAIL_SUBJECT_PREFIX]} Confirm Your Email " + f"Address" + ) + body = render_template("auth/email/change_email.html", user=user, token=token) + super().__init__(body, subject, receiver) + + +class ChangePasswordEmail(Email): + def __init__(self, receiver: str, user): + subject = f"{current_app.config[KEY_OO_MAIL_SUBJECT_PREFIX]} Your Password Has Changed" + body = render_template( + "auth/email/change_password.html", + user=user, + help_email=current_app.config["OO_HELP_EMAIL"], + ) + super().__init__(body, subject, receiver) + + +class ConfirmAccountEmail(Email): + def __init__(self, receiver: str, user, token: str): + subject = ( + f"{current_app.config[KEY_OO_MAIL_SUBJECT_PREFIX]} Confirm Your Account" + ) + body = render_template("auth/email/confirm.html", user=user, token=token) + super().__init__(body, subject, receiver) + + +class ConfirmedUserEmail(Email): + def __init__(self, receiver: str, user, admin): + subject = f"{current_app.config[KEY_OO_MAIL_SUBJECT_PREFIX]} New User Confirmed" + body = render_template( + "auth/email/new_confirmation.html", user=user, admin=admin + ) + super().__init__(body, subject, receiver) + + +class ResetPasswordEmail(Email): + def __init__(self, receiver: str, user, token: str): + subject = ( + f"{current_app.config[KEY_OO_MAIL_SUBJECT_PREFIX]} Reset Your Password" + ) + body = render_template("auth/email/reset_password.html", user=user, token=token) + super().__init__(body, subject, receiver) diff --git a/OpenOversight/app/static/css/cropper.css b/OpenOversight/app/static/css/cropper.css old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/css/cropper.min.css b/OpenOversight/app/static/css/cropper.min.css old mode 100755 new mode 100644 index 9426dacce..9f0118bb4 --- a/OpenOversight/app/static/css/cropper.min.css +++ b/OpenOversight/app/static/css/cropper.min.css @@ -6,4 +6,4 @@ * Released under the MIT license * * Date: 2016-09-03T05:50:45.412Z - */.cropper-container{font-size:0;line-height:0;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;direction:ltr!important}.cropper-container img{display:block;width:100%;min-width:0!important;max-width:none!important;height:100%;min-height:0!important;max-height:none!important;image-orientation:0deg!important}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{position:absolute;top:0;right:0;bottom:0;left:0}.cropper-wrap-box{overflow:hidden}.cropper-drag-box{opacity:0;background-color:#fff;filter:alpha(opacity=0)}.cropper-dashed,.cropper-modal{opacity:.5;filter:alpha(opacity=50)}.cropper-modal{background-color:#000}.cropper-view-box{display:block;overflow:hidden;width:100%;height:100%;outline:#39f solid 1px;outline-color:rgba(51,153,255,.75)}.cropper-dashed{position:absolute;display:block;border:0 dashed #eee}.cropper-dashed.dashed-h{top:33.33333%;left:0;width:100%;height:33.33333%;border-top-width:1px;border-bottom-width:1px}.cropper-dashed.dashed-v{top:0;left:33.33333%;width:33.33333%;height:100%;border-right-width:1px;border-left-width:1px}.cropper-center{position:absolute;top:50%;left:50%;display:block;width:0;height:0;opacity:.75;filter:alpha(opacity=75)}.cropper-center:after,.cropper-center:before{position:absolute;display:block;content:' ';background-color:#eee}.cropper-center:before{top:0;left:-3px;width:7px;height:1px}.cropper-center:after{top:-3px;left:0;width:1px;height:7px}.cropper-face,.cropper-line,.cropper-point{position:absolute;display:block;width:100%;height:100%;opacity:.1;filter:alpha(opacity=10)}.cropper-face{top:0;left:0;background-color:#fff}.cropper-line,.cropper-point{background-color:#39f}.cropper-line.line-e{top:0;right:-3px;width:5px;cursor:e-resize}.cropper-line.line-n{top:-3px;left:0;height:5px;cursor:n-resize}.cropper-line.line-w{top:0;left:-3px;width:5px;cursor:w-resize}.cropper-line.line-s{bottom:-3px;left:0;height:5px;cursor:s-resize}.cropper-point{width:5px;height:5px;opacity:.75;filter:alpha(opacity=75)}.cropper-point.point-e{top:50%;right:-3px;margin-top:-3px;cursor:e-resize}.cropper-point.point-n{top:-3px;left:50%;margin-left:-3px;cursor:n-resize}.cropper-point.point-w{top:50%;left:-3px;margin-top:-3px;cursor:w-resize}.cropper-point.point-s{bottom:-3px;left:50%;margin-left:-3px;cursor:s-resize}.cropper-point.point-ne{top:-3px;right:-3px;cursor:ne-resize}.cropper-point.point-nw{top:-3px;left:-3px;cursor:nw-resize}.cropper-point.point-sw{bottom:-3px;left:-3px;cursor:sw-resize}.cropper-point.point-se{right:-3px;bottom:-3px;width:20px;height:20px;cursor:se-resize;opacity:1;filter:alpha(opacity=100)}.cropper-point.point-se:before{position:absolute;right:-50%;bottom:-50%;display:block;width:200%;height:200%;content:' ';opacity:0;background-color:#39f;filter:alpha(opacity=0)}@media (min-width:768px){.cropper-point.point-se{width:15px;height:15px}}@media (min-width:992px){.cropper-point.point-se{width:10px;height:10px}}@media (min-width:1200px){.cropper-point.point-se{width:5px;height:5px;opacity:.75;filter:alpha(opacity=75)}}.cropper-invisible{opacity:0;filter:alpha(opacity=0)}.cropper-bg{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC)}.cropper-hide{position:absolute;display:block;width:0;height:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} \ No newline at end of file + */.cropper-container{font-size:0;line-height:0;position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;direction:ltr!important}.cropper-container img{display:block;width:100%;min-width:0!important;max-width:none!important;height:100%;min-height:0!important;max-height:none!important;image-orientation:0deg!important}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{position:absolute;top:0;right:0;bottom:0;left:0}.cropper-wrap-box{overflow:hidden}.cropper-drag-box{opacity:0;background-color:#fff;filter:alpha(opacity=0)}.cropper-dashed,.cropper-modal{opacity:.5;filter:alpha(opacity=50)}.cropper-modal{background-color:#000}.cropper-view-box{display:block;overflow:hidden;width:100%;height:100%;outline:#39f solid 1px;outline-color:rgba(51,153,255,.75)}.cropper-dashed{position:absolute;display:block;border:0 dashed #eee}.cropper-dashed.dashed-h{top:33.33333%;left:0;width:100%;height:33.33333%;border-top-width:1px;border-bottom-width:1px}.cropper-dashed.dashed-v{top:0;left:33.33333%;width:33.33333%;height:100%;border-right-width:1px;border-left-width:1px}.cropper-center{position:absolute;top:50%;left:50%;display:block;width:0;height:0;opacity:.75;filter:alpha(opacity=75)}.cropper-center:after,.cropper-center:before{position:absolute;display:block;content:' ';background-color:#eee}.cropper-center:before{top:0;left:-3px;width:7px;height:1px}.cropper-center:after{top:-3px;left:0;width:1px;height:7px}.cropper-face,.cropper-line,.cropper-point{position:absolute;display:block;width:100%;height:100%;opacity:.1;filter:alpha(opacity=10)}.cropper-face{top:0;left:0;background-color:#fff}.cropper-line,.cropper-point{background-color:#39f}.cropper-line.line-e{top:0;right:-3px;width:5px;cursor:e-resize}.cropper-line.line-n{top:-3px;left:0;height:5px;cursor:n-resize}.cropper-line.line-w{top:0;left:-3px;width:5px;cursor:w-resize}.cropper-line.line-s{bottom:-3px;left:0;height:5px;cursor:s-resize}.cropper-point{width:5px;height:5px;opacity:.75;filter:alpha(opacity=75)}.cropper-point.point-e{top:50%;right:-3px;margin-top:-3px;cursor:e-resize}.cropper-point.point-n{top:-3px;left:50%;margin-left:-3px;cursor:n-resize}.cropper-point.point-w{top:50%;left:-3px;margin-top:-3px;cursor:w-resize}.cropper-point.point-s{bottom:-3px;left:50%;margin-left:-3px;cursor:s-resize}.cropper-point.point-ne{top:-3px;right:-3px;cursor:ne-resize}.cropper-point.point-nw{top:-3px;left:-3px;cursor:nw-resize}.cropper-point.point-sw{bottom:-3px;left:-3px;cursor:sw-resize}.cropper-point.point-se{right:-3px;bottom:-3px;width:20px;height:20px;cursor:se-resize;opacity:1;filter:alpha(opacity=100)}.cropper-point.point-se:before{position:absolute;right:-50%;bottom:-50%;display:block;width:200%;height:200%;content:' ';opacity:0;background-color:#39f;filter:alpha(opacity=0)}@media (min-width:768px){.cropper-point.point-se{width:15px;height:15px}}@media (min-width:992px){.cropper-point.point-se{width:10px;height:10px}}@media (min-width:1200px){.cropper-point.point-se{width:5px;height:5px;opacity:.75;filter:alpha(opacity=75)}}.cropper-invisible{opacity:0;filter:alpha(opacity=0)}.cropper-bg{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC)}.cropper-hide{position:absolute;display:block;width:0;height:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed} diff --git a/OpenOversight/app/static/css/font-awesome.min.css b/OpenOversight/app/static/css/font-awesome.min.css old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/css/jquery-ui.min.css b/OpenOversight/app/static/css/jquery-ui.min.css index c635d4f4d..d11ab7806 100644 --- a/OpenOversight/app/static/css/jquery-ui.min.css +++ b/OpenOversight/app/static/css/jquery-ui.min.css @@ -3,4 +3,4 @@ * Includes: draggable.css, sortable.css * Copyright jQuery Foundation and other contributors; Licensed MIT */ -.ui-draggable-handle{-ms-touch-action:none;touch-action:none}.ui-sortable-handle{-ms-touch-action:none;touch-action:none} \ No newline at end of file +.ui-draggable-handle{-ms-touch-action:none;touch-action:none}.ui-sortable-handle{-ms-touch-action:none;touch-action:none} diff --git a/OpenOversight/app/static/css/openoversight.css b/OpenOversight/app/static/css/openoversight.css index dc95c5771..637f32e29 100644 --- a/OpenOversight/app/static/css/openoversight.css +++ b/OpenOversight/app/static/css/openoversight.css @@ -10,12 +10,16 @@ body { .footer { position: absolute; - bottom: 0; + bottom: 140px; width: 100%; /* Set the fixed height of the footer here */ height: 100px; } +div[role=main] { + padding-bottom: 30px; +} + .social:hover { color: #000000; -webkit-transform: scale(1.1); @@ -36,7 +40,7 @@ body { .frontpage-leads { margin-top: 5%; - margin-bottom: 10%; + margin-bottom: 5%; } .subtle-pad { @@ -67,6 +71,10 @@ body { margin-bottom: 20px; } + .theme-showcase { + margin-bottom: 40px; + } + .theme-showcase > p > .btn { margin: 5px 0; } @@ -111,6 +119,7 @@ a > .tutorial{ min-height: 700px; margin-bottom: 50px; } + /* Since positioning the image, we need to help out the caption */ .carousel-caption { display:block; @@ -171,7 +180,7 @@ a > .tutorial{ .hero-section { /*TODO: get background image- using color as standin for now*/ - background-color: #d3d3d3; + background-color: #eee; text-align: center; padding: 150px; } @@ -181,7 +190,7 @@ a > .tutorial{ } .hero { - color:white; + color: #111; font-size:40px; text-shadow: 1px 1px 1px #999; } @@ -325,10 +334,6 @@ a > .tutorial{ font-size: smaller; } -.text-gray-lighter { - color: #D3D3D3; -} - .bg-light-gray { background-color: #eeeeee; } @@ -479,6 +484,11 @@ tr:hover .row-actions { padding: 5px 0; } +.filter-sidebar .panel-body-long { + max-height: 25vh; + overflow-y: auto; +} + .filter-sidebar .panel-heading .accordion-toggle:after { font-family: 'Glyphicons Halflings'; content: "\e252"; @@ -491,6 +501,12 @@ tr:hover .row-actions { content: "\e253"; } +@media all and (min-width: 768px) and (max-width: 991px) { + .filter-sidebar > .form > .btn.btn-primary:first-of-type { + margin-bottom: 5px; + } +} + .search-results .list-group-item { border: 0; } @@ -527,10 +543,10 @@ tr:hover .row-actions { .console .button-explanation { height:70px; } - + } - + .console .button-explanation .text { display:none; } @@ -560,3 +576,59 @@ tr:hover .row-actions { display:block; } +.setup-content { + margin-bottom: 20px; +} + +.bottom-10 { + bottom: 10px; +} + +.officer-face { + border: none; + height: 300px; + margin: auto; +} + +.officer-face.officer-profile { + display: block; +} + +@media (min-width: 992px) { + .officer-face.officer-profile { + height: 510px; + } +} + +@media (min-width: 768px) and (max-width: 991px) { + .officer-face.officer-profile { + height: 590px; + } +} + +@media (max-width: 767px) { + .officer-face.officer-profile { + height: 460px; + padding-bottom: 10px; + } +} + +#face-img { + border: none; + display: block; + margin: auto; + max-height: 500px; + padding-bottom: 10px; +} + +.face-wrap { + margin: auto; + position: relative; +} + +#face-tag-frame { + position: absolute; + border: 2px solid crimson; + visibility: hidden; + width: auto +} diff --git a/OpenOversight/app/static/css/qunit.css b/OpenOversight/app/static/css/qunit.css old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/css/select2.min.css b/OpenOversight/app/static/css/select2.min.css new file mode 100644 index 000000000..facda8d4e --- /dev/null +++ b/OpenOversight/app/static/css/select2.min.css @@ -0,0 +1,2 @@ +/*! Select2 4.0.13 | https://github.com/select2/select2/blob/master/LICENSE.md */ +.select2-container{box-sizing:border-box;display:inline-block;margin:0;position:relative;vertical-align:middle}.select2-container .select2-selection--single{box-sizing:border-box;cursor:pointer;display:block;height:28px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--single .select2-selection__rendered{display:block;padding-left:8px;padding-right:20px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-selection--single .select2-selection__clear{position:relative}.select2-container[dir="rtl"] .select2-selection--single .select2-selection__rendered{padding-right:8px;padding-left:20px}.select2-container .select2-selection--multiple{box-sizing:border-box;cursor:pointer;display:block;min-height:32px;user-select:none;-webkit-user-select:none}.select2-container .select2-selection--multiple .select2-selection__rendered{display:inline-block;overflow:hidden;padding-left:8px;text-overflow:ellipsis;white-space:nowrap}.select2-container .select2-search--inline{float:left}.select2-container .select2-search--inline .select2-search__field{box-sizing:border-box;border:none;font-size:100%;margin-top:5px;padding:0}.select2-container .select2-search--inline .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-dropdown{background-color:white;border:1px solid #aaa;border-radius:4px;box-sizing:border-box;display:block;position:absolute;left:-100000px;width:100%;z-index:1051}.select2-results{display:block}.select2-results__options{list-style:none;margin:0;padding:0}.select2-results__option{padding:6px;user-select:none;-webkit-user-select:none}.select2-results__option[aria-selected]{cursor:pointer}.select2-container--open .select2-dropdown{left:0}.select2-container--open .select2-dropdown--above{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--open .select2-dropdown--below{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-search--dropdown{display:block;padding:4px}.select2-search--dropdown .select2-search__field{padding:4px;width:100%;box-sizing:border-box}.select2-search--dropdown .select2-search__field::-webkit-search-cancel-button{-webkit-appearance:none}.select2-search--dropdown.select2-search--hide{display:none}.select2-close-mask{border:0;margin:0;padding:0;display:block;position:fixed;left:0;top:0;min-height:100%;min-width:100%;height:auto;width:auto;opacity:0;z-index:99;background-color:#fff;filter:alpha(opacity=0)}.select2-hidden-accessible{border:0 !important;clip:rect(0 0 0 0) !important;-webkit-clip-path:inset(50%) !important;clip-path:inset(50%) !important;height:1px !important;overflow:hidden !important;padding:0 !important;position:absolute !important;width:1px !important;white-space:nowrap !important}.select2-container--default .select2-selection--single{background-color:#fff;border:1px solid #aaa;border-radius:4px}.select2-container--default .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--default .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold}.select2-container--default .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--default .select2-selection--single .select2-selection__arrow{height:26px;position:absolute;top:1px;right:1px;width:20px}.select2-container--default .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--default[dir="rtl"] .select2-selection--single .select2-selection__arrow{left:1px;right:auto}.select2-container--default.select2-container--disabled .select2-selection--single{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection--single .select2-selection__clear{display:none}.select2-container--default.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--default .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text}.select2-container--default .select2-selection--multiple .select2-selection__rendered{box-sizing:border-box;list-style:none;margin:0;padding:0 5px;width:100%}.select2-container--default .select2-selection--multiple .select2-selection__rendered li{list-style:none}.select2-container--default .select2-selection--multiple .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-top:5px;margin-right:10px;padding:1px}.select2-container--default .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove{color:#999;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--default .select2-selection--multiple .select2-selection__choice__remove:hover{color:#333}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice,.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-search--inline{float:right}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice{margin-left:5px;margin-right:auto}.select2-container--default[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--default.select2-container--focus .select2-selection--multiple{border:solid black 1px;outline:0}.select2-container--default.select2-container--disabled .select2-selection--multiple{background-color:#eee;cursor:default}.select2-container--default.select2-container--disabled .select2-selection__choice__remove{display:none}.select2-container--default.select2-container--open.select2-container--above .select2-selection--single,.select2-container--default.select2-container--open.select2-container--above .select2-selection--multiple{border-top-left-radius:0;border-top-right-radius:0}.select2-container--default.select2-container--open.select2-container--below .select2-selection--single,.select2-container--default.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--default .select2-search--dropdown .select2-search__field{border:1px solid #aaa}.select2-container--default .select2-search--inline .select2-search__field{background:transparent;border:none;outline:0;box-shadow:none;-webkit-appearance:textfield}.select2-container--default .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--default .select2-results__option[role=group]{padding:0}.select2-container--default .select2-results__option[aria-disabled=true]{color:#999}.select2-container--default .select2-results__option[aria-selected=true]{background-color:#ddd}.select2-container--default .select2-results__option .select2-results__option{padding-left:1em}.select2-container--default .select2-results__option .select2-results__option .select2-results__group{padding-left:0}.select2-container--default .select2-results__option .select2-results__option .select2-results__option{margin-left:-1em;padding-left:2em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-2em;padding-left:3em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-3em;padding-left:4em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-4em;padding-left:5em}.select2-container--default .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option .select2-results__option{margin-left:-5em;padding-left:6em}.select2-container--default .select2-results__option--highlighted[aria-selected]{background-color:#5897fb;color:white}.select2-container--default .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic .select2-selection--single{background-color:#f7f7f7;border:1px solid #aaa;border-radius:4px;outline:0;background-image:-webkit-linear-gradient(top, #fff 50%, #eee 100%);background-image:-o-linear-gradient(top, #fff 50%, #eee 100%);background-image:linear-gradient(to bottom, #fff 50%, #eee 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic .select2-selection--single:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--single .select2-selection__rendered{color:#444;line-height:28px}.select2-container--classic .select2-selection--single .select2-selection__clear{cursor:pointer;float:right;font-weight:bold;margin-right:10px}.select2-container--classic .select2-selection--single .select2-selection__placeholder{color:#999}.select2-container--classic .select2-selection--single .select2-selection__arrow{background-color:#ddd;border:none;border-left:1px solid #aaa;border-top-right-radius:4px;border-bottom-right-radius:4px;height:26px;position:absolute;top:1px;right:1px;width:20px;background-image:-webkit-linear-gradient(top, #eee 50%, #ccc 100%);background-image:-o-linear-gradient(top, #eee 50%, #ccc 100%);background-image:linear-gradient(to bottom, #eee 50%, #ccc 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFCCCCCC', GradientType=0)}.select2-container--classic .select2-selection--single .select2-selection__arrow b{border-color:#888 transparent transparent transparent;border-style:solid;border-width:5px 4px 0 4px;height:0;left:50%;margin-left:-4px;margin-top:-2px;position:absolute;top:50%;width:0}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__clear{float:left}.select2-container--classic[dir="rtl"] .select2-selection--single .select2-selection__arrow{border:none;border-right:1px solid #aaa;border-radius:0;border-top-left-radius:4px;border-bottom-left-radius:4px;left:1px;right:auto}.select2-container--classic.select2-container--open .select2-selection--single{border:1px solid #5897fb}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow{background:transparent;border:none}.select2-container--classic.select2-container--open .select2-selection--single .select2-selection__arrow b{border-color:transparent transparent #888 transparent;border-width:0 4px 5px 4px}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--single{border-top:none;border-top-left-radius:0;border-top-right-radius:0;background-image:-webkit-linear-gradient(top, #fff 0%, #eee 50%);background-image:-o-linear-gradient(top, #fff 0%, #eee 50%);background-image:linear-gradient(to bottom, #fff 0%, #eee 50%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFFFFFFF', endColorstr='#FFEEEEEE', GradientType=0)}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--single{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0;background-image:-webkit-linear-gradient(top, #eee 50%, #fff 100%);background-image:-o-linear-gradient(top, #eee 50%, #fff 100%);background-image:linear-gradient(to bottom, #eee 50%, #fff 100%);background-repeat:repeat-x;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#FFEEEEEE', endColorstr='#FFFFFFFF', GradientType=0)}.select2-container--classic .select2-selection--multiple{background-color:white;border:1px solid #aaa;border-radius:4px;cursor:text;outline:0}.select2-container--classic .select2-selection--multiple:focus{border:1px solid #5897fb}.select2-container--classic .select2-selection--multiple .select2-selection__rendered{list-style:none;margin:0;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__clear{display:none}.select2-container--classic .select2-selection--multiple .select2-selection__choice{background-color:#e4e4e4;border:1px solid #aaa;border-radius:4px;cursor:default;float:left;margin-right:5px;margin-top:5px;padding:0 5px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove{color:#888;cursor:pointer;display:inline-block;font-weight:bold;margin-right:2px}.select2-container--classic .select2-selection--multiple .select2-selection__choice__remove:hover{color:#555}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice{float:right;margin-left:5px;margin-right:auto}.select2-container--classic[dir="rtl"] .select2-selection--multiple .select2-selection__choice__remove{margin-left:2px;margin-right:auto}.select2-container--classic.select2-container--open .select2-selection--multiple{border:1px solid #5897fb}.select2-container--classic.select2-container--open.select2-container--above .select2-selection--multiple{border-top:none;border-top-left-radius:0;border-top-right-radius:0}.select2-container--classic.select2-container--open.select2-container--below .select2-selection--multiple{border-bottom:none;border-bottom-left-radius:0;border-bottom-right-radius:0}.select2-container--classic .select2-search--dropdown .select2-search__field{border:1px solid #aaa;outline:0}.select2-container--classic .select2-search--inline .select2-search__field{outline:0;box-shadow:none}.select2-container--classic .select2-dropdown{background-color:#fff;border:1px solid transparent}.select2-container--classic .select2-dropdown--above{border-bottom:none}.select2-container--classic .select2-dropdown--below{border-top:none}.select2-container--classic .select2-results>.select2-results__options{max-height:200px;overflow-y:auto}.select2-container--classic .select2-results__option[role=group]{padding:0}.select2-container--classic .select2-results__option[aria-disabled=true]{color:grey}.select2-container--classic .select2-results__option--highlighted[aria-selected]{background-color:#3875d7;color:#fff}.select2-container--classic .select2-results__group{cursor:default;display:block;padding:6px}.select2-container--classic.select2-container--open .select2-dropdown{border-color:#5897fb} diff --git a/OpenOversight/app/static/fonts/FontAwesome.otf b/OpenOversight/app/static/fonts/FontAwesome.otf old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/bootstrap/glyphicons-halflings-regular.svg b/OpenOversight/app/static/fonts/bootstrap/glyphicons-halflings-regular.svg index 94fb5490a..187805af6 100644 --- a/OpenOversight/app/static/fonts/bootstrap/glyphicons-halflings-regular.svg +++ b/OpenOversight/app/static/fonts/bootstrap/glyphicons-halflings-regular.svg @@ -285,4 +285,4 @@ - \ No newline at end of file + diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.eot b/OpenOversight/app/static/fonts/fontawesome-webfont.eot old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.svg b/OpenOversight/app/static/fonts/fontawesome-webfont.svg old mode 100755 new mode 100644 index 8b66187fe..efee1983f --- a/OpenOversight/app/static/fonts/fontawesome-webfont.svg +++ b/OpenOversight/app/static/fonts/fontawesome-webfont.svg @@ -682,4 +682,4 @@ - \ No newline at end of file + diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.ttf b/OpenOversight/app/static/fonts/fontawesome-webfont.ttf old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.woff b/OpenOversight/app/static/fonts/fontawesome-webfont.woff old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/fonts/fontawesome-webfont.woff2 b/OpenOversight/app/static/fonts/fontawesome-webfont.woff2 old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/images/test_cop1.png b/OpenOversight/app/static/images/test_cop1.png index df3163cf1..6f4d4788a 100644 Binary files a/OpenOversight/app/static/images/test_cop1.png and b/OpenOversight/app/static/images/test_cop1.png differ diff --git a/OpenOversight/app/static/images/test_cop2.png b/OpenOversight/app/static/images/test_cop2.png index d7859f8ec..6f5a65cb4 100644 Binary files a/OpenOversight/app/static/images/test_cop2.png and b/OpenOversight/app/static/images/test_cop2.png differ diff --git a/OpenOversight/app/static/images/test_cop3.png b/OpenOversight/app/static/images/test_cop3.png index 13c7b8963..263a78d1c 100644 Binary files a/OpenOversight/app/static/images/test_cop3.png and b/OpenOversight/app/static/images/test_cop3.png differ diff --git a/OpenOversight/app/static/images/test_cop4.png b/OpenOversight/app/static/images/test_cop4.png index 062e56d2d..da580a6a3 100644 Binary files a/OpenOversight/app/static/images/test_cop4.png and b/OpenOversight/app/static/images/test_cop4.png differ diff --git a/OpenOversight/app/static/images/test_cop5.png b/OpenOversight/app/static/images/test_cop5.png index 9f88a1d94..bd8e6d5b3 100644 Binary files a/OpenOversight/app/static/images/test_cop5.png and b/OpenOversight/app/static/images/test_cop5.png differ diff --git a/OpenOversight/app/static/js/bootstrap.js b/OpenOversight/app/static/js/bootstrap.js old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/js/bootstrap.min.js b/OpenOversight/app/static/js/bootstrap.min.js old mode 100755 new mode 100644 index 9bcd2fcca..be9574d70 --- a/OpenOversight/app/static/js/bootstrap.min.js +++ b/OpenOversight/app/static/js/bootstrap.min.js @@ -4,4 +4,4 @@ * Licensed under the MIT license */ if("undefined"==typeof jQuery)throw new Error("Bootstrap's JavaScript requires jQuery");+function(a){"use strict";var b=a.fn.jquery.split(" ")[0].split(".");if(b[0]<2&&b[1]<9||1==b[0]&&9==b[1]&&b[2]<1||b[0]>3)throw new Error("Bootstrap's JavaScript requires jQuery version 1.9.1 or higher, but lower than version 4")}(jQuery),+function(a){"use strict";function b(){var a=document.createElement("bootstrap"),b={WebkitTransition:"webkitTransitionEnd",MozTransition:"transitionend",OTransition:"oTransitionEnd otransitionend",transition:"transitionend"};for(var c in b)if(void 0!==a.style[c])return{end:b[c]};return!1}a.fn.emulateTransitionEnd=function(b){var c=!1,d=this;a(this).one("bsTransitionEnd",function(){c=!0});var e=function(){c||a(d).trigger(a.support.transition.end)};return setTimeout(e,b),this},a(function(){a.support.transition=b(),a.support.transition&&(a.event.special.bsTransitionEnd={bindType:a.support.transition.end,delegateType:a.support.transition.end,handle:function(b){if(a(b.target).is(this))return b.handleObj.handler.apply(this,arguments)}})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var c=a(this),e=c.data("bs.alert");e||c.data("bs.alert",e=new d(this)),"string"==typeof b&&e[b].call(c)})}var c='[data-dismiss="alert"]',d=function(b){a(b).on("click",c,this.close)};d.VERSION="3.3.7",d.TRANSITION_DURATION=150,d.prototype.close=function(b){function c(){g.detach().trigger("closed.bs.alert").remove()}var e=a(this),f=e.attr("data-target");f||(f=e.attr("href"),f=f&&f.replace(/.*(?=#[^\s]*$)/,""));var g=a("#"===f?[]:f);b&&b.preventDefault(),g.length||(g=e.closest(".alert")),g.trigger(b=a.Event("close.bs.alert")),b.isDefaultPrevented()||(g.removeClass("in"),a.support.transition&&g.hasClass("fade")?g.one("bsTransitionEnd",c).emulateTransitionEnd(d.TRANSITION_DURATION):c())};var e=a.fn.alert;a.fn.alert=b,a.fn.alert.Constructor=d,a.fn.alert.noConflict=function(){return a.fn.alert=e,this},a(document).on("click.bs.alert.data-api",c,d.prototype.close)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.button"),f="object"==typeof b&&b;e||d.data("bs.button",e=new c(this,f)),"toggle"==b?e.toggle():b&&e.setState(b)})}var c=function(b,d){this.$element=a(b),this.options=a.extend({},c.DEFAULTS,d),this.isLoading=!1};c.VERSION="3.3.7",c.DEFAULTS={loadingText:"loading..."},c.prototype.setState=function(b){var c="disabled",d=this.$element,e=d.is("input")?"val":"html",f=d.data();b+="Text",null==f.resetText&&d.data("resetText",d[e]()),setTimeout(a.proxy(function(){d[e](null==f[b]?this.options[b]:f[b]),"loadingText"==b?(this.isLoading=!0,d.addClass(c).attr(c,c).prop(c,!0)):this.isLoading&&(this.isLoading=!1,d.removeClass(c).removeAttr(c).prop(c,!1))},this),0)},c.prototype.toggle=function(){var a=!0,b=this.$element.closest('[data-toggle="buttons"]');if(b.length){var c=this.$element.find("input");"radio"==c.prop("type")?(c.prop("checked")&&(a=!1),b.find(".active").removeClass("active"),this.$element.addClass("active")):"checkbox"==c.prop("type")&&(c.prop("checked")!==this.$element.hasClass("active")&&(a=!1),this.$element.toggleClass("active")),c.prop("checked",this.$element.hasClass("active")),a&&c.trigger("change")}else this.$element.attr("aria-pressed",!this.$element.hasClass("active")),this.$element.toggleClass("active")};var d=a.fn.button;a.fn.button=b,a.fn.button.Constructor=c,a.fn.button.noConflict=function(){return a.fn.button=d,this},a(document).on("click.bs.button.data-api",'[data-toggle^="button"]',function(c){var d=a(c.target).closest(".btn");b.call(d,"toggle"),a(c.target).is('input[type="radio"], input[type="checkbox"]')||(c.preventDefault(),d.is("input,button")?d.trigger("focus"):d.find("input:visible,button:visible").first().trigger("focus"))}).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',function(b){a(b.target).closest(".btn").toggleClass("focus",/^focus(in)?$/.test(b.type))})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.carousel"),f=a.extend({},c.DEFAULTS,d.data(),"object"==typeof b&&b),g="string"==typeof b?b:f.slide;e||d.data("bs.carousel",e=new c(this,f)),"number"==typeof b?e.to(b):g?e[g]():f.interval&&e.pause().cycle()})}var c=function(b,c){this.$element=a(b),this.$indicators=this.$element.find(".carousel-indicators"),this.options=c,this.paused=null,this.sliding=null,this.interval=null,this.$active=null,this.$items=null,this.options.keyboard&&this.$element.on("keydown.bs.carousel",a.proxy(this.keydown,this)),"hover"==this.options.pause&&!("ontouchstart"in document.documentElement)&&this.$element.on("mouseenter.bs.carousel",a.proxy(this.pause,this)).on("mouseleave.bs.carousel",a.proxy(this.cycle,this))};c.VERSION="3.3.7",c.TRANSITION_DURATION=600,c.DEFAULTS={interval:5e3,pause:"hover",wrap:!0,keyboard:!0},c.prototype.keydown=function(a){if(!/input|textarea/i.test(a.target.tagName)){switch(a.which){case 37:this.prev();break;case 39:this.next();break;default:return}a.preventDefault()}},c.prototype.cycle=function(b){return b||(this.paused=!1),this.interval&&clearInterval(this.interval),this.options.interval&&!this.paused&&(this.interval=setInterval(a.proxy(this.next,this),this.options.interval)),this},c.prototype.getItemIndex=function(a){return this.$items=a.parent().children(".item"),this.$items.index(a||this.$active)},c.prototype.getItemForDirection=function(a,b){var c=this.getItemIndex(b),d="prev"==a&&0===c||"next"==a&&c==this.$items.length-1;if(d&&!this.options.wrap)return b;var e="prev"==a?-1:1,f=(c+e)%this.$items.length;return this.$items.eq(f)},c.prototype.to=function(a){var b=this,c=this.getItemIndex(this.$active=this.$element.find(".item.active"));if(!(a>this.$items.length-1||a<0))return this.sliding?this.$element.one("slid.bs.carousel",function(){b.to(a)}):c==a?this.pause().cycle():this.slide(a>c?"next":"prev",this.$items.eq(a))},c.prototype.pause=function(b){return b||(this.paused=!0),this.$element.find(".next, .prev").length&&a.support.transition&&(this.$element.trigger(a.support.transition.end),this.cycle(!0)),this.interval=clearInterval(this.interval),this},c.prototype.next=function(){if(!this.sliding)return this.slide("next")},c.prototype.prev=function(){if(!this.sliding)return this.slide("prev")},c.prototype.slide=function(b,d){var e=this.$element.find(".item.active"),f=d||this.getItemForDirection(b,e),g=this.interval,h="next"==b?"left":"right",i=this;if(f.hasClass("active"))return this.sliding=!1;var j=f[0],k=a.Event("slide.bs.carousel",{relatedTarget:j,direction:h});if(this.$element.trigger(k),!k.isDefaultPrevented()){if(this.sliding=!0,g&&this.pause(),this.$indicators.length){this.$indicators.find(".active").removeClass("active");var l=a(this.$indicators.children()[this.getItemIndex(f)]);l&&l.addClass("active")}var m=a.Event("slid.bs.carousel",{relatedTarget:j,direction:h});return a.support.transition&&this.$element.hasClass("slide")?(f.addClass(b),f[0].offsetWidth,e.addClass(h),f.addClass(h),e.one("bsTransitionEnd",function(){f.removeClass([b,h].join(" ")).addClass("active"),e.removeClass(["active",h].join(" ")),i.sliding=!1,setTimeout(function(){i.$element.trigger(m)},0)}).emulateTransitionEnd(c.TRANSITION_DURATION)):(e.removeClass("active"),f.addClass("active"),this.sliding=!1,this.$element.trigger(m)),g&&this.cycle(),this}};var d=a.fn.carousel;a.fn.carousel=b,a.fn.carousel.Constructor=c,a.fn.carousel.noConflict=function(){return a.fn.carousel=d,this};var e=function(c){var d,e=a(this),f=a(e.attr("data-target")||(d=e.attr("href"))&&d.replace(/.*(?=#[^\s]+$)/,""));if(f.hasClass("carousel")){var g=a.extend({},f.data(),e.data()),h=e.attr("data-slide-to");h&&(g.interval=!1),b.call(f,g),h&&f.data("bs.carousel").to(h),c.preventDefault()}};a(document).on("click.bs.carousel.data-api","[data-slide]",e).on("click.bs.carousel.data-api","[data-slide-to]",e),a(window).on("load",function(){a('[data-ride="carousel"]').each(function(){var c=a(this);b.call(c,c.data())})})}(jQuery),+function(a){"use strict";function b(b){var c,d=b.attr("data-target")||(c=b.attr("href"))&&c.replace(/.*(?=#[^\s]+$)/,"");return a(d)}function c(b){return this.each(function(){var c=a(this),e=c.data("bs.collapse"),f=a.extend({},d.DEFAULTS,c.data(),"object"==typeof b&&b);!e&&f.toggle&&/show|hide/.test(b)&&(f.toggle=!1),e||c.data("bs.collapse",e=new d(this,f)),"string"==typeof b&&e[b]()})}var d=function(b,c){this.$element=a(b),this.options=a.extend({},d.DEFAULTS,c),this.$trigger=a('[data-toggle="collapse"][href="#'+b.id+'"],[data-toggle="collapse"][data-target="#'+b.id+'"]'),this.transitioning=null,this.options.parent?this.$parent=this.getParent():this.addAriaAndCollapsedClass(this.$element,this.$trigger),this.options.toggle&&this.toggle()};d.VERSION="3.3.7",d.TRANSITION_DURATION=350,d.DEFAULTS={toggle:!0},d.prototype.dimension=function(){var a=this.$element.hasClass("width");return a?"width":"height"},d.prototype.show=function(){if(!this.transitioning&&!this.$element.hasClass("in")){var b,e=this.$parent&&this.$parent.children(".panel").children(".in, .collapsing");if(!(e&&e.length&&(b=e.data("bs.collapse"),b&&b.transitioning))){var f=a.Event("show.bs.collapse");if(this.$element.trigger(f),!f.isDefaultPrevented()){e&&e.length&&(c.call(e,"hide"),b||e.data("bs.collapse",null));var g=this.dimension();this.$element.removeClass("collapse").addClass("collapsing")[g](0).attr("aria-expanded",!0),this.$trigger.removeClass("collapsed").attr("aria-expanded",!0),this.transitioning=1;var h=function(){this.$element.removeClass("collapsing").addClass("collapse in")[g](""),this.transitioning=0,this.$element.trigger("shown.bs.collapse")};if(!a.support.transition)return h.call(this);var i=a.camelCase(["scroll",g].join("-"));this.$element.one("bsTransitionEnd",a.proxy(h,this)).emulateTransitionEnd(d.TRANSITION_DURATION)[g](this.$element[0][i])}}}},d.prototype.hide=function(){if(!this.transitioning&&this.$element.hasClass("in")){var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),!b.isDefaultPrevented()){var c=this.dimension();this.$element[c](this.$element[c]())[0].offsetHeight,this.$element.addClass("collapsing").removeClass("collapse in").attr("aria-expanded",!1),this.$trigger.addClass("collapsed").attr("aria-expanded",!1),this.transitioning=1;var e=function(){this.transitioning=0,this.$element.removeClass("collapsing").addClass("collapse").trigger("hidden.bs.collapse")};return a.support.transition?void this.$element[c](0).one("bsTransitionEnd",a.proxy(e,this)).emulateTransitionEnd(d.TRANSITION_DURATION):e.call(this)}}},d.prototype.toggle=function(){this[this.$element.hasClass("in")?"hide":"show"]()},d.prototype.getParent=function(){return a(this.options.parent).find('[data-toggle="collapse"][data-parent="'+this.options.parent+'"]').each(a.proxy(function(c,d){var e=a(d);this.addAriaAndCollapsedClass(b(e),e)},this)).end()},d.prototype.addAriaAndCollapsedClass=function(a,b){var c=a.hasClass("in");a.attr("aria-expanded",c),b.toggleClass("collapsed",!c).attr("aria-expanded",c)};var e=a.fn.collapse;a.fn.collapse=c,a.fn.collapse.Constructor=d,a.fn.collapse.noConflict=function(){return a.fn.collapse=e,this},a(document).on("click.bs.collapse.data-api",'[data-toggle="collapse"]',function(d){var e=a(this);e.attr("data-target")||d.preventDefault();var f=b(e),g=f.data("bs.collapse"),h=g?"toggle":e.data();c.call(f,h)})}(jQuery),+function(a){"use strict";function b(b){var c=b.attr("data-target");c||(c=b.attr("href"),c=c&&/#[A-Za-z]/.test(c)&&c.replace(/.*(?=#[^\s]*$)/,""));var d=c&&a(c);return d&&d.length?d:b.parent()}function c(c){c&&3===c.which||(a(e).remove(),a(f).each(function(){var d=a(this),e=b(d),f={relatedTarget:this};e.hasClass("open")&&(c&&"click"==c.type&&/input|textarea/i.test(c.target.tagName)&&a.contains(e[0],c.target)||(e.trigger(c=a.Event("hide.bs.dropdown",f)),c.isDefaultPrevented()||(d.attr("aria-expanded","false"),e.removeClass("open").trigger(a.Event("hidden.bs.dropdown",f)))))}))}function d(b){return this.each(function(){var c=a(this),d=c.data("bs.dropdown");d||c.data("bs.dropdown",d=new g(this)),"string"==typeof b&&d[b].call(c)})}var e=".dropdown-backdrop",f='[data-toggle="dropdown"]',g=function(b){a(b).on("click.bs.dropdown",this.toggle)};g.VERSION="3.3.7",g.prototype.toggle=function(d){var e=a(this);if(!e.is(".disabled, :disabled")){var f=b(e),g=f.hasClass("open");if(c(),!g){"ontouchstart"in document.documentElement&&!f.closest(".navbar-nav").length&&a(document.createElement("div")).addClass("dropdown-backdrop").insertAfter(a(this)).on("click",c);var h={relatedTarget:this};if(f.trigger(d=a.Event("show.bs.dropdown",h)),d.isDefaultPrevented())return;e.trigger("focus").attr("aria-expanded","true"),f.toggleClass("open").trigger(a.Event("shown.bs.dropdown",h))}return!1}},g.prototype.keydown=function(c){if(/(38|40|27|32)/.test(c.which)&&!/input|textarea/i.test(c.target.tagName)){var d=a(this);if(c.preventDefault(),c.stopPropagation(),!d.is(".disabled, :disabled")){var e=b(d),g=e.hasClass("open");if(!g&&27!=c.which||g&&27==c.which)return 27==c.which&&e.find(f).trigger("focus"),d.trigger("click");var h=" li:not(.disabled):visible a",i=e.find(".dropdown-menu"+h);if(i.length){var j=i.index(c.target);38==c.which&&j>0&&j--,40==c.which&&jdocument.documentElement.clientHeight;this.$element.css({paddingLeft:!this.bodyIsOverflowing&&a?this.scrollbarWidth:"",paddingRight:this.bodyIsOverflowing&&!a?this.scrollbarWidth:""})},c.prototype.resetAdjustments=function(){this.$element.css({paddingLeft:"",paddingRight:""})},c.prototype.checkScrollbar=function(){var a=window.innerWidth;if(!a){var b=document.documentElement.getBoundingClientRect();a=b.right-Math.abs(b.left)}this.bodyIsOverflowing=document.body.clientWidth
',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(a.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusin"==b.type?"focus":"hover"]=!0),c.tip().hasClass("in")||"in"==c.hoverState?void(c.hoverState="in"):(clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.isInStateTrue=function(){for(var a in this.inState)if(this.inState[a])return!0;return!1},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);if(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusout"==b.type?"focus":"hover"]=!1),!c.isInStateTrue())return clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.getPosition(this.$viewport);h="bottom"==h&&k.bottom+m>o.bottom?"top":"top"==h&&k.top-mo.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.right&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){if(!this.$tip&&(this.$tip=a(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),b?(c.inState.click=!c.inState.click,c.isInStateTrue()?c.enter(c):c.leave(c)):c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type),a.$tip&&a.$tip.detach(),a.$tip=null,a.$arrow=null,a.$viewport=null,a.$element=null})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;!e&&/destroy|hide/.test(b)||(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.7",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.7",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.7",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return e=a-d&&"bottom"},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); \ No newline at end of file +this.activeTarget=b,this.clear();var c=this.selector+'[data-target="'+b+'"],'+this.selector+'[href="'+b+'"]',d=a(c).parents("li").addClass("active");d.parent(".dropdown-menu").length&&(d=d.closest("li.dropdown").addClass("active")),d.trigger("activate.bs.scrollspy")},b.prototype.clear=function(){a(this.selector).parentsUntil(this.options.target,".active").removeClass("active")};var d=a.fn.scrollspy;a.fn.scrollspy=c,a.fn.scrollspy.Constructor=b,a.fn.scrollspy.noConflict=function(){return a.fn.scrollspy=d,this},a(window).on("load.bs.scrollspy.data-api",function(){a('[data-spy="scroll"]').each(function(){var b=a(this);c.call(b,b.data())})})}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.tab");e||d.data("bs.tab",e=new c(this)),"string"==typeof b&&e[b]()})}var c=function(b){this.element=a(b)};c.VERSION="3.3.7",c.TRANSITION_DURATION=150,c.prototype.show=function(){var b=this.element,c=b.closest("ul:not(.dropdown-menu)"),d=b.data("target");if(d||(d=b.attr("href"),d=d&&d.replace(/.*(?=#[^\s]*$)/,"")),!b.parent("li").hasClass("active")){var e=c.find(".active:last a"),f=a.Event("hide.bs.tab",{relatedTarget:b[0]}),g=a.Event("show.bs.tab",{relatedTarget:e[0]});if(e.trigger(f),b.trigger(g),!g.isDefaultPrevented()&&!f.isDefaultPrevented()){var h=a(d);this.activate(b.closest("li"),c),this.activate(h,h.parent(),function(){e.trigger({type:"hidden.bs.tab",relatedTarget:b[0]}),b.trigger({type:"shown.bs.tab",relatedTarget:e[0]})})}}},c.prototype.activate=function(b,d,e){function f(){g.removeClass("active").find("> .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.7",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return e=a-d&&"bottom"},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); diff --git a/OpenOversight/app/static/js/cropper.js b/OpenOversight/app/static/js/cropper.js old mode 100755 new mode 100644 diff --git a/OpenOversight/app/static/js/cropper.min.js b/OpenOversight/app/static/js/cropper.min.js old mode 100755 new mode 100644 index d285e2c2b..62c740264 --- a/OpenOversight/app/static/js/cropper.min.js +++ b/OpenOversight/app/static/js/cropper.min.js @@ -7,4 +7,4 @@ * * Date: 2016-09-03T05:50:45.412Z */ -!function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t("object"==typeof exports?require("jquery"):jQuery)}(function(t){"use strict";function i(t){return"number"==typeof t&&!isNaN(t)}function e(t){return"undefined"==typeof t}function s(t,e){var s=[];return i(e)&&s.push(e),s.slice.apply(t,s)}function a(t,i){var e=s(arguments,2);return function(){return t.apply(i,e.concat(s(arguments)))}}function o(t){var i=t.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i);return i&&(i[1]!==C.protocol||i[2]!==C.hostname||i[3]!==C.port)}function h(t){var i="timestamp="+(new Date).getTime();return t+(t.indexOf("?")===-1?"?":"&")+i}function n(t){return t?' crossOrigin="'+t+'"':""}function r(t,i){var e;return t.naturalWidth&&!mt?i(t.naturalWidth,t.naturalHeight):(e=document.createElement("img"),e.onload=function(){i(this.width,this.height)},void(e.src=t.src))}function p(t){var e=[],s=t.rotate,a=t.scaleX,o=t.scaleY;return i(s)&&0!==s&&e.push("rotate("+s+"deg)"),i(a)&&1!==a&&e.push("scaleX("+a+")"),i(o)&&1!==o&&e.push("scaleY("+o+")"),e.length?e.join(" "):"none"}function l(t,i){var e,s,a=Ct(t.degree)%180,o=(a>90?180-a:a)*Math.PI/180,h=bt(o),n=Bt(o),r=t.width,p=t.height,l=t.aspectRatio;return i?(e=r/(n+h/l),s=e/l):(e=r*n+p*h,s=r*h+p*n),{width:e,height:s}}function c(e,s){var a,o,h,n=t("")[0],r=n.getContext("2d"),p=0,c=0,d=s.naturalWidth,g=s.naturalHeight,u=s.rotate,f=s.scaleX,m=s.scaleY,v=i(f)&&i(m)&&(1!==f||1!==m),w=i(u)&&0!==u,x=w||v,C=d*Ct(f||1),b=g*Ct(m||1);return v&&(a=C/2,o=b/2),w&&(h=l({width:C,height:b,degree:u}),C=h.width,b=h.height,a=C/2,o=b/2),n.width=C,n.height=b,x&&(p=-d/2,c=-g/2,r.save(),r.translate(a,o)),w&&r.rotate(u*Math.PI/180),v&&r.scale(f,m),r.drawImage(e,$t(p),$t(c),$t(d),$t(g)),x&&r.restore(),n}function d(i){var e=i.length,s=0,a=0;return e&&(t.each(i,function(t,i){s+=i.pageX,a+=i.pageY}),s/=e,a/=e),{pageX:s,pageY:a}}function g(t,i,e){var s,a="";for(s=i,e+=i;s=8&&(r=s+a)))),r)for(d=c.getUint16(r,o),l=0;l")[0].getContext),mt=b&&/(Macintosh|iPhone|iPod|iPad).*AppleWebKit/i.test(b.userAgent),vt=Number,wt=Math.min,xt=Math.max,Ct=Math.abs,bt=Math.sin,Bt=Math.cos,yt=Math.sqrt,Dt=Math.round,$t=Math.floor,Lt=String.fromCharCode;v.prototype={constructor:v,init:function(){var t,i=this.$element;if(i.is("img")){if(this.isImg=!0,this.originalUrl=t=i.attr("src"),!t)return;t=i.prop("src")}else i.is("canvas")&&ft&&(t=i[0].toDataURL());this.load(t)},trigger:function(i,e){var s=t.Event(i,e);return this.$element.trigger(s),s},load:function(i){var e,s,a=this.options,n=this.$element;if(i&&(n.one(A,a.build),!this.trigger(A).isDefaultPrevented())){if(this.url=i,this.image={},!a.checkOrientation||!B)return this.clone();if(e=t.proxy(this.read,this),V.test(i))return J.test(i)?e(f(i)):this.clone();s=new XMLHttpRequest,s.onerror=s.onabort=t.proxy(function(){this.clone()},this),s.onload=function(){e(this.response)},a.checkCrossOrigin&&o(i)&&n.prop("crossOrigin")&&(i=h(i)),s.open("get",i),s.responseType="arraybuffer",s.send()}},read:function(t){var i=this.options,e=u(t),s=this.image,a=0,o=1,h=1;if(e>1)switch(this.url=m(t),e){case 2:o=-1;break;case 3:a=-180;break;case 4:h=-1;break;case 5:a=90,h=-1;break;case 6:a=90;break;case 7:a=90,o=-1;break;case 8:a=-90}i.rotatable&&(s.rotate=a),i.scalable&&(s.scaleX=o,s.scaleY=h),this.clone()},clone:function(){var i,e,s=this.options,a=this.$element,r=this.url,p="";s.checkCrossOrigin&&o(r)&&(p=a.prop("crossOrigin"),p?i=r:(p="anonymous",i=h(r))),this.crossOrigin=p,this.crossOriginUrl=i,this.$clone=e=t("'),this.isImg?a[0].complete?this.start():a.one(I,t.proxy(this.start,this)):e.one(I,t.proxy(this.start,this)).one(F,t.proxy(this.stop,this)).addClass(X).insertAfter(a)},start:function(){var i=this.$element,e=this.$clone;this.isImg||(e.off(F,this.stop),i=e),r(i[0],t.proxy(function(i,e){t.extend(this.image,{naturalWidth:i,naturalHeight:e,aspectRatio:i/e}),this.isLoaded=!0,this.build()},this))},stop:function(){this.$clone.remove(),this.$clone=null},build:function(){var i,e,s,a=this.options,o=this.$element,h=this.$clone;this.isLoaded&&(this.isBuilt&&this.unbuild(),this.$container=o.parent(),this.$cropper=i=t(v.TEMPLATE),this.$canvas=i.find(".cropper-canvas").append(h),this.$dragBox=i.find(".cropper-drag-box"),this.$cropBox=e=i.find(".cropper-crop-box"),this.$viewBox=i.find(".cropper-view-box"),this.$face=s=e.find(".cropper-face"),o.addClass(Y).after(i),this.isImg||h.removeClass(X),this.initPreview(),this.bind(),a.aspectRatio=xt(0,a.aspectRatio)||NaN,a.viewMode=xt(0,wt(3,Dt(a.viewMode)))||0,a.autoCrop?(this.isCropped=!0,a.modal&&this.$dragBox.addClass(T)):e.addClass(Y),a.guides||e.find(".cropper-dashed").addClass(Y),a.center||e.find(".cropper-center").addClass(Y),a.cropBoxMovable&&s.addClass(M).data(it,lt),a.highlight||s.addClass(k),a.background&&i.addClass(R),a.cropBoxResizable||e.find(".cropper-line, .cropper-point").addClass(Y),this.setDragMode(a.dragMode),this.render(),this.isBuilt=!0,this.setData(a.data),o.one(S,a.built),this.completing=setTimeout(t.proxy(function(){this.trigger(S),this.trigger(K,this.getData()),this.isCompleted=!0},this),0))},unbuild:function(){this.isBuilt&&(this.isCompleted||clearTimeout(this.completing),this.isBuilt=!1,this.isCompleted=!1,this.initialImage=null,this.initialCanvas=null,this.initialCropBox=null,this.container=null,this.canvas=null,this.cropBox=null,this.unbind(),this.resetPreview(),this.$preview=null,this.$viewBox=null,this.$cropBox=null,this.$dragBox=null,this.$canvas=null,this.$container=null,this.$cropper.remove(),this.$cropper=null)},render:function(){this.initContainer(),this.initCanvas(),this.initCropBox(),this.renderCanvas(),this.isCropped&&this.renderCropBox()},initContainer:function(){var t=this.options,i=this.$element,e=this.$container,s=this.$cropper;s.addClass(Y),i.removeClass(Y),s.css(this.container={width:xt(e.width(),vt(t.minContainerWidth)||200),height:xt(e.height(),vt(t.minContainerHeight)||100)}),i.addClass(Y),s.removeClass(Y)},initCanvas:function(){var i,e=this.options.viewMode,s=this.container,a=s.width,o=s.height,h=this.image,n=h.naturalWidth,r=h.naturalHeight,p=90===Ct(h.rotate),l=p?r:n,c=p?n:r,d=l/c,g=a,u=o;o*d>a?3===e?g=o*d:u=a/d:3===e?u=a/d:g=o*d,i={naturalWidth:l,naturalHeight:c,aspectRatio:d,width:g,height:u},i.oldLeft=i.left=(a-g)/2,i.oldTop=i.top=(o-u)/2,this.canvas=i,this.isLimited=1===e||2===e,this.limitCanvas(!0,!0),this.initialImage=t.extend({},h),this.initialCanvas=t.extend({},i)},limitCanvas:function(t,i){var e,s,a,o,h=this.options,n=h.viewMode,r=this.container,p=r.width,l=r.height,c=this.canvas,d=c.aspectRatio,g=this.cropBox,u=this.isCropped&&g;t&&(e=vt(h.minCanvasWidth)||0,s=vt(h.minCanvasHeight)||0,n&&(n>1?(e=xt(e,p),s=xt(s,l),3===n&&(s*d>e?e=s*d:s=e/d)):e?e=xt(e,u?g.width:0):s?s=xt(s,u?g.height:0):u&&(e=g.width,s=g.height,s*d>e?e=s*d:s=e/d)),e&&s?s*d>e?s=e/d:e=s*d:e?s=e/d:s&&(e=s*d),c.minWidth=e,c.minHeight=s,c.maxWidth=1/0,c.maxHeight=1/0),i&&(n?(a=p-c.width,o=l-c.height,c.minLeft=wt(0,a),c.minTop=wt(0,o),c.maxLeft=xt(0,a),c.maxTop=xt(0,o),u&&this.isLimited&&(c.minLeft=wt(g.left,g.left+g.width-c.width),c.minTop=wt(g.top,g.top+g.height-c.height),c.maxLeft=g.left,c.maxTop=g.top,2===n&&(c.width>=p&&(c.minLeft=wt(0,a),c.maxLeft=xt(0,a)),c.height>=l&&(c.minTop=wt(0,o),c.maxTop=xt(0,o))))):(c.minLeft=-c.width,c.minTop=-c.height,c.maxLeft=p,c.maxTop=l))},renderCanvas:function(t){var i,e,s=this.canvas,a=this.image,o=a.rotate,h=a.naturalWidth,n=a.naturalHeight;this.isRotated&&(this.isRotated=!1,e=l({width:a.width,height:a.height,degree:o}),i=e.width/e.height,i!==s.aspectRatio&&(s.left-=(e.width-s.width)/2,s.top-=(e.height-s.height)/2,s.width=e.width,s.height=e.height,s.aspectRatio=i,s.naturalWidth=h,s.naturalHeight=n,o%180&&(e=l({width:h,height:n,degree:o}),s.naturalWidth=e.width,s.naturalHeight=e.height),this.limitCanvas(!0,!1))),(s.width>s.maxWidth||s.widths.maxHeight||s.heighte.width?o.height=o.width/s:o.width=o.height*s),this.cropBox=o,this.limitCropBox(!0,!0),o.width=wt(xt(o.width,o.minWidth),o.maxWidth),o.height=wt(xt(o.height,o.minHeight),o.maxHeight),o.width=xt(o.minWidth,o.width*a),o.height=xt(o.minHeight,o.height*a),o.oldLeft=o.left=e.left+(e.width-o.width)/2,o.oldTop=o.top=e.top+(e.height-o.height)/2,this.initialCropBox=t.extend({},o)},limitCropBox:function(t,i){var e,s,a,o,h=this.options,n=h.aspectRatio,r=this.container,p=r.width,l=r.height,c=this.canvas,d=this.cropBox,g=this.isLimited;t&&(e=vt(h.minCropBoxWidth)||0,s=vt(h.minCropBoxHeight)||0,e=wt(e,p),s=wt(s,l),a=wt(p,g?c.width:p),o=wt(l,g?c.height:l),n&&(e&&s?s*n>e?s=e/n:e=s*n:e?s=e/n:s&&(e=s*n),o*n>a?o=a/n:a=o*n),d.minWidth=wt(e,a),d.minHeight=wt(s,o),d.maxWidth=a,d.maxHeight=o),i&&(g?(d.minLeft=xt(0,c.left),d.minTop=xt(0,c.top),d.maxLeft=wt(p,c.left+c.width)-d.width,d.maxTop=wt(l,c.top+c.height)-d.height):(d.minLeft=0,d.minTop=0,d.maxLeft=p-d.width,d.maxTop=l-d.height))},renderCropBox:function(){var t=this.options,i=this.container,e=i.width,s=i.height,a=this.cropBox;(a.width>a.maxWidth||a.widtha.maxHeight||a.height'),this.$viewBox.html(i),this.$preview.each(function(){var i=t(this);i.data(tt,{width:i.width(),height:i.height(),html:i.html()}),i.html("')})},resetPreview:function(){this.$preview.each(function(){var i=t(this),e=i.data(tt);i.css({width:e.width,height:e.height}).html(e.html).removeData(tt)})},preview:function(){var i=this.image,e=this.canvas,s=this.cropBox,a=s.width,o=s.height,h=i.width,n=i.height,r=s.left-e.left-i.left,l=s.top-e.top-i.top;this.isCropped&&!this.isDisabled&&(this.$clone2.css({width:h,height:n,marginLeft:-r,marginTop:-l,transform:p(i)}),this.$preview.each(function(){var e=t(this),s=e.data(tt),c=s.width,d=s.height,g=c,u=d,f=1;a&&(f=c/a,u=o*f),o&&u>d&&(f=d/o,g=a*f,u=d),e.css({width:g,height:u}).find("img").css({width:h*f,height:n*f,marginLeft:-r*f,marginTop:-l*f,transform:p(i)})}))},bind:function(){var i=this.options,e=this.$element,s=this.$cropper;t.isFunction(i.cropstart)&&e.on(N,i.cropstart),t.isFunction(i.cropmove)&&e.on(_,i.cropmove),t.isFunction(i.cropend)&&e.on(q,i.cropend),t.isFunction(i.crop)&&e.on(K,i.crop),t.isFunction(i.zoom)&&e.on(Z,i.zoom),s.on(z,t.proxy(this.cropStart,this)),i.zoomable&&i.zoomOnWheel&&s.on(E,t.proxy(this.wheel,this)),i.toggleDragModeOnDblclick&&s.on(U,t.proxy(this.dblclick,this)),x.on(O,this._cropMove=a(this.cropMove,this)).on(P,this._cropEnd=a(this.cropEnd,this)),i.responsive&&w.on(j,this._resize=a(this.resize,this))},unbind:function(){var i=this.options,e=this.$element,s=this.$cropper;t.isFunction(i.cropstart)&&e.off(N,i.cropstart),t.isFunction(i.cropmove)&&e.off(_,i.cropmove),t.isFunction(i.cropend)&&e.off(q,i.cropend),t.isFunction(i.crop)&&e.off(K,i.crop),t.isFunction(i.zoom)&&e.off(Z,i.zoom),s.off(z,this.cropStart),i.zoomable&&i.zoomOnWheel&&s.off(E,this.wheel),i.toggleDragModeOnDblclick&&s.off(U,this.dblclick),x.off(O,this._cropMove).off(P,this._cropEnd),i.responsive&&w.off(j,this._resize)},resize:function(){var i,e,s,a=this.options.restore,o=this.$container,h=this.container;!this.isDisabled&&h&&(s=o.width()/h.width,1===s&&o.height()===h.height||(a&&(i=this.getCanvasData(),e=this.getCropBoxData()),this.render(),a&&(this.setCanvasData(t.each(i,function(t,e){i[t]=e*s})),this.setCropBoxData(t.each(e,function(t,i){e[t]=i*s})))))},dblclick:function(){this.isDisabled||(this.$dragBox.hasClass(W)?this.setDragMode(dt):this.setDragMode(ct))},wheel:function(i){var e=i.originalEvent||i,s=vt(this.options.wheelZoomRatio)||.1,a=1;this.isDisabled||(i.preventDefault(),this.wheeling||(this.wheeling=!0,setTimeout(t.proxy(function(){this.wheeling=!1},this),50),e.deltaY?a=e.deltaY>0?1:-1:e.wheelDelta?a=-e.wheelDelta/120:e.detail&&(a=e.detail>0?1:-1),this.zoom(-a*s,i)))},cropStart:function(i){var e,s,a=this.options,o=i.originalEvent,h=o&&o.touches,n=i;if(!this.isDisabled){if(h){if(e=h.length,e>1){if(!a.zoomable||!a.zoomOnTouch||2!==e)return;n=h[1],this.startX2=n.pageX,this.startY2=n.pageY,s=gt}n=h[0]}if(s=s||t(n.target).data(it),Q.test(s)){if(this.trigger(N,{originalEvent:o,action:s}).isDefaultPrevented())return;i.preventDefault(),this.action=s,this.cropping=!1,this.startX=n.pageX||o&&o.pageX,this.startY=n.pageY||o&&o.pageY,s===ct&&(this.cropping=!0,this.$dragBox.addClass(T))}}},cropMove:function(t){var i,e=this.options,s=t.originalEvent,a=s&&s.touches,o=t,h=this.action;if(!this.isDisabled){if(a){if(i=a.length,i>1){if(!e.zoomable||!e.zoomOnTouch||2!==i)return;o=a[1],this.endX2=o.pageX,this.endY2=o.pageY}o=a[0]}if(h){if(this.trigger(_,{originalEvent:s,action:h}).isDefaultPrevented())return;t.preventDefault(),this.endX=o.pageX||s&&s.pageX,this.endY=o.pageY||s&&s.pageY,this.change(o.shiftKey,h===gt?t:null)}}},cropEnd:function(t){var i=t.originalEvent,e=this.action;this.isDisabled||e&&(t.preventDefault(),this.cropping&&(this.cropping=!1,this.$dragBox.toggleClass(T,this.isCropped&&this.options.modal)),this.action="",this.trigger(q,{originalEvent:i,action:e}))},change:function(t,i){var e,s,a=this.options,o=a.aspectRatio,h=this.action,n=this.container,r=this.canvas,p=this.cropBox,l=p.width,c=p.height,d=p.left,g=p.top,u=d+l,f=g+c,m=0,v=0,w=n.width,x=n.height,C=!0;switch(!o&&t&&(o=l&&c?l/c:1),this.isLimited&&(m=p.minLeft,v=p.minTop,w=m+wt(n.width,r.width,r.left+r.width),x=v+wt(n.height,r.height,r.top+r.height)),s={x:this.endX-this.startX,y:this.endY-this.startY},o&&(s.X=s.y*o,s.Y=s.x/o),h){case lt:d+=s.x,g+=s.y;break;case et:if(s.x>=0&&(u>=w||o&&(g<=v||f>=x))){C=!1;break}l+=s.x,o&&(c=l/o,g-=s.Y/2),l<0&&(h=st,l=0);break;case ot:if(s.y<=0&&(g<=v||o&&(d<=m||u>=w))){C=!1;break}c-=s.y,g+=s.y,o&&(l=c*o,d+=s.X/2),c<0&&(h=at,c=0);break;case st:if(s.x<=0&&(d<=m||o&&(g<=v||f>=x))){C=!1;break}l-=s.x,d+=s.x,o&&(c=l/o,g+=s.Y/2),l<0&&(h=et,l=0);break;case at:if(s.y>=0&&(f>=x||o&&(d<=m||u>=w))){C=!1;break}c+=s.y,o&&(l=c*o,d-=s.X/2),c<0&&(h=ot,c=0);break;case rt:if(o){if(s.y<=0&&(g<=v||u>=w)){C=!1;break}c-=s.y,g+=s.y,l=c*o}else s.x>=0?uv&&(c-=s.y,g+=s.y):(c-=s.y,g+=s.y);l<0&&c<0?(h=nt,c=0,l=0):l<0?(h=pt,l=0):c<0&&(h=ht,c=0);break;case pt:if(o){if(s.y<=0&&(g<=v||d<=m)){C=!1;break}c-=s.y,g+=s.y,l=c*o,d+=s.X}else s.x<=0?d>m?(l-=s.x,d+=s.x):s.y<=0&&g<=v&&(C=!1):(l-=s.x,d+=s.x),s.y<=0?g>v&&(c-=s.y,g+=s.y):(c-=s.y,g+=s.y);l<0&&c<0?(h=ht,c=0,l=0):l<0?(h=rt,l=0):c<0&&(h=nt,c=0);break;case nt:if(o){if(s.x<=0&&(d<=m||f>=x)){C=!1;break}l-=s.x,d+=s.x,c=l/o}else s.x<=0?d>m?(l-=s.x,d+=s.x):s.y>=0&&f>=x&&(C=!1):(l-=s.x,d+=s.x),s.y>=0?f=0&&(u>=w||f>=x)){C=!1;break}l+=s.x,c=l/o}else s.x>=0?u=0&&f>=x&&(C=!1):l+=s.x,s.y>=0?f0?h=s.y>0?ht:rt:s.x<0&&(d-=l,h=s.y>0?nt:pt),s.y<0&&(g-=c),this.isCropped||(this.$cropBox.removeClass(Y),this.isCropped=!0,this.isLimited&&this.limitCropBox(!0,!0))}C&&(p.width=l,p.height=c,p.left=d,p.top=g,this.action=h,this.renderCropBox()),this.startX=this.endX,this.startY=this.endY},crop:function(){this.isBuilt&&!this.isDisabled&&(this.isCropped||(this.isCropped=!0,this.limitCropBox(!0,!0),this.options.modal&&this.$dragBox.addClass(T),this.$cropBox.removeClass(Y)),this.setCropBoxData(this.initialCropBox))},reset:function(){this.isBuilt&&!this.isDisabled&&(this.image=t.extend({},this.initialImage),this.canvas=t.extend({},this.initialCanvas),this.cropBox=t.extend({},this.initialCropBox),this.renderCanvas(),this.isCropped&&this.renderCropBox())},clear:function(){this.isCropped&&!this.isDisabled&&(t.extend(this.cropBox,{left:0,top:0,width:0,height:0}),this.isCropped=!1,this.renderCropBox(),this.limitCanvas(!0,!0),this.renderCanvas(),this.$dragBox.removeClass(T),this.$cropBox.addClass(Y))},replace:function(t,i){!this.isDisabled&&t&&(this.isImg&&this.$element.attr("src",t),i?(this.url=t,this.$clone.attr("src",t),this.isBuilt&&this.$preview.find("img").add(this.$clone2).attr("src",t)):(this.isImg&&(this.isReplaced=!0),this.options.data=null,this.load(t)))},enable:function(){this.isBuilt&&(this.isDisabled=!1,this.$cropper.removeClass(H))},disable:function(){this.isBuilt&&(this.isDisabled=!0,this.$cropper.addClass(H))},destroy:function(){var t=this.$element;this.isLoaded?(this.isImg&&this.isReplaced&&t.attr("src",this.originalUrl),this.unbuild(),t.removeClass(Y)):this.isImg?t.off(I,this.start):this.$clone&&this.$clone.remove(),t.removeData(L)},move:function(t,i){var s=this.canvas;this.moveTo(e(t)?t:s.left+vt(t),e(i)?i:s.top+vt(i))},moveTo:function(t,s){var a=this.canvas,o=!1;e(s)&&(s=t),t=vt(t),s=vt(s),this.isBuilt&&!this.isDisabled&&this.options.movable&&(i(t)&&(a.left=t,o=!0),i(s)&&(a.top=s,o=!0),o&&this.renderCanvas(!0))},zoom:function(t,i){var e=this.canvas;t=vt(t),t=t<0?1/(1-t):1+t,this.zoomTo(e.width*t/e.naturalWidth,i)},zoomTo:function(t,i){var e,s,a,o,h,n=this.options,r=this.canvas,p=r.width,l=r.height,c=r.naturalWidth,g=r.naturalHeight;if(t=vt(t),t>=0&&this.isBuilt&&!this.isDisabled&&n.zoomable){if(s=c*t,a=g*t,i&&(e=i.originalEvent),this.trigger(Z,{originalEvent:e,oldRatio:p/c,ratio:s/c}).isDefaultPrevented())return;e?(o=this.$cropper.offset(),h=e.touches?d(e.touches):{pageX:i.pageX||e.pageX||0,pageY:i.pageY||e.pageY||0},r.left-=(s-p)*((h.pageX-o.left-r.left)/p),r.top-=(a-l)*((h.pageY-o.top-r.top)/l)):(r.left-=(s-p)/2,r.top-=(a-l)/2),r.width=s,r.height=a,this.renderCanvas(!0)}},rotate:function(t){this.rotateTo((this.image.rotate||0)+vt(t))},rotateTo:function(t){t=vt(t),i(t)&&this.isBuilt&&!this.isDisabled&&this.options.rotatable&&(this.image.rotate=t%360,this.isRotated=!0,this.renderCanvas(!0))},scale:function(t,s){var a=this.image,o=!1;e(s)&&(s=t),t=vt(t),s=vt(s),this.isBuilt&&!this.isDisabled&&this.options.scalable&&(i(t)&&(a.scaleX=t,o=!0),i(s)&&(a.scaleY=s,o=!0),o&&this.renderImage(!0))},scaleX:function(t){var e=this.image.scaleY;this.scale(t,i(e)?e:1)},scaleY:function(t){var e=this.image.scaleX;this.scale(i(e)?e:1,t)},getData:function(i){var e,s,a=this.options,o=this.image,h=this.canvas,n=this.cropBox;return this.isBuilt&&this.isCropped?(s={x:n.left-h.left,y:n.top-h.top,width:n.width,height:n.height},e=o.width/o.naturalWidth,t.each(s,function(t,a){a/=e,s[t]=i?Dt(a):a})):s={x:0,y:0,width:0,height:0},a.rotatable&&(s.rotate=o.rotate||0),a.scalable&&(s.scaleX=o.scaleX||1,s.scaleY=o.scaleY||1),s},setData:function(e){var s,a,o,h=this.options,n=this.image,r=this.canvas,p={};t.isFunction(e)&&(e=e.call(this.element)),this.isBuilt&&!this.isDisabled&&t.isPlainObject(e)&&(h.rotatable&&i(e.rotate)&&e.rotate!==n.rotate&&(n.rotate=e.rotate,this.isRotated=s=!0),h.scalable&&(i(e.scaleX)&&e.scaleX!==n.scaleX&&(n.scaleX=e.scaleX,a=!0),i(e.scaleY)&&e.scaleY!==n.scaleY&&(n.scaleY=e.scaleY,a=!0)),s?this.renderCanvas():a&&this.renderImage(),o=n.width/n.naturalWidth,i(e.x)&&(p.left=e.x*o+r.left),i(e.y)&&(p.top=e.y*o+r.top),i(e.width)&&(p.width=e.width*o),i(e.height)&&(p.height=e.height*o),this.setCropBoxData(p))},getContainerData:function(){return this.isBuilt?this.container:{}},getImageData:function(){return this.isLoaded?this.image:{}},getCanvasData:function(){var i=this.canvas,e={};return this.isBuilt&&t.each(["left","top","width","height","naturalWidth","naturalHeight"],function(t,s){e[s]=i[s]}),e},setCanvasData:function(e){var s=this.canvas,a=s.aspectRatio;t.isFunction(e)&&(e=e.call(this.$element)),this.isBuilt&&!this.isDisabled&&t.isPlainObject(e)&&(i(e.left)&&(s.left=e.left),i(e.top)&&(s.top=e.top),i(e.width)?(s.width=e.width,s.height=e.width/a):i(e.height)&&(s.height=e.height,s.width=e.height*a),this.renderCanvas(!0))},getCropBoxData:function(){var t,i=this.cropBox;return this.isBuilt&&this.isCropped&&(t={left:i.left,top:i.top,width:i.width,height:i.height}),t||{}},setCropBoxData:function(e){var s,a,o=this.cropBox,h=this.options.aspectRatio;t.isFunction(e)&&(e=e.call(this.$element)),this.isBuilt&&this.isCropped&&!this.isDisabled&&t.isPlainObject(e)&&(i(e.left)&&(o.left=e.left),i(e.top)&&(o.top=e.top),i(e.width)&&(s=!0,o.width=e.width),i(e.height)&&(a=!0,o.height=e.height),h&&(s?o.height=o.width/h:a&&(o.width=o.height*h)),this.renderCropBox())},getCroppedCanvas:function(i){var e,s,a,o,h,n,r,p,l,d,g;if(this.isBuilt&&ft)return this.isCropped?(t.isPlainObject(i)||(i={}),g=this.getData(),e=g.width,s=g.height,p=e/s,t.isPlainObject(i)&&(h=i.width,n=i.height,h?(n=h/p,r=h/e):n&&(h=n*p,r=n/s)),a=$t(h||e),o=$t(n||s),l=t("")[0],l.width=a,l.height=o,d=l.getContext("2d"),i.fillColor&&(d.fillStyle=i.fillColor,d.fillRect(0,0,a,o)),d.drawImage.apply(d,function(){var t,i,a,o,h,n,p=c(this.$clone[0],this.image),l=p.width,d=p.height,u=this.canvas,f=[p],m=g.x+u.naturalWidth*(Ct(g.scaleX||1)-1)/2,v=g.y+u.naturalHeight*(Ct(g.scaleY||1)-1)/2;return m<=-e||m>l?m=t=a=h=0:m<=0?(a=-m,m=0,t=h=wt(l,e+m)):m<=l&&(a=0,t=h=wt(e,l-m)),t<=0||v<=-s||v>d?v=i=o=n=0:v<=0?(o=-v,v=0,i=n=wt(d,s+v)):v<=d&&(o=0,i=n=wt(s,d-v)),f.push($t(m),$t(v),$t(t),$t(i)),r&&(a*=r,o*=r,h*=r,n*=r),h>0&&n>0&&f.push($t(a),$t(o),$t(h),$t(n)),f}.call(this)),l):c(this.$clone[0],this.image)},setAspectRatio:function(t){var i=this.options;this.isDisabled||e(t)||(i.aspectRatio=xt(0,t)||NaN,this.isBuilt&&(this.initCropBox(),this.isCropped&&this.renderCropBox()))},setDragMode:function(t){var i,e,s=this.options;this.isLoaded&&!this.isDisabled&&(i=t===ct,e=s.movable&&t===dt,t=i||e?t:ut,this.$dragBox.data(it,t).toggleClass(W,i).toggleClass(M,e),s.cropBoxMovable||this.$face.data(it,t).toggleClass(W,i).toggleClass(M,e))}},v.DEFAULTS={viewMode:0,dragMode:"crop",aspectRatio:NaN,data:null,preview:"",responsive:!0,restore:!0,checkCrossOrigin:!0,checkOrientation:!0,modal:!0,guides:!0,center:!0,highlight:!0,background:!0,autoCrop:!0,autoCropArea:.8,movable:!0,rotatable:!0,scalable:!0,zoomable:!0,zoomOnTouch:!0,zoomOnWheel:!0,wheelZoomRatio:.1,cropBoxMovable:!0,cropBoxResizable:!0,toggleDragModeOnDblclick:!0,minCanvasWidth:0,minCanvasHeight:0,minCropBoxWidth:0,minCropBoxHeight:0,minContainerWidth:200,minContainerHeight:100,build:null,built:null,cropstart:null,cropmove:null,cropend:null,crop:null,zoom:null},v.setDefaults=function(i){t.extend(v.DEFAULTS,i)},v.TEMPLATE='
',v.other=t.fn.cropper,t.fn.cropper=function(i){var a,o=s(arguments,1);return this.each(function(){var e,s,h=t(this),n=h.data(L);if(!n){if(/destroy/.test(i))return;e=t.extend({},h.data(),t.isPlainObject(i)&&i),h.data(L,n=new v(this,e))}"string"==typeof i&&t.isFunction(s=n[i])&&(a=s.apply(n,o))}),e(a)?this:a},t.fn.cropper.Constructor=v,t.fn.cropper.setDefaults=v.setDefaults,t.fn.cropper.noConflict=function(){return t.fn.cropper=v.other,this}}); \ No newline at end of file +!function(t){"function"==typeof define&&define.amd?define(["jquery"],t):t("object"==typeof exports?require("jquery"):jQuery)}(function(t){"use strict";function i(t){return"number"==typeof t&&!isNaN(t)}function e(t){return"undefined"==typeof t}function s(t,e){var s=[];return i(e)&&s.push(e),s.slice.apply(t,s)}function a(t,i){var e=s(arguments,2);return function(){return t.apply(i,e.concat(s(arguments)))}}function o(t){var i=t.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i);return i&&(i[1]!==C.protocol||i[2]!==C.hostname||i[3]!==C.port)}function h(t){var i="timestamp="+(new Date).getTime();return t+(t.indexOf("?")===-1?"?":"&")+i}function n(t){return t?' crossOrigin="'+t+'"':""}function r(t,i){var e;return t.naturalWidth&&!mt?i(t.naturalWidth,t.naturalHeight):(e=document.createElement("img"),e.onload=function(){i(this.width,this.height)},void(e.src=t.src))}function p(t){var e=[],s=t.rotate,a=t.scaleX,o=t.scaleY;return i(s)&&0!==s&&e.push("rotate("+s+"deg)"),i(a)&&1!==a&&e.push("scaleX("+a+")"),i(o)&&1!==o&&e.push("scaleY("+o+")"),e.length?e.join(" "):"none"}function l(t,i){var e,s,a=Ct(t.degree)%180,o=(a>90?180-a:a)*Math.PI/180,h=bt(o),n=Bt(o),r=t.width,p=t.height,l=t.aspectRatio;return i?(e=r/(n+h/l),s=e/l):(e=r*n+p*h,s=r*h+p*n),{width:e,height:s}}function c(e,s){var a,o,h,n=t("")[0],r=n.getContext("2d"),p=0,c=0,d=s.naturalWidth,g=s.naturalHeight,u=s.rotate,f=s.scaleX,m=s.scaleY,v=i(f)&&i(m)&&(1!==f||1!==m),w=i(u)&&0!==u,x=w||v,C=d*Ct(f||1),b=g*Ct(m||1);return v&&(a=C/2,o=b/2),w&&(h=l({width:C,height:b,degree:u}),C=h.width,b=h.height,a=C/2,o=b/2),n.width=C,n.height=b,x&&(p=-d/2,c=-g/2,r.save(),r.translate(a,o)),w&&r.rotate(u*Math.PI/180),v&&r.scale(f,m),r.drawImage(e,$t(p),$t(c),$t(d),$t(g)),x&&r.restore(),n}function d(i){var e=i.length,s=0,a=0;return e&&(t.each(i,function(t,i){s+=i.pageX,a+=i.pageY}),s/=e,a/=e),{pageX:s,pageY:a}}function g(t,i,e){var s,a="";for(s=i,e+=i;s=8&&(r=s+a)))),r)for(d=c.getUint16(r,o),l=0;l")[0].getContext),mt=b&&/(Macintosh|iPhone|iPod|iPad).*AppleWebKit/i.test(b.userAgent),vt=Number,wt=Math.min,xt=Math.max,Ct=Math.abs,bt=Math.sin,Bt=Math.cos,yt=Math.sqrt,Dt=Math.round,$t=Math.floor,Lt=String.fromCharCode;v.prototype={constructor:v,init:function(){var t,i=this.$element;if(i.is("img")){if(this.isImg=!0,this.originalUrl=t=i.attr("src"),!t)return;t=i.prop("src")}else i.is("canvas")&&ft&&(t=i[0].toDataURL());this.load(t)},trigger:function(i,e){var s=t.Event(i,e);return this.$element.trigger(s),s},load:function(i){var e,s,a=this.options,n=this.$element;if(i&&(n.one(A,a.build),!this.trigger(A).isDefaultPrevented())){if(this.url=i,this.image={},!a.checkOrientation||!B)return this.clone();if(e=t.proxy(this.read,this),V.test(i))return J.test(i)?e(f(i)):this.clone();s=new XMLHttpRequest,s.onerror=s.onabort=t.proxy(function(){this.clone()},this),s.onload=function(){e(this.response)},a.checkCrossOrigin&&o(i)&&n.prop("crossOrigin")&&(i=h(i)),s.open("get",i),s.responseType="arraybuffer",s.send()}},read:function(t){var i=this.options,e=u(t),s=this.image,a=0,o=1,h=1;if(e>1)switch(this.url=m(t),e){case 2:o=-1;break;case 3:a=-180;break;case 4:h=-1;break;case 5:a=90,h=-1;break;case 6:a=90;break;case 7:a=90,o=-1;break;case 8:a=-90}i.rotatable&&(s.rotate=a),i.scalable&&(s.scaleX=o,s.scaleY=h),this.clone()},clone:function(){var i,e,s=this.options,a=this.$element,r=this.url,p="";s.checkCrossOrigin&&o(r)&&(p=a.prop("crossOrigin"),p?i=r:(p="anonymous",i=h(r))),this.crossOrigin=p,this.crossOriginUrl=i,this.$clone=e=t("'),this.isImg?a[0].complete?this.start():a.one(I,t.proxy(this.start,this)):e.one(I,t.proxy(this.start,this)).one(F,t.proxy(this.stop,this)).addClass(X).insertAfter(a)},start:function(){var i=this.$element,e=this.$clone;this.isImg||(e.off(F,this.stop),i=e),r(i[0],t.proxy(function(i,e){t.extend(this.image,{naturalWidth:i,naturalHeight:e,aspectRatio:i/e}),this.isLoaded=!0,this.build()},this))},stop:function(){this.$clone.remove(),this.$clone=null},build:function(){var i,e,s,a=this.options,o=this.$element,h=this.$clone;this.isLoaded&&(this.isBuilt&&this.unbuild(),this.$container=o.parent(),this.$cropper=i=t(v.TEMPLATE),this.$canvas=i.find(".cropper-canvas").append(h),this.$dragBox=i.find(".cropper-drag-box"),this.$cropBox=e=i.find(".cropper-crop-box"),this.$viewBox=i.find(".cropper-view-box"),this.$face=s=e.find(".cropper-face"),o.addClass(Y).after(i),this.isImg||h.removeClass(X),this.initPreview(),this.bind(),a.aspectRatio=xt(0,a.aspectRatio)||NaN,a.viewMode=xt(0,wt(3,Dt(a.viewMode)))||0,a.autoCrop?(this.isCropped=!0,a.modal&&this.$dragBox.addClass(T)):e.addClass(Y),a.guides||e.find(".cropper-dashed").addClass(Y),a.center||e.find(".cropper-center").addClass(Y),a.cropBoxMovable&&s.addClass(M).data(it,lt),a.highlight||s.addClass(k),a.background&&i.addClass(R),a.cropBoxResizable||e.find(".cropper-line, .cropper-point").addClass(Y),this.setDragMode(a.dragMode),this.render(),this.isBuilt=!0,this.setData(a.data),o.one(S,a.built),this.completing=setTimeout(t.proxy(function(){this.trigger(S),this.trigger(K,this.getData()),this.isCompleted=!0},this),0))},unbuild:function(){this.isBuilt&&(this.isCompleted||clearTimeout(this.completing),this.isBuilt=!1,this.isCompleted=!1,this.initialImage=null,this.initialCanvas=null,this.initialCropBox=null,this.container=null,this.canvas=null,this.cropBox=null,this.unbind(),this.resetPreview(),this.$preview=null,this.$viewBox=null,this.$cropBox=null,this.$dragBox=null,this.$canvas=null,this.$container=null,this.$cropper.remove(),this.$cropper=null)},render:function(){this.initContainer(),this.initCanvas(),this.initCropBox(),this.renderCanvas(),this.isCropped&&this.renderCropBox()},initContainer:function(){var t=this.options,i=this.$element,e=this.$container,s=this.$cropper;s.addClass(Y),i.removeClass(Y),s.css(this.container={width:xt(e.width(),vt(t.minContainerWidth)||200),height:xt(e.height(),vt(t.minContainerHeight)||100)}),i.addClass(Y),s.removeClass(Y)},initCanvas:function(){var i,e=this.options.viewMode,s=this.container,a=s.width,o=s.height,h=this.image,n=h.naturalWidth,r=h.naturalHeight,p=90===Ct(h.rotate),l=p?r:n,c=p?n:r,d=l/c,g=a,u=o;o*d>a?3===e?g=o*d:u=a/d:3===e?u=a/d:g=o*d,i={naturalWidth:l,naturalHeight:c,aspectRatio:d,width:g,height:u},i.oldLeft=i.left=(a-g)/2,i.oldTop=i.top=(o-u)/2,this.canvas=i,this.isLimited=1===e||2===e,this.limitCanvas(!0,!0),this.initialImage=t.extend({},h),this.initialCanvas=t.extend({},i)},limitCanvas:function(t,i){var e,s,a,o,h=this.options,n=h.viewMode,r=this.container,p=r.width,l=r.height,c=this.canvas,d=c.aspectRatio,g=this.cropBox,u=this.isCropped&&g;t&&(e=vt(h.minCanvasWidth)||0,s=vt(h.minCanvasHeight)||0,n&&(n>1?(e=xt(e,p),s=xt(s,l),3===n&&(s*d>e?e=s*d:s=e/d)):e?e=xt(e,u?g.width:0):s?s=xt(s,u?g.height:0):u&&(e=g.width,s=g.height,s*d>e?e=s*d:s=e/d)),e&&s?s*d>e?s=e/d:e=s*d:e?s=e/d:s&&(e=s*d),c.minWidth=e,c.minHeight=s,c.maxWidth=1/0,c.maxHeight=1/0),i&&(n?(a=p-c.width,o=l-c.height,c.minLeft=wt(0,a),c.minTop=wt(0,o),c.maxLeft=xt(0,a),c.maxTop=xt(0,o),u&&this.isLimited&&(c.minLeft=wt(g.left,g.left+g.width-c.width),c.minTop=wt(g.top,g.top+g.height-c.height),c.maxLeft=g.left,c.maxTop=g.top,2===n&&(c.width>=p&&(c.minLeft=wt(0,a),c.maxLeft=xt(0,a)),c.height>=l&&(c.minTop=wt(0,o),c.maxTop=xt(0,o))))):(c.minLeft=-c.width,c.minTop=-c.height,c.maxLeft=p,c.maxTop=l))},renderCanvas:function(t){var i,e,s=this.canvas,a=this.image,o=a.rotate,h=a.naturalWidth,n=a.naturalHeight;this.isRotated&&(this.isRotated=!1,e=l({width:a.width,height:a.height,degree:o}),i=e.width/e.height,i!==s.aspectRatio&&(s.left-=(e.width-s.width)/2,s.top-=(e.height-s.height)/2,s.width=e.width,s.height=e.height,s.aspectRatio=i,s.naturalWidth=h,s.naturalHeight=n,o%180&&(e=l({width:h,height:n,degree:o}),s.naturalWidth=e.width,s.naturalHeight=e.height),this.limitCanvas(!0,!1))),(s.width>s.maxWidth||s.widths.maxHeight||s.heighte.width?o.height=o.width/s:o.width=o.height*s),this.cropBox=o,this.limitCropBox(!0,!0),o.width=wt(xt(o.width,o.minWidth),o.maxWidth),o.height=wt(xt(o.height,o.minHeight),o.maxHeight),o.width=xt(o.minWidth,o.width*a),o.height=xt(o.minHeight,o.height*a),o.oldLeft=o.left=e.left+(e.width-o.width)/2,o.oldTop=o.top=e.top+(e.height-o.height)/2,this.initialCropBox=t.extend({},o)},limitCropBox:function(t,i){var e,s,a,o,h=this.options,n=h.aspectRatio,r=this.container,p=r.width,l=r.height,c=this.canvas,d=this.cropBox,g=this.isLimited;t&&(e=vt(h.minCropBoxWidth)||0,s=vt(h.minCropBoxHeight)||0,e=wt(e,p),s=wt(s,l),a=wt(p,g?c.width:p),o=wt(l,g?c.height:l),n&&(e&&s?s*n>e?s=e/n:e=s*n:e?s=e/n:s&&(e=s*n),o*n>a?o=a/n:a=o*n),d.minWidth=wt(e,a),d.minHeight=wt(s,o),d.maxWidth=a,d.maxHeight=o),i&&(g?(d.minLeft=xt(0,c.left),d.minTop=xt(0,c.top),d.maxLeft=wt(p,c.left+c.width)-d.width,d.maxTop=wt(l,c.top+c.height)-d.height):(d.minLeft=0,d.minTop=0,d.maxLeft=p-d.width,d.maxTop=l-d.height))},renderCropBox:function(){var t=this.options,i=this.container,e=i.width,s=i.height,a=this.cropBox;(a.width>a.maxWidth||a.widtha.maxHeight||a.height'),this.$viewBox.html(i),this.$preview.each(function(){var i=t(this);i.data(tt,{width:i.width(),height:i.height(),html:i.html()}),i.html("')})},resetPreview:function(){this.$preview.each(function(){var i=t(this),e=i.data(tt);i.css({width:e.width,height:e.height}).html(e.html).removeData(tt)})},preview:function(){var i=this.image,e=this.canvas,s=this.cropBox,a=s.width,o=s.height,h=i.width,n=i.height,r=s.left-e.left-i.left,l=s.top-e.top-i.top;this.isCropped&&!this.isDisabled&&(this.$clone2.css({width:h,height:n,marginLeft:-r,marginTop:-l,transform:p(i)}),this.$preview.each(function(){var e=t(this),s=e.data(tt),c=s.width,d=s.height,g=c,u=d,f=1;a&&(f=c/a,u=o*f),o&&u>d&&(f=d/o,g=a*f,u=d),e.css({width:g,height:u}).find("img").css({width:h*f,height:n*f,marginLeft:-r*f,marginTop:-l*f,transform:p(i)})}))},bind:function(){var i=this.options,e=this.$element,s=this.$cropper;t.isFunction(i.cropstart)&&e.on(N,i.cropstart),t.isFunction(i.cropmove)&&e.on(_,i.cropmove),t.isFunction(i.cropend)&&e.on(q,i.cropend),t.isFunction(i.crop)&&e.on(K,i.crop),t.isFunction(i.zoom)&&e.on(Z,i.zoom),s.on(z,t.proxy(this.cropStart,this)),i.zoomable&&i.zoomOnWheel&&s.on(E,t.proxy(this.wheel,this)),i.toggleDragModeOnDblclick&&s.on(U,t.proxy(this.dblclick,this)),x.on(O,this._cropMove=a(this.cropMove,this)).on(P,this._cropEnd=a(this.cropEnd,this)),i.responsive&&w.on(j,this._resize=a(this.resize,this))},unbind:function(){var i=this.options,e=this.$element,s=this.$cropper;t.isFunction(i.cropstart)&&e.off(N,i.cropstart),t.isFunction(i.cropmove)&&e.off(_,i.cropmove),t.isFunction(i.cropend)&&e.off(q,i.cropend),t.isFunction(i.crop)&&e.off(K,i.crop),t.isFunction(i.zoom)&&e.off(Z,i.zoom),s.off(z,this.cropStart),i.zoomable&&i.zoomOnWheel&&s.off(E,this.wheel),i.toggleDragModeOnDblclick&&s.off(U,this.dblclick),x.off(O,this._cropMove).off(P,this._cropEnd),i.responsive&&w.off(j,this._resize)},resize:function(){var i,e,s,a=this.options.restore,o=this.$container,h=this.container;!this.isDisabled&&h&&(s=o.width()/h.width,1===s&&o.height()===h.height||(a&&(i=this.getCanvasData(),e=this.getCropBoxData()),this.render(),a&&(this.setCanvasData(t.each(i,function(t,e){i[t]=e*s})),this.setCropBoxData(t.each(e,function(t,i){e[t]=i*s})))))},dblclick:function(){this.isDisabled||(this.$dragBox.hasClass(W)?this.setDragMode(dt):this.setDragMode(ct))},wheel:function(i){var e=i.originalEvent||i,s=vt(this.options.wheelZoomRatio)||.1,a=1;this.isDisabled||(i.preventDefault(),this.wheeling||(this.wheeling=!0,setTimeout(t.proxy(function(){this.wheeling=!1},this),50),e.deltaY?a=e.deltaY>0?1:-1:e.wheelDelta?a=-e.wheelDelta/120:e.detail&&(a=e.detail>0?1:-1),this.zoom(-a*s,i)))},cropStart:function(i){var e,s,a=this.options,o=i.originalEvent,h=o&&o.touches,n=i;if(!this.isDisabled){if(h){if(e=h.length,e>1){if(!a.zoomable||!a.zoomOnTouch||2!==e)return;n=h[1],this.startX2=n.pageX,this.startY2=n.pageY,s=gt}n=h[0]}if(s=s||t(n.target).data(it),Q.test(s)){if(this.trigger(N,{originalEvent:o,action:s}).isDefaultPrevented())return;i.preventDefault(),this.action=s,this.cropping=!1,this.startX=n.pageX||o&&o.pageX,this.startY=n.pageY||o&&o.pageY,s===ct&&(this.cropping=!0,this.$dragBox.addClass(T))}}},cropMove:function(t){var i,e=this.options,s=t.originalEvent,a=s&&s.touches,o=t,h=this.action;if(!this.isDisabled){if(a){if(i=a.length,i>1){if(!e.zoomable||!e.zoomOnTouch||2!==i)return;o=a[1],this.endX2=o.pageX,this.endY2=o.pageY}o=a[0]}if(h){if(this.trigger(_,{originalEvent:s,action:h}).isDefaultPrevented())return;t.preventDefault(),this.endX=o.pageX||s&&s.pageX,this.endY=o.pageY||s&&s.pageY,this.change(o.shiftKey,h===gt?t:null)}}},cropEnd:function(t){var i=t.originalEvent,e=this.action;this.isDisabled||e&&(t.preventDefault(),this.cropping&&(this.cropping=!1,this.$dragBox.toggleClass(T,this.isCropped&&this.options.modal)),this.action="",this.trigger(q,{originalEvent:i,action:e}))},change:function(t,i){var e,s,a=this.options,o=a.aspectRatio,h=this.action,n=this.container,r=this.canvas,p=this.cropBox,l=p.width,c=p.height,d=p.left,g=p.top,u=d+l,f=g+c,m=0,v=0,w=n.width,x=n.height,C=!0;switch(!o&&t&&(o=l&&c?l/c:1),this.isLimited&&(m=p.minLeft,v=p.minTop,w=m+wt(n.width,r.width,r.left+r.width),x=v+wt(n.height,r.height,r.top+r.height)),s={x:this.endX-this.startX,y:this.endY-this.startY},o&&(s.X=s.y*o,s.Y=s.x/o),h){case lt:d+=s.x,g+=s.y;break;case et:if(s.x>=0&&(u>=w||o&&(g<=v||f>=x))){C=!1;break}l+=s.x,o&&(c=l/o,g-=s.Y/2),l<0&&(h=st,l=0);break;case ot:if(s.y<=0&&(g<=v||o&&(d<=m||u>=w))){C=!1;break}c-=s.y,g+=s.y,o&&(l=c*o,d+=s.X/2),c<0&&(h=at,c=0);break;case st:if(s.x<=0&&(d<=m||o&&(g<=v||f>=x))){C=!1;break}l-=s.x,d+=s.x,o&&(c=l/o,g+=s.Y/2),l<0&&(h=et,l=0);break;case at:if(s.y>=0&&(f>=x||o&&(d<=m||u>=w))){C=!1;break}c+=s.y,o&&(l=c*o,d-=s.X/2),c<0&&(h=ot,c=0);break;case rt:if(o){if(s.y<=0&&(g<=v||u>=w)){C=!1;break}c-=s.y,g+=s.y,l=c*o}else s.x>=0?uv&&(c-=s.y,g+=s.y):(c-=s.y,g+=s.y);l<0&&c<0?(h=nt,c=0,l=0):l<0?(h=pt,l=0):c<0&&(h=ht,c=0);break;case pt:if(o){if(s.y<=0&&(g<=v||d<=m)){C=!1;break}c-=s.y,g+=s.y,l=c*o,d+=s.X}else s.x<=0?d>m?(l-=s.x,d+=s.x):s.y<=0&&g<=v&&(C=!1):(l-=s.x,d+=s.x),s.y<=0?g>v&&(c-=s.y,g+=s.y):(c-=s.y,g+=s.y);l<0&&c<0?(h=ht,c=0,l=0):l<0?(h=rt,l=0):c<0&&(h=nt,c=0);break;case nt:if(o){if(s.x<=0&&(d<=m||f>=x)){C=!1;break}l-=s.x,d+=s.x,c=l/o}else s.x<=0?d>m?(l-=s.x,d+=s.x):s.y>=0&&f>=x&&(C=!1):(l-=s.x,d+=s.x),s.y>=0?f=0&&(u>=w||f>=x)){C=!1;break}l+=s.x,c=l/o}else s.x>=0?u=0&&f>=x&&(C=!1):l+=s.x,s.y>=0?f0?h=s.y>0?ht:rt:s.x<0&&(d-=l,h=s.y>0?nt:pt),s.y<0&&(g-=c),this.isCropped||(this.$cropBox.removeClass(Y),this.isCropped=!0,this.isLimited&&this.limitCropBox(!0,!0))}C&&(p.width=l,p.height=c,p.left=d,p.top=g,this.action=h,this.renderCropBox()),this.startX=this.endX,this.startY=this.endY},crop:function(){this.isBuilt&&!this.isDisabled&&(this.isCropped||(this.isCropped=!0,this.limitCropBox(!0,!0),this.options.modal&&this.$dragBox.addClass(T),this.$cropBox.removeClass(Y)),this.setCropBoxData(this.initialCropBox))},reset:function(){this.isBuilt&&!this.isDisabled&&(this.image=t.extend({},this.initialImage),this.canvas=t.extend({},this.initialCanvas),this.cropBox=t.extend({},this.initialCropBox),this.renderCanvas(),this.isCropped&&this.renderCropBox())},clear:function(){this.isCropped&&!this.isDisabled&&(t.extend(this.cropBox,{left:0,top:0,width:0,height:0}),this.isCropped=!1,this.renderCropBox(),this.limitCanvas(!0,!0),this.renderCanvas(),this.$dragBox.removeClass(T),this.$cropBox.addClass(Y))},replace:function(t,i){!this.isDisabled&&t&&(this.isImg&&this.$element.attr("src",t),i?(this.url=t,this.$clone.attr("src",t),this.isBuilt&&this.$preview.find("img").add(this.$clone2).attr("src",t)):(this.isImg&&(this.isReplaced=!0),this.options.data=null,this.load(t)))},enable:function(){this.isBuilt&&(this.isDisabled=!1,this.$cropper.removeClass(H))},disable:function(){this.isBuilt&&(this.isDisabled=!0,this.$cropper.addClass(H))},destroy:function(){var t=this.$element;this.isLoaded?(this.isImg&&this.isReplaced&&t.attr("src",this.originalUrl),this.unbuild(),t.removeClass(Y)):this.isImg?t.off(I,this.start):this.$clone&&this.$clone.remove(),t.removeData(L)},move:function(t,i){var s=this.canvas;this.moveTo(e(t)?t:s.left+vt(t),e(i)?i:s.top+vt(i))},moveTo:function(t,s){var a=this.canvas,o=!1;e(s)&&(s=t),t=vt(t),s=vt(s),this.isBuilt&&!this.isDisabled&&this.options.movable&&(i(t)&&(a.left=t,o=!0),i(s)&&(a.top=s,o=!0),o&&this.renderCanvas(!0))},zoom:function(t,i){var e=this.canvas;t=vt(t),t=t<0?1/(1-t):1+t,this.zoomTo(e.width*t/e.naturalWidth,i)},zoomTo:function(t,i){var e,s,a,o,h,n=this.options,r=this.canvas,p=r.width,l=r.height,c=r.naturalWidth,g=r.naturalHeight;if(t=vt(t),t>=0&&this.isBuilt&&!this.isDisabled&&n.zoomable){if(s=c*t,a=g*t,i&&(e=i.originalEvent),this.trigger(Z,{originalEvent:e,oldRatio:p/c,ratio:s/c}).isDefaultPrevented())return;e?(o=this.$cropper.offset(),h=e.touches?d(e.touches):{pageX:i.pageX||e.pageX||0,pageY:i.pageY||e.pageY||0},r.left-=(s-p)*((h.pageX-o.left-r.left)/p),r.top-=(a-l)*((h.pageY-o.top-r.top)/l)):(r.left-=(s-p)/2,r.top-=(a-l)/2),r.width=s,r.height=a,this.renderCanvas(!0)}},rotate:function(t){this.rotateTo((this.image.rotate||0)+vt(t))},rotateTo:function(t){t=vt(t),i(t)&&this.isBuilt&&!this.isDisabled&&this.options.rotatable&&(this.image.rotate=t%360,this.isRotated=!0,this.renderCanvas(!0))},scale:function(t,s){var a=this.image,o=!1;e(s)&&(s=t),t=vt(t),s=vt(s),this.isBuilt&&!this.isDisabled&&this.options.scalable&&(i(t)&&(a.scaleX=t,o=!0),i(s)&&(a.scaleY=s,o=!0),o&&this.renderImage(!0))},scaleX:function(t){var e=this.image.scaleY;this.scale(t,i(e)?e:1)},scaleY:function(t){var e=this.image.scaleX;this.scale(i(e)?e:1,t)},getData:function(i){var e,s,a=this.options,o=this.image,h=this.canvas,n=this.cropBox;return this.isBuilt&&this.isCropped?(s={x:n.left-h.left,y:n.top-h.top,width:n.width,height:n.height},e=o.width/o.naturalWidth,t.each(s,function(t,a){a/=e,s[t]=i?Dt(a):a})):s={x:0,y:0,width:0,height:0},a.rotatable&&(s.rotate=o.rotate||0),a.scalable&&(s.scaleX=o.scaleX||1,s.scaleY=o.scaleY||1),s},setData:function(e){var s,a,o,h=this.options,n=this.image,r=this.canvas,p={};t.isFunction(e)&&(e=e.call(this.element)),this.isBuilt&&!this.isDisabled&&t.isPlainObject(e)&&(h.rotatable&&i(e.rotate)&&e.rotate!==n.rotate&&(n.rotate=e.rotate,this.isRotated=s=!0),h.scalable&&(i(e.scaleX)&&e.scaleX!==n.scaleX&&(n.scaleX=e.scaleX,a=!0),i(e.scaleY)&&e.scaleY!==n.scaleY&&(n.scaleY=e.scaleY,a=!0)),s?this.renderCanvas():a&&this.renderImage(),o=n.width/n.naturalWidth,i(e.x)&&(p.left=e.x*o+r.left),i(e.y)&&(p.top=e.y*o+r.top),i(e.width)&&(p.width=e.width*o),i(e.height)&&(p.height=e.height*o),this.setCropBoxData(p))},getContainerData:function(){return this.isBuilt?this.container:{}},getImageData:function(){return this.isLoaded?this.image:{}},getCanvasData:function(){var i=this.canvas,e={};return this.isBuilt&&t.each(["left","top","width","height","naturalWidth","naturalHeight"],function(t,s){e[s]=i[s]}),e},setCanvasData:function(e){var s=this.canvas,a=s.aspectRatio;t.isFunction(e)&&(e=e.call(this.$element)),this.isBuilt&&!this.isDisabled&&t.isPlainObject(e)&&(i(e.left)&&(s.left=e.left),i(e.top)&&(s.top=e.top),i(e.width)?(s.width=e.width,s.height=e.width/a):i(e.height)&&(s.height=e.height,s.width=e.height*a),this.renderCanvas(!0))},getCropBoxData:function(){var t,i=this.cropBox;return this.isBuilt&&this.isCropped&&(t={left:i.left,top:i.top,width:i.width,height:i.height}),t||{}},setCropBoxData:function(e){var s,a,o=this.cropBox,h=this.options.aspectRatio;t.isFunction(e)&&(e=e.call(this.$element)),this.isBuilt&&this.isCropped&&!this.isDisabled&&t.isPlainObject(e)&&(i(e.left)&&(o.left=e.left),i(e.top)&&(o.top=e.top),i(e.width)&&(s=!0,o.width=e.width),i(e.height)&&(a=!0,o.height=e.height),h&&(s?o.height=o.width/h:a&&(o.width=o.height*h)),this.renderCropBox())},getCroppedCanvas:function(i){var e,s,a,o,h,n,r,p,l,d,g;if(this.isBuilt&&ft)return this.isCropped?(t.isPlainObject(i)||(i={}),g=this.getData(),e=g.width,s=g.height,p=e/s,t.isPlainObject(i)&&(h=i.width,n=i.height,h?(n=h/p,r=h/e):n&&(h=n*p,r=n/s)),a=$t(h||e),o=$t(n||s),l=t("")[0],l.width=a,l.height=o,d=l.getContext("2d"),i.fillColor&&(d.fillStyle=i.fillColor,d.fillRect(0,0,a,o)),d.drawImage.apply(d,function(){var t,i,a,o,h,n,p=c(this.$clone[0],this.image),l=p.width,d=p.height,u=this.canvas,f=[p],m=g.x+u.naturalWidth*(Ct(g.scaleX||1)-1)/2,v=g.y+u.naturalHeight*(Ct(g.scaleY||1)-1)/2;return m<=-e||m>l?m=t=a=h=0:m<=0?(a=-m,m=0,t=h=wt(l,e+m)):m<=l&&(a=0,t=h=wt(e,l-m)),t<=0||v<=-s||v>d?v=i=o=n=0:v<=0?(o=-v,v=0,i=n=wt(d,s+v)):v<=d&&(o=0,i=n=wt(s,d-v)),f.push($t(m),$t(v),$t(t),$t(i)),r&&(a*=r,o*=r,h*=r,n*=r),h>0&&n>0&&f.push($t(a),$t(o),$t(h),$t(n)),f}.call(this)),l):c(this.$clone[0],this.image)},setAspectRatio:function(t){var i=this.options;this.isDisabled||e(t)||(i.aspectRatio=xt(0,t)||NaN,this.isBuilt&&(this.initCropBox(),this.isCropped&&this.renderCropBox()))},setDragMode:function(t){var i,e,s=this.options;this.isLoaded&&!this.isDisabled&&(i=t===ct,e=s.movable&&t===dt,t=i||e?t:ut,this.$dragBox.data(it,t).toggleClass(W,i).toggleClass(M,e),s.cropBoxMovable||this.$face.data(it,t).toggleClass(W,i).toggleClass(M,e))}},v.DEFAULTS={viewMode:0,dragMode:"crop",aspectRatio:NaN,data:null,preview:"",responsive:!0,restore:!0,checkCrossOrigin:!0,checkOrientation:!0,modal:!0,guides:!0,center:!0,highlight:!0,background:!0,autoCrop:!0,autoCropArea:.8,movable:!0,rotatable:!0,scalable:!0,zoomable:!0,zoomOnTouch:!0,zoomOnWheel:!0,wheelZoomRatio:.1,cropBoxMovable:!0,cropBoxResizable:!0,toggleDragModeOnDblclick:!0,minCanvasWidth:0,minCanvasHeight:0,minCropBoxWidth:0,minCropBoxHeight:0,minContainerWidth:200,minContainerHeight:100,build:null,built:null,cropstart:null,cropmove:null,cropend:null,crop:null,zoom:null},v.setDefaults=function(i){t.extend(v.DEFAULTS,i)},v.TEMPLATE='
',v.other=t.fn.cropper,t.fn.cropper=function(i){var a,o=s(arguments,1);return this.each(function(){var e,s,h=t(this),n=h.data(L);if(!n){if(/destroy/.test(i))return;e=t.extend({},h.data(),t.isPlainObject(i)&&i),h.data(L,n=new v(this,e))}"string"==typeof i&&t.isFunction(s=n[i])&&(a=s.apply(n,o))}),e(a)?this:a},t.fn.cropper.Constructor=v,t.fn.cropper.setDefaults=v.setDefaults,t.fn.cropper.noConflict=function(){return t.fn.cropper=v.other,this}}); diff --git a/OpenOversight/app/static/js/dropzone.js b/OpenOversight/app/static/js/dropzone.js index f9ba2ddd0..0c008c8db 100644 --- a/OpenOversight/app/static/js/dropzone.js +++ b/OpenOversight/app/static/js/dropzone.js @@ -105,9 +105,9 @@ /* This is a list of all available events you can register on a dropzone object. - + You can register an event handler like this: - + dropzone.on("dragEnter", function() { }); */ @@ -1655,7 +1655,7 @@ /* - + Bugfix for iOS 6 and 7 Source: http://stackoverflow.com/questions/11929099/html5-canvas-drawimage-ratio-bug-ios based on the work of https://github.com/stomita/ios-imagefile-megapixel diff --git a/OpenOversight/app/static/js/find_officer.js b/OpenOversight/app/static/js/find_officer.js index 631003444..d226bdc98 100644 --- a/OpenOversight/app/static/js/find_officer.js +++ b/OpenOversight/app/static/js/find_officer.js @@ -1,89 +1,106 @@ -$(document).ready(function() { - - var navListItems = $('ul.setup-panel li a'), - allWells = $('.setup-content'); +function buildSelect(name, data_url, dept_id) { + return $.get({ + url: data_url, + data: {department_id: dept_id} + }).done(function(data) { + const dropdown = $( + ' - var ranks_url = $(this).data('ranks-url'); - var ranks = $.ajax({ - url: ranks_url, - data: {department_id: dept_id} - }).done(function(ranks) { - $('input#rank').replaceWith('');this.$searchContainer=t,this.$search=t.find("input");var n=e.call(this);return this._transferTabIndex(),n},e.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),t.on("open",function(){r.$search.attr("aria-controls",i),r.$search.trigger("focus")}),t.on("close",function(){r.$search.val(""),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.trigger("focus")}),t.on("enable",function(){r.$search.prop("disabled",!1),r._transferTabIndex()}),t.on("disable",function(){r.$search.prop("disabled",!0)}),t.on("focus",function(e){r.$search.trigger("focus")}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")}),this.$selection.on("focusin",".select2-search--inline",function(e){r.trigger("focus",e)}),this.$selection.on("focusout",".select2-search--inline",function(e){r._handleBlur(e)}),this.$selection.on("keydown",".select2-search--inline",function(e){if(e.stopPropagation(),r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented(),e.which===l.BACKSPACE&&""===r.$search.val()){var t=r.$searchContainer.prev(".select2-selection__choice");if(0this.maximumInputLength?this.trigger("results:message",{message:"inputTooLong",args:{maximum:this.maximumInputLength,input:t.term,params:t}}):e.call(this,t,n)},e}),e.define("select2/data/maximumSelectionLength",[],function(){function e(e,t,n){this.maximumSelectionLength=n.get("maximumSelectionLength"),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("select",function(){r._checkIfMaximumSelected()})},e.prototype.query=function(e,t,n){var r=this;this._checkIfMaximumSelected(function(){e.call(r,t,n)})},e.prototype._checkIfMaximumSelected=function(e,n){var r=this;this.current(function(e){var t=null!=e?e.length:0;0=r.maximumSelectionLength?r.trigger("results:message",{message:"maximumSelected",args:{maximum:r.maximumSelectionLength}}):n&&n()})},e}),e.define("select2/dropdown",["jquery","./utils"],function(t,e){function n(e,t){this.$element=e,this.options=t,n.__super__.constructor.call(this)}return e.Extend(n,e.Observable),n.prototype.render=function(){var e=t('');return e.attr("dir",this.options.get("dir")),this.$dropdown=e},n.prototype.bind=function(){},n.prototype.position=function(e,t){},n.prototype.destroy=function(){this.$dropdown.remove()},n}),e.define("select2/dropdown/search",["jquery","../utils"],function(o,e){function t(){}return t.prototype.render=function(e){var t=e.call(this),n=o('');return this.$searchContainer=n,this.$search=n.find("input"),t.prepend(n),t},t.prototype.bind=function(e,t,n){var r=this,i=t.id+"-results";e.call(this,t,n),this.$search.on("keydown",function(e){r.trigger("keypress",e),r._keyUpPrevented=e.isDefaultPrevented()}),this.$search.on("input",function(e){o(this).off("keyup")}),this.$search.on("keyup input",function(e){r.handleSearch(e)}),t.on("open",function(){r.$search.attr("tabindex",0),r.$search.attr("aria-controls",i),r.$search.trigger("focus"),window.setTimeout(function(){r.$search.trigger("focus")},0)}),t.on("close",function(){r.$search.attr("tabindex",-1),r.$search.removeAttr("aria-controls"),r.$search.removeAttr("aria-activedescendant"),r.$search.val(""),r.$search.trigger("blur")}),t.on("focus",function(){t.isOpen()||r.$search.trigger("focus")}),t.on("results:all",function(e){null!=e.query.term&&""!==e.query.term||(r.showSearch(e)?r.$searchContainer.removeClass("select2-search--hide"):r.$searchContainer.addClass("select2-search--hide"))}),t.on("results:focus",function(e){e.data._resultId?r.$search.attr("aria-activedescendant",e.data._resultId):r.$search.removeAttr("aria-activedescendant")})},t.prototype.handleSearch=function(e){if(!this._keyUpPrevented){var t=this.$search.val();this.trigger("query",{term:t})}this._keyUpPrevented=!1},t.prototype.showSearch=function(e,t){return!0},t}),e.define("select2/dropdown/hidePlaceholder",[],function(){function e(e,t,n,r){this.placeholder=this.normalizePlaceholder(n.get("placeholder")),e.call(this,t,n,r)}return e.prototype.append=function(e,t){t.results=this.removePlaceholder(t.results),e.call(this,t)},e.prototype.normalizePlaceholder=function(e,t){return"string"==typeof t&&(t={id:"",text:t}),t},e.prototype.removePlaceholder=function(e,t){for(var n=t.slice(0),r=t.length-1;0<=r;r--){var i=t[r];this.placeholder.id===i.id&&n.splice(r,1)}return n},e}),e.define("select2/dropdown/infiniteScroll",["jquery"],function(n){function e(e,t,n,r){this.lastParams={},e.call(this,t,n,r),this.$loadingMore=this.createLoadingMore(),this.loading=!1}return e.prototype.append=function(e,t){this.$loadingMore.remove(),this.loading=!1,e.call(this,t),this.showLoadingMore(t)&&(this.$results.append(this.$loadingMore),this.loadMoreIfNeeded())},e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("query",function(e){r.lastParams=e,r.loading=!0}),t.on("query:append",function(e){r.lastParams=e,r.loading=!0}),this.$results.on("scroll",this.loadMoreIfNeeded.bind(this))},e.prototype.loadMoreIfNeeded=function(){var e=n.contains(document.documentElement,this.$loadingMore[0]);if(!this.loading&&e){var t=this.$results.offset().top+this.$results.outerHeight(!1);this.$loadingMore.offset().top+this.$loadingMore.outerHeight(!1)<=t+50&&this.loadMore()}},e.prototype.loadMore=function(){this.loading=!0;var e=n.extend({},{page:1},this.lastParams);e.page++,this.trigger("query:append",e)},e.prototype.showLoadingMore=function(e,t){return t.pagination&&t.pagination.more},e.prototype.createLoadingMore=function(){var e=n('
  • '),t=this.options.get("translations").get("loadingMore");return e.html(t(this.lastParams)),e},e}),e.define("select2/dropdown/attachBody",["jquery","../utils"],function(f,a){function e(e,t,n){this.$dropdownParent=f(n.get("dropdownParent")||document.body),e.call(this,t,n)}return e.prototype.bind=function(e,t,n){var r=this;e.call(this,t,n),t.on("open",function(){r._showDropdown(),r._attachPositioningHandler(t),r._bindContainerResultHandlers(t)}),t.on("close",function(){r._hideDropdown(),r._detachPositioningHandler(t)}),this.$dropdownContainer.on("mousedown",function(e){e.stopPropagation()})},e.prototype.destroy=function(e){e.call(this),this.$dropdownContainer.remove()},e.prototype.position=function(e,t,n){t.attr("class",n.attr("class")),t.removeClass("select2"),t.addClass("select2-container--open"),t.css({position:"absolute",top:-999999}),this.$container=n},e.prototype.render=function(e){var t=f(""),n=e.call(this);return t.append(n),this.$dropdownContainer=t},e.prototype._hideDropdown=function(e){this.$dropdownContainer.detach()},e.prototype._bindContainerResultHandlers=function(e,t){if(!this._containerResultsHandlersBound){var n=this;t.on("results:all",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:append",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("results:message",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("select",function(){n._positionDropdown(),n._resizeDropdown()}),t.on("unselect",function(){n._positionDropdown(),n._resizeDropdown()}),this._containerResultsHandlersBound=!0}},e.prototype._attachPositioningHandler=function(e,t){var n=this,r="scroll.select2."+t.id,i="resize.select2."+t.id,o="orientationchange.select2."+t.id,s=this.$container.parents().filter(a.hasScroll);s.each(function(){a.StoreData(this,"select2-scroll-position",{x:f(this).scrollLeft(),y:f(this).scrollTop()})}),s.on(r,function(e){var t=a.GetData(this,"select2-scroll-position");f(this).scrollTop(t.y)}),f(window).on(r+" "+i+" "+o,function(e){n._positionDropdown(),n._resizeDropdown()})},e.prototype._detachPositioningHandler=function(e,t){var n="scroll.select2."+t.id,r="resize.select2."+t.id,i="orientationchange.select2."+t.id;this.$container.parents().filter(a.hasScroll).off(n),f(window).off(n+" "+r+" "+i)},e.prototype._positionDropdown=function(){var e=f(window),t=this.$dropdown.hasClass("select2-dropdown--above"),n=this.$dropdown.hasClass("select2-dropdown--below"),r=null,i=this.$container.offset();i.bottom=i.top+this.$container.outerHeight(!1);var o={height:this.$container.outerHeight(!1)};o.top=i.top,o.bottom=i.top+o.height;var s=this.$dropdown.outerHeight(!1),a=e.scrollTop(),l=e.scrollTop()+e.height(),c=ai.bottom+s,d={left:i.left,top:o.bottom},p=this.$dropdownParent;"static"===p.css("position")&&(p=p.offsetParent());var h={top:0,left:0};(f.contains(document.body,p[0])||p[0].isConnected)&&(h=p.offset()),d.top-=h.top,d.left-=h.left,t||n||(r="below"),u||!c||t?!c&&u&&t&&(r="below"):r="above",("above"==r||t&&"below"!==r)&&(d.top=o.top-h.top-s),null!=r&&(this.$dropdown.removeClass("select2-dropdown--below select2-dropdown--above").addClass("select2-dropdown--"+r),this.$container.removeClass("select2-container--below select2-container--above").addClass("select2-container--"+r)),this.$dropdownContainer.css(d)},e.prototype._resizeDropdown=function(){var e={width:this.$container.outerWidth(!1)+"px"};this.options.get("dropdownAutoWidth")&&(e.minWidth=e.width,e.position="relative",e.width="auto"),this.$dropdown.css(e)},e.prototype._showDropdown=function(e){this.$dropdownContainer.appendTo(this.$dropdownParent),this._positionDropdown(),this._resizeDropdown()},e}),e.define("select2/dropdown/minimumResultsForSearch",[],function(){function e(e,t,n,r){this.minimumResultsForSearch=n.get("minimumResultsForSearch"),this.minimumResultsForSearch<0&&(this.minimumResultsForSearch=1/0),e.call(this,t,n,r)}return e.prototype.showSearch=function(e,t){return!(function e(t){for(var n=0,r=0;r');return e.attr("dir",this.options.get("dir")),this.$container=e,this.$container.addClass("select2-container--"+this.options.get("theme")),u.StoreData(e[0],"element",this.$element),e},d}),e.define("jquery-mousewheel",["jquery"],function(e){return e}),e.define("jquery.select2",["jquery","jquery-mousewheel","./select2/core","./select2/defaults","./select2/utils"],function(i,e,o,t,s){if(null==i.fn.select2){var a=["open","close","destroy"];i.fn.select2=function(t){if("object"==typeof(t=t||{}))return this.each(function(){var e=i.extend(!0,{},t);new o(i(this),e)}),this;if("string"!=typeof t)throw new Error("Invalid arguments for Select2: "+t);var n,r=Array.prototype.slice.call(arguments,1);return this.each(function(){var e=s.GetData(this,"select2");null==e&&window.console&&console.error&&console.error("The select2('"+t+"') method was called on an element that is not using Select2."),n=e[t].apply(e,r)}),-1 li > a { padding-top: 10px; diff --git a/OpenOversight/app/static/scss/bootstrap/_type.scss b/OpenOversight/app/static/scss/bootstrap/_type.scss index 620796adc..2cb11b967 100644 --- a/OpenOversight/app/static/scss/bootstrap/_type.scss +++ b/OpenOversight/app/static/scss/bootstrap/_type.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + // // Typography // -------------------------------------------------- @@ -25,7 +27,7 @@ h1, .h1, h2, .h2, h3, .h3 { margin-top: $line-height-computed; - margin-bottom: ($line-height-computed / 2); + margin-bottom: math.div($line-height-computed, 2); small, .small { @@ -35,8 +37,8 @@ h3, .h3 { h4, .h4, h5, .h5, h6, .h6 { - margin-top: ($line-height-computed / 2); - margin-bottom: ($line-height-computed / 2); + margin-top: math.div($line-height-computed, 2); + margin-bottom: math.div($line-height-computed, 2); small, .small { @@ -56,7 +58,7 @@ h6, .h6 { font-size: $font-size-h6; } // ------------------------- p { - margin: 0 0 ($line-height-computed / 2); + margin: 0 0 math.div($line-height-computed, 2); } .lead { @@ -77,7 +79,7 @@ p { // Ex: (12px small font / 14px base font) * 100% = about 85% small, .small { - font-size: floor((100% * $font-size-small / $font-size-base)); + font-size: floor(math.div(100% * $font-size-small, $font-size-base)); } mark, @@ -136,7 +138,7 @@ mark, // ------------------------- .page-header { - padding-bottom: (($line-height-computed / 2) - 1); + padding-bottom: (math.div($line-height-computed, 2) - 1); margin: ($line-height-computed * 2) 0 $line-height-computed; border-bottom: 1px solid $page-header-border-color; } @@ -149,7 +151,7 @@ mark, ul, ol { margin-top: 0; - margin-bottom: ($line-height-computed / 2); + margin-bottom: math.div($line-height-computed, 2); ul, ol { margin-bottom: 0; @@ -239,7 +241,7 @@ abbr[data-original-title] { // Blockquotes blockquote { - padding: ($line-height-computed / 2) $line-height-computed; + padding: math.div($line-height-computed, 2) $line-height-computed; margin: 0 0 $line-height-computed; font-size: $blockquote-font-size; border-left: 5px solid $blockquote-border-color; diff --git a/OpenOversight/app/static/scss/bootstrap/_variables.scss b/OpenOversight/app/static/scss/bootstrap/_variables.scss index e04968529..6ea39bd0c 100644 --- a/OpenOversight/app/static/scss/bootstrap/_variables.scss +++ b/OpenOversight/app/static/scss/bootstrap/_variables.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + $bootstrap-sass-asset-helper: false !default; // // Variables @@ -365,8 +367,8 @@ $container-lg: $container-large-desktop !default; $navbar-height: 50px !default; $navbar-margin-bottom: $line-height-computed !default; $navbar-border-radius: $border-radius-base !default; -$navbar-padding-horizontal: floor(($grid-gutter-width / 2)) !default; -$navbar-padding-vertical: (($navbar-height - $line-height-computed) / 2) !default; +$navbar-padding-horizontal: floor(math.div($grid-gutter-width, 2)) !default; +$navbar-padding-vertical: math.div(($navbar-height - $line-height-computed), 2) !default; $navbar-collapse-max-height: 340px !default; $navbar-default-color: #777 !default; @@ -374,7 +376,7 @@ $navbar-default-bg: #f8f8f8 !default; $navbar-default-border: darken($navbar-default-bg, 6.5%) !default; // Navbar links -$navbar-default-link-color: #777 !default; +$navbar-default-link-color: #555 !default; $navbar-default-link-hover-color: #333 !default; $navbar-default-link-hover-bg: transparent !default; $navbar-default-link-active-color: #555 !default; @@ -853,7 +855,7 @@ $pre-scrollable-max-height: 340px !default; //** Horizontal offset for forms and lists. $component-offset-horizontal: 180px !default; //** Text muted color -$text-muted: $gray-light !default; +$text-muted: $gray !default; //** Abbreviations and acronyms border color $abbr-border-color: $gray-light !default; //** Headings small color diff --git a/OpenOversight/app/static/scss/bootstrap/mixins/_grid-framework.scss b/OpenOversight/app/static/scss/bootstrap/mixins/_grid-framework.scss index 16d038c04..d6d1fcd72 100644 --- a/OpenOversight/app/static/scss/bootstrap/mixins/_grid-framework.scss +++ b/OpenOversight/app/static/scss/bootstrap/mixins/_grid-framework.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + // Framework grid generation // // Used only by Bootstrap to generate the correct number of grid classes given @@ -13,8 +15,8 @@ // Prevent columns from collapsing when empty min-height: 1px; // Inner gutter via padding - padding-left: ceil(($grid-gutter-width / 2)); - padding-right: floor(($grid-gutter-width / 2)); + padding-left: ceil(math.div($grid-gutter-width, 2)); + padding-right: floor(math.div($grid-gutter-width, 2)); } } @@ -33,12 +35,12 @@ @mixin calc-grid-column($index, $class, $type) { @if ($type == width) and ($index > 0) { .col-#{$class}-#{$index} { - width: percentage(($index / $grid-columns)); + width: percentage(math.div($index, $grid-columns)); } } @if ($type == push) and ($index > 0) { .col-#{$class}-push-#{$index} { - left: percentage(($index / $grid-columns)); + left: percentage(math.div($index, $grid-columns)); } } @if ($type == push) and ($index == 0) { @@ -48,7 +50,7 @@ } @if ($type == pull) and ($index > 0) { .col-#{$class}-pull-#{$index} { - right: percentage(($index / $grid-columns)); + right: percentage(math.div($index, $grid-columns)); } } @if ($type == pull) and ($index == 0) { @@ -58,7 +60,7 @@ } @if ($type == offset) { .col-#{$class}-offset-#{$index} { - margin-left: percentage(($index / $grid-columns)); + margin-left: percentage(math.div($index, $grid-columns)); } } } diff --git a/OpenOversight/app/static/scss/bootstrap/mixins/_grid.scss b/OpenOversight/app/static/scss/bootstrap/mixins/_grid.scss index 59551dac1..8a396460b 100644 --- a/OpenOversight/app/static/scss/bootstrap/mixins/_grid.scss +++ b/OpenOversight/app/static/scss/bootstrap/mixins/_grid.scss @@ -1,3 +1,5 @@ +@use "sass:math"; + // Grid system // // Generate semantic grid columns with these mixins. @@ -6,15 +8,15 @@ @mixin container-fixed($gutter: $grid-gutter-width) { margin-right: auto; margin-left: auto; - padding-left: floor(($gutter / 2)); - padding-right: ceil(($gutter / 2)); + padding-left: floor(math.div($gutter, 2)); + padding-right: ceil(math.div($gutter, 2)); @include clearfix; } // Creates a wrapper for a series of columns @mixin make-row($gutter: $grid-gutter-width) { - margin-left: ceil(($gutter / -2)); - margin-right: floor(($gutter / -2)); + margin-left: ceil(math.div($gutter, -2)); + margin-right: floor(math.div($gutter, -2)); @include clearfix; } @@ -22,46 +24,46 @@ @mixin make-xs-column($columns, $gutter: $grid-gutter-width) { position: relative; float: left; - width: percentage(($columns / $grid-columns)); + width: percentage(math.div($columns, $grid-columns)); min-height: 1px; - padding-left: ($gutter / 2); - padding-right: ($gutter / 2); + padding-left: math.div($gutter, 2); + padding-right: math.div($gutter, 2); } @mixin make-xs-column-offset($columns) { - margin-left: percentage(($columns / $grid-columns)); + margin-left: percentage(math.div($columns, $grid-columns)); } @mixin make-xs-column-push($columns) { - left: percentage(($columns / $grid-columns)); + left: percentage(math.div($columns, $grid-columns)); } @mixin make-xs-column-pull($columns) { - right: percentage(($columns / $grid-columns)); + right: percentage(math.div($columns, $grid-columns)); } // Generate the small columns @mixin make-sm-column($columns, $gutter: $grid-gutter-width) { position: relative; min-height: 1px; - padding-left: ($gutter / 2); - padding-right: ($gutter / 2); + padding-left: math.div($gutter, 2); + padding-right: math.div($gutter, 2); @media (min-width: $screen-sm-min) { float: left; - width: percentage(($columns / $grid-columns)); + width: percentage(math.div($columns, $grid-columns)); } } @mixin make-sm-column-offset($columns) { @media (min-width: $screen-sm-min) { - margin-left: percentage(($columns / $grid-columns)); + margin-left: percentage(math.div($columns, $grid-columns)); } } @mixin make-sm-column-push($columns) { @media (min-width: $screen-sm-min) { - left: percentage(($columns / $grid-columns)); + left: percentage(math.div($columns, $grid-columns)); } } @mixin make-sm-column-pull($columns) { @media (min-width: $screen-sm-min) { - right: percentage(($columns / $grid-columns)); + right: percentage(math.div($columns, $grid-columns)); } } @@ -69,27 +71,27 @@ @mixin make-md-column($columns, $gutter: $grid-gutter-width) { position: relative; min-height: 1px; - padding-left: ($gutter / 2); - padding-right: ($gutter / 2); + padding-left: math.div($gutter, 2); + padding-right: math.div($gutter, 2); @media (min-width: $screen-md-min) { float: left; - width: percentage(($columns / $grid-columns)); + width: percentage(math.div($columns, $grid-columns)); } } @mixin make-md-column-offset($columns) { @media (min-width: $screen-md-min) { - margin-left: percentage(($columns / $grid-columns)); + margin-left: percentage(math.div($columns, $grid-columns)); } } @mixin make-md-column-push($columns) { @media (min-width: $screen-md-min) { - left: percentage(($columns / $grid-columns)); + left: percentage(math.div($columns, $grid-columns)); } } @mixin make-md-column-pull($columns) { @media (min-width: $screen-md-min) { - right: percentage(($columns / $grid-columns)); + right: percentage(math.div($columns, $grid-columns)); } } @@ -97,26 +99,26 @@ @mixin make-lg-column($columns, $gutter: $grid-gutter-width) { position: relative; min-height: 1px; - padding-left: ($gutter / 2); - padding-right: ($gutter / 2); + padding-left: math.div($gutter, 2); + padding-right: math.div($gutter, 2); @media (min-width: $screen-lg-min) { float: left; - width: percentage(($columns / $grid-columns)); + width: percentage(math.div($columns, $grid-columns)); } } @mixin make-lg-column-offset($columns) { @media (min-width: $screen-lg-min) { - margin-left: percentage(($columns / $grid-columns)); + margin-left: percentage(math.div($columns, $grid-columns)); } } @mixin make-lg-column-push($columns) { @media (min-width: $screen-lg-min) { - left: percentage(($columns / $grid-columns)); + left: percentage(math.div($columns, $grid-columns)); } } @mixin make-lg-column-pull($columns) { @media (min-width: $screen-lg-min) { - right: percentage(($columns / $grid-columns)); + right: percentage(math.div($columns, $grid-columns)); } } diff --git a/OpenOversight/app/static/scss/bootstrap/mixins/_nav-divider.scss b/OpenOversight/app/static/scss/bootstrap/mixins/_nav-divider.scss index 2e6da02a4..04c7af20d 100644 --- a/OpenOversight/app/static/scss/bootstrap/mixins/_nav-divider.scss +++ b/OpenOversight/app/static/scss/bootstrap/mixins/_nav-divider.scss @@ -1,10 +1,12 @@ +@use "sass:math"; + // Horizontal dividers // // Dividers (basically an hr) within dropdowns and nav lists @mixin nav-divider($color: #e5e5e5) { height: 1px; - margin: (($line-height-computed / 2) - 1) 0; + margin: (math.div($line-height-computed, 2) - 1) 0; overflow: hidden; background-color: $color; } diff --git a/OpenOversight/app/static/scss/bootstrap/mixins/_nav-vertical-align.scss b/OpenOversight/app/static/scss/bootstrap/mixins/_nav-vertical-align.scss index c8fbf1a7d..cd9ab6773 100644 --- a/OpenOversight/app/static/scss/bootstrap/mixins/_nav-vertical-align.scss +++ b/OpenOversight/app/static/scss/bootstrap/mixins/_nav-vertical-align.scss @@ -1,9 +1,11 @@ +@use "sass:math"; + // Navbar vertical align // // Vertically center elements in the navbar. // Example: an element has a height of 30px, so write out `.navbar-vertical-align(30px);` to calculate the appropriate top margin. @mixin navbar-vertical-align($element-height) { - margin-top: (($navbar-height - $element-height) / 2); - margin-bottom: (($navbar-height - $element-height) / 2); + margin-top: math.div(($navbar-height - $element-height), 2); + margin-bottom: math.div(($navbar-height - $element-height), 2); } diff --git a/OpenOversight/app/templates/403.html b/OpenOversight/app/templates/403.html index cd205df7b..287e07271 100644 --- a/OpenOversight/app/templates/403.html +++ b/OpenOversight/app/templates/403.html @@ -1,12 +1,12 @@ {% extends "base.html" %} -{% block title %}Forbidden{% endblock %} - +{% block title %} + Forbidden +{% endblock title %} {% block content %} - -
    - -

    Forbidden

    -

    You do not have permissions to view this page. Return to homepage.

    - -
    -{% endblock %} +
    +

    Forbidden

    +

    + You do not have permissions to view this page. Return to homepage. +

    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/404.html b/OpenOversight/app/templates/404.html index 72e8db817..315bf8497 100644 --- a/OpenOversight/app/templates/404.html +++ b/OpenOversight/app/templates/404.html @@ -1,12 +1,12 @@ {% extends "base.html" %} -{% block title %}Page Not Found{% endblock %} - +{% block title %} + Page Not Found +{% endblock title %} {% block content %} - -
    - -

    Page Not Found

    -

    We couldn't find the page you are looking for. Return to homepage.

    - -
    -{% endblock %} +
    +

    Page Not Found

    +

    + We couldn't find the page you are looking for. Return to homepage. +

    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/413.html b/OpenOversight/app/templates/413.html new file mode 100644 index 000000000..ab81a048b --- /dev/null +++ b/OpenOversight/app/templates/413.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %} + File Too Large +{% endblock title %} +{% block content %} +
    +

    File Too Large

    +

    + The file you are trying to upload is too large. Return to homepage. +

    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/429.html b/OpenOversight/app/templates/429.html index 8d6aa059f..fc23cb10d 100644 --- a/OpenOversight/app/templates/429.html +++ b/OpenOversight/app/templates/429.html @@ -1,12 +1,12 @@ {% extends "base.html" %} -{% block title %}Too Many Requests{% endblock %} - +{% block title %} + Too Many Requests +{% endblock title %} {% block content %} - -
    - -

    Too Many Requests

    -

    You're sending requests too fast. Wait a minute and try again. Return to homepage.

    - -
    -{% endblock %} +
    +

    Too Many Requests

    +

    + You're sending requests too fast. Wait a minute and try again. Return to homepage. +

    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/500.html b/OpenOversight/app/templates/500.html index 871974232..9094b161f 100644 --- a/OpenOversight/app/templates/500.html +++ b/OpenOversight/app/templates/500.html @@ -1,12 +1,12 @@ {% extends "base.html" %} -{% block title %}Internal Server Error{% endblock %} - +{% block title %} + Internal Server Error +{% endblock title %} {% block content %} - -
    - -

    Internal Server Error

    -

    Oops! Something went wrong. Return to homepage.

    - -
    -{% endblock %} +
    +

    Internal Server Error

    +

    + Oops! Something went wrong. Return to homepage. +

    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/about.html b/OpenOversight/app/templates/about.html index dba9eb9f1..5318a92a0 100644 --- a/OpenOversight/app/templates/about.html +++ b/OpenOversight/app/templates/about.html @@ -1,152 +1,166 @@ {% extends "base.html" %} -{% block title %}About OpenOversight{% endblock %} +{% block title %} + About OpenOversight +{% endblock title %} {% block meta %} - -{% endblock %} + +{% endblock meta %} {% block content %} - -
    -
    -
    About OpenOversight
    -
    -
    -

    - OpenOversight is a Lucy Parsons Labs project that aims to improve law enforcement - visibility and transparency using public and crowdsourced data. We maintain databases, digital galleries, and profiles of individual law enforcement officers from departments - across the United States that consolidate information including names, birthdates, mentions in news articles, salaries, and photographs. -

    - - - -
    -
    - -

    This project is a response to the lack of transparency and justice in policing. The public should have the right to know which officers are patrolling their neighborhoods and watching their communities. When officers abuse their positions of power, they should be able to be easily identified and held accountable. +

    +
    +
    About OpenOversight
    +
    +
    +

    + + OpenOversight is a Lucy Parsons Labs project that aims to improve law enforcement + visibility and transparency using public and crowdsourced data. We maintain databases, digital galleries, and profiles of individual law enforcement officers from departments + across the United States that consolidate information including names, birthdates, mentions in news articles, salaries, and photographs. +

    +
    + Try it +
    +
    +
    + +

    + This project is a response to the lack of transparency and justice in policing. The public should have the right to know which officers are patrolling their neighborhoods and watching their communities. When officers abuse their positions of power, they should be able to be easily identified and held accountable. +

    +
    +
    + +

    + It is the first project of its kind in the United States, and was first implemented in Chicago in October 2016. + OpenOversight launched in the East Bay of the San Francisco Bay Area in fall 2017 and in New York City in 2018. A + Baltimore instance was launched in 2019 at BPDWatch.com. +

    +
    +
    + +

    + OpenOversight is released as free and open source software so others can launch similar law enforcement accountability + projects in their own cities. The software is available for download and collaborative development on + GitHub. +

    +
    +
    -
    -

    - It is the first project of its kind in the United States, and was first implemented in Chicago in October 2016. - OpenOversight launched in the East Bay of the San Francisco Bay Area in fall 2017 and in New York City in 2018. A - Baltimore instance was launched in 2019 at BPDWatch.com. -

    +
    +
    +
    +

    Legal

    + A note to law enforcement +

    + Illinois: This project does not perform facial recognition on officers in Illinois and is thus in compliance with the Biometric + Information Privacy Act. + Requests or questions regarding this project from those affiliated with law enforcement must be directed to our + legal representation at legal@lucyparsonslabs.com. +

    +
    +
    +

    Contact and media

    +

    + For media inquiries about OpenOversight, please email media@lucyparsonslabs.com. + For other inquiries about the project, including collaboration, please contact openoversight@lucyparsonslabs.com. +

    +
    +
    -
    -

    - OpenOversight is released as free and open source software so others can launch similar law enforcement accountability - projects in their own cities. The software is available for download and collaborative development on - GitHub.

    - +
    +
    +

    Press Release

    +

    + In support of demands for greater police accountability, Illinois nonprofit The Lucy Parsons Labs launched + OpenOversight, an interactive web tool and accountability platform that makes it easier for the public to identify + police officers, including for the purpose of complaints. We rely on crowdsourced and public data to build a + database + of police officers in a city, allowing the public to filter through the dataset to find the name and badge number + of + the offending officer. +

    +

    + Using OpenOversight, members of the public can search for the names and badge numbers of police with whom they + have negative interactions using the officer's estimated age, race and gender. Using this information, the + OpenOversight web application returns a digital gallery of potential matches and, when possible, includes pictures + of officers in uniform to assist in identification. "The deck is stacked against people harmed by police," says + Jennifer Helsby, CTO of the Lucy Parsons Labs and lead developer on the OpenOversight project. "Police are almost + never held accountable for misconduct or crimes they commit. To file a misconduct complaint, the burden is on the + public to provide as much detailed data about the officer as possible. OpenOversight aims to empower citizens with + tools that make it easier to identify officers and hold them accountable." +

    +
    -
    -
    -
    -
    -

    Legal

    A note to law enforcement -

    Illinois: This project does not perform facial recognition on officers in Illinois and is thus in compliance with the Biometric - Information Privacy Act. - Requests or questions regarding this project from those affiliated with law enforcement must be directed to our - legal representation at legal@lucyparsonslabs.com.

    -
    -
    -

    Contact and media

    -

    For media inquiries about OpenOversight, please email media@lucyparsonslabs.com. - For other inquiries about the project, including collaboration, please contact openoversight@lucyparsonslabs.com. -

    -
    - -
    -

    Press Release

    -

    In support of demands for greater police accountability, Illinois nonprofit The Lucy Parsons Labs launched - OpenOversight, an interactive web tool and accountability platform that makes it easier for the public to identify - police officers, including for the purpose of complains. We rely on crowdsourced and public data to build a - database - of police officers in a city, allowing the public to filter through the dataset to find the name and badge number - of - the offending officer.

    - -

    Using OpenOversight, members of the public can search for the names and badge numbers of police with whom they - have negative interactions using the officer's estimated age, race and gender. Using this information, the - OpenOversight web application returns a digital gallery of potential matches and, when possible, includes pictures - of officers in uniform to assist in identification. "The deck is stacked against people harmed by police," says - Jennifer Helsby, CTO of the Lucy Parsons Labs and lead developer on the OpenOversight project. "Police are almost - never held accountable for misconduct or crimes they commit. To file a misconduct complaint, the burden is on the - public to provide as much detailed data about the officer as possible. OpenOversight aims to empower citizens with - tools that make it easier to identify officers and hold them accountable." -

    -
    -
    - -
    -
    -

    Facts and Figures about the Chicago Police Department

    -
    -
    -
    - To file a police complaint in Chicago, a member of the public needs - to know as much - detailed data - about the officer as possible. Based on complaints data from the Invisible - Institute, from March 2011 - March 2015, 28% of complaints (4,000 total complaints) were immediately - dropped due to no officer identification. -

    - - Source: Citizen Police Data Project - -

    -
    - Less than 2% of the 28,567 complaints filed against the Chicago - police department from March 2011 to September 2015 resulted in discipline. Most officers who do face - discipline - are suspended for a week or less. -

    - - Source: Citizens Police Data Project - -

    +
    +
    +

    Facts and Figures about the Chicago Police Department

    +
    +
    +
    + To file a police complaint in Chicago, a member of the public needs + to know as much + detailed data + about the officer as possible. Based on complaints data from the Invisible + Institute, from March 2011 - March 2015, 28% of complaints (4,000 total complaints) were immediately + dropped due to no officer identification. +

    + + Source: Citizen Police Data Project + +

    +
    +
    + Less than 2% of the 28,567 complaints filed against the Chicago + police department from March 2011 to September 2015 resulted in discipline. Most officers who do face + discipline + are suspended for a week or less. +

    + Source: Citizens Police Data Project +

    +
    +
    +
    +
    + All complaints against officers must be supported by a sworn affidavit. False complaints can result in + perjury charges, a Class 3 felony. +

    + + Source: Chicago Police + +

    +
    +
    + Chicago spent over $500 million from 2004 to 2014 on settlements, + legal fees and other costs related to complaints against police officers. +

    + Source: Better Government Association +

    +
    +
    + In 2015, there was no discipline in more than 99 percent of the + thousands of misconduct complaints against Chicago police officers. +

    + + Source: New York Times + +

    +
    +
    -
    -
    - All complaints against officers must be supported by a sworn affidavit. False complaints can result in - perjury charges, a Class 3 felony. -

    - - Source: Chicago Police - -

    -
    - Chicago spent over $500 million from 2004 to 2014 on settlements, - legal fees and other costs related to complaints against police officers. -

    - Source: Better Government Association -

    -
    - In 2015, there was no discipline in more than 99 percent of the - thousands of misconduct complaints against Chicago police officers. -

    - Source: New York Times -

    -
    -
    - -
    - -
    -
    -

    Sponsors

    -
    -
    -
    +
    +
    +

    Sponsors

    -
    - +
    +
    +
    + Shuttleworth Foundation logo +
    -
    - -{% endblock %} +{% endblock content %} diff --git a/OpenOversight/app/templates/add_edit_department.html b/OpenOversight/app/templates/add_edit_department.html deleted file mode 100644 index 78ea1ef77..000000000 --- a/OpenOversight/app/templates/add_edit_department.html +++ /dev/null @@ -1,65 +0,0 @@ -{% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} - -{% block title %}OpenOversight Admin - {% if update %} Update {% else %} Add {% endif %} Department{% endblock %} - -{% block content %} -
    - - -
    -
    - {{ form.hidden_tag() }} - {{ wtf.form_errors(form, hiddens="only") }} - - {{ wtf.form_field(form.name, autofocus="autofocus") }} - {{ wtf.form_field(form.short_name) }} -
    - {{ form.jobs.label }} -
    -
    Enter ranks in hierarchical order, from lowest to highest rank:
    -
    -
    - {{ wtf.form_errors(form.jobs, hiddens="only") }} -
    - {% if form.jobs|length > 1 %} - {% for subfield in (form.jobs|rejectattr('data.is_sworn_officer','eq',False)|sort(attribute='data.order')|list) %} -
    -
    -
    - -
    - {{ subfield(class="form-control")|safe}} - - - - {%- if subfield.errors %} - {%- for error in subfield.errors %} -

    {{error}}

    - {%- endfor %} - {%- endif %} -
    -
    - {% endfor %} - {% else %} -
    -
    - - - - -
    -
    - {% endif %} - -
    - {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }} -
    -
    -
    - -
    -{% endblock %} diff --git a/OpenOversight/app/templates/add_edit_salary.html b/OpenOversight/app/templates/add_edit_salary.html index c8d08a039..385af0a57 100644 --- a/OpenOversight/app/templates/add_edit_salary.html +++ b/OpenOversight/app/templates/add_edit_salary.html @@ -1,25 +1,39 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight Admin - {% if update %} Edit {% else %} Add {% endif %} Officer Salary{% endblock %} - +{% block title %} + OpenOversight Admin - + {% if update %} + Edit + {% else %} + Add + {% endif %} + Officer Salary +{% endblock title %} {% block content %} -
    - - -
    -
    +
    + +
    + {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} {{ wtf.form_field(form.salary) }} {{ wtf.form_field(form.overtime_pay) }} {{ wtf.form_field(form.year) }} {{ wtf.form_field(form.is_fiscal_year) }} - - -
    -
    - -
    -{% endblock %} + + +
    +
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/add_officer.html b/OpenOversight/app/templates/add_officer.html index e20543a3c..e5e6c4289 100644 --- a/OpenOversight/app/templates/add_officer.html +++ b/OpenOversight/app/templates/add_officer.html @@ -1,15 +1,19 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight Admin - Add New Officer{% endblock %} - +{% block title %} + OpenOversight Admin - Add New Officer +{% endblock title %} {% block content %} -
    - - -
    -
    +
    + +
    + {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} {{ wtf.form_field(form.department) }} @@ -23,35 +27,36 @@

    Add Officer

    {{ wtf.form_field(form.unique_internal_identifier) }} {{ wtf.form_field(form.job_id) }} {{ wtf.form_field(form.unit) }} -

    Don't see your unit? Add one!

    +

    + Don't see your unit? Add one! +

    {{ wtf.form_field(form.employment_date) }} {{ wtf.form_field(form.birth_year) }}
    - {{ form.salaries.label }} - {% for subform in form.salaries %} - {% include "partials/subform.html" %} - {% endfor %} - + {{ form.salaries.label }} + {% for subform in form.salaries %} + {% include "partials/subform.html" %} + {% endfor %} +
    {% include "partials/links_subform.html" %}
    - {{ form.notes.label }} - {% for subform in form.notes %} - {% include "partials/subform.html" %} - {% endfor %} - + {{ form.notes.label }} + {% for subform in form.notes %} + {% include "partials/subform.html" %} + {% endfor %} +
    - {{ form.descriptions.label }} - {% for subform in form.descriptions %} - {% include "partials/subform.html" %} - {% endfor %} - + {{ form.descriptions.label }} + {% for subform in form.descriptions %} + {% include "partials/subform.html" %} + {% endfor %} +
    {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }} - -
    -
    - -
    -{% endblock %} + +
    +
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/add_unit.html b/OpenOversight/app/templates/add_unit.html index 6965806e0..6e497f795 100644 --- a/OpenOversight/app/templates/add_unit.html +++ b/OpenOversight/app/templates/add_unit.html @@ -1,23 +1,22 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight Admin - Add New Unit{% endblock %} - +{% block title %} + OpenOversight Admin - Add New Unit +{% endblock title %} {% block content %} -
    - - -
    -
    +
    + +
    + {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} - {{ wtf.form_field(form.descrip, autofocus="autofocus") }} + {{ wtf.form_field(form.description, autofocus="autofocus") }} {{ wtf.form_field(form.department) }} {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }} - -
    -
    - -
    -{% endblock %} + +
    +
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/all_depts.html b/OpenOversight/app/templates/all_depts.html deleted file mode 100644 index fdb3719a6..000000000 --- a/OpenOversight/app/templates/all_depts.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends "base.html" %} -{% import "bootstrap/wtf.html" as wtf %} -{% block content %} - -
    - - - {% for dept in departments %} -

    - {{ dept.name }} - officers.csv - assignments.csv - {% if dept.incidents %} - incidents.csv - {% endif %} - -

    - {% endfor %} - -
    - -{% endblock %} diff --git a/OpenOversight/app/templates/auth/change_dept_pref.html b/OpenOversight/app/templates/auth/change_dept_pref.html index c32fcd7a1..bf0e7aad7 100644 --- a/OpenOversight/app/templates/auth/change_dept_pref.html +++ b/OpenOversight/app/templates/auth/change_dept_pref.html @@ -1,16 +1,13 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight - Volunteer Change Default Department{% endblock %} - +{% block title %} + OpenOversight - Volunteer Change Default Department +{% endblock title %} {% block content %} -
    - - -
    - {{ wtf.quick_form(form, button_map={'submit':'primary'}) }} -
    - -
    -{% endblock %} +
    + +
    {{ wtf.quick_form(form, button_map={'submit':'primary'}) }}
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/change_email.html b/OpenOversight/app/templates/auth/change_email.html index 3e4ffc11e..61b56dba2 100644 --- a/OpenOversight/app/templates/auth/change_email.html +++ b/OpenOversight/app/templates/auth/change_email.html @@ -1,16 +1,13 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight - Volunteer Change Email Address{% endblock %} - +{% block title %} + OpenOversight - Volunteer Change Email Address +{% endblock title %} {% block content %} -
    - - -
    - {{ wtf.quick_form(form, button_map={'submit':'primary'}) }} -
    - -
    -{% endblock %} +
    + +
    {{ wtf.quick_form(form, button_map={'submit':'primary'}) }}
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/change_password.html b/OpenOversight/app/templates/auth/change_password.html index 19041f609..9571d8db6 100644 --- a/OpenOversight/app/templates/auth/change_password.html +++ b/OpenOversight/app/templates/auth/change_password.html @@ -1,39 +1,51 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight - Change Password{% endblock %} - +{% block title %} + OpenOversight - Change Password +{% endblock title %} {% block content %} -
    - - -
    -
    +
    + +
    + {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} - {{ wtf.form_field(form.old_password) }} -
    - - - -

    - {% if form.password.errors %} -

    {% for error in form.password.errors %}{{ error }} {% endfor %}

    - {% endif %} +
    + + + +

    + {% if form.password.errors %} +

    + {% for error in form.password.errors %}{{ error }}{% endfor %} +

    + {% endif %}
    - - - - {% if form.password2.errors %} -

    {% for error in form.password2.errors %}{{ error }} {% endfor %}

    - {% endif %} + + + + {% if form.password2.errors %} +

    + {% for error in form.password2.errors %}{{ error }}{% endfor %} +

    + {% endif %}
    {{ wtf.form_field(form.submit, id="password-button", button_map={'submit':'primary'}, disabled=True) }} - -
    - -
    -{% endblock %} + +
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/email/change_email.html b/OpenOversight/app/templates/auth/email/change_email.html index 56c27d775..681083b70 100644 --- a/OpenOversight/app/templates/auth/email/change_email.html +++ b/OpenOversight/app/templates/auth/email/change_email.html @@ -1,7 +1,11 @@

    Dear {{ user.username }},

    -

    To confirm your new email address click here.

    +

    + To confirm your new email address click here. +

    Alternatively, you can paste the following link in your browser's address bar:

    {{ url_for('auth.change_email', token=token, _external=True) }}

    Sincerely,

    The OpenOversight Team

    -

    Note: replies to this email address are not monitored.

    +

    + Please note that we may not monitor replies to this email address. +

    diff --git a/OpenOversight/app/templates/auth/email/change_email.txt b/OpenOversight/app/templates/auth/email/change_email.txt deleted file mode 100644 index 0604e669a..000000000 --- a/OpenOversight/app/templates/auth/email/change_email.txt +++ /dev/null @@ -1,11 +0,0 @@ -Dear {{ user.username }}, - -To confirm your new email address click on the following link: - -{{ url_for('auth.change_email', token=token, _external=True) }} - -Sincerely, - -The OpenOversight Team - -Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/change_password.html b/OpenOversight/app/templates/auth/email/change_password.html new file mode 100644 index 000000000..8e4987ff0 --- /dev/null +++ b/OpenOversight/app/templates/auth/email/change_password.html @@ -0,0 +1,11 @@ +

    Dear {{ user.username }},

    +

    Your password has just been changed.

    +

    If you initiated this change to your password, you can ignore this email.

    +

    + If you did not reset your password, please contact the OpenOversight help account; they will help you address this issue. +

    +

    Sincerely,

    +

    The OpenOversight Team

    +

    + Please note that we may not monitor replies to this email address. +

    diff --git a/OpenOversight/app/templates/auth/email/confirm.html b/OpenOversight/app/templates/auth/email/confirm.html index 8baccb426..14a89e677 100644 --- a/OpenOversight/app/templates/auth/email/confirm.html +++ b/OpenOversight/app/templates/auth/email/confirm.html @@ -1,8 +1,14 @@

    Dear {{ user.username }},

    -

    Welcome to OpenOversight!

    -

    To confirm your account please click here.

    +

    + Welcome to OpenOversight! +

    +

    + To confirm your account please click here. +

    Alternatively, you can paste the following link in your browser's address bar:

    {{ url_for('auth.confirm', token=token, _external=True) }}

    Sincerely,

    The OpenOversight Team

    -

    Note: replies to this email address are not monitored.

    +

    + Please note that we may not monitor replies to this email address. +

    diff --git a/OpenOversight/app/templates/auth/email/confirm.txt b/OpenOversight/app/templates/auth/email/confirm.txt deleted file mode 100644 index 41abe7c57..000000000 --- a/OpenOversight/app/templates/auth/email/confirm.txt +++ /dev/null @@ -1,13 +0,0 @@ -Dear {{ user.username }}, - -Welcome to OpenOversight! - -To confirm your account please click on the following link: - -{{ url_for('auth.confirm', token=token, _external=True) }} - -Sincerely, - -The OpenOversight Team - -Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/new_confirmation.html b/OpenOversight/app/templates/auth/email/new_confirmation.html index 3397904fa..e50475a32 100644 --- a/OpenOversight/app/templates/auth/email/new_confirmation.html +++ b/OpenOversight/app/templates/auth/email/new_confirmation.html @@ -4,9 +4,13 @@
  • Username: {{ user.username }}
  • Email: {{ user.email }}
  • -

    To view or delete this user, please click here.

    +

    + To view or delete this user, please click here. +

    Alternatively, you can paste the following link in your browser's address bar:

    {{ url_for('auth.edit_user', user_id=user.id, _external=True) }}

    Sincerely,

    The OpenOversight Team

    -

    Note: replies to this email address are not monitored.

    +

    + Please note that we may not monitor replies to this email address. +

    diff --git a/OpenOversight/app/templates/auth/email/new_confirmation.txt b/OpenOversight/app/templates/auth/email/new_confirmation.txt deleted file mode 100644 index a6328ac0e..000000000 --- a/OpenOversight/app/templates/auth/email/new_confirmation.txt +++ /dev/null @@ -1,16 +0,0 @@ -Dear {{ admin.username }}, - -A new user account with the following information has been confirmed: - -Username: {{ user.username }} -Email: {{ user.email }} - -To view or delete this user, click on the following link: - -{{ url_for('auth.edit_user', user_id=user.id, _external=True) }} - -Sincerely, - -The OpenOversight Team - -Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/new_registration.html b/OpenOversight/app/templates/auth/email/new_registration.html index 120ad3309..8128359d7 100644 --- a/OpenOversight/app/templates/auth/email/new_registration.html +++ b/OpenOversight/app/templates/auth/email/new_registration.html @@ -4,9 +4,13 @@
  • Username: {{ user.username }}
  • Email: {{ user.email }}
  • -

    To approve or delete this user, please click here.

    +

    + To approve or delete this user, please click here. +

    Alternatively, you can paste the following link in your browser's address bar:

    {{ url_for('auth.edit_user', user_id=user.id, _external=True) }}

    Sincerely,

    The OpenOversight Team

    -

    Note: replies to this email address are not monitored.

    +

    + Please note that we may not monitor replies to this email address. +

    diff --git a/OpenOversight/app/templates/auth/email/new_registration.txt b/OpenOversight/app/templates/auth/email/new_registration.txt deleted file mode 100644 index 5840085a9..000000000 --- a/OpenOversight/app/templates/auth/email/new_registration.txt +++ /dev/null @@ -1,16 +0,0 @@ -Dear {{ admin.username }}, - -A new user has registered with the following information: - -Username: {{ user.username }} -Email: {{ user.email }} - -To approve or delete this user, click on the following link: - -{{ url_for('auth.edit_user', user_id=user.id, _external=True) }} - -Sincerely, - -The OpenOversight Team - -Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/email/reset_password.html b/OpenOversight/app/templates/auth/email/reset_password.html index 06093ab80..ce67a476c 100644 --- a/OpenOversight/app/templates/auth/email/reset_password.html +++ b/OpenOversight/app/templates/auth/email/reset_password.html @@ -1,8 +1,12 @@

    Dear {{ user.username }},

    -

    To reset your password click here.

    +

    + To reset your password click here. +

    Alternatively, you can paste the following link in your browser's address bar:

    {{ url_for('auth.password_reset', token=token, _external=True) }}

    If you have not requested a password reset simply ignore this message.

    Sincerely,

    The OpenOversight Team

    -

    Note: replies to this email address are not monitored.

    +

    + Please note that we may not monitor replies to this email address. +

    diff --git a/OpenOversight/app/templates/auth/email/reset_password.txt b/OpenOversight/app/templates/auth/email/reset_password.txt deleted file mode 100644 index d14796c51..000000000 --- a/OpenOversight/app/templates/auth/email/reset_password.txt +++ /dev/null @@ -1,13 +0,0 @@ -Dear {{ user.username }}, - -To reset your password click on the following link: - -{{ url_for('auth.password_reset', token=token, _external=True) }} - -If you have not requested a password reset simply ignore this message. - -Sincerely, - -The OpenOversight Team - -Note: replies to this email address are not monitored. diff --git a/OpenOversight/app/templates/auth/login.html b/OpenOversight/app/templates/auth/login.html index 911ce523a..3c2c4a98a 100644 --- a/OpenOversight/app/templates/auth/login.html +++ b/OpenOversight/app/templates/auth/login.html @@ -1,28 +1,32 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}Volunteer Login - OpenOversight{% endblock %} -{% block meta %}{% endblock %} - +{% block title %} + Volunteer Login - OpenOversight +{% endblock title %} +{% block meta %} + +{% endblock meta %} {% block content %} -
    - - -
    -
    +
    + +
    + {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} - {{ wtf.form_field(form.email, autofocus="autofocus") }} {{ wtf.form_field(form.password) }} {{ wtf.form_field(form.remember_me) }} {{ wtf.form_field(form.submit, id="submit", button_map={'submit':'primary'}) }} - -
    -

    Forgot your password? Click here to reset it.

    -

    New user? Click here to register.

    -
    - -
    -{% endblock %} + +
    +

    + Forgot your password? Click here to reset it. +

    +

    + New user? Click here to register. +

    +
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/register.html b/OpenOversight/app/templates/auth/register.html index 572facc8a..f2da42afe 100644 --- a/OpenOversight/app/templates/auth/register.html +++ b/OpenOversight/app/templates/auth/register.html @@ -1,40 +1,56 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}Volunteer Registration - OpenOversight{% endblock %} -{% block meta %}{% endblock %} - +{% block title %} + Volunteer Registration - OpenOversight +{% endblock title %} +{% block meta %} + +{% endblock meta %} {% block content %} -
    - - -
    -
    +
    + +
    + {{ form.hidden_tag() }} {{ wtf.form_errors(form, hiddens="only") }} - {{ wtf.form_field(form.email, autofocus="autofocus") }} {{ wtf.form_field(form.username) }} -
    - - - -

    - {% if form.password.errors %} -

    {% for error in form.password.errors %}{{ error }} {% endfor %}

    - {% endif %} +
    + + + +

    + {% if form.password.errors %} +

    + {% for error in form.password.errors %}{{ error }}{% endfor %} +

    + {% endif %}
    - - - - {% if form.password2.errors %} -

    {% for error in form.password2.errors %}{{ error }} {% endfor %}

    - {% endif %} + + + + {% if form.password2.errors %} +

    + {% for error in form.password2.errors %}{{ error }}{% endfor %} +

    + {% endif %}
    {{ wtf.form_field(form.submit, id="password-button", button_map={'submit':'primary'}, disabled=True) }} - -
    -
    -{% endblock %} + +
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/reset_password.html b/OpenOversight/app/templates/auth/reset_password.html index 6b325665d..ad0c3fe38 100644 --- a/OpenOversight/app/templates/auth/reset_password.html +++ b/OpenOversight/app/templates/auth/reset_password.html @@ -1,17 +1,16 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}Volunteer Password Reset - OpenOversight{% endblock %} -{% block meta %}{% endblock %} - +{% block title %} + Volunteer Password Reset - OpenOversight +{% endblock title %} +{% block meta %} + +{% endblock meta %} {% block content %} -
    - - -
    - {{ wtf.quick_form(form, button_map={'submit':'primary'}) }} -
    - -
    -{% endblock %} +
    + +
    {{ wtf.quick_form(form, button_map={'submit':'primary'}) }}
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/unapproved.html b/OpenOversight/app/templates/auth/unapproved.html index 73ceeba66..9765ca870 100644 --- a/OpenOversight/app/templates/auth/unapproved.html +++ b/OpenOversight/app/templates/auth/unapproved.html @@ -1,20 +1,19 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight - Waiting For Approval{% endblock %} - +{% block title %} + OpenOversight - Waiting For Approval +{% endblock title %} {% block content %} -
    - +
    -

    An OpenOversight administrator has not approved your account yet.

    -

    - Before you can access this site you need to wait for an administrator to approve your account. - Once you are approved, you should receive an email with a confirmation link. -

    +

    An OpenOversight administrator has not approved your account yet.

    +

    + Before you can access this site you need to wait for an administrator to approve your account. + Once you are approved, you should receive an email with a confirmation link. +

    - -
    -{% endblock %} +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/unconfirmed.html b/OpenOversight/app/templates/auth/unconfirmed.html index d537a1d5c..cab61f343 100644 --- a/OpenOversight/app/templates/auth/unconfirmed.html +++ b/OpenOversight/app/templates/auth/unconfirmed.html @@ -1,23 +1,23 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight - Confirm Your Account{% endblock %} - +{% block title %} + OpenOversight - Confirm Your Account +{% endblock title %} {% block content %} -
    - - -
    -

    You have not confirmed your account yet.

    -

    - Before you can access this site you need to confirm your account. - Check your inbox, you should have received an email with a confirmation link. -

    -

    - Need another confirmation email? - Click here -

    - -
    -{% endblock %} +
    + +
    +

    You have not confirmed your account yet.

    +

    + Before you can access this site you need to confirm your account. + Check your inbox, you should have received an email with a confirmation link. +

    +

    + Need another confirmation email? + Click here +

    +
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/user.html b/OpenOversight/app/templates/auth/user.html index 0d5764ab7..81cb2a6f8 100644 --- a/OpenOversight/app/templates/auth/user.html +++ b/OpenOversight/app/templates/auth/user.html @@ -1,21 +1,21 @@ {% extends "base.html" %} {% import "bootstrap/wtf.html" as wtf %} -{% block title %}OpenOversight Admin - Update User{% endblock %} - +{% block title %} + OpenOversight Admin - Update User +{% endblock title %} {% block content %} -
    - - -
    -
    - {{ wtf.quick_form(form, button_map={"submit":"primary", "delete": "danger"}) }} -
    -
    -
    -
    -{% endblock %} +
    + +
    +
    + {{ wtf.quick_form(form, button_map={"submit":"primary", "delete": "danger"}) }} +
    +
    +
    +
    +{% endblock content %} diff --git a/OpenOversight/app/templates/auth/user_delete.html b/OpenOversight/app/templates/auth/user_delete.html index 9e8756a09..157f38171 100644 --- a/OpenOversight/app/templates/auth/user_delete.html +++ b/OpenOversight/app/templates/auth/user_delete.html @@ -1,20 +1,17 @@ {% extends "base.html" %} - {% block content %} -
    - - -

    - Are you sure you want to delete this user? - This cannot be undone. -

    - - -
    -

    -
    +
    + +

    + Are you sure you want to delete this user? + This cannot be undone. +

    + + +
    +

    +
    {% endblock content %} diff --git a/OpenOversight/app/templates/auth/users.html b/OpenOversight/app/templates/auth/users.html index e32752b9a..a5caf7c0c 100644 --- a/OpenOversight/app/templates/auth/users.html +++ b/OpenOversight/app/templates/auth/users.html @@ -1,81 +1,80 @@ {% extends "base.html" %} - -{% block title %}OpenOversight Admin - Users{% endblock %} - +{% block title %} + OpenOversight Admin - Users +{% endblock title %} {% block content %} -
    -

    Users

    -
    - -
    -
    - {% with paginate=objects, - next_url=url_for('auth.get_users', page=objects.next_num), - prev_url=url_for('auth.get_users', page=objects.prev_num), - location='top' %} - {% include "partials/paginate_nav.html" %} - {% endwith %} -
    - - - - - - - - - - - {% for user in objects.items %} - - - - - - - - - - {% endfor %} -
    UsernameEmailStatusIs Area Coordinator?Area Coordinator DepartmentIs Administator?
    - {{ user.username }} -
    -
    - Edit user | - Profile -
    -
    {{ user.email }} - {% if user.is_disabled %} - Disabled - {% elif user.confirmed %} - Active - {% elif user.approved %} - Pending Confirmation - {% else %} - Pending Approval - {% endif %} - - {% if user.is_area_coordinator %} - - {% else %} - - {% endif %} - {{ user.is_area_coordinator }} - {{ user.ac_department.name }} - {% if user.is_administrator %} - - {% else %} - - {% endif %} - {{ user.is_admin }} -
    -
    - {% with paginate=objects, - next_url=url_for('auth.get_users', page=objects.next_num), - prev_url=url_for('auth.get_users', page=objects.prev_num), - location='bottom' %} - {% include "partials/paginate_nav.html" %} - {% endwith %} -
    -
    +
    +

    Users

    +
    +
    +
    + {% with paginate=objects, + next_url=url_for('auth.get_users', page=objects.next_num), + prev_url=url_for('auth.get_users', page=objects.prev_num), + location='top' %} + {% include "partials/paginate_nav.html" %} + {% endwith %} +
    + + + + + + + + + + {% for user in objects.items %} + + + + + + + + + {% endfor %} +
    UsernameEmailStatusIs Area Coordinator?Area Coordinator DepartmentIs Administator?
    + {{ user.username }} +
    +
    + Edit user | + Profile +
    +
    + {{ user.email }} + + {% if user.is_disabled %} + Disabled + {% elif user.confirmed %} + Active + {% elif user.approved %} + Pending Confirmation + {% else %} + Pending Approval + {% endif %} + + {% if user.is_area_coordinator %} + + {% else %} + + {% endif %} + {{ user.is_area_coordinator }} + {{ user.ac_department.name }} + {% if user.is_administrator %} + + {% else %} + + {% endif %} + {{ user.is_admin }} +
    +
    + {% with paginate=objects, + next_url=url_for('auth.get_users', page=objects.next_num), + prev_url=url_for('auth.get_users', page=objects.prev_num), + location='bottom' %} + {% include "partials/paginate_nav.html" %} + {% endwith %} +
    +
    {% endblock content %} diff --git a/OpenOversight/app/templates/base.html b/OpenOversight/app/templates/base.html index e718a69f5..91c64430c 100644 --- a/OpenOversight/app/templates/base.html +++ b/OpenOversight/app/templates/base.html @@ -1,145 +1,186 @@ + {% block meta %} - - - {% endblock %} - - {% block title %}OpenOversight - a Lucy Parsons Labs project{% endblock %} - + + + {% endblock meta %} + + {% block title %} + OpenOversight - a Lucy Parsons Labs project + {% endblock title %} + + {% if 'TIMEZONE' not in session %} + + {% endif %} - - + - - + - - + - - + - - + - - - {% block head %}{% endblock %} + {% block head %} + {% endblock head %} - - - {% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} -
    -
    - -
    -
    - {% endfor %} - {% endif %} + {% if messages %} + {% for message in messages %} +
    +
    + +
    +
    + {% endfor %} + {% endif %} {% endwith %} - {% block content %}{% endblock %} - -