├── .babelrc ├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .dockerignore ├── .github ├── bin │ └── wait-for-it.sh ├── dependabot.yml ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── pull-request.yml │ └── release.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── __mocks__ └── mapbox-gl.ts ├── bin ├── api-extractor.js ├── gendocs.py └── get_release_version.py ├── docs ├── code-style.md ├── contributing.md ├── developing.md ├── guides │ ├── data-pipeline.md │ └── getting-started.md ├── img │ ├── components.png │ ├── entrypoints.png │ └── logo.jpg ├── index.md ├── logo.png ├── overrides │ ├── home.html │ └── partials │ │ ├── footer.html │ │ └── header.html ├── stylesheets │ └── extra.css └── templates │ └── text.mako ├── env.d.ts ├── example ├── context_processors.py ├── frontend │ ├── controllers │ │ └── map-data-controller.ts │ └── main.ts ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py ├── templates │ ├── base.html │ ├── map.html │ └── uk_constituencies │ │ └── mp_list.html ├── uk │ ├── models.py │ ├── urls.py │ └── views.py ├── urls.py └── wsgi.py ├── frontend ├── core │ ├── index.ts │ ├── loader.ts │ ├── test-utils.ts │ └── util │ │ ├── css-utils.ts │ │ ├── file-mock.ts │ │ ├── promise-utils.ts │ │ ├── stimulus-test-utils.ts │ │ ├── stimulus-utils.ts │ │ └── types.ts ├── geo │ ├── __tests__ │ │ ├── map-layer.spec.ts │ │ ├── map-source.spec.ts │ │ └── map.spec.ts │ ├── controllers │ │ ├── map-controller.ts │ │ ├── map-layer-controller.ts │ │ └── map-source-controller.ts │ ├── index.ts │ ├── test-utils.ts │ └── utils │ │ ├── map-test-utils.ts │ │ └── map-utils.ts ├── index.bundled.ts ├── index.lib.ts └── index.test-utils.ts ├── groundwork ├── __init__.py ├── contrib │ └── airtable │ │ └── datasources.py ├── core │ ├── cache.py │ ├── cron.py │ ├── datasources.py │ ├── internal │ │ ├── asset_loader.py │ │ ├── class_util.py │ │ ├── collection_util.py │ │ └── sync_manager.py │ ├── management │ │ └── commands │ │ │ └── run_cron_tasks.py │ ├── template.py │ ├── templatetags │ │ └── groundwork_core.py │ └── types.py └── geo │ ├── docs │ └── map.components.md │ ├── examples.py │ ├── templates │ └── groundwork │ │ └── geo │ │ ├── components │ │ ├── map.html │ │ ├── map_canvas.html │ │ └── map_config.html │ │ └── examples │ │ ├── complex_map_example.html │ │ └── map_example.html │ ├── templatetags │ └── groundwork_geo.py │ └── territories │ └── uk │ ├── internal │ └── serializers.py │ ├── ons.py │ ├── parliament.py │ └── postcodes.py ├── jest.config.js ├── manage.py ├── mkdocs.yaml ├── package.json ├── poetry.lock ├── pyproject.toml ├── settings.py ├── test ├── __init__.py ├── contrib │ └── airtable │ │ └── test_airtable_datasource.py ├── core │ ├── __init__.py │ ├── test_cache.py │ └── test_synced_model.py ├── geo │ ├── test_parliament_api.py │ ├── test_postcodes_api.py │ └── test_tags.py ├── migrations │ ├── 0001_initial.py │ └── __init__.py ├── models.py └── tags.py ├── tsconfig.json ├── types.d.ts ├── vite.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { "targets": { "esmodules": false, "node": "current" } } 8 | ], 9 | "babel-preset-vite", 10 | "@babel/preset-typescript" 11 | ] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # [Choice] Python version: 3, 3.8, 3.7, 3.6 2 | ARG VARIANT=3 3 | FROM mcr.microsoft.com/vscode/devcontainers/python:0-${VARIANT}-bullseye 4 | 5 | ENV PYTHONUNBUFFERED 1 6 | 7 | # Update args in docker-compose.yaml to set the UID/GID of the "vscode" user. 8 | ARG USER_UID=1000 9 | ARG USER_GID=$USER_UID 10 | RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then groupmod --gid $USER_GID vscode && usermod --uid $USER_UID --gid $USER_GID vscode; fi 11 | 12 | # [Option] Install Node.js 13 | ARG INSTALL_NODE="true" 14 | ARG NODE_VERSION="lts/*" 15 | RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 16 | 17 | RUN apt-get update 18 | RUN apt-get install -y \ 19 | curl \ 20 | git \ 21 | build-essential \ 22 | libpq-dev \ 23 | libjpeg62-turbo-dev \ 24 | zlib1g-dev \ 25 | libwebp-dev \ 26 | binutils \ 27 | libproj-dev \ 28 | gdal-bin \ 29 | g++ 30 | 31 | 32 | ENV POETRY_HOME=/usr/local 33 | RUN curl -sSL https://install.python-poetry.org | python3 - 34 | 35 | USER vscode 36 | 37 | ENV PATH=$PATH:/home/vscode/.local/bin 38 | RUN poetry config virtualenvs.create false 39 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.163.1/containers/python-3-postgres 3 | // Update the VARIANT arg in docker-compose.yml to pick a Python version: 3, 3.8, 3.7, 3.6 4 | { 5 | "name": "Python 3 & PostgreSQL", 6 | "dockerComposeFile": "docker-compose.yml", 7 | "service": "app", 8 | "workspaceFolder": "/workspace", 9 | "forwardPorts": [8000], 10 | // Set *default* container specific settings.json values on container create. 11 | "settings": { 12 | "terminal.integrated.profiles.linux": { 13 | "bash": { 14 | "path": "bash", 15 | "icon": "terminal-bash" 16 | }, 17 | "zsh": { 18 | "path": "zsh" 19 | } 20 | }, 21 | "terminal.integrated.defaultProfile.windows": "zsh", 22 | "sqltools.connections": [ 23 | { 24 | "name": "Container database", 25 | "driver": "PostgreSQL", 26 | "previewLimit": 50, 27 | "server": "localhost", 28 | "port": 5432, 29 | "database": "postgres", 30 | "username": "postgres", 31 | "password": "postgres" 32 | } 33 | ], 34 | "python.pythonPath": "/usr/local/bin/python", 35 | "python.linting.enabled": true, 36 | "python.linting.pylintEnabled": true, 37 | "python.formatting.autopep8Path": "/usr/local/py-utils/bin/autopep8", 38 | "python.formatting.blackPath": "/usr/local/py-utils/bin/black", 39 | "python.formatting.yapfPath": "/usr/local/py-utils/bin/yapf", 40 | "python.linting.banditPath": "/usr/local/py-utils/bin/bandit", 41 | "python.linting.flake8Path": "/usr/local/py-utils/bin/flake8", 42 | "python.linting.mypyPath": "/usr/local/py-utils/bin/mypy", 43 | "python.linting.pycodestylePath": "/usr/local/py-utils/bin/pycodestyle", 44 | "python.linting.pydocstylePath": "/usr/local/py-utils/bin/pydocstyle", 45 | "python.linting.pylintPath": "/usr/local/py-utils/bin/pylint", 46 | "python.testing.pytestPath": "/usr/local/py-utils/bin/pytest" 47 | }, 48 | // Add the IDs of extensions you want installed when the container is created. 49 | "extensions": [ 50 | "ms-python.python", 51 | "mtxr.sqltools", 52 | "mtxr.sqltools-driver-pg", 53 | "esbenp.prettier-vscode", 54 | "orta.vscode-jest", 55 | "bungcip.better-toml", 56 | "ninoseki.vscode-pylens" 57 | ], 58 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 59 | // "forwardPorts": [5000, 5432], 60 | // Use 'postCreateCommand' to run commands after the container is created. 61 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 62 | "remoteUser": "vscode", 63 | "postCreateCommand": "make bootstrap" 64 | } 65 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | args: 9 | VARIANT: 3.9 10 | INSTALL_NODE: "true" 11 | NODE_VERSION: "lts/*" 12 | USER_UID: 1000 13 | USER_GID: 1000 14 | 15 | environment: 16 | - DATABASE_URL=postgres://postgres:postgres@db:5432/postgres 17 | - DEBUG=True 18 | - PY_IGNORE_IMPORTMISMATCH=1 19 | 20 | # Overrides default command so things don't shut down after the process ends. 21 | command: sleep infinity 22 | 23 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 24 | network_mode: service:db 25 | 26 | # Uncomment the next line to use a non-root user for all processes. 27 | # user: vscode 28 | 29 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. 30 | # (Adding the "ports" property to this file will not forward from a Codespace.) 31 | 32 | volumes: 33 | # Mounts the project folder to '/workspace'. The target path inside the container 34 | # should match what your application expects. In this case, the compose file is 35 | # in a sub-folder, so you will mount '..'. You would then reference this path as the 36 | # 'workspaceFolder' in '.devcontainer/devcontainer.json' so VS Code starts here. 37 | - ..:/workspace:cached 38 | 39 | db: 40 | image: kartoza/postgis:latest 41 | restart: unless-stopped 42 | volumes: 43 | - ck_postgres_db:/var/lib/postgresql 44 | environment: 45 | - POSTGRES_USER=postgres 46 | - POSTGRES_PASSWORD=postgres 47 | - POSTGRES_DBNAME=postgres 48 | - POSTGRES_HOSTNAME=postgres 49 | - POSTGRES_PORT=5432 50 | 51 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. 52 | # (Adding the "ports" property to this file will not forward from a Codespace.) 53 | 54 | volumes: 55 | ck_postgres_db: {} 56 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .devcontainer 3 | .github 4 | .*_cache 5 | -------------------------------------------------------------------------------- /.github/bin/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi 183 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "pip" 13 | directory: "/" # Location of package manifests 14 | schedule: 15 | interval: "weekly" 16 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ## Description 5 | 6 | 7 | 8 | ## Motivation and Context 9 | 10 | 11 | 12 | 13 | ## How Can It Be Tested? 14 | 15 | 16 | 17 | 18 | 19 | ## Screenshots (if appropriate): 20 | 21 | ## Types of changes 22 | 23 | 24 | 25 | - [ ] Bug fix (non-breaking change which fixes an issue) 26 | - [ ] New feature (non-breaking change which adds functionality) 27 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 28 | - [ ] Documentation change 29 | 30 | ## Checklist: 31 | 32 | 33 | 34 | - [ ] I have documented all new methods using google docstring format. 35 | - [ ] I have added type annotations to any public API methods. 36 | - [ ] I have updated any relevant high-level documentation. 37 | - [ ] I have added a usage example to the example app if relevant. 38 | - [ ] I have written tests covering all new changes. 39 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | deploy-docs: 8 | runs-on: ubuntu-latest 9 | container: 10 | image: ghcr.io/commonknowledge/do-app-baseimage-django-node:ec719be0d63e9628fb34604ce198c679084c3eeb 11 | # Workaround for: https://github.com/actions/checkout/issues/211 12 | options: --user 1001 13 | volumes: 14 | - "/home/runner/docker/.cache:/home/app/.cache" 15 | env: 16 | DATABASE_URL: postgres://postgres:postgres@db:5432/postgres 17 | DEBUG: True 18 | PY_IGNORE_IMPORTMISMATCH: 1 19 | services: 20 | db: 21 | image: kartoza/postgis:latest 22 | env: 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_DBNAME: postgres 26 | POSTGRES_HOSTNAME: postgres 27 | POSTGRES_PORT: 5432 28 | steps: 29 | - uses: actions/checkout@v2 30 | - uses: actions/cache@v2 31 | with: 32 | path: /home/runner/docker/.cache/poetry 33 | key: do-app-baseimage-django-node:ec719be0d63e9628fb34604ce198c679084c3eeb-poetry-${{ hashFiles('poetry.lock') }} 34 | - run: make install 35 | - run: make lint 36 | - run: make deploy-docs 37 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: pull-request 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | jobs: 7 | lint-and-test: 8 | runs-on: ubuntu-latest 9 | container: 10 | image: ghcr.io/commonknowledge/do-app-baseimage-django-node:ec719be0d63e9628fb34604ce198c679084c3eeb 11 | # Workaround for: https://github.com/actions/checkout/issues/211 12 | options: --user 1001 13 | volumes: 14 | - "/home/runner/docker/.cache:/home/app/.cache" 15 | env: 16 | DATABASE_URL: postgres://postgres:postgres@db:5432/postgres 17 | DEBUG: True 18 | PY_IGNORE_IMPORTMISMATCH: 1 19 | services: 20 | db: 21 | image: kartoza/postgis:latest 22 | env: 23 | POSTGRES_USER: postgres 24 | POSTGRES_PASSWORD: postgres 25 | POSTGRES_DBNAME: postgres 26 | POSTGRES_HOSTNAME: postgres 27 | POSTGRES_PORT: 5432 28 | steps: 29 | - uses: actions/checkout@v2 30 | - uses: actions/cache@v2 31 | with: 32 | path: /home/runner/docker/.cache/poetry 33 | key: do-app-baseimage-django-node:ec719be0d63e9628fb34604ce198c679084c3eeb-poetry-${{ hashFiles('poetry.lock') }} 34 | - run: make install 35 | - run: make ci 36 | env: 37 | EXAMPLE_AIRTABLE_BASE: ${{ secrets.EXAMPLE_AIRTABLE_BASE }} 38 | EXAMPLE_AIRTABLE_API_KEY: ${{ secrets.EXAMPLE_AIRTABLE_API_KEY }} 39 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | container: 9 | image: ghcr.io/commonknowledge/do-app-baseimage-django-node:ec719be0d63e9628fb34604ce198c679084c3eeb 10 | # Workaround for: https://github.com/actions/checkout/issues/211 11 | options: --user 1001 12 | volumes: 13 | - "/home/runner/docker/.cache:/home/app/.cache" 14 | env: 15 | DATABASE_URL: postgres://postgres:postgres@db:5432/postgres 16 | DEBUG: True 17 | PY_IGNORE_IMPORTMISMATCH: 1 18 | services: 19 | db: 20 | image: kartoza/postgis:latest 21 | env: 22 | POSTGRES_USER: postgres 23 | POSTGRES_PASSWORD: postgres 24 | POSTGRES_DBNAME: postgres 25 | POSTGRES_HOSTNAME: postgres 26 | POSTGRES_PORT: 5432 27 | steps: 28 | - uses: actions/checkout@v2 29 | - name: setup git config 30 | run: | 31 | git config user.name "GitHub Actions Bot" 32 | git config user.email "<>" 33 | - uses: actions/cache@v2 34 | with: 35 | path: /home/runner/docker/.cache/poetry 36 | key: do-app-baseimage-django-node:ec719be0d63e9628fb34604ce198c679084c3eeb-poetry-${{ hashFiles('poetry.lock') }} 37 | - run: make install 38 | - run: chmod +x .github/bin/wait-for-it.sh 39 | - run: .github/bin/wait-for-it.sh db:5432 40 | - run: make lint 41 | - run: make prepare-release 42 | env: 43 | GITHUB_REF: v1.0.1 44 | - run: make release 45 | env: 46 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # JS 132 | node_modules 133 | 134 | # Local 135 | local.py 136 | 137 | # Docs artifacts 138 | docs/api 139 | docs/components 140 | 141 | .DS_Store 142 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3.9 3 | 4 | default_stages: [commit, push] 5 | 6 | repos: 7 | - repo: https://github.com/pre-commit/pre-commit-hooks 8 | rev: v2.5.0 9 | hooks: 10 | # YAML code formatter 11 | - id: check-yaml 12 | # Enforce EOF newlines 13 | - id: end-of-file-fixer 14 | exclude: LICENSE 15 | 16 | - repo: https://github.com/asottile/pyupgrade 17 | rev: v2.38.2 18 | hooks: 19 | # Upgrade outdated python syntax 20 | - id: pyupgrade 21 | name: pyupgrade 22 | entry: pyupgrade --py38-plus 23 | types: [python] 24 | language: python 25 | 26 | - repo: https://github.com/pycqa/isort 27 | rev: 5.10.1 28 | hooks: 29 | # Sort ordering of python imports 30 | - id: isort 31 | name: isort 32 | entry: isort --settings-path pyproject.toml 33 | types: [python] 34 | language: python 35 | 36 | - repo: https://github.com/psf/black 37 | rev: 22.8.0 38 | hooks: 39 | # Run code formatting on python code 40 | - id: black 41 | name: black 42 | entry: black --config pyproject.toml 43 | types: [python] 44 | language: python 45 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | dist 3 | build 4 | static 5 | node_modules 6 | .*_cache 7 | *.html 8 | docs/api 9 | docs/components 10 | 11 | # .gitignore include 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | pip-wheel-metadata/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 106 | __pypackages__/ 107 | 108 | # Celery stuff 109 | celerybeat-schedule 110 | celerybeat.pid 111 | 112 | # SageMath parsed files 113 | *.sage.py 114 | 115 | # Environments 116 | .env 117 | .venv 118 | env/ 119 | venv/ 120 | ENV/ 121 | env.bak/ 122 | venv.bak/ 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | 142 | # JS 143 | node_modules 144 | 145 | # Local 146 | local.py 147 | 148 | # Docs artifacts 149 | docs/api 150 | docs/components 151 | 152 | .DS_Store 153 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "compounds": [ 7 | { 8 | "name": "Run Example App", 9 | "configurations": ["django-runserver", "vite-dev"], 10 | "presentation": { 11 | "hidden": false, 12 | "group": "", 13 | "order": 1 14 | } 15 | } 16 | ], 17 | "configurations": [ 18 | { 19 | "name": "django-runserver", 20 | "type": "python", 21 | "request": "launch", 22 | "program": "${workspaceFolder}/manage.py", 23 | "args": ["runserver"], 24 | "justMyCode": true, 25 | "django": true 26 | }, 27 | { 28 | "name": "django-run-cron-tasks", 29 | "type": "python", 30 | "request": "launch", 31 | "program": "${workspaceFolder}/manage.py", 32 | "args": ["run_cron_tasks", "--once"], 33 | "justMyCode": true, 34 | "django": true 35 | }, 36 | { 37 | "name": "gendocs", 38 | "type": "python", 39 | "request": "launch", 40 | "program": "bin/gendocs.py", 41 | "justMyCode": true 42 | }, 43 | { 44 | "name": "vite-dev", 45 | "type": "node", 46 | "request": "launch", 47 | "runtimeExecutable": "yarn", 48 | "program": "vite", 49 | "args": ["--mode", "dev"] 50 | }, 51 | { 52 | "type": "node", 53 | "name": "vscode-jest-tests", 54 | "request": "launch", 55 | "args": ["--runInBand", "--watchAll=false"], 56 | "cwd": "${workspaceFolder}", 57 | "console": "integratedTerminal", 58 | "internalConsoleOptions": "neverOpen", 59 | "disableOptimisticBPs": true, 60 | "program": "${workspaceFolder}/node_modules/.bin/jest", 61 | "windows": { 62 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 63 | }, 64 | "presentation": { 65 | "hidden": true 66 | } 67 | } 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/home/vscode/.cache/pypoetry/virtualenvs/groundwork-django-SsXfCHgY-py3.9/bin/python", 3 | "python.linting.pylintEnabled": false, 4 | "python.linting.mypyEnabled": false, 5 | "python.linting.banditEnabled": true, 6 | "python.testing.autoTestDiscoverOnSaveEnabled": true, 7 | "python.testing.pytestEnabled": true, 8 | "editor.formatOnSave": true, 9 | "html.format.templating": true, 10 | "jest.autoRun": "off", 11 | "[django-html]": { 12 | "editor.formatOnSave": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #* Variables 2 | SHELL := /usr/bin/env bash 3 | PYTHON := python 4 | 5 | #* Installation 6 | 7 | .PHONY: install 8 | install: 9 | poetry install -n 10 | yarn 11 | 12 | .PHONY: pre-commit-install 13 | pre-commit-install: 14 | poetry run pre-commit install 15 | 16 | .PHONY: migrate 17 | migrate: 18 | poetry run python manage.py migrate 19 | 20 | .PHONY: bootstrap 21 | bootstrap: install pre-commit-install migrate 22 | touch local.py 23 | 24 | 25 | #* Formatters 26 | 27 | .PHONY: codestyle 28 | codestyle: 29 | poetry run pyupgrade --exit-zero-even-if-changed --py38-plus **/*.py 30 | poetry run isort --gitignore --settings-path pyproject.toml ./ 31 | poetry run black --config pyproject.toml ./ 32 | yarn prettier --write . 33 | 34 | .PHONY: formatting 35 | formatting: codestyle 36 | 37 | 38 | #* Documentation 39 | 40 | .PHONY: python-api-docs 41 | python-api-docs: 42 | rm -rf docs/api 43 | mkdir -p docs/api 44 | poetry run python bin/gendocs.py 45 | 46 | .PHONY: component-docs 47 | component-docs: 48 | rm -rf docs/components 49 | mkdir -p docs/components 50 | cp groundwork/**/docs/*.components.md docs/components/ 51 | 52 | .PHONY: api-docs 53 | api-docs: python-api-docs component-docs 54 | 55 | .PHONY: build-docs 56 | build-docs: api-docs 57 | poetry run mkdocs build -d build/docs 58 | 59 | .PHONY: serve-docs 60 | serve-docs: api-docs 61 | poetry run mkdocs serve -a localhost:8001 62 | 63 | .PHONY: deploy-docs 64 | deploy-docs: api-docs 65 | poetry run mkdocs gh-deploy --force 66 | 67 | 68 | #* Linting 69 | 70 | .PHONY: test 71 | test: 72 | poetry run pytest -vs -m "not integration_test" 73 | yarn test 74 | 75 | .PHONY: check-codestyle 76 | check-codestyle: 77 | poetry run isort --gitignore --diff --check-only --settings-path pyproject.toml ./ 78 | poetry run black --diff --check --config pyproject.toml ./ 79 | poetry run darglint --docstring-style google --verbosity 2 groundwork 80 | yarn tsc --noemit 81 | yarn prettier --check . 82 | 83 | .PHONY: check-safety 84 | check-safety: 85 | poetry check 86 | poetry run safety check --full-report 87 | poetry run bandit -ll --recursive groundwork tests 88 | 89 | .PHONY: lint 90 | lint: check-codestyle check-safety test 91 | 92 | .PHONY: ci 93 | ci: lint build-docs build-js build-python 94 | poetry run pytest 95 | yarn test 96 | 97 | 98 | #* Build & release flow 99 | 100 | .PHONY: set-release-version 101 | set-release-version: 102 | npm version --new-version $$(poetry run python bin/get_release_version.py) --no-git-tag-version 103 | poetry version $$(poetry run python bin/get_release_version.py) 104 | 105 | .PHONY: build-js 106 | build-js: 107 | yarn vite build --mode lib 108 | yarn vite build --mode bundled 109 | yarn vite build --mode test-utils 110 | rm -rf build/ts 111 | yarn tsc 112 | node bin/api-extractor.js 113 | 114 | .PHONY: build-python 115 | build-python: build-js 116 | rm -rf groundwork/core/static 117 | cp -r build/bundled groundwork/core/static 118 | poetry build 119 | rm -rf groundwork/core/static 120 | 121 | .PHONY: prepare-release 122 | prepare-release: clean-all set-release-version build-js build-python 123 | 124 | .PHONY: release 125 | release: 126 | echo //registry.npmjs.org/:_authToken=$$NPM_TOKEN > ~/.npmrc 127 | npm publish 128 | poetry config pypi-token.pypi $$PYPI_TOKEN 129 | poetry publish 130 | 131 | 132 | 133 | #* Cleaning 134 | 135 | .PHONY: pycache-remove 136 | pycache-remove: 137 | find . | grep -E "(__pycache__|\.pyc|\.pyo$$)" | xargs rm -rf 138 | 139 | .PHONY: build-remove 140 | build-remove: 141 | rm -rf build/ groundwork/core/static/ docs/api/ docs/components/ temp/ 142 | 143 | .PHONY: clean-all 144 | clean-all: pycache-remove build-remove 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Groundwork 2 | 3 | An integrated Django and Javascript framework for people who build tools for organisers. 4 | 5 | For more information, check out [the documentation](https://groundwork.commonknowledge.coop/). 6 | 7 | Work on this project kindly supported by [Rosa-Luxemburg-Stiftung](https://www.rosalux.de). 8 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## 🔐 Reporting Security Issues 4 | 5 | > Do not open issues that might have security implications! 6 | > It is critical that security related issues are reported privately so we have time to address them before they become public knowledge. 7 | 8 | Vulnerabilities can be reported by emailing core members: 9 | 10 | - commonknowledge [developers@commonknowledge.coop](mailto:developers@commonknowledge.coop) 11 | 12 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 13 | 14 | - Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 15 | - Full paths of source file(s) related to the manifestation of the issue 16 | - The location of the affected source code (tag/branch/commit or direct URL) 17 | - Any special configuration required to reproduce the issue 18 | - Environment (e.g. Linux / Windows / macOS) 19 | - Step-by-step instructions to reproduce the issue 20 | - Proof-of-concept or exploit code (if possible) 21 | - Impact of the issue, including how an attacker might exploit the issue 22 | 23 | This information will help us triage your report more quickly. 24 | 25 | ## Preferred Languages 26 | 27 | We prefer all communications to be in English. 28 | -------------------------------------------------------------------------------- /__mocks__/mapbox-gl.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import mapboxgl, { AnySourceImpl } from "mapbox-gl"; 3 | 4 | type MockedMethods = Pick< 5 | mapboxgl.Map, 6 | | "getContainer" 7 | | "addLayer" 8 | | "removeLayer" 9 | | "getLayer" 10 | | "addSource" 11 | | "removeSource" 12 | | "getSource" 13 | >; 14 | 15 | class MockMap extends EventEmitter implements MockedMethods { 16 | private layers = new Map(); 17 | private sources = new Map(); 18 | 19 | constructor(private options?: mapboxgl.MapboxOptions) { 20 | super(); 21 | 22 | setTimeout(() => { 23 | this.emit("load"); 24 | }); 25 | } 26 | 27 | getContainer() { 28 | return this.options?.container as HTMLElement; 29 | } 30 | 31 | resize() {} 32 | 33 | addLayer(layer: mapboxgl.AnyLayer): mapboxgl.Map { 34 | this.layers.set(layer.id, layer); 35 | return this as any; 36 | } 37 | 38 | removeLayer(id: string): mapboxgl.Map { 39 | this.layers.delete(id); 40 | return this as any; 41 | } 42 | 43 | getLayer(id: string): mapboxgl.AnyLayer { 44 | return this.layers.get(id)!; 45 | } 46 | 47 | addSource(id: string, source: mapboxgl.AnySourceData): mapboxgl.Map { 48 | this.sources.set(id, new MockSource(source.type)); 49 | return this as any; 50 | } 51 | 52 | removeSource(id: string): mapboxgl.Map { 53 | this.sources.delete(id); 54 | return this as any; 55 | } 56 | 57 | getSource(id: string): mapboxgl.AnySourceImpl { 58 | return this.sources.get(id) as any; 59 | } 60 | } 61 | 62 | class MockSource { 63 | constructor(readonly type: string) {} 64 | } 65 | 66 | export default { Map: MockMap }; 67 | -------------------------------------------------------------------------------- /bin/api-extractor.js: -------------------------------------------------------------------------------- 1 | const { ExtractorConfig, Extractor } = require("@microsoft/api-extractor"); 2 | 3 | const IGNORED_SYMBOLS = ["mapbox-gl"]; 4 | 5 | /** 6 | * Rollup the d.ts files produced by typescript into single files to go with the rolled-up library. 7 | */ 8 | const invoke = (slug) => { 9 | const packageJson = require("../package.json"); 10 | const peerDependencies = new Set([ 11 | ...Object.keys(packageJson.peerDependencies), 12 | ...IGNORED_SYMBOLS, 13 | ]); 14 | 15 | const config = ExtractorConfig.prepare({ 16 | configObject: { 17 | mainEntryPointFilePath: `/build/ts/frontend/index.${slug}.d.ts`, 18 | bundledPackages: [ 19 | ...Object.keys(packageJson.dependencies ?? {}), 20 | ...Object.keys(packageJson.devDependencies).filter( 21 | (x) => !peerDependencies.has(x) 22 | ), 23 | ], 24 | projectFolder: process.cwd(), 25 | compiler: { 26 | tsconfigFilePath: "/tsconfig.json", 27 | }, 28 | dtsRollup: { 29 | enabled: true, 30 | publicTrimmedFilePath: `/build/${slug}/index.d.ts`, 31 | }, 32 | apiReport: { 33 | enabled: false, 34 | reportFileName: ".api.md", 35 | }, 36 | docModel: { 37 | enabled: false, 38 | }, 39 | tsdocMetadata: { 40 | enabled: false, 41 | }, 42 | }, 43 | configObjectFullPath: undefined, 44 | packageJson, 45 | packageJsonFullPath: require.resolve("../package.json"), 46 | }); 47 | 48 | Extractor.invoke(config, { 49 | localBuild: true, 50 | showVerboseMessages: true, 51 | }); 52 | }; 53 | 54 | // import * from 'groundwork-django/test-utils' 55 | invoke("test-utils"); 56 | 57 | // import * from 'groundwork-django' 58 | invoke("lib"); 59 | -------------------------------------------------------------------------------- /bin/gendocs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import re 4 | import sys 5 | from pathlib import Path 6 | 7 | import django 8 | import pdoc 9 | from django.conf import settings 10 | from django.template import Context, Template 11 | from pdoc.html_helpers import to_markdown 12 | 13 | # Setup directory locations 14 | OUTPUT_DIR = Path.cwd() / "docs" / "api" 15 | TEMPLATE_PATH = Path.cwd() / "docs" / "templates" 16 | 17 | sys.path.append(os.path.abspath("./")) 18 | 19 | # Override the default pdoc markdown template cos it's ugly 20 | pdoc.tpl_lookup.directories.insert( 21 | 0, 22 | str(TEMPLATE_PATH), 23 | ) 24 | 25 | # Specify settings module and setup django 26 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 27 | django.setup() 28 | 29 | 30 | modules = [app for app in settings.INSTALLED_APPS if app.startswith("groundwork.")] 31 | context = pdoc.Context() 32 | 33 | modules = [pdoc.Module(mod, context=context) for mod in modules] 34 | pdoc.link_inheritance(context) 35 | 36 | 37 | def recursive_htmls(mod): 38 | yield mod.name, mod.text() 39 | for submod in mod.submodules(): 40 | yield from recursive_htmls(submod) 41 | 42 | 43 | if __name__ == "__main__": 44 | for mod in modules: 45 | for module_name, html in recursive_htmls(mod): 46 | docs_path = OUTPUT_DIR / f"{module_name}.md" 47 | 48 | with open(str(docs_path), "w", encoding="utf8") as f: 49 | f.write(html) 50 | -------------------------------------------------------------------------------- /bin/get_release_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | 6 | # Exp 7 | def get_version(ref_name): 8 | if ref_name: 9 | ref_match = re.search(r"v(\d+)\.(\d+)\.(\d+)$", ref_name) 10 | if ref_match is None: 11 | return () 12 | 13 | return ref_match.groups() 14 | 15 | 16 | if __name__ == "__main__": 17 | print(".".join(get_version(os.getenv("GITHUB_REF_NAME")))) 18 | -------------------------------------------------------------------------------- /docs/code-style.md: -------------------------------------------------------------------------------- 1 | # Style guide 2 | 3 | 1. All public APIs must be fully documented. 4 | 5 | - We use [Google docstring format](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings). These will at some point be extracted into searchable API docs, so it's important to get this right. A linter will check for you that they are correctly formatted. 6 | 7 | 2. Aim for 100% test coverage on public APIs. 8 | 9 | 3. Use type hints. 10 | 11 | 4. Format your code. 12 | 13 | - Run `make formatting` to do this explicitly. 14 | - If you run `make bootstrap` to setup the project (as the devcontainer does automatically), you should get pre-commit hooks installed that do this for you. 15 | 16 | 5. Check that all checks pass before requesting a PR review. 17 | - You can do this using `make lint` 18 | - Individual checks are documented at: 19 | - https://pypi.org/project/darglint/ 20 | - https://pypi.org/project/isort/ 21 | - https://mypy-lang.org/ 22 | - https://pypi.org/project/safety/ 23 | - https://pypi.org/project/bandit/ 24 | - Rules are configured in pyproject.yaml 25 | -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributor workflow 2 | 3 | Creating a high-level pattern library like this one comes with risks. The most significant one is premature abstraction. Our general approach here is that API design happens best in the context of real-world applications. Features should be proposed for this library once they have reached a certain level of stability. 4 | 5 | We don't want to have an excessively prescriptive sense of what is 'in' and 'out' of scope for this library. So long as something fits within our architecture, is testable and has an API that has stabilised through real-world use, it's a candidate for inclusion. 6 | 7 | ## New features and functionality 8 | 9 | 1. The first stage of adding a new module happens within an application that uses it. Use the convention of placing it in the `groundwork.experimental` package within your application repository to indicate to other people that it's in the process of being abstracted and that they should be mindful of these guidelines. 10 | 2. Remove any dependencies from application code outside the `groundwork` package. Start to think about how it can be tested in isolation (if it isn't already). 11 | 3. Open a feature request against this repository. Describe the new feature, include links to your implementation other repositories. If the application is publicly accessible, include links to it in the live app. 12 | 4. Discuss and refine the API with other contributors. 13 | 5. When the feature request is accepted, fork this repository (or create a feature branch if you have write access) and commit the feature implementation. Ensure that you have good test coverage of both Python and Javascript components and all public API methods are documented. 14 | 15 | ## Bugs & backward-compatible API changes 16 | 17 | We're more open to backward-compatible API changes. For smaller changes, opening a pull request is fine. 18 | 19 | Some additional pointers: 20 | 21 | - Ensure that you have good test coverage of both Python and Javascript components and all public API methods are documented. 22 | - These changes are always a good opportuntiy to improve test coverage of the exsting functionality. Think about how your changes may lead to regressions to the existing functionality and add tests to guard against them. 23 | 24 | ## Improvements to documentation 25 | 26 | Yes please! 27 | 28 | ## Releases 29 | 30 | Releases are published to package managers when a release tag in `vX.X.X` format is published in GitHub. 31 | 32 | We follow the [Semantic Versioning](https://semver.org/) spec for release numbers. 33 | -------------------------------------------------------------------------------- /docs/developing.md: -------------------------------------------------------------------------------- 1 | ## Easy mode: Development containers 2 | 3 | We love development containers. They keep your development environment in a VM and isolated from the rest of your device which is good security practice. They also make it extremely easy to share editor configurations and development dependencies that can't be managed using language package managers. 4 | 5 | [Much as](https://theintercept.com/2020/07/14/microsoft-police-state-mass-surveillance-facial-recognition/) we hate to [stan](https://devblogs.microsoft.com/azuregov/federal-agencies-continue-to-advance-capabilities-with-azure-government/) [Microsoft](https://en.wikipedia.org/wiki/Embrace,_extend,_and_extinguish), it's the easiest option to work on this library. 6 | 7 | (you may be interested in trying out [VSCodium](https://vscodium.com/), an opensource distribution of VSCode) 8 | 9 | ### In Visual Studio Code 10 | 11 | 1. From the command palette select "Remote Containers: Clone Repository in Named Container Volume". 12 | 2. Enter: `git@github.com:commonknowledge/groundwork.git`. 13 | 3. Choose a development container to install to, or create a new one. 14 | 4. Wait for the container to initialize and the project bootstrap scripts to finish. 15 | 16 | ### More information 17 | 18 | - [VSCode Development Containers](https://code.visualstudio.com/docs/remote/containers) 19 | 20 | ## Hard mode: Do it yourself 21 | 22 | 1. Ensure that you have the following installed: 23 | - NodeJS 24 | - Yarn 25 | - Python 3.9+ 26 | - Poetry 27 | 2. Follow the installation instructions for [DjangoGIS's local dependencies](https://docs.djangoproject.com/en/3.2/ref/contrib/gis/install/postgis/). 28 | 3. Ensure that you have the `DATABASE_URL` environmental variable pointing to a local postgres database. 29 | 4. Clone the repository and run `make bootstrap`. 30 | -------------------------------------------------------------------------------- /docs/guides/getting-started.md: -------------------------------------------------------------------------------- 1 | Groundwork is distributed as a Python library [via PyPI](https://pypi.org/project/groundwork-django/). Frontend components are distibuted [via NPM](https://npmjs.org/package/groundwork-django). 2 | 3 | ## Using a starter kit 4 | 5 | The simplest way to get started using Groundwork is to clone our [starter repository](https://github.com/commonknowledge/groundwork-starter-template). To do this: 6 | 7 | 1. [Create a new repository from our template](https://github.com/commonknowledge/groundwork-starter-template/generate) 8 | 2. Follow the setup instructions in the new repository's readme. 9 | -------------------------------------------------------------------------------- /docs/img/components.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/docs/img/components.png -------------------------------------------------------------------------------- /docs/img/entrypoints.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/docs/img/entrypoints.png -------------------------------------------------------------------------------- /docs/img/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/docs/img/logo.jpg -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | template: home.html 3 | hide: 4 | - navigation 5 | - toc 6 | --- 7 | 8 | # Groundwork 9 | 10 | An integrated Django and Javascript framework for rapidly building tools. 11 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/docs/logo.png -------------------------------------------------------------------------------- /docs/overrides/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block hero %} 4 | 26 | 27 |
28 |
29 |
30 |
31 |

Groundwork

32 |

An integrated Django and Javascript framework for people who build tools for organisers.

33 | 34 | Featuring: 35 |
    36 |
  • Pluggable API clients for popular organising tools.
  • 37 |
  • Data pipeline tools to easily pull in and store data from external services.
  • 38 |
  • Easy access to open-source geographical and administrative area information.
  • 39 |
  • High-level components for displaying data on maps.
  • 40 |
41 |
42 |
43 |
44 |
45 | {% endblock %} 46 | 47 | 48 | {% block content %} 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /docs/overrides/partials/footer.html: -------------------------------------------------------------------------------- 1 | {% import "partials/language.html" as lang with context %} 2 | 58 | -------------------------------------------------------------------------------- /docs/overrides/partials/header.html: -------------------------------------------------------------------------------- 1 | {#- 2 | This file was automatically generated - do not edit 3 | -#} 4 | {% set class = "md-header" %} 5 | {% if "navigation.tabs.sticky" in features %} 6 | {% set class = class ~ " md-header--lifted" %} 7 | {% endif %} 8 |
9 | 81 | {% if "navigation.tabs.sticky" in features %} 82 | {% if "navigation.tabs" in features %} 83 | {% include "partials/tabs.html" %} 84 | {% endif %} 85 | {% endif %} 86 |
87 | -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --md-default-fg-color: rgba(0, 0, 0, 0.87); 3 | --md-default-fg-color--light: rgba(0, 0, 0, 0.54); 4 | --md-default-fg-color--lighter: rgba(0, 0, 0, 0.32); 5 | --md-default-fg-color--lightest: rgba(0, 0, 0, 0.07); 6 | --md-default-bg-color: #fff; 7 | --md-default-bg-color--light: hsla(0, 0%, 100%, 0.7); 8 | --md-default-bg-color--lighter: hsla(0, 0%, 100%, 0.3); 9 | --md-default-bg-color--lightest: hsla(0, 0%, 100%, 0.12); 10 | --md-primary-fg-color: #000000; 11 | --md-primary-fg-color--light: #00e8a2; 12 | --md-primary-fg-color--dark: #02563d; 13 | --md-primary-bg-color: #fff; 14 | --md-primary-bg-color--light: hsla(0, 0%, 100%, 0.7); 15 | --md-accent-fg-color: #00e8a2; 16 | --md-accent-fg-color--transparent: rgba(82, 254, 99, 0.1); 17 | --md-accent-bg-color: #fff; 18 | --md-accent-bg-color--light: hsla(0, 0%, 100%, 0.7); 19 | 20 | --md-code-fg-color: #36464e; 21 | --md-code-bg-color: #f5f5f5; 22 | --md-code-hl-color: rgba(255, 255, 0, 0.5); 23 | --md-code-hl-number-color: #d52a2a; 24 | --md-code-hl-special-color: #db1457; 25 | --md-code-hl-function-color: #a846b9; 26 | --md-code-hl-constant-color: #6e59d9; 27 | --md-code-hl-keyword-color: #3f6ec6; 28 | --md-code-hl-string-color: #1c7d4d; 29 | --md-code-hl-name-color: var(--md-code-fg-color); 30 | --md-code-hl-operator-color: var(--md-default-fg-color--light); 31 | --md-code-hl-punctuation-color: var(--md-default-fg-color--light); 32 | --md-code-hl-comment-color: var(--md-default-fg-color--light); 33 | --md-code-hl-generic-color: var(--md-default-fg-color--light); 34 | --md-code-hl-variable-color: var(--md-default-fg-color--light); 35 | --md-typeset-color: var(--md-default-fg-color); 36 | --md-typeset-a-color: var(--md-primary-fg-color); 37 | --md-typeset-mark-color: rgba(255, 255, 0, 0.5); 38 | --md-typeset-del-color: hsla(6, 90%, 60%, 0.15); 39 | --md-typeset-ins-color: rgba(11, 213, 112, 0.15); 40 | --md-typeset-kbd-color: #fafafa; 41 | --md-typeset-kbd-accent-color: #fff; 42 | --md-typeset-kbd-border-color: #b8b8b8; 43 | --md-typeset-table-color: rgba(0, 0, 0, 0.12); 44 | --md-admonition-fg-color: var(--md-default-fg-color); 45 | --md-admonition-bg-color: var(--md-default-bg-color); 46 | --md-footer-fg-color: #fff; 47 | --md-footer-fg-color--light: hsla(0, 0%, 100%, 0.7); 48 | --md-footer-fg-color--lighter: hsla(0, 0%, 100%, 0.3); 49 | --md-footer-bg-color: rgba(0, 0, 0, 0.87); 50 | --md-footer-bg-color--dark: rgba(0, 0, 0, 0.32); 51 | } 52 | 53 | .md-typeset h1, 54 | .md-typeset h2 { 55 | color: var(--md-default-fg-color); 56 | } 57 | 58 | .md-content a { 59 | text-decoration: underline; 60 | } 61 | 62 | .md-header__title { 63 | margin-left: 0 !important; 64 | } 65 | -------------------------------------------------------------------------------- /docs/templates/text.mako: -------------------------------------------------------------------------------- 1 | ## Define mini-templates for each portion of the doco. 2 | 3 | <%! 4 | import re 5 | from pdoc.html_helpers import to_markdown 6 | from dataclasses import is_dataclass 7 | 8 | def indent(s, spaces=4): 9 | new = s.replace('\n', '\n' + ' ' * spaces) 10 | return ' ' * spaces + new.strip() 11 | 12 | def to_markdown_fixed(docstring, module): 13 | md = to_markdown(docstring, module=module) 14 | md = md.replace("Args\n-----=", "__Parameters__\n\n") 15 | md = md.replace("Returns\n-----=", "__Returns__\n: ") 16 | 17 | md = re.sub(r"(.+)\n-----=", r"__\1__\n\n", md) 18 | return md 19 | 20 | def is_dataclass_doc(c): 21 | return is_dataclass(c.obj) 22 | %> 23 | 24 | <%def name="deflist(s)"> 25 | ${to_markdown_fixed(s, module=m)} 26 | 27 | 28 | <%def name="h2(s)">## ${s} 29 | 30 | 31 | <%def name="h3(s)">### ${s} 32 | 33 | 34 | <%def name="h4(s)">#### ${s} 35 | 36 | 37 | <%def name="ref(s)"> 38 | <% 39 | def make_link(match): 40 | fullname = match.group(0) 41 | href = anchor(fullname) 42 | qualname = fullname.split('.')[-1] 43 | 44 | return f'{qualname}' 45 | 46 | 47 | s, _ = re.subn( 48 | r'groundwork\.[^ \[\]]+', 49 | make_link, 50 | s, 51 | ) 52 | return s 53 | %> 54 | 55 | 56 | <%def name="filter_refs(refs)"> 57 | <% 58 | return [ 59 | ref for ref 60 | in refs 61 | if ref.refname.startswith('groundwork.') 62 | and not ref.refname.split('.')[-1].startswith('_') 63 | ] 64 | %> 65 | 66 | 67 | <%def name="anchor(s)"> 68 | <% 69 | parts = s.split('.') 70 | last = parts[-1] 71 | parts.pop(-1) 72 | 73 | return '../' + '.'.join(parts) + '/#' + last.lower().replace(' ', '-') 74 | %> 75 | 76 | 77 | <%def name="function(func)" buffered="True"> 78 | <% 79 | returns = show_type_annotations and func.return_annotation() or '' 80 | if returns: 81 | returns = ' \N{non-breaking hyphen}> ' + ref(returns) 82 | %> 83 |
 84 | ${func.name}(${", ".join(func.params(annotate=show_type_annotations))|ref})${returns}
 85 | 
86 | 87 | ${func.docstring | deflist} 88 | 89 | 90 | <%def name="variable(var)" buffered="True"> 91 | <% 92 | annot = show_type_annotations and var.type_annotation() or '' 93 | if annot: 94 | annot = f'
{ref(annot)}
' 95 | %> 96 | 97 | ${annot} 98 | ${var.docstring | deflist} 99 | 100 | 101 | <%def name="class_(cls)" buffered="True"> 102 | 103 | ${cls.docstring | deflist} 104 | 105 | <% 106 | def filter_documented(items): 107 | return [ 108 | item for item in items if item.docstring 109 | ] 110 | 111 | class_vars = cls.class_variables(show_inherited_members, sort=sort_identifiers) 112 | static_methods = filter_documented(cls.functions(show_inherited_members, sort=sort_identifiers)) 113 | inst_vars = cls.instance_variables(show_inherited_members, sort=sort_identifiers) 114 | methods = filter_documented(cls.methods(show_inherited_members, sort=sort_identifiers)) 115 | mro = cls.mro() 116 | subclasses = cls.subclasses() 117 | 118 | if not is_dataclass_doc(cls): 119 | class_vars = filter_documented(class_vars) 120 | %> 121 | 122 | % if mro and len(filter_refs(mro)) > 0: 123 | __Inherits:__ 124 | 125 | % for c in filter_refs(mro): 126 | - [${c.refname}](${c.refname|anchor}) 127 | % endfor 128 | % endif 129 | 130 | % if subclasses and len(filter_refs(subclasses)) > 0: 131 | __Subclasses:__ 132 | 133 | % for c in filter_refs(subclasses): 134 | - [${c.refname}](${c.refname|anchor}) 135 | % endfor 136 | % endif 137 | 138 | % if not is_dataclass_doc(cls): 139 | __Constructor__: 140 | 141 |
142 | ${cls.name}(${", ".join(cls.params(annotate=show_type_annotations))})
143 | 
144 | % endif 145 | 146 | 147 | % if is_dataclass_doc(cls): 148 | 149 | ${h3('Properties')} 150 | 151 | All properties are valid as keyword-args to the constructor. They are required unless marked optional below. 152 | 153 | % for v in class_vars: 154 | ${h4(v.name)} 155 | ${variable(v)} 156 | % endfor 157 | 158 | % endif 159 | 160 | % if not is_dataclass_doc(cls): 161 | 162 | % if class_vars: 163 | ${h3('Class variables')} 164 | % for v in class_vars: 165 | ${h4(v.name)} 166 | ${variable(v)} 167 | % endfor 168 | % endif 169 | 170 | % if inst_vars: 171 | ${h3('Instance variables')} 172 | % for v in inst_vars: 173 | ${h4(v.name)} 174 | ${variable(v)} 175 | % endfor 176 | % endif 177 | 178 | % endif 179 | 180 | % if static_methods: 181 | ${h3('Static methods')} 182 | % for f in static_methods: 183 | ${h4(f.name)} 184 | ${function(f)} 185 | % endfor 186 | % endif 187 | 188 | 189 | % if methods: 190 | ${h3('Methods')} 191 | % for m in methods: 192 | ${h4(m.name)} 193 | ${function(m)} 194 | % endfor 195 | % endif 196 | 197 | 198 | 199 | ## Start the output logic for an entire module. 200 | 201 | <% 202 | variables = module.variables(sort=sort_identifiers) 203 | classes = module.classes(sort=sort_identifiers) 204 | functions = module.functions(sort=sort_identifiers) 205 | submodules = module.submodules() 206 | heading = 'Namespace' if module.is_namespace else 'Module' 207 | %> 208 | 209 | ```python 210 | import ${module.name} 211 | ``` 212 | 213 | ${module.docstring} 214 | 215 | % if submodules: 216 | ## Sub-modules 217 | % for m in submodules: 218 | * [${m.name}](../${m.name}/) 219 | % endfor 220 | % endif 221 | 222 | % for f in functions: 223 | ${h2(f.name)} 224 | ${function(f)} 225 | % endfor 226 | 227 | % for c in classes: 228 | ${h2(c.name)} 229 | ${class_(c)} 230 | % endfor 231 | 232 | % for v in variables: 233 | ${h2(v.name)} 234 | ${variable(v)} 235 | % endfor 236 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module "stimulus-controller-resolver"; 4 | -------------------------------------------------------------------------------- /example/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def use_settings(request): 5 | return {"settings": settings} 6 | -------------------------------------------------------------------------------- /example/frontend/controllers/map-data-controller.ts: -------------------------------------------------------------------------------- 1 | import type { Map, MapLayerEventType } from "mapbox-gl"; 2 | import { MapConfigController } from "../../../frontend/index.lib"; 3 | 4 | export default class MapDataController extends MapConfigController { 5 | static targets = ["eventLog"]; 6 | private readonly eventLogTarget?: HTMLElement; 7 | static values = { 8 | mapEvent: { type: String, default: "click" }, 9 | }; 10 | private mapEventValue!: keyof MapLayerEventType; 11 | 12 | connectMap(map: Map) { 13 | map?.on(this.mapEventValue, (e) => { 14 | if (!this.eventLogTarget) return; 15 | this.eventLogTarget.innerHTML = JSON.stringify(e, null, 2); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /example/frontend/main.ts: -------------------------------------------------------------------------------- 1 | import { startApp } from "../../frontend/index.lib"; 2 | const controllers = import.meta.glob("./controllers/*-controller.ts"); 3 | 4 | startApp(controllers); 5 | -------------------------------------------------------------------------------- /example/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-03 09:07 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="Party", 18 | fields=[ 19 | ("last_sync_time", models.DateTimeField()), 20 | ( 21 | "id", 22 | models.UUIDField( 23 | default=uuid.uuid4, 24 | editable=False, 25 | primary_key=True, 26 | serialize=False, 27 | ), 28 | ), 29 | ("external_id", models.IntegerField()), 30 | ("name", models.CharField(max_length=512)), 31 | ("foreground_colour", models.CharField(max_length=16, null=True)), 32 | ("background_colour", models.CharField(max_length=16, null=True)), 33 | ], 34 | options={ 35 | "abstract": False, 36 | }, 37 | ), 38 | migrations.CreateModel( 39 | name="MP", 40 | fields=[ 41 | ("last_sync_time", models.DateTimeField()), 42 | ( 43 | "id", 44 | models.UUIDField( 45 | default=uuid.uuid4, 46 | editable=False, 47 | primary_key=True, 48 | serialize=False, 49 | ), 50 | ), 51 | ("external_id", models.IntegerField()), 52 | ("name_display_as", models.CharField(max_length=512)), 53 | ("thumbnail_url", models.URLField(max_length=512)), 54 | ( 55 | "latest_party", 56 | models.ForeignKey( 57 | null=True, 58 | on_delete=django.db.models.deletion.SET_NULL, 59 | to="example.party", 60 | ), 61 | ), 62 | ], 63 | options={ 64 | "abstract": False, 65 | }, 66 | ), 67 | migrations.CreateModel( 68 | name="Constituency", 69 | fields=[ 70 | ("last_sync_time", models.DateTimeField()), 71 | ( 72 | "id", 73 | models.UUIDField( 74 | default=uuid.uuid4, 75 | editable=False, 76 | primary_key=True, 77 | serialize=False, 78 | ), 79 | ), 80 | ("external_id", models.IntegerField()), 81 | ("name", models.CharField(max_length=512)), 82 | ("ons_code", models.CharField(max_length=512)), 83 | ( 84 | "current_mp", 85 | models.ForeignKey( 86 | null=True, 87 | on_delete=django.db.models.deletion.SET_NULL, 88 | to="example.mp", 89 | ), 90 | ), 91 | ], 92 | options={ 93 | "abstract": False, 94 | }, 95 | ), 96 | ] 97 | -------------------------------------------------------------------------------- /example/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/example/migrations/__init__.py -------------------------------------------------------------------------------- /example/models.py: -------------------------------------------------------------------------------- 1 | from example.uk.models import * 2 | -------------------------------------------------------------------------------- /example/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static django_vite groundwork_core %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 18 | 19 | {% if settings.GROUNDWORK_USE_BUNDLED_ASSETS %} 20 | {% groundwork_static %} 21 | {% else %} 22 | {% vite_hmr_client %} 23 | {% vite_asset 'example/frontend/main.ts' %} 24 | {% endif %} 25 | 26 | 32 | 33 | {% block extra_css %} {% endblock %} 34 | 35 | 36 | 39 | {% block content %} {% endblock %} 40 | 41 | 42 | -------------------------------------------------------------------------------- /example/templates/map.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load groundwork_geo %} 4 | 5 | {% block content %} 6 | 7 | {% map in_place=False class="vw-100 vh-100" center="[4.53,52.22]" zoom=9 %} 8 |
9 |

Inline popup with access to Map instance

10 |
11 |
12 | {% for id, data in view.sources.items %} 13 | {% map_source id=id data=data %} 14 | {% endfor %} 15 | 16 | {% for layer in view.layers.items %} 17 | {% map_layer layer=layer %} 18 | {% endfor %} 19 | 20 | {% map_canvas %} 21 | {% endmap %} 22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /example/templates/uk_constituencies/mp_list.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |

UK MPs

7 |
8 | 9 |
10 | {% for constituency in object_list %} 11 |
12 |
13 | 14 | 15 |
16 |
{{constituency.name}}
17 | {% if constituency.current_mp %} 18 |
19 |
Current MP
20 |
{{constituency.current_mp.name_display_as|default:"Independent"}}
21 |
22 |
23 |
Party
24 |
{{constituency.current_mp.latest_party.name|default:"Independent"}}
25 |
26 | {% else %} 27 | Seat currently vacant 28 | {% endif %} 29 |
30 |
31 |
32 | {% endfor %} 33 |
34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /example/uk/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from groundwork.core.datasources import SyncConfig, SyncedModel 4 | from groundwork.geo.territories.uk import parliament 5 | 6 | 7 | class Constituency(SyncedModel): 8 | sync_config = SyncConfig( 9 | datasource=parliament.constituencies, 10 | ) 11 | 12 | external_id = models.IntegerField() 13 | name = models.CharField(max_length=512) 14 | ons_code = models.CharField(max_length=512) 15 | current_mp = models.ForeignKey("MP", null=True, on_delete=models.SET_NULL) 16 | 17 | 18 | class MP(SyncedModel): 19 | sync_config = SyncConfig(datasource=parliament.members, sync_interval=None) 20 | 21 | external_id = models.IntegerField() 22 | name_display_as = models.CharField(max_length=512) 23 | thumbnail_url = models.URLField(max_length=512) 24 | latest_party = models.ForeignKey("Party", null=True, on_delete=models.SET_NULL) 25 | 26 | 27 | class Party(SyncedModel): 28 | sync_config = SyncConfig(datasource=parliament.parties, sync_interval=None) 29 | 30 | external_id = models.IntegerField() 31 | name = models.CharField(max_length=512) 32 | foreground_colour = models.CharField(max_length=16, null=True) 33 | background_colour = models.CharField(max_length=16, null=True) 34 | -------------------------------------------------------------------------------- /example/uk/urls.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from django.urls import include, path 4 | 5 | from example.uk import views 6 | 7 | urlpatterns: List[Any] = [path("mps/", views.MpListView.as_view())] 8 | -------------------------------------------------------------------------------- /example/uk/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import ListView 2 | 3 | from example.uk.models import Constituency 4 | 5 | 6 | class MpListView(ListView): 7 | model = Constituency 8 | template_name = "uk_constituencies/mp_list.html" 9 | -------------------------------------------------------------------------------- /example/urls.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from django.urls import include, path 4 | 5 | urlpatterns: List[Any] = [ 6 | path("geo/", include("groundwork.geo.examples")), 7 | path("uk/", include("example.uk.urls")), 8 | ] 9 | -------------------------------------------------------------------------------- /example/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for example project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /frontend/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./util/stimulus-utils"; 2 | export * from "./loader"; 3 | -------------------------------------------------------------------------------- /frontend/core/loader.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Application, 3 | Controller, 4 | ControllerConstructor, 5 | } from "@hotwired/stimulus"; 6 | import StimulusControllerResolver from "stimulus-controller-resolver"; 7 | 8 | /** 9 | * Convenience for: 10 | * - Starting a Stimulus app 11 | * - Registering async loaders for Stimulus controller modules 12 | * - Adding in loaders for all the controllers in this package. 13 | **/ 14 | export function startApp(...modules: AsyncModuleMap[]) { 15 | const app = Application.start(); 16 | const allModules: AsyncModuleMap = Object.assign( 17 | {}, 18 | EXPORTED_MODULES, 19 | ...modules 20 | ); 21 | 22 | const resolver = createAsyncControllerResolver(allModules); 23 | 24 | StimulusControllerResolver.install(app, resolver); 25 | return app; 26 | } 27 | 28 | /** 29 | * Given a module map, return a function that returns the module constructor for an identifier. 30 | **/ 31 | const createAsyncControllerResolver = (pathMap: AsyncModuleMap) => { 32 | // Replace file paths in the module map with the controller identifier 33 | const identifierMap: AsyncModuleMap = Object.fromEntries( 34 | Object.entries(pathMap).flatMap(([path, loader]) => { 35 | const identifier = getIdentifierFromPath(path); 36 | if (identifier) { 37 | return [[identifier, loader]]; 38 | } 39 | 40 | return []; 41 | }) 42 | ); 43 | 44 | return async (key: string) => { 45 | const module = await identifierMap[key]?.(); 46 | if (!module) { 47 | throw Error( 48 | `Controller not found: ${key}. Have you named the file ${key}-controller.ts?` 49 | ); 50 | } 51 | 52 | if (!module.default || !(module.default.prototype instanceof Controller)) { 53 | throw Error( 54 | `Module ${key} should have as its default export a subclass of Controller` 55 | ); 56 | } 57 | 58 | return module.default; 59 | }; 60 | }; 61 | 62 | /** 63 | * Compiled-in references to all controllers in this package 64 | * See: https://vitejs.dev/guide/features.html#glob-import 65 | */ 66 | const EXPORTED_MODULES = import.meta.glob("../**/*-controller.ts"); 67 | 68 | /** 69 | * Given a path to a controller following the Stimulus file naming convention, 70 | * return its identifier 71 | **/ 72 | const getIdentifierFromPath = (fullPath: string) => 73 | /([a-zA-Z-_0-9]*)[-_]controller\.(t|j)sx?$/.exec(fullPath)?.[1]; 74 | 75 | /** 76 | * Mapping of controller identifiers to async loaders for modules exporting a controller. 77 | */ 78 | interface AsyncModuleMap { 79 | [identifier: string]: () => Promise<{ default?: ControllerConstructor }>; 80 | } 81 | -------------------------------------------------------------------------------- /frontend/core/test-utils.ts: -------------------------------------------------------------------------------- 1 | export * from "./util/stimulus-test-utils"; 2 | -------------------------------------------------------------------------------- /frontend/core/util/css-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Load css from string and add to the document's style 3 | * 4 | * @param css Result of import "*.css" 5 | */ 6 | export const createCssLoader = (css: string) => { 7 | let el: HTMLStyleElement | undefined; 8 | 9 | return () => { 10 | if (el) { 11 | return; 12 | } 13 | 14 | el = document.createElement("style"); 15 | el.innerHTML = css as any; 16 | document.head.appendChild(el); 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /frontend/core/util/file-mock.ts: -------------------------------------------------------------------------------- 1 | export default "file-contents"; 2 | -------------------------------------------------------------------------------- /frontend/core/util/promise-utils.ts: -------------------------------------------------------------------------------- 1 | export const resolveablePromise = () => { 2 | let doResolve: (x: T) => void; 3 | const resolve = (x: T) => doResolve(x); 4 | 5 | return Object.assign( 6 | new Promise((resolve) => { 7 | doResolve = resolve; 8 | }), 9 | { resolve } 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /frontend/core/util/stimulus-test-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Application, 3 | Controller, 4 | ControllerConstructor, 5 | } from "@hotwired/stimulus"; 6 | import { 7 | BoundFunctions, 8 | getQueriesForElement, 9 | queries, 10 | } from "@testing-library/dom"; 11 | 12 | /** 13 | * Configuration object for a test fixture 14 | */ 15 | export interface TestFixtureOpts { 16 | /** An html string binding the controller(s) to be tested */ 17 | html: string; 18 | 19 | /** 20 | * List of controller classes made available to the test fixture. 21 | * 22 | * The identifier for the controller is derived from the classname – it is the lowercase version of the classname, 23 | * with any trailing 'controller' removed. 24 | * 25 | * For example: 26 | * - `HelloWorldController` would be referenced via the attribute `data-controller="helloworld"` 27 | * - `Map` would be referenced via the attribute `data-controller="map"` 28 | */ 29 | controllers: ControllerConstructor[]; 30 | } 31 | 32 | /** 33 | * Test fixture binding `@testing-library/dom`'s [queries](https://testing-library.com/docs/queries/about) 34 | * to a rendered stimulus application. 35 | */ 36 | export interface TestFixture extends BoundFunctions { 37 | /** The stimulus application */ 38 | application: Application; 39 | 40 | /** 41 | * Given a controller class and a test id (attached using `data-test-id`), return the matching controller instance. 42 | * Throws if the element is not found or no controller is registered. 43 | * 44 | * @param controller Any controller class 45 | * @param elementOrTestId Either the element the controller is attached to or its test id 46 | * @returns The instance of `controller` bound to the element. 47 | */ 48 | getController( 49 | controller: new (...args: any[]) => T, 50 | elementOrTestId: string | Element 51 | ): T; 52 | } 53 | 54 | /** 55 | * 56 | * 57 | * @param param0 `TestFixtureOpts` object configuring the test application. 58 | * @returns A `TestFixture` instance wrapping a test application. 59 | */ 60 | export const createTestFixture = async ({ 61 | html, 62 | controllers, 63 | }: TestFixtureOpts): Promise => { 64 | // Create an html element containg the test fixture's html 65 | const testCtx = document.createElement("body"); 66 | testCtx.innerHTML = html; 67 | 68 | // Create a new stimulus application and register the test controllers. 69 | const application = Application.start(testCtx); 70 | for (const controller of controllers) { 71 | application.register(getTestControllerIdentifier(controller), controller); 72 | } 73 | 74 | // Allow Stimulus' mutation observers to fire at the end of the event loop so that it binds the controllers 75 | await Promise.resolve(setTimeout); 76 | 77 | // Combine testing-library's queries with some additional utils to create the fixture 78 | const boundQueries = getQueriesForElement(testCtx, queries); 79 | return Object.assign(boundQueries, { 80 | getController( 81 | controller: new (...args: any[]) => T, 82 | elementOrTestId: string | Element 83 | ) { 84 | const el = 85 | typeof elementOrTestId === "string" 86 | ? boundQueries.getByTestId(elementOrTestId) 87 | : elementOrTestId; 88 | 89 | const identifier = getTestControllerIdentifier(controller); 90 | const instance = application.getControllerForElementAndIdentifier( 91 | el, 92 | identifier 93 | ); 94 | if (!instance) { 95 | throw Error( 96 | `No controller with identifier ${identifier} found on element with testid: ${elementOrTestId}` 97 | ); 98 | } 99 | 100 | return instance as T; 101 | }, 102 | application, 103 | }); 104 | }; 105 | 106 | /** 107 | * Given a controller class, return the identifier used to register it. 108 | * 109 | * @param controller Any controller class 110 | * @returns The identifier that the controller is registered with 111 | */ 112 | export const getTestControllerIdentifier = ( 113 | controller: ControllerConstructor 114 | ) => controller.name.toLowerCase().replace(/controller$/, ""); 115 | -------------------------------------------------------------------------------- /frontend/core/util/stimulus-utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Return either the data represented by a #-reference to a json script 3 | * or, if a json string the decoded json value. 4 | * 5 | * This is useful for passing large JSON objects to the frontend that would be unweildy to 6 | * do via stimulus's default json value type 7 | * 8 | * @param data Either a JSON string or a #-reference to a script element whose inner text is JSON. 9 | * @returns The decoded json object 10 | */ 11 | export const getReferencedData = (data: string): T | undefined => { 12 | if (!data) { 13 | return; 14 | } 15 | 16 | if (data.startsWith("#")) { 17 | const json = document.querySelector(data)?.textContent; 18 | return json ? JSON.parse(json) : undefined; 19 | } 20 | 21 | return JSON.parse(data); 22 | }; 23 | -------------------------------------------------------------------------------- /frontend/core/util/types.ts: -------------------------------------------------------------------------------- 1 | export type Dict = Record; 2 | -------------------------------------------------------------------------------- /frontend/geo/__tests__/map-layer.spec.ts: -------------------------------------------------------------------------------- 1 | import MapLayerController from "../controllers/map-layer-controller"; 2 | import { createConfigFixture } from "../utils/map-test-utils"; 3 | 4 | test("attaching a layer adds the layer to the map", async () => { 5 | const someLayer = { 6 | id: "myLayer", 7 | }; 8 | 9 | const fixture = await createConfigFixture(MapLayerController, { 10 | values: { 11 | layer: someLayer, 12 | }, 13 | }); 14 | 15 | expect(fixture.map?.getLayer(someLayer.id)).toEqual(someLayer); 16 | }); 17 | -------------------------------------------------------------------------------- /frontend/geo/__tests__/map-source.spec.ts: -------------------------------------------------------------------------------- 1 | import MapSourceController from "../controllers/map-source-controller"; 2 | import { createConfigFixture } from "../utils/map-test-utils"; 3 | 4 | test("attaching a source adds the source to the map", async () => { 5 | const someId = "map-source-id"; 6 | const someSource = { 7 | type: "geojson", 8 | }; 9 | 10 | const fixture = await createConfigFixture(MapSourceController, { 11 | values: { 12 | id: someId, 13 | data: someSource, 14 | }, 15 | }); 16 | 17 | const source = fixture.map?.getSource(someId); 18 | 19 | expect(source?.type).toEqual(someSource.type); 20 | }); 21 | -------------------------------------------------------------------------------- /frontend/geo/__tests__/map.spec.ts: -------------------------------------------------------------------------------- 1 | import { createTestFixture } from "../../core/test-utils"; 2 | import MapController from "../controllers/map-controller"; 3 | import { MapConfigController } from "../"; 4 | 5 | test("initializes mapbox and binds to canvas", async () => { 6 | const fixture = await createTestFixture({ 7 | controllers: [MapController], 8 | html: ` 9 |
10 |
11 |
12 | `, 13 | }); 14 | 15 | const map = fixture.getController(MapController, "map"); 16 | const mapbox = await map.mapbox; 17 | 18 | expect(mapbox?.getContainer()).toBe(fixture.getByTestId("canvas")); 19 | }); 20 | 21 | test("sets up config controllers", async () => { 22 | const connectMap = jest.fn(); 23 | class SomeConfigController extends MapConfigController { 24 | connectMap = connectMap; 25 | } 26 | 27 | const fixture = await createTestFixture({ 28 | controllers: [MapController, SomeConfigController], 29 | html: ` 30 |
31 | 32 | 33 |
34 |
35 | `, 36 | }); 37 | 38 | const map = fixture.getController(MapController, "map"); 39 | const config = fixture.getController(SomeConfigController, "config"); 40 | await config.ready; 41 | 42 | expect(config.map).toBe(await map.mapbox); 43 | expect(connectMap).toBeCalledWith(await map.mapbox); 44 | }); 45 | -------------------------------------------------------------------------------- /frontend/geo/controllers/map-controller.ts: -------------------------------------------------------------------------------- 1 | import mapboxCSS from "mapbox-gl/dist/mapbox-gl.css"; 2 | 3 | import { Controller } from "@hotwired/stimulus"; 4 | import mapbox from "mapbox-gl"; 5 | import { getReferencedData } from "../../core"; 6 | import { createCssLoader } from "../../core/util/css-utils"; 7 | 8 | /** 9 | * @internal 10 | * 11 | * Symbol used to attach mapbox instance to dom element. 12 | */ 13 | export const MAPBOX_MAP_SYMBOL = "__mapbox_instance"; 14 | 15 | export default class MapController extends Controller { 16 | static targets = ["canvas", "config"]; 17 | static values = { 18 | apiKey: String, 19 | center: String, 20 | style: String, 21 | zoom: Number, 22 | }; 23 | 24 | private apiKeyValue!: string; 25 | private styleValue!: string; 26 | private centerValue!: string; 27 | private zoomValue!: number; 28 | 29 | private canvasTarget!: HTMLElement; 30 | private configTargets!: HTMLElement[]; 31 | 32 | initialize() { 33 | loadCss(); 34 | 35 | if (!this.canvasTarget) { 36 | console.error( 37 | 'No canvas target registered with map controller. Add a child with the attribute `data-map-target="canvas"`' 38 | ); 39 | } 40 | 41 | const el = this.canvasTarget as any; 42 | 43 | // Install the mapbox instance on the canvas element. Adding it here means that frameworks like turbo can make the 44 | // canvas persist between loads while recreating controllers so that its configuration can be driven reactively, 45 | // eg - from the url. 46 | if (!el[MAPBOX_MAP_SYMBOL]) { 47 | el[MAPBOX_MAP_SYMBOL] = this.loadMap(); 48 | } 49 | 50 | // Size the canvas element to match the containing element. 51 | const containerStyle = window.getComputedStyle(this.element); 52 | if (containerStyle.position === "static") { 53 | (this.element as HTMLElement).style.position = "relative"; 54 | } 55 | 56 | this.canvasTarget.style.opacity = "0"; 57 | this.canvasTarget.style.width = "100%"; 58 | this.canvasTarget.style.height = "100%"; 59 | } 60 | 61 | async connect() { 62 | const mapbox = await this.mapbox; 63 | if (!mapbox) { 64 | return; 65 | } 66 | 67 | // Give any config targets the opportunity to configure the map. 68 | for (const target of this.configTargets) { 69 | target.dispatchEvent( 70 | new CustomEvent("map:ready", { 71 | bubbles: false, 72 | detail: { map: mapbox }, 73 | }) 74 | ); 75 | } 76 | } 77 | 78 | /** 79 | * Return the mapbox instance attached to the map canvas element. 80 | */ 81 | get mapbox() { 82 | const el = this.canvasTarget as any; 83 | return el[MAPBOX_MAP_SYMBOL] as Promise | undefined; 84 | } 85 | 86 | /** 87 | * Parse and return the centre value as a json object. 88 | */ 89 | private get latLng() { 90 | return getReferencedData(this.centerValue) ?? [0, 0]; 91 | } 92 | 93 | /** 94 | * Initialize the mapbox instance and attach to the dom. 95 | */ 96 | private loadMap() { 97 | return new Promise(async (resolve) => { 98 | if (!this.apiKeyValue) { 99 | console.error("Mapbox: No API token defined."); 100 | return resolve(undefined); 101 | } 102 | 103 | if (!this.canvasTarget) { 104 | console.error("Mapbox: No canvas target defined."); 105 | return resolve(undefined); 106 | } 107 | 108 | const map = new mapbox.Map({ 109 | accessToken: this.apiKeyValue, 110 | container: this.canvasTarget, 111 | style: this.styleValue || "mapbox://styles/mapbox/streets-v11", 112 | center: this.latLng, 113 | zoom: this.zoomValue || 2, 114 | }); 115 | 116 | // Wait for the map to finish loading before resolving. 117 | map.on("load", () => { 118 | map.resize(); 119 | setTimeout(() => { 120 | this.canvasTarget.style.opacity = "1"; 121 | resolve(map); 122 | }, 120); 123 | }); 124 | }); 125 | } 126 | } 127 | 128 | const loadCss = 129 | import.meta.env.MODE !== "bundled" ? createCssLoader(mapboxCSS) : () => {}; 130 | -------------------------------------------------------------------------------- /frontend/geo/controllers/map-layer-controller.ts: -------------------------------------------------------------------------------- 1 | import type { Map, AnyLayer } from "mapbox-gl"; 2 | import { getReferencedData } from "../../core/util/stimulus-utils"; 3 | import { MapConfigController } from "../utils/map-utils"; 4 | 5 | export default class MapLayerController extends MapConfigController { 6 | static values = { 7 | layer: String, 8 | }; 9 | 10 | layerValue!: string; 11 | 12 | connectMap(map: Map) { 13 | const layer = this.layer; 14 | 15 | if (layer && !map.getLayer(layer.id)) { 16 | map.addLayer(layer); 17 | } 18 | } 19 | 20 | disconnectMap(map: Map) { 21 | const layer = this.layer; 22 | 23 | if (layer) { 24 | map.removeLayer(layer.id); 25 | } 26 | } 27 | 28 | get layer() { 29 | return getReferencedData(this.layerValue); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/geo/controllers/map-source-controller.ts: -------------------------------------------------------------------------------- 1 | import type { Map, AnySourceData } from "mapbox-gl"; 2 | import { getReferencedData } from "../../core/util/stimulus-utils"; 3 | import { MapConfigController } from "../utils/map-utils"; 4 | 5 | export default class MapSourceController extends MapConfigController { 6 | static values = { 7 | id: String, 8 | data: String, 9 | }; 10 | 11 | idValue!: string; 12 | dataValue!: string; 13 | 14 | connectMap(map: Map) { 15 | const data = this.sourceData; 16 | 17 | if (data && !map.getSource(this.idValue)) { 18 | map.addSource(this.idValue, data); 19 | } 20 | } 21 | 22 | disconnectMap(map: Map) { 23 | map.removeLayer(this.idValue); 24 | } 25 | 26 | get sourceData() { 27 | return getReferencedData(this.dataValue); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/geo/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./utils/map-utils"; 2 | -------------------------------------------------------------------------------- /frontend/geo/test-utils.ts: -------------------------------------------------------------------------------- 1 | export * from "./utils/map-test-utils"; 2 | -------------------------------------------------------------------------------- /frontend/geo/utils/map-test-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createTestFixture, 3 | getTestControllerIdentifier, 4 | } from "../../core/test-utils"; 5 | 6 | import MapController from "../controllers/map-controller"; 7 | import { MapConfigController } from "./map-utils"; 8 | 9 | /** 10 | * Utility to set up a 11 | * @param controller 12 | */ 13 | export const createConfigFixture = async ( 14 | controller: new (...args: any[]) => T, 15 | opts: { 16 | values?: {}; 17 | } = {} 18 | ) => { 19 | const identifier = getTestControllerIdentifier(controller); 20 | const attributeString = Object.entries(opts.values ?? {}) 21 | .map( 22 | ([key, val]) => `data-${identifier}-${key}-value='${serializeVal(val)}'` 23 | ) 24 | .join(" "); 25 | 26 | const fixture = await createTestFixture({ 27 | controllers: [MapController, controller], 28 | html: ` 29 |
30 | 31 | 32 |
33 |
34 | `, 35 | }); 36 | 37 | const map = fixture.getController(MapController, "map"); 38 | const config = fixture.getController(controller, "config"); 39 | 40 | await config.ready; 41 | 42 | return Object.assign(fixture, { 43 | map: await map.mapbox, 44 | config, 45 | }); 46 | }; 47 | 48 | const serializeVal = (val: any) => { 49 | if (val === null || val === undefined) { 50 | return ""; 51 | } 52 | 53 | if (typeof val === "object") { 54 | return JSON.stringify(val); 55 | } 56 | 57 | return val; 58 | }; 59 | -------------------------------------------------------------------------------- /frontend/geo/utils/map-utils.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "@hotwired/stimulus"; 2 | import type { Map } from "mapbox-gl"; 3 | import { resolveablePromise } from "../../core/util/promise-utils"; 4 | 5 | /** 6 | * Base class for a map config controller. 7 | * 8 | * Subclassers should override `connectMap` and `disconnectMap`, and/or implement their own event handlers to configure 9 | * the mapbox instance with controls, data sources, custom layers, etc. 10 | */ 11 | export class MapConfigController extends Controller { 12 | map?: Map; 13 | ready = resolveablePromise(); 14 | 15 | initialize() { 16 | this.element.addEventListener("map:ready", this.handleMapReady, { 17 | once: true, 18 | }); 19 | } 20 | 21 | disconnect() { 22 | if (this.map) { 23 | this.disconnectMap(this.map); 24 | } 25 | } 26 | 27 | connectMap(map: Map): void | Promise {} 28 | disconnectMap(map: Map): void | Promise {} 29 | 30 | private handleMapReady = async (event: any) => { 31 | const map = event.detail.map; 32 | this.map = map; 33 | await this.connectMap(map); 34 | this.ready.resolve(); 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /frontend/index.bundled.ts: -------------------------------------------------------------------------------- 1 | import { startApp } from "./core"; 2 | 3 | // Entrypoint for apps where this is included as a simple js include / without their own frontend toolchain. 4 | startApp(); 5 | -------------------------------------------------------------------------------- /frontend/index.lib.ts: -------------------------------------------------------------------------------- 1 | export * from "./core"; 2 | export * from "./geo"; 3 | -------------------------------------------------------------------------------- /frontend/index.test-utils.ts: -------------------------------------------------------------------------------- 1 | export * from "./core/test-utils"; 2 | export * from "./geo/test-utils"; 3 | -------------------------------------------------------------------------------- /groundwork/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/groundwork/__init__.py -------------------------------------------------------------------------------- /groundwork/contrib/airtable/datasources.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Iterable, Optional, TypeVar 2 | 3 | import dataclasses 4 | 5 | from django.conf import settings 6 | from rest_framework_dataclasses.field_utils import get_type_info 7 | 8 | from groundwork.core.datasources import RestDatasource 9 | 10 | ResourceT = TypeVar("ResourceT") 11 | 12 | 13 | def airtable_field(name: str, **kwargs: Dict[str, Any]) -> dataclasses.Field: 14 | """ 15 | Return a [dataclass field](https://docs.python.org/3/library/dataclasses.html#dataclasses.Field) used to annotate 16 | a Resource class with the name of the column in Airtable. 17 | 18 | For example, if you have an Airtable like this: 19 | 20 | | First Name | Last Name | 21 | | ----------- | ---------- | 22 | | Stafford | Beer | 23 | | Clara | Zetkin | 24 | 25 | You could map it onto a django model like this: 26 | 27 | ```python 28 | @dataclass 29 | class People: 30 | id: str 31 | first_name: str = airtable_field('First Name') 32 | last_name: str = airtable_field('Last Name') 33 | ``` 34 | 35 | If you do not annotate your field like this, `AirtableDatasource` will expect your column in Airtable to have the 36 | same name as your Resource class. 37 | 38 | Args: 39 | name: Airtable column name associated with this field. 40 | kwargs: Keyword args passed to [dataclasses.field](https://docs.python.org/3/library/dataclasses.html#dataclasses.field). 41 | 42 | Returns: 43 | A dataclass field descriptor identifying the corresponding Airtable column. 44 | 45 | """ 46 | metadata = {__name__: {"airtable_field": name}} 47 | metadata.update(kwargs.pop("metadata", None) or {}) 48 | 49 | return dataclasses.field(metadata=metadata, **kwargs) 50 | 51 | 52 | class AirtableDatasource(RestDatasource[ResourceT]): 53 | """ 54 | Base class for implementing clients to Airtable bases and converting their responses to resource objects. 55 | 56 | You are encouraged to use Python's inbuilt [`@dataclass`](https://docs.python.org/3/library/dataclasses.html) 57 | decorator and define type hints when defining these classes as this allows type-safe serializers to be 58 | auto-generated and decreases the amount of boilerplate code that you need to write. 59 | 60 | __Example:__ 61 | 62 | Let's assume we have a public airtable with the base id `4rQYK6P56My`. It contains a table called 'Active Members', 63 | which looks like this: 64 | 65 | | First Name | Last Name | 66 | | ----------- | ---------- | 67 | | Stafford | Beer | 68 | | Clara | Zetkin | 69 | 70 | 71 | We can create a datasource for it as follows: 72 | 73 | ```python 74 | from dataclasses import dataclass 75 | from groundwork.contrib.airtable.datasources import AirtableDatasource, airtable_field 76 | 77 | @dataclass 78 | class Person: 79 | id: str 80 | first_name: str = airtable_field('First Name') 81 | last_name: str = airtable_field('Last Name') 82 | 83 | my_datasource = AirtableDatasource( 84 | base_id="4rQYK6P56My", 85 | table_name="Active Members", 86 | resource_class=Person, 87 | ) 88 | ``` 89 | 90 | As with other datasource types, configuration can all either be provided as keyword-args to the constructor, or 91 | overridden in subclasses. 92 | """ 93 | 94 | base_url = "https://api.airtable.com/v0" 95 | 96 | api_key: str 97 | """ 98 | Airtable API key. Required for private Airtable bases. If not defined, will default to the value of 99 | `django.conf.settings.AIRTABLE_API_KEY`. 100 | """ 101 | 102 | base_id: Optional[str] = None 103 | """ 104 | ID of the airtable base. You can find this in your base's [API Docs](https://airtable.com/api) 105 | """ 106 | 107 | table_name: Optional[str] = None 108 | """ 109 | Name of the table to fetch from. 110 | """ 111 | 112 | def __init__(self, resource_type: ResourceT, base=None, table=None, **kwargs): 113 | super().__init__(resource_type=resource_type, **kwargs) 114 | 115 | if not getattr(self, "path", None): 116 | assert self.base_id 117 | assert self.table_name 118 | self.path = f"/{self.base_id}/{self.table_name}" 119 | 120 | if not hasattr(self, "api_key"): 121 | self.api_key = getattr(settings, "AIRTABLE_API_KEY", None) 122 | 123 | def paginate(self, **query: Dict[str, Any]) -> Iterable[ResourceT]: 124 | offset = None 125 | 126 | while True: 127 | if offset is not None: 128 | query["offset"] = offset 129 | data = self.fetch_url(self.url, query) 130 | 131 | yield from data["records"] 132 | 133 | offset = data.get("offset") 134 | if offset is None: 135 | return 136 | 137 | def deserialize(self, data: Dict[str, Any]) -> ResourceT: 138 | field_data = data["fields"] 139 | 140 | mapped_data = { 141 | field.name: self._get_mapped_field_value(field, field_data) 142 | for field in dataclasses.fields(self.resource_type) 143 | } 144 | mapped_data["id"] = data["id"] 145 | 146 | return super().deserialize(mapped_data) 147 | 148 | def get_headers(self) -> Dict[str, str]: 149 | headers = {} 150 | 151 | if self.api_key: 152 | headers["Authorization"] = f"Bearer {self.api_key}" 153 | 154 | return headers 155 | 156 | def _get_mapped_field_name(self, field: dataclasses.Field) -> str: 157 | """ 158 | Look up the mapped field name expected from the Airtable response. 159 | 160 | Args: 161 | field: Dataclass field descriptor for the resource field 162 | 163 | Returns: 164 | Airtable column name defined in the field's metadata. Returns the field name if none found, 165 | """ 166 | 167 | if __name__ not in field.metadata: 168 | return field.name 169 | 170 | return field.metadata[__name__]["airtable_field"] 171 | 172 | def _get_mapped_field_value( 173 | self, field: dataclasses.Field, data: Dict[str, Any] 174 | ) -> Any: 175 | """ 176 | Handle the fact that Airtable omits fields for 'falsy' values. Use the field metadata to determine if we have 177 | a type supporting a 'falsy' value and return it if missing from the airtable response. 178 | 179 | Args: 180 | field: Dataclass field descriptor for the resource field. 181 | data: The raw json object containing field values returned by Airtable. 182 | 183 | Returns: 184 | The value in `data` identified by `field`, with the appropriate 'falsy' value substituted for missing values 185 | if relevant to the field type. 186 | """ 187 | 188 | mapped_name = self._get_mapped_field_name(field) 189 | if mapped_name in data: 190 | return data[mapped_name] 191 | 192 | type_info = get_type_info(field.type) 193 | 194 | if type_info.base_type == bool: 195 | return False 196 | 197 | if type_info.base_type == str: 198 | return "" 199 | 200 | if type_info.is_mapping: 201 | return {} 202 | 203 | if type_info.is_many: 204 | return [] 205 | 206 | return None 207 | -------------------------------------------------------------------------------- /groundwork/core/cache.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, TypeVar 2 | 3 | from django.core.cache import cache 4 | from django.db.models import QuerySet 5 | 6 | from groundwork.core.types import Decorator 7 | 8 | 9 | def django_cached(prefix: str, get_key: Any = None, ttl: int = 500) -> Decorator: 10 | """ 11 | Decorator to cache a function using the default cache. 12 | 13 | Args: 14 | get_key: Return a cache key given the arguments to the function 15 | prefix: Prefix applied to the cache key 16 | ttl: TTL in seconds 17 | 18 | Returns: 19 | A function decorator 20 | """ 21 | 22 | def decorator(fn): 23 | def cached_fn(*args, **kwargs): 24 | key = prefix 25 | if get_key != None: 26 | key += "." + str(get_key(*args, **kwargs)) 27 | 28 | hit = cache.get(key) 29 | if hit is None: 30 | hit = fn(*args, **kwargs) 31 | if isinstance(hit, QuerySet): 32 | hit = tuple(hit[:10000]) 33 | 34 | cache.set(key, hit, ttl) 35 | 36 | return hit 37 | 38 | return cached_fn 39 | 40 | return decorator 41 | 42 | 43 | def django_cached_model_property( 44 | prefix: str, get_key: Any = None, ttl: int = 500 45 | ) -> Decorator: 46 | """ 47 | Decorator to cache a model method using the default cache, scoped to the model instance. 48 | 49 | Args: 50 | get_key: Return a cache key given the arguments to the function 51 | prefix: Prefix applied to the cache key 52 | ttl: TTL in seconds 53 | 54 | Returns: 55 | A method decorator 56 | """ 57 | 58 | if get_key is None: 59 | get_key_on_model = lambda self, *args, **kargs: self.id 60 | else: 61 | get_key_on_model = ( 62 | lambda self, *args, **kwargs: f"{self.id}.{get_key(self, *args, **kwargs)}" 63 | ) 64 | 65 | return django_cached(prefix, get_key=get_key_on_model, ttl=ttl) 66 | -------------------------------------------------------------------------------- /groundwork/core/cron.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | from datetime import timedelta 4 | 5 | import schedule 6 | 7 | 8 | def register_cron(fn: Callable[[], None], interval: timedelta) -> None: 9 | """ 10 | Registers a cron task to run at a specified interval. 11 | 12 | Calling this function alone will not do anything. In order to run pending cron tasks, you must call 13 | `run_pending_cron_tasks` (the included management task `run_pending_cron_tasks` will do this for you on a loop) 14 | 15 | Args: 16 | fn: Function implementing the cron task. 17 | interval: Interval to run the cron task at. 18 | """ 19 | schedule.every(interval=interval.total_seconds()).seconds.do(fn) 20 | 21 | 22 | def run_pending_cron_tasks(all: bool = False) -> None: 23 | """ 24 | Runs all pending cron tasks then returns. 25 | 26 | You usually won't want to call this – unless yu are implementing a custom clock process. In general, you'll want 27 | the management command `run_pending_cron_tasks`, which calls this for you on a loop. 28 | 29 | Args: 30 | all: Run all tasks regardless of whether they're scheduled 31 | """ 32 | 33 | if all: 34 | schedule.run_all() 35 | else: 36 | schedule.run_pending() 37 | -------------------------------------------------------------------------------- /groundwork/core/internal/asset_loader.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for loading the built assets bundled with the python package. 3 | 4 | Adatpted from django-vite package, modified to accomodate the fact that it's being distributed with a library. 5 | """ 6 | 7 | from typing import Dict, List, Optional 8 | 9 | import json 10 | from os.path import dirname 11 | from os.path import join as path_join 12 | from urllib.parse import urljoin 13 | 14 | from django.conf import settings 15 | from django.utils.safestring import mark_safe 16 | 17 | # If using in development or production mode. 18 | DJANGO_VITE_DEV_MODE = getattr(settings, "GROUNDWORK_VITE_DEV_MODE", False) 19 | 20 | # Default Vite server protocol (http or https) 21 | DJANGO_VITE_DEV_SERVER_PROTOCOL = getattr( 22 | settings, "GROUNDWORK_VITE_DEV_SERVER_PROTOCOL", "http" 23 | ) 24 | 25 | # Default vite server hostname. 26 | DJANGO_VITE_DEV_SERVER_HOST = getattr( 27 | settings, "GROUNDWORK_VITE_DEV_SERVER_HOST", "localhost" 28 | ) 29 | 30 | # Default Vite server port. 31 | DJANGO_VITE_DEV_SERVER_PORT = getattr(settings, "GROUNDWORK_VITE_DEV_SERVER_PORT", 3000) 32 | 33 | # Default Vite server path to HMR script. 34 | DJANGO_VITE_WS_CLIENT_URL = getattr( 35 | settings, "GROUNDWORK_VITE_WS_CLIENT_URL", "@vite/client" 36 | ) 37 | 38 | GROUNDWORK_CORE_PATH = dirname(dirname(__file__)) 39 | STATIC_URL = urljoin(settings.STATIC_URL, "groundwork/") 40 | 41 | # Location of Vite compiled assets (only used in Vite production mode). 42 | # Must be included in your "STATICFILES_DIRS". 43 | # In Django production mode this folder need to be collected as static 44 | # files using "python manage.py collectstatic". 45 | if settings.DEBUG: 46 | DJANGO_VITE_ASSETS_PATH = path_join(GROUNDWORK_CORE_PATH, "static", "groundwork") 47 | else: 48 | DJANGO_VITE_ASSETS_PATH = path_join(settings.STATIC_ROOT, "groundwork") 49 | 50 | # Path to your manifest file generated by Vite. 51 | # Should by in "GROUNDWORK_VITE_ASSETS_PATH". 52 | DJANGO_VITE_MANIFEST_PATH = getattr( 53 | settings, 54 | "GROUNDWORK_VITE_MANIFEST_PATH", 55 | path_join( 56 | DJANGO_VITE_ASSETS_PATH, 57 | "manifest.json", 58 | ), 59 | ) 60 | 61 | # Motif in the 'manifest.json' to find the polyfills generated by Vite. 62 | DJANGO_VITE_LEGACY_POLYFILLS_MOTIF = getattr( 63 | settings, "GROUNDWORK_VITE_LEGACY_POLYFILLS_MOTIF", "legacy-polyfills" 64 | ) 65 | 66 | 67 | class GroundworkAssetLoader: 68 | """ 69 | Class handling Vite asset loading. 70 | """ 71 | 72 | _instance = None 73 | 74 | def __init__(self) -> None: 75 | raise RuntimeError("Use the instance() method instead.") 76 | 77 | def generate_vite_asset( 78 | self, 79 | path: str, 80 | scripts_attrs: Optional[Dict[str, str]] = None, 81 | ) -> str: 82 | """ 83 | Generates a " 145 | 146 | def _generate_css_files_of_asset( 147 | self, path: str, already_processed: List[str] 148 | ) -> List[str]: 149 | """ 150 | Generates all CSS tags for dependencies of an asset. 151 | 152 | Args: 153 | path: Path to an asset in the 'manifest.json'. 154 | already_processed: List of already processed CSS file. 155 | 156 | Returns: 157 | List of CSS tags. 158 | """ 159 | 160 | tags = [] 161 | manifest_entry = self._manifest[path] 162 | 163 | if "imports" in manifest_entry: 164 | for import_path in manifest_entry["imports"]: 165 | tags.extend( 166 | self._generate_css_files_of_asset(import_path, already_processed) 167 | ) 168 | 169 | if "css" in manifest_entry: 170 | for css_path in manifest_entry["css"]: 171 | if css_path not in already_processed: 172 | tags.append( 173 | GroundworkAssetLoader._generate_stylesheet_tag(css_path) 174 | ) 175 | 176 | already_processed.append(css_path) 177 | 178 | return tags 179 | 180 | def generate_vite_asset_url(self, path: str) -> str: 181 | """ 182 | Generates only the URL of an asset managed by ViteJS. 183 | Warning, this function does not generate URLs for dependant assets. 184 | 185 | Args: 186 | path: Path to a Vite asset. 187 | 188 | Raises: 189 | RuntimeError: If cannot find the asset path in the manifest (only in production). 190 | 191 | Returns: 192 | The URL of this asset. 193 | """ 194 | 195 | if DJANGO_VITE_DEV_MODE: 196 | return GroundworkAssetLoader._generate_vite_server_url(path) 197 | 198 | if path not in self._manifest: 199 | raise RuntimeError( 200 | f"Cannot find {path} in Vite manifest " 201 | f"at {DJANGO_VITE_MANIFEST_PATH}" 202 | ) 203 | 204 | return self._manifest[path]["file"] 205 | 206 | def generate_vite_legacy_polyfills( 207 | self, 208 | scripts_attrs: Optional[Dict[str, str]] = None, 209 | ) -> str: 210 | """ 211 | Generates a ' 357 | 358 | @staticmethod 359 | def _generate_stylesheet_tag(href: str) -> str: 360 | """ 361 | Generates and HTML stylesheet tag for CSS. 362 | 363 | Args: 364 | href: CSS file URL. 365 | 366 | Returns: 367 | CSS link tag. 368 | """ 369 | 370 | return f'' 371 | 372 | @staticmethod 373 | def _generate_vite_server_url(path: Optional[str] = None) -> str: 374 | """ 375 | Generates an URL to and asset served by the Vite development server. 376 | 377 | Args: 378 | path: Path to the asset. (default: {None}) 379 | 380 | Returns: 381 | str -- Full URL to the asset. 382 | """ 383 | 384 | return urljoin( 385 | f"{DJANGO_VITE_DEV_SERVER_PROTOCOL}://" 386 | f"{DJANGO_VITE_DEV_SERVER_HOST}:{DJANGO_VITE_DEV_SERVER_PORT}", 387 | urljoin(settings.STATIC_URL, path if path is not None else ""), 388 | ) 389 | -------------------------------------------------------------------------------- /groundwork/core/internal/class_util.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, Type 2 | 3 | 4 | def mixin_classes(*classlist): 5 | """ 6 | Create a new class inheriting from classlist in the specified order. 7 | 8 | Useful for allowing configuration classes on models to inherit from the configuration classes of the model's 9 | superclass. 10 | 11 | Args: 12 | classlist: The list of classes to merge together. 13 | 14 | Returns: 15 | A new class mixing `parents` in as supeclasses of `cls1`. 16 | """ 17 | 18 | cls1, *parents = classlist 19 | 20 | return type(cls1.__name__, (cls1, *parents), {}) 21 | 22 | 23 | def get_superclass_of_type( 24 | cls: Type[Any], superclass: Type[Any] 25 | ) -> Optional[Type[Any]]: 26 | """ 27 | Return the first class in a class' method resolution order (other than itself) that is a subclass of a specified 28 | type. 29 | 30 | Args: 31 | cls: The subclass to search the resolution order of. 32 | superclass: The class that the returned value should descend from. 33 | 34 | Returns: 35 | The matching superclass, or None if none was found. 36 | """ 37 | 38 | return next((x for x in cls.__mro__[1:] if issubclass(x, superclass)), None) 39 | -------------------------------------------------------------------------------- /groundwork/core/internal/collection_util.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Tuple, TypeVar 2 | 3 | KeyT = TypeVar("KeyT") 4 | ValT = TypeVar("ValT") 5 | 6 | 7 | def compact_values( 8 | dictlike: Iterable[Tuple[KeyT, ValT]] 9 | ) -> Iterable[Tuple[KeyT, ValT]]: 10 | return ((key, val) for key, val in dictlike if val is not None) 11 | -------------------------------------------------------------------------------- /groundwork/core/internal/sync_manager.py: -------------------------------------------------------------------------------- 1 | from typing import Any, DefaultDict, Dict, Optional, Type 2 | 3 | import logging 4 | import uuid 5 | from collections import defaultdict 6 | from dataclasses import dataclass, field 7 | from datetime import datetime 8 | 9 | from django.db import models, transaction 10 | from django.utils import timezone 11 | 12 | from groundwork.core.datasources import SyncedModel 13 | from groundwork.core.internal.collection_util import compact_values 14 | 15 | 16 | @dataclass 17 | class ModelSyncState: 18 | resolved_instances: Dict[str, Any] = field(default_factory=dict) 19 | 20 | 21 | class SyncManager: 22 | """ 23 | Manages the synchronisation logic for pulling all instances of a remote datasource. 24 | 25 | Due to sometimes needing to recurse through referenced models, a synchronisation session is stateful. 26 | """ 27 | 28 | models: DefaultDict[Type[SyncedModel], ModelSyncState] 29 | 30 | def __init__(self) -> None: 31 | self.models = defaultdict(ModelSyncState) 32 | self.sync_time = timezone.now() 33 | self.ignored_fields = {field.name for field in SyncedModel._meta.get_fields()} 34 | 35 | def sync_model(self, model: Type[SyncedModel]) -> None: 36 | """ 37 | Pull the result of calling list() on a `SyncedModel`'s datasource into the local database. 38 | Recursively resolves relationships to other SyncedModels. 39 | 40 | Args: 41 | model: The model class to sync from its datasource. 42 | """ 43 | 44 | logging.info("Beginning sync of %s…", model._meta.verbose_name) 45 | start_time = datetime.now() 46 | 47 | # First of all, figure out how to join the remote data to the local data 48 | model_join_key = model.sync_config.external_id 49 | resource_join_key = model.sync_config.datasource.identifer 50 | 51 | # Fetch all the models from remote 52 | resources = model.sync_config.datasource.list() 53 | model_state = self.models[model] 54 | 55 | # Iterate over the resources and write them into the database 56 | for resource in resources: 57 | 58 | # We don't want to lock up the database for ages, but also we need a transaction here to ensure that any 59 | # referenced foreign keys are resolved into the database with this instance. 60 | # 61 | # Compromise approach: transaction per instance (and its non-m2m dependencies) 62 | with transaction.atomic(): 63 | resource_id = getattr(resource, resource_join_key) 64 | join_query = {model_join_key: resource_id} 65 | 66 | try: 67 | instance = model.objects.get(**join_query) 68 | 69 | except model.DoesNotExist: 70 | instance = model(**join_query) 71 | 72 | model_state.resolved_instances[resource_id] = instance 73 | 74 | for key, val in self.prepare_resource_attrs_for_save( 75 | model, resource 76 | ).items(): 77 | setattr(instance, key, val) 78 | 79 | instance.save() 80 | 81 | self.set_resource_m2m(model, resource, instance) 82 | 83 | duration = datetime.now() - start_time 84 | logging.info("Completed sync of %s in %s", model._meta.verbose_name, duration) 85 | 86 | def resove_embedded_value(self, model: Type[SyncedModel], resource: Any) -> Any: 87 | """ 88 | Given a resorce object, get or create a model representation for it and return it, updating from the resource 89 | if needed. 90 | 91 | Args: 92 | model: The model class to resolve into. 93 | resource: The resource instance to convert to a model. 94 | 95 | Returns: 96 | Local model representation of the resource, saved in the database. 97 | """ 98 | 99 | identifier_key = model.sync_config.datasource.identifer 100 | identifier = getattr(resource, identifier_key) 101 | 102 | model_state = self.models[model] 103 | model_query = {model.sync_config.external_id: identifier} 104 | 105 | try: 106 | instance = model.objects.get(**model_query) 107 | except model.DoesNotExist: 108 | instance = model() 109 | 110 | model_state.resolved_instances[identifier] = instance 111 | 112 | attrs = self.prepare_resource_attrs_for_save(model, resource) 113 | for key, val in attrs.items(): 114 | setattr(instance, key, val) 115 | 116 | instance.save() 117 | return instance 118 | 119 | def resolve_by_external_id(self, model: Type[SyncedModel], id: Any) -> Any: 120 | """ 121 | Given the external id for an instance of a model class, either: 122 | 123 | - If the instance has already been synced, return it. 124 | - If the instance has not yet been synced, fetch it from the datasource, save a local copy and return 125 | that. 126 | 127 | Args: 128 | model: The model class to resolve into. This model's sync config will be used to fetch the resource if needed. 129 | id: Identifier used to fetch the resource fron the datasource. 130 | 131 | Returns: 132 | The local model representation of the resource identified by `id`. 133 | """ 134 | 135 | # Get the current state for the model type in this sync session 136 | sync_state = self.models[model] 137 | 138 | # If the model has already been referenced elsewhere, return the cached instance we have in memory already. 139 | if id in sync_state.resolved_instances: 140 | return sync_state.resolved_instances[id] 141 | 142 | external_id_field = model.sync_config.external_id 143 | try: 144 | # If a local copy already exists, add it to the in-memory cache and return it 145 | instance = model.objects.get(**{external_id_field: id}) 146 | sync_state.resolved_instances[id] = instance 147 | 148 | return instance 149 | 150 | except model.DoesNotExist: 151 | # If a copy doesn't exist, resolve it from the dtasource. We only resolve enough of its properties 152 | # to save it in the database – we don't recurse into m2m relationships yet – save that for when this model 153 | # gets its own top-level sync. 154 | 155 | # Create the model here. Store it in our cache _before_ resolving its attributes in case there are cyclic 156 | # relationships. 157 | 158 | # Note that this means that this method must be called within a transaction or else saving may throw. 159 | instance = model() 160 | sync_state.resolved_instances[id] = instance 161 | 162 | # Fetch the remote referenced data and assign to the model. 163 | resource = model.sync_config.datasource.get(id) 164 | for key, val in self.prepare_resource_attrs_for_save( 165 | model, resource 166 | ).items(): 167 | setattr(instance, key, val) 168 | 169 | instance.save() 170 | 171 | return instance 172 | 173 | def prepare_resource_attrs_for_save( 174 | self, model: Type[SyncedModel], resource: Any 175 | ) -> Dict[str, Any]: 176 | """ 177 | Given an object returned by the datasource, prepare it for saving to the database. 178 | 179 | The default implementation: 180 | - Strips from each resource any fields not present in the model. 181 | - Prepares each attribute by calling `prepare_field_for_save`. 182 | - Updates the `last_sync_time` attribute with the current date & time. 183 | 184 | Args: 185 | model: The model class detailing how the attributes of `resource` are to be treated. 186 | resource: A resource returned by the datasource. 187 | 188 | Returns: 189 | A dictionary of properties suitable for assigning to an instance of `model`. 190 | """ 191 | 192 | identifier = model.sync_config.datasource.identifer 193 | properties = dict( 194 | compact_values( 195 | (field.name, self.prepare_attr_field_for_save(model, field, resource)) 196 | for field in model._meta.get_fields() 197 | if field.name not in self.ignored_fields 198 | ) 199 | ) 200 | 201 | properties["last_sync_time"] = self.sync_time 202 | properties[model.sync_config.external_id] = getattr(resource, identifier) 203 | return properties 204 | 205 | def set_resource_m2m( 206 | self, model: Type[SyncedModel], resource: Any, instance: Any 207 | ) -> None: 208 | """ 209 | Given an object returned by the datasource and the local model representing it, apply the m2m 210 | relationships in the resource. 211 | 212 | Args: 213 | model: The model class detailing how the attributes of `resource` are to be treated. 214 | resource: A resource returned by the datasource. 215 | instance: The local model instance to update the m2m relationships of. 216 | """ 217 | 218 | resolved_m2m_values = compact_values( 219 | (field.name, self.prepare_m2m_field_for_save(model, field, resource)) 220 | for field in model._meta.get_fields() 221 | ) 222 | 223 | for key, values in resolved_m2m_values: 224 | related_manager = getattr(instance, key) 225 | related_manager.set(values) 226 | 227 | def prepare_attr_field_for_save( 228 | self, model: Type[SyncedModel], field: models.Field, resource: Any 229 | ) -> Optional[Any]: 230 | """ 231 | Given a value returned by the datasource, convert it into a value suitable for saving locally into the 232 | field represented by `field`. 233 | 234 | The default implementation returns the value as-is unless the field is a foreign key, in which case the 235 | value is assumed to be an external identifier and the referenced local instance is returned, fetching it from 236 | the datasource and saving if needed. 237 | 238 | Many-to-many relationships are ignored and handled separately as then can't be applied to a model before it is 239 | saved. 240 | 241 | Args: 242 | model: The model class detailing how the attributes of `resource` are to be treated. 243 | field: The field descriptor that we wish to update the value of. 244 | resource: A resource returned by the datasource. 245 | 246 | Returns: 247 | A value suitable for saving in the slot identified by `field`. Or `None` if no value is suitable. 248 | """ 249 | 250 | resolved_key = self.fetch_urlsource_field_key(model, field.name) 251 | if resolved_key is None: 252 | return None 253 | 254 | value = getattr(resource, resolved_key, None) 255 | if value is None: 256 | return None 257 | 258 | if field.is_relation and issubclass(field.related_model, SyncedModel): 259 | if field.many_to_many: 260 | return None 261 | 262 | if self.is_identifier(value): 263 | return self.resolve_by_external_id(field.related_model, value) 264 | 265 | return self.resove_embedded_value(field.related_model, value) 266 | 267 | return value 268 | 269 | def prepare_m2m_field_for_save( 270 | self, model: Type[SyncedModel], field: models.Field, resource: Any 271 | ) -> Any: 272 | """ 273 | Given a list of external ids returned by the remote datasource, resolve the external ids into the local model 274 | (fetching from remote if needed) and return the new list of related values. 275 | 276 | Args: 277 | model: The model class detailing how the attributes of `resource` are to be treated. 278 | field: The field descriptor that we wish to update the value of. 279 | resource: A resource returned by the datasource. 280 | 281 | Returns: 282 | A list of model instances suitable for assigning to the m2m relationship, or `None` if this is not an m2m 283 | relationship that we need to update. 284 | """ 285 | 286 | # If these aren't referencing another SyncedModel then we have no idea how to map these onto an external id, 287 | # so skip. 288 | if not field.many_to_many or not issubclass(field.related_model, SyncedModel): 289 | return None 290 | 291 | resolved_key = self.fetch_urlsource_field_key(model, field.name) 292 | if resolved_key is None: 293 | return None 294 | 295 | values = getattr(resource, resolved_key, None) 296 | if values is None: 297 | return [] 298 | 299 | return [ 300 | self.resolve_by_external_id(field.related_model, ref) 301 | if self.is_identifier(ref) 302 | else self.resove_embedded_value(field.related_model, ref) 303 | for ref in values 304 | ] 305 | 306 | def fetch_urlsource_field_key( 307 | cls, model: Type[SyncedModel], model_key: str 308 | ) -> Optional[str]: 309 | """ 310 | Return the datasource key for a given model key. 311 | 312 | Args: 313 | model: The model class that the field is being mapped to. 314 | model_key: The field that we wish to update the value of. 315 | 316 | Returns: 317 | The key to look up on the resource to assign to the model. 318 | """ 319 | 320 | if model.sync_config.field_map is None: 321 | return model_key 322 | 323 | return model.sync_config.field_map.get(model_key) 324 | 325 | def is_identifier(self, value: Any) -> bool: 326 | return ( 327 | isinstance(value, str) 328 | or isinstance(value, int) 329 | or isinstance(value, uuid.UUID) 330 | ) 331 | -------------------------------------------------------------------------------- /groundwork/core/management/commands/run_cron_tasks.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | from django.core.management.base import BaseCommand, CommandParser 4 | 5 | from groundwork.core.cron import run_pending_cron_tasks 6 | 7 | 8 | class Command(BaseCommand): 9 | help = "Background worker to run cron tasks" 10 | 11 | def add_arguments(self, parser: CommandParser) -> None: 12 | parser.add_argument( 13 | "--once", 14 | action="store_true", 15 | help="Run all registered tasks once, then exit", 16 | ) 17 | 18 | def handle(self, *args, once, **options): 19 | if once: 20 | run_pending_cron_tasks(all=True) 21 | return 22 | 23 | while True: 24 | run_pending_cron_tasks() 25 | sleep(30.0) 26 | -------------------------------------------------------------------------------- /groundwork/core/template.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import functools 4 | from inspect import getfullargspec, unwrap 5 | 6 | from django.template.library import Library, SimpleNode, parse_bits 7 | 8 | from groundwork.core.types import Decorator 9 | 10 | 11 | def register_block_tag( 12 | library: Library, 13 | takes_context: Optional[bool] = None, 14 | upto: Optional[bool] = None, 15 | name: Optional[str] = None, 16 | ) -> Decorator: 17 | """ 18 | Helper for creating and registering a template tag that contains a block of html _and_ accepts args and kwargs similarly to the `@simple_tag` decorator. 19 | 20 | Wrap a callable in a parser, passing its args and kwargs to the wrapped callable, along with 21 | its content nodelist as the `children` kwarg. 22 | 23 | The implementation is identical to Django's inbuilt Library.simple_tag, except that it continues to 24 | parse up to an end marker. 25 | 26 | Args: 27 | library: The tags library to register the tag with. 28 | takes_context: Whether the tag accepts its parent context. Note that the block's content _always_ receives its parent context. If provided, the decorated function must have `context` as its first parameter. 29 | name: The name of the template tag. Defaults to the function name. 30 | upto: Override the tag signifying the end of the html block. Defaults to "end" prepended to the tag name. 31 | 32 | Returns: 33 | A decorator that can be used similarly to @simple_tag 34 | """ 35 | 36 | def dec(func): 37 | params, varargs, varkw, defaults, kwonly, kwonly_defaults, _ = getfullargspec( 38 | unwrap(func) 39 | ) 40 | function_name = name or getattr(func, "_decorated_function", func).__name__ 41 | upto_name = upto or f"end{function_name}" 42 | 43 | @functools.wraps(func) 44 | def compile_func(parser, token): 45 | bits = token.split_contents()[1:] 46 | target_var = None 47 | nodelist = parser.parse((upto_name,)) 48 | parser.delete_first_token() 49 | 50 | if len(bits) >= 2 and bits[-2] == "as": 51 | target_var = bits[-1] 52 | bits = bits[:-2] 53 | args, kwargs = parse_bits( 54 | parser, 55 | bits, 56 | params, 57 | varargs, 58 | varkw, 59 | defaults, 60 | kwonly, 61 | kwonly_defaults, 62 | takes_context, 63 | function_name, 64 | ) 65 | 66 | return _BlockTagNode( 67 | func, takes_context, args, kwargs, target_var, children=nodelist 68 | ) 69 | 70 | library.tag(function_name, compile_func) 71 | return func 72 | 73 | return dec 74 | 75 | 76 | class _BlockTagNode(SimpleNode): 77 | def __init__(self, *args, children=None, **kwargs): 78 | super().__init__(*args, **kwargs) 79 | self.children = children 80 | 81 | def get_resolved_arguments(self, context): 82 | args, kwargs = super().get_resolved_arguments(context) 83 | kwargs["children"] = self.children 84 | return args, kwargs 85 | -------------------------------------------------------------------------------- /groundwork/core/templatetags/groundwork_core.py: -------------------------------------------------------------------------------- 1 | from django.template import Library 2 | from django.utils.safestring import mark_safe 3 | 4 | from groundwork.core.internal.asset_loader import GroundworkAssetLoader 5 | 6 | register = Library() 7 | 8 | 9 | @register.simple_tag 10 | @mark_safe 11 | def groundwork_static(): 12 | loader = GroundworkAssetLoader.instance() 13 | return "".join( 14 | [ 15 | loader.generate_dynamic_handlers(), 16 | loader.generate_vite_asset("frontend/index.bundled.ts"), 17 | loader.generate_vite_legacy_asset("frontend/index.bundled-legacy.ts"), 18 | ] 19 | ) 20 | -------------------------------------------------------------------------------- /groundwork/core/types.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, TypeVar 2 | 3 | _T = TypeVar("_T", bound=Callable[..., Any]) 4 | 5 | """ 6 | Type hint for a decorator function. 7 | """ 8 | Decorator = Callable[[_T], _T] 9 | -------------------------------------------------------------------------------- /groundwork/geo/docs/map.components.md: -------------------------------------------------------------------------------- 1 | # Map Components 2 | 3 | ## Map 4 | 5 | Renders a Map onto the page. 6 | 7 | === "Django API" 8 | 9 | ```django 10 | {% load groundwork_geo %} 11 | 12 | {% map class="my-map-class" center="[4.53,52.22]" zoom=9 %} 13 | 14 | {% endmap %} 15 | ``` 16 | 17 | __Parameters__ 18 | 19 | __`element`__ 20 | : Optional. Return a cache key given the arguments to the function 21 | 22 | __`style`__ 23 | : Optional. Override the map style on a per-map basis. Defaults to the `MAPBOX_DEFAULT_STYLE` django config. 24 | 25 | __`api_key`__ 26 | : Optional. Override the map API key on a per-map basis. Defaults to the `MAPBOX_PUBLIC_API_KEY` django config. 27 | 28 | __`center`__ 29 | : Optional. Initial [lon,lat] location to center the map on. 30 | 31 | __`zoom`__ 32 | : Optional. Initial zoom value. Defaults to 2. 33 | 34 | Any additional arguments are passed to the map as attributes. 35 | 36 | === "Stimulus API" 37 | 38 | ```html 39 |
46 | 47 |
48 |
49 | ``` 50 | 51 | __Values__ 52 | 53 | __`api-key`__ 54 | : A valid mapbox public API key 55 | 56 | __`center`__ 57 | : JSON array representing a [lon,lat] pair to initially center the map on. 58 | 59 | __`zoom`__ 60 | : Initial map zoom value. Defaults to 2. 61 | 62 | __`style`__ 63 | : Style for the map. Defaults to `mapbox/streets-v11`. 64 | 65 | __Targets__ 66 | 67 | __`canvas`__ 68 | : Required. An element to render the map into. 69 | 70 | __`config`__ 71 | : One or more map config elements. These should have a controller that subclasses MapConfigController 72 | 73 | ## Map source 74 | 75 | Adds a datasource to a map. 76 | 77 | === "Django API" 78 | 79 | ```django 80 | {% load groundwork_geo %} 81 | 82 | {% map %} 83 | {% map_source id="my_datasource_id" data=my_datasource %} 84 | {% endmap %} 85 | ``` 86 | 87 | __Parameters__ 88 | 89 | __`id`__ 90 | : ID for the datasource made available to layers 91 | 92 | __`data`__ 93 | : JSON object conforming to the [Mapbox source specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/) 94 | 95 | === "Stimulus API" 96 | 97 | ```html 98 |
102 |
108 |
109 |
110 |
111 | ``` 112 | 113 | __Parameters__ 114 | 115 | __`id`__ 116 | : ID for the datasource made available to layers 117 | 118 | __`data`__ 119 | : JSON object conforming to the [Mapbox source specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/) 120 | 121 | ## Map layer 122 | 123 | Adds a layer to a map. 124 | 125 | === "Django API" 126 | 127 | ```django 128 | {% load groundwork_geo %} 129 | 130 | {% map %} 131 | {% map_layer layer=my_layer %} 132 | {% endmap %} 133 | ``` 134 | 135 | __Parameters__ 136 | 137 | __`layer`__ 138 | : JSON object conforming to the [Mapbox layer specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/) 139 | 140 | === "Stimulus API" 141 | 142 | ```html 143 |
147 |
152 |
153 |
154 |
155 | ``` 156 | 157 | __Parameters__ 158 | 159 | __`layer`__ 160 | : JSON object conforming to the [Mapbox layer specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/layers/) 161 | -------------------------------------------------------------------------------- /groundwork/geo/examples.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List 2 | 3 | from django.urls import path 4 | from django.views.generic import TemplateView 5 | 6 | 7 | class MapExampleView(TemplateView): 8 | @property 9 | def sources(self): 10 | return { 11 | "mapbox-terrain": { 12 | "type": "vector", 13 | "url": "mapbox://mapbox.mapbox-terrain-v2", 14 | } 15 | } 16 | 17 | @property 18 | def layers(self): 19 | return { 20 | "terrain-data": { 21 | "id": "terrain-data", 22 | "type": "line", 23 | "source": "mapbox-terrain", 24 | "source-layer": "contour", 25 | "layout": {"line-join": "round", "line-cap": "round"}, 26 | "paint": {"line-color": "#ff69b4", "line-width": 1}, 27 | } 28 | } 29 | 30 | 31 | urlpatterns: List[Any] = [ 32 | path( 33 | "map/", 34 | MapExampleView.as_view( 35 | template_name="groundwork/geo/examples/map_example.html" 36 | ), 37 | ), 38 | path( 39 | "complex-map/", 40 | MapExampleView.as_view( 41 | template_name="groundwork/geo/examples/complex_map_example.html" 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /groundwork/geo/templates/groundwork/geo/components/map.html: -------------------------------------------------------------------------------- 1 | <{{element}} 2 | {% for key, val in attrs %} 3 | {% if val %}{{key}}="{{val}}"{% endif %} 4 | {% endfor %} 5 | {% for key, val in values %} 6 | {% if val %}data-map-{{key}}-value="{{val}}"{% endif %} 7 | {% endfor %} 8 | data-controller="map"> 9 | {{slots}} 10 | {% if in_place is False %} 11 | {% comment %}It's up to the developer to place the map canvas{% endcomment %} 12 | {% else %} 13 | {% include "groundwork/geo/components/map_canvas.html" only %} 14 | {% endif %} 15 | 16 | -------------------------------------------------------------------------------- /groundwork/geo/templates/groundwork/geo/components/map_canvas.html: -------------------------------------------------------------------------------- 1 | <{% firstof element "div" %} 2 | {% for key, val in attrs %} 3 | {% if val %}{{key}}="{{val}}"{% endif %} 4 | {% endfor %} 5 | data-map-target="canvas" 6 | > 7 | 8 | -------------------------------------------------------------------------------- /groundwork/geo/templates/groundwork/geo/components/map_config.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | {% for key, value in json.items %} 13 | {{value|json_script:key}} 14 | {% endfor %} 15 | -------------------------------------------------------------------------------- /groundwork/geo/templates/groundwork/geo/examples/complex_map_example.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load groundwork_geo %} 4 | 5 | {% block content %} 6 | 7 | {% map in_place=False center="[4.53,52.22]" zoom=9 %} 8 |
14 |

Inline popup with access to Map instance

15 | 16 | 17 | 18 |
19 | 20 | {% for id, data in view.sources.items %} 21 | {% map_source id=id data=data %} 22 | {% endfor %} 23 | 24 | {% for layer in view.layers.items %} 25 | {% map_layer layer=layer %} 26 | {% endfor %} 27 | 28 | {% map_canvas class="vw-100 vh-100" %} 29 | {% endmap %} 30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /groundwork/geo/templates/groundwork/geo/examples/map_example.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load groundwork_geo %} 4 | 5 | {% block content %} 6 | 7 | {% map class="vw-100 vh-100" center="[4.53,52.22]" zoom=9 %} 8 | {% for id, data in view.sources.items %} 9 | {% map_source id=id data=data %} 10 | {% endfor %} 11 | 12 | {% for layer in view.layers.items %} 13 | {% map_layer layer=layer %} 14 | {% endfor %} 15 | {% endmap %} 16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /groundwork/geo/templatetags/groundwork_geo.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional 2 | 3 | import functools 4 | 5 | from django import template 6 | from django.conf import settings 7 | from django.template.base import token_kwargs 8 | from django.template.library import Library, parse_bits 9 | from django.template.loader import get_template 10 | 11 | from groundwork.core.template import register_block_tag 12 | 13 | register = Library() 14 | 15 | 16 | MAP_TEMPLATE = get_template("groundwork/geo/components/map.html") 17 | 18 | 19 | @register_block_tag(library=register, takes_context=True) 20 | def map( 21 | context: Any, 22 | element: str = "div", 23 | style: Optional[str] = None, 24 | api_key: Optional[str] = None, 25 | center: Any = None, 26 | zoom: Optional[int] = None, 27 | in_place: Optional[bool] = True, 28 | children: Optional[template.NodeList] = None, 29 | **attrs: Dict[str, str], 30 | ) -> template.Node: 31 | if api_key is None: 32 | api_key = getattr(settings, "MAPBOX_PUBLIC_API_KEY", None) 33 | 34 | if style is None: 35 | style = getattr(settings, "MAPBOX_DEFAULT_STYLE", None) 36 | 37 | return MAP_TEMPLATE.render( 38 | { 39 | "element": element, 40 | "in_place": in_place, 41 | "values": ( 42 | ("api-key", api_key), 43 | ("center", center), 44 | ("style", style), 45 | ("zoom", zoom), 46 | ), 47 | "attrs": tuple(attrs.items()), 48 | "slots": children.render(context) if children else "", 49 | } 50 | ) 51 | 52 | 53 | @register.inclusion_tag("groundwork/geo/components/map_canvas.html") 54 | def map_canvas(element: str = "div", **attrs: Dict[str, str]): 55 | return {"element": element, "attrs": tuple(attrs.items())} 56 | 57 | 58 | @register.inclusion_tag("groundwork/geo/components/map_config.html") 59 | def map_source(id, data): 60 | ref = "map_source_config" + "-" + id 61 | 62 | return { 63 | "controller": "map-source", 64 | "values": {"id": id, "data": "#" + ref}, 65 | "json": {ref: data}, 66 | } 67 | 68 | 69 | @register.inclusion_tag("groundwork/geo/components/map_config.html") 70 | def map_layer(id, layer): 71 | ref = "map_layer_config" + "-" + id 72 | 73 | return { 74 | "controller": "map-layer", 75 | "values": {"layer": "#" + ref}, 76 | "json": {ref: layer}, 77 | } 78 | -------------------------------------------------------------------------------- /groundwork/geo/territories/uk/internal/serializers.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Type 2 | 3 | import dataclasses 4 | 5 | from rest_framework.serializers import Field 6 | from rest_framework_dataclasses.serializers import DataclassSerializer 7 | 8 | 9 | class EmbeddedValueField(Field): 10 | """ 11 | Serializer field for decoding embeded resources of the form 12 | 13 | ``` 14 | {"value": {...the thing we actually want }, "links": [...]} 15 | ``` 16 | 17 | Wraps an inner serializer, extracts the value field from the returned data and returns that. 18 | """ 19 | 20 | def __init__(self, serializer, *args, **kwargs): 21 | super().__init__(*args, **kwargs) 22 | self.serializer = serializer 23 | 24 | def to_internal_value(self, data): 25 | return self.serializer.to_internal_value(data["value"]) 26 | 27 | def to_representation(self, value): 28 | return {"value": self.serializer.to_representation(value)} 29 | 30 | 31 | def embedded_value(dataclass: Type[Any]) -> dataclasses.Field: 32 | """ 33 | Convenience function for returning a dataclass field descriptor that informs `DataclassSerializer` that we wish 34 | to use the EmbeddedValueField serializer. 35 | 36 | Args: 37 | dataclass: A dataclass type to deserialize the embedded value to 38 | 39 | Returns: 40 | A dataclass field descriptor. 41 | """ 42 | 43 | dataclass_serializer = type( 44 | f"{dataclass.__name__}Serializer", 45 | (DataclassSerializer,), 46 | {"Meta": type("Meta", (), {"dataclass": dataclass})}, 47 | ) 48 | return dataclasses.field( 49 | metadata={"serializer_field": EmbeddedValueField(dataclass_serializer())} 50 | ) 51 | -------------------------------------------------------------------------------- /groundwork/geo/territories/uk/ons.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | from dataclasses import dataclass 4 | 5 | from groundwork.core.datasources import RestDatasource 6 | 7 | 8 | class OnsCodeType: 9 | WESTMINSTER_CONSTITUENCY_ENGLAND = "E14" 10 | WESTMINSTER_CONSTITUENCY_WALES = "W07" 11 | WESTMINSTER_CONSTITUENCY_SCOTLAND = "S14" 12 | WESTMINSTER_CONSTITUENCY_NI = "N06" 13 | 14 | 15 | @dataclass 16 | class OnsCode: 17 | code: str 18 | label: str 19 | 20 | def is_type(self, *types: str) -> bool: 21 | return next((True for t in types if self.code.startswith(t)), False) 22 | 23 | @property 24 | def is_westminster_constituency(self) -> bool: 25 | return self.is_type( 26 | OnsCodeType.WESTMINSTER_CONSTITUENCY_ENGLAND, 27 | OnsCodeType.WESTMINSTER_CONSTITUENCY_WALES, 28 | OnsCodeType.WESTMINSTER_CONSTITUENCY_SCOTLAND, 29 | OnsCodeType.WESTMINSTER_CONSTITUENCY_NI, 30 | ) 31 | 32 | 33 | ResourceT = TypeVar("ResourceT") 34 | 35 | 36 | class _ONSApiDatasource(RestDatasource[ResourceT]): 37 | base_url = "https://api.beta.ons.gov.uk/v1" 38 | 39 | def paginate(self, **kwargs): 40 | kwargs.setdefault("limit", 100) 41 | 42 | i = 0 43 | 44 | while True: 45 | res = self.fetch_url(self.url, kwargs) 46 | 47 | for item in res["items"]: 48 | yield item 49 | i += 1 50 | 51 | if i >= res["total_count"]: 52 | return 53 | 54 | kwargs["offset"] = i 55 | 56 | 57 | constituency_codes: RestDatasource[OnsCode] = _ONSApiDatasource( 58 | path="/code-lists/parliamentary-constituencies/editions/one-off/codes", 59 | resource_type=OnsCode, 60 | filter=lambda item: item.is_westminster_constituency, 61 | ) 62 | """ 63 | Looks up ONS constituency resources mapping the official constituency name to its ONS code. 64 | 65 | This is primarily used internally to clean data returned by APIs that don't provide ONS codes. 66 | """ 67 | -------------------------------------------------------------------------------- /groundwork/geo/territories/uk/parliament.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, TypeVar, cast 2 | 3 | import re 4 | from dataclasses import dataclass, field 5 | from datetime import datetime 6 | 7 | from djangorestframework_camel_case.parser import CamelCaseJSONParser 8 | 9 | from groundwork.core.cache import django_cached 10 | from groundwork.core.datasources import RestDatasource 11 | from groundwork.geo.territories.uk import ons 12 | from groundwork.geo.territories.uk.internal.serializers import embedded_value 13 | 14 | # https://members-api.parliament.uk/index.html 15 | 16 | 17 | @dataclass 18 | class Party: 19 | """ 20 | Represent a political party 21 | """ 22 | 23 | id: int 24 | name: str 25 | is_lords_main_party: bool 26 | is_lords_spiritual_party: bool 27 | is_independent_party: bool 28 | abbreviation: Optional[str] = None 29 | background_colour: Optional[str] = None 30 | government_type: Optional[int] = None 31 | foreground_colour: Optional[str] = None 32 | 33 | 34 | @dataclass 35 | class Representation: 36 | """ 37 | Represent an MP's period of representation in parliament. 38 | """ 39 | 40 | # Stub definition. 41 | membership_from_id: Optional[int] = None 42 | 43 | 44 | @dataclass 45 | class Member: 46 | """ 47 | Represent an MP. 48 | """ 49 | 50 | id: int 51 | name_list_as: str 52 | name_display_as: str 53 | name_full_title: str 54 | gender: str 55 | thumbnail_url: str 56 | latest_house_membership: Representation 57 | latest_party: Optional[Party] = None 58 | name_address_as: Optional[str] = None 59 | 60 | 61 | @dataclass 62 | class CurrentRepresentation: 63 | """ 64 | Represent a current MP. 65 | """ 66 | 67 | representation: Representation 68 | member: Member = embedded_value(Member) 69 | 70 | 71 | @dataclass 72 | class Constituency: 73 | """ 74 | Represent a Westminster constituency. 75 | """ 76 | 77 | id: int 78 | name: str 79 | start_date: datetime 80 | ons_code: str 81 | end_date: Optional[datetime] = None 82 | current_representation: Optional[CurrentRepresentation] = None 83 | 84 | @property 85 | def current_mp(self) -> Optional[Member]: 86 | if self.current_representation: 87 | return self.current_representation.member 88 | else: 89 | return None 90 | 91 | 92 | ResourceT = TypeVar("ResourceT") 93 | 94 | 95 | class _ParliamentApiDatasource(RestDatasource[ResourceT]): 96 | parser_class = CamelCaseJSONParser 97 | base_url = "https://members-api.parliament.uk/api" 98 | list_suffix = "/Search" 99 | 100 | def flatten_resource(self, data: Any) -> Any: 101 | if set(data.keys()) == {"value", "links"}: 102 | data = data["value"] 103 | 104 | return data 105 | 106 | def deserialize(self, data: Any) -> ResourceT: 107 | return super().deserialize(self.flatten_resource(data)) 108 | 109 | def paginate(self, **kwargs): 110 | # We use the search API for 'list' operations. A search query must be provided, otherwise no results are 111 | # returned 112 | kwargs.setdefault("searchText", "") 113 | url = self.url + self.list_suffix 114 | 115 | i = 0 116 | 117 | while True: 118 | res = self.fetch_url(url, kwargs) 119 | 120 | for item in res["items"]: 121 | yield item["value"] 122 | i += 1 123 | 124 | kwargs["skip"] = i 125 | if i >= res["total_results"]: 126 | return 127 | 128 | 129 | class _ParliamentSmallListApiDatasource(_ParliamentApiDatasource[ResourceT]): 130 | """ 131 | Adapt resources that only return a small number of responses and therefore don't support a get() 132 | method. 133 | """ 134 | 135 | list_suffix = "" 136 | 137 | def get(self, id: str, **kwargs: Dict[str, Any]) -> ResourceT: 138 | return cast(ResourceT, next(x for x in self.list() if self.get_id(x) == id)) 139 | 140 | 141 | class _ParliamentConstituenciesDatasource(_ParliamentApiDatasource[Constituency]): 142 | """ 143 | Augments the constituency API response with the ONS code for the constituency, as this is not provided by the 144 | parliament API by default and is widely required for matching to geographical locations. 145 | """ 146 | 147 | path = "/Location/Constituency" 148 | 149 | def deserialize(self, data: Any) -> Any: 150 | data = self.flatten_resource(data) 151 | ons_lookup = self.get_ons_code_lookup() 152 | constituency_name = data["name"].lower() 153 | 154 | data["ons_code"] = ons_lookup[constituency_name] 155 | return super().deserialize(data) 156 | 157 | @django_cached(__name__ + ".ons_code_lookup") 158 | def get_ons_code_lookup(self): 159 | # Retreive constituency codes mapped to official constituency name. This is the only common identifier shared 160 | # by ons and parliament APIs. Although not the most robust imaginable way of doing this, we figure it is better 161 | # for this to fail fast in a list operation (typically in a batch job) rather than failing later 162 | # (typically in response to a user request) 163 | 164 | return { 165 | ons_code.label.lower(): ons_code.code 166 | for ons_code in ons.constituency_codes.list() 167 | } 168 | 169 | 170 | constituencies: RestDatasource[Constituency] = _ParliamentConstituenciesDatasource( 171 | resource_type=Constituency 172 | ) 173 | """ 174 | Resource returning all current UK constituencies, along with their current representation in parliament. 175 | """ 176 | 177 | 178 | members: RestDatasource[Member] = _ParliamentApiDatasource( 179 | path="/Members", 180 | resource_type=Member, 181 | ) 182 | """ 183 | Resource returning all current UK MPs, along with their current representation in parliament. 184 | """ 185 | 186 | 187 | parties: RestDatasource[Party] = _ParliamentSmallListApiDatasource( 188 | path="/Parties/GetActive/Commons", resource_type=Party 189 | ) 190 | """ 191 | Resource returning all current UK political parties represented in Westminster 192 | """ 193 | -------------------------------------------------------------------------------- /groundwork/geo/territories/uk/postcodes.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Optional, TypeVar 2 | 3 | from dataclasses import dataclass 4 | 5 | from django.contrib.gis.geos import Point 6 | 7 | from groundwork.core.datasources import RestDatasource 8 | 9 | 10 | @dataclass 11 | class OnsCodes: 12 | """ 13 | ONS Codes for UK governmental boundaries a postcode falls within. 14 | """ 15 | 16 | admin_district: str 17 | admin_county: str 18 | admin_ward: str 19 | parish: str 20 | parliamentary_constituency: str 21 | ccg: str 22 | ccg_id: str 23 | ced: str 24 | nuts: str 25 | lsoa: str 26 | msoa: str 27 | lau2: str 28 | 29 | 30 | @dataclass 31 | class GeolocatedPostcode: 32 | """ 33 | Metadata about a geolocated postcode. 34 | """ 35 | 36 | postcode: str 37 | quality: int 38 | eastings: int 39 | northings: int 40 | country: str 41 | nhs_ha: str 42 | longitude: float 43 | latitude: float 44 | primary_care_trust: str 45 | region: str 46 | lsoa: str 47 | msoa: str 48 | incode: str 49 | outcode: str 50 | parliamentary_constituency: str 51 | admin_county: Optional[str] 52 | admin_district: str 53 | parish: str 54 | admin_ward: str 55 | ced: Optional[str] 56 | ccg: str 57 | nuts: str 58 | codes: OnsCodes 59 | 60 | def to_point(self): 61 | """ 62 | Representation of this postcode's geolocation as a [Django GIS](https://docs.djangoproject.com/en/3.2/ref/contrib/gis/)-compatible point. 63 | 64 | Returns: 65 | A Django-GIS Point representing the postcode 66 | """ 67 | return Point(self.longitude, self.latitude, srid=4326) 68 | 69 | 70 | ResourceT = TypeVar("ResourceT") 71 | 72 | 73 | class _PostcodesApiDatasource(RestDatasource[ResourceT]): 74 | base_url = "https://api.postcodes.io" 75 | 76 | def fetch_url(self, url: str, query: Dict[str, Any]) -> Any: 77 | res = super().fetch_url(url, query) 78 | return res["result"] 79 | 80 | 81 | postcode: RestDatasource[GeolocatedPostcode] = _PostcodesApiDatasource( 82 | path="/postcodes", 83 | resource_type=GeolocatedPostcode, 84 | ) 85 | """ 86 | Geolocated postcode API resource. 87 | 88 | Only GET requests are supported. 89 | 90 | __`get(postcode)`:__ 91 | 92 | Geocodes `postcode` and returns a `GeolocatedPostcode` instance. 93 | """ 94 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | testEnvironment: "jsdom", 4 | moduleNameMapper: { 5 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": 6 | require.resolve("./frontend/core/util/file-mock.ts"), 7 | "\\.(css|scss)$": require.resolve("./frontend/core/util/file-mock.ts"), 8 | }, 9 | setupFiles: ["mutationobserver-shim"], 10 | testPathIgnorePatterns: ["/node_modules/", "/build/"], 11 | }; 12 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /mkdocs.yaml: -------------------------------------------------------------------------------- 1 | site_name: Groundwork 2 | site_url: https://groundwork.commonknowledge.coop/ 3 | repo_url: https://github.com/commonknowledge/groundwork/ 4 | edit_uri: edit/main/docs/ 5 | nav: 6 | - Home: index.md 7 | - Guides: 8 | - Getting Started: guides/getting-started.md 9 | - Data Pipeline: guides/data-pipeline.md 10 | - Reference: 11 | - Core: 12 | - Data sources: api/groundwork.core.datasources.md 13 | - Cron tasks: api/groundwork.core.cron.md 14 | - Utilities: 15 | - Cache utils: api/groundwork.core.cache.md 16 | - Template utils: api/groundwork.core.template.md 17 | - Geo: 18 | - Map Components: components/map.components.md 19 | - UK Geographical Data: 20 | - Postcode Geolocation: api/groundwork.geo.territories.uk.postcodes.md 21 | - Parliament API: api/groundwork.geo.territories.uk.parliament.md 22 | - Integrations: 23 | - Airtable: 24 | - Data Sources: api/groundwork.contrib.airtable.datasources.md 25 | - Contributing: 26 | - Contribution Guidelines: contributing.md 27 | - Developer Setup: developing.md 28 | - Code Style: code-style.md 29 | theme: 30 | name: material 31 | logo: img/logo.jpg 32 | favicon: img/logo.jpg 33 | custom_dir: docs/overrides 34 | font: 35 | text: IBM Plex Sans 36 | features: 37 | - navigation.instant 38 | - navigation.tabs 39 | - navigation.tabs.sticky 40 | - navigation.indexes 41 | markdown_extensions: 42 | - pymdownx.tabbed: 43 | alternate_style: true 44 | - admonition 45 | - def_list 46 | - pymdownx.details 47 | - pymdownx.highlight 48 | - pymdownx.inlinehilite 49 | - pymdownx.superfences 50 | - pymdownx.snippets 51 | extra_css: 52 | - stylesheets/extra.css 53 | extra: 54 | social: 55 | - icon: fontawesome/brands/twitter 56 | link: https://twitter.com/commonknowledge 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "groundwork-django", 3 | "version": "0.1.6", 4 | "repository": "https://github.com/commonknowledge/groundwork.git", 5 | "description": "An integrated Django and Javascript framework for people who build tools for organisers.", 6 | "author": "Common Knowledge ", 7 | "license": "GPL-3.0-only", 8 | "types": "types.d.ts", 9 | "scripts": { 10 | "test": "jest" 11 | }, 12 | "dependencies": { 13 | "events": "^3.3.0", 14 | "stimulus-controller-resolver": "^2.0.1", 15 | "superclusterd": "^1.1.1" 16 | }, 17 | "devDependencies": { 18 | "@babel/preset-env": "^7.16.4", 19 | "@babel/preset-typescript": "^7.16.0", 20 | "@hotwired/stimulus": "^3.0.1", 21 | "@hotwired/turbo": "^7.0.1", 22 | "@microsoft/api-extractor": "^7.18.20", 23 | "@testing-library/dom": "^8.11.1", 24 | "@types/jest": "^27.0.3", 25 | "@types/mapbox-gl": "^2.7.5", 26 | "@vitejs/plugin-legacy": "^1.6.2", 27 | "babel-core": "^6.26.3", 28 | "babel-jest": "^27.4.2", 29 | "babel-preset-vite": "^1.0.4", 30 | "es-module-lexer": "^0.9.3", 31 | "jest": "^27.3.1", 32 | "mapbox-gl": "^2.6.0", 33 | "mutationobserver-shim": "^0.3.7", 34 | "prettier": "^2.4.1", 35 | "ts-jest": "^27.0.7", 36 | "typescript": "^4.4.4", 37 | "vite": "^2.6.14", 38 | "vite-plugin-dynamic-publicpath": "^1.1.0" 39 | }, 40 | "peerDependencies": { 41 | "@hotwired/stimulus": "^3.0.1", 42 | "@testing-library/dom": "^8.11.1", 43 | "mapbox-gl": "^2.6.0" 44 | }, 45 | "files": [ 46 | "build/lib", 47 | "build/test-utils", 48 | "types.d.ts" 49 | ], 50 | "module": "./build/lib/index.js", 51 | "exports": { 52 | ".": { 53 | "import": "./build/lib/index.js" 54 | }, 55 | "./test-utils": { 56 | "import": "./build/test-utils/index.js" 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # Poetry pyproject.toml: https://python-poetry.org/docs/pyproject/ 2 | [build-system] 3 | build-backend = "poetry.core.masonry.api" 4 | requires = [ 5 | "setuptools", 6 | "poetry_core>=1.0.0", 7 | ] 8 | 9 | [tool.poetry] 10 | name = "groundwork-django" 11 | version = "0.1.6" 12 | description = "An integrated Django and Javascript framework for people who build tools for organisers." 13 | readme = "README.md" 14 | authors = ["Common Knowledge "] 15 | license = "GNU GPL v3.0" 16 | repository = "https://github.com/commonknowledge/groundwork" 17 | homepage = "https://groundwork.commonknowledge.coop/" 18 | packages = [ 19 | { include = "groundwork" } 20 | ] 21 | exclude = [] 22 | 23 | # Keywords description https://python-poetry.org/docs/pyproject/#keywords 24 | keywords = [] #! Update me 25 | 26 | # Pypi classifiers: https://pypi.org/classifiers/ 27 | classifiers = [ #! Update me 28 | "Development Status :: 3 - Alpha", 29 | "Intended Audience :: Developers", 30 | "Operating System :: OS Independent", 31 | "Topic :: Software Development :: Libraries :: Python Modules", 32 | "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", 33 | "Programming Language :: Python :: 3", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3.9", 36 | ] 37 | 38 | [tool.poetry.dependencies] 39 | python = "^3.9" 40 | schedule = "^1.1.0" 41 | djangorestframework-camel-case = "^1.2.0" 42 | djangorestframework-dataclasses = "^1.0.0" 43 | 44 | [tool.poetry.dev-dependencies] 45 | django = '^4.1' 46 | django-vite = "^2.0.2" 47 | bandit = "^1.7.0" 48 | black = {version = "^22.3.0", allow-prereleases = true} 49 | dj-database-url = '>=0.5.0' 50 | darglint = "^1.8.0" 51 | isort = {extras = ["colors"], version = "^5.9.3"} 52 | mkdocs = "^1.2.3" 53 | mkdocs-material = "^8.2.8" 54 | pdoc3 = "^0.10.0" 55 | pre-commit = "^2.17.0" 56 | psycopg2 = '^2.8.6' 57 | pydocstyle = "^6.1.1" 58 | pylint = "^2.10.2" 59 | pytest = "^7.1.1" 60 | pytest-django = "^4.4.0" 61 | pyupgrade = "^2.24.0" 62 | safety = "*" 63 | setuptools = "*" 64 | wheel = "^0.38.0" 65 | 66 | [tool.black] 67 | # https://github.com/psf/black 68 | target-version = ["py39"] 69 | line-length = 88 70 | color = true 71 | 72 | exclude = ''' 73 | /( 74 | \.git 75 | | \.hg 76 | | \.mypy_cache 77 | | \.tox 78 | | \.venv 79 | | _build 80 | | buck-out 81 | | node_modules 82 | | build 83 | | dist 84 | | env 85 | | venv 86 | )/ 87 | ''' 88 | 89 | [tool.isort] 90 | # https://github.com/timothycrosley/isort/ 91 | py_version = 39 92 | line_length = 88 93 | 94 | known_typing = ["typing", "types", "typing_extensions", "mypy", "mypy_extensions"] 95 | sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] 96 | include_trailing_comma = true 97 | profile = "black" 98 | multi_line_output = 3 99 | indent = 4 100 | color_output = true 101 | 102 | 103 | [tool.pytest.ini_options] 104 | # https://docs.pytest.org/en/6.2.x/customize.html#pyproject-toml 105 | # Directories that are not visited by pytest collector: 106 | norecursedirs =["hooks", "*.egg", ".eggs", "dist", "build", "docs", ".tox", ".git", "__pycache__"] 107 | doctest_optionflags = ["NUMBER", "NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] 108 | 109 | # Extra options: 110 | addopts = [ 111 | "--strict-markers", 112 | "--tb=short", 113 | "--doctest-modules", 114 | "--doctest-continue-on-failure", 115 | ] 116 | 117 | markers = [ 118 | "integration_test: marks tests as integrtation tests (not run in make lint)", 119 | ] 120 | 121 | DJANGO_SETTINGS_MODULE = "settings" 122 | python_files = "tests.py test_*.py *_tests.py" 123 | -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import dj_database_url 5 | 6 | DEBUG = True 7 | TEMPLATE_DEBUG = True 8 | DJANGO_VITE_DEV_MODE = True 9 | 10 | ALLOWED_HOSTS = ["*"] 11 | EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" 12 | BASE_URL = "http://localhost:8080" 13 | 14 | 15 | BASE_DIR = os.path.dirname(__file__) 16 | PROJECT_DIR = os.path.join(BASE_DIR, "example") 17 | SECRET_KEY = ( 18 | "django-insecure-lt!8@q40mll#wdum^+n!y67i-_3k%1p-9k$5#s!ok2-o8wr7eh" # nosec 19 | ) 20 | 21 | INSTALLED_APPS = [ 22 | "groundwork.core", 23 | "groundwork.geo", 24 | "groundwork.contrib.airtable", 25 | "test", 26 | "example", 27 | "django_vite", 28 | "django.contrib.gis", 29 | "django.contrib.admin", 30 | "django.contrib.auth", 31 | "django.contrib.contenttypes", 32 | "django.contrib.sessions", 33 | "django.contrib.messages", 34 | "django.contrib.staticfiles", 35 | ] 36 | 37 | MIDDLEWARE = [ 38 | "django.contrib.sessions.middleware.SessionMiddleware", 39 | "django.middleware.common.CommonMiddleware", 40 | "django.middleware.csrf.CsrfViewMiddleware", 41 | "django.contrib.auth.middleware.AuthenticationMiddleware", 42 | "django.contrib.messages.middleware.MessageMiddleware", 43 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 44 | "django.middleware.security.SecurityMiddleware", 45 | "django.middleware.locale.LocaleMiddleware", 46 | ] 47 | 48 | ROOT_URLCONF = "example.urls" 49 | 50 | TEMPLATES = [ 51 | { 52 | "BACKEND": "django.template.backends.django.DjangoTemplates", 53 | "DIRS": [ 54 | os.path.join(PROJECT_DIR, "templates"), 55 | ], 56 | "APP_DIRS": True, 57 | "OPTIONS": { 58 | "context_processors": [ 59 | "django.template.context_processors.debug", 60 | "django.template.context_processors.request", 61 | "django.contrib.auth.context_processors.auth", 62 | "django.contrib.messages.context_processors.messages", 63 | "example.context_processors.use_settings", 64 | "django.template.context_processors.i18n", 65 | ], 66 | }, 67 | }, 68 | ] 69 | 70 | WSGI_APPLICATION = "example.wsgi.application" 71 | 72 | 73 | # Database 74 | # https://docs.djangoproject.com/en/3.2/ref/settings/#databases 75 | 76 | if os.getenv("DATABASE_URL"): 77 | DATABASES = { 78 | "default": dj_database_url.parse( 79 | re.sub(r"^postgres(ql)?", "postgis", os.getenv("DATABASE_URL", "")), 80 | conn_max_age=600, 81 | ssl_require=False, 82 | ) 83 | } 84 | 85 | # Password validation 86 | # https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators 87 | 88 | AUTH_PASSWORD_VALIDATORS = [ 89 | { 90 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 91 | }, 92 | { 93 | "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", 94 | }, 95 | { 96 | "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", 97 | }, 98 | { 99 | "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", 100 | }, 101 | ] 102 | 103 | 104 | # Internationalization 105 | # https://docs.djangoproject.com/en/3.2/topics/i18n/ 106 | 107 | LANGUAGE_CODE = "en-us" 108 | 109 | TIME_ZONE = "UTC" 110 | 111 | USE_I18N = True 112 | 113 | USE_L10N = True 114 | 115 | USE_TZ = True 116 | 117 | # Wagtail internationalisation 118 | # https://docs.wagtail.io/en/stable/advanced_topics/i18n.html 119 | 120 | USE_I18N = True 121 | WAGTAIL_I18N_ENABLED = True 122 | 123 | # Static files (CSS, JavaScript, Images) 124 | # https://docs.djangoproject.com/en/3.2/howto/static-files/ 125 | 126 | STATICFILES_FINDERS = [ 127 | "django.contrib.staticfiles.finders.FileSystemFinder", 128 | "django.contrib.staticfiles.finders.AppDirectoriesFinder", 129 | ] 130 | 131 | STATICFILES_DIRS = [ 132 | os.path.join(BASE_DIR, "dist"), 133 | ] 134 | 135 | WEBPACK_LOADER = { 136 | "DEFAULT": { 137 | "BUNDLE_DIR_NAME": "", # must end with slash 138 | "STATS_FILE": os.path.join(BASE_DIR, "dist/webpack-stats.json"), 139 | "POLL_INTERVAL": 0.1, 140 | "TIMEOUT": None, 141 | "IGNORE": [r".+\.hot-update.js", r".+\.map"], 142 | "LOADER_CLASS": "webpack_loader.loader.WebpackLoader", 143 | } 144 | } 145 | 146 | # ManifestStaticFilesStorage is recommended in production, to prevent outdated 147 | # JavaScript / CSS assets being served from cache (e.g. after a Wagtail upgrade). 148 | # See https://docs.djangoproject.com/en/3.2/ref/contrib/staticfiles/#manifeststaticfilesstorage 149 | STATICFILES_STORAGE = "django.contrib.staticfiles.storage.ManifestStaticFilesStorage" 150 | 151 | STATIC_ROOT = os.path.join(BASE_DIR, "static") 152 | STATIC_URL = "/static/" 153 | 154 | MEDIA_ROOT = os.path.join(BASE_DIR, "media") 155 | MEDIA_URL = "/media/" 156 | 157 | DJANGO_VITE_ASSETS_PATH = "/static" 158 | 159 | 160 | # Wagtail settings 161 | 162 | DEFAULT_AUTO_FIELD = "django.db.models.AutoField" 163 | 164 | # Rest settings 165 | 166 | REST_FRAMEWORK = { 167 | "DEFAULT_AUTHENTICATION_CLASSES": [ 168 | "rest_framework.authentication.SessionAuthentication", 169 | "rest_framework.authentication.BasicAuthentication", 170 | ] 171 | } 172 | 173 | 174 | # Logging 175 | 176 | DJANGO_LOG_LEVEL = os.getenv("DJANGO_LOG_LEVEL", "INFO") 177 | 178 | LOGGING = { 179 | "version": 1, 180 | "disable_existing_loggers": False, 181 | "handlers": { 182 | "console": { 183 | "class": "logging.StreamHandler", 184 | }, 185 | }, 186 | "loggers": { 187 | "": { 188 | "handlers": ["console"], 189 | "level": DJANGO_LOG_LEVEL, 190 | }, 191 | }, 192 | } 193 | 194 | INTERNAL_IPS = [ 195 | "127.0.0.1", 196 | ] 197 | 198 | # Test settings 199 | 200 | EXAMPLE_AIRTABLE_BASE = os.getenv("EXAMPLE_AIRTABLE_BASE") 201 | EXAMPLE_AIRTABLE_API_KEY = os.getenv("EXAMPLE_AIRTABLE_API_KEY") 202 | 203 | 204 | try: 205 | from local import * 206 | except ImportError: 207 | pass 208 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/test/__init__.py -------------------------------------------------------------------------------- /test/contrib/airtable/test_airtable_datasource.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dataclasses import dataclass 3 | from test.tags import integration_test 4 | 5 | from django.conf import settings 6 | from django.test import TestCase 7 | 8 | from groundwork.contrib.airtable import datasources 9 | 10 | 11 | @integration_test 12 | class AirtableApiTests(TestCase): 13 | def setUp(self): 14 | self.datasource = datasources.AirtableDatasource( 15 | resource_type=MyResource, 16 | api_key=settings.EXAMPLE_AIRTABLE_API_KEY, 17 | base_id=settings.EXAMPLE_AIRTABLE_BASE, 18 | table_name="Table 1", 19 | ) 20 | 21 | def test_can_paginate_list(self): 22 | self.assertListReturnsAtLeastCount(self.datasource, 120) 23 | 24 | def test_can_get(self): 25 | self.assertCanGetResourceReturnedFromList(self.datasource) 26 | 27 | def assertListReturnsAtLeastCount(self, resource_type, expected): 28 | results = list(resource_type.list()) 29 | self.assertGreater(len(results), expected) 30 | 31 | def assertCanGetResourceReturnedFromList(self, resource_type): 32 | resource = next(resource_type.list()) 33 | resource_type.get(resource_type.get_id(resource)) 34 | 35 | 36 | @dataclass 37 | class MyResource: 38 | id: str 39 | name: str = datasources.airtable_field("Name") 40 | notes: str = datasources.airtable_field("Notes") 41 | -------------------------------------------------------------------------------- /test/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/test/core/__init__.py -------------------------------------------------------------------------------- /test/core/test_cache.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from groundwork.core.cache import cache, django_cached, django_cached_model_property 4 | 5 | 6 | class CacheTestCase(TestCase): 7 | def test_gets_from_cache(self): 8 | @django_cached("test_gets_from_cache", get_key=lambda x: x) 9 | def cached_fn(x): 10 | cached_fn.num_calls += 1 11 | return cached_fn.num_calls 12 | 13 | cached_fn.num_calls = 0 14 | 15 | res = cached_fn(12) 16 | self.assertEqual(res, 1) 17 | self.assertIsNotNone( 18 | cache.get("test_gets_from_cache.12"), "uses expected cache key" 19 | ) 20 | 21 | res = cached_fn(12) 22 | self.assertEqual(res, 1, "doesn’t refetch cached values") 23 | 24 | res = cached_fn(13) 25 | self.assertEqual(res, 2, "distinguishes between serialized parameters") 26 | 27 | def test_gets_from_cache_with_model_property(self): 28 | class SomeModel: 29 | id = "some_id" 30 | num_calls = 0 31 | 32 | @django_cached_model_property( 33 | "test_gets_from_cache", get_key=lambda self, x: x 34 | ) 35 | def cached_fn(self, x): 36 | self.num_calls += 1 37 | return self.num_calls 38 | 39 | instance = SomeModel() 40 | 41 | res = instance.cached_fn(12) 42 | self.assertEqual(res, 1) 43 | self.assertIsNotNone( 44 | cache.get("test_gets_from_cache.some_id.12"), "uses expected cache key" 45 | ) 46 | 47 | res = instance.cached_fn(12) 48 | self.assertEqual(res, 1, "doesn’t refetch cached values") 49 | 50 | res = instance.cached_fn(13) 51 | self.assertEqual(res, 2, "distinguishes between serialized parameters") 52 | -------------------------------------------------------------------------------- /test/core/test_synced_model.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Optional 2 | 3 | from dataclasses import dataclass, field 4 | 5 | from django.db import models 6 | from django.test import TestCase 7 | 8 | from groundwork.core.datasources import MockDatasource, SyncConfig, SyncedModel 9 | 10 | 11 | class SyncedModelTestCase(TestCase): 12 | def setUp(self) -> None: 13 | SomeSyncedModel.sync_config = SomeSyncedModel.initial_config() 14 | SomeRelatedModel.sync_config = SomeRelatedModel.initial_config() 15 | 16 | def test_handles_sync_with_all_optional_fields_provided(self): 17 | SomeSyncedModel.sync_config.datasource.data = [ 18 | SomeResource( 19 | id="1", 20 | required_value="Required 1", 21 | optional_value="Optional 1", 22 | optional_relationship="1", 23 | required_relationship="1", 24 | ), 25 | SomeResource( 26 | id="2", 27 | required_value="Required 2", 28 | optional_value="Optional 2", 29 | optional_relationship="2", 30 | required_relationship="2", 31 | ), 32 | ] 33 | SomeRelatedModel.sync_config.datasource.data = [ 34 | SomeResource( 35 | id="1", 36 | ), 37 | SomeResource( 38 | id="2", 39 | ), 40 | ] 41 | 42 | SomeSyncedModel.sync() 43 | 44 | self.assertModelCount(SomeSyncedModel, 2) 45 | self.assertModelCount(SomeRelatedModel, 2) 46 | 47 | self.assertModelExists( 48 | SomeSyncedModel, 49 | external_id="1", 50 | required_value="Required 1", 51 | optional_value="Optional 1", 52 | optional_relationship__external_id="1", 53 | required_relationship__external_id="1", 54 | ) 55 | 56 | self.assertModelExists( 57 | SomeSyncedModel, 58 | external_id="2", 59 | required_value="Required 2", 60 | optional_value="Optional 2", 61 | optional_relationship__external_id="2", 62 | required_relationship__external_id="2", 63 | ) 64 | 65 | def test_handles_sync_with_no_optional_fields_provided(self): 66 | SomeSyncedModel.sync_config.datasource.data = [ 67 | SomeResource( 68 | id="1", 69 | required_value="Required 1", 70 | ), 71 | SomeResource( 72 | id="2", 73 | required_value="Required 2", 74 | ), 75 | ] 76 | SomeRelatedModel.sync_config.datasource.data = [ 77 | SomeResource( 78 | id="1", 79 | ) 80 | ] 81 | 82 | SomeSyncedModel.sync() 83 | 84 | self.assertModelCount(SomeSyncedModel, 2) 85 | 86 | self.assertModelExists( 87 | SomeSyncedModel, 88 | external_id="1", 89 | required_value="Required 1", 90 | ) 91 | 92 | self.assertModelExists( 93 | SomeSyncedModel, 94 | external_id="2", 95 | required_value="Required 2", 96 | ) 97 | 98 | def test_handles_sync_with_mapped_fields(self): 99 | @dataclass 100 | class MappedResource(SomeResource): 101 | required_value_mapped: str = "" 102 | required_relationship_mapped: str = "" 103 | 104 | SomeSyncedModel.sync_config.field_map = { 105 | "required_value": "required_value_mapped", 106 | "required_relationship": "required_relationship_mapped", 107 | } 108 | 109 | SomeSyncedModel.sync_config.datasource.data = [ 110 | MappedResource( 111 | id="1", 112 | required_value_mapped="Required 1", 113 | required_relationship_mapped="1", 114 | ), 115 | MappedResource( 116 | id="2", 117 | required_value_mapped="Required 2", 118 | required_relationship_mapped="1", 119 | ), 120 | ] 121 | SomeRelatedModel.sync_config.datasource.data = [ 122 | SomeResource( 123 | id="1", 124 | ) 125 | ] 126 | 127 | SomeSyncedModel.sync() 128 | 129 | self.assertModelCount(SomeSyncedModel, 2) 130 | 131 | self.assertModelExists( 132 | SomeSyncedModel, 133 | external_id="1", 134 | required_value="Required 1", 135 | ) 136 | 137 | self.assertModelExists( 138 | SomeSyncedModel, 139 | external_id="2", 140 | required_value="Required 2", 141 | ) 142 | 143 | def test_handles_m2m_relationships(self): 144 | SomeSyncedModel.sync_config.datasource.data = [ 145 | SomeResource(id="1", m2m_relationship=["1", "2"]), 146 | ] 147 | SomeRelatedModel.sync_config.datasource.data = [ 148 | SomeResource( 149 | id="1", 150 | ), 151 | SomeResource( 152 | id="2", 153 | ), 154 | ] 155 | 156 | SomeSyncedModel.sync() 157 | 158 | self.assertModelCount(SomeSyncedModel, 1) 159 | self.assertModelCount(SomeRelatedModel, 2) 160 | 161 | self.assertModelExists( 162 | SomeRelatedModel, external_id="1", m2m_of__external_id="1" 163 | ) 164 | self.assertModelExists( 165 | SomeRelatedModel, external_id="2", m2m_of__external_id="1" 166 | ) 167 | 168 | def test_handles_recursive_relationships(self): 169 | SomeSyncedModel.sync_config.datasource.data = [ 170 | SomeResource(id="1", recursive_relationship="1"), 171 | SomeResource(id="2", recursive_relationship="1"), 172 | ] 173 | 174 | SomeSyncedModel.sync() 175 | 176 | self.assertModelCount(SomeSyncedModel, 2) 177 | 178 | self.assertModelExists( 179 | SomeSyncedModel, external_id="1", recursive_relationship__external_id="1" 180 | ) 181 | 182 | self.assertModelExists( 183 | SomeSyncedModel, external_id="2", recursive_relationship__external_id="1" 184 | ) 185 | 186 | def test_handles_embedded_relationships(self): 187 | SomeSyncedModel.sync_config.datasource.data = [ 188 | SomeResource( 189 | id="1", 190 | optional_relationship="1", 191 | required_relationship=SomeRelatedResource(id="2"), 192 | m2m_relationship=[SomeRelatedResource(id="3")], 193 | ) 194 | ] 195 | 196 | SomeSyncedModel.sync() 197 | 198 | self.assertModelCount(SomeSyncedModel, 1) 199 | self.assertModelCount(SomeRelatedModel, 3) 200 | 201 | self.assertModelExists( 202 | SomeRelatedModel, external_id="2", required_of__external_id="1" 203 | ) 204 | 205 | self.assertModelExists( 206 | SomeRelatedModel, external_id="3", m2m_of__external_id="1" 207 | ) 208 | 209 | def test_syncs_multiple_times_without_error(self): 210 | SomeSyncedModel.sync() 211 | SomeSyncedModel.sync() 212 | 213 | def assertModelCount(self, model, count, **kwargs): 214 | self.assertEqual(model.objects.filter(**kwargs).count(), count) 215 | 216 | def assertModelExists(self, model, **kwargs): 217 | self.assertModelCount(model, 1, **kwargs) 218 | 219 | 220 | @dataclass 221 | class SomeResource: 222 | id: str 223 | required_value: str = "some_value" 224 | required_relationship: str = "1" 225 | m2m_relationship: List[Any] = field(default_factory=list) 226 | optional_value: Optional[Any] = None 227 | optional_relationship: Optional[Any] = None 228 | recursive_relationship: Optional[Any] = None 229 | 230 | 231 | @dataclass 232 | class SomeRelatedResource: 233 | id: str 234 | value: str = "some_value" 235 | 236 | 237 | class SomeSyncedModel(SyncedModel): 238 | @staticmethod 239 | def initial_config(): 240 | return SyncConfig(datasource=MockDatasource([SomeResource(id="1")])) 241 | 242 | sync_config = SyncConfig(datasource=MockDatasource([])) 243 | 244 | external_id = models.CharField(max_length=128) 245 | 246 | required_value = models.CharField(max_length=128) 247 | optional_value = models.CharField(max_length=128, null=True) 248 | 249 | optional_relationship = models.ForeignKey( 250 | "SomeRelatedModel", 251 | null=True, 252 | related_name="optional_of", 253 | on_delete=models.CASCADE, 254 | ) 255 | required_relationship = models.ForeignKey( 256 | "SomeRelatedModel", 257 | related_name="required_of", 258 | on_delete=models.CASCADE, 259 | ) 260 | recursive_relationship = models.ForeignKey( 261 | "SomeSyncedModel", 262 | null=True, 263 | related_name="recursive_of", 264 | on_delete=models.CASCADE, 265 | ) 266 | m2m_relationship = models.ManyToManyField("SomeRelatedModel", related_name="m2m_of") 267 | embedded = models.ForeignKey( 268 | "SomeRelatedModel", null=True, on_delete=models.SET_NULL 269 | ) 270 | 271 | 272 | class SomeRelatedModel(SyncedModel): 273 | @staticmethod 274 | def initial_config(): 275 | return SyncConfig( 276 | datasource=MockDatasource([SomeResource(id="1")]), 277 | sync_interval=None, 278 | ) 279 | 280 | sync_config = SyncConfig(datasource=MockDatasource([])) 281 | 282 | external_id = models.CharField(max_length=128) 283 | name = models.CharField(max_length=128) 284 | -------------------------------------------------------------------------------- /test/geo/test_parliament_api.py: -------------------------------------------------------------------------------- 1 | from test.tags import integration_test 2 | 3 | from django.test import TestCase 4 | 5 | from groundwork.geo.territories.uk import parliament 6 | 7 | 8 | @integration_test 9 | class ParliamentApiTests(TestCase): 10 | def test_returns_constituencies(self): 11 | self.assertListReturnsAtLeastCount(parliament.constituencies, 300) 12 | self.assertCanGetResourceReturnedFromList(parliament.constituencies) 13 | 14 | def test_returns_members(self): 15 | self.assertListReturnsAtLeastCount(parliament.members, 300) 16 | self.assertCanGetResourceReturnedFromList(parliament.members) 17 | 18 | def test_returns_parties(self): 19 | self.assertListReturnsAtLeastCount(parliament.parties, 4) 20 | self.assertCanGetResourceReturnedFromList(parliament.parties) 21 | 22 | def assertListReturnsAtLeastCount(self, resource_type, expected): 23 | results = list(resource_type.list()) 24 | self.assertGreater(len(results), expected) 25 | 26 | def assertCanGetResourceReturnedFromList(self, resource_type): 27 | resource = next(resource_type.list()) 28 | resource_type.get(resource_type.get_id(resource)) 29 | -------------------------------------------------------------------------------- /test/geo/test_postcodes_api.py: -------------------------------------------------------------------------------- 1 | from test.tags import integration_test 2 | 3 | from django.test import TestCase 4 | 5 | from groundwork.geo.territories.uk import postcodes 6 | 7 | 8 | @integration_test 9 | class PostcodesIOApiTests(TestCase): 10 | def test_geocodes_postcode(self): 11 | for example in self.EXAMPLE_POSTCODES: 12 | expected = self.to_value_type(**example) 13 | result = postcodes.postcode.get(example["postcode"]) 14 | self.assertEqual(result.postcode, expected.postcode) 15 | 16 | def to_value_type(self, codes, **kwargs): 17 | return postcodes.GeolocatedPostcode(codes=postcodes.OnsCodes(**codes), **kwargs) 18 | 19 | EXAMPLE_POSTCODES = [ 20 | { 21 | "postcode": "OX49 5NU", 22 | "quality": 1, 23 | "eastings": 464438, 24 | "northings": 195677, 25 | "country": "England", 26 | "nhs_ha": "South Central", 27 | "longitude": -1.069876, 28 | "latitude": 51.6562, 29 | "primary_care_trust": "Oxfordshire", 30 | "region": "South East", 31 | "lsoa": "South Oxfordshire 011B", 32 | "msoa": "South Oxfordshire 011", 33 | "incode": "5NU", 34 | "outcode": "OX49", 35 | "parliamentary_constituency": "Henley", 36 | "admin_district": "South Oxfordshire", 37 | "parish": "Brightwell Baldwin", 38 | "admin_county": "Oxfordshire", 39 | "admin_ward": "Chalgrove", 40 | "ced": "Chalgrove and Watlington", 41 | "ccg": "NHS Oxfordshire", 42 | "nuts": "Oxfordshire CC", 43 | "codes": { 44 | "admin_district": "E07000179", 45 | "admin_county": "E10000025", 46 | "admin_ward": "E05009735", 47 | "parish": "E04008109", 48 | "parliamentary_constituency": "E14000742", 49 | "ccg": "E38000136", 50 | "ccg_id": "10Q", 51 | "ced": "E58001732", 52 | "nuts": "TLJ14", 53 | "lsoa": "E01028601", 54 | "msoa": "E02005968", 55 | "lau2": "E07000179", 56 | }, 57 | }, 58 | { 59 | "postcode": "M32 0JG", 60 | "quality": 1, 61 | "eastings": 379988, 62 | "northings": 395476, 63 | "country": "England", 64 | "nhs_ha": "North West", 65 | "longitude": -2.302836, 66 | "latitude": 53.455654, 67 | "primary_care_trust": "Trafford", 68 | "region": "North West", 69 | "lsoa": "Trafford 003C", 70 | "msoa": "Trafford 003", 71 | "incode": "0JG", 72 | "outcode": "M32", 73 | "parliamentary_constituency": "Stretford and Urmston", 74 | "admin_district": "Trafford", 75 | "parish": "Trafford, unparished area", 76 | "admin_county": None, 77 | "admin_ward": "Gorse Hill", 78 | "ced": None, 79 | "ccg": "NHS Trafford", 80 | "nuts": "Greater Manchester South West", 81 | "codes": { 82 | "admin_district": "E08000009", 83 | "admin_county": "E99999999", 84 | "admin_ward": "E05000829", 85 | "parish": "E43000163", 86 | "parliamentary_constituency": "E14000979", 87 | "ccg": "E38000187", 88 | "ccg_id": "02A", 89 | "ced": "E99999999", 90 | "nuts": "TLD34", 91 | "lsoa": "E01006187", 92 | "msoa": "E02001261", 93 | "lau2": "E08000009", 94 | }, 95 | }, 96 | { 97 | "postcode": "NE30 1DP", 98 | "quality": 1, 99 | "eastings": 435958, 100 | "northings": 568671, 101 | "country": "England", 102 | "nhs_ha": "North East", 103 | "longitude": -1.439269, 104 | "latitude": 55.011303, 105 | "primary_care_trust": "North Tyneside", 106 | "region": "North East", 107 | "lsoa": "North Tyneside 016C", 108 | "msoa": "North Tyneside 016", 109 | "incode": "1DP", 110 | "outcode": "NE30", 111 | "parliamentary_constituency": "Tynemouth", 112 | "admin_district": "North Tyneside", 113 | "parish": "North Tyneside, unparished area", 114 | "admin_county": None, 115 | "admin_ward": "Tynemouth", 116 | "ced": None, 117 | "ccg": "NHS North Tyneside", 118 | "nuts": "Tyneside", 119 | "codes": { 120 | "admin_district": "E08000022", 121 | "admin_county": "E99999999", 122 | "admin_ward": "E05001130", 123 | "parish": "E43000176", 124 | "parliamentary_constituency": "E14001006", 125 | "ccg": "E38000127", 126 | "ccg_id": "99C", 127 | "ced": "E99999999", 128 | "nuts": "TLC22", 129 | "lsoa": "E01008561", 130 | "msoa": "E02001753", 131 | "lau2": "E08000022", 132 | }, 133 | }, 134 | ] 135 | -------------------------------------------------------------------------------- /test/geo/test_tags.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from django.template import Context, Template 4 | from django.test import TestCase, override_settings 5 | 6 | 7 | @override_settings(MAPBOX_PUBLIC_API_KEY="dummy") 8 | class GeoTagsTestCase(TestCase): 9 | def test_renders_map(self): 10 | html = render_template( 11 | "{% load groundwork_geo %}" '{% map class="w-100" zoom=9 %}' "{% endmap %}" 12 | ) 13 | 14 | self.assertInHTML( 15 | '
' 16 | '
' 17 | "
", 18 | html, 19 | ) 20 | 21 | def test_renders_map_in_place_false_without_canvas(self): 22 | html = render_template( 23 | "{% load groundwork_geo %}" 24 | '{% map in_place=False class="w-100" zoom=9 %}' 25 | "{% endmap %}" 26 | ) 27 | 28 | self.assertInHTML( 29 | '
' 30 | "
", 31 | html, 32 | ) 33 | 34 | def test_renders_map_in_place_false_with_canvas(self): 35 | html = render_template( 36 | "{% load groundwork_geo %}" 37 | '{% map in_place=False class="w-100" zoom=9 %}' 38 | '{% map_canvas class="w-50" %}' 39 | "{% endmap %}" 40 | ) 41 | 42 | self.assertInHTML( 43 | '
' 44 | '
' 45 | "
", 46 | html, 47 | ) 48 | 49 | 50 | def render_template(content: str) -> Any: 51 | context = Context() 52 | template_to_render = Template(content) 53 | 54 | return template_to_render.render(context) 55 | -------------------------------------------------------------------------------- /test/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.9 on 2021-12-02 20:01 2 | 3 | import uuid 4 | 5 | import django.db.models.deletion 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="SomeRelatedModel", 18 | fields=[ 19 | ("last_sync_time", models.DateTimeField()), 20 | ( 21 | "id", 22 | models.UUIDField( 23 | default=uuid.uuid4, 24 | editable=False, 25 | primary_key=True, 26 | serialize=False, 27 | ), 28 | ), 29 | ("external_id", models.CharField(max_length=128)), 30 | ("name", models.CharField(max_length=128)), 31 | ], 32 | options={ 33 | "abstract": False, 34 | }, 35 | ), 36 | migrations.CreateModel( 37 | name="SomeSyncedModel", 38 | fields=[ 39 | ("last_sync_time", models.DateTimeField()), 40 | ( 41 | "id", 42 | models.UUIDField( 43 | default=uuid.uuid4, 44 | editable=False, 45 | primary_key=True, 46 | serialize=False, 47 | ), 48 | ), 49 | ("external_id", models.CharField(max_length=128)), 50 | ("required_value", models.CharField(max_length=128)), 51 | ("optional_value", models.CharField(max_length=128, null=True)), 52 | ( 53 | "embedded", 54 | models.ForeignKey( 55 | null=True, 56 | on_delete=django.db.models.deletion.SET_NULL, 57 | to="test.somerelatedmodel", 58 | ), 59 | ), 60 | ( 61 | "m2m_relationship", 62 | models.ManyToManyField( 63 | related_name="m2m_of", to="test.SomeRelatedModel" 64 | ), 65 | ), 66 | ( 67 | "optional_relationship", 68 | models.ForeignKey( 69 | null=True, 70 | on_delete=django.db.models.deletion.CASCADE, 71 | related_name="optional_of", 72 | to="test.somerelatedmodel", 73 | ), 74 | ), 75 | ( 76 | "recursive_relationship", 77 | models.ForeignKey( 78 | null=True, 79 | on_delete=django.db.models.deletion.CASCADE, 80 | related_name="recursive_of", 81 | to="test.somesyncedmodel", 82 | ), 83 | ), 84 | ( 85 | "required_relationship", 86 | models.ForeignKey( 87 | on_delete=django.db.models.deletion.CASCADE, 88 | related_name="required_of", 89 | to="test.somerelatedmodel", 90 | ), 91 | ), 92 | ], 93 | options={ 94 | "abstract": False, 95 | }, 96 | ), 97 | ] 98 | -------------------------------------------------------------------------------- /test/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/commonknowledge/groundwork/7fd441463ebe0d5da386d01b60f6b76fd6382caf/test/migrations/__init__.py -------------------------------------------------------------------------------- /test/models.py: -------------------------------------------------------------------------------- 1 | from test.core.test_synced_model import * 2 | -------------------------------------------------------------------------------- /test/tags.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | integration_test = pytest.mark.integration_test 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./build/ts", 4 | "target": "es2019", 5 | "lib": ["DOM", "ES2019"], 6 | "module": "ES2020", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "strict": true, 12 | "skipLibCheck": true 13 | }, 14 | "exclude": ["build", "types.d.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "groundwork-django" { 2 | export * from "groundwork-django/build/lib"; 3 | } 4 | 5 | declare module "groundwork-django/test-utils" { 6 | export * from "groundwork-django/build/test-utils"; 7 | } 8 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { defineConfig } from "vite"; 3 | import legacy from "@vitejs/plugin-legacy"; 4 | import { useDynamicPublicPath } from "vite-plugin-dynamic-publicpath"; 5 | 6 | import { peerDependencies } from "./package.json"; 7 | import { resolve } from "path"; 8 | 9 | export default defineConfig(({ mode }) => { 10 | // Bundled mode: 11 | // - JS distributed via pypi. npm package and frontend js toolchain not required. 12 | // - All dependencies included in bundle. 13 | // 14 | // Unbundled mode: 15 | // - JS distributed via npm. Frontend widgets require this to be built. 16 | // - Some dependencies included in bundle. 17 | // - Some dependencies externalised (see package.json peerDependencies field) 18 | // 19 | // Test-utils mode: 20 | // - JS distributed via npm. Available via `import 'groundwork-django/test-utils'` 21 | // - Some dependencies included in bundle. 22 | // - Some dependencies externalised (see package.json peerDependencies field) 23 | // - Some dependencies that will throw in test environments (such as mapbox) replaced with the mock we use internally 24 | // for testing. 25 | 26 | const isBundled = mode === "bundled"; 27 | const isDev = mode === "dev"; 28 | 29 | const isTestUtils = mode === "test-utils"; 30 | const isLibrary = !isBundled && !isDev; 31 | 32 | const entrypoint = `frontend/index.${mode}.ts`; 33 | const outDir = `build/${mode}/`; 34 | 35 | const alias = !isTestUtils 36 | ? [] 37 | : TEST_MOCKS.map((moduleName) => ({ 38 | find: new RegExp(`^${moduleName}$`), 39 | replacement: resolve(`__mocks__/${moduleName}.ts`), 40 | })); 41 | 42 | return { 43 | // In bundled mode, we use the dynamicPublicPath plugin instead of a hardcoded asset path 44 | base: isBundled ? "" : "/static/", 45 | 46 | // Treat css as a static asset, so that we can do the head tag injection ourselves. 47 | // Vite's css pipeline doesn't play nicely with libraries – it bundles everything into a root css file, 48 | // which we don't want here. 49 | // assetsInclude: isBundled ? undefined : ["mapbox-gl/dist/mapbox-gl.css"], 50 | optimizeDeps: { 51 | entries: [entrypoint], 52 | }, 53 | plugins: compact([ 54 | isBundled && 55 | legacy({ 56 | polyfills: false, 57 | targets: ["defaults", "not IE 11"], 58 | }), 59 | isBundled && 60 | // In bundled mode, we need to inject STATIC_URL into templates and pick it up with these 61 | useDynamicPublicPath({ 62 | dynamicImportHandler: "window.__groundwork_dynamic_handler__", 63 | dynamicImportPreload: "window.__groundwork_dynamic_preload__", 64 | }), 65 | ]), 66 | resolve: { 67 | alias, 68 | }, 69 | build: { 70 | manifest: isBundled, 71 | cssCodeSplit: true, 72 | emptyOutDir: true, 73 | outDir, 74 | rollupOptions: { 75 | external: isBundled ? [] : Object.keys(peerDependencies), 76 | output: { 77 | dir: outDir, 78 | }, 79 | 80 | input: { 81 | main: entrypoint, 82 | }, 83 | }, 84 | lib: !isLibrary 85 | ? undefined 86 | : { 87 | entry: path.resolve(__dirname, entrypoint), 88 | formats: isBundled ? ["cjs"] : ["es"], 89 | name: "groundwork", 90 | fileName: () => `index.js`, 91 | }, 92 | }, 93 | }; 94 | }); 95 | 96 | const TEST_MOCKS = ["mapbox-gl"]; 97 | 98 | const compact = (array) => array.filter((item) => !!item); 99 | --------------------------------------------------------------------------------