diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c6fba08 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +# example \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..5e10e4f --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,39 @@ +--- +name: Build & Push Docker Image + +on: + workflow_call: + inputs: + environment: + required: true + type: string +jobs: + build: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Declare image's tag + shell: bash + run: | + echo "sha_short=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_ENV" + - name: Build + uses: docker/build-push-action@v6 + with: + context: . + push: false + tags: qmra:${{ env.sha_short }} + load: true + - name: Save + run: docker save qmra > img.tar + - name: Push + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SERVER_SSH_KEY }} + source: "img.tar" + target: ${{ secrets.DEPLOY_PATH }} \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..4c291b4 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,47 @@ +--- +name: CICD Pipeline + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + name: test app + uses: ./.github/workflows/test.yaml + build-dev: + needs: test + name: build-dev + uses: ./.github/workflows/build.yaml + with: + environment: dev + secrets: inherit + deploy-dev: + needs: build-dev + name: deploy-dev + uses: ./.github/workflows/deploy.yaml + with: + environment: dev + secrets: inherit + build-prod: + if: github.ref_name == 'main' + needs: test + name: build-prod + uses: ./.github/workflows/build.yaml + with: + environment: prod + secrets: inherit + deploy-prod: + if: github.ref_name == 'main' + needs: + - build-prod + - deploy-dev + name: deploy-prod + uses: ./.github/workflows/deploy.yaml + with: + environment: prod + secrets: inherit \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml new file mode 100644 index 0000000..b651671 --- /dev/null +++ b/.github/workflows/deploy.yaml @@ -0,0 +1,31 @@ +--- +name: Deploy Django Application + +on: + workflow_call: + inputs: + environment: + required: true + type: string +jobs: + deploy: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Declare image's tag + shell: bash + run: | + echo "sha_short=$(git rev-parse --short "$GITHUB_SHA")" >> "$GITHUB_ENV" + - name: Deploy + uses: appleboy/ssh-action@v1.1.0 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SERVER_SSH_KEY }} + script: | + cd ${{ secrets.DEPLOY_PATH }} && git pull && git checkout ${{ github.head_ref || github.ref_name }} + microk8s ctr image import img.tar && rm img.tar + cd infra/helm + microk8s helm upgrade --install -f ./qmra/${{ inputs.environment }}.values.yaml qmra ./qmra -n qmra --set app_secret_key.value=${{ secrets.APP_SECRET_KEY }},image.tag=${{ env.sha_short }} \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 6450c60..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,35 +0,0 @@ ---- -name: Deploy Django Application - -on: - push: - branches: - - production -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: 3.x - - - name: Install rsync and configure SSH - run: | - sudo apt-get install -y rsync - echo "${{ secrets.DEPLOY_SERVER_SSH_KEY }}" > private_key.pem - chmod 600 private_key.pem - - - name: Deploy to remote server - env: - REMOTE_HOST: ${{ secrets.DEPLOY_HOST }} - REMOTE_USER: ${{ secrets.DEPLOY_USER }} - REMOTE_DIR: ${{ secrets.DEPLOY_PATH }} - run: | - rsync -avz --itemize-changes --omit-dir-times --no-perms --delete --exclude '.git' --exclude 'private_key.pem' --exclude '**/__pycache__' -e "ssh -i private_key.pem -o StrictHostKeyChecking=no" ./ ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }}:${{ env.REMOTE_DIR }} - ssh -i private_key.pem -o StrictHostKeyChecking=no ${{ env.REMOTE_USER }}@${{ env.REMOTE_HOST }} "\ - - sudo /usr/bin/pkill gunicorn || true && cd ${{ secrets.DEPLOY_PATH }}/tools/scripts && /bin/bash 07_run.sh" \ No newline at end of file diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..e1a7ba4 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,18 @@ +--- +name: Test Django Application + +on: + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install dependencies + run: pip install -r requirements.txt && pip install -r requirements.test.txt + - name: Test + run: python manage.py test diff --git a/.gitignore b/.gitignore index 098fe0d..cd37399 100644 --- a/.gitignore +++ b/.gitignore @@ -1,131 +1,13 @@ -# Byte-compiled / optimized / DLL files +.DS_Store __pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints -email_service.ipynb - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ +.idea +.static qmra.db -notebooks \ No newline at end of file +default_qmra_data.db +qmra-prod.db +dump* +prod-migrations/ +*.tar +.vscode +test.zip \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9ff1427 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# pull official base image +FROM python:3.12.5-slim + +# set work directory +WORKDIR qmra + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +RUN apt-get update -y && apt upgrade -y && apt-get clean && rm -rf /var/lib/apt/lists/* +# install dependencies +COPY ./requirements.txt . +RUN pip install --upgrade pip &&\ + pip install --no-cache-dir -r requirements.txt &&\ + pip install --no-cache-dir gunicorn + +# copy project +COPY ./qmra ./qmra +COPY ./manage.py . \ No newline at end of file diff --git a/README.md b/README.md index b21bd55..6aa9cee 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,37 @@ # qmra Web-application for calculating microbial risk for drinking water and water reuse systems. -# Installation -This software can be found [here]("https://www.qmra.org"), but also can be run locally. -For local installation: - - - install git - - setup and activate a virtual environment - - clone this repository - - cd into qmra/tools - -### Install django +## Installation +create a venv +```bash +python -m venv venv ``` -pip install django +source it (Mac/Linux) +```bash +source venv/bin/activate +``` +or on windows +```bash +source venv/Scripts/activate ``` -### Install necessary requirements - -```python +then, install the requirements with +```bash pip install -r requirements.txt - ``` -### Create new superuser for admin page +collect the statics and migrate with +```bash +python manage.py collectstatic +python manage.py migrate ``` -python manage.py createsuperuser -``` -### run app locally +run the app locally -``` +```bash python manage.py runserver - ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8f3b8cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.8' + +services: + web: + build: . + command: > + sh -c "python manage.py collectstatic --noinput --clear && + python manage.py migrate && + gunicorn qmra.wsgi:application --bind 0.0.0.0:8080" + volumes: + - ./qmra.db/:/qmra/qmra.db + - /var/cache/qmra/static:/var/cache/qmra/static + ports: + - 8080:8080 + environment: + DOMAIN_NAME: ${DOMAIN_NAME} \ No newline at end of file diff --git a/infra/bootstrap-k8.sh b/infra/bootstrap-k8.sh new file mode 100644 index 0000000..7b1cbce --- /dev/null +++ b/infra/bootstrap-k8.sh @@ -0,0 +1,52 @@ +#! bin/bash +set -e + +# add admin user +adduser k8admin +usermod -aG sudo k8admin +su - k8admin + +sudo apt update && sudo apt upgrade -y + +# install microk8s +sudo snap install microk8s --classic --channel=1.31 +# add the user +sudo usermod -a -G microk8s $(echo $USER) +mkdir -p ~/.kube +chmod 0700 ~/.kube +newgrp microk8s +# copy the kube config (for k9s) +cd .kube +microk8s config > config +# microk8s status --wait-ready + +# install kubectl +sudo snap install kubectl --classic +kubectl config use-context microk8s + +# install k9s +wget https://github.com/derailed/k9s/releases/download/v0.32.5/k9s_linux_amd64.deb && sudo apt install ./k9s_linux_amd64.deb && rm k9s_linux_amd64.deb + +# put the aliases in bash_aliases +cat < .bash_aliases +alias mk8='microk8s' +alias k8='microk8s kubectl' +alias helm='microk8s helm' +alias sudo='sudo ' +EOF +source .bash_aliases + +# enable add-ons +mk8 enable ingress cert-manager hostpath-storage metrics-server +mk8 disable ha-cluster +#observability dashboard hostpath-storage + +# firewall settings: +sudo ufw default deny incoming +sudo ufw default allow outgoing +sudo ufw allow ssh +sudo ufw allow https +#ufw allow http #necessary for certbot to obtain certificate +sudo ufw enable +# needed by mk8s hostpath-provisionner: +sudo ufw default allow routed \ No newline at end of file diff --git a/infra/build-docker-image.sh b/infra/build-docker-image.sh new file mode 100644 index 0000000..1e4f1c9 --- /dev/null +++ b/infra/build-docker-image.sh @@ -0,0 +1,6 @@ +#! /bin/bash +set -e +docker build . -t dev.qmra:local +docker save dev.qmra > dev.qmra.tar +microk8s ctr image import dev.qmra.tar +rm dev.qmra.tar \ No newline at end of file diff --git a/infra/deployment_scripts/01_setup_server.sh b/infra/deployment_scripts/01_setup_server.sh new file mode 100644 index 0000000..c06ffd9 --- /dev/null +++ b/infra/deployment_scripts/01_setup_server.sh @@ -0,0 +1,31 @@ +#! /bin/bash + +# update system +apt update && apt -y upgrade + + +# enable firewall +ufw default deny incoming +ufw default allow outgoing +ufw allow ssh +ufw allow https +ufw allow http #necessary for certbot to obtain certificate +ufw enable + +# install docker +# Add Docker's official GPG key: +sudo apt-get update +sudo apt-get install ca-certificates curl +sudo install -m 0755 -d /etc/apt/keyrings +sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc + +# Add the repository to Apt sources: +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update + +sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + diff --git a/infra/deployment_scripts/02_setup_nginx.sh b/infra/deployment_scripts/02_setup_nginx.sh new file mode 100644 index 0000000..8c339d5 --- /dev/null +++ b/infra/deployment_scripts/02_setup_nginx.sh @@ -0,0 +1,46 @@ +#! /bin/bash + +apt install nginx -y + + +set -e + +source ./infra/deployment_scripts/django_deployment_vars + + +# Configuring nginx for django + # Setting up nginx +cat <<-EOF1 >/etc/nginx/sites-available/$DOMAIN + server { + listen 80; + listen [::]:80; + server_name $DOMAIN www.$DOMAIN; + root /var/www/$DOMAIN; + location / { + proxy_pass http://localhost:8080; + proxy_set_header Host \$http_host; + proxy_redirect off; + proxy_set_header X-Forwarded-For \$remote_addr; + proxy_set_header X-Forwarded-Proto \$scheme; + client_max_body_size 20m; + } + location /static/ { + alias /var/cache/qmra/static/; + } + } +EOF1 + +cd /etc/nginx/sites-enabled + [ -e $DOMAIN ] \ + || ln -s ../sites-available/$DOMAIN . + + +service nginx start +apt install snapd -y +snap install core +snap refresh core +snap install --classic certbot +if [ ! -f /usr/bin/certbot ]; then + ln -s /snap/bin/certbot /usr/bin/certbot +fi +certbot --nginx diff --git a/infra/deployment_scripts/03_setup_app.sh b/infra/deployment_scripts/03_setup_app.sh new file mode 100644 index 0000000..907f864 --- /dev/null +++ b/infra/deployment_scripts/03_setup_app.sh @@ -0,0 +1,37 @@ +#! /bin/bash + +set -e + +source ./infra/deployment_scripts/django_deployment_vars + +DJANGO_USER=$DJANGO_PROJECT +DJANGO_GROUP=$DJANGO_PROJECT + +# Creating a user and group +if ! getent passwd $DJANGO_PROJECT; then + adduser --system --home=/var/opt/$DJANGO_PROJECT \ + --no-create-home --disabled-password --group \ + --shell=/bin/bash $DJANGO_USER +fi + +# install deps + +source venv/bin/activate +pip install -r requirements.txt + +# create initial db + + +python3 manage.py migrate +python3 manage.py seed_qmra + +## collect static + +mkdir -p /var/cache/qmra/static +chown $DJANGO_USER /var/cache/qmra/static + +python manage.py collectstatic --clear --no-input + +service nginx stop +service nginx start + diff --git a/infra/deployment_scripts/04_deploy_latest.sh b/infra/deployment_scripts/04_deploy_latest.sh new file mode 100644 index 0000000..62d73f1 --- /dev/null +++ b/infra/deployment_scripts/04_deploy_latest.sh @@ -0,0 +1,8 @@ +set -e + +source ./infra/deployment_scripts/django_deployment_vars + +git pull + +docker compose down +DOMAIN_NAME=dev.qmra.org docker compose up -d --build --force-recreate \ No newline at end of file diff --git a/infra/deployment_scripts/django_deployment_vars b/infra/deployment_scripts/django_deployment_vars new file mode 100644 index 0000000..cf3716e --- /dev/null +++ b/infra/deployment_scripts/django_deployment_vars @@ -0,0 +1,17 @@ +DOMAIN=dev.qmra.org + +# You should probably leave these as is +DJANGO_PROJECT=qmra +DJANGO_APP_LOCATION=qmra +DJANGO_APP_DIR=qmra + +DJANGO_PROJECT_REPOSITORY=http://github.com/kwb-r/qmra-webapp.git +REPOSITORY_NAME=qmra +DJANGO_USER=qmra +DJANGO_GROUP=qmra + +# Test with both Python 2 and 3 +PYTHON=3 + +# apache or nginx +WEB_SERVER=nginx diff --git a/infra/helm/monitoring/bootstrap-monitoring.sh b/infra/helm/monitoring/bootstrap-monitoring.sh new file mode 100644 index 0000000..77c46b9 --- /dev/null +++ b/infra/helm/monitoring/bootstrap-monitoring.sh @@ -0,0 +1,15 @@ +#! /bin/bash +set -e +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm repo add grafana https://grafana.github.io/helm-charts +helm repo update + +helm install prometheus prometheus-community/prometheus -n monitoring --create-namespace \ + --set server.global.scrape_interval=30s,server.retention=5d +helm install grafana grafana/grafana -n monitoring \ + --set persistence.storageClassName=microk8s-hostpath +helm upgrade --install loki grafana/loki-stack -n monitoring \ + --set fluent-bit.enabled=false,promtail.enabled=true,grafana.enabled=false,loki.image.tag=2.9.3 + +# get the grafana password: +# microk8s kubectl get secret --namespace monitoring grafana -o jsonpath="{.data.admin-password}" | base64 --decode ; echo \ No newline at end of file diff --git a/infra/helm/qmra/.helmignore b/infra/helm/qmra/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/infra/helm/qmra/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/infra/helm/qmra/Chart.yaml b/infra/helm/qmra/Chart.yaml new file mode 100644 index 0000000..fd1ab1e --- /dev/null +++ b/infra/helm/qmra/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: qmra +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/infra/helm/qmra/dev.values.yaml b/infra/helm/qmra/dev.values.yaml new file mode 100644 index 0000000..0a97e88 --- /dev/null +++ b/infra/helm/qmra/dev.values.yaml @@ -0,0 +1,62 @@ +# Default values for app. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +namespace: qmra +domain: dev.qmra.org +monitoring_domain: dev2.qmra.org +replicaCount: 1 +debug: true + +app_secret_key: + secret_name: qmra-secret-key-secret + value: Y21WaGJHeDVYM05sWTNKbGRGOTJZV3gxWlRFSwo= +configmap_name: qmra-configmap + +sqlite: + mount_path: /var/lib/qmra/qmra.db + hostpath: /var/lib/qmra/qmra.db + +qmra_default: + mount_path: /var/lib/qmra/default_qmra_data.db + hostpath: /var/lib/qmra/default_qmra_data.db + +static: + mount_path: /var/cache/qmra/static + hostpath: /var/cache/qmra/static + +image: + repository: qmra + tag: local + pullPolicy: Never +livenessProbe: + httpGet: + path: /health + port: http +readinessProbe: + httpGet: + path: /ready + port: http +resources: + limits: + cpu: 500m + memory: 1Gi + requests: + cpu: 100m + memory: 256Mi + +ingress: + tlsSecretName: tls-secret-key-ref + +tls: + acme_email: antoine.daurat@kompetenz-wasser.de + + +podAnnotations: + prometheus.io/scrape: "true" + prometheus.io/path: /metrics + prometheus.io/port: "8080" + + + + diff --git a/infra/helm/qmra/prod.values.yaml b/infra/helm/qmra/prod.values.yaml new file mode 100644 index 0000000..ed3d45f --- /dev/null +++ b/infra/helm/qmra/prod.values.yaml @@ -0,0 +1,62 @@ +# Default values for app. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +namespace: qmra +domain: qmra.org +monitoring_domain: monitoring.qmra.org +replicaCount: 1 +debug: false + +app_secret_key: + secret_name: qmra-secret-key-secret + value: Y21WaGJHeDVYM05sWTNKbGRGOTJZV3gxWlRFSwo= +configmap_name: qmra-configmap + +sqlite: + mount_path: /var/lib/qmra/qmra.db + hostpath: /var/lib/qmra/qmra.db + +qmra_default: + mount_path: /var/lib/qmra/default_qmra_data.db + hostpath: /var/lib/qmra/default_qmra_data.db + +static: + mount_path: /var/cache/qmra/static + hostpath: /var/cache/qmra/static + +image: + repository: qmra + tag: local + pullPolicy: Never +livenessProbe: + httpGet: + path: /health + port: http +readinessProbe: + httpGet: + path: /ready + port: http +resources: + limits: + cpu: 500m + memory: 1Gi + requests: + cpu: 100m + memory: 256Mi + +ingress: + tlsSecretName: tls-secret-key-ref + +tls: + acme_email: antoine.daurat@kompetenz-wasser.de + + +podAnnotations: + prometheus.io/scrape: "true" + prometheus.io/path: /metrics + prometheus.io/port: "8080" + + + + diff --git a/infra/helm/qmra/templates/_helpers.tpl b/infra/helm/qmra/templates/_helpers.tpl new file mode 100644 index 0000000..6e13b3a --- /dev/null +++ b/infra/helm/qmra/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "app.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "app.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "app.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "app.labels" -}} +helm.sh/chart: {{ include "app.chart" . }} +{{ include "app.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "app.selectorLabels" -}} +app.kubernetes.io/name: {{ include "app.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "app.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "app.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/infra/helm/qmra/templates/configmap.yaml b/infra/helm/qmra/templates/configmap.yaml new file mode 100644 index 0000000..270665e --- /dev/null +++ b/infra/helm/qmra/templates/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + namespace: {{ .Values.namespace }} + name: {{ .Values.configmap_name }} +data: + DEBUG: "{{ .Values.debug }}" + DOMAIN_NAME: {{ .Values.domain }} + SQLITE_PATH: {{ .Values.sqlite.mount_path }} + DEFAULT_QMRA_PATH: {{ .Values.qmra_default.mount_path }} + STATIC_ROOT: {{ .Values.static.mount_path }} +--- +apiVersion: v1 +kind: Secret +metadata: + namespace: {{ .Values.namespace }} + name: {{ .Values.app_secret_key.secret_name }} +type: Opaque +data: + key: {{ .Values.app_secret_key.value }} \ No newline at end of file diff --git a/infra/helm/qmra/templates/deployment.yaml b/infra/helm/qmra/templates/deployment.yaml new file mode 100644 index 0000000..d9a6285 --- /dev/null +++ b/infra/helm/qmra/templates/deployment.yaml @@ -0,0 +1,136 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "app.fullname" . }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "app.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "app.labels" . | nindent 8 }} + spec: + initContainers: + - name: migrate + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + envFrom: + - configMapRef: + name: {{ .Values.configmap_name }} + volumeMounts: + - name: sqlite + mountPath: {{ .Values.sqlite.mount_path }} + command: [ python, manage.py, migrate ] + - name: migrate-qmra-default + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + envFrom: + - configMapRef: + name: {{ .Values.configmap_name }} + volumeMounts: + - name: qmra-default + mountPath: {{ .Values.qmra_default.mount_path }} + command: [ python, manage.py, migrate, --database, qmra ] + - name: export-default + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + envFrom: + - configMapRef: + name: {{ .Values.configmap_name }} + volumeMounts: + - name: qmra-default + mountPath: {{ .Values.qmra_default.mount_path }} + command: [ python, manage.py, export_default ] + - name: move-static + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + envFrom: + - configMapRef: + name: {{ .Values.configmap_name }} + volumeMounts: + - name: static + mountPath: {{ .Values.static.mount_path }} + command: [ python, manage.py, collectstatic, --noinput ] + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: [gunicorn, qmra.wsgi:application, --bind, 0.0.0.0:8080] + env: + - name: THIS_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: SECRET_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.app_secret_key.secret_name }} + key: key + envFrom: + - configMapRef: + name: {{ .Values.configmap_name }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - name: sqlite + mountPath: {{ .Values.sqlite.mount_path }} + - name: qmra-default + mountPath: {{ .Values.qmra_default.mount_path }} + - name: static + mountPath: {{ .Values.static.mount_path }} + volumes: + - name: sqlite + persistentVolumeClaim: + claimName: {{ include "app.fullname" . }}-sqlite-file-pvc + - name: qmra-default + persistentVolumeClaim: + claimName: {{ include "app.fullname" . }}-qmra-default-file-pvc + - name: static + persistentVolumeClaim: + claimName: {{ include "app.fullname" . }}-static-files-pvc +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "app.fullname" . }}-static + labels: + app.kubernetes.io/name: qmra-static + app.kubernetes.io/instance: qmra-static +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: qmra-static + app.kubernetes.io/instance: qmra-static + template: + metadata: + labels: + app.kubernetes.io/name: qmra-static + app.kubernetes.io/instance: qmra-static + spec: + containers: + - name: nginx-static + image: flashspys/nginx-static + ports: + - name: http + containerPort: 80 + protocol: TCP + volumeMounts: + - name: static + mountPath: /static + volumes: + - name: static + persistentVolumeClaim: + claimName: {{ include "app.fullname" . }}-static-files-pvc \ No newline at end of file diff --git a/infra/helm/qmra/templates/ingress.yaml b/infra/helm/qmra/templates/ingress.yaml new file mode 100644 index 0000000..dd66cf3 --- /dev/null +++ b/infra/helm/qmra/templates/ingress.yaml @@ -0,0 +1,76 @@ +{{- $fullName := include "app.fullname" . -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }} + annotations: + cert-manager.io/cluster-issuer: lets-encrypt +spec: + ingressClassName: nginx + tls: + - hosts: + - {{ .Values.domain }} + secretName: {{ .Values.ingress.tlsSecretName }} + rules: + - host: {{ .Values.domain }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ $fullName }} + port: + number: 8080 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ $fullName }}-static + annotations: + cert-manager.io/cluster-issuer: lets-encrypt + nginx.ingress.kubernetes.io/use-regex: "true" + # remove "/static" from the path: + nginx.ingress.kubernetes.io/rewrite-target: /$2 +spec: + ingressClassName: nginx + tls: + - hosts: + - {{ .Values.domain }} + secretName: {{ .Values.ingress.tlsSecretName }} + rules: + - host: {{ .Values.domain }} + http: + paths: + - path: /static(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: {{ $fullName }}-static + port: + number: 80 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + namespace: monitoring + name: monitoring-ingress + annotations: + cert-manager.io/cluster-issuer: lets-encrypt +spec: + ingressClassName: nginx + tls: + - hosts: + - {{ .Values.monitoring_domain }} + secretName: {{ .Values.ingress.tlsSecretName }} + rules: + - host: {{ .Values.monitoring_domain }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: grafana + port: + number: 80 \ No newline at end of file diff --git a/infra/helm/qmra/templates/issuer.yaml b/infra/helm/qmra/templates/issuer.yaml new file mode 100644 index 0000000..e80f1c4 --- /dev/null +++ b/infra/helm/qmra/templates/issuer.yaml @@ -0,0 +1,16 @@ +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: lets-encrypt +spec: + acme: + email: {{ .Values.tls.acme_email }} + server: https://acme-v02.api.letsencrypt.org/directory + privateKeySecretRef: + # Secret resource that will be used to store the account's private key. + name: {{ .Values.ingress.tlsSecretName }} + # Add a single challenge solver, HTTP01 using nginx + solvers: + - http01: + ingress: + class: public \ No newline at end of file diff --git a/infra/helm/qmra/templates/service.yaml b/infra/helm/qmra/templates/service.yaml new file mode 100644 index 0000000..f5e267f --- /dev/null +++ b/infra/helm/qmra/templates/service.yaml @@ -0,0 +1,33 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.fullname" . }} + labels: + {{- include "app.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - port: 8080 + targetPort: http + protocol: TCP + name: http + selector: + {{- include "app.selectorLabels" . | nindent 4 }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "app.fullname" . }}-static + labels: + app.kubernetes.io/name: {{ include "app.name" . }}-static + app.kubernetes.io/instance: {{ .Release.Name }}-static +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app.kubernetes.io/name: {{ include "app.name" . }}-static + app.kubernetes.io/instance: {{ .Release.Name }}-static diff --git a/infra/helm/qmra/templates/volumes.yaml b/infra/helm/qmra/templates/volumes.yaml new file mode 100644 index 0000000..95859d5 --- /dev/null +++ b/infra/helm/qmra/templates/volumes.yaml @@ -0,0 +1,89 @@ +apiVersion: v1 +kind: PersistentVolume +metadata: + name: {{ include "app.fullname" . }}-static-files-pv +spec: + capacity: + storage: 1Gi + volumeMode: Filesystem + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: microk8s-hostpath + hostPath: + path: {{ .Values.static.hostpath }} + type: DirectoryOrCreate +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: {{ include "app.fullname" . }}-sqlite-file-pv +spec: + capacity: + storage: 10Gi + volumeMode: Filesystem + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: microk8s-hostpath + hostPath: + path: {{ .Values.sqlite.hostpath }} + type: FileOrCreate +--- +apiVersion: v1 +kind: PersistentVolume +metadata: + name: {{ include "app.fullname" . }}-qmra-default-file-pv +spec: + capacity: + storage: 2Gi + volumeMode: Filesystem + accessModes: + - ReadWriteMany + persistentVolumeReclaimPolicy: Retain + storageClassName: microk8s-hostpath + hostPath: + path: {{ .Values.qmra_default.hostpath }} + type: FileOrCreate +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "app.fullname" . }}-static-files-pvc +spec: + accessModes: + - ReadWriteMany + volumeName: {{ include "app.fullname" . }}-static-files-pv + volumeMode: Filesystem + resources: + requests: + storage: 1Gi + storageClassName: microk8s-hostpath +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "app.fullname" . }}-sqlite-file-pvc +spec: + accessModes: + - ReadWriteMany + volumeMode: Filesystem + volumeName: {{ include "app.fullname" . }}-sqlite-file-pv + resources: + requests: + storage: 1Gi + storageClassName: microk8s-hostpath +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "app.fullname" . }}-qmra-default-file-pvc +spec: + accessModes: + - ReadWriteMany + volumeMode: Filesystem + volumeName: {{ include "app.fullname" . }}-qmra-default-file-pv + resources: + requests: + storage: 1Gi + storageClassName: microk8s-hostpath \ No newline at end of file diff --git a/infra/purge-k8-docker-images.sh b/infra/purge-k8-docker-images.sh new file mode 100644 index 0000000..aaf7e4a --- /dev/null +++ b/infra/purge-k8-docker-images.sh @@ -0,0 +1,3 @@ +!# /bin/bash + +crictl -r unix:///var/snap/microk8s/common/run/containerd.sock rmi --prune \ No newline at end of file diff --git a/tools/manage.py b/manage.py similarity index 89% rename from tools/manage.py rename to manage.py index 9f5239f..689019a 100644 --- a/tools/manage.py +++ b/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tools.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'qmra.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/prod_backup_2025-12-default_qmra_data b/prod_backup_2025-12-default_qmra_data new file mode 100644 index 0000000..3ac8900 Binary files /dev/null and b/prod_backup_2025-12-default_qmra_data differ diff --git a/qmra.db b/qmra.db deleted file mode 100644 index 6ca9014..0000000 Binary files a/qmra.db and /dev/null differ diff --git a/tools/.gitignore b/qmra/.gitignore similarity index 53% rename from tools/.gitignore rename to qmra/.gitignore index 7c00f17..af0b9ce 100644 --- a/tools/.gitignore +++ b/qmra/.gitignore @@ -1,5 +1,2 @@ -testmail.ipyn -qmra.db -.qmra.db comparison_function.ipynb notebooks diff --git a/tools/qmratool/__init__.py b/qmra/__init__.py similarity index 100% rename from tools/qmratool/__init__.py rename to qmra/__init__.py diff --git a/tools/tools/asgi.py b/qmra/asgi.py similarity index 74% rename from tools/tools/asgi.py rename to qmra/asgi.py index f8da75b..f5e6bc4 100644 --- a/tools/tools/asgi.py +++ b/qmra/asgi.py @@ -1,5 +1,5 @@ """ -ASGI config for tools project. +ASGI config for qmra project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tools.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'qmra.settings') application = get_asgi_application() diff --git a/qmra/logs.py b/qmra/logs.py new file mode 100644 index 0000000..8c4eb33 --- /dev/null +++ b/qmra/logs.py @@ -0,0 +1,61 @@ +import logging +import sys +from django.utils import log +import structlog + + +class ExcludeEventsFilter(logging.Filter): + def __init__(self, excluded_event_type=None): + super().__init__() + self.excluded_event_type = excluded_event_type + + def filter(self, record): + if not isinstance(record.msg, dict) or self.excluded_event_type is None: + return True # Include the log message if msg is not a dictionary or excluded_event_type is not provided + + if record.msg.get('event') in self.excluded_event_type: + return False # Exclude the log message + return True # Include the log message + + +no_error_filter = log.CallbackFilter( + lambda r: not r.levelname == "ERROR" +) + + +def no_health(record): + rqs = record.msg["request"] + return not ("/health" in rqs or "/ready" in rqs or "/metrics" in rqs) + + +no_health_filter = log.CallbackFilter(no_health) + + +console_stdout_handler = { + "class": "logging.StreamHandler", + "formatter": "key_value", + "level": "INFO", + "filters": [no_error_filter, no_health_filter], + "stream": "ext://sys.stdout" +} + +console_stderr_handler = { + "class": "logging.StreamHandler", + "formatter": "key_value", + "level": "ERROR", + "stream": "ext://sys.stderr" +} + +json_stdout_handler = { + "class": "logging.StreamHandler", + "formatter": "json_formatter", + "filters": [no_error_filter, no_health_filter], + "stream": "ext://sys.stdout" +} + +json_stderr_handler = { + "class": "logging.StreamHandler", + "formatter": "json_formatter", + "filters": [no_error_filter, no_health_filter], + "stream": "ext://sys.stderr" +} \ No newline at end of file diff --git a/tools/qmratool/migrations/__init__.py b/qmra/management/__init__.py similarity index 100% rename from tools/qmratool/migrations/__init__.py rename to qmra/management/__init__.py diff --git a/tools/tools/__init__.py b/qmra/management/commands/__init__.py similarity index 100% rename from tools/tools/__init__.py rename to qmra/management/commands/__init__.py diff --git a/qmra/management/commands/collect_static_default_entities.py b/qmra/management/commands/collect_static_default_entities.py new file mode 100644 index 0000000..74d1af4 --- /dev/null +++ b/qmra/management/commands/collect_static_default_entities.py @@ -0,0 +1,90 @@ +import json + +from django.core.management.base import BaseCommand +import pandas as pd + + +def get_default_pathogens(): + pathogen = pd.read_csv("raw_public_data/tbl_pathogen.csv", encoding="windows-1251") + pathogen = pathogen[pathogen.id.isin((3, 32, 34))] + health = pd.read_csv("raw_public_data/tbl_health.csv", encoding="windows-1251") + # NOTE: HEALTH has been modified 'pathogen_id' is now 'pathogen_group'!! + # i.e. Rotavirus -> Viruses, jejuni -> Bacteria, parvum -> Protozoa + # this reflects the usage done until now and solve the problem of having too few data available... + health = health.loc[:, ["group", "infection_to_illness", "dalys_per_case"]] + dose_response = pd.read_csv("raw_public_data/tbl_doseResponse.csv", encoding="windows-1251") + dose_response = dose_response.loc[:, ["pathogen_id", "best_fit_model", "k", "alpha", "n50"]] + pathogen = pd.merge(pathogen, health, on="group", how="left") + return pd.merge(pathogen, dose_response.rename(columns=dict(pathogen_id="id")), on="id", how="left") + + +def get_default_sources(): + return pd.read_csv("raw_public_data/tbl_waterSource.csv", encoding="windows-1251") + + +def get_default_inflows(): + inflows = pd.read_csv("raw_public_data/tbl_inflow.csv", encoding="windows-1251") + inflows.ReferenceID = inflows.ReferenceID.astype(pd.StringDtype()) + pathogens = pd.read_csv("raw_public_data/tbl_pathogen.csv", encoding="windows-1251") + sources = pd.read_csv("raw_public_data/tbl_waterSource.csv", encoding="windows-1251") + inflows = pd.merge(inflows, sources, left_on="source_id", right_on="id", how="left").rename(columns={"name": "source_name"}) + inflows = pd.merge(inflows, pathogens, left_on="pathogen_id", right_on="id", how="left").rename(columns={"name": "pathogen_name"}) + inflows = inflows[inflows.pathogen_id.isin((3, 32, 34))] + inflows["id"] = list(range(len(inflows.index))) + return inflows.loc[:, ["id", "source_name", "pathogen_name", "min", "max", "ReferenceID"]] + + +def get_default_treatments(): + treatments = pd.read_csv("raw_public_data/tbl_treatment.csv", encoding="windows-1251") + logremovals = pd.read_csv("raw_public_data/tbl_logRemoval.csv", encoding="windows-1251") + logremovals.reference = logremovals.reference.astype(pd.StringDtype()) + logremovals = logremovals.loc[:, ["treatment_id", "min", "max", "pathogen_group", "reference"]] + for grp_name, grp in logremovals.groupby("pathogen_group"): + grp = grp.loc[:, ["treatment_id", "min", "max", "reference"]].rename( + columns=dict(treatment_id="id", + reference=f"{grp_name.lower()}_reference", + min=f"{grp_name.lower()}_min", + max=f"{grp_name.lower()}_max")) + treatments = pd.merge(treatments, grp, on="id", how="outer") + return treatments + + +def get_default_exposures(): + exposures = pd.read_csv("raw_public_data/tbl_ingestion.csv", encoding="windows-1251") + exposures.ReferenceID = exposures.ReferenceID.astype(pd.Int16Dtype()).astype(pd.StringDtype()) + return exposures + + +def get_default_references(): + return pd.read_csv("raw_public_data/tbl_reference.csv", encoding="windows-1251") + + +def save_as_json(data: dict, destination: str): + with open(destination, "w") as f: + json.dump(data, f) + + +class Command(BaseCommand): + help = "Create the default static data of qmra" + + def handle(self, *args, **options): + default_pathogens = get_default_pathogens() + default_pathogens = {d["name"]: d for d in default_pathogens.replace({float("nan"): None}).to_dict(orient="records")} + save_as_json(default_pathogens, "qmra/static/data/default-pathogens.json") + default_inflows = get_default_inflows() + default_inflows = {k: d.to_dict(orient="records") for k, d in default_inflows.replace({float("nan"): None}).groupby("source_name")} + save_as_json(default_inflows, "qmra/static/data/default-inflows.json") + default_treatments = get_default_treatments() + default_treatments = {d["name"]: d for d in default_treatments.replace({float("nan"): None}).to_dict(orient="records")} + save_as_json(default_treatments, "qmra/static/data/default-treatments.json") + default_sources = get_default_sources() + default_sources = {d["name"]: d for d in default_sources.replace({float("nan"): None}).to_dict(orient="records")} + save_as_json(default_sources, "qmra/static/data/default-sources.json") + default_exposures = get_default_exposures() + default_exposures = {d["name"]: d for d in default_exposures.replace({float("nan"): None}).to_dict(orient="records")} + save_as_json(default_exposures, "qmra/static/data/default-exposures.json") + save_as_json({d["ReferenceID"]: d for d in get_default_references().to_dict(orient="records")}, "qmra/static/data/default-references.json") + + +if __name__ == '__main__': + Command().handle() \ No newline at end of file diff --git a/qmra/management/commands/export_default.py b/qmra/management/commands/export_default.py new file mode 100644 index 0000000..913f14f --- /dev/null +++ b/qmra/management/commands/export_default.py @@ -0,0 +1,48 @@ +import json +from django.core.management.base import BaseCommand +from qmra.risk_assessment.qmra_models import QMRAReference, QMRAReferences, QMRASource, \ + QMRASources, QMRAPathogen, QMRAPathogens, QMRAInflow, QMRAInflows, QMRATreatment, \ + QMRATreatments, QMRAExposure, QMRAExposures + + +def save_as_json(data, destination: str): + with open(destination, "w") as f: + json.dump(data, f) + + +class Command(BaseCommand): + help = "export the default data of qmra to json files for serving them as statics" + + # def add_arguments(self, parser): + # parser.add_argument('--format', type=str, help="'json' (default) or 'csv' ", default="json") + + def handle(self, *args, **options): + save_as_json( + {src.name: src.to_dict() for src in QMRASource.objects.all()}, + QMRASources.source + ) + save_as_json( + {pathogen.name: pathogen.to_dict() for pathogen in QMRAPathogen.objects.all()}, + QMRAPathogens.source + ) + save_as_json( + {src.name: [inflow.to_dict() for inflow in QMRAInflow.objects.filter(source__name=src.name).all()] + for src in QMRASource.objects.all()}, + QMRAInflows.source + ) + save_as_json( + {t.name: t.to_dict() for t in QMRATreatment.objects.all()}, + QMRATreatments.source + ) + save_as_json( + {e.name: e.to_dict() for e in QMRAExposure.objects.all()}, + QMRAExposures.source + ) + save_as_json( + {str(ref.pk): ref.to_dict() for ref in QMRAReference.objects.all()}, + QMRAReferences.source + ) + + +if __name__ == '__main__': + Command().handle() diff --git a/qmra/management/commands/seed_default_db.py b/qmra/management/commands/seed_default_db.py new file mode 100644 index 0000000..2e41741 --- /dev/null +++ b/qmra/management/commands/seed_default_db.py @@ -0,0 +1,26 @@ +from django.core.management.base import BaseCommand +from qmra.risk_assessment.qmra_models import QMRAReferences, QMRASources, QMRAPathogens, QMRAInflows, \ + QMRATreatments, QMRAExposures + + +class Command(BaseCommand): + help = "Create the default static data of qmra" + + def handle(self, *args, **options): + for _, ref in QMRAReferences.data.items(): + ref.save() + for _, pat in QMRAPathogens.data.items(): + pat.save() + for _, source in QMRASources.data.items(): + source.save() + for _, inflows in QMRAInflows.data.items(): + for inflow in inflows: + inflow.save() + for _, treatment in QMRATreatments.data.items(): + treatment.save() + for _, exposure in QMRAExposures.data.items(): + exposure.save() + + +if __name__ == '__main__': + Command().handle() diff --git a/qmra/prod_models.py b/qmra/prod_models.py new file mode 100644 index 0000000..9db5ebf --- /dev/null +++ b/qmra/prod_models.py @@ -0,0 +1,304 @@ +from django.db import models + + +class AuthGroup(models.Model): + name = models.CharField(unique=True, max_length=150) + + class Meta: + managed = False + db_table = 'auth_group' + + +class AuthGroupPermissions(models.Model): + group = models.ForeignKey(AuthGroup, models.DO_NOTHING) + permission = models.ForeignKey('AuthPermission', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'auth_group_permissions' + unique_together = (('group', 'permission'),) + + +class AuthPermission(models.Model): + content_type = models.ForeignKey('DjangoContentType', models.DO_NOTHING) + codename = models.CharField(max_length=100) + name = models.CharField(max_length=255) + + class Meta: + managed = False + db_table = 'auth_permission' + unique_together = (('content_type', 'codename'),) + + +class DjangoAdminLog(models.Model): + action_time = models.DateTimeField() + object_id = models.TextField(blank=True, null=True) + object_repr = models.CharField(max_length=200) + change_message = models.TextField() + content_type = models.ForeignKey('DjangoContentType', models.DO_NOTHING, blank=True, null=True) + user = models.ForeignKey('User', models.DO_NOTHING) + action_flag = models.PositiveSmallIntegerField() + + class Meta: + managed = False + db_table = 'django_admin_log' + + +class DjangoContentType(models.Model): + app_label = models.CharField(max_length=100) + model = models.CharField(max_length=100) + + class Meta: + managed = False + db_table = 'django_content_type' + unique_together = (('app_label', 'model'),) + + +class DjangoMigrations(models.Model): + app = models.CharField(max_length=255) + name = models.CharField(max_length=255) + applied = models.DateTimeField() + + class Meta: + managed = False + db_table = 'django_migrations' + + +class DjangoSession(models.Model): + session_key = models.CharField(primary_key=True, max_length=40) + session_data = models.TextField() + expire_date = models.DateTimeField() + + class Meta: + managed = False + db_table = 'django_session' + + +class Comparison(models.Model): + + class Meta: + managed = False + db_table = 'comparison' + + +class ComparisonRiskAssessment(models.Model): + comparison = models.ForeignKey(Comparison, models.DO_NOTHING) + riskassessment = models.ForeignKey('Riskassessment', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'comparison_risk_assessment' + unique_together = (('comparison', 'riskassessment'),) + + +class Doseresponse(models.Model): + bestfitmodel = models.CharField(max_length=250) + k = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + alpha = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + n50 = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + hosttype = models.CharField(max_length=250) + doseunits = models.CharField(max_length=250) + route = models.CharField(max_length=250) + response = models.CharField(max_length=250) + pathogen = models.ForeignKey('Pathogen', models.DO_NOTHING) + reference = models.ForeignKey('Reference', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'doseresponse' + + +class Exposure(models.Model): + name = models.CharField(max_length=250) + description = models.CharField(max_length=250) + events_per_year = models.IntegerField() + reference = models.ForeignKey('Reference', models.DO_NOTHING) + user = models.ForeignKey('User', models.DO_NOTHING) + volume_per_event = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + + class Meta: + managed = False + db_table = 'exposure' + + +class Guideline(models.Model): + name = models.CharField(max_length=250) + description = models.CharField(max_length=250) + reference = models.ForeignKey('Reference', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'guideline' + + +class Health(models.Model): + infection_to_illness = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + dalys_per_case = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + pathogen = models.ForeignKey('Pathogen', models.DO_NOTHING) + reference = models.ForeignKey('Reference', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'health' + + +class Inflow(models.Model): + min = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + max = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + mean = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + alpha = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + beta = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + distribution = models.CharField(max_length=64) + pathogen_in_ref = models.CharField(max_length=200) + notes = models.CharField(max_length=200) + pathogen = models.ForeignKey('Pathogen', models.DO_NOTHING) + reference = models.ForeignKey('Reference', models.DO_NOTHING) + water_source = models.ForeignKey('Sourcewater', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'inflow' + + +class Logremoval(models.Model): + min = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + max = models.DecimalField(max_digits=10, decimal_places=5) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + mean = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + alpha = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + beta = models.DecimalField(max_digits=10, decimal_places=5, blank=True, null=True) # max_digits and decimal_places have been guessed, as this database handles decimal fields as float + distribution = models.CharField(max_length=64) + pathogen_group = models.ForeignKey('Pathogengroup', models.DO_NOTHING) + reference = models.ForeignKey('Reference', models.DO_NOTHING) + treatment = models.ForeignKey('Treatment', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'logremoval' + + +class Pathogen(models.Model): + pathogen = models.CharField(max_length=64) + description = models.TextField() + pathogen_group = models.ForeignKey('Pathogengroup', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'pathogen' + + +class Pathogengroup(models.Model): + pathogen_group = models.CharField(max_length=64) + description = models.TextField() + + class Meta: + managed = False + db_table = 'pathogengroup' + + +class Qa(models.Model): + question = models.CharField(max_length=120) + answer = models.TextField() + + class Meta: + managed = False + db_table = 'qa' + + +class Reference(models.Model): + name = models.CharField(max_length=50) + link = models.CharField(max_length=200) + + class Meta: + managed = False + db_table = 'reference' + + +class Riskassessment(models.Model): + name = models.CharField(max_length=64) + description = models.TextField() + exposure = models.ForeignKey(Exposure, models.DO_NOTHING, blank=True, null=True) + source = models.ForeignKey('Sourcewater', models.DO_NOTHING) + user = models.ForeignKey('User', models.DO_NOTHING) + created_at = models.DateTimeField(blank=True, null=True) + + class Meta: + managed = False + db_table = 'riskassessment' + + +class RiskassessmentTreatment(models.Model): + riskassessment = models.ForeignKey(Riskassessment, models.DO_NOTHING) + treatment = models.ForeignKey('Treatment', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'riskassessment_treatment' + unique_together = (('riskassessment', 'treatment'),) + + +class Sourcewater(models.Model): + water_source_name = models.CharField(max_length=64) + water_source_description = models.CharField(max_length=2000) + user = models.ForeignKey('User', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'sourcewater' + + +class Text(models.Model): + title = models.CharField(max_length=120) + content = models.TextField() + + class Meta: + managed = False + db_table = 'text' + + +class Treatment(models.Model): + name = models.CharField(max_length=64) + group = models.CharField(max_length=64) + category = models.CharField(max_length=64) + description = models.TextField() + user = models.ForeignKey('User', models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'treatment' + + +class User(models.Model): + password = models.CharField(max_length=128) + last_login = models.DateTimeField(blank=True, null=True) + is_superuser = models.BooleanField() + username = models.CharField(unique=True, max_length=150) + first_name = models.CharField(max_length=150) + last_name = models.CharField(max_length=150) + email = models.CharField(max_length=254) + is_staff = models.BooleanField() + is_active = models.BooleanField() + date_joined = models.DateTimeField() + + class Meta: + managed = False + db_table = 'user' + + +class UserGroups(models.Model): + user = models.ForeignKey(User, models.DO_NOTHING) + group = models.ForeignKey(AuthGroup, models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'user_groups' + unique_together = (('user', 'group'),) + + +class UserUserPermissions(models.Model): + user = models.ForeignKey(User, models.DO_NOTHING) + permission = models.ForeignKey(AuthPermission, models.DO_NOTHING) + + class Meta: + managed = False + db_table = 'user_user_permissions' + unique_together = (('user', 'permission'),) \ No newline at end of file diff --git a/qmra/risk_assessment/__init__.py b/qmra/risk_assessment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qmra/risk_assessment/admin.py b/qmra/risk_assessment/admin.py new file mode 100644 index 0000000..467a22a --- /dev/null +++ b/qmra/risk_assessment/admin.py @@ -0,0 +1,78 @@ +from django.contrib import admin +from django.core.management import call_command + +from qmra.risk_assessment.qmra_models import QMRASource, QMRAPathogen, QMRAInflow, \ + QMRATreatment, QMRAExposure, QMRAReference + +""" + +""" + + +def _changeform_view(self, request, object_id, form_url, extra_context): + # this method of ModelAdmin is wrapped in a transaction and it calls save_model() and save_related() + # and then returns a response. + # we extend it to update the static data every time an admin changes a model (and/or its related) + response = super(type(self), self)._changeform_view(request, object_id, form_url, extra_context) + call_command("export_default") + call_command("collectstatic", "--no-input") + return response + + +@admin.register(QMRAReference) +class QMRAReferenceAdmin(admin.ModelAdmin): + list_display = ["name", "link"] + _changeform_view = _changeform_view + + +class QMRAInflowInline(admin.TabularInline): + model = QMRAInflow + fields = ["pathogen", "min", "max", "reference"] + + +@admin.register(QMRASource) +class QMRASourceAdmin(admin.ModelAdmin): + list_display = ["name", "description"] + inlines = [QMRAInflowInline] + + _changeform_view = _changeform_view + + +@admin.register(QMRAExposure) +class QMRAExposureAdmin(admin.ModelAdmin): + list_display = ["name", "events_per_year", "volume_per_event"] + # inlines = [ReferenceInline] + + _changeform_view = _changeform_view + + +@admin.register(QMRAPathogen) +class QMRAPathogenAdmin(admin.ModelAdmin): + list_display = ["name", "group"] + + _changeform_view = _changeform_view + + +@admin.register(QMRATreatment) +class QMRATreatmentAdmin(admin.ModelAdmin): + list_display = [ + "name", "group", + "bacteria_min", + "bacteria_max", + "viruses_min", + "viruses_max", + "protozoa_min", + "protozoa_max", + ] + fields = [ + ("name", "group"), + ("bacteria_min", "bacteria_max"), + "bacteria_references", + ("viruses_min", "viruses_max"), + "viruses_references", + ("protozoa_min", "protozoa_max"), + "protozoa_references" + ] + filter_horizontal = ["bacteria_references", "viruses_references", "protozoa_references"] + + _changeform_view = _changeform_view diff --git a/qmra/risk_assessment/apps.py b/qmra/risk_assessment/apps.py new file mode 100644 index 0000000..6f458fe --- /dev/null +++ b/qmra/risk_assessment/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class RiskAssessmentConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'qmra.risk_assessment' diff --git a/qmra/risk_assessment/dbrouter.py b/qmra/risk_assessment/dbrouter.py new file mode 100644 index 0000000..b167bdf --- /dev/null +++ b/qmra/risk_assessment/dbrouter.py @@ -0,0 +1,21 @@ +class DBRouter(object): + """Default* entities are managed by admins, everything else is for the users""" + + def db_for_read(self, model, **hints): + if "QMRA" in model.__name__: + return 'qmra' + return "default" + + def db_for_write(self, model, **hints): + if "QMRA" in model.__name__: + return 'qmra' + return "default" + + def allow_migrate(self, db, app_label, model_name=None, **hints): + """ + Make sure the qmra data (and only this data!) is in its db + """ + if model_name is not None and "qmra" in model_name: + return db == "qmra" + # model_name is None when the migration is a RunPython or a RunSQL + return db == "default" or model_name is None diff --git a/qmra/risk_assessment/exports.py b/qmra/risk_assessment/exports.py new file mode 100644 index 0000000..f70aa62 --- /dev/null +++ b/qmra/risk_assessment/exports.py @@ -0,0 +1,102 @@ +from zipfile import ZipFile +import base64 +from django.db.models import QuerySet +from django.template.loader import render_to_string + +from qmra.risk_assessment.models import RiskAssessment, RiskAssessmentResult, Inflow, Treatment +import pandas as pd + +from qmra.risk_assessment.plots import risk_plots + + +def inflows_as_df(inflows: QuerySet[Inflow]): + dfs = [] + for inflow in inflows.all(): + dfs += [pd.DataFrame({ + "Pathogen": [inflow.pathogen], + "Minimum Concentration": [inflow.min], + "Maximum Concentration": [inflow.max], + })] + return pd.concat(dfs) + + +def treatments_as_df(treatments: QuerySet[Treatment]) -> pd.DataFrame: + dfs = [] + for t in treatments.all(): + dfs += [pd.DataFrame({ + "Treatment": [t.name] * 3, + "Pathogen group": ["Viruses", "Bacteria", "Protozoa"], + "Maximum LRV": [t.viruses_max, t.bacteria_max, t.protozoa_max], + "Minimum LRV": [t.viruses_min, t.bacteria_min, t.protozoa_min] + })] + return pd.concat(dfs) + + +def risk_assessment_result_as_df(pathogen: str, r: RiskAssessmentResult) -> pd.DataFrame: + return pd.DataFrame({ + ("", "pathogen"): [pathogen] * 2, + ("", "stat"): ["Maximum LRV", "Minimum LRV"], + ("Infection prob.", "min"): [ + r.infection_maximum_lrv_min, r.infection_minimum_lrv_min + ], + ("Infection prob.", "25%"): [ + r.infection_maximum_lrv_q1, r.infection_minimum_lrv_q1 + ], + ("Infection prob.", "50%"): [ + r.infection_maximum_lrv_median, r.infection_minimum_lrv_median + ], + ("Infection prob.", "75%"): [ + r.infection_maximum_lrv_q3, r.infection_minimum_lrv_q3 + ], + ("Infection prob.", "max"): [ + r.infection_maximum_lrv_max, r.infection_minimum_lrv_max + ], + ("DALYs pppy", "min"): [ + r.dalys_maximum_lrv_min, r.dalys_minimum_lrv_min + ], + ("DALYs pppy", "25%"): [ + r.dalys_maximum_lrv_q1, r.dalys_minimum_lrv_q1 + ], + ("DALYs pppy", "50%"): [ + r.dalys_maximum_lrv_median, r.dalys_minimum_lrv_median + ], + ("DALYs pppy", "75%"): [ + r.dalys_maximum_lrv_q3, r.dalys_minimum_lrv_q3 + ], + ("DALYs pppy", "max"): [ + r.dalys_maximum_lrv_max, r.dalys_minimum_lrv_max + ], + }) + + +def results_as_df(results: dict[str, RiskAssessmentResult]) -> pd.DataFrame: + dfs = [] + for pathogen, r in results.items(): + dfs += [risk_assessment_result_as_df(pathogen, r)] + return pd.concat(dfs) + + +def risk_assessment_summary(assessment: RiskAssessment) -> str: + return f'"Risk Assessment\'s Name:","{assessment.name}"\n"Description:","{assessment.description}"\n\n"Result\'s Summary:"\n' + + +def risk_assessment_as_zip(buffer, risk_assessment: RiskAssessment): + inflows = inflows_as_df(risk_assessment.inflows) + treatments = treatments_as_df(risk_assessment.treatments) + results = results_as_df({r.pathogen: r for r in risk_assessment.results.all()}) + plots = risk_plots(risk_assessment.results.all(), "png") + report = render_to_string("assessment-result-export.html", + context=dict(results=risk_assessment.results.all(), + infection_risk=risk_assessment.infection_risk, + risk_plot_data=base64.b64encode(plots[0]).decode("utf-8"), + daly_plot_data=base64.b64encode(plots[1]).decode("utf-8"))) + with ZipFile(buffer, mode="w") as archive: + archive.mkdir("exposure-assessment") + archive.mkdir("results-plots") + archive.writestr("exposure-assessment/inflows.csv", inflows.to_csv(sep=",", decimal=".", index=False)) + archive.writestr("exposure-assessment/treatments.csv", treatments.to_csv(sep=",", decimal=".", index=False)) + archive.writestr(f"{risk_assessment.name}-result.csv", + risk_assessment_summary(risk_assessment) + results.to_csv(sep=",", decimal=".", index=False)) + archive.writestr(f"{risk_assessment.name}-report.html", report) + archive.writestr("results-plots/infection-probability.png", plots[0]) + archive.writestr("results-plots/dalys-pppy.png", plots[1]) diff --git a/qmra/risk_assessment/forms.py b/qmra/risk_assessment/forms.py new file mode 100644 index 0000000..57b1bc7 --- /dev/null +++ b/qmra/risk_assessment/forms.py @@ -0,0 +1,252 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.forms import modelformset_factory +from crispy_forms.bootstrap import AppendedText +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Field, Row, Column, HTML + +from qmra.risk_assessment.models import Inflow, Treatment, \ + RiskAssessment +from qmra.risk_assessment.qmra_models import QMRASources, QMRATreatments, QMRAExposures +from qmra.user.models import User + + +def _zero_if_none(x): return x if x is not None else 0 + + +class RiskAssessmentForm(forms.ModelForm): + source_name = forms.ChoiceField() + exposure_name = forms.ChoiceField() + + class Meta: + model = RiskAssessment + fields = [ + "name", + "description", + "source_name", + "exposure_name", + "events_per_year", + "volume_per_event" + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["source_name"].label = "Select a source water type to add pathogen concentrations" + self.fields["exposure_name"].choices = QMRAExposures.choices() + self.fields["source_name"].choices = QMRASources.choices() + self.fields['events_per_year'].widget.attrs['min'] = 0 + self.fields['volume_per_event'].widget.attrs['min'] = 0 + self.fields['volume_per_event'].label = "Volume per event in liters" + self.helper = FormHelper(self) + self.helper.form_tag = False + self.helper.label_class = "text-muted small" + self.helper.layout = Layout( + Row(Column("name"), Column("description"), css_id="name-and-description"), + Row(Column("exposure_name"), Column("events_per_year"), Column("volume_per_event"), css_id="exposure-form-fieldset"), + # Row("source_name", css_id="source-form") + ) + + def set_user(self, user: User): + self.fields["exposure_name"].choices = [ + ["Your Exposures", [(e.name, e.name) for e in user.exposures.all()]], + *self.fields["exposure_name"].choices + ] + self.fields["source_name"].choices = [ + ["Your Sources", [(s.name, s.name) for s in user.sources.all()]], + *self.fields["source_name"].choices + ] + return self + + def clean(self): + cleaned_data = super().clean() + if cleaned_data["events_per_year"] <= 0: + self.add_error("events_per_year", "this field must be greater than 0") + if cleaned_data["volume_per_event"] <= 0: + self.add_error("volume_per_event", "this field must be greater than 0") + return cleaned_data + + +class InflowForm(forms.ModelForm): + # DELETE = forms.BooleanField(label="remove") + + class Meta: + model = Inflow + fields = ['pathogen', 'min', 'max'] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.initial.update(kwargs.get("initial", {})) + self.helper = FormHelper(self) + self.helper.render_hidden_fields = False + self.helper.render_unmentioned_fields = False + self.helper.form_tag = False + self.helper.disable_csrf = True + self.helper.label_class = "text-muted small" + self.fields['pathogen'].disabled = True + self.fields['pathogen'].label = "Reference Pathogen" + self.fields['min'].widget.attrs['min'] = 0 + self.fields['max'].widget.attrs['min'] = 0 + self.fields['min'].label = "Minimum concentration" + self.fields['max'].label = "Maximum concentration" + self.fields['max'].required = True + self.helper.layout = Layout( + 'pathogen', + AppendedText('min', 'N/L'), + AppendedText('max', 'N/L'), + # "DELETE" + ) + + def clean(self): + cleaned_data = super().clean() + mn, mx = cleaned_data.get("min", 0), cleaned_data.get("max", 0) + if mn < 0: + self.add_error("min", "this field must be positive or 0") + if mx < 0: + self.add_error("max", "this field must be positive or 0") + if mn > mx: + msg = "minimum concentration must be less than maximum concentration" + self.add_error("min", msg) + self.add_error("max", msg) + return cleaned_data + + +InflowFormSetBase = modelformset_factory( + Inflow, form=InflowForm, + extra=0, max_num=3, min_num=3, + can_delete=False, can_delete_extra=False +) + + +class InflowFormSet(InflowFormSetBase): + + def __init__(self, *args, **kwargs): + kwargs["initial"] = [ + {"pathogen": "Rotavirus"}, + {"pathogen": 'Campylobacter jejuni'}, + {"pathogen": "Cryptosporidium parvum"}, + ] + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + # for form in self.forms: + # form.fields["DELETE"].label = "remove" + + def clean(self): + cleaned_data = [f for f in self.forms if not self._should_delete_form(f)] + unq_pathogens = {f.cleaned_data["pathogen"] for f in cleaned_data if f.cleaned_data.get("pathogen", False)} + if len(unq_pathogens) < len(cleaned_data): + raise ValidationError("each pathogen must be unique") + + +class TreatmentForm(forms.ModelForm): + name = forms.ChoiceField() + + class Meta: + model = Treatment + fields = [ + "name", + "bacteria_min", + "bacteria_max", + 'viruses_min', + 'viruses_max', + "protozoa_min", + "protozoa_max" + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.form_tag = False + self.helper.disable_csrf = True + self.helper.label_class = "text-muted small" + # self.fields['name'].choices = DefaultTreatments.choices() + self.fields['name'].label = "" + self.fields['bacteria_min'].label = "" + self.fields['bacteria_max'].label = "" + self.fields['viruses_min'].label = "" + self.fields['viruses_max'].label = "" + self.fields['protozoa_min'].label = "" + self.fields['protozoa_max'].label = "" + label_style = "class='text-muted text-center w-100' style='margin-top: .4em;'" + self.helper.layout = Layout( + Field("name", css_class="disabled-input d-none"), + Row(Column(HTML(f"
")), + Column(HTML(f"")), + Column(HTML(f""))), + Row(Column(HTML(f"")), + Column("bacteria_min"), Column("bacteria_max")), + Row(Column(HTML(f"")), + Column("viruses_min"), Column("viruses_max")), + Row(Column(HTML(f"")), + Column("protozoa_min"), Column("protozoa_max")), + # Row(Column("DELETE")) + ) + + def clean(self): + cleaned_data = super().clean() + b_min = _zero_if_none(cleaned_data.get("bacteria_min", 0)) + b_max = _zero_if_none(cleaned_data.get("bacteria_max", 0)) + v_min = _zero_if_none(cleaned_data.get("viruses_min", 0)) + v_max = _zero_if_none(cleaned_data.get("viruses_max", 0)) + p_min = _zero_if_none(cleaned_data.get("protozoa_min", 0)) + p_max = _zero_if_none(cleaned_data.get("protozoa_max", 0)) + msg = "min. must be less than max" + if b_min > b_max: + self.add_error("bacteria_min", msg) + if v_min > v_max: + self.add_error("viruses_min", msg) + if p_min > p_max: + self.add_error("protozoa_min", msg) + return cleaned_data + + +TreatmentFormSetBase = modelformset_factory( + Treatment, form=TreatmentForm, + extra=0, max_num=30, min_num=0, + can_delete=True, can_delete_extra=True +) + + +class AddTreatmentForm(forms.Form): + select_treatment = forms.ChoiceField(choices=[], widget=forms.Select()) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["select_treatment"].choices = QMRATreatments.choices() + self.fields["select_treatment"].required = False + self.fields["select_treatment"].label = "Select treatment to add" + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.label_class = "text-muted" + self.helper.layout = Layout( + "select_treatment", + ) + + def set_user(self, user: User): + self.fields["select_treatment"].choices = [ + *self.fields["select_treatment"].choices, + *[(t.name, t.name) for t in user.treatments.all()] + ] + return self + + +class TreatmentFormSet(TreatmentFormSetBase): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper() + self.helper.form_tag = False + choices = QMRATreatments.choices() + self.form.base_fields["name"].choices = choices + if not kwargs.get("queryset", False): + self.queryset = Treatment.objects.none() + else: + # make sure the treatment name is still valid even it has been changed in the default + self.form.base_fields["name"].choices += [(t.name, t.name) for t in kwargs["queryset"] if t.name not in choices] + + def set_user(self, user: User): + self.form.base_fields["name"].choices = [ + *self.form.base_fields['name'].choices, + *[(t.name, t.name) for t in user.treatments.all()] + ] + return self diff --git a/qmra/risk_assessment/migrations/0001_initial.py b/qmra/risk_assessment/migrations/0001_initial.py new file mode 100644 index 0000000..6be5424 --- /dev/null +++ b/qmra/risk_assessment/migrations/0001_initial.py @@ -0,0 +1,86 @@ +# Generated by Django 5.0.6 on 2024-10-04 06:17 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RiskAssessment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, null=True)), + ('name', models.CharField(blank=True, default='', max_length=64)), + ('description', models.TextField(blank=True, default='', max_length=500)), + ('source_name', models.CharField(blank=True, choices=[('', '---------'), ('groundwater', [('groundwater', 'groundwater')]), ('rainwater', [('rainwater, rooftop harvesting', 'rainwater, rooftop harvesting'), ('rainwater, stormwater harvesting', 'rainwater, stormwater harvesting')]), ('sewage', [('sewage, raw', 'sewage, raw'), ('sewage, treated', 'sewage, treated')]), ('surface water', [('surface water, contaminated', 'surface water, contaminated'), ('surface water, general', 'surface water, general'), ('surface water, protected', 'surface water, protected')]), ('other', 'other')], max_length=256)), + ('exposure_name', models.CharField(blank=True, choices=[('', '---------'), ('domestic use', [('domestic use, car washing', 'domestic use, car washing'), ('domestic use, toilet flushing', 'domestic use, toilet flushing'), ('domestic use, washing machine', 'domestic use, washing machine')]), ('drinking water', [('drinking water', 'drinking water')]), ('irrigation', [('irrigation, garden', 'irrigation, garden'), ('irrigation, public', 'irrigation, public'), ('irrigation, restricted', 'irrigation, restricted'), ('irrigation, unrestricted', 'irrigation, unrestricted')]), ('other', 'other')], max_length=256)), + ('events_per_year', models.IntegerField()), + ('volume_per_event', models.FloatField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='risk_assessments', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Inflow', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('pathogen', models.CharField(choices=[('', '---------'), ('Bacteria', [('Bacillus anthracis', 'Bacillus anthracis'), ('Burkholderia pseudomallei', 'Burkholderia pseudomallei'), ('Campylobacter jejuni', 'Campylobacter jejuni'), ('Coxiella burnetii', 'Coxiella burnetii'), ('Escherichia coli enterohemorrhagic (EHEC)', 'Escherichia coli enterohemorrhagic (EHEC)'), ('Escherichia coli', 'Escherichia coli'), ('Francisella tularensis', 'Francisella tularensis'), ('Legionella pneumophila', 'Legionella pneumophila'), ('Listeria monocytogenes (Death as response)', 'Listeria monocytogenes (Death as response)'), ('Listeria monocytogenes (Infection)', 'Listeria monocytogenes (Infection)'), ('Listeria monocytogenes (Stillbirths)', 'Listeria monocytogenes (Stillbirths)'), ('Mycobacterium avium', 'Mycobacterium avium'), ('Pseudomonas aeruginosa (Contact lens)', 'Pseudomonas aeruginosa (Contact lens)'), ('Pseudomonas aeruginosa (bacterimia)', 'Pseudomonas aeruginosa (bacterimia)'), ('Rickettsia rickettsi', 'Rickettsia rickettsi'), ('Salmonella Typhi', 'Salmonella Typhi'), ('Salmonella anatum', 'Salmonella anatum'), ('Salmonella meleagridis', 'Salmonella meleagridis'), ('Salmonella nontyphoid', 'Salmonella nontyphoid'), ('Salmonella serotype newport', 'Salmonella serotype newport'), ('Shigella', 'Shigella'), ('Staphylococcus aureus', 'Staphylococcus aureus'), ('Vibrio cholerae', 'Vibrio cholerae'), ('Yersinia pestis', 'Yersinia pestis')]), ('Viruses', [('Adenovirus', 'Adenovirus'), ('Echovirus', 'Echovirus'), ('Enteroviruses', 'Enteroviruses'), ('Influenza', 'Influenza'), ('Lassa virus', 'Lassa virus'), ('Poliovirus', 'Poliovirus'), ('Rhinovirus', 'Rhinovirus'), ('Rotavirus', 'Rotavirus'), ('SARS', 'SARS')]), ('Protozoa', [('Cryptosporidium parvum', 'Cryptosporidium parvum'), ('Endamoeba coli', 'Endamoeba coli'), ('Giardia duodenalis', 'Giardia duodenalis'), ('Naegleria fowleri', 'Naegleria fowleri')])], max_length=256)), + ('min', models.FloatField()), + ('max', models.FloatField()), + ('risk_assessment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inflows', to='risk_assessment.riskassessment')), + ], + ), + migrations.CreateModel( + name='RiskAssessmentResult', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('pathogen', models.CharField(choices=[('', '---------'), ('Bacteria', [('Bacillus anthracis', 'Bacillus anthracis'), ('Burkholderia pseudomallei', 'Burkholderia pseudomallei'), ('Campylobacter jejuni', 'Campylobacter jejuni'), ('Coxiella burnetii', 'Coxiella burnetii'), ('Escherichia coli enterohemorrhagic (EHEC)', 'Escherichia coli enterohemorrhagic (EHEC)'), ('Escherichia coli', 'Escherichia coli'), ('Francisella tularensis', 'Francisella tularensis'), ('Legionella pneumophila', 'Legionella pneumophila'), ('Listeria monocytogenes (Death as response)', 'Listeria monocytogenes (Death as response)'), ('Listeria monocytogenes (Infection)', 'Listeria monocytogenes (Infection)'), ('Listeria monocytogenes (Stillbirths)', 'Listeria monocytogenes (Stillbirths)'), ('Mycobacterium avium', 'Mycobacterium avium'), ('Pseudomonas aeruginosa (Contact lens)', 'Pseudomonas aeruginosa (Contact lens)'), ('Pseudomonas aeruginosa (bacterimia)', 'Pseudomonas aeruginosa (bacterimia)'), ('Rickettsia rickettsi', 'Rickettsia rickettsi'), ('Salmonella Typhi', 'Salmonella Typhi'), ('Salmonella anatum', 'Salmonella anatum'), ('Salmonella meleagridis', 'Salmonella meleagridis'), ('Salmonella nontyphoid', 'Salmonella nontyphoid'), ('Salmonella serotype newport', 'Salmonella serotype newport'), ('Shigella', 'Shigella'), ('Staphylococcus aureus', 'Staphylococcus aureus'), ('Vibrio cholerae', 'Vibrio cholerae'), ('Yersinia pestis', 'Yersinia pestis')]), ('Viruses', [('Adenovirus', 'Adenovirus'), ('Echovirus', 'Echovirus'), ('Enteroviruses', 'Enteroviruses'), ('Influenza', 'Influenza'), ('Lassa virus', 'Lassa virus'), ('Poliovirus', 'Poliovirus'), ('Rhinovirus', 'Rhinovirus'), ('Rotavirus', 'Rotavirus'), ('SARS', 'SARS')]), ('Protozoa', [('Cryptosporidium parvum', 'Cryptosporidium parvum'), ('Endamoeba coli', 'Endamoeba coli'), ('Giardia duodenalis', 'Giardia duodenalis'), ('Naegleria fowleri', 'Naegleria fowleri')])], max_length=256)), + ('infection_risk', models.BooleanField()), + ('dalys_risk', models.BooleanField()), + ('infection_minimum_lrv_min', models.FloatField()), + ('infection_minimum_lrv_max', models.FloatField()), + ('infection_minimum_lrv_q1', models.FloatField()), + ('infection_minimum_lrv_q3', models.FloatField()), + ('infection_minimum_lrv_median', models.FloatField()), + ('infection_maximum_lrv_min', models.FloatField()), + ('infection_maximum_lrv_max', models.FloatField()), + ('infection_maximum_lrv_q1', models.FloatField()), + ('infection_maximum_lrv_q3', models.FloatField()), + ('infection_maximum_lrv_median', models.FloatField()), + ('dalys_minimum_lrv_min', models.FloatField()), + ('dalys_minimum_lrv_max', models.FloatField()), + ('dalys_minimum_lrv_q1', models.FloatField()), + ('dalys_minimum_lrv_q3', models.FloatField()), + ('dalys_minimum_lrv_median', models.FloatField()), + ('dalys_maximum_lrv_min', models.FloatField()), + ('dalys_maximum_lrv_max', models.FloatField()), + ('dalys_maximum_lrv_q1', models.FloatField()), + ('dalys_maximum_lrv_q3', models.FloatField()), + ('dalys_maximum_lrv_median', models.FloatField()), + ('risk_assessment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='risk_assessment.riskassessment')), + ], + ), + migrations.CreateModel( + name='Treatment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(choices=[('Bank filtration', 'Bank filtration'), ('Chlorination, drinking water', 'Chlorination, drinking water'), ('Chlorination, wastewater', 'Chlorination, wastewater'), ('Chlorine dioxide', 'Chlorine dioxide'), ('Conventional clarification', 'Conventional clarification'), ('Dissolved air flotation', 'Dissolved air flotation'), ('Dual media filtration', 'Dual media filtration'), ('Granular high-rate filtration', 'Granular high-rate filtration'), ('High-rate clarification', 'High-rate clarification'), ('Lime softening', 'Lime softening'), ('Membrane filtration', 'Membrane filtration'), ('Microfiltration', 'Microfiltration'), ('Nanofiltration', 'Nanofiltration'), ('Ozonation, drinking water', 'Ozonation, drinking water'), ('Ozonation, wastewater', 'Ozonation, wastewater'), ('Precoat filtration', 'Precoat filtration'), ('Primary treatment', 'Primary treatment'), ('Reverse osmosis', 'Reverse osmosis'), ('Roughing filters', 'Roughing filters'), ('Secondary treatment', 'Secondary treatment'), ('Slow sand filtration', 'Slow sand filtration'), ('Storage reservoirs', 'Storage reservoirs'), ('UV disinfection 20 mJ/cm2, drinking', 'UV disinfection 20 mJ/cm2, drinking'), ('UV disinfection 40 mJ/cm2, drinking', 'UV disinfection 40 mJ/cm2, drinking'), ('UV disinfection, wastewater', 'UV disinfection, wastewater'), ('Ultrafiltration', 'Ultrafiltration'), ('Wetlands, subsurface flow', 'Wetlands, subsurface flow'), ('Wetlands, surface flow', 'Wetlands, surface flow')], max_length=64)), + ('bacteria_min', models.FloatField(blank=True, null=True)), + ('bacteria_max', models.FloatField(blank=True, null=True)), + ('viruses_min', models.FloatField(blank=True, null=True)), + ('viruses_max', models.FloatField(blank=True, null=True)), + ('protozoa_min', models.FloatField(blank=True, null=True)), + ('protozoa_max', models.FloatField(blank=True, null=True)), + ('risk_assessment', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='treatments', to='risk_assessment.riskassessment')), + ], + ), + ] diff --git a/qmra/risk_assessment/migrations/0002_alter_inflow_pathogen_and_more.py b/qmra/risk_assessment/migrations/0002_alter_inflow_pathogen_and_more.py new file mode 100644 index 0000000..0087d1f --- /dev/null +++ b/qmra/risk_assessment/migrations/0002_alter_inflow_pathogen_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.6 on 2024-10-18 14:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='inflow', + name='pathogen', + field=models.CharField(choices=[('', '---------'), ('Bacteria', [('Campylobacter jejuni', 'Campylobacter jejuni')]), ('Viruses', [('Rotavirus', 'Rotavirus')]), ('Protozoa', [('Cryptosporidium parvum', 'Cryptosporidium parvum')])], max_length=256), + ), + migrations.AlterField( + model_name='riskassessmentresult', + name='dalys_risk', + field=models.CharField(choices=[('min', 'min'), ('max', 'max'), ('none', 'none')], max_length=4), + ), + migrations.AlterField( + model_name='riskassessmentresult', + name='infection_risk', + field=models.CharField(choices=[('min', 'min'), ('max', 'max'), ('none', 'none')], max_length=4), + ), + migrations.AlterField( + model_name='riskassessmentresult', + name='pathogen', + field=models.CharField(choices=[('', '---------'), ('Bacteria', [('Campylobacter jejuni', 'Campylobacter jejuni')]), ('Viruses', [('Rotavirus', 'Rotavirus')]), ('Protozoa', [('Cryptosporidium parvum', 'Cryptosporidium parvum')])], max_length=256), + ), + ] diff --git a/qmra/risk_assessment/migrations/0003_userexposure_usersource_usertreatment.py b/qmra/risk_assessment/migrations/0003_userexposure_usersource_usertreatment.py new file mode 100644 index 0000000..54964b4 --- /dev/null +++ b/qmra/risk_assessment/migrations/0003_userexposure_usersource_usertreatment.py @@ -0,0 +1,56 @@ +# Generated by Django 5.0.6 on 2025-02-05 06:54 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0002_alter_inflow_pathogen_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UserExposure', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField()), + ('description', models.TextField()), + ('events_per_year', models.IntegerField()), + ('volume_per_event', models.FloatField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exposures', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserSource', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField(max_length=64)), + ('rotavirus_min', models.FloatField(blank=True, null=True)), + ('rotavirus_max', models.FloatField(blank=True, null=True)), + ('campylobacter_min', models.FloatField(blank=True, null=True)), + ('campylobacter_max', models.FloatField(blank=True, null=True)), + ('cryptosporidium_min', models.FloatField(blank=True, null=True)), + ('cryptosporidium_max', models.FloatField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sources', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='UserTreatment', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.TextField(max_length=64)), + ('bacteria_min', models.FloatField(blank=True, null=True)), + ('bacteria_max', models.FloatField(blank=True, null=True)), + ('viruses_min', models.FloatField(blank=True, null=True)), + ('viruses_max', models.FloatField(blank=True, null=True)), + ('protozoa_min', models.FloatField(blank=True, null=True)), + ('protozoa_max', models.FloatField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='treatments', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/qmra/risk_assessment/migrations/0004_alter_riskassessment_exposure_name_and_more.py b/qmra/risk_assessment/migrations/0004_alter_riskassessment_exposure_name_and_more.py new file mode 100644 index 0000000..8d2b2e0 --- /dev/null +++ b/qmra/risk_assessment/migrations/0004_alter_riskassessment_exposure_name_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.6 on 2025-02-13 16:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0003_userexposure_usersource_usertreatment'), + ] + + operations = [ + migrations.AlterField( + model_name='riskassessment', + name='exposure_name', + field=models.CharField(blank=True, max_length=256), + ), + migrations.AlterField( + model_name='riskassessment', + name='source_name', + field=models.CharField(blank=True, max_length=256), + ), + migrations.AlterField( + model_name='treatment', + name='name', + field=models.CharField(max_length=64), + ), + migrations.AlterField( + model_name='userexposure', + name='description', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/qmra/risk_assessment/migrations/0005_treatment_train_index.py b/qmra/risk_assessment/migrations/0005_treatment_train_index.py new file mode 100644 index 0000000..9654878 --- /dev/null +++ b/qmra/risk_assessment/migrations/0005_treatment_train_index.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2025-02-28 09:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0004_alter_riskassessment_exposure_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='treatment', + name='train_index', + field=models.IntegerField(default=0), + ), + ] diff --git a/qmra/risk_assessment/migrations/0006_qmrapathogen_qmrareference_qmrasource_qmraexposure_and_more.py b/qmra/risk_assessment/migrations/0006_qmrapathogen_qmrareference_qmrasource_qmraexposure_and_more.py new file mode 100644 index 0000000..53e0403 --- /dev/null +++ b/qmra/risk_assessment/migrations/0006_qmrapathogen_qmrareference_qmrasource_qmraexposure_and_more.py @@ -0,0 +1,84 @@ +# Generated by Django 5.0.6 on 2025-09-17 06:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0005_treatment_train_index'), + ] + + operations = [ + migrations.CreateModel( + name='QMRAPathogen', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('group', models.CharField(choices=[('Bacteria', 'Bacteria'), ('Viruses', 'Viruses'), ('Protozoa', 'Protozoa')], max_length=256)), + ('name', models.CharField(max_length=256)), + ('best_fit_model', models.CharField(choices=[('exponential', 'exponential'), ('beta-Poisson', 'beta-Poisson')], max_length=256)), + ('k', models.FloatField(blank=True, null=True)), + ('alpha', models.FloatField(blank=True, null=True)), + ('n50', models.FloatField(blank=True, null=True)), + ('infection_to_illness', models.FloatField(blank=True, default=True, null=True)), + ('dalys_per_case', models.FloatField(blank=True, default=True, null=True)), + ], + ), + migrations.CreateModel( + name='QMRAReference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256)), + ('link', models.URLField(max_length=512)), + ], + ), + migrations.CreateModel( + name='QMRASource', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256)), + ('description', models.CharField(max_length=512)), + ], + ), + migrations.CreateModel( + name='QMRAExposure', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256)), + ('description', models.CharField(max_length=256)), + ('events_per_year', models.IntegerField()), + ('volume_per_event', models.FloatField()), + ('reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='risk_assessment.qmrareference')), + ], + ), + migrations.CreateModel( + name='QMRAInflow', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('min', models.FloatField()), + ('max', models.FloatField()), + ('pathogen', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='risk_assessment.qmrapathogen')), + ('reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='risk_assessment.qmrareference')), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='risk_assessment.qmrasource')), + ], + ), + migrations.CreateModel( + name='QMRATreatment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256)), + ('group', models.CharField(max_length=256)), + ('description', models.CharField(max_length=512)), + ('bacteria_min', models.FloatField(blank=True, null=True)), + ('bacteria_max', models.FloatField(blank=True, null=True)), + ('viruses_min', models.FloatField(blank=True, null=True)), + ('viruses_max', models.FloatField(blank=True, null=True)), + ('protozoa_min', models.FloatField(blank=True, null=True)), + ('protozoa_max', models.FloatField(blank=True, null=True)), + ('bacteria_reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='bacteria_lrv', to='risk_assessment.qmrareference')), + ('protozoa_reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='protozoa_lrv', to='risk_assessment.qmrareference')), + ('viruses_reference', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='viruses_lrv', to='risk_assessment.qmrareference')), + ], + ), + ] diff --git a/qmra/risk_assessment/migrations/0007_qmratreatment_bacteria_references_and_more.py b/qmra/risk_assessment/migrations/0007_qmratreatment_bacteria_references_and_more.py new file mode 100644 index 0000000..6250f5a --- /dev/null +++ b/qmra/risk_assessment/migrations/0007_qmratreatment_bacteria_references_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.6 on 2026-01-15 12:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0006_qmrapathogen_qmrareference_qmrasource_qmraexposure_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='qmratreatment', + name='bacteria_references', + field=models.ManyToManyField(related_name='bacteria_lrvs', to='risk_assessment.qmrareference'), + ), + migrations.AddField( + model_name='qmratreatment', + name='protozoa_references', + field=models.ManyToManyField(related_name='protozoa_lrvs', to='risk_assessment.qmrareference'), + ), + migrations.AddField( + model_name='qmratreatment', + name='viruses_references', + field=models.ManyToManyField(related_name='viruses_lrvs', to='risk_assessment.qmrareference'), + ), + ] diff --git a/qmra/risk_assessment/migrations/0008_auto_20260115_1342.py b/qmra/risk_assessment/migrations/0008_auto_20260115_1342.py new file mode 100644 index 0000000..0509230 --- /dev/null +++ b/qmra/risk_assessment/migrations/0008_auto_20260115_1342.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.6 on 2026-01-15 12:42 + +from django.db import migrations + + +def migrate_references(apps, schema_editor): + if schema_editor.connection.alias != "qmra": + return + QMRATreatment = apps.get_model("risk_assessment", "QMRATreatment") + for treatment in QMRATreatment.objects.all(): + + treatment.bacteria_references.add(treatment.bacteria_reference) + treatment.viruses_references.add(treatment.viruses_reference) + treatment.protozoa_references.add(treatment.protozoa_reference) + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0007_qmratreatment_bacteria_references_and_more'), + ] + + operations = [ + migrations.RunPython(migrate_references), + ] diff --git a/qmra/risk_assessment/migrations/0009_auto_20260121_1454.py b/qmra/risk_assessment/migrations/0009_auto_20260121_1454.py new file mode 100644 index 0000000..2f5e73d --- /dev/null +++ b/qmra/risk_assessment/migrations/0009_auto_20260121_1454.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.6 on 2026-01-15 12:42 +from django.core.management import call_command +from django.db import migrations + + +def migrate_references(apps, schema_editor): + if schema_editor.connection.alias != "qmra": + return + QMRATreatment = apps.get_model("risk_assessment", "QMRATreatment") + all_treatments = QMRATreatment.objects.all() + if len(all_treatments) == 0: + call_command("seed_default_db") + all_treatments = QMRATreatment.objects.all() + for treatment in all_treatments: + + treatment.bacteria_references.add(treatment.bacteria_reference) + treatment.viruses_references.add(treatment.viruses_reference) + treatment.protozoa_references.add(treatment.protozoa_reference) + treatment.save() + call_command("export_default") + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0008_auto_20260115_1342'), + ] + + operations = [ + migrations.RunPython(migrate_references), + ] diff --git a/qmra/risk_assessment/migrations/0010_remove_qmratreatment_bacteria_reference_and_more.py b/qmra/risk_assessment/migrations/0010_remove_qmratreatment_bacteria_reference_and_more.py new file mode 100644 index 0000000..c40b1f6 --- /dev/null +++ b/qmra/risk_assessment/migrations/0010_remove_qmratreatment_bacteria_reference_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.6 on 2026-01-22 09:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('risk_assessment', '0009_auto_20260121_1454'), + ] + + operations = [ + migrations.RemoveField( + model_name='qmratreatment', + name='bacteria_reference', + ), + migrations.RemoveField( + model_name='qmratreatment', + name='protozoa_reference', + ), + migrations.RemoveField( + model_name='qmratreatment', + name='viruses_reference', + ), + ] diff --git a/qmra/risk_assessment/migrations/__init__.py b/qmra/risk_assessment/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qmra/risk_assessment/models.py b/qmra/risk_assessment/models.py new file mode 100644 index 0000000..030def7 --- /dev/null +++ b/qmra/risk_assessment/models.py @@ -0,0 +1,152 @@ +import uuid + +from django.db import models +from django.db.models import QuerySet + +from qmra.risk_assessment.qmra_models import QMRAPathogens, QMRATreatment +from qmra.user.models import User + + +# @dtc.dataclass + + +class Inflow(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + risk_assessment = models.ForeignKey("RiskAssessment", related_name="inflows", on_delete=models.CASCADE) + pathogen = models.CharField(choices=QMRAPathogens.choices(), + blank=False, null=False, max_length=256) + # reference = models.ForeignKey( + # Reference, blank=True, null=True, default=None, + # on_delete=models.CASCADE) + min = models.FloatField() + max = models.FloatField() + # pathogen_in_ref = models.CharField(max_length=200, default="unknown") + # notes = models.CharField(max_length=200, default="unknown") + + +class Treatment(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + risk_assessment = models.ForeignKey("RiskAssessment", related_name="treatments", on_delete=models.CASCADE) + name = models.CharField(max_length=64) + train_index = models.IntegerField(blank=False, null=False, default=0) + bacteria_min = models.FloatField(blank=True, null=True) + bacteria_max = models.FloatField(blank=True, null=True) + viruses_min = models.FloatField(blank=True, null=True) + viruses_max = models.FloatField(blank=True, null=True) + protozoa_min = models.FloatField(blank=True, null=True) + protozoa_max = models.FloatField(blank=True, null=True) + + @classmethod + def from_default(cls, default: QMRATreatment, risk_assessment): + return Treatment.objects.create( + risk_assessment=risk_assessment, + name=default.name, + bacteria_min=default.bacteria_min, + bacteria_max=default.bacteria_max, + viruses_min=default.viruses_min, + viruses_max=default.viruses_max, + protozoa_min=default.protozoa_min, + protozoa_max=default.protozoa_max, + ) + + def above_max_lrv(self): + return [field for field in [ + "Viruses LRV Maximum" if self.viruses_max is not None and self.viruses_max > 6 else None, + "Bacteria LRV Maximum" if self.bacteria_max is not None and self.bacteria_max > 6 else None, + "Protozoa LRV Maximum" if self.protozoa_max is not None and self.protozoa_max > 6 else None, + ] if field is not None] + + +class RiskAssessment(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="risk_assessments") + created_at = models.DateTimeField(auto_now_add=True, null=True) + name = models.CharField(max_length=64, default="", blank=True) + description = models.TextField(max_length=500, default="", blank=True) + source_name = models.CharField(blank=True, max_length=256) + inflows: QuerySet[Inflow] + treatments: QuerySet[Treatment] + exposure_name = models.CharField(blank=True, max_length=256) + events_per_year = models.IntegerField() + volume_per_event = models.FloatField() + + results: QuerySet["RiskAssessmentResult"] + + @property + def infection_risk(self): + risks = {r.infection_risk for r in self.results.all()} + return 'max' if 'max' in risks else ("min" if 'min' in risks else 'none') + + @property + def dalys_risk(self): + return any(r.dalys_risk for r in self.results.all()) + + @property + def pathogens_labels(self): + return ", ".join([inflow.pathogen for inflow in self.inflows.all()]) + + @property + def treatments_labels(self): + return ", ".join([treatment.name for treatment in self.treatments.all()]) + + def results_list(self): + return [r.as_dict() for r in self.results.all()] + + def __str__(self): + return self.name + + +class RiskAssessmentResult(models.Model): + risk_assessment = models.ForeignKey(RiskAssessment, on_delete=models.CASCADE, related_name="results") + + pathogen = models.CharField(choices=QMRAPathogens.choices(), max_length=256) + infection_risk = models.CharField(choices=[("min", "min"), ("max", "max"), ("none", "none")], max_length=4) + dalys_risk = models.CharField(choices=[("min", "min"), ("max", "max"), ("none", "none")], max_length=4) + infection_minimum_lrv_min = models.FloatField() + infection_minimum_lrv_max = models.FloatField() + infection_minimum_lrv_q1 = models.FloatField() + infection_minimum_lrv_q3 = models.FloatField() + infection_minimum_lrv_median = models.FloatField() + infection_maximum_lrv_min = models.FloatField() + infection_maximum_lrv_max = models.FloatField() + infection_maximum_lrv_q1 = models.FloatField() + infection_maximum_lrv_q3 = models.FloatField() + infection_maximum_lrv_median = models.FloatField() + dalys_minimum_lrv_min = models.FloatField() + dalys_minimum_lrv_max = models.FloatField() + dalys_minimum_lrv_q1 = models.FloatField() + dalys_minimum_lrv_q3 = models.FloatField() + dalys_minimum_lrv_median = models.FloatField() + dalys_maximum_lrv_min = models.FloatField() + dalys_maximum_lrv_max = models.FloatField() + dalys_maximum_lrv_q1 = models.FloatField() + dalys_maximum_lrv_q3 = models.FloatField() + dalys_maximum_lrv_median = models.FloatField() + + def as_dict(self): + return dict( + ra_name=self.risk_assessment.name, + pathogen=self.pathogen, + infection_risk=self.infection_risk, + dalys_risk=self.dalys_risk, + infection_minimum_lrv_min=self.infection_minimum_lrv_min, + infection_minimum_lrv_max=self.infection_minimum_lrv_max, + infection_minimum_lrv_q1=self.infection_minimum_lrv_q1, + infection_minimum_lrv_q3=self.infection_minimum_lrv_q3, + infection_minimum_lrv_median=self.infection_minimum_lrv_median, + infection_maximum_lrv_min=self.infection_maximum_lrv_min, + infection_maximum_lrv_max=self.infection_maximum_lrv_max, + infection_maximum_lrv_q1=self.infection_maximum_lrv_q1, + infection_maximum_lrv_q3=self.infection_maximum_lrv_q3, + infection_maximum_lrv_median=self.infection_maximum_lrv_median, + dalys_minimum_lrv_min=self.dalys_minimum_lrv_min, + dalys_minimum_lrv_max=self.dalys_minimum_lrv_max, + dalys_minimum_lrv_q1=self.dalys_minimum_lrv_q1, + dalys_minimum_lrv_q3=self.dalys_minimum_lrv_q3, + dalys_minimum_lrv_median=self.dalys_minimum_lrv_median, + dalys_maximum_lrv_min=self.dalys_maximum_lrv_min, + dalys_maximum_lrv_max=self.dalys_maximum_lrv_max, + dalys_maximum_lrv_q1=self.dalys_maximum_lrv_q1, + dalys_maximum_lrv_q3=self.dalys_maximum_lrv_q3, + dalys_maximum_lrv_median=self.dalys_maximum_lrv_median + ) diff --git a/qmra/risk_assessment/plots.py b/qmra/risk_assessment/plots.py new file mode 100644 index 0000000..98a33ab --- /dev/null +++ b/qmra/risk_assessment/plots.py @@ -0,0 +1,132 @@ +from plotly import graph_objects as go +from plotly.offline import plot +from plotly import express as px +import pandas as pd + +RISK_CATEGORY_BG_COLORS = dict( + none='#E2FBAC', min='#FFDDB5', max='#FFECF4' +) +MAX_COLOR_SEQ = [ + "hsl(359, 100%, 40%)", + "hsl(359, 100%, 60%)", + "hsl(359, 100%, 80%)", +] + +COLORS = { + "Rotavirus": "hsl(332, 100, 49%)", + "Campylobacter jejuni": "hsl(188, 100, 45%)", + "Cryptosporidium parvum": "hsl(239, 100, 45%)", +} + + +def risk_plots(risk_assessment_results, output_type="div"): + infection_prob_fig = go.Figure() + dalys_fig = go.Figure() + for i, r in enumerate(risk_assessment_results): + infection_prob_fig.add_trace(go.Box( + x=["Minimum LRV", "Maximum LRV"], + lowerfence=[r.infection_minimum_lrv_min, r.infection_maximum_lrv_min], + upperfence=[r.infection_minimum_lrv_max, r.infection_maximum_lrv_max], + q1=[r.infection_minimum_lrv_q1, r.infection_maximum_lrv_q1], + q3=[r.infection_minimum_lrv_q3, r.infection_maximum_lrv_q3], + median=[r.infection_minimum_lrv_median, r.infection_maximum_lrv_median], + name=r.pathogen, + marker=dict(color=COLORS[r.pathogen]), + hoverinfo="y" + )) + # infection_prob_fig.add_annotation(text=r.pathogen, showarrow=False, xref="paper", yref="paper", x=(i+1)/7, y=0) + dalys_fig.add_trace(go.Box( + x=["Minimum LRV", "Maximum LRV"], + lowerfence=[r.dalys_minimum_lrv_min, r.dalys_maximum_lrv_min], + upperfence=[r.dalys_minimum_lrv_max, r.dalys_maximum_lrv_max], + q1=[r.dalys_minimum_lrv_q1, r.dalys_maximum_lrv_q1], + q3=[r.dalys_minimum_lrv_q3, r.dalys_maximum_lrv_q3], + median=[r.dalys_minimum_lrv_median, r.dalys_maximum_lrv_median], + name=r.pathogen, + marker=dict(color=COLORS[r.pathogen]), + hoverinfo="y" + )) + # dalys_fig.add_annotation(text=r.pathogen, showarrow=False, xref="paper", yref="paper", x=(i+1)/7, y=0) + + infection_prob_fig.update_layout( + boxmode='group', + height=350, + # font_family="Helvetica Neue, Helvetica, Arial, sans-serif", + font_color="black", + plot_bgcolor="#F6F6FF", + xaxis=dict(title="", showgrid=False), + yaxis=dict(title="Probability of infection per year", + showgrid=False), + margin=dict(l=(int(output_type == "png") * 30), r=(int(output_type == "png") * 30), t=30, b=30), + legend=dict( + orientation="h", + yanchor="top", + xanchor="center", + x=0.5, + ) + ) + infection_prob_fig.update_yaxes(type="log", + showexponent='all', + dtick=1, + exponentformat='power' + ) + infection_prob_fig.add_hline(y=0.0001, line_dash="dashdot", + label=dict( + text="tolerable risk level", + textposition="end", + yanchor="top", + font=dict(color="#FF0000") + ), + line=dict(color="#FF0000", width=1) + ) + infection_prob_fig.update_traces( + marker_size=8 + ) + + dalys_fig.update_layout( + boxmode='group', + height=350, + # font_family="Helvetica Neue, Helvetica, Arial, sans-serif", + font_color="black", + plot_bgcolor="#F6F6FF", + xaxis=dict(title="", showgrid=False), + yaxis=dict(title="DALYs pppy", showgrid=False), + margin=dict(l=(int(output_type == "png") * 30), r=(int(output_type == "png") * 30), t=30, b=30), + legend=dict( + orientation="h", + yanchor="top", + xanchor="center", + x=0.5, + ) + ) + dalys_fig.update_yaxes(type="log", + showexponent='all', + dtick=1, + exponentformat='power' + ) + dalys_fig.add_hline(y=0.000001, line_dash="dashdot", + label=dict( + text="tolerable risk level", + textposition="end", + yanchor="top", + font=dict(color="#FF0000") + ), + line=dict(color="#FF0000", width=1) + ) + dalys_fig.update_traces( + marker_size=8 + ) + if output_type == "div": + return plot(infection_prob_fig, output_type="div", config={"displaylogo": False, + "modeBarButtonsToRemove": ['zoom2d', 'pan2d', + 'select2d', 'lasso2d', + 'zoomIn2d', 'zoomOut2d', + 'autoScale2d', + 'resetScale2d']}, + include_plotlyjs=False), \ + plot(dalys_fig, output_type="div", config={"displaylogo": False, + "modeBarButtonsToRemove": ['zoom2d', 'pan2d', 'select2d', + 'lasso2d', 'zoomIn2d', 'zoomOut2d', + 'autoScale2d', 'resetScale2d']}, + include_plotlyjs=False) + return infection_prob_fig.to_image(format=output_type), dalys_fig.to_image(format=output_type) diff --git a/qmra/risk_assessment/qmra_models.py b/qmra/risk_assessment/qmra_models.py new file mode 100644 index 0000000..4f2cf4f --- /dev/null +++ b/qmra/risk_assessment/qmra_models.py @@ -0,0 +1,373 @@ +import abc +import dataclasses as dtc +import enum +import json +from itertools import groupby +from typing import Optional, Any + +import numpy as np +from django.db import models +from django.forms import model_to_dict +from django.utils.functional import classproperty + + +""" +IMPORTANT NOTE: + +qmra has 2 databases: +1. 'default' contains the users' defaults and risk_assessment data +2. 'qmra' contains the KWB's defaults and is managed only by KWB + +to bootstrap the 'qmra' db: +``` +python manage.py collect_default_static_entities # create data/default-*.json from the original QMRA data (.csv in raw_public_data/) +python manage.py seed_default_db # ingest the json into the 'qmra' db +``` +additionally, +``` +python manage.py export_default +``` +re-create the json **from** the 'qmra' db and is called every time an admin saves something in the admin page + +In order to provide the correct choices in server-side rendered forms, this module exposes `StaticEntities` models. +These classes read the .json files (not the 'qmra' db!) and scrape the keys for valid choices. + +""" + +class ExponentialDistribution: + def __init__(self, k): + self.k = k + + def pdf(self, x): + return 1 - np.exp(-self.k * x) + + +class BetaPoissonDistribution: + def __init__(self, alpha, n50): + self.alpha = alpha + self.n50 = n50 + + def pdf(self, x): + return 1 - (1 + x * (2 ** (1 / self.alpha) - 1) / self.n50) ** -self.alpha + + +class StaticEntity(metaclass=abc.ABCMeta): + _raw_data: Optional[dict[str, dict[str, Any]]] = None + + @property + @abc.abstractmethod + def source(self) -> str: + pass + + @property + @abc.abstractmethod + def model(self) -> dtc.dataclass: + pass + + @property + @abc.abstractmethod + def primary_key(self) -> str: + pass + + @classproperty + def raw_data(cls) -> dict[str, dict[str, Any]]: + # because an admin can change this data while the app runs, + # we need _raw_data to be loaded dynamically... + with open(cls.source, "r") as f: + cls._raw_data = json.load(f) + return cls._raw_data + + @classproperty + def data(cls) -> dict[str, model]: + return {k: cls.model.from_dict(r) for k, r in cls.raw_data.items()} + + @classmethod + @abc.abstractmethod + def choices(cls): + pass + + @classmethod + def get(cls, pk: str): + return cls.data[pk] + + +class QMRAReference(models.Model): + name = models.CharField(blank=False, null=False, max_length=256) + link = models.URLField(blank=False, null=False, max_length=512) + + @classmethod + def from_dict(cls, data: dict): + return QMRAReference( + pk=data["ReferenceID"], + name=data["ReferenceName"], + link=data["ReferenceLink"] + ) + + def to_dict(self): + return dict( + ReferenceID=self.pk, + ReferenceName=self.name, + ReferenceLink=self.link + ) + + def __str__(self): + return self.name + + +class QMRAReferences(StaticEntity): + source = "qmra/static/data/default-references.json" + model = QMRAReference + primary_key = "name" + + +class PathogenGroup(models.TextChoices): + Bacteria = "Bacteria" + Viruses = "Viruses" + Protozoa = "Protozoa" + + +class ModelDistributionType(enum.Enum): + exponential = "exponential" + beta_poisson = "beta-Poisson" + + +class QMRASource(models.Model): + name = models.CharField(blank=False, null=False, max_length=256) + description = models.CharField(blank=False, null=False, max_length=512) + + @classmethod + def from_dict(cls, data) -> "QMRASource": + return QMRASource( + pk=data["id"], name=data["name"], description=data['description'] + ) + + def to_dict(self) -> dict: + data = model_to_dict(self) + data["id"] = self.pk + return data + + +class QMRASources(StaticEntity): + source = "qmra/static/data/default-sources.json" + model = QMRASource + primary_key = "name" + + @classmethod + def choices(cls): + grouped = {grp: list(v) for grp, v in + groupby(sorted(cls.data.values(), key=lambda x: x.name), key=lambda x: x.name.split(",")[0])} + return [ + ("", "---------"), + *[(k, [(x.name, x.name) for x in v]) for k, v in grouped.items()], + ("other", "other") + ] + + +class QMRAPathogen(models.Model): + group = models.CharField(choices=PathogenGroup.choices, + blank=False, null=False, max_length=256) + name = models.CharField(blank=False, null=False, max_length=256) + # fields from "doseResponse.csv" + best_fit_model = models.CharField(choices=[(m.value, m.value) for m in ModelDistributionType], + blank=False, null=False, max_length=256) + k = models.FloatField(blank=True, null=True) + alpha = models.FloatField(blank=True, null=True) + n50 = models.FloatField(blank=True, null=True) + # fields from "health.csv" + infection_to_illness = models.FloatField(blank=True, null=True, default=True) + dalys_per_case = models.FloatField(blank=True, null=True, default=True) + + @classmethod + def from_dict(cls, data) -> "QMRAPathogen": + return QMRAPathogen( + pk=data["id"], + group=data["group"], + name=data["name"], + best_fit_model=data["best_fit_model"], + k=data["k"], + alpha=data["alpha"], + n50=data["n50"], + infection_to_illness=data["infection_to_illness"], + dalys_per_case=data["dalys_per_case"], + ) + + def to_dict(self) -> dict: + return model_to_dict(self) + + def get_distribution(self): + if self.best_fit_model == ModelDistributionType.exponential.value: + return ExponentialDistribution(self.k) + elif self.best_fit_model == ModelDistributionType.beta_poisson.value: + return BetaPoissonDistribution(self.alpha, self.n50) + else: + raise TypeError(f"Unknown ModelDistributionType: {self.best_fit_model}") + + def __str__(self): + return self.name + + +class QMRAPathogens(StaticEntity): + source = "qmra/static/data/default-pathogens.json" + model = QMRAPathogen + primary_key = "name" + + @classmethod + def choices(cls): + grouped = {grp: list(v) for grp, v in groupby(cls.data.values(), key=lambda x: x.group)} + return [ + ("", "---------"), + *[(grp, [(x.name, x.name) for x in v]) for grp, v in grouped.items()], + ] + + +class QMRAInflow(models.Model): + source = models.ForeignKey(QMRASource, on_delete=models.CASCADE) + pathogen = models.ForeignKey(QMRAPathogen, on_delete=models.CASCADE) + min: float = models.FloatField() + max: float = models.FloatField() + reference = models.ForeignKey(QMRAReference, blank=True, null=True, on_delete=models.CASCADE) + + @classmethod + def from_dict(cls, data: dict): + return QMRAInflow( + pk=data["id"], + source=QMRASource.objects.get(name=data["source_name"]), + pathogen=QMRAPathogen.objects.get(name=data["pathogen_name"]), + reference_id=data.get("ReferenceID", None), + min=data["min"], + max=data["max"] + ) + + def to_dict(self): + data = model_to_dict(self, exclude={"source", "pathogen"}) + data["source_name"] = self.source.name + data["pathogen_name"] = self.pathogen.name + data["ReferenceID"] = str(self.reference.pk) if self.reference is not None else None + data["id"] = self.pk + return data + + +class QMRAInflows(StaticEntity): + source = "qmra/static/data/default-inflows.json" + model = QMRAInflow + primary_key = None + + @classproperty + def data(cls): + return {k: [QMRAInflow.from_dict(d) for d in data] + for k, data in cls.raw_data.items()} + + @classmethod + def choices(cls): + return [] + + +class QMRATreatment(models.Model): + name: str = models.CharField(max_length=256) + group: str = models.CharField(max_length=256) + description: str = models.CharField(max_length=512) + bacteria_min: Optional[float] = models.FloatField(blank=True, null=True) + bacteria_max: Optional[float] = models.FloatField(blank=True, null=True) + bacteria_references = models.ManyToManyField(QMRAReference, related_name="bacteria_lrvs") + _bacteria_references_tmp: list[str] + + viruses_min: Optional[float] = models.FloatField(blank=True, null=True) + viruses_max: Optional[float] = models.FloatField(blank=True, null=True) + viruses_references = models.ManyToManyField(QMRAReference, related_name="viruses_lrvs") + _viruses_references_tmp: list[str] + + protozoa_min: Optional[float] = models.FloatField(blank=True, null=True) + protozoa_max: Optional[float] = models.FloatField(blank=True, null=True) + protozoa_references = models.ManyToManyField(QMRAReference, related_name="protozoa_lrvs") + _protozoa_references_tmp: list[str] + + @classmethod + def from_dict(cls, data): + t = QMRATreatment( + pk=data["id"], + name=data['name'], + group=data['group'], + description=data['description'], + bacteria_min=data['bacteria_min'], + bacteria_max=data['bacteria_max'], + viruses_min=data['viruses_min'], + viruses_max=data['viruses_max'], + protozoa_min=data['protozoa_min'], + protozoa_max=data['protozoa_max'], + ) + t._bacteria_references_tmp = data.get("bacteria_references", []) + t._viruses_references_tmp = data.get("viruses_references", []) + t._protozoa_references_tmp = data.get("protozoa_references", []) + return t + + def to_dict(self): + data = model_to_dict(self) + data["bacteria_references"] = [str(ref.pk) for ref in self.bacteria_references.all()] + data["viruses_references"] = [str(ref.pk) for ref in self.viruses_references.all()] + data["protozoa_references"] = [str(ref.pk) for ref in self.protozoa_references.all()] + return data + + def save( + self, force_insert=False, force_update=False, using=None, update_fields=None + ): + super().save(force_insert, force_update, using, update_fields) + if hasattr(self, "_viruses_references_tmp"): + self.viruses_references.set(self._viruses_references_tmp) + if hasattr(self, "_protozoa_references_tmp"): + self.protozoa_references.set(self._protozoa_references_tmp) + if hasattr(self, "_bacteria_references_tmp"): + self.bacteria_references.set(self._bacteria_references_tmp) + return self + + +class QMRATreatments(StaticEntity): + source = "qmra/static/data/default-treatments.json" + model = QMRATreatment + primary_key = "name" + + @classmethod + def choices(cls): + return [ + *[(x.name, x.name) for x in sorted(cls.data.values(), key=lambda x: x.name)], + ] + + +class QMRAExposure(models.Model): + name: str = models.CharField(max_length=256) + description: str = models.CharField(max_length=256) + events_per_year: int = models.IntegerField() + volume_per_event: float = models.FloatField() + reference = models.ForeignKey(QMRAReference, blank=True, null=True, on_delete=models.CASCADE) + + @classmethod + def from_dict(cls, data): + return QMRAExposure( + pk=data["id"], + name=data["name"], + description=data["description"], + events_per_year=data["events_per_year"], + volume_per_event=data["volume_per_event"], + reference_id=int(data["ReferenceID"]) if data["ReferenceID"] is not None else None + ) + + def to_dict(self): + data = model_to_dict(self) + data["id"] = self.pk + data["ReferenceID"] = str(self.reference.pk) if self.reference is not None else None + return data + + +class QMRAExposures(StaticEntity): + source = "qmra/static/data/default-exposures.json" + model = QMRAExposure + primary_key = "name" + + @classmethod + def choices(cls): + grouped = {grp: list(v) for grp, v in + groupby(sorted(cls.data.values(), key=lambda x: x.name), key=lambda x: x.name.split(",")[0])} + return [ + ("", "---------"), + *[(k, [(x.name, x.name) for x in sorted(v, key=lambda x: x.name)]) for k, v in grouped.items()], + ("other", "other") + ] diff --git a/qmra/risk_assessment/risk.py b/qmra/risk_assessment/risk.py new file mode 100644 index 0000000..eaf888b --- /dev/null +++ b/qmra/risk_assessment/risk.py @@ -0,0 +1,121 @@ +from typing import Iterable + +import numpy as np + +from qmra.risk_assessment.models import RiskAssessment, RiskAssessmentResult, Treatment +from qmra.risk_assessment.qmra_models import PathogenGroup, QMRAPathogens + + +def get_annual_risk( + inflow_min: float, inflow_max: float, + log_removal: float, + volume_per_event: float, + events_per_year: int, + distribution, + n_events: int = 10_000, + n_years: int = 1000, +): + generator = np.random.default_rng(seed=42) + inflow = generator.normal( + loc=(np.log10(inflow_min + 10 ** (-8)) + np.log10(inflow_max)) / 2, + scale=(np.log10(inflow_max) - np.log10(inflow_min + 10 ** (-8))) / 4, + size=n_events + ) + lrv = np.ones((n_events,)) * log_removal + outflow = inflow - lrv + dose = (10 ** outflow) * volume_per_event + event_probs = distribution.pdf(dose) + event_samples = generator.choice(event_probs, size=(n_years, events_per_year), replace=True) + return 1 - np.prod(1 - event_samples, axis=1) + + +def lrv_by_pathogen_group(treatments: Iterable[Treatment]) -> dict: + lrvs = { + PathogenGroup.Bacteria: dict(min=0, max=0), + PathogenGroup.Viruses: dict(min=0, max=0), + PathogenGroup.Protozoa: dict(min=0, max=0) + } + + def zero_if_none(x): return x if x is not None else 0 + + for t in treatments: + lrvs[PathogenGroup.Bacteria]["min"] += zero_if_none(t.bacteria_min) + lrvs[PathogenGroup.Bacteria]["max"] += zero_if_none(t.bacteria_max) + lrvs[PathogenGroup.Viruses]["min"] += zero_if_none(t.viruses_min) + lrvs[PathogenGroup.Viruses]["max"] += zero_if_none(t.viruses_max) + lrvs[PathogenGroup.Protozoa]["min"] += zero_if_none(t.protozoa_min) + lrvs[PathogenGroup.Protozoa]["max"] += zero_if_none(t.protozoa_max) + return lrvs + + +def assess_risk(risk_assessment: RiskAssessment, inflows, treatments, save=True) -> dict[str, RiskAssessmentResult]: + # assuming the model has been already validated + lrvs = lrv_by_pathogen_group(treatments) + results = {} + + for inflow in inflows: + # unpack params + pathogen = QMRAPathogens.get(inflow.pathogen) + group = pathogen.group + dist = pathogen.get_distribution() + + def to_dalys(pr, pat=pathogen): + return pr * pat.infection_to_illness * pat.dalys_per_case + + # min / max probs + min_prob = get_annual_risk( + inflow.min, inflow.max, + lrvs[group]["max"], + risk_assessment.volume_per_event, risk_assessment.events_per_year, + dist + ) + max_prob = get_annual_risk( + inflow.min, inflow.max, + lrvs[group]["min"], + risk_assessment.volume_per_event, risk_assessment.events_per_year, + dist + ) + # get the stats + min_prob_mean = min_prob.mean() + max_prob_mean = max_prob.mean() + maximum_lrv_min = min_prob.min() + maximum_lrv_max = min_prob.max() + maximum_lrv_q1 = np.percentile(min_prob, 25) + maximum_lrv_q3 = np.percentile(min_prob, 75) + maximum_lrv_median = np.median(min_prob) + minimum_lrv_min = max_prob.min() + minimum_lrv_max = max_prob.max() + minimum_lrv_q1 = np.percentile(max_prob, 25) + minimum_lrv_q3 = np.percentile(max_prob, 75) + minimum_lrv_median = np.median(max_prob) + # make result + results[inflow.pathogen] = RiskAssessmentResult( + risk_assessment=risk_assessment, + infection_risk="max" if min_prob_mean > (10 ** -4) else ("min" if max_prob_mean > (10 ** -4) else "none"), + dalys_risk="max" if to_dalys(min_prob_mean) > (10 ** -6) else ( + "min" if to_dalys(max_prob_mean) > (10 ** -6) else "none"), + pathogen=inflow.pathogen, + infection_maximum_lrv_min=maximum_lrv_min, + infection_maximum_lrv_max=maximum_lrv_max, + infection_maximum_lrv_q1=maximum_lrv_q1, + infection_maximum_lrv_q3=maximum_lrv_q3, + infection_maximum_lrv_median=maximum_lrv_median, + infection_minimum_lrv_min=minimum_lrv_min, + infection_minimum_lrv_max=minimum_lrv_max, + infection_minimum_lrv_q1=minimum_lrv_q1, + infection_minimum_lrv_q3=minimum_lrv_q3, + infection_minimum_lrv_median=minimum_lrv_median, + dalys_maximum_lrv_min=to_dalys(maximum_lrv_min), + dalys_maximum_lrv_max=to_dalys(maximum_lrv_max), + dalys_maximum_lrv_q1=to_dalys(maximum_lrv_q1), + dalys_maximum_lrv_q3=to_dalys(maximum_lrv_q3), + dalys_maximum_lrv_median=to_dalys(maximum_lrv_median), + dalys_minimum_lrv_min=to_dalys(minimum_lrv_min), + dalys_minimum_lrv_max=to_dalys(minimum_lrv_max), + dalys_minimum_lrv_q1=to_dalys(minimum_lrv_q1), + dalys_minimum_lrv_q3=to_dalys(minimum_lrv_q3), + dalys_minimum_lrv_median=to_dalys(minimum_lrv_median), + ) + if save: + results[inflow.pathogen].save() + return results diff --git a/qmra/risk_assessment/templates/assessment-configurator.html b/qmra/risk_assessment/templates/assessment-configurator.html new file mode 100644 index 0000000..9eb503e --- /dev/null +++ b/qmra/risk_assessment/templates/assessment-configurator.html @@ -0,0 +1,390 @@ +{% extends "layout.html" %} +{% load crispy_forms_tags %} +{% load static %} +{% block body %} +
+
+

Risk assessment parameters

+ {% include "risk-assessment-form-fieldset.html" with risk_assessment_form=risk_assessment_form %} + {% include "inflows-form-fieldset.html" with inflow_form=inflow_form source_name_field=risk_assessment_form.source_name %} + {% include "treatments-form-fieldset.html" with treatment_form=treatment_form add_treatment_form=add_treatment_form %} + {% if request.user.is_authenticated %} +
+
+ +
+
+ +
+
+ {% endif %} +
+
+ +
+ +
+
+

Exposure:

+
+
+
[1]values taken from +
+
+
+

Source water:

+
+
+ + + + + + + + + + + + + + + + + + + + + +
PathogenMin. concentration[1]Max. concentration[1]
Rotavirus
Campylobacter jejuni
Cryptosporidium parvum
+
+
[1]values taken from +
+
+
+

Treatments

+
+
+
+
+ {% if user_exposure_form is not None and user_source_form is not None and user_treatment_form is not None %} +
+ {% crispy user_exposure_form %} +
+
+ {% crispy user_source_form %} +
+
+ {% crispy user_treatment_form %} +
+ {% endif %} +
+
+
+{% endblock %} +{% block script %} +
+ {% include "configurator-guided-tour.html" %} + + + {% include "risk-assessment-form-js.html" %} +
+{% endblock %} diff --git a/qmra/risk_assessment/templates/assessment-result-export.html b/qmra/risk_assessment/templates/assessment-result-export.html new file mode 100644 index 0000000..03743b9 --- /dev/null +++ b/qmra/risk_assessment/templates/assessment-result-export.html @@ -0,0 +1,26 @@ + + +{% include "head.html" %} + +
+ {% include "assessment-result.html" with results=results infection_risk=infection_risk risk_plot_data=risk_plot_data daly_plot_data=daly_plot_data %} +
+ + + \ No newline at end of file diff --git a/qmra/risk_assessment/templates/assessment-result.html b/qmra/risk_assessment/templates/assessment-result.html new file mode 100644 index 0000000..eef547d --- /dev/null +++ b/qmra/risk_assessment/templates/assessment-result.html @@ -0,0 +1,105 @@ +
+

Result

+ {% if max_lrv_warning_for|length > 0 %} +
+

Warning!

+

Some treatment(s) in this risk assessment has one or more maximum LRV above 6. +
The WHO standards do not allow LRVs above 6 in order to promote redundancy and robustness in treatment trains. +

+

The following fields triggered this warning

+
    + {% for t_name, values in max_lrv_warning_for.items %} +
  1. {{t_name}}
  2. +
      + {% for value_name in values %} +
    1. {{value_name}}
    2. + {% endfor %} +
    + {% endfor %} +
+
+ {% endif %} +
+
The risk of this water reuse scenario can be compared to + two commonly applied health-based targets (WHO 2022): +
+
    +
  1. 1 in 10,000 infections per person per year
  2. +
  3. 0.000001 Disability Adjusted Life Years (DALY)
  4. +
+ {% if infection_risk == "none" %} +
+ The estimated probability of infection per year / DALYs pppy + does not exceed the tolerable risk level indicated by the WHO + neither calculating with the minimum nor the maximum LRV for + none of the considered pathogens. +
+
+ According to the estimation, the water reuse scenario + achieves a tolerable risk level. + +
+ {% elif infection_risk == "min" %} +
+ The estimated probability of infection per year / DALYs pppy + from one or more pathogens exceeds the tolerable risk level + indicated by the WHO calculated using the minimum LRV. +
+
+ A closer look into the individual treatments + is necessary to evaluate the risk of the scenario. + +
+ {% elif infection_risk == "max" %} +
+ The estimated probability of infection per year / DALYs pppy + from one or more pathogens exceeds the tolerable risk level + indicated by the WHO calculated using the maximum LRV. +
+
+
+ More efficient treatments or additional treatment + steps are likely to be necessary to achieve a tolerable risk level for the water reuse scenario. + +
+ {% endif %} +
+ {% if risk_plot is not None %} +
+ {% autoescape off %} + {{ risk_plot }} + {% endautoescape %} +
+ {% endif %} + {% if daly_plot is not None %} +
+ {% autoescape off %} + {{ daly_plot }} + {% endautoescape %} +
+ {% endif %} + {% if risk_plot_data is not None %} +
+ +
+ {% endif %} + {% if daly_plot_data is not None %} +
+ +
+ {% endif %} + + +
+ diff --git a/qmra/risk_assessment/templates/configurator-guided-tour.html b/qmra/risk_assessment/templates/configurator-guided-tour.html new file mode 100644 index 0000000..a309b90 --- /dev/null +++ b/qmra/risk_assessment/templates/configurator-guided-tour.html @@ -0,0 +1,47 @@ + \ No newline at end of file diff --git a/qmra/risk_assessment/templates/default-changes-notification.html b/qmra/risk_assessment/templates/default-changes-notification.html new file mode 100644 index 0000000..7fcf757 --- /dev/null +++ b/qmra/risk_assessment/templates/default-changes-notification.html @@ -0,0 +1,51 @@ +
+ + + +
\ No newline at end of file diff --git a/qmra/risk_assessment/templates/failed-plot.html b/qmra/risk_assessment/templates/failed-plot.html new file mode 100644 index 0000000..8b1938d --- /dev/null +++ b/qmra/risk_assessment/templates/failed-plot.html @@ -0,0 +1,23 @@ +
+ +

Parameters validation failed!

+ {% if form.non_form_errors %} +

{{form.non_form_errors.as_text}}

+ {% endif %} + {% if form.total_error_count %} + + {% endif %} +
\ No newline at end of file diff --git a/qmra/risk_assessment/templates/faqs.html b/qmra/risk_assessment/templates/faqs.html new file mode 100644 index 0000000..22fa26d --- /dev/null +++ b/qmra/risk_assessment/templates/faqs.html @@ -0,0 +1,147 @@ +{% extends "layout.html" %} +{% load static %} +{% block body %} +
+

Frequently Asked Questions (FAQs)

+
1. What is Quantitative Microbial Risk Assessment (QMRA)?
+

QMRA is a method used to evaluate the potential human health risks associated with exposure to microbial + contaminants, such as bacteria, viruses, and protozoa. It is commonly used in water treatment and reuse schemes + to assess the likelihood of illness from pathogens in water.

+
2. Is this tool free to use?
+

Yes, the QMRA tool is completely free to use. It provides you with powerful resources and guidance to assess + microbial risks in your water reuse schemes without any cost.

+
3. Who should use this QMRA tool?
+

This tool is designed for water safety professionals, engineers, and public health officials involved in water + reuse schemes. It is also suitable for researchers, students, and anyone interested in understanding the risks + for human health associated with microbial contaminants in water.

+

According to the EU Regulation 2020/741, conducting a risk assessment is an essential and mandatory step for all + water reuse projects intended for agricultural irrigation within the EU. The QMRA tool can be used as a key + component of risk assessment to comply with this EU Regulation.

+
4. What can the QMRA tool be used for?
+

The QMRA tool assesses the microbial risk to human health for a water reuse scenario, taking into account the + water source, treatment process, and exposure scenario. It can be useful in two phases of a water reuse + project:

+ +

Please note, this tool is not designed for water treatment process design.

+ +
5. What is the basic functionality of the QMRA tool?
+

The QMRA Tool offers a user-friendly graphical interface that allows you to configure key input variables for + conducting a Quantitative Microbial Risk Assessment (QMRA) in the context of water supply and water reuse + systems. + Based on your configuration, the tool performs a Monte Carlo Simulation (MCS) to generate a range of potential + risk outcomes.

+

The tool is designed to accommodate both beginners and experts:

+ +

The tool provides two basic modes of use:

+ +

Model Inputs:

+ +

Safe custom input data by saving the risk assessment.

+

Simulation Outputs:

+

The simulated risk is expressed and visualized in terms of:

+ +

The tool evaluates the water supply or water reuse scenario by comparing the simulation results with the tolerable + risk levels set by the WHO:

+ +

Additionally, the QMRA Tool allows you to export your saved risk assessments for further analysis or reporting by + using the download button on the "My Risk Assessments" overview page.

+
6. What types of water reuse schemes can this tool assess?
+

The tool can be used to assess a variety of water reuse schemes, including potable and non-potable reuse, incl. + agricultural irrigation and domestic uses (e.g. car wash, toilet flushing). It provides flexibility to customize the + assessment based on the specific use case.

+
7. What data are used in the tool and where can I access them?
+

The tool uses data (e.g. pathogen concentrations in different source waters, LRV for specific water treatments) + derived from scientific literature and international guidance documents. All references are transparently listed in + the “Reference” tab of the risk assessment configuration page.

+
8. Can I customize the parameters in the tool?
+

Absolutely! While the tool provides default values and guidance, you can fully customize the parameters, such as + pathogen concentrations, treatment effectiveness (Log Removal Values - LRV), and exposure scenarios, to match your + specific situation.

+
9. How accurate are the results provided by the tool?
+

The tool uses internationally recognized data and guidelines to provide reliable risk estimates. However, for the + most accurate results, it’s recommended to use locally obtained data for pathogen concentrations and treatment + effectiveness whenever possible. +

Please note that the tool is not a water treatment process design tool, and is based on assumptions that lead to + limitations (see 14. What are the limitations of this tool?)

+

Typical treatments are given in the user manual.

+
10. Can I save and share my risk assessment reports?
+

Yes, after completing your assessment, you can save your results and generate an output (.zip including excel with + underlying data and assessment results as well as assessment result plots). These reports can be shared with + colleagues, stakeholders, or regulatory bodies.

+
11. What support is available if I have questions?
+

The tool includes built-in tooltips for each step. If you have further questions, you can access the online + documentation or contact our support team for assistance.

+
12. Is the tool compliant with international guidelines?
+

Yes, the tool is developed based on international guidelines for water safety and QMRA, ensuring that your + assessments are aligned with best practices and standards. The specific guidelines used are accessible via the "Reference" + tab of the risk assessment configuration page.

+
13. What are the limitations of this tool?
+

While the QMRA Tool is a powerful resource for assessing microbial risks in water reuse schemes, it does have some + limitations:

+ +

By being aware of these limitations, users can better understand the context in which the QMRA Tool is most + effective and can supplement it with additional data or analysis as needed. +

+
+{% endblock %} \ No newline at end of file diff --git a/qmra/risk_assessment/templates/home-guided-tour.html b/qmra/risk_assessment/templates/home-guided-tour.html new file mode 100644 index 0000000..5cc7b22 --- /dev/null +++ b/qmra/risk_assessment/templates/home-guided-tour.html @@ -0,0 +1,35 @@ + \ No newline at end of file diff --git a/qmra/risk_assessment/templates/imprint.html b/qmra/risk_assessment/templates/imprint.html new file mode 100644 index 0000000..8f50a19 --- /dev/null +++ b/qmra/risk_assessment/templates/imprint.html @@ -0,0 +1,34 @@ +{% extends "layout.html" %} +{% load static %} +{% block body %} +
+

Imprint

+ +

Funding



+
+
+
+
+
+
+
+
+
+
+

Contact

+ Wolfgang Seis
+ Kompetenzzentrum Wasser Berlin
+ Wissenschaftlicher Mitarbeiter
+
+ wolfgang.seis@kompetenz-wasser.de
+ +49 (0) 30 53 653 807 +
+
+ Pia Schumann
+ Kompetenzzentrum Wasser Berlin
+ Wissenschaftliche Mitarbeiterin
+
+ pia.schumann@kompetenz-wasser.de
+ +49 (0) 30 53 653 835 +
+{% endblock %} \ No newline at end of file diff --git a/qmra/risk_assessment/templates/inflows-form-fieldset.html b/qmra/risk_assessment/templates/inflows-form-fieldset.html new file mode 100644 index 0000000..e85c873 --- /dev/null +++ b/qmra/risk_assessment/templates/inflows-form-fieldset.html @@ -0,0 +1,36 @@ +{% load crispy_forms_tags %} +{% load static %} +
+
+ {{ source_name_field | as_crispy_field }} +
{{ inflow_form.management_form|crispy }}
+ {% if inflow_form.non_form_errors %} + +
there were errors validating the list of inflows:
+ + {{inflow_form.non_form_errors }} + + {% endif %} + {% for form in inflow_form %} +
+ {% for field in form.hidden_fields %} + {{field}} + {% endfor %} +
+ {% crispy form %} +
+
+ {% endfor %} +
+
+
+
+{% include "inflows-form-js.html" %} \ No newline at end of file diff --git a/qmra/risk_assessment/templates/inflows-form-js.html b/qmra/risk_assessment/templates/inflows-form-js.html new file mode 100644 index 0000000..b19e498 --- /dev/null +++ b/qmra/risk_assessment/templates/inflows-form-js.html @@ -0,0 +1,204 @@ +{% load static %} + + + + + diff --git a/qmra/risk_assessment/templates/inflows-plot.html b/qmra/risk_assessment/templates/inflows-plot.html new file mode 100644 index 0000000..8aaa6cb --- /dev/null +++ b/qmra/risk_assessment/templates/inflows-plot.html @@ -0,0 +1,5 @@ +
+ {% autoescape off %} + {{ plot }} + {% endautoescape %} +
\ No newline at end of file diff --git a/qmra/risk_assessment/templates/risk-assessment-form-fieldset.html b/qmra/risk_assessment/templates/risk-assessment-form-fieldset.html new file mode 100644 index 0000000..b5301c8 --- /dev/null +++ b/qmra/risk_assessment/templates/risk-assessment-form-fieldset.html @@ -0,0 +1,7 @@ +{% load crispy_forms_tags %} +{% load static %} +
+
+ {% crispy risk_assessment_form %} +
+
\ No newline at end of file diff --git a/qmra/risk_assessment/templates/risk-assessment-form-js.html b/qmra/risk_assessment/templates/risk-assessment-form-js.html new file mode 100644 index 0000000..c414676 --- /dev/null +++ b/qmra/risk_assessment/templates/risk-assessment-form-js.html @@ -0,0 +1,97 @@ +{% load static %} + + diff --git a/qmra/risk_assessment/templates/risk-assessment-list.html b/qmra/risk_assessment/templates/risk-assessment-list.html new file mode 100644 index 0000000..e86ebcd --- /dev/null +++ b/qmra/risk_assessment/templates/risk-assessment-list.html @@ -0,0 +1,307 @@ +{% extends "layout.html" %} +{% load static %} +{% block body %} + + +{% csrf_token %} +{% include "home-guided-tour.html" %} +
+ + {% include "default-changes-notification.html" %} + +
+
+
+ +
+
+
+ +
+ +
+
+ +
+
+ + {% if user.is_authenticated %} +

My Risk Assessments

+
+ {%for assessment in assessments%} + + + + +
+
+
+ +
+
+
+

+ {{assessment.name}}  +

+
+
+ {% if assessment.description %} + {{assessment.description}} + {% else %} + No description + {% endif %} +
+
+
+
+ + + + + + + +
+
+
+ {{assessment.created_at.date}} +
+
+ {{ assessment.results_list | json_script:assessment.id}} +
+ + {%empty%} + There are currently no risk assessments. Click on Create new risk assessment to create your + first risk assessment. + {%endfor%} +
+
+ + +{%endif%} +{% endblock %} + +{% block side-list %} +{% endblock %} \ No newline at end of file diff --git a/qmra/risk_assessment/templates/treatments-form-fieldset.html b/qmra/risk_assessment/templates/treatments-form-fieldset.html new file mode 100644 index 0000000..4262736 --- /dev/null +++ b/qmra/risk_assessment/templates/treatments-form-fieldset.html @@ -0,0 +1,67 @@ +{% load crispy_forms_tags %} +{% load static %} + + + + + + + + + + + + +
+ +
+ +
+ {{ treatment_form.management_form|crispy }} +
+ +
+
+
+ {{ add_treatment_form.select_treatment | as_crispy_field }} +
+
+ + +
+
+
+
+ +
+
+
+ {% for form in treatment_form %} +
+ + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} + {% crispy form %} +
+ {% endfor %} + +
+
+
+{% include "treatments-form-js.html" %} \ No newline at end of file diff --git a/qmra/risk_assessment/templates/treatments-form-js.html b/qmra/risk_assessment/templates/treatments-form-js.html new file mode 100644 index 0000000..2f0902b --- /dev/null +++ b/qmra/risk_assessment/templates/treatments-form-js.html @@ -0,0 +1,478 @@ +{% load static %} + + \ No newline at end of file diff --git a/qmra/risk_assessment/templates/treatments-plot.html b/qmra/risk_assessment/templates/treatments-plot.html new file mode 100644 index 0000000..44f360d --- /dev/null +++ b/qmra/risk_assessment/templates/treatments-plot.html @@ -0,0 +1,6 @@ +

Logremoval of individual treatments by pathogen group

+
+ {% autoescape off %} + {{ plot }} + {% endautoescape %} +
\ No newline at end of file diff --git a/qmra/risk_assessment/tests/__init__.py b/qmra/risk_assessment/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qmra/risk_assessment/tests/test_assess_risk.py b/qmra/risk_assessment/tests/test_assess_risk.py new file mode 100644 index 0000000..7ea5848 --- /dev/null +++ b/qmra/risk_assessment/tests/test_assess_risk.py @@ -0,0 +1,241 @@ +"""test computation of risk assessment""" +import warnings + +from django.core.management import call_command +from django.test import TestCase +from assertpy import assert_that + +from qmra.risk_assessment.models import RiskAssessment, Inflow, Treatment, RiskAssessmentResult +from qmra.risk_assessment.qmra_models import QMRAPathogens, QMRAInflows, QMRATreatments +from qmra.risk_assessment.risk import assess_risk +from qmra.user.models import User + + +class TestAssesRisk(TestCase): + databases = ["default", "qmra"] + + @classmethod + def setUpClass(cls): + super().setUpClass() + call_command("seed_default_db") + + def test_with_standard_pathogens_and_all_treatments(self): + given_user = User.objects.create_user("test-user", "test-user@test.com", "password") + given_user.save() + given_ra = RiskAssessment.objects.create( + user=given_user, + events_per_year=1, + volume_per_event=2, + ) + given_ra.save() + given_inflows = [ + Inflow.objects.create( + risk_assessment=given_ra, + pathogen="Rotavirus", + min=0.1, max=0.2 + ), + Inflow.objects.create( + risk_assessment=given_ra, + pathogen="Campylobacter jejuni", + min=0.1, max=0.2 + ), + Inflow( + risk_assessment=given_ra, + pathogen="Cryptosporidium parvum", + min=0.1, max=0.2 + ), + ] + given_treatments = [ + Treatment.from_default(t, given_ra) + for _, t in QMRATreatments.data.items() + ] + given_ra.inflows.set(given_inflows, bulk=False) + given_ra.treatments.set(given_treatments, bulk=False) + + results = assess_risk(given_ra, given_inflows, given_treatments) + + assert_that(len(results)).is_equal_to(len(given_inflows)) + + assert_that(sorted([infl.pathogen for infl in given_inflows])).is_equal_to( + sorted(results.keys()) + ) + + def test_with_all_pathogens(self): + given_user = User.objects.create_user("test-user", "test-user@test.com", "password") + given_user.save() + given_ra = RiskAssessment.objects.create( + user=given_user, + events_per_year=1, + volume_per_event=2, + ) + given_ra.save() + given_inflows = [ + Inflow.objects.create( + risk_assessment=given_ra, + pathogen=p, + min=0.1, max=0.2 + ) for p, _ in QMRAPathogens.data.items() + ] + given_treatments = [ + Treatment.from_default(QMRATreatments.get("Coagulation, flocculation and media filtration"), given_ra), + Treatment.from_default(QMRATreatments.get("Slow sand filtration"), given_ra), + ] + given_ra.inflows.set(given_inflows, bulk=False) + given_ra.treatments.set(given_treatments, bulk=False) + + results = assess_risk(given_ra, given_inflows, given_treatments) + + assert_that(len(results)).is_equal_to(len(given_inflows)) + + assert_that(sorted([infl.pathogen for infl in given_inflows])).is_equal_to( + sorted(results.keys()) + ) + + for pathogen, r in results.items(): + for attr in r.__dict__: + if "lrv" in attr: + assert_that(getattr(r, attr), + f"{attr} > 0 for {pathogen}" + ).is_greater_than_or_equal_to(0) + assert_that(getattr(r, attr), + f"{attr} < 1 for {pathogen}" + ).is_less_than_or_equal_to(1) + + def test_regression_test(self): + given_user = User.objects.create_user("test-user", "test-user@test.com", "password") + given_user.save() + given_ra = RiskAssessment.objects.create( + user=given_user, + # Exposure = drinking water + events_per_year=365, + volume_per_event=1, + ) + given_ra.save() + given_inflows = [ + Inflow.objects.create( + risk_assessment=given_ra, + pathogen=inflow.pathogen.name, + min=inflow.min, max=inflow.max + ) for inflow in QMRAInflows.get("groundwater") + ] + given_treatments = [ + Treatment.from_default(QMRATreatments.get("Primary treatment"), given_ra) + ] + given_ra.inflows.set(given_inflows, bulk=False) + given_ra.treatments.set(given_treatments, bulk=False) + + expected_rotavirus = RiskAssessmentResult( + risk_assessment=given_ra, + infection_minimum_lrv_min=0.9935, + infection_minimum_lrv_max=1., + infection_minimum_lrv_q1=0.999909, + infection_minimum_lrv_q3=0.999998, + infection_minimum_lrv_median=0.999984, + infection_maximum_lrv_min=0.9771782, + infection_maximum_lrv_max=1., + infection_maximum_lrv_q1=0.999764, + infection_maximum_lrv_q3=0.999993, + infection_maximum_lrv_median=0.999954, + dalys_minimum_lrv_min=0.006954503, + dalys_minimum_lrv_max=0.007, + dalys_minimum_lrv_q1=0.006999, + dalys_minimum_lrv_q3=0.006999987, + dalys_minimum_lrv_median=0.006999891, + dalys_maximum_lrv_min=0.006840247, + dalys_maximum_lrv_max=0.007, + dalys_maximum_lrv_q1=0.00699835, + dalys_maximum_lrv_q3=0.00699995, + dalys_maximum_lrv_median=0.00699968, + ) + expected_jejuni = RiskAssessmentResult( + risk_assessment=given_ra, + infection_minimum_lrv_min=0.6206353, + infection_minimum_lrv_max=0.999927, + infection_minimum_lrv_q1=0.9586206, + infection_minimum_lrv_q3=0.992637, + infection_minimum_lrv_median=0.9822, + infection_maximum_lrv_min=0.3300553, + infection_maximum_lrv_max=0.998453, + infection_maximum_lrv_q1=0.8156798, + infection_maximum_lrv_q3=0.9479051, + infection_maximum_lrv_median=0.8980201, + dalys_minimum_lrv_min=0.0008564767, + dalys_minimum_lrv_max=0.0013799, + dalys_minimum_lrv_q1=0.001322896, + dalys_minimum_lrv_q3=0.00136984, + dalys_minimum_lrv_median=0.001355435, + dalys_maximum_lrv_min=0.0004554763, + dalys_maximum_lrv_max=.001377865, + dalys_maximum_lrv_q1=0.001125638, + dalys_maximum_lrv_q3=0.001308109, + dalys_maximum_lrv_median=0.001239268, + ) + expected_parvum = RiskAssessmentResult( + risk_assessment=given_ra, + infection_minimum_lrv_min=0.4384957, + infection_minimum_lrv_max=1, + infection_minimum_lrv_q1=.9648057, + infection_minimum_lrv_q3=0.999996, + infection_minimum_lrv_median=0.998185, + infection_maximum_lrv_min=0.03984487, + infection_maximum_lrv_max=.999987, + infection_maximum_lrv_q1=0.2839756, + infection_maximum_lrv_q3=0.6926215, + infection_maximum_lrv_median=0.4572991, + dalys_minimum_lrv_min=0.0004604205, + dalys_minimum_lrv_max=0.00105, + dalys_minimum_lrv_q1=0.001013046, + dalys_minimum_lrv_q3=0.001049996, + dalys_minimum_lrv_median=0.001048095, + dalys_maximum_lrv_min=0.0004183712, + dalys_maximum_lrv_max=0.001049987, + dalys_maximum_lrv_q1=0.0002981744, + dalys_maximum_lrv_q3=0.0007272526, + dalys_maximum_lrv_median=0.0004801641, + ) + results = assess_risk(given_ra, given_inflows, given_treatments) + # all pathogens exceeds all tolerable risk levels + for r in results.values(): + assert_that(r.infection_risk).is_true() + assert_that(r.dalys_risk).is_true() + + accepted_tolerance_q = .05 # for q1, median, q3 + accepted_tolerance_ex = .2 # for min and max + failed = "" + for attr in results["Rotavirus"].__dict__: + if "lrv" in attr: + if "min" == attr.split("_")[-1] or "max" == attr.split("_")[-1]: + accepted_tolerance = accepted_tolerance_ex + else: + accepted_tolerance = accepted_tolerance_q + try: + assert_that(getattr(results["Rotavirus"], attr), + f"'{attr}' fails regression test for rotavirus") \ + .is_close_to(getattr(expected_rotavirus, attr), tolerance=accepted_tolerance) + except AssertionError as e: + failed += str(e)+"\n" + for attr in results["Campylobacter jejuni"].__dict__: + if "lrv" in attr: + if "min" == attr.split("_")[-1] or "max" == attr.split("_")[-1]: + accepted_tolerance = accepted_tolerance_ex + else: + accepted_tolerance = accepted_tolerance_q + try: + assert_that(getattr(results["Campylobacter jejuni"], attr), + f"'{attr}' fails regression test for jejuni") \ + .is_close_to(getattr(expected_jejuni, attr), tolerance=accepted_tolerance) + except AssertionError as e: + failed += str(e)+"\n" + for attr in results["Cryptosporidium parvum"].__dict__: + if "lrv" in attr: + if "min" == attr.split("_")[-1] or "max" == attr.split("_")[-1]: + accepted_tolerance = accepted_tolerance_ex + else: + accepted_tolerance = accepted_tolerance_q + try: + assert_that(getattr(results["Cryptosporidium parvum"], attr), + f"'{attr}' fails regression test for parvum") \ + .is_close_to(getattr(expected_parvum, attr), tolerance=accepted_tolerance) + except AssertionError as e: + failed += str(e) + "\n" + warnings.warn(failed) \ No newline at end of file diff --git a/qmra/risk_assessment/tests/test_export.py b/qmra/risk_assessment/tests/test_export.py new file mode 100644 index 0000000..773a0cc --- /dev/null +++ b/qmra/risk_assessment/tests/test_export.py @@ -0,0 +1,56 @@ +import io + +from assertpy import assert_that +from django.test import TestCase +import pandas as pd + +from qmra.risk_assessment import exports +from qmra.risk_assessment.models import RiskAssessment, Inflow, Treatment +from qmra.risk_assessment.qmra_models import QMRATreatments +from qmra.risk_assessment.risk import assess_risk +from qmra.user.models import User + + +class TestResultExport(TestCase): + + def test_that(self): + given_user = User.objects.create_user("test-user2", "test-user@test.com", "password") + given_user.save() + given_ra = RiskAssessment.objects.create( + user=given_user, + events_per_year=1, + volume_per_event=2, + ) + given_ra.save() + given_inflows = [ + Inflow.objects.create( + risk_assessment=given_ra, + pathogen="Rotavirus", + min=0.1, max=0.2 + ), + Inflow.objects.create( + risk_assessment=given_ra, + pathogen="Campylobacter jejuni", + min=0.1, max=0.2 + ), + Inflow( + risk_assessment=given_ra, + pathogen="Cryptosporidium parvum", + min=0.1, max=0.2 + ), + ] + given_treatments = [ + Treatment.from_default(t, given_ra) + for t in list(QMRATreatments.data.values())[:3] + ] + given_ra.inflows.set(given_inflows, bulk=False) + given_ra.treatments.set(given_treatments, bulk=False) + + results = assess_risk(given_ra, given_inflows, given_treatments) + given_ra = RiskAssessment.objects.get(pk=given_ra.id) + with io.BytesIO() as buffer: + exports.risk_assessment_as_zip(buffer, given_ra) + buffer.seek(0) + with open("test.zip", "wb") as f: + f.write(buffer.getvalue()) + assert_that(buffer).is_not_none() diff --git a/qmra/risk_assessment/tests/test_plots.py b/qmra/risk_assessment/tests/test_plots.py new file mode 100644 index 0000000..efc7fc3 --- /dev/null +++ b/qmra/risk_assessment/tests/test_plots.py @@ -0,0 +1,6 @@ +from django.test import TestCase + + +class TestPlots(TestCase): + def test_inflows_plot(self): + pass \ No newline at end of file diff --git a/qmra/risk_assessment/tests/test_risk_assessment_api.py b/qmra/risk_assessment/tests/test_risk_assessment_api.py new file mode 100644 index 0000000..8cc9d63 --- /dev/null +++ b/qmra/risk_assessment/tests/test_risk_assessment_api.py @@ -0,0 +1 @@ +"""test get, create, update, delete requests""" diff --git a/qmra/risk_assessment/tests/test_risk_assessment_form.py b/qmra/risk_assessment/tests/test_risk_assessment_form.py new file mode 100644 index 0000000..e28527d --- /dev/null +++ b/qmra/risk_assessment/tests/test_risk_assessment_form.py @@ -0,0 +1,118 @@ +from django.test import TestCase +from assertpy import assert_that + +from qmra.risk_assessment.forms import RiskAssessmentForm, InflowForm, InflowFormSet, TreatmentForm, TreatmentFormSet +from qmra.risk_assessment.qmra_models import QMRATreatments + + +class TestRiskAssessmentForm(TestCase): + def test_gt_zero_constraints(self): + given_form = RiskAssessmentForm( + data=dict(events_per_year=0, volume_per_event=1) + ) + given_form.full_clean() + assert_that(len(given_form.errors)).is_greater_than(0) + assert_that(given_form.errors).contains_key("events_per_year") + + given_form = RiskAssessmentForm( + data=dict(events_per_year=1, volume_per_event=0) + ) + given_form.full_clean() + assert_that(len(given_form.errors)).is_greater_than(0) + assert_that(given_form.errors).contains_key("volume_per_event") + + +class TestInflowForm(TestCase): + def test_that_pathogen_can_not_be_blank(self): + given_form = InflowForm( + data=dict(min=0, max=10, pathogen="") + ) + given_form.full_clean() + assert_that(len(given_form.errors)).is_equal_to(1) + assert_that(given_form.errors).contains_key("pathogen") + + def test_non_negative_constraints(self): + given_form = InflowForm( + data=dict(min=-1, max=1), initial=dict(pathogen="Rotavirus") + ) + given_form.full_clean() + assert_that(len(given_form.errors)).is_equal_to(1) + assert_that(given_form.errors).contains_key("min") + + given_form = InflowForm( + data=dict(min=0, max=-1), initial=dict(pathogen="Rotavirus") + ) + given_form.full_clean() + assert_that(len(given_form.errors)).is_equal_to(2) + assert_that(given_form.errors).contains_key("min") + assert_that(given_form.errors).contains_key("max") + + def test_that_form_is_valid_when_min_max_eq(self): + given_form = InflowForm( + data=dict(min=0, max=0), initial=dict(pathogen="Rotavirus") + ) + given_form.full_clean() + assert_that(len(given_form.errors)).is_equal_to(0) + + def test_that_min_needs_to_be_lower_than_max(self): + given_form = InflowForm( + data=dict(min=10, max=1), initial=dict(pathogen="Rotavirus") + ) + given_form.full_clean() + assert_that(len(given_form.errors)).is_equal_to(2) + assert_that(given_form.errors).contains_key("min") + assert_that(given_form.errors).contains_key("max") + + +class TestInflowFormset(TestCase): + @classmethod + def make_formset_data(cls, forms): + data = { + "form-INITIAL_FORMS": "0", + "form-TOTAL_FORMS": str(len(forms)), + "form-MAX_NUM_FORMS": "1000", + } + for i, f in enumerate(forms): + d = {f"inflow-{i}-{k}": v for k, v in f.items()} + data = {**data, **d} + return data + + +class TestTreatmentForm(TestCase): + def test_that_negative_are_allowed(self): + data = dict( + name="Primary treatment", + bacteria_min=-2, + bacteria_max=-1, + viruses_min=-2, + viruses_max=-1, + protozoa_min=-2, + protozoa_max=-1 + ) + given_form = TreatmentForm(data=data) + # ugly hack to work around dynamic choices... + given_form.fields["name"].choices = QMRATreatments.choices() + given_form.full_clean() + print(given_form.errors) + assert_that(len(given_form.errors)).is_equal_to(0) + + def test_that_min_needs_to_be_less_than_max(self): + default_data = dict( + name="Primary treatment", + bacteria_min=0, + bacteria_max=0, + viruses_min=0, + viruses_max=0, + protozoa_min=0, + protozoa_max=0 + ) + for prfx in ["bacteria", "viruses", "protozoa"]: + mn = prfx + "_min" + mx = prfx + "_max" + data = {**default_data, mn: 2, mx: 1} + given_form = TreatmentForm(data=data) + # ugly hack to work around dynamic choices... + given_form.fields["name"].choices = QMRATreatments.choices() + given_form.full_clean() + assert_that(len(given_form.errors)).is_equal_to(1) + assert_that(given_form.errors).contains_key(mn) diff --git a/qmra/risk_assessment/tests/test_static_entities.py b/qmra/risk_assessment/tests/test_static_entities.py new file mode 100644 index 0000000..7db1cc2 --- /dev/null +++ b/qmra/risk_assessment/tests/test_static_entities.py @@ -0,0 +1,121 @@ +from unittest import TestCase +from assertpy import assert_that +from qmra.risk_assessment.qmra_models import PathogenGroup, QMRASource, QMRASources, QMRAPathogen, \ + QMRAPathogens, QMRATreatment, QMRATreatments, QMRAExposure, QMRAExposures + + +class TestDefaultPathogens(TestCase): + expected_length = 3 + def test_properties(self): + under_test = QMRAPathogens + + assert_that(under_test.raw_data).is_instance_of(dict) + assert_that(len(under_test.raw_data)).is_equal_to(self.expected_length) + + assert_that(under_test.data).is_instance_of(dict) + assert_that(under_test.data[list(under_test.data.keys())[0]]).is_instance_of(QMRAPathogen) + assert_that(len(under_test.data)).is_equal_to(self.expected_length) + + def test_get(self): + under_test = QMRAPathogens + + rotavirus = under_test.get("Rotavirus") + assert_that(rotavirus).is_instance_of(QMRAPathogen) + assert_that(rotavirus.name).is_equal_to("Rotavirus") + assert_that(rotavirus.group).is_equal_to(PathogenGroup.Viruses) + + jejuni = under_test.get("Campylobacter jejuni") + assert_that(jejuni).is_instance_of(QMRAPathogen) + assert_that(jejuni.name).is_equal_to("Campylobacter jejuni") + assert_that(jejuni.group).is_equal_to(PathogenGroup.Bacteria) + + parvum = under_test.get("Cryptosporidium parvum") + assert_that(parvum).is_instance_of(QMRAPathogen) + assert_that(parvum.name).is_equal_to("Cryptosporidium parvum") + assert_that(parvum.group).is_equal_to(PathogenGroup.Protozoa) + + def test_choices(self): + under_test = QMRAPathogens + + choices = under_test.choices() + # print(choices) + assert_that(choices).is_instance_of(list) + # assert_that(len(choices)).is_equal_to(self.expected_length+2) # other, blank + assert_that(choices[0]).is_instance_of(tuple) + assert_that(choices[0][0]).is_instance_of(str) + assert_that(choices[0][1]).is_instance_of(str) + + +class TestDefaultSources(TestCase): + expected_length = 8 + + def test_properties(self): + under_test = QMRASources + + assert_that(under_test.raw_data).is_instance_of(dict) + assert_that(len(under_test.raw_data)).is_equal_to(self.expected_length) + + assert_that(under_test.data).is_instance_of(dict) + assert_that(under_test.data[list(under_test.data.keys())[0]]).is_instance_of(QMRASource) + assert_that(len(under_test.data)).is_equal_to(self.expected_length) + + def test_choices(self): + under_test = QMRASources + + choices = under_test.choices() + # print(choices) + assert_that(choices).is_instance_of(list) + # assert_that(len(choices)).is_equal_to(self.expected_length+2) # other, blank + assert_that(choices[0]).is_instance_of(tuple) + assert_that(choices[0][0]).is_instance_of(str) + assert_that(choices[0][1]).is_instance_of(str) + + +class TestDefaultTreatments(TestCase): + expected_length = 22 + + def test_properties(self): + under_test = QMRATreatments + + assert_that(under_test.raw_data).is_instance_of(dict) + assert_that(len(under_test.raw_data)).is_equal_to(self.expected_length) + + assert_that(under_test.data).is_instance_of(dict) + assert_that(under_test.data[list(under_test.data.keys())[0]]).is_instance_of(QMRATreatment) + assert_that(len(under_test.data)).is_equal_to(self.expected_length) + + def test_choices(self): + under_test = QMRATreatments + + choices = under_test.choices() + # print(choices) + assert_that(choices).is_instance_of(list) + # assert_that(len(choices)).is_equal_to(self.expected_length+2) # other, blank + assert_that(choices[0]).is_instance_of(tuple) + assert_that(choices[0][0]).is_instance_of(str) + assert_that(choices[0][1]).is_instance_of(str) + + +class TestDefaultExposures(TestCase): + expected_length = 8 + + def test_properties(self): + under_test = QMRAExposures + + assert_that(under_test.raw_data).is_instance_of(dict) + assert_that(len(under_test.raw_data)).is_equal_to(self.expected_length) + + assert_that(under_test.data).is_instance_of(dict) + assert_that(under_test.data[list(under_test.data.keys())[0]]).is_instance_of(QMRAExposure) + assert_that(len(under_test.data)).is_equal_to(self.expected_length) + + def test_choices(self): + under_test = QMRAExposures + + choices = under_test.choices() + # print(choices) + assert_that(choices).is_instance_of(list) + # assert_that(len(choices)).is_equal_to(self.expected_length+2) # other, blank + assert_that(choices[0]).is_instance_of(tuple) + assert_that(choices[0][0]).is_instance_of(str) + assert_that(choices[0][1]).is_instance_of(str) \ No newline at end of file diff --git a/qmra/risk_assessment/urls.py b/qmra/risk_assessment/urls.py new file mode 100644 index 0000000..6a1dba6 --- /dev/null +++ b/qmra/risk_assessment/urls.py @@ -0,0 +1,77 @@ +from django.urls import path, include +from qmra.risk_assessment import views + + +urlpatterns = [ + path( + "assessments", + views.list_risk_assessment_view, + name="assessments", + ), + path( + "assessment", + views.risk_assessment_view, + name="assessment", + ), + path( + "assessment/", + views.risk_assessment_view, + name="assessment", + ), + path( + "assessment//results", + views.risk_assessment_result, + name="assessment-result", + ), + path( + "assessment//export", + views.export_risk_assessment, + name="assessment-export", + ), + path( + "assessment/results", + views.risk_assessment_result, + name="assessment-result", + ), + path( + "exposure", + views.create_exposure, + name="exposure" + ), + path( + "exposures", + views.list_exposures, + name="exposures" + ), + path( + "source", + views.create_source, + name="source" + ), + path( + "sources", + views.list_sources, + name="sources" + ), + path( + "source/inflow-fit", + views.fit_source_inflow, + name="source-inflow-fit" + ), + path( + "inflows", + views.list_inflows, + name="inflows" + ), + path( + "treatment", + views.create_treatment, + name="treatment" + ), + path( + "treatments", + views.list_treatments, + name="treatments" + ), + path('', include('django_prometheus.urls')), +] diff --git a/qmra/risk_assessment/user_models.py b/qmra/risk_assessment/user_models.py new file mode 100644 index 0000000..2052572 --- /dev/null +++ b/qmra/risk_assessment/user_models.py @@ -0,0 +1,263 @@ +import uuid + +from crispy_forms.bootstrap import AppendedText +from django.db import models +from django import forms +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout, Field, Row, Column, HTML, Submit + +from qmra.risk_assessment.forms import _zero_if_none +from qmra.user.models import User + + +class UserExposure(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="exposures") + name = models.TextField() + description = models.TextField(blank=True, null=True) + events_per_year = models.IntegerField() + volume_per_event = models.FloatField() + + +class UserExposureForm(forms.ModelForm): + class Meta: + model = UserExposure + fields = [ + "name", + "description", + "events_per_year", + "volume_per_event" + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["name"].label = "Exposure name" + self.fields["description"].label = "Description" + self.fields['volume_per_event'].widget.attrs['min'] = 0 + self.fields['volume_per_event'].label = "Volume per event in liters" + self.helper = FormHelper(self) + self.helper.form_action = "exposure" + self.helper.form_tag = True + self.helper.label_class = "text-muted small" + self.helper.form_id = "user-exposure-form" + self.helper.layout = Layout( + Row(Column("name"), Column("description"), css_id="name-and-description"), + Row(Column("events_per_year"), Column("volume_per_event"), css_id="exposure-form-fieldset"), + Submit('submit', 'Submit') + ) + + +class UserSource(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, related_name="sources", on_delete=models.CASCADE) + name = models.TextField(max_length=64) + rotavirus_min = models.FloatField(blank=True, null=True) + rotavirus_max = models.FloatField(blank=True, null=True) + campylobacter_min = models.FloatField(blank=True, null=True) + campylobacter_max = models.FloatField(blank=True, null=True) + cryptosporidium_min = models.FloatField(blank=True, null=True) + cryptosporidium_max = models.FloatField(blank=True, null=True) + + +class UserSourceForm(forms.ModelForm): + pathogen1 = forms.ChoiceField(choices=[("", "Rotavirus")]) + pathogen2 = forms.ChoiceField(choices=[("", "Campylobacter jejuni")]) + pathogen3 = forms.ChoiceField(choices=[("", "Cryptosporidium parvum")]) + + class Meta: + model = UserSource + fields = [ + "name", + "rotavirus_min", + "rotavirus_max", + "campylobacter_min", + "campylobacter_max", + "cryptosporidium_min", + "cryptosporidium_max" + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["name"].label = "Source water name" + self.helper = FormHelper(self) + self.helper.form_action = "source" + self.helper.form_tag = True + self.helper.form_id = "user-source-form" + self.helper.label_class = "text-muted small" + self.fields["pathogen1"].disabled = True + self.fields["pathogen1"].widget.attrs["class"] = ( + self.fields["pathogen1"].widget.attrs.get("class", "") + " source-pathogen-cell" + ) + self.fields["pathogen1"].required = False + self.fields["pathogen1"].label = "" + self.fields["pathogen2"].disabled = True + self.fields["pathogen2"].widget.attrs["class"] = ( + self.fields["pathogen2"].widget.attrs.get("class", "") + " source-pathogen-cell" + ) + self.fields["pathogen2"].required = False + self.fields["pathogen2"].label = "" + self.fields["pathogen3"].disabled = True + self.fields["pathogen3"].widget.attrs["class"] = ( + self.fields["pathogen3"].widget.attrs.get("class", "") + " source-pathogen-cell" + ) + self.fields["pathogen3"].required = False + self.fields["pathogen3"].label = "" + self.fields['rotavirus_min'].widget.attrs['min'] = 0 + self.fields['rotavirus_max'].widget.attrs['min'] = 0 + self.fields['campylobacter_min'].widget.attrs['min'] = 0 + self.fields['campylobacter_min'].label = "" + self.fields['campylobacter_max'].widget.attrs['min'] = 0 + self.fields['campylobacter_max'].label = "" + self.fields['cryptosporidium_min'].widget.attrs['min'] = 0 + self.fields['cryptosporidium_min'].label = "" + self.fields['cryptosporidium_max'].widget.attrs['min'] = 0 + self.fields['cryptosporidium_max'].label = "" + self.fields['rotavirus_min'].label = "" + self.fields['rotavirus_max'].label = "" + self.helper.layout = Layout( + 'name', + Row( + Column(HTML("")), + Column(HTML("")), + Column(HTML("")) + ), + HTML("
"), + Row( + Column("pathogen1"), + Column(AppendedText('rotavirus_min', 'N/L')), + Column(AppendedText('rotavirus_max', 'N/L')) + ), + Row( + Column(HTML( + "
" + "
" + "" + "" + "
" + "" + "
" + "
" + ), css_class="col-12") + ), + HTML("
"), + HTML("
"), + Row( + Column("pathogen2"), + Column(AppendedText('campylobacter_min', 'N/L')), + Column(AppendedText('campylobacter_max', 'N/L')) + ), + Row( + Column(HTML( + "
" + "
" + "" + "" + "
" + "" + "
" + "
" + ), css_class="col-12") + ), + HTML("
"), + HTML("
"), + Row( + Column("pathogen3"), + Column(AppendedText('cryptosporidium_min', 'N/L')), + Column(AppendedText('cryptosporidium_max', 'N/L')) + ), + Row( + Column(HTML( + "
" + "
" + "" + "" + "
" + "" + "
" + "
" + ), css_class="col-12") + ), + HTML("
"), + Submit("Submit", "Submit") + ) + + +class UserTreatment(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user = models.ForeignKey(User, related_name="treatments", on_delete=models.CASCADE) + name = models.TextField(max_length=64) + bacteria_min = models.FloatField(blank=True, null=True) + bacteria_max = models.FloatField(blank=True, null=True) + viruses_min = models.FloatField(blank=True, null=True) + viruses_max = models.FloatField(blank=True, null=True) + protozoa_min = models.FloatField(blank=True, null=True) + protozoa_max = models.FloatField(blank=True, null=True) + + +class UserTreatmentForm(forms.ModelForm): + class Meta: + model = UserTreatment + fields = [ + "name", + "bacteria_min", + "bacteria_max", + 'viruses_min', + 'viruses_max', + "protozoa_min", + "protozoa_max" + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.helper = FormHelper(self) + self.helper.form_tag = True + self.helper.form_id = "user-treatment-form" + self.helper.form_action = "treatment" + self.helper.label_class = "text-muted small" + self.fields['name'].label = "treatment name" + self.fields['bacteria_min'].label = "" + self.fields['bacteria_max'].label = "" + self.fields['viruses_min'].label = "" + self.fields['viruses_max'].label = "" + self.fields['protozoa_min'].label = "" + self.fields['protozoa_max'].label = "" + label_style = "class='text-muted text-center w-100' style='margin-top: .4em;'" + self.helper.layout = Layout( + Field("name"), + Row(Column(HTML(f"
")), + Column(HTML(f"")), + Column(HTML(f""))), + Row(Column(HTML(f"")), + Column("bacteria_min"), Column("bacteria_max")), + Row(Column(HTML(f"")), + Column("viruses_min"), Column("viruses_max")), + Row(Column(HTML(f"")), + Column("protozoa_min"), Column("protozoa_max")), + Submit('submit', 'Submit') + ) + + def clean(self): + cleaned_data = super().clean() + b_min = _zero_if_none(cleaned_data.get("bacteria_min", 0)) + b_max = _zero_if_none(cleaned_data.get("bacteria_max", 0)) + v_min = _zero_if_none(cleaned_data.get("viruses_min", 0)) + v_max = _zero_if_none(cleaned_data.get("viruses_max", 0)) + p_min = _zero_if_none(cleaned_data.get("protozoa_min", 0)) + p_max = _zero_if_none(cleaned_data.get("protozoa_max", 0)) + msg = "min. must be less than max" + if b_min > b_max: + self.add_error("bacteria_min", msg) + if v_min > v_max: + self.add_error("viruses_min", msg) + if p_min > p_max: + self.add_error("protozoa_min", msg) + return cleaned_data diff --git a/qmra/risk_assessment/views.py b/qmra/risk_assessment/views.py new file mode 100644 index 0000000..c9cea02 --- /dev/null +++ b/qmra/risk_assessment/views.py @@ -0,0 +1,403 @@ +import io +import math + +from crispy_forms.utils import render_crispy_form +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseRedirect, HttpResponse, JsonResponse +from django.shortcuts import render +from django.template.context_processors import csrf +from django.urls import reverse + +from qmra.risk_assessment import exports +from qmra.risk_assessment.forms import InflowFormSet, RiskAssessmentForm, TreatmentFormSet, AddTreatmentForm +from qmra.risk_assessment.models import Inflow, RiskAssessment, Treatment +from qmra.risk_assessment.plots import risk_plots +from qmra.risk_assessment.risk import assess_risk +from django.db import transaction + +import numpy as np +import pandas as pd + +from qmra.risk_assessment.user_models import UserExposureForm, UserTreatmentForm, UserSourceForm, UserExposure, \ + UserSource, UserTreatment + + +@transaction.atomic +def create_risk_assessment(user, risk_assessment_form, inflow_form, treatment_form): + risk_assessment = risk_assessment_form.save(commit=False) + risk_assessment.user = user + with_same_name = RiskAssessment.objects.filter(user=user, name=risk_assessment.name).first() + if with_same_name is not None and risk_assessment.id != with_same_name.id is not None: + risk_assessment.name = risk_assessment.name + " (2)" + risk_assessment.save() + inflows = inflow_form.save(commit=False) + for deleted in inflow_form.deleted_forms: + deleted.instance.delete() + for inflow in inflows: + inflow.risk_assessment = risk_assessment + inflow.save() + treatments = treatment_form.save(commit=False) + for deleted in treatment_form.deleted_forms: + deleted.instance.delete() + for treatment in treatments: + treatment.risk_assessment = risk_assessment + treatment.train_index = -1 + treatment.save() + # save the order of the treatments (:crossed_finger:...) + for i, treatment in enumerate(risk_assessment.treatments.all()): + treatment.train_index = i + treatment.save() + return risk_assessment + + +def _nb_quantile(target, r, p, max_k=100000): + if not (0 < p < 1): + return None + pmf = p ** r + cdf = pmf + if cdf >= target: + return 0 + for k in range(1, max_k + 1): + pmf *= (k - 1 + r) / k * (1 - p) + if pmf == 0: + break + cdf += pmf + if cdf >= target: + return k + return None + + +def _fit_negative_binomial(values): + series = np.asarray(values, dtype=float) + mean = float(np.mean(series)) + variance = float(np.var(series, ddof=1 if len(series) > 1 else 0)) + if variance <= mean: + return None, None, "Data are under-dispersed; negative binomial fit is not suitable." + r = (mean ** 2) / (variance - mean) + p = r / (r + mean) + q025 = _nb_quantile(0.025, r, p) + q975 = _nb_quantile(0.975, r, p) + if q025 is None or q975 is None: + return None, None, "Unable to compute distribution quantiles for the fitted model." + return (r, p, mean, variance), (q025, q975), None + + +def _simulate_negative_binomial(r, p, n_samples=5000): + scale = (1 - p) / p + lam = np.random.gamma(shape=r, scale=scale, size=n_samples) + return np.random.poisson(lam=lam) + + +@transaction.atomic +def assess_and_save_results(risk_assessment: RiskAssessment) -> RiskAssessment: + results = assess_risk(risk_assessment, risk_assessment.inflows.all(), + risk_assessment.treatments.all()) + for r in results.values(): + r.save() + return RiskAssessment.objects.get(id=risk_assessment.id) + + +@login_required(login_url="/login") +def list_risk_assessment_view(request): + return render(request, "risk-assessment-list.html", + context=dict(assessments=request.user.risk_assessments.order_by("-created_at").all())) + + +# @login_required(login_url="/login") +def risk_assessment_view(request, risk_assessment_id=None): + if not request.user.is_authenticated: + # free trial, no save button + return render(request, "assessment-configurator.html", + context=dict( + risk_assessment_form=RiskAssessmentForm( + prefix="ra", + initial=dict(name=f"Assessment")), + inflow_form=InflowFormSet(queryset=Inflow.objects.none(), prefix="inflow"), + add_treatment_form=AddTreatmentForm(), + treatment_form=TreatmentFormSet(prefix="treatments") + )) + if request.method == "POST": + if risk_assessment_id is not None: + instance = RiskAssessment.objects.get(id=risk_assessment_id) + inflows = instance.inflows.all() + treatments = instance.treatments.order_by("train_index").all() + else: + instance = None + inflows = Inflow.objects.none() + treatments = Treatment.objects.none() + risk_assessment_form = RiskAssessmentForm(request.POST, instance=instance, prefix="ra").set_user(request.user) + inflow_form = InflowFormSet(request.POST, queryset=inflows, prefix="inflow") + treatment_form = TreatmentFormSet(request.POST, queryset=treatments, prefix="treatments").set_user(request.user) + if risk_assessment_form.is_valid() and \ + inflow_form.is_valid() and \ + treatment_form.is_valid(): + ra = create_risk_assessment(request.user, risk_assessment_form, inflow_form, treatment_form) + for r in ra.results.all(): + r.delete() + ra = assess_and_save_results(ra) + if request.GET.get("redirect", False): + return HttpResponseRedirect(reverse("assessments")) + return HttpResponseRedirect(reverse("assessment", kwargs=dict(risk_assessment_id=ra.id))) + else: + print(inflow_form.errors) + print(treatment_form.errors) + return render(request, "assessment-configurator.html", + context=dict( + risk_assessment=RiskAssessment.objects.get(id=risk_assessment_id) if risk_assessment_id is not None else None, + risk_assessment_form=risk_assessment_form.set_user(request.user), + inflow_form=inflow_form, + add_treatment_form=AddTreatmentForm().set_user(request.user), + treatment_form=treatment_form.set_user(request.user), + user_exposure_form=UserExposureForm(), + user_source_form=UserSourceForm(), + user_treatment_form=UserTreatmentForm(), + user_exposures=UserExposure.objects.filter(user=request.user).all(), + user_sources=UserSource.objects.filter(user=request.user).all(), + user_treatments=UserTreatment.objects.filter(user=request.user).all(), + )) + if risk_assessment_id is None: + return render(request, "assessment-configurator.html", + context=dict( + risk_assessment_form=RiskAssessmentForm( + prefix="ra", initial=dict(name=f"Assessment {len(request.user.risk_assessments.all())+1}") + ).set_user(request.user), + inflow_form=InflowFormSet(queryset=Inflow.objects.none(), prefix="inflow"), + add_treatment_form=AddTreatmentForm().set_user(request.user), + treatment_form=TreatmentFormSet(prefix="treatments").set_user(request.user), + user_exposure_form=UserExposureForm(), + user_source_form=UserSourceForm(), + user_treatment_form=UserTreatmentForm(), + user_exposures=UserExposure.objects.filter(user=request.user).all(), + user_sources=UserSource.objects.filter(user=request.user).all(), + user_treatments=UserTreatment.objects.filter(user=request.user).all(), + )) + risk_assessment = RiskAssessment.objects.get(id=risk_assessment_id) + if request.method == "DELETE": + risk_assessment.delete() + return render(request, "risk-assessment-list.html", + context=dict(assessments=request.user.risk_assessments.all())) + + return render(request, "assessment-configurator.html", + context=dict( + risk_assessment=risk_assessment, + risk_assessment_form=RiskAssessmentForm(instance=risk_assessment, prefix="ra").set_user(request.user), + inflow_form=InflowFormSet(queryset=risk_assessment.inflows.all(), prefix="inflow"), + add_treatment_form=AddTreatmentForm().set_user(request.user), + treatment_form=TreatmentFormSet(queryset=risk_assessment.treatments.order_by("train_index").all(), prefix="treatments").set_user(request.user), + user_exposure_form=UserExposureForm(), + user_source_form=UserSourceForm(), + user_treatment_form=UserTreatmentForm(), + user_exposures=UserExposure.objects.filter(user=request.user).all(), + user_sources=UserSource.objects.filter(user=request.user).all(), + user_treatments=UserTreatment.objects.filter(user=request.user).all(), + )) + + +# @login_required(login_url="/login") +def risk_assessment_result(request): + if request.method == "POST": + risk_assessment_form = RiskAssessmentForm(request.POST, instance=None, prefix="ra") + inflow_form = InflowFormSet(request.POST, prefix="inflow") + treatment_form = TreatmentFormSet(request.POST, prefix="treatments") + if request.user.is_authenticated: + risk_assessment_form = risk_assessment_form.set_user(request.user) + treatment_form = treatment_form.set_user(request.user) + if risk_assessment_form.is_valid() and \ + inflow_form.is_valid() and \ + treatment_form.is_valid(): + ra = risk_assessment_form.save(commit=False) + # print(inflow_form.is_valid(), treatment_form.is_valid()) + inflows = [f.instance for f in inflow_form.forms if f not in inflow_form.deleted_forms] + treatments = [f.instance for f in treatment_form.forms if f not in treatment_form.deleted_forms] + results = assess_risk(ra, + inflows, + treatments, save=False) + risks = {r.infection_risk for r in results.values()} + risk_category = 'max' if 'max' in risks else ("min" if 'min' in risks else 'none') + plots = risk_plots(results.values()) + return render(request, "assessment-result.html", + context=dict(results=results.values(), + infection_risk=risk_category, + risk_plot=plots[0], daly_plot=plots[1], + max_lrv_warning_for={t.name: t.above_max_lrv() + for t in treatments + if len(t.above_max_lrv())})) + else: + # print(inflow_form.errors) + # print(treatment_form.errors) + return HttpResponse(status=422) + + elif request.method == "GET": + risk_assessment_id = request.GET.get("id") + if risk_assessment_id is not None: + risk_assessment = RiskAssessment.objects.get(id=risk_assessment_id) + if not any(risk_assessment.results.all()): + risk_assessment = assess_and_save_results(risk_assessment) + results = risk_assessment.results.all() + plots = risk_plots(results) + return render(request, "assessment-result.html", + context=dict(results=results, + infection_risk=risk_assessment.infection_risk, + risk_plot=plots[0], daly_plot=plots[1], + max_lrv_warning_for={t.name: t.above_max_lrv() + for t in risk_assessment.treatments.all() + if len(t.above_max_lrv())})) + + +@login_required(login_url="/login") +def export_risk_assessment(request, risk_assessment_id=None): + if risk_assessment_id is not None: + risk_assessment = RiskAssessment.objects.get(id=risk_assessment_id) + if not any(risk_assessment.results.all()): + risk_assessment = assess_and_save_results(risk_assessment) + response = HttpResponse(content_type="application/zip") + response["Content-Disposition"] = ( + "attachment; filename=" + str(risk_assessment.name) + ".zip" + ) + exports.risk_assessment_as_zip(response, risk_assessment) + return response + return HttpResponse(status=422) + + +@login_required(login_url="/login") +def create_exposure(request): + if request.method == "POST": + exposure_form = UserExposureForm(request.POST) + if exposure_form.is_valid(): + # handle duplicate names + if any(UserExposure.objects.filter(name=exposure_form.instance.name, user=request.user).all()): + exposure_form.add_error("name", "'name' must be unique. You already have an exposure with this name") + ctx = {} + ctx.update(csrf(request)) + return HttpResponse(render_crispy_form(exposure_form, context=ctx), status=422) + exposure_form.instance.user = request.user + exposure_form.save(commit=True) + return HttpResponseRedirect(request.META["HTTP_REFERER"]) + return HttpResponse(status=422) + return HttpResponse(status=404) + + +def list_exposures(request): + if not request.user.is_authenticated: + return JsonResponse({}) + return JsonResponse({e["name"]: e for e in UserExposure.objects.filter(user=request.user).values().all()}) + + +@login_required(login_url="/login") +def create_source(request): + if request.method == "POST": + source_form = UserSourceForm(request.POST) + if source_form.is_valid(): + # handle duplicate names + if any(UserExposure.objects.filter(name=source_form.instance.name, user=request.user).all()): + source_form.add_error("name", "'name' must be unique. You already have a source with this name") + ctx = {} + ctx.update(csrf(request)) + return HttpResponse(render_crispy_form(source_form, context=ctx), status=422) + source_form.instance.user = request.user + source_form.save(commit=True) + return HttpResponseRedirect(request.META["HTTP_REFERER"]) + return HttpResponse(status=422) + return HttpResponse(status=404) + + +def list_sources(request): + if not request.user.is_authenticated: + return JsonResponse({}) + return JsonResponse({s["name"]: s for s in UserSource.objects.filter(user=request.user).values().all()}) + + +@login_required(login_url="/login") +def fit_source_inflow(request): + if request.method != "POST": + return JsonResponse({"ok": False, "errors": ["Invalid request method."]}, status=405) + pathogen = request.POST.get("pathogen") + upload = request.FILES.get("file") + if not pathogen or upload is None: + return JsonResponse({"ok": False, "errors": ["Missing pathogen or CSV file."]}, status=400) + try: + dataframe = pd.read_csv(upload, header=None) + except Exception: + return JsonResponse({"ok": False, "errors": ["Unable to parse CSV file."]}, status=400) + if dataframe.shape[1] < 1: + return JsonResponse({"ok": False, "errors": ["CSV file must contain at least one column."]}, status=400) + series = dataframe.iloc[:, 0].dropna() + if series.empty: + return JsonResponse({"ok": False, "errors": ["No data values found for this pathogen."]}, status=400) + values = series.to_list() + for value in values: + if isinstance(value, str): + return JsonResponse({"ok": False, "errors": ["Non-integer values detected in the CSV column."]}, status=400) + if float(value) % 1 != 0: + return JsonResponse({"ok": False, "errors": ["Non-integer values detected in the CSV column."]}, status=400) + if int(value) < 0: + return JsonResponse({"ok": False, "errors": ["Negative values are not allowed."]}, status=400) + params, quantiles, error = _fit_negative_binomial(values) + if error: + return JsonResponse({"ok": False, "errors": [error]}, status=400) + r, p, mean, variance = params + q025, q975 = quantiles + q025_floor = math.floor(q025) + q975_ceil = math.ceil(q975) + samples = _simulate_negative_binomial(r, p, n_samples=5000) + counts, bin_edges = np.histogram(samples, bins=30) + bin_centers = ((bin_edges[:-1] + bin_edges[1:]) / 2.0).tolist() + return JsonResponse({ + "ok": True, + "pathogen": pathogen, + "n_samples": len(values), + "r": r, + "p": p, + "mean": mean, + "variance": variance, + "q025": q025_floor, + "q975": q975_ceil, + "histogram": { + "bins": bin_centers, + "counts": counts.tolist() + } + }) + + +def list_inflows(request): + if not request.user.is_authenticated: + return JsonResponse({}) + return JsonResponse({s["name"]: [ + dict(pathogen_name="Rotavirus", source_name=s["name"], + min=s["rotavirus_min"], max=s["rotavirus_max"], + referenceID=None + ), + dict(pathogen_name="Campylobacter jejuni", source_name=s["name"], + min=s["campylobacter_min"], max=s["campylobacter_max"], + referenceID=None + ), + dict(pathogen_name="Cryptosporidium parvum", source_name=s["name"], + min=s["cryptosporidium_min"], max=s["cryptosporidium_max"], + referenceID=None + ), + ] for s in UserSource.objects.filter(user=request.user).values().all()}) + + +@login_required(login_url="/login") +def create_treatment(request): + if request.method == "POST": + treatment_form = UserTreatmentForm(request.POST) + if treatment_form.is_valid(): + # handle duplicate names + if any(UserExposure.objects.filter(name=treatment_form.instance.name, user=request.user).all()): + treatment_form.add_error("name", "'name' must be unique. You already have a treatment with this name") + ctx = {} + ctx.update(csrf(request)) + return HttpResponse(render_crispy_form(treatment_form, context=ctx), status=422) + treatment_form.instance.user = request.user + treatment_form.save(commit=True) + return HttpResponseRedirect(request.META["HTTP_REFERER"]) + return HttpResponse(status=422) + return HttpResponse(status=404) + + +def list_treatments(request): + if not request.user.is_authenticated: + return JsonResponse({}) + return JsonResponse({t["name"]: {**t, "treatment_name": t["name"]} + for t in UserTreatment.objects.filter(user=request.user).values().all()}) diff --git a/qmra/risk_assessment/views_v0.py b/qmra/risk_assessment/views_v0.py new file mode 100644 index 0000000..1300a5d --- /dev/null +++ b/qmra/risk_assessment/views_v0.py @@ -0,0 +1,798 @@ +import numpy as np +import pandas as pd +from crispy_forms.bootstrap import InlineCheckboxes +from crispy_forms.helper import FormHelper +from django import forms +from django.contrib.auth.decorators import login_required +from django.db.models import Q +from django.http import HttpResponseRedirect, HttpResponse, JsonResponse +from django.shortcuts import render +from django.urls import reverse +from django_pandas.io import read_frame +from formtools.wizard.views import SessionWizardView +from plotly import express as px, graph_objs as go +from plotly.offline import plot + +from qmra.scenario.views import ExposureForm +from qmra.source.models import Inflow +from qmra.risk_assessment.models import RiskAssessment, Health, DoseResponse, Comparison +from qmra.scenario.models import ExposureScenario +from qmra.source.views import InflowFormSet, WaterSourceForm +from qmra.treatment.models import Treatment, Pathogen, LogRemoval +from qmra.source.models import WaterSource +from qmra.treatment.views import TreatmentForm, LogRemovalFormSet +from crispy_forms.layout import Layout, Fieldset, Field, Div + + +# TODO: +# select categories of [source, treatment, exposure, pathogen] +# new data model +# ra form and views +# POST fieldset for plots (treatment, inflows) +# create and serve json static files for +# - default pathogens +# - default inflows +# - default ... + +class RadioSelectWithTooltip(forms.RadioSelect): + option_template_name = "option-with-tooltip.html" + + +class RAForm(forms.ModelForm): + def __init__(self, user, *args, **kwargs): + super(RAForm, self).__init__(*args, **kwargs) + self.fields[ + "treatment" + ].help_text = "Please select your treatment configuration" + self.fields["source"].help_text = "Please select your source water" + self.fields["source"].empty_label = None + self.fields["source"].queryset = WaterSource.objects.filter( + Q(user__exact=user) | Q(user__isnull=True) + ).order_by("name") + self.fields["exposure"].empty_label = None + self.fields["exposure"].help_text = "Please define your exposure scenario" + self.fields["exposure"].queryset = ExposureScenario.objects.filter( + Q(user__exact=user) | Q(user__isnull=True) + ).order_by("name") + self.fields["treatment"].queryset = ( + Treatment.objects.filter( + Q(user__exact=user) | Q(user__isnull=True) + ) + .order_by("name") + .order_by("category") + ) + self.fields["name"].widget.attrs.update({'class': 'text-field-bg'}) + self.fields["description"].widget.attrs.update({'class': 'text-field-bg'}) + self.helper = FormHelper(self) + self.helper.field_class="mygrid" + self.helper.form_tag = False + self.helper.disable_csrf = True + self.helper.layout = Layout( + "name", "description", "source", + Div("treatment", field_id="ra-treatment-boxes"), + "exposure" + ) + + class Meta: + model = RiskAssessment + fields = ["name", "description", "source", "treatment", "exposure"] + widgets = { + "source": forms.Select(attrs={"empty_label": None}), + "treatment": forms.CheckboxSelectMultiple(), + # "exposure": forms.RadioSelect(attrs={"empty_label": None}), + } + + +@login_required(login_url="/login") +def inline_risk_assessment(request, risk_assessment_id = None): + if request.method == "POST": + inflow_form = InflowFormSet(request.POST) + treatment_form = LogRemovalFormSet(request.POST) + exposure_form = ExposureForm(request.POST) + any_error = not all(f.is_valid() for f in (inflow_form, treatment_form, exposure_form)) + if any_error: + return render(request, "assessment-configurator.html", + dict(inflow_form=inflow_form, + treatment_form=treatment_form, + exposure_form=exposure_form)) + return HttpResponseRedirect(reverse("assessment")) + ra_form = RAForm(request.user, prefix="ra") + ra_form.helper.form_tag = False + source_form = WaterSourceForm() + inflow_form = InflowFormSet(queryset=Inflow.objects.none(), prefix="inflow") + inflow_form.helper.form_tag = False + treatment_form = LogRemovalFormSet(queryset=LogRemoval.objects.none(), prefix="logremoval") + treatment_form.helper.form_tag = False + exposure_form = ExposureForm(prefix="exposure") + exposure_form.helper.form_tag = False + return render(request, "assessment-configurator.html", + dict(ra_form=ra_form, + source_form=source_form, + inflow_form=inflow_form, + treatment_form=treatment_form, + exposure_form=exposure_form, + treatments=ra_form.fields["treatment"].queryset, + sources=ra_form.fields["source"].queryset, + exposures=ra_form.fields["exposure"].queryset + )) + + +class ComparisonForm(forms.ModelForm): + class Meta: + model = Comparison + fields = ["risk_assessment"] + widgets = {"risk_assessment": forms.CheckboxSelectMultiple()} + + def __init__(self, user, *args, **kwargs): + super(ComparisonForm, self).__init__(*args, **kwargs) + self.fields["risk_assessment"].queryset = RiskAssessment.objects.filter( + user=user + ) + self.fields[ + "risk_assessment" + ].help_text = "Select risk assessments for comparison" + self.helper = FormHelper() + + +@login_required(login_url="/login") +def risk_assessment_view(request, risk_assessment_id=None): + if risk_assessment_id is None: + if request.method == "POST": + ra_form = RAForm(request.user, request.POST) + if ra_form.is_valid(): + ra = new_assessment(request.user, ra_form) + return HttpResponseRedirect(reverse("assessment-result", kwargs=dict(risk_assessment_id=ra.id))) + else: + return HttpResponse(request, "Form not valid") + else: + if request.GET.get("form"): + ra_form = RAForm(request.user) + return render(request, "assessment-form.html", + { + "ra_form": ra_form, + "treatments": ra_form.fields["treatment"].queryset, + "sources": ra_form.fields["source"].queryset, + "exposures": ra_form.fields["exposure"].queryset + }) + return render(request, "risk-assessment-list.html", + {"assessments": request.user.assessments.all()}) + elif request.method == "GET": + ra = RiskAssessment.objects.get(id=risk_assessment_id) + if request.GET.get("form", False): + ra_form = RAForm(request.user, instance=ra) + return render(request, "assessment-form.html", + { + "ra_form": ra_form, + "treatments": ra_form.fields["treatment"].queryset, + "sources": ra_form.fields["source"].queryset, + "exposures": ra_form.fields["exposure"].queryset + }) + return JsonResponse(ra.serialize(), safe=False) + elif request.method == "POST": + ra = RiskAssessment.objects.get(id=risk_assessment_id) + ra_form = RAForm(request.user, request.POST, instance=ra) + if ra_form.is_valid(): + new_assessment(request.user, ra_form) + return HttpResponseRedirect(reverse("assessment")) + elif request.method == "DELETE": + RiskAssessment.objects.get(id=risk_assessment_id).delete() + return HttpResponseRedirect(reverse("assessment")) + + +def new_assessment(user, ra_form): + assessment = ra_form.save(commit=False) + assessment.user = user + assessment.source = ra_form.cleaned_data["source"] + assessment.exposure = ra_form.cleaned_data["exposure"] + assessment.save() + assessment.treatment.set(ra_form.cleaned_data["treatment"]) + assessment.save() + return assessment + + +class RAFormStep1(forms.ModelForm): + class Meta: + model = RiskAssessment + fields = ["name", "description"] + + +class RAFormStep2(forms.ModelForm): + def __init__(self, *args, **kwargs): + super(RAFormStep2, self).__init__(*args, **kwargs) + self.fields["source"].help_text = "Please select your source water" + self.fields["source"].empty_label = None + self.helper = FormHelper() + + class Meta: + model = RiskAssessment + fields = ["source"] + widgets = { + "source": forms.RadioSelect(attrs={"empty_label": None}), + } + + +class RAFormStep3(forms.ModelForm): + def __init__(self, *args, **kwargs): + super(RAFormStep3, self).__init__(*args, **kwargs) + self.fields[ + "treatment" + ].help_text = "Please select your treatment configuration" + self.helper = FormHelper() + + class Meta: + model = RiskAssessment + fields = ["treatment"] + widgets = { + "treatment": forms.CheckboxSelectMultiple(), + } + + +class RAFormStep4(forms.ModelForm): + def __init__(self, *args, **kwargs): + super(RAFormStep4, self).__init__(*args, **kwargs) + + self.fields["exposure"].empty_label = None + self.fields["exposure"].help_text = "Please define your exposure scenario" + self.helper = FormHelper() + + class Meta: + model = RiskAssessment + fields = ["exposure"] + widgets = { + "exposure": forms.RadioSelect(attrs={"empty_label": None}), + } + + +class RAFormWizard(SessionWizardView): + form_list = [RAFormStep1, RAFormStep2, RAFormStep3, RAFormStep4] + template_name = "assessment-steps/step1.html" # Default, can be overridden + TEMPLATES = { + "0": "assessment-steps/step1.html", + "1": "assessment-steps/step2.html", + "2": "assessment-steps/step3.html", + "3": "assessment-steps/step4.html", + } + + def get_template_names(self): + return [self.TEMPLATES[self.steps.current]] + + def get_context_data(self, form, **kwargs): + context = super().get_context_data(form=form, **kwargs) + if self.steps.current == '1': + context.update({'sources': WaterSource.objects.filter( + Q(user__exact=self.request.user) | Q(user__isnull=True) + ).order_by("id")}) + elif self.steps.current == '2': + context.update({'treatments': ( + Treatment.objects.filter( + Q(user__exact=self.request.user) | Q(user__isnull=True) + ) + .order_by("id") + .order_by("category") + )}) + elif self.steps.current == '3': + context.update({'exposures': ExposureScenario.objects.filter( + Q(user__exact=self.request.user) | Q(user__isnull=True) + ).order_by("id")}) + return context + + def done(self, form_list, **kwargs): + all_cleaned_data = {} + for form in form_list: + all_cleaned_data.update(form.cleaned_data) + + # Extract treatments for later (before we pop them) + treatments = all_cleaned_data.pop('treatment', []) + + # Get the current user from the request + current_user = self.request.user + + # Now you can use `all_cleaned_data` to save the RiskAssessment instance or perform other operations. + risk_assessment = RiskAssessment(**all_cleaned_data) + + # Set the user field to the current user + risk_assessment.user = current_user + + risk_assessment.save() + + # Set the many-to-many field treatments using set() + risk_assessment.treatment.set(treatments) + + return HttpResponseRedirect(reverse("index")) + # Save your model instance or perform other operations + # This method is called after completing all the steps + + +@login_required(login_url="/login") +def export_summary(request, risk_assessment_id): + ra = RiskAssessment.objects.get(id=risk_assessment_id) + if ra.user == request.user: + results_long = simulate_risk(ra) + results_long.rename(columns={"value": "infection_prob"}, inplace=True) + results_long["pathogen"] = results_long["variable"].str.split("_", expand=True)[ + 0 + ] + results_long["stat"] = results_long["variable"].str.split("_", expand=True)[1] + + health = read_frame(Health.objects.all()) + results_long = pd.merge(results_long, health, on="pathogen") + results_long["DALYs pppy"] = ( + results_long.infection_prob + * results_long.infection_to_illness.astype(float) + * results_long.dalys_per_case.astype(float) + ) + results_long = results_long.groupby(["pathogen", "stat"]).describe( + percentiles=[0.05, 0.25, 0.5, 0.75, 0.95] + )[["infection_prob", "DALYs pppy"]] + response = HttpResponse(content_type="text/csv") + response["Content-Disposition"] = ( + "attachment; filename=" + str(ra.name) + "_summary.csv" + ) + + # results_long.to_csv(path_or_buf=response, sep=',',float_format='%.2f', index=False, decimal=".") + results_long.to_csv(path_or_buf=response, sep=",", decimal=".") + + return response + else: + return HttpResponseRedirect(reverse("login")) + + +@login_required(login_url="/login") +def calculate_risk(request, risk_assessment_id): + ra = RiskAssessment.objects.get(id=risk_assessment_id) + # Selecting inflow concentration based in source water type + df_inflow = read_frame( + Inflow.objects.filter(water_source=ra.source).values( + "min", "max", "pathogen__name", "water_source__name" + ) + ) + df_inflow = df_inflow[ + df_inflow["pathogen__name"].isin( + ["Rotavirus", "Campylobacter jejuni", "Cryptosporidium parvum"] + ) + ] + # Querying for Logremoval based on selected treatments + df_treatment = read_frame( + LogRemoval.objects.filter(treatment__in=ra.treatment.all()).values( + "min", "max", "treatment__name", "pathogen_group__pathogen_group" + ) + ) + + results_long = simulate_risk(ra) + # results_long["value"] = round(results_long["value"],1) + + results_long["pathogen"] = results_long["variable"].str.split("_", expand=True)[0] + results_long["stat"] = results_long["variable"].str.split("_", expand=True)[1] + + health = read_frame(Health.objects.all()) + results_long = pd.merge(results_long, health, on="pathogen") + results_long["DALYs pppy"] = ( + results_long.value + * results_long.infection_to_illness.astype(float) + * results_long.dalys_per_case.astype(float) + ) + summary = results_long.groupby(["pathogen", "stat"]).mean() + risk = sum(summary["value"] > 10 ** -4) > 0 + dalyrisk = sum(summary["DALYs pppy"] > 10 ** -6) > 0 + + if risk: + bgcolor = "rgba(245, 0, 0, 0.15)" + lcolor = "firebrick" + else: + bgcolor = None + lcolor = "#0003e2" + + if dalyrisk: + dalybgcolor = "rgba(245, 0, 0, 0.15)" + dlcolor = "firebrick" + else: + dalybgcolor = None + dlcolor = "#0003e2" + + risk_colors = ["#78BEF9", "#8081F1", "#5F60B3"] + risk_colors_extended = [ + "#78BEF9", + "#8081F1", + "#5F60B3", + "#3D3E73", + "#F29C99", + "#7375D9", + "#CBCCF4", + ] + + fig = px.box( + results_long, + x="stat", + y="value", + color="pathogen", + points=False, + log_y=True, + color_discrete_sequence=risk_colors, + hover_data={'value': ':.2e'} + ) + + fig.update_layout( + font_family="Helvetica Neue, Helvetica, Arial, sans-serif", + font_color="black", + plot_bgcolor=bgcolor, + xaxis_title="", + yaxis_title="Probability of infection per year", + margin=dict(l=0, r=0, t=0, b=0), + legend=dict( + orientation="h", + yanchor="top", + xanchor="center", + x=0.5, + ) + ) + fig.add_hline(y=0.0001, line_dash="dashdot", line=dict(color=lcolor, width=3)) + fig.update_traces( + marker_size=8 + ) + + risk_plot = plot(fig, output_type="div", config={'displayModeBar': False}) + + fig = px.box( + results_long, + x="stat", + y="DALYs pppy", + color="pathogen", + points=False, + log_y=True, + color_discrete_sequence=risk_colors, + ) + + fig.update_layout( + font_family="Helvetica Neue, Helvetica, Arial, sans-serif", + font_color="black", + xaxis_title="", + yaxis_title="DALYs pppy", + plot_bgcolor=dalybgcolor, + margin=dict(l=0, r=0, t=0, b=0), + legend=dict( + orientation="h", + yanchor="top", + xanchor="center", + x=0.5, + ) + ) + fig.add_hline(y=0.000001, line_dash="dashdot", line=dict(color=dlcolor, width=3)) + fig.update_traces( + marker_size=8 + ) + daly_plot = plot(fig, output_type="div", config={'displayModeBar': False}) + + # reshaping dataframe for plotting + df_inflow2 = pd.melt( + df_inflow, ("pathogen__name", "water_source__name") + ) + df_inflow2 = df_inflow2[ + df_inflow2.pathogen__name.isin( + ["Rotavirus", "Cryptosporidium parvum", "Campylobacter jejuni"] + ) + ] + df_inflow2 = df_inflow2.rename( + columns={"pathogen__name": "Pathogen", "variable": ""} + ) + fig2 = px.bar( + df_inflow2, + x="", + y="value", + log_y=True, + facet_col="Pathogen", + barmode="group", + category_orders={ + "Pathogen": ["Rotavirus", "Campylobacter jejuni", "Cryptosporidium parvum"] + }, + color_discrete_sequence=risk_colors_extended, + ) + + fig2.for_each_annotation( + lambda a: a.update( + text=a.text.split("=")[-1], font=dict(size=10, color="black"), + ) + ) + + fig2.update_layout( + font_family="Helvetica Neue, Helvetica, Arial, sans-serif", + font_color="black", + yaxis_title="Source water concentrations in N/L", + margin=dict(l=0, r=0, t=40, b=0), + ) + + plot_div2 = plot(fig2, output_type="div", config={'displayModeBar': False}) + + # reshaping + df = pd.melt(df_treatment, ("treatment__name", "pathogen_group__pathogen_group")) + df = df.rename( + columns={ + "treatment__name": "Treatment", + "pathogen_group__pathogen_group": "Pathogen Group", + "variable": "", + } + ) + fig = px.bar( + df, + x="", + y="value", + color="Treatment", + facet_col="Pathogen Group", + category_orders={"Pathogen Group": ["Viruses", "Bacteria", "Protozoa"]}, + color_discrete_sequence=risk_colors_extended, + ) + + fig.for_each_annotation( + lambda a: a.update(text=a.text.split("=")[-1], font=dict(size=13)) + ) + fig.update_layout( + margin=dict(l=0, r=0, t=20, b=0), + legend=dict(orientation="h", yanchor="top", + xanchor="center", + x=0.5, ), + font_family="Helvetica Neue, Helvetica, Arial, sans-serif", + font_color="black", + yaxis_title="Logremoval of individual treatment step", + ) + + plot_div = plot(fig, output_type="div", config={'displayModeBar': False}) + + return render( + request, + "assessment-result.html", + { + "plot_div": plot_div, + "plot_div2": plot_div2, + "daly_plot": daly_plot, + "risk_plot": risk_plot, + "ra": ra, + "risk": risk, + "dalyrisk": risk + }, + ) + + +def annual_risk(nexposure, event_probs): + return 1 - np.prod(1 - np.random.choice(event_probs, nexposure, True)) + + +def simulate_risk(ra): + # Selecting inflow concentration based in source water type + df_inflow = read_frame( + Inflow.objects.filter(water_source=ra.source).values( + "min", "max", "pathogen__name", "water_source__name" + ) + ) + df_inflow = df_inflow[ + df_inflow["pathogen__name"].isin( + ["Rotavirus", "Campylobacter jejuni", "Cryptosporidium parvum"] + ) + ] + # Querying dose response parameters based on pathogen inflow + dr_models = read_frame( + DoseResponse.objects.filter( + pathogen__in=Pathogen.objects.filter( + name__in=df_inflow["pathogen__name"] + ) + ) + ) + + # Querying for Logremoval based on selected treatments + df_treatment = read_frame( + LogRemoval.objects.filter(treatment__in=ra.treatment.all()).values( + "min", "max", "treatment__name", "pathogen_group__pathogen_group" + ) + ) + + # Summarizing treatment to treatment max and treatment min + # df_treatment_summary=df_treatment.groupby(["pathogen_group__pathogen_group"]).sum().reset_index() + df_treatment_summary = ( + df_treatment.groupby(["pathogen_group__pathogen_group"])[["min", "max"]] + .apply(lambda x: x.sum()) + .reset_index() + ) + + results = pd.DataFrame() + + for index, row in df_inflow.iterrows(): + d = df_inflow.loc[df_inflow["pathogen__name"] == row["pathogen__name"]] + dr = dr_models.loc[dr_models["pathogen"] == row["pathogen__name"]] + + if row["pathogen__name"] == "Rotavirus": + selector = "Viruses" + elif row["pathogen__name"] == "Cryptosporidium parvum": + selector = "Protozoa" + else: + selector = "Bacteria" + # result.append(selector) + + df_treat = df_treatment_summary[ + df_treatment_summary["pathogen_group__pathogen_group"] == selector + ] + + risk_df = pd.DataFrame( + { + "inflow": np.random.normal( + loc=( + np.log10(float(d["min"]) + 10 ** (-8)) + + np.log10(float(d["max"])) + ) + / 2, + scale=( + np.log10(float(d["max"])) + - np.log10(float(d["min"]) + 10 ** (-8)) + ) + / 4, + size=10000, + ), # @Wolfgang: low == high ? + "LRV": np.random.uniform( + low=df_treat["min"], high=df_treat["min"], size=10000 + ), + "LRVmax": np.random.uniform( + low=df_treat["max"], high=df_treat["max"], size=10000 + ), + } + ) + risk_df["outflow"] = risk_df["inflow"] - risk_df["LRV"] + risk_df["outflow_min"] = risk_df["inflow"] - risk_df["LRVmax"] + + risk_df["dose"] = (10 ** risk_df["outflow"]) * float( + ra.exposure.volume_per_event + ) + risk_df["dose_min"] = (10 ** risk_df["outflow_min"]) * float( + ra.exposure.volume_per_event + ) + + if selector != "Protozoa": + risk_df["probs"] = 1 - ( + 1 + (risk_df["dose"]) * (2 ** (1 / float(dr.alpha)) - 1) / float(dr.n50) + ) ** -float(dr.alpha) + risk_df["probs_min"] = 1 - ( + 1 + + (risk_df["dose_min"]) + * (2 ** (1 / float(dr.alpha)) - 1) + / float(dr.n50) + ) ** -float(dr.alpha) + + else: + # @Wolfgang: 1 - k * exp(-k * dose) ? + risk_df["probs"] = 1 - np.exp(-float(dr.k) * (risk_df["dose"])) + risk_df["probs_min"] = 1 - np.exp(-float(dr.k) * (risk_df["dose_min"])) + + results[row["pathogen__name"] + "_MinimumLRV"] = [ + annual_risk(int(ra.exposure.events_per_year), risk_df["probs"]) + for _ in range(1000) + ] + results[row["pathogen__name"] + "_MaximumLRV"] = [ + annual_risk(int(ra.exposure.events_per_year), risk_df["probs_min"]) + for _ in range(1000) + ] + + results_long = pd.melt(results) + results_long["log probability"] = np.log10(results_long["value"]) + return results_long + + +@login_required(login_url="/login") +def comparison_view(request): + user = request.user + if request.method == "POST": + form = ComparisonForm(user, request.POST) + if form.is_valid(): + comparison = Comparison() + comparison.save() + comparison.risk_assessment.set(form.cleaned_data["risk_assessment"]) + results = [] + ras = comparison.risk_assessment.all() + for i in range(len(ras)): + sim = simulate_risk(ras[i]) + sim["Assessment"] = ras[i].name + results.append(sim) + + df = pd.concat(results) + + df["pathogen"] = df["variable"].str.split("_", expand=True)[0] + df["stat"] = df["variable"].str.split("_", expand=True)[1] + dfmin = ( + df.groupby(["pathogen", "Assessment"]) + .min("value") + .reset_index() + .assign(stat="min") + ) + dfmax = ( + df.groupby(["pathogen", "Assessment"]) + .max("value") + .reset_index() + .assign(stat="max") + ) + df_summary = dfmin.append(dfmax).sort_values(by="value", ascending=False) + df_mean = ( + df.groupby(["pathogen", "Assessment", "stat"]) + .mean("value") + .reset_index() + .sort_values(by="value", ascending=False) + ) + + fig = plot_comparison(df_summary, df_mean) + + risk_plot = plot(fig, output_type="div", config={'displayModeBar': False}) + + return render( + request, + "assessment-result.html", + {"risk_plot": risk_plot, "comparison": True}, + ) + else: + form = ComparisonForm(user=user) + return render(request, "comparison.html", {"form": form}) + + +def plot_comparison(df, df2): + fig = px.box( + df, + x="Assessment", + y="value", + color="pathogen", + log_y=True, + color_discrete_sequence=[ + "#78BEF9", + "#8081F1", + "#5F60B3", + "#3D3E73", + "#F29C99", + "#7375D9", + "#CBCCF4", + ], + ) + + fig.add_traces( + list( + px.box( + df2, + x="Assessment", + y="value", + color="pathogen", + log_y=True, + color_discrete_sequence=["#7375D9", "#7375D9", "#7375D9", "#7375D9"], + ).select_traces() + ) + ) + + fig.update_layout( + font_family="Helvetica Neue, Helvetica, Arial, sans-serif", + font_color="black", + yaxis_title="Probability of infection per year", + xaxis_title="", + margin=dict(l=0, r=0, t=30, b=0), + annotations=[ + go.Annotation( + y=-4, + x=1.2, + text="Tolerable risk level of 1/10000 infections pppy", + bgcolor="#0003e2", + bordercolor="white", + borderpad=5, + font=dict(color="white"), + ) + ], + legend=dict( + orientation="h", + yanchor="top", + xanchor="center", + x=0.5, + font=dict(size=10, color="black"), + bgcolor="white", + bordercolor="#007c9e", + borderwidth=0, + ) + ) + + fig.add_hline( + y=0.0001, + line_dash="dashdot", + line=dict(color="#0003e2", width=3), + ) + + fig.update_traces( + marker_size=8, hovertemplate=None, hoverinfo="skip", line=dict(width=0) + ) + return fig diff --git a/qmra/settings.py b/qmra/settings.py new file mode 100644 index 0000000..46c0181 --- /dev/null +++ b/qmra/settings.py @@ -0,0 +1,214 @@ +""" +Django settings for qmra project. + +Generated by 'django-admin startproject' using Django 3.1.1. + +For more information on this file, see +https://docs.djangoproject.com/en/3.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/3.1/ref/settings/ +""" + +from pathlib import Path +import os +import structlog + +from qmra.logs import json_stdout_handler, json_stderr_handler, console_stdout_handler, console_stderr_handler + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/3.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = os.getenv("SECRET_KEY", "123") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = os.getenv("DEBUG", "true").lower() == "true" + +ALLOWED_HOSTS = [ + "188.245.69.0", + "localhost", + "127.0.0.1", + os.getenv("DOMAIN_NAME", ""), + os.getenv("THIS_POD_IP", "") +] + +CSRF_TRUSTED_ORIGINS = [f"https://{os.getenv('DOMAIN_NAME', '')}"] +CSRF_ALLOWED_ORIGINS = [f"https://{os.getenv('DOMAIN_NAME', '')}"] +# Application definition + +INSTALLED_APPS = [ + 'qmra', + 'qmra.user', + 'qmra.risk_assessment', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'crispy_forms', + 'crispy_bootstrap4', + "django_structlog", + "django_prometheus" +] + +os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" +MIDDLEWARE = [ + 'django_prometheus.middleware.PrometheusBeforeMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django_structlog.middlewares.RequestMiddleware", + 'django_prometheus.middleware.PrometheusAfterMiddleware', +] + +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap4" +CRISPY_TEMPLATE_PACK = 'bootstrap4' + +ROOT_URLCONF = 'qmra.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'qmra.wsgi.application' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.getenv("SQLITE_PATH", BASE_DIR / 'qmra.db'), + }, + 'qmra': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.getenv("DEFAULT_QMRA_PATH", BASE_DIR / 'default_qmra_data.db'), + } +} +DATABASE_ROUTERS = ('qmra.risk_assessment.dbrouter.DBRouter',) + +AUTH_USER_MODEL = "user.User" +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# Internationalization +# https://docs.djangoproject.com/en/3.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.1/howto/static-files/ +STATIC_URL = '/static/' +STATIC_ROOT = os.getenv("STATIC_ROOT", ".static") +STATICFILES_DIRS = [ + BASE_DIR / "qmra/static" +] + +LOGGING = { + "version": 1, + "disable_existing_loggers": True, + "formatters": { + "json_formatter": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.processors.JSONRenderer(), + }, + "plain_console": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.dev.ConsoleRenderer(), + }, + "key_value": { + "()": structlog.stdlib.ProcessorFormatter, + "processor": structlog.processors.KeyValueRenderer( + key_order=['timestamp', 'request', 'code', 'level', 'event']), + }, + }, + "handlers": { + # Important notes regarding handlers. + # + # 1. Make sure you use handlers adapted for your project. + # These handlers configurations are only examples for this library. + # See python's logging.handlers: https://docs.python.org/3/library/logging.handlers.html + # + # 2. You might also want to use different logging configurations depending of the environment. + # Different files (local.py, tests.py, production.py, ci.py, etc.) or only conditions. + # See https://docs.djangoproject.com/en/dev/topics/settings/#designating-the-settings + "console_out": console_stdout_handler, + "console_err": console_stderr_handler, + "json_out": json_stdout_handler, + "json_err": json_stderr_handler, + }, + "loggers": { + "django_structlog": { + "handlers": ["console_out", "console_err"], + "level": "INFO", + 'propagate': False, + } + } +} + +structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.stdlib.filter_by_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, +) + +EMAIL_USE_TLS = True +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_HOST_USER = 'myusername@gmail.com' +EMAIL_HOST_PASSWORD = 'mypassword' +DEFAULT_FROM_EMAIL = 'myusername@gmail.com' +# EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_BACKEND = "django.core.mail.backends.dummy.EmailBackend" +SERVER_EMAIL = 'myusername@gmail.com' \ No newline at end of file diff --git a/qmra/static/css/styles.css b/qmra/static/css/styles.css new file mode 100644 index 0000000..ed186dc --- /dev/null +++ b/qmra/static/css/styles.css @@ -0,0 +1,198 @@ +:root { + --kwb-blue: rgb(0, 3, 226); + --kwb-lightblue: #B1B2FF; + --primary: var(--kwb-blue); + --bla: #cc0025; + --bla: hsl(349, 100%, 60%); + --bla: hsl(349, 100%, 80%); + --bla: hsl(23, 100%, 62%); + --bla: #FFA570; + --bla: #ED5500; + --bla: #1B6638; + --bla: hsl(144, 39%, 45%); + --bla: hsl(144, 43%, 67%); +} + +.info{ +color:grey; +font-style: italic; +font-size: small; +} + +/* REMOVE STEPS BUTTON ON NUMBER INPUTS: */ +/* Chrome, Safari, Edge, Opera */ +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type=number] { + -moz-appearance: textfield; +} + +.btn-primary, .btn-primary:hover, .btn-primary:active, .btn-primary:visited { + color: white !important; + background-color: var(--kwb-blue) !important; + border-color: var(--kwb-blue) !important; + } + +.btn-outline-primary, .btn-outline-primary:hover, .btn-outline-primary:active, .btn-outline-primary:visited { + color: var(--kwb-blue); + border-color: var(--kwb-blue); +} +.btn-outline-primary:hover { + color: white; + background-color: var(--kwb-blue); + border-color: var(--kwb-blue); +} + +.btn-secondary, .btn-secondary:hover, .btn-secondary:active, .btn-secondary:visited { + color: white !important; + background-color: #B1B2FF !important; + border-color:#B1B2FF !important; + } + +.info{ + color:grey; + font-style: italic; + font-size: small; +} + +.btn-secondary, .btn-secondary:hover, .btn-secondary:active, .btn-secondary:visited { + background-color: #B1B2FF !important; + border-color: #B1B2FF !important; + } + +.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary:visited { + color: #B1B2FF; + border-color: #B1B2FF; +} +.btn-outline-secondary:hover { + color: white; + background-color: #B1B2FF; + border-color: #B1B2FF; +} + +.btn-outline-secondary:focus, .btn-secondary:focus { + box-shadow: 0 0 0 .2rem var(--kwb-blue) !important; +} + +.selected-treatment { + box-shadow: 0 0 0 .2rem var(--kwb-blue) !important; +} + +.custom-select { + padding: .375rem .75rem; +} +.text_link_kwb { color: var(--kwb-blue); } + +.kwb_headline { + color: var(--kwb-blue) !important; + font-weight: bold; + +} +.form-control::placeholder { + color: var(--kwb-blue); +} +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active{ + -webkit-box-shadow: 0 0 0 30px white inset !important; +} +:is(h1, h2, h3, h4, h5, h6) { + color: #0003e2 !important; + font-weight: bold !important; +} + +.info{ + color: #0003e2; +} +.headline{ + color: #0003e2; + font-weight: bold; +} +html, body { + height: 100%; /* Ensure that the html and body elements take up the full height of the viewport */ + margin: 0; /* Remove default margin */ +} + +body { + display: flex; /* Use flexbox to lay out children */ + flex-direction: column; /* Stack children vertically */ +} + +main { + flex: 1; /* Allows the main content to grow and fill available space, pushing the footer down */ + /* Adjust padding or margins as needed */ +} + +footer { + /* Your existing styles should be fine, but ensure it doesn't grow with flex settings */ + background-color: #0003e2 !important;; +} +.footer-item{ + color: white; +} + /* Add this inside your {% block body %} to scope the style to this template, + or move it to your external CSS file for global scope */ + + +.custom-media:hover { + background-color: #EAEAFD; /* Light grey background on hover */ + cursor: pointer; /* Change cursor to indicate clickable */ +} + /* Adjust the border color on hover */ +.custom-media:hover { + border-color: #0003e2; /* Bootstrap primary color for example */ + transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out; +} +.text-field-bg { + background-color: #F2F2FF; +} + +.radio-div-bg { + background-color: #F2F2FF; +} + +/* Optional: Directly style the radio buttons if needed */ +.radio-bg input[type="radio"] { + background-color: #0003e2 ; +} +#div_id_source, #div_id_treatment, #div_id_exposure{ + background-color: #F2F2FF; + padding: 5px; + border-color: #8081F1; + +} +.form-check-input { + color: #8081FF; +} + + /* Custom CSS for table cell content centering */ +.table th, .table td { + text-align: center; /* Center align table cell content */ + vertical-align: middle; /* Center vertically */ + } + /* Custom background color for the table header */ + .custom-header { + background-color: transparent !important; /* Green background, change as needed */ + color: black; /* White text color */ + } + +.max-risk { + background: #FFECF4; + color: #FF0532; +} + +.min-risk { + background: #FFDDB5; + color: #ED5500; +} + +.none-risk { + background: #E2FBAC; + color: #088B3C; +} \ No newline at end of file diff --git a/qmra/static/data/default-exposures.json b/qmra/static/data/default-exposures.json new file mode 100644 index 0000000..261c552 --- /dev/null +++ b/qmra/static/data/default-exposures.json @@ -0,0 +1 @@ +{"irrigation, unrestricted": {"id": 1, "name": "irrigation, unrestricted", "description": "100 g of lettuce leaves hold 10.8 mL water and cucumbers 0.4 mL at worst case (immediately post watering). A serve of lettuce (40 g) might hold 5 mL of recycled water and other produce might hold up to 1 mL per serve. Calculated frequencies are based on Autralian Bureau of Statistics (ABS) data", "events_per_year": 70, "volume_per_event": 0.005, "reference": 48, "ReferenceID": "48"}, "domestic use, car washing": {"id": 2, "name": "domestic use, car washing", "description": "Assumed similar to garden watering estimated to typically occur every second day during dry months (half year). Exposure to aerosols occurs during watering.", "events_per_year": 25, "volume_per_event": 0.0001, "reference": null, "ReferenceID": null}, "irrigation, restricted": {"id": 3, "name": "irrigation, restricted", "description": "Based on unrestricted irrigation, but far less frequent due to restricted access", "events_per_year": 1, "volume_per_event": 0.005, "reference": null, "ReferenceID": null}, "domestic use, toilet flushing": {"id": 4, "name": "domestic use, toilet flushing", "description": "Frequency based on three uses of home toilet per day. Aerosol volumes are less than those produced by garden irrigation.", "events_per_year": 1100, "volume_per_event": 1e-05, "reference": 48, "ReferenceID": "48"}, "drinking water": {"id": 5, "name": "drinking water", "description": "Assumption for ingestion of drinking water", "events_per_year": 365, "volume_per_event": 1.0, "reference": null, "ReferenceID": null}, "irrigation, public": {"id": 7, "name": "irrigation, public", "description": "Frequencies moderate as most people use municipal areas sparingly (estimate 1/2 - 3 weeks). People are unlikely to be directly exposed to large amounts of spray and therefore exposure is from indirect ingestion via contact with lawns, etc. Likely to be higher when used to irrigate facilities such as sports grounds or golf courses (estimate 1/week)\r\n\r\ngrounds and golf courses (estimate 1/week)", "events_per_year": 50, "volume_per_event": 0.001, "reference": 48, "ReferenceID": "48"}, "irrigation, garden": {"id": 8, "name": "irrigation, garden", "description": "Garden watering estimated to typically occur every second day during dry months (half year). Routine exposure results from indirect ingestion via contact with plants, lawns, etc.", "events_per_year": 90, "volume_per_event": 0.001, "reference": 48, "ReferenceID": "48"}, "domestic use, washing machine": {"id": 9, "name": "domestic use, washing machine", "description": "Assumes one member of household exposed. Calculated frequency based on Australian Bureau of Statistics (ABS) data. Aerosol volumes are less than those produced by garden irrigation (machines usually closed during operation).", "events_per_year": 100, "volume_per_event": 1e-05, "reference": 48, "ReferenceID": "48"}} \ No newline at end of file diff --git a/qmra/static/data/default-inflows.json b/qmra/static/data/default-inflows.json new file mode 100644 index 0000000..293f3fe --- /dev/null +++ b/qmra/static/data/default-inflows.json @@ -0,0 +1 @@ +{"sewage, treated": [{"id": 0, "min": 0.1, "max": 1000.0, "reference": 42, "source_name": "sewage, treated", "pathogen_name": "Rotavirus", "ReferenceID": "42"}, {"id": 8, "min": 0.001, "max": 1000.0, "reference": 42, "source_name": "sewage, treated", "pathogen_name": "Campylobacter jejuni", "ReferenceID": "42"}, {"id": 16, "min": 0.01, "max": 10000.0, "reference": 42, "source_name": "sewage, treated", "pathogen_name": "Cryptosporidium parvum", "ReferenceID": "42"}], "surface water, general": [{"id": 1, "min": 0.01, "max": 100.0, "reference": 39, "source_name": "surface water, general", "pathogen_name": "Rotavirus", "ReferenceID": "39"}, {"id": 9, "min": 100.0, "max": 10000.0, "reference": 39, "source_name": "surface water, general", "pathogen_name": "Campylobacter jejuni", "ReferenceID": "39"}, {"id": 17, "min": 0.0, "max": 1000.0, "reference": 39, "source_name": "surface water, general", "pathogen_name": "Cryptosporidium parvum", "ReferenceID": "39"}], "surface water, contaminated": [{"id": 2, "min": 30.0, "max": 60.0, "reference": 43, "source_name": "surface water, contaminated", "pathogen_name": "Rotavirus", "ReferenceID": "43"}, {"id": 10, "min": 90.0, "max": 2500.0, "reference": 43, "source_name": "surface water, contaminated", "pathogen_name": "Campylobacter jejuni", "ReferenceID": "43"}, {"id": 18, "min": 2.0, "max": 480.0, "reference": 43, "source_name": "surface water, contaminated", "pathogen_name": "Cryptosporidium parvum", "ReferenceID": "43"}], "surface water, protected": [{"id": 3, "min": 0.0, "max": 3.0, "reference": 43, "source_name": "surface water, protected", "pathogen_name": "Rotavirus", "ReferenceID": "43"}, {"id": 11, "min": 0.0, "max": 1100.0, "reference": 43, "source_name": "surface water, protected", "pathogen_name": "Campylobacter jejuni", "ReferenceID": "43"}, {"id": 19, "min": 2.0, "max": 240.0, "reference": 43, "source_name": "surface water, protected", "pathogen_name": "Cryptosporidium parvum", "ReferenceID": "43"}], "rainwater, rooftop harvesting": [{"id": 4, "min": 0.0, "max": 0.01, "reference": 44, "source_name": "rainwater, rooftop harvesting", "pathogen_name": "Rotavirus", "ReferenceID": "44"}, {"id": 12, "min": 0.0, "max": 24.0, "reference": 44, "source_name": "rainwater, rooftop harvesting", "pathogen_name": "Campylobacter jejuni", "ReferenceID": "44"}, {"id": 20, "min": 0.0, "max": 0.19, "reference": 44, "source_name": "rainwater, rooftop harvesting", "pathogen_name": "Cryptosporidium parvum", "ReferenceID": "44"}], "rainwater, stormwater harvesting": [{"id": 5, "min": 9.74510658007135, "max": 64.7460691472062, "reference": 45, "source_name": "rainwater, stormwater harvesting", "pathogen_name": "Rotavirus", "ReferenceID": "45"}, {"id": 13, "min": 13.8694279635122, "max": 287.039358509118, "reference": 45, "source_name": "rainwater, stormwater harvesting", "pathogen_name": "Campylobacter jejuni", "ReferenceID": "45"}, {"id": 21, "min": 4.52008261942372e-05, "max": 0.0880751977503127, "reference": 45, "source_name": "rainwater, stormwater harvesting", "pathogen_name": "Cryptosporidium parvum", "ReferenceID": "45"}], "groundwater": [{"id": 7, "min": 0.0, "max": 2.0, "reference": 43, "source_name": "groundwater", "pathogen_name": "Rotavirus", "ReferenceID": "43"}, {"id": 15, "min": 0.0, "max": 10.0, "reference": 43, "source_name": "groundwater", "pathogen_name": "Campylobacter jejuni", "ReferenceID": "43"}, {"id": 23, "min": 0.0, "max": 1.0, "reference": 43, "source_name": "groundwater", "pathogen_name": "Cryptosporidium parvum", "ReferenceID": "43"}], "sewage, raw": [{"id": 6, "min": 50.0, "max": 5000.0, "reference": 39, "source_name": "sewage, raw", "pathogen_name": "Rotavirus", "ReferenceID": "39"}, {"id": 14, "min": 100.0, "max": 1000000.0, "reference": 39, "source_name": "sewage, raw", "pathogen_name": "Campylobacter jejuni", "ReferenceID": "39"}, {"id": 22, "min": 1.0, "max": 10000.0, "reference": 39, "source_name": "sewage, raw", "pathogen_name": "Cryptosporidium parvum", "ReferenceID": "39"}]} \ No newline at end of file diff --git a/qmra/static/data/default-pathogens.json b/qmra/static/data/default-pathogens.json new file mode 100644 index 0000000..68414bd --- /dev/null +++ b/qmra/static/data/default-pathogens.json @@ -0,0 +1 @@ +{"Campylobacter jejuni": {"id": 3, "group": "Bacteria", "name": "Campylobacter jejuni", "best_fit_model": "beta-Poisson", "k": null, "alpha": 0.144, "n50": 890.0, "infection_to_illness": 0.3, "dalys_per_case": 0.0046}, "Rotavirus": {"id": 32, "group": "Viruses", "name": "Rotavirus", "best_fit_model": "beta-Poisson", "k": null, "alpha": 0.253, "n50": 6.17, "infection_to_illness": 0.5, "dalys_per_case": 0.014}, "Cryptosporidium parvum": {"id": 34, "group": "Protozoa", "name": "Cryptosporidium parvum", "best_fit_model": "exponential", "k": 0.0572, "alpha": null, "n50": null, "infection_to_illness": 0.7, "dalys_per_case": 0.0015}} \ No newline at end of file diff --git a/qmra/static/data/default-references.json b/qmra/static/data/default-references.json new file mode 100644 index 0000000..fcb018b --- /dev/null +++ b/qmra/static/data/default-references.json @@ -0,0 +1 @@ +{"1": {"ReferenceID": 1, "ReferenceName": "Adams et al. 1976 & Haggerty and John 1978", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Naegleria_fowleri:_Dose_Response_Models"}, "2": {"ReferenceID": 2, "ReferenceName": "Black et al 1988", "ReferenceLink": "https://qmrawiki.org/experiments/campylobacter-jejuni/108%2B%2B"}, "3": {"ReferenceID": 3, "ReferenceName": "Cliver, 1981", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Enteroviruses:_Dose_Response_Models"}, "4": {"ReferenceID": 4, "ReferenceName": "Cornick & Helgerson (2004)", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Escherichia_coli_enterohemorrhagic_(EHEC):_Dose_Response_Models"}, "5": {"ReferenceID": 5, "ReferenceName": "Couch, Cate et al. 1966", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Adenovirus:_Dose_Response_Models"}, "6": {"ReferenceID": 6, "ReferenceName": "Day and Berendt, 1972", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Francisella_tularensis:_Dose_Response_Models"}, "7": {"ReferenceID": 7, "ReferenceName": "DeDiego et al., 2008 & De Albuquerque et al., 2006", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/SARS:_Dose_Response_Models"}, "8": {"ReferenceID": 8, "ReferenceName": "DEMEAUWARE Deliverable 3.1 (p.18-19): NRMMC-EPHC-AHMC (2006), WHO 2006, Table 3.6)", "ReferenceLink": "https://www.kompetenz-wasser.de/media/pages/forschung/publikationen/843/eb7a40d5d0-1702634140/Seis-2015-843.pdf"}, "9": {"ReferenceID": 9, "ReferenceName": "Druett 1953", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Bacillus_anthracis:_Dose_Response_Models"}, "10": {"ReferenceID": 10, "ReferenceName": "DuPont et al. (1971)", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Escherichia_coli:_Dose_Response_Models"}, "11": {"ReferenceID": 11, "ReferenceName": "DuPont et al. (1972b)", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Shigella:_Dose_Response_Models"}, "12": {"ReferenceID": 12, "ReferenceName": "Golnazarian", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Listeria_monocytogenes_(Infection):_Dose_Response_Models"}, "13": {"ReferenceID": 13, "ReferenceName": "Golnazarian, Donnelly et al. 1989", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Listeria_monocytogenes_(Death_as_response):_Dose_Response_Models"}, "14": {"ReferenceID": 14, "ReferenceName": "Hazlett, Rosen et al. 1978", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Pseudomonas_aeruginosa_(bacterimia):_Dose_Response_Models"}, "15": {"ReferenceID": 15, "ReferenceName": "Hendley et al., 1972", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Rhinovirus:_Dose_Response_Models"}, "16": {"ReferenceID": 16, "ReferenceName": "Hornick et al. (1966),Hornick et al. (1970)", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Salmonella_Typhi:_Dose_Response_Models"}, "17": {"ReferenceID": 17, "ReferenceName": "Hornick et al., (1971)", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Vibrio_cholerae:_Dose_Response_Models"}, "18": {"ReferenceID": 18, "ReferenceName": "Jahrling et al., 1982", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Lassa_virus:_Dose_Response_Models"}, "19": {"ReferenceID": 19, "ReferenceName": "Koprowski", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Poliovirus:_Dose_Response_Models"}, "20": {"ReferenceID": 20, "ReferenceName": "Lathem et al. 2005", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Yersinia_pestis:_Dose_Response_Models"}, "21": {"ReferenceID": 21, "ReferenceName": "Lawin-Brussel et al. (1993)", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Pseudomonas_aeruginosa_(Contact_lens):_Dose_Response_Models"}, "22": {"ReferenceID": 22, "ReferenceName": "Liu, Koo et al. 2002 and Brett and Woods 1996", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Burkholderia_pseudomallei:_Dose_Response_Models"}, "23": {"ReferenceID": 23, "ReferenceName": "McCullough and Eisele 1951,2", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Salmonella_meleagridis:_Dose_Response_Models"}, "24": {"ReferenceID": 24, "ReferenceName": "McCullough and Elsele,1951", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Salmonella_anatum:_Dose_Response_Models"}, "25": {"ReferenceID": 25, "ReferenceName": "McCullough and Elsele,1951", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Salmonella_serotype_newport:_Dose_Response_Models"}, "26": {"ReferenceID": 26, "ReferenceName": "Messner et al. 2001", "ReferenceLink": "https://qmrawiki.org/experiments/cryptosporidium-parvum"}, "27": {"ReferenceID": 27, "ReferenceName": "Meynell and Meynell,1958", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Salmonella_nontyphoid:_Dose_Response_Models"}, "28": {"ReferenceID": 28, "ReferenceName": "Muller et al. (1983)", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Legionella_pneumophila:_Dose_Response_Models"}, "29": {"ReferenceID": 29, "ReferenceName": "Murphy et al., 1984 & Murphy et al., 1985", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Influenza:_Dose_Response_Models"}, "30": {"ReferenceID": 30, "ReferenceName": "O'Brien et al(1976)", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Mycobacterium_avium:_Dose_Response_Models"}, "31": {"ReferenceID": 31, "ReferenceName": "Rendtorff 1954", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Endamoeba_coli:_Dose_Response_Models"}, "32": {"ReferenceID": 32, "ReferenceName": "Rendtorff 1954", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Giardia_duodenalis:_Dose_Response_Models"}, "33": {"ReferenceID": 33, "ReferenceName": "Rose and Haas 1999", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Staphylococcus_aureus:_Dose_Response_Models"}, "34": {"ReferenceID": 34, "ReferenceName": "Saslaw and Carlisle 1966 and Dupont, Hornick et al. 1973", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Rickettsia_rickettsi:_Dose_Response_Models"}, "35": {"ReferenceID": 35, "ReferenceName": "Schiff et al.,1984", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Echovirus:_Dose_Response_Models"}, "36": {"ReferenceID": 36, "ReferenceName": "Smith, Williams2007", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Listeria_monocytogenes_(Stillbirths):_Dose_Response_Models"}, "37": {"ReferenceID": 37, "ReferenceName": "Ward et al, 1986", "ReferenceLink": "https://qmrawiki.org/experiments/rotavirus"}, "38": {"ReferenceID": 38, "ReferenceName": "WHO (2011): Drinking water guideline, Table 7.4", "ReferenceLink": "http://apps.who.int/iris/bitstream/10665/44584/1/9789241548151_eng.pdf#page=155"}, "39": {"ReferenceID": 39, "ReferenceName": "WHO (2011): Drinking water guideline, Table 7.6", "ReferenceLink": "http://apps.who.int/iris/bitstream/10665/44584/1/9789241548151_eng.pdf#page=159"}, "40": {"ReferenceID": 40, "ReferenceName": "WHO (2011): Drinking water guideline, Table 7.7", "ReferenceLink": "http://apps.who.int/iris/bitstream/10665/44584/1/9789241548151_eng.pdf#page=162"}, "41": {"ReferenceID": 41, "ReferenceName": "Williams et al, 1982", "ReferenceLink": "http://qmrawiki.canr.msu.edu/index.php/Coxiella_burnetii:_Dose_Response_Models"}, "42": {"ReferenceID": 42, "ReferenceName": "WHO (2006) safe use wastewater V2", "ReferenceLink": "https://www.who.int/publications/i/item/9241546832"}, "43": {"ReferenceID": 43, "ReferenceName": "WHO GDWQ (2004)", "ReferenceLink": "https://www.who.int/publications/i/item/9789241549950"}, "44": {"ReferenceID": 44, "ReferenceName": "KWR 2016.081", "ReferenceLink": "https://library.kwrwater.nl/publication/54026237/"}, "45": {"ReferenceID": 45, "ReferenceName": "Sales Ortells 2015", "ReferenceLink": "https://repository.tudelft.nl/islandora/object/uuid:0e41d07b-9f44-4220-aaac-e22c73c5074a?collection=research"}, "46": {"ReferenceID": 46, "ReferenceName": "MICRORISK final report chapter 4 Table 4.11", "ReferenceLink": "https://www.kwrwater.nl/wp-content/uploads/2016/09/MICRORISK-FINAL-REPORT-Quantitative-microbial-risk-assessment-in-the-Water-Safety-Plan.pdf"}, "47": {"ReferenceID": 47, "ReferenceName": "NSF/ANSI 419 validation", "ReferenceLink": "http://info.nsf.org/Certified/pdwe/Listings.asp"}, "48": {"ReferenceID": 48, "ReferenceName": "EPHC, NRMMC, AHMC (2006)", "ReferenceLink": "https://www.susana.org/en/knowledge-hub/resources-and-publications/library/details/1533"}, "49": {"ReferenceID": 49, "ReferenceName": "WHO 2017", "ReferenceLink": "https://www.who.int/water_sanitation_health/publications/drinking-water-quality-guidelines-4-including-1st-addendum/en/"}, "50": {"ReferenceID": 50, "ReferenceName": "Hijnen et al. (2006)", "ReferenceLink": "https://doi.org/10.1016/j.watres.2005.10.030"}, "51": {"ReferenceID": 51, "ReferenceName": "Australian Guidelines for Water Recycling: Managing Health and Environmental Risks. 2020 Draft of Chapters 1, 2, 3 and 5 and Appendices 2 and 3", "ReferenceLink": "https://qldwater.com.au/public/Australian%20Guidelines%20for%20Water%20Recycling%20Consultation%20Draft%20Revised.docx"}, "52": {"ReferenceID": 52, "ReferenceName": "FlexTreat Abschlussbericht", "ReferenceLink": "https://kompetenz-wasser.de/media/pages/forschung/publikationen/flexible-und-zuverlaessige-konzepte-fuer-eine-nachhaltige-wasserwieder-verwendung-in-der-landwirtschaft-abschlussbericht/8e2b0750bd-1751274868/20250429_flextreat_abschlussbericht.pdf"}, "53": {"ReferenceID": 53, "ReferenceName": "Pecson et al., 2017", "ReferenceLink": "https://www.sciencedirect.com/science/article/pii/S0043135417304888"}} \ No newline at end of file diff --git a/qmra/static/data/default-sources.json b/qmra/static/data/default-sources.json new file mode 100644 index 0000000..a480919 --- /dev/null +++ b/qmra/static/data/default-sources.json @@ -0,0 +1 @@ +{"sewage, treated": {"id": 1, "name": "sewage, treated", "description": "Municipal sewage that has received secondary, so including activated sludge"}, "surface water, general": {"id": 2, "name": "surface water, general", "description": "Rivers, lakes, ponds. Select this categogy if no indication of the level of contamination is known"}, "surface water, contaminated": {"id": 3, "name": "surface water, contaminated", "description": "Rivers, lakes, ponds that are prone to discharge of treated or untreated wastewater or other sources of fecal contamination are known (e.g. cattle accessing the water, runoff from agricultural land)"}, "surface water, protected": {"id": 4, "name": "surface water, protected", "description": "Rivers, lakes, ponds where the absence of pollution is actively managed, e.g. in protcted catchments"}, "rainwater, rooftop harvesting": {"id": 5, "name": "rainwater, rooftop harvesting", "description": "Rainwater collected from rooftops or other surface areas which are kept relatively clean"}, "rainwater, stormwater harvesting": {"id": 6, "name": "rainwater, stormwater harvesting", "description": "Rainwater collected from roads or other surfaces which are likely to be contaminated"}, "groundwater": {"id": 7, "name": "groundwater", "description": "Groundwater, especially shallow wells, may be contaminated from ingress from the surface (manure, latrines) or contamination through the well head"}, "sewage, raw": {"id": 8, "name": "sewage, raw", "description": "Municipal sewage that has not received any treatment or only minimal treatment e.g. sedimentation"}} \ No newline at end of file diff --git a/qmra/static/data/default-treatments.json b/qmra/static/data/default-treatments.json new file mode 100644 index 0000000..2d1d5b2 --- /dev/null +++ b/qmra/static/data/default-treatments.json @@ -0,0 +1 @@ +{"Coagulation, flocculation and sedimentation": {"id": 1, "name": "Coagulation, flocculation and sedimentation", "group": "Clarification", "description": "Consists of coagulant and/or flocculant aid (e.g. polymer) dosing, rapid mixing, slow mixing and sedimentation. Log removal depends on process optimisation. Rapid changes in source water quality such as turbidity increase due to monsoon rainfall or algeal blooms may decrease treatment effect and require adjustment of process settings.", "bacteria_min": 0.2, "bacteria_max": 2.0, "viruses_min": 0.1, "viruses_max": 2.0, "protozoa_min": 1.0, "protozoa_max": 2.0, "bacteria_references": ["40"], "viruses_references": ["40"], "protozoa_references": ["40"]}, "Slow sand filtration": {"id": 8, "name": "Slow sand filtration", "group": "Filtration", "description": "Water is filtered through a fixed bed sand operatied down flow with rates of 0.1 to 1 m/h and contact times of 3 to 6 hours. The filter is not backwashed. In weeks to months a 'schmutzdecke' will develop on the filter which enhances log removal. Grain size, flow rate and temperature also affect log removal. Consistent low filtered water turbidity of ? 0.3 NTU (none to exceed 1 NTU) are associated higher log removal of pathogens\r\n\r\nassociated with 1 - 2 log reduction of viruses and 2.5 - 3 log reduction of Cryptosporidiuma", "bacteria_min": 2.0, "bacteria_max": 6.0, "viruses_min": 0.25, "viruses_max": 4.0, "protozoa_min": 0.3, "protozoa_max": 5.0, "bacteria_references": ["40"], "viruses_references": ["40"], "protozoa_references": ["40"]}, "Bank filtration": {"id": 9, "name": "Bank filtration", "group": "Pretreatment", "description": "Water is abstracted through wells located close to surface water, thus the bank serves as a natural filter. Log removal depends on travel distance and time, soil type (grain size),\r\n and geochemicl conditions (oxygen level, pH)", "bacteria_min": 2.0, "bacteria_max": 6.0, "viruses_min": 2.0, "viruses_max": 6.0, "protozoa_min": 1.0, "protozoa_max": 6.0, "bacteria_references": ["51"], "viruses_references": ["51"], "protozoa_references": ["51"]}, "UV disinfection 20 mJ/cm2, drinking": {"id": 15, "name": "UV disinfection 20 mJ/cm2, drinking", "group": "Primary disinfection", "description": "UV-light is mostly effective at 254 nm where it affects DNA or RNA thus preventing reproduction of the organism (inactivation). Log reduction for drinking water UV is based on closed UV-reactors wich have been validated according to appropriate standards (e.g. USEPA or DVGW). Effectiveness of disinfection depends on delivered fluence (dose in mJ/cm2), which varies with lamp intensity, exposure time (flow rate) and UV-absorption by the water (organics). Excessive turbidity and certain dissolved species inhibit this process; hence, turbidity should be kept below 1 NTU to support effective disinfection.", "bacteria_min": 4.6, "bacteria_max": 6.0, "viruses_min": 2.0, "viruses_max": 3.1, "protozoa_min": 2.4, "protozoa_max": 3.0, "bacteria_references": ["50"], "viruses_references": ["50"], "protozoa_references": ["50"]}, "Primary treatment": {"id": 16, "name": "Primary treatment", "group": "Pretreatment", "description": "Primary treatment consists of temporarily holding the sewage in a quiescent basin where heavy solids can settle to the bottom while oil, grease and lighter solids float to the surface. The settled and floating materials are removed and the remaining liquid may be discharged or subjected to secondary treatment", "bacteria_min": 0.0, "bacteria_max": 0.5, "viruses_min": 0.0, "viruses_max": 0.1, "protozoa_min": 0.0, "protozoa_max": 1.0, "bacteria_references": ["8"], "viruses_references": ["8"], "protozoa_references": ["8"]}, "Secondary treatment": {"id": 17, "name": "Secondary treatment", "group": "Pretreatment", "description": "Secondary treatment consists of an activated sludge process to break down organics in the wastewater and a settling stage to separate the biologiscal sludge from the water.", "bacteria_min": 1.0, "bacteria_max": 2.0, "viruses_min": 0.5, "viruses_max": 2.0, "protozoa_min": 0.5, "protozoa_max": 2.0, "bacteria_references": ["51"], "viruses_references": ["51"], "protozoa_references": ["51"]}, "Dual media filtration": {"id": 18, "name": "Dual media filtration", "group": "Filtration", "description": "Water is filtered through a fixed bed consisting of two layers of granular media (e.g. antracite and sand) generally operatied down flow with rates of 5 to 20 m/h and contact times of 4 to 15 minutes. They are regularly backwashed to remove built up solids in the filter. Log removal depends on filter media and coagulation pretreatment;consistent low filtered water turbidity of ? 0.3 NTU (none to exceed 1 NTU)\r\n are associated higher log removal of pathogens", "bacteria_min": 0.0, "bacteria_max": 1.0, "viruses_min": 0.5, "viruses_max": 2.0, "protozoa_min": 1.5, "protozoa_max": 2.5, "bacteria_references": ["8"], "viruses_references": ["8"], "protozoa_references": ["8"]}, "Reverse osmosis": {"id": 21, "name": "Reverse osmosis", "group": "Filtration", "description": "A reverse osmosis membrane is a thin sheet with small openings that removes solids and most soluble molecules, including salts (< 0,004 \u043f\u0457\u0405m depending on selected membrane) from the water when this is led through the membrane. It can take the form of spiral wound membranes, hollow fibers or sheets. Actual log reduction depends on the selected membrane and is determined by challenge testing.", "bacteria_min": 5.0, "bacteria_max": 6.0, "viruses_min": 5.0, "viruses_max": 6.0, "protozoa_min": 5.0, "protozoa_max": 6.0, "bacteria_references": ["47"], "viruses_references": ["47"], "protozoa_references": ["47"]}, "Wetlands, surface flow": {"id": 23, "name": "Wetlands, surface flow", "group": "Wetlands", "description": "An artificial wetland to treat municipal or industrial wastewater, greywater or stormwater runoff by a combination of sedimentation and biological processes including plants. Effect depends on design and climate, especially les log reduction at lower temperatures.", "bacteria_min": 1.5, "bacteria_max": 2.5, "viruses_min": null, "viruses_max": null, "protozoa_min": 0.5, "protozoa_max": 1.5, "bacteria_references": ["8"], "viruses_references": [], "protozoa_references": ["8"]}, "Wetlands, subsurface flow": {"id": 24, "name": "Wetlands, subsurface flow", "group": "Wetlands", "description": "An artificial wetland to treat municipal or industrial wastewater, greywater or stormwater runoff by a combination of sedimentation, filtration and biological processes including plants. Effect depends on design, soil/filter media and climate, especially les log reduction at lower temperatures.", "bacteria_min": 0.5, "bacteria_max": 3.0, "viruses_min": null, "viruses_max": null, "protozoa_min": 0.5, "protozoa_max": 2.0, "bacteria_references": ["8"], "viruses_references": [], "protozoa_references": ["8"]}, "UV disinfection, wastewater": {"id": 25, "name": "UV disinfection, wastewater", "group": "Primary disinfection", "description": "UV-light is mostly effective at 254 nm where it affects DNA or RNA thus preventing reproduction of the organism (inactivation). Effectiveness of disinfection depends on delivered fluence (dose in mJ/cm2), which varies with lamp intensity, exposure time (flow rate) and UV-absorption by the water (organics). Wastewater UV-reactors are generally open-channel reactors in which UV lamps are placed. Excessive turbidity and certain dissolved species inhibit this process; hence the effect in wastewater highly depends on the water quality an is generally lower than in drinking water at the same dose.", "bacteria_min": 2.0, "bacteria_max": 4.0, "viruses_min": 1.0, "viruses_max": 3.0, "protozoa_min": 3.0, "protozoa_max": 3.0, "bacteria_references": ["8"], "viruses_references": ["8"], "protozoa_references": ["8"]}, "Microfiltration": {"id": 26, "name": "Microfiltration", "group": "Filtration", "description": "A microfiltration membrane is a thin sheet with small openings that removes solids (0.1-10 \u043f\u0457\u0405m depending on selected membrane) from the water when this is led through the membrane. It can take the form of capilary tubes, hollow fibers or sheet membranes. Actual log reduction depends on the selected membrane and is determined by challenge testing.", "bacteria_min": 0.0, "bacteria_max": 4.3, "viruses_min": 0.0, "viruses_max": 3.7, "protozoa_min": 2.3, "protozoa_max": 6.0, "bacteria_references": ["46"], "viruses_references": ["46"], "protozoa_references": ["46"]}, "Ultrafiltration (module certification)": {"id": 27, "name": "Ultrafiltration (module certification)", "group": "Filtration", "description": "An ultrafiltration membrane is a thin sheet with small openings that removes solids (0.005-0,2 \u043f\u0457\u0405m depending on selected membrane) from the water when this is led through the membrane. It can take the form of capilary tubes, hollow fibers, spiral wound or sheet membranes. Actual log reduction depends on the selected membrane and is determined by challenge testing.", "bacteria_min": 5.5, "bacteria_max": 6.0, "viruses_min": 1.1, "viruses_max": 5.5, "protozoa_min": 0.8, "protozoa_max": 6.0, "bacteria_references": ["47"], "viruses_references": ["47"], "protozoa_references": ["47"]}, "Nanofiltration": {"id": 28, "name": "Nanofiltration", "group": "Filtration", "description": "An nanofiltration membrane is a thin sheet with small openings that removes solids and larger soluble molecules (0.001-0,03 \u043f\u0457\u0405m depending on selected membrane) from the water when this is led through the membrane. It can take the form of spiral wound or hollow fiber membranes. Actual log reduction depends on the selected membrane and is determined by challenge testing.", "bacteria_min": 5.0, "bacteria_max": 6.0, "viruses_min": 5.0, "viruses_max": 6.0, "protozoa_min": 5.0, "protozoa_max": 6.0, "bacteria_references": ["47"], "viruses_references": ["47"], "protozoa_references": ["47"]}, "UV disinfection 40 mJ/cm2, drinking": {"id": 29, "name": "UV disinfection 40 mJ/cm2, drinking", "group": "Primary disinfection", "description": "UV-light is mostly effective at 254 nm where it affects DNA or RNA thus preventing reproduction of the organism (inactivation). Log reduction for drinking water UV is based on closed UV-reactors wich have been validated according to appropriate standards (e.g. USEPA or DVGW). Effectiveness of disinfection depends on delivered fluence (dose in mJ/cm2), which varies with lamp intensity, exposure time (flow rate) and UV-absorption by the water (organics). Excessive turbidity and certain dissolved species inhibit this process; hence, turbidity should be kept below 1 NTU to support effective disinfection.", "bacteria_min": 4.6, "bacteria_max": 6.0, "viruses_min": 4.1, "viruses_max": 5.9, "protozoa_min": 2.5, "protozoa_max": 3.0, "bacteria_references": ["50"], "viruses_references": ["50"], "protozoa_references": ["50"]}, "Soil aquifer treatment": {"id": 30, "name": "Soil aquifer treatment", "group": "Pretreatment", "description": "", "bacteria_min": 0.0, "bacteria_max": 6.0, "viruses_min": 0.0, "viruses_max": 6.0, "protozoa_min": 0.0, "protozoa_max": 6.0, "bacteria_references": ["51"], "viruses_references": ["51"], "protozoa_references": ["51"]}, "Chlorination": {"id": 31, "name": "Chlorination", "group": "Disinfection", "description": "", "bacteria_min": 2.0, "bacteria_max": 6.0, "viruses_min": 2.0, "viruses_max": 6.0, "protozoa_min": 0.0, "protozoa_max": 2.0, "bacteria_references": ["51"], "viruses_references": ["51"], "protozoa_references": ["51"]}, "Coagulation, flocculation and media filtration": {"id": 32, "name": "Coagulation, flocculation and media filtration", "group": "Clarification", "description": "", "bacteria_min": 1.0, "bacteria_max": 4.0, "viruses_min": 1.0, "viruses_max": 2.0, "protozoa_min": 2.5, "protozoa_max": 4.0, "bacteria_references": ["51"], "viruses_references": ["51"], "protozoa_references": ["51"]}, "Reverse Osmosis (RO, Australian Guidelines)": {"id": 33, "name": "Reverse Osmosis (RO, Australian Guidelines)", "group": "Filtration", "description": "", "bacteria_min": 1.5, "bacteria_max": 6.0, "viruses_min": 1.5, "viruses_max": 6.0, "protozoa_min": 1.5, "protozoa_max": 6.0, "bacteria_references": ["51"], "viruses_references": ["51"], "protozoa_references": ["51"]}, "Ozonation for disinfection >1mgO3/mgDOC": {"id": 34, "name": "Ozonation for disinfection >1mgO3/mgDOC", "group": "Disinfectionxxx", "description": "", "bacteria_min": 2.0, "bacteria_max": 4.0, "viruses_min": 2.0, "viruses_max": 4.0, "protozoa_min": 2.0, "protozoa_max": 3.0, "bacteria_references": ["40"], "viruses_references": ["40"], "protozoa_references": ["40"]}, "Ozonation for organic micropollutant removal (0.4-0.6 mgO3/mgDOC)": {"id": 35, "name": "Ozonation for organic micropollutant removal (0.4-0.6 mgO3/mgDOC)", "group": "Organic micropollutant removal", "description": "", "bacteria_min": 1.0, "bacteria_max": 3.0, "viruses_min": 1.0, "viruses_max": 2.5, "protozoa_min": 0.0, "protozoa_max": 1.0, "bacteria_references": ["52"], "viruses_references": ["52"], "protozoa_references": ["52"]}, "UV AOP": {"id": 36, "name": "UV AOP", "group": "Disinfection", "description": "", "bacteria_min": 6.0, "bacteria_max": 9.0, "viruses_min": 6.0, "viruses_max": 6.0, "protozoa_min": 6.0, "protozoa_max": 6.0, "bacteria_references": ["48", "53"], "viruses_references": ["49", "53"], "protozoa_references": ["53"]}} \ No newline at end of file diff --git a/tools/qmratool/static/qmratool/favicon/browserconfig.xml b/qmra/static/favicon/browserconfig.xml similarity index 70% rename from tools/qmratool/static/qmratool/favicon/browserconfig.xml rename to qmra/static/favicon/browserconfig.xml index cf22df6..537e5ab 100644 --- a/tools/qmratool/static/qmratool/favicon/browserconfig.xml +++ b/qmra/static/favicon/browserconfig.xml @@ -2,7 +2,7 @@ - + #da532c diff --git a/qmra/static/favicon/favicon.svg b/qmra/static/favicon/favicon.svg new file mode 100644 index 0000000..18afa5e --- /dev/null +++ b/qmra/static/favicon/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/qmratool/static/qmratool/favicon/site.webmanifest b/qmra/static/favicon/site.webmanifest similarity index 100% rename from tools/qmratool/static/qmratool/favicon/site.webmanifest rename to qmra/static/favicon/site.webmanifest diff --git a/qmra/static/img/AquaNES.png b/qmra/static/img/AquaNES.png new file mode 100644 index 0000000..a4c2ea1 Binary files /dev/null and b/qmra/static/img/AquaNES.png differ diff --git a/qmra/static/img/close.svg b/qmra/static/img/close.svg new file mode 100644 index 0000000..1d283f5 --- /dev/null +++ b/qmra/static/img/close.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/qmra/static/img/download.svg b/qmra/static/img/download.svg new file mode 100644 index 0000000..6d472b3 --- /dev/null +++ b/qmra/static/img/download.svg @@ -0,0 +1,3 @@ + diff --git a/qmra/static/img/go-back.svg b/qmra/static/img/go-back.svg new file mode 100644 index 0000000..6bec12f --- /dev/null +++ b/qmra/static/img/go-back.svg @@ -0,0 +1,6 @@ + + + + + diff --git a/qmra/static/img/interreg.png b/qmra/static/img/interreg.png new file mode 100644 index 0000000..a22e98f Binary files /dev/null and b/qmra/static/img/interreg.png differ diff --git a/qmra/static/img/nextGen.png b/qmra/static/img/nextGen.png new file mode 100644 index 0000000..c005246 Binary files /dev/null and b/qmra/static/img/nextGen.png differ diff --git a/qmra/static/img/pen.svg b/qmra/static/img/pen.svg new file mode 100644 index 0000000..31fad30 --- /dev/null +++ b/qmra/static/img/pen.svg @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/qmra/static/img/qmra-full-logo.svg b/qmra/static/img/qmra-full-logo.svg new file mode 100644 index 0000000..7a9b195 --- /dev/null +++ b/qmra/static/img/qmra-full-logo.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/qmra/static/img/qmra-small-logo.svg b/qmra/static/img/qmra-small-logo.svg new file mode 100644 index 0000000..b1bcdad --- /dev/null +++ b/qmra/static/img/qmra-small-logo.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/qmra/static/img/ra.svg b/qmra/static/img/ra.svg new file mode 100644 index 0000000..da37e64 --- /dev/null +++ b/qmra/static/img/ra.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/qmra/static/img/ultimate.png b/qmra/static/img/ultimate.png new file mode 100644 index 0000000..a003adb Binary files /dev/null and b/qmra/static/img/ultimate.png differ diff --git a/qmra/templates/DSGVO.html b/qmra/templates/DSGVO.html new file mode 100644 index 0000000..b92f753 --- /dev/null +++ b/qmra/templates/DSGVO.html @@ -0,0 +1,47 @@ + + + + + + + + + + + {% if locale == "de" %} +
+ See English version + {% include "dsg-qmra-de.html" %} +
+ {% elif locale == "en" %} + See German version +
+ {% include "dsg-qmra-en.html" %} +
+ {% endif %} + + \ No newline at end of file diff --git a/qmra/templates/dsg-qmra-de.html b/qmra/templates/dsg-qmra-de.html new file mode 100644 index 0000000..f8a20c3 --- /dev/null +++ b/qmra/templates/dsg-qmra-de.html @@ -0,0 +1,53 @@ +

Datenschutzerklärung

+

Präambel

+

Mit der folgenden Datenschutzerklärung möchten wir Sie darüber aufklären, welche Arten Ihrer personenbezogenen Daten (nachfolgend auch kurz als "Daten" bezeichnet) wir zu welchen Zwecken und in welchem Umfang im Rahmen der Bereitstellung unserer Applikation verarbeiten.

+

Die verwendeten Begriffe sind nicht geschlechtsspezifisch.

+ +

Stand: 15. Dezember 2025

+

Rechtstext von Dr. Schwenke - für weitere Informationen bitte anklicken.

Inhaltsübersicht

Verantwortlicher

Tobias Evel
KWB Kompetenzzentrum Wasser Berlin gGmbH
Grunewaldstraße 61-62
10825 Berlin

+

Vertretungsberechtigte Personen: Dr. Pascale Rouault (Geschäftsführerin)

+

E-Mail-Adresse: datenschutz@kompetenz-wasser.de

+

Impressum: https://www.kompetenz-wasser.de/de/impressum

+ +

Kontakt Datenschutzbeauftragter

datenschutz@kompetenz-wasser.de

+ +

Übersicht der Verarbeitungen

Die nachfolgende Übersicht fasst die Arten der verarbeiteten Daten und die Zwecke ihrer Verarbeitung zusammen und verweist auf die betroffenen Personen.

Arten der verarbeiteten Daten

+
  • Bestandsdaten.
  • Kontaktdaten.
  • Inhaltsdaten.
  • Nutzungsdaten.
  • Meta-, Kommunikations- und Verfahrensdaten.
  • Protokolldaten.

Kategorien betroffener Personen

  • Nutzer.

Zwecke der Verarbeitung

  • Erbringung vertraglicher Leistungen und Erfüllung vertraglicher Pflichten.
  • Sicherheitsmaßnahmen.
  • Organisations- und Verwaltungsverfahren.
  • Bereitstellung unseres Onlineangebotes und Nutzerfreundlichkeit.
  • Informationstechnische Infrastruktur.

Maßgebliche Rechtsgrundlagen

Maßgebliche Rechtsgrundlagen nach der DSGVO: Im Folgenden erhalten Sie eine Übersicht der Rechtsgrundlagen der DSGVO, auf deren Basis wir personenbezogene Daten verarbeiten. Bitte nehmen Sie zur Kenntnis, dass neben den Regelungen der DSGVO nationale Datenschutzvorgaben in Ihrem bzw. unserem Wohn- oder Sitzland gelten können. Sollten ferner im Einzelfall speziellere Rechtsgrundlagen maßgeblich sein, teilen wir Ihnen diese in der Datenschutzerklärung mit.

+
  • Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO) - Die Verarbeitung ist für die Erfüllung eines Vertrags, dessen Vertragspartei die betroffene Person ist, oder zur Durchführung vorvertraglicher Maßnahmen erforderlich, die auf Anfrage der betroffenen Person erfolgen.
  • Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO) - die Verarbeitung ist zur Wahrung der berechtigten Interessen des Verantwortlichen oder eines Dritten notwendig, vorausgesetzt, dass die Interessen, Grundrechte und Grundfreiheiten der betroffenen Person, die den Schutz personenbezogener Daten verlangen, nicht überwiegen.

Nationale Datenschutzregelungen in Deutschland: Zusätzlich zu den Datenschutzregelungen der DSGVO gelten nationale Regelungen zum Datenschutz in Deutschland. Hierzu gehört insbesondere das Gesetz zum Schutz vor Missbrauch personenbezogener Daten bei der Datenverarbeitung (Bundesdatenschutzgesetz – BDSG). Das BDSG enthält insbesondere Spezialregelungen zum Recht auf Auskunft, zum Recht auf Löschung, zum Widerspruchsrecht, zur Verarbeitung besonderer Kategorien personenbezogener Daten, zur Verarbeitung für andere Zwecke und zur Übermittlung sowie automatisierten Entscheidungsfindung im Einzelfall einschließlich Profiling. Ferner können Landesdatenschutzgesetze der einzelnen Bundesländer zur Anwendung gelangen.

+

Maßgebliche Rechtsgrundlagen nach dem Schweizer Datenschutzgesetz: Wenn Sie sich in der Schweiz befinden, bearbeiten wir Ihre Daten auf Grundlage des Bundesgesetzes über den Datenschutz (kurz „Schweizer DSG"). Anders als beispielsweise die DSGVO sieht das Schweizer DSG grundsätzlich nicht vor, dass eine Rechtsgrundlage für die Bearbeitung der Personendaten genannt werden muss und die Bearbeitung von Personendaten nach Treu und Glauben durchgeführt wird, rechtmäßig und verhältnismäßig ist (Art. 6 Abs. 1 und 2 des Schweizer DSG). Zudem werden Personendaten von uns nur zu einem bestimmten, für die betroffene Person erkennbaren Zweck beschafft und nur so bearbeitet, wie es mit diesem Zweck vereinbar ist (Art. 6 Abs. 3 des Schweizer DSG).

+

Hinweis auf Geltung DSGVO und Schweizer DSG: Diese Datenschutzhinweise dienen sowohl der Informationserteilung nach dem Schweizer DSG als auch nach der Datenschutzgrundverordnung (DSGVO). Aus diesem Grund bitten wir Sie zu beachten, dass aufgrund der breiteren räumlichen Anwendung und Verständlichkeit die Begriffe der DSGVO verwendet werden. Insbesondere statt der im Schweizer DSG verwendeten Begriffe „Bearbeitung" von „Personendaten", "überwiegendes Interesse" und "besonders schützenswerte Personendaten" werden die in der DSGVO verwendeten Begriffe „Verarbeitung" von „personenbezogenen Daten" sowie "berechtigtes Interesse" und "besondere Kategorien von Daten" verwendet. Die gesetzliche Bedeutung der Begriffe wird jedoch im Rahmen der Geltung des Schweizer DSG weiterhin nach dem Schweizer DSG bestimmt.

+ +

Sicherheitsmaßnahmen

Wir treffen nach Maßgabe der gesetzlichen Vorgaben unter Berücksichtigung des Stands der Technik, der Implementierungskosten und der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung sowie der unterschiedlichen Eintrittswahrscheinlichkeiten und des Ausmaßes der Bedrohung der Rechte und Freiheiten natürlicher Personen geeignete technische und organisatorische Maßnahmen, um ein dem Risiko angemessenes Schutzniveau zu gewährleisten.

+

Zu den Maßnahmen gehören insbesondere die Sicherung der Vertraulichkeit, Integrität und Verfügbarkeit von Daten durch Kontrolle des physischen und elektronischen Zugangs zu den Daten als auch des sie betreffenden Zugriffs, der Eingabe, der Weitergabe, der Sicherung der Verfügbarkeit und ihrer Trennung. Des Weiteren haben wir Verfahren eingerichtet, die eine Wahrnehmung von Betroffenenrechten, die Löschung von Daten und Reaktionen auf die Gefährdung der Daten gewährleisten. Ferner berücksichtigen wir den Schutz personenbezogener Daten bereits bei der Entwicklung bzw. Auswahl von Hardware, Software sowie Verfahren entsprechend dem Prinzip des Datenschutzes, durch Technikgestaltung und durch datenschutzfreundliche Voreinstellungen.

+

Sicherung von Online-Verbindungen durch TLS-/SSL-Verschlüsselungstechnologie (HTTPS): Um die Daten der Nutzer, die über unsere Online-Dienste übertragen werden, vor unerlaubten Zugriffen zu schützen, setzen wir auf die TLS-/SSL-Verschlüsselungstechnologie. Secure Sockets Layer (SSL) und Transport Layer Security (TLS) sind die Eckpfeiler der sicheren Datenübertragung im Internet. Diese Technologien verschlüsseln die Informationen, die zwischen der Website oder App und dem Browser des Nutzers (oder zwischen zwei Servern) übertragen werden, wodurch die Daten vor unbefugtem Zugriff geschützt sind. TLS, als die weiterentwickelte und sicherere Version von SSL, gewährleistet, dass alle Datenübertragungen den höchsten Sicherheitsstandards entsprechen. Wenn eine Website durch ein SSL-/TLS-Zertifikat gesichert ist, wird dies durch die Anzeige von HTTPS in der URL signalisiert. Dies dient als ein Indikator für die Nutzer, dass ihre Daten sicher und verschlüsselt übertragen werden.

+ +

Allgemeine Informationen zur Datenspeicherung und Löschung

Wir löschen personenbezogene Daten, die wir verarbeiten, gemäß den gesetzlichen Bestimmungen, sobald die zugrundeliegenden Einwilligungen widerrufen werden oder keine weiteren rechtlichen Grundlagen für die Verarbeitung bestehen. Dies betrifft Fälle, in denen der ursprüngliche Verarbeitungszweck entfällt oder die Daten nicht mehr benötigt werden. Ausnahmen von dieser Regelung bestehen, wenn gesetzliche Pflichten oder besondere Interessen eine längere Aufbewahrung oder Archivierung der Daten erfordern.

+

Insbesondere müssen Daten, die aus handels- oder steuerrechtlichen Gründen aufbewahrt werden müssen oder deren Speicherung notwendig ist zur Rechtsverfolgung oder zum Schutz der Rechte anderer natürlicher oder juristischer Personen, entsprechend archiviert werden.

+

Unsere Datenschutzhinweise enthalten zusätzliche Informationen zur Aufbewahrung und Löschung von Daten, die speziell für bestimmte Verarbeitungsprozesse gelten.

+

Bei mehreren Angaben zur Aufbewahrungsdauer oder Löschungsfristen eines Datums, ist stets die längste Frist maßgeblich. Daten, die nicht mehr für den ursprünglich vorgesehenen Zweck, sondern aufgrund gesetzlicher Vorgaben oder anderer Gründe aufbewahrt werden, verarbeiten wir ausschließlich zu den Gründen, die ihre Aufbewahrung rechtfertigen.

+

Aufbewahrung und Löschung von Daten: Die folgenden allgemeinen Fristen gelten für die Aufbewahrung und Archivierung nach deutschem Recht:

  • 10 Jahre - Aufbewahrungsfrist für Bücher und Aufzeichnungen, Jahresabschlüsse, Inventare, Lageberichte, Eröffnungsbilanz sowie die zu ihrem Verständnis erforderlichen Arbeitsanweisungen und sonstigen Organisationsunterlagen (§ 147 Abs. 1 Nr. 1 i.V.m. Abs. 3 AO, § 14b Abs. 1 UStG, § 257 Abs. 1 Nr. 1 i.V.m. Abs. 4 HGB).
  • 8 Jahre - Buchungsbelege, wie z. B. Rechnungen und Kostenbelege (§ 147 Abs. 1 Nr. 4 und 4a i.V.m. Abs. 3 Satz 1 AO sowie § 257 Abs. 1 Nr. 4 i.V.m. Abs. 4 HGB).
  • 6 Jahre - Übrige Geschäftsunterlagen: empfangene Handels- oder Geschäftsbriefe, Wiedergaben der abgesandten Handels- oder Geschäftsbriefe, sonstige Unterlagen, soweit sie für die Besteuerung von Bedeutung sind, z. B. Stundenlohnzettel, Betriebsabrechnungsbögen, Kalkulationsunterlagen, Preisauszeichnungen, aber auch Lohnabrechnungsunterlagen, soweit sie nicht bereits Buchungsbelege sind und Kassenstreifen (§ 147 Abs. 1 Nr. 2, 3, 5 i.V.m. Abs. 3 AO, § 257 Abs. 1 Nr. 2 u. 3 i.V.m. Abs. 4 HGB).
  • 3 Jahre - Daten, die erforderlich sind, um potenzielle Gewährleistungs- und Schadensersatzansprüche oder ähnliche vertragliche Ansprüche und Rechte zu berücksichtigen sowie damit verbundene Anfragen zu bearbeiten, basierend auf früheren Geschäftserfahrungen und üblichen Branchenpraktiken, werden für die Dauer der regulären gesetzlichen Verjährungsfrist von drei Jahren gespeichert (§§ 195, 199 BGB).
+

Aufbewahrung und Löschung von Daten: Die folgenden allgemeinen Fristen gelten für die Aufbewahrung und Archivierung nach dem Schweizer Recht:

  • 10 Jahre - Aufbewahrungsfrist für Bücher und Aufzeichnungen, Jahresabschlüsse, Inventare, Lageberichte, Eröffnungsbilanzen, Buchungsbelege und Rechnungen sowie alle erforderlichen Arbeitsanweisungen und sonstigen Organisationsunterlagen (Art. 958f des Schweizerischen Obligationenrechts (OR)).
  • 10 Jahre - Daten, die zur Berücksichtigung potenzieller Schadenersatzansprüche oder ähnlicher vertraglicher Ansprüche und Rechte notwendig sind, sowie für die Bearbeitung damit verbundener Anfragen, basierend auf früheren Geschäftserfahrungen und den üblichen Branchenpraktiken, werden für den Zeitraum der gesetzlichen Verjährungsfrist von zehn Jahren gespeichert, es sei denn, eine kürzere Frist von fünf Jahren ist maßgeblich, die in bestimmten Fällen einschlägig ist (Art. 127, 130 OR). Mit Ablauf von fünf Jahren verjähren die Forderungen für Miet-, Pacht- und Kapitalzinse sowie andere periodische Leistungen, aus Lieferung von Lebensmitteln, für Beköstigung und für Wirtsschulden, sowie aus Handwerksarbeit, Kleinverkauf von Waren, ärztlicher Besorgung, Berufsarbeiten von Anwälten, Rechtsagenten, Prokuratoren und Notaren und aus dem Arbeitsverhältnis von Arbeitnehmern (Art. 128 OR).
+

Fristbeginn mit Ablauf des Jahres: Beginnt eine Frist nicht ausdrücklich zu einem bestimmten Datum und beträgt sie mindestens ein Jahr, so startet sie automatisch am Ende des Kalenderjahres, in dem das fristauslösende Ereignis eingetreten ist. Im Fall laufender Vertragsverhältnisse, in deren Rahmen Daten gespeichert werden, ist das fristauslösende Ereignis der Zeitpunkt des Wirksamwerdens der Kündigung oder sonstige Beendigung des Rechtsverhältnisses.

+ +

Rechte der betroffenen Personen

Rechte der betroffenen Personen aus der DSGVO: Ihnen stehen als Betroffene nach der DSGVO verschiedene Rechte zu, die sich insbesondere aus Art. 15 bis 21 DSGVO ergeben:

  • Widerspruchsrecht: Sie haben das Recht, aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit gegen die Verarbeitung der Sie betreffenden personenbezogenen Daten, die aufgrund von Art. 6 Abs. 1 lit. e oder f DSGVO erfolgt, Widerspruch einzulegen; dies gilt auch für ein auf diese Bestimmungen gestütztes Profiling. Werden die Sie betreffenden personenbezogenen Daten verarbeitet, um Direktwerbung zu betreiben, haben Sie das Recht, jederzeit Widerspruch gegen die Verarbeitung der Sie betreffenden personenbezogenen Daten zum Zwecke derartiger Werbung einzulegen; dies gilt auch für das Profiling, soweit es mit solcher Direktwerbung in Verbindung steht.
  • Widerrufsrecht bei Einwilligungen: Sie haben das Recht, erteilte Einwilligungen jederzeit zu widerrufen.
  • Auskunftsrecht: Sie haben das Recht, eine Bestätigung darüber zu verlangen, ob betreffende Daten verarbeitet werden und auf Auskunft über diese Daten sowie auf weitere Informationen und Kopie der Daten entsprechend den gesetzlichen Vorgaben.
  • Recht auf Berichtigung: Sie haben entsprechend den gesetzlichen Vorgaben das Recht, die Vervollständigung der Sie betreffenden Daten oder die Berichtigung der Sie betreffenden unrichtigen Daten zu verlangen.
  • Recht auf Löschung und Einschränkung der Verarbeitung: Sie haben nach Maßgabe der gesetzlichen Vorgaben das Recht, zu verlangen, dass Sie betreffende Daten unverzüglich gelöscht werden, bzw. alternativ nach Maßgabe der gesetzlichen Vorgaben eine Einschränkung der Verarbeitung der Daten zu verlangen.
  • Recht auf Datenübertragbarkeit: Sie haben das Recht, Sie betreffende Daten, die Sie uns bereitgestellt haben, nach Maßgabe der gesetzlichen Vorgaben in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten oder deren Übermittlung an einen anderen Verantwortlichen zu fordern.
  • Beschwerde bei Aufsichtsbehörde: Entsprechend den gesetzlichen Vorgaben und unbeschadet eines anderweitigen verwaltungsrechtlichen oder gerichtlichen Rechtsbehelfs, haben Sie ferner das Recht, bei einer Datenschutzaufsichtsbehörde, insbesondere einer Aufsichtsbehörde im Mitgliedstaat, in dem Sie sich gewöhnlich aufhalten, der Aufsichtsbehörde Ihres Arbeitsplatzes oder des Ortes des mutmaßlichen Verstoßes, eine Beschwerde einzulegen, wenn Sie der Ansicht sein sollten, dass die Verarbeitung der Ihre Person betreffenden personenbezogenen Daten gegen die DSGVO verstößt.
+

Rechte der betroffenen Personen nach dem Schweizer DSG:

+

Ihnen stehen als betroffene Person nach Maßgabe der Vorgaben des Schweizer DSG die folgenden Rechte zu:

  • Recht auf Auskunft: Sie haben das Recht, eine Bestätigung darüber zu verlangen, ob Sie betreffende Personendaten verarbeitet werden, und auf Erhalt derjenigen Informationen, die erforderlich sind, damit Sie Ihre Rechte nach diesem Gesetz geltend machen können und eine transparente Datenbearbeitung gewährleistet ist.
  • Recht auf Datenherausgabe oder -übertragung: Sie haben das Recht, die Herausgabe Ihrer Personendaten, die Sie uns bekanntgegeben haben, in einem gängigen elektronischen Format zu verlangen.
  • Recht auf Berichtigung: Sie haben das Recht, die Berichtigung der Sie betreffenden unrichtigen Personendaten zu verlangen.
  • Recht auf Widerspruch, Löschung und Vernichtung: Sie haben das Recht, der Verarbeitung Ihrer Daten zu widersprechen, sowie zu verlangen, dass die Sie betreffenden Personendaten gelöscht oder vernichtet werden.
+ +

Bereitstellung des Onlineangebots und Webhosting

Wir verarbeiten die Daten der Nutzer, um ihnen unsere Online-Dienste zur Verfügung stellen zu können. Zu diesem Zweck verarbeiten wir die IP-Adresse des Nutzers, die notwendig ist, um die Inhalte und Funktionen unserer Online-Dienste an den Browser oder das Endgerät der Nutzer zu übermitteln.

+
  • Verarbeitete Datenarten: Nutzungsdaten (z. B. Seitenaufrufe und Verweildauer, Klickpfade, Nutzungsintensität und -frequenz, verwendete Gerätetypen und Betriebssysteme, Interaktionen mit Inhalten und Funktionen); Meta-, Kommunikations- und Verfahrensdaten (z. B. IP-Adressen, Zeitangaben, Identifikationsnummern, beteiligte Personen). Protokolldaten (z. B. Logfiles betreffend Logins oder den Abruf von Daten oder Zugriffszeiten.).
  • Betroffene Personen: Nutzer (z. B. Webseitenbesucher, Nutzer von Onlinediensten).
  • Zwecke der Verarbeitung: Bereitstellung unseres Onlineangebotes und Nutzerfreundlichkeit; Informationstechnische Infrastruktur (Betrieb und Bereitstellung von Informationssystemen und technischen Geräten (Computer, Server etc.)). Sicherheitsmaßnahmen.
  • Aufbewahrung und Löschung: Löschung entsprechend Angaben im Abschnitt "Allgemeine Informationen zur Datenspeicherung und Löschung".
  • Rechtsgrundlagen: Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO).

Weitere Hinweise zu Verarbeitungsprozessen, Verfahren und Diensten:

  • Erhebung von Zugriffsdaten und Logfiles: Der Zugriff auf unser Onlineangebot wird in Form von sogenannten "Server-Logfiles" protokolliert. Zu den Serverlogfiles können die Adresse und der Name der abgerufenen Webseiten und Dateien, Datum und Uhrzeit des Abrufs, übertragene Datenmengen, Meldung über erfolgreichen Abruf, Browsertyp nebst Version, das Betriebssystem des Nutzers, Referrer URL (die zuvor besuchte Seite) und im Regelfall IP-Adressen und der anfragende Provider gehören. Die Serverlogfiles können zum einen zu Sicherheitszwecken eingesetzt werden, z. B. um eine Überlastung der Server zu vermeiden (insbesondere im Fall von missbräuchlichen Angriffen, sogenannten DDoS-Attacken), und zum anderen, um die Auslastung der Server und ihre Stabilität sicherzustellen; Rechtsgrundlagen: Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO). Löschung von Daten: Logfile-Informationen werden für die Dauer von maximal 30 Tagen gespeichert und danach gelöscht oder anonymisiert. Daten, deren weitere Aufbewahrung zu Beweiszwecken erforderlich ist, sind bis zur endgültigen Klärung des jeweiligen Vorfalls von der Löschung ausgenommen.
  • Hetzner: Leistungen auf dem Gebiet der Bereitstellung von informationstechnischer Infrastruktur und verbundenen Dienstleistungen (z. B. Speicherplatz und/oder Rechenkapazitäten); Dienstanbieter: Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Deutschland; Rechtsgrundlagen: Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO); Website: https://www.hetzner.com; Datenschutzerklärung: https://www.hetzner.com/de/rechtliches/datenschutz. Auftragsverarbeitungsvertrag: https://docs.hetzner.com/de/general/general-terms-and-conditions/data-privacy-faq/.
+

Einsatz von Cookies

Unter dem Begriff „Cookies" werden Funktionen, die Informationen auf Endgeräten der Nutzer speichern und aus ihnen auslesen, verstanden. Cookies können ferner in Bezug auf unterschiedliche Anliegen Einsatz finden, etwa zu Zwecken der Funktionsfähigkeit, der Sicherheit und des Komforts von Onlineangeboten sowie der Erstellung von Analysen der Besucherströme. Wir verwenden Cookies gemäß den gesetzlichen Vorschriften. Dazu holen wir, wenn erforderlich, vorab die Zustimmung der Nutzer ein. Ist eine Zustimmung nicht notwendig, setzen wir auf unsere berechtigten Interessen. Dies gilt, wenn das Speichern und Auslesen von Informationen unerlässlich ist, um ausdrücklich angeforderte Inhalte und Funktionen bereitstellen zu können. Dazu zählen etwa die Speicherung von Einstellungen sowie die Sicherstellung der Funktionalität und Sicherheit unseres Onlineangebots. Die Einwilligung kann jederzeit widerrufen werden. Wir informieren klar über deren Umfang und welche Cookies genutzt werden.

+

Hinweise zu datenschutzrechtlichen Rechtsgrundlagen: Ob wir personenbezogene Daten mithilfe von Cookies verarbeiten, hängt von einer Einwilligung ab. Liegt eine Einwilligung vor, dient sie als Rechtsgrundlage. Ohne Einwilligung stützen wir uns auf unsere berechtigten Interessen, die vorstehend in diesem Abschnitt und im Kontext der jeweiligen Dienste und Verfahren erläutert sind.

+

Speicherdauer: Im Hinblick auf die Speicherdauer werden die folgenden Arten von Cookies unterschieden:

  • Temporäre Cookies (auch: Session- oder Sitzungscookies): Temporäre Cookies werden spätestens gelöscht, nachdem ein Nutzer ein Onlineangebot verlassen und sein Endgerät (z. B. Browser oder mobile Applikation) geschlossen hat.
  • Permanente Cookies: Permanente Cookies bleiben auch nach dem Schließen des Endgeräts gespeichert. So können beispielsweise der Log-in-Status gespeichert und bevorzugte Inhalte direkt angezeigt werden, wenn der Nutzer eine Website erneut besucht. Ebenso können die mithilfe von Cookies erhobenen Nutzerdaten zur Reichweitenmessung Verwendung finden. Sofern wir Nutzern keine expliziten Angaben zur Art und Speicherdauer von Cookies mitteilen (z. B. im Rahmen der Einholung der Einwilligung), sollten sie davon ausgehen, dass diese permanent sind und die Speicherdauer bis zu zwei Jahre betragen kann.

Allgemeine Hinweise zum Widerruf und Widerspruch (Opt-out): Nutzer können die von ihnen abgegebenen Einwilligungen jederzeit widerrufen und zudem einen Widerspruch gegen die Verarbeitung entsprechend den gesetzlichen Vorgaben, auch mittels der Privatsphäre-Einstellungen ihres Browsers, erklären.

+
  • Verarbeitete Datenarten: Meta-, Kommunikations- und Verfahrensdaten (z. B. IP-Adressen, Zeitangaben, Identifikationsnummern, beteiligte Personen).
  • Betroffene Personen: Nutzer (z. B. Webseitenbesucher, Nutzer von Onlinediensten).
  • Rechtsgrundlagen: Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO).
+

Verarbeitung von Daten im Rahmen der Applikation (App)

Wir verarbeiten die Daten der Nutzer unserer Applikation, soweit diese erforderlich sind, um den Nutzern die Applikation sowie deren Funktionalitäten bereitstellen, deren Sicherheit überwachen und sie weiterentwickeln zu können. Wir können ferner Nutzer unter Beachtung der gesetzlichen Vorgaben kontaktieren, sofern die Kommunikation zu Zwecken der Administration oder Nutzung der Applikation erforderlich ist. Im Übrigen verweisen wir im Hinblick auf die Verarbeitung der Daten der Nutzer auf die Datenschutzhinweise in dieser Datenschutzerklärung.

+

Rechtsgrundlagen: Die Verarbeitung von Daten, die für die Bereitstellung der Funktionalitäten der Applikation erforderlich ist, dient der Erfüllung von vertraglichen Pflichten. Dies gilt auch, wenn die Bereitstellung der Funktionen eine Berechtigung der Nutzer (z. B. Freigaben von Gerätefunktionen) voraussetzt. Sofern die Verarbeitung von Daten für die Bereitstellung der Funktionalitäten der Applikation nicht erforderlich ist, aber der Sicherheit der Applikation oder unseren betriebswirtschaftlichen Interessen dient (z. B. Erhebung von Daten zu Zwecken der Optimierung der Applikation oder Sicherheitszwecken), erfolgt sie auf Grundlage unserer berechtigten Interessen. Sofern Nutzer ausdrücklich deren Einwilligung in die Verarbeitung ihrer Daten gebeten werden, erfolgt die Verarbeitung der von der Einwilligung umfassten Daten auf Grundlage der Einwilligung.

+
  • Verarbeitete Datenarten: Bestandsdaten (z. B. der vollständige Name, Wohnadresse, Kontaktinformationen, Kundennummer, etc.); Nutzungsdaten (z. B. Seitenaufrufe und Verweildauer, Klickpfade, Nutzungsintensität und -frequenz, verwendete Gerätetypen und Betriebssysteme, Interaktionen mit Inhalten und Funktionen). Meta-, Kommunikations- und Verfahrensdaten (z. B. IP-Adressen, Zeitangaben, Identifikationsnummern, beteiligte Personen).
  • Betroffene Personen: Nutzer (z. B. Webseitenbesucher, Nutzer von Onlinediensten).
  • Zwecke der Verarbeitung: Erbringung vertraglicher Leistungen und Erfüllung vertraglicher Pflichten; Sicherheitsmaßnahmen. Bereitstellung unseres Onlineangebotes und Nutzerfreundlichkeit.
  • Aufbewahrung und Löschung: Löschung entsprechend Angaben im Abschnitt "Allgemeine Informationen zur Datenspeicherung und Löschung".
  • Rechtsgrundlagen: Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO). Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO).

Weitere Hinweise zu Verarbeitungsprozessen, Verfahren und Diensten:

  • Kein Standortverlauf und keine Bewegungsprofile: Die Standortdaten werden lediglich punktuell eingesetzt und nicht zur Bildung eines Standortverlaufs oder eines Bewegungsprofils der verwendeten Geräte, bzw. ihrer Nutzer verarbeitet.
+

Registrierung, Anmeldung und Nutzerkonto

Nutzer können ein Nutzerkonto anlegen. Im Rahmen der Registrierung werden den Nutzern die erforderlichen Pflichtangaben mitgeteilt und zu Zwecken der Bereitstellung des Nutzerkontos auf Grundlage vertraglicher Pflichterfüllung verarbeitet. Zu den verarbeiteten Daten gehören insbesondere die Login-Informationen (Nutzername, Passwort sowie eine E-Mail-Adresse).

+

Im Rahmen der Inanspruchnahme unserer Registrierungs- und Anmeldefunktionen sowie der Nutzung des Nutzerkontos speichern wir die IP-Adresse und den Zeitpunkt der jeweiligen Nutzerhandlung. Die Speicherung erfolgt auf Grundlage unserer berechtigten Interessen als auch jener der Nutzer an einem Schutz vor Missbrauch und sonstiger unbefugter Nutzung. Eine Weitergabe dieser Daten an Dritte erfolgt grundsätzlich nicht, es sei denn, sie ist zur Verfolgung unserer Ansprüche erforderlich oder es besteht eine gesetzliche Verpflichtung hierzu.

+

Die Nutzer können über Vorgänge, die für deren Nutzerkonto relevant sind, wie z. B. technische Änderungen, per E-Mail informiert werden.

+
  • Verarbeitete Datenarten: Bestandsdaten (z. B. der vollständige Name, Wohnadresse, Kontaktinformationen, Kundennummer, etc.); Kontaktdaten (z. B. Post- und E-Mail-Adressen oder Telefonnummern); Inhaltsdaten (z. B. textliche oder bildliche Nachrichten und Beiträge sowie die sie betreffenden Informationen, wie z. B. Angaben zur Autorenschaft oder Zeitpunkt der Erstellung); Nutzungsdaten (z. B. Seitenaufrufe und Verweildauer, Klickpfade, Nutzungsintensität und -frequenz, verwendete Gerätetypen und Betriebssysteme, Interaktionen mit Inhalten und Funktionen). Protokolldaten (z. B. Logfiles betreffend Logins oder den Abruf von Daten oder Zugriffszeiten.).
  • Betroffene Personen: Nutzer (z. B. Webseitenbesucher, Nutzer von Onlinediensten).
  • Zwecke der Verarbeitung: Erbringung vertraglicher Leistungen und Erfüllung vertraglicher Pflichten; Sicherheitsmaßnahmen; Organisations- und Verwaltungsverfahren. Bereitstellung unseres Onlineangebotes und Nutzerfreundlichkeit.
  • Aufbewahrung und Löschung: Löschung entsprechend Angaben im Abschnitt "Allgemeine Informationen zur Datenspeicherung und Löschung". Löschung nach Kündigung.
  • Rechtsgrundlagen: Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO). Berechtigte Interessen (Art. 6 Abs. 1 S. 1 lit. f) DSGVO).

Weitere Hinweise zu Verarbeitungsprozessen, Verfahren und Diensten:

  • Registrierung mit Pseudonymen: Nutzer dürfen statt Klarnamen Pseudonyme als Nutzernamen verwenden; Rechtsgrundlagen: Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO).
  • Profile der Nutzer sind nicht öffentlich: Die Profile der Nutzer sind öffentlich nicht sichtbar und nicht zugänglich.
  • Löschung von Daten nach Kündigung: Wenn Nutzer ihr Nutzerkonto gekündigt haben, werden deren Daten im Hinblick auf das Nutzerkonto, vorbehaltlich einer gesetzlichen Erlaubnis, Pflicht oder Einwilligung der Nutzer, gelöscht; Rechtsgrundlagen: Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO).
  • Keine Aufbewahrungspflicht für Daten: Es obliegt den Nutzern, ihre Daten bei erfolgter Kündigung vor dem Vertragsende zu sichern. Wir sind berechtigt, sämtliche während der Vertragsdauer gespeicherte Daten des Nutzers unwiederbringlich zu löschen; Rechtsgrundlagen: Vertragserfüllung und vorvertragliche Anfragen (Art. 6 Abs. 1 S. 1 lit. b) DSGVO).
+

Änderung und Aktualisierung

Wir bitten Sie, sich regelmäßig über den Inhalt unserer Datenschutzerklärung zu informieren. Wir passen die Datenschutzerklärung an, sobald die Änderungen der von uns durchgeführten Datenverarbeitungen dies erforderlich machen. Wir informieren Sie, sobald durch die Änderungen eine Mitwirkungshandlung Ihrerseits (z. B. Einwilligung) oder eine sonstige individuelle Benachrichtigung erforderlich wird.

+

Sofern wir in dieser Datenschutzerklärung Adressen und Kontaktinformationen von Unternehmen und Organisationen angeben, bitten wir zu beachten, dass die Adressen sich über die Zeit ändern können und bitten die Angaben vor Kontaktaufnahme zu prüfen.

+ +

Begriffsdefinitionen

In diesem Abschnitt erhalten Sie eine Übersicht über die in dieser Datenschutzerklärung verwendeten Begrifflichkeiten. Soweit die Begrifflichkeiten gesetzlich definiert sind, gelten deren gesetzliche Definitionen. Die nachfolgenden Erläuterungen sollen dagegen vor allem dem Verständnis dienen.

+
  • Bestandsdaten: Bestandsdaten umfassen wesentliche Informationen, die für die Identifikation und Verwaltung von Vertragspartnern, Benutzerkonten, Profilen und ähnlichen Zuordnungen notwendig sind. Diese Daten können u.a. persönliche und demografische Angaben wie Namen, Kontaktinformationen (Adressen, Telefonnummern, E-Mail-Adressen), Geburtsdaten und spezifische Identifikatoren (Benutzer-IDs) beinhalten. Bestandsdaten bilden die Grundlage für jegliche formelle Interaktion zwischen Personen und Diensten, Einrichtungen oder Systemen, indem sie eine eindeutige Zuordnung und Kommunikation ermöglichen.
  • Inhaltsdaten: Inhaltsdaten umfassen Informationen, die im Zuge der Erstellung, Bearbeitung und Veröffentlichung von Inhalten aller Art generiert werden. Diese Kategorie von Daten kann Texte, Bilder, Videos, Audiodateien und andere multimediale Inhalte einschließen, die auf verschiedenen Plattformen und Medien veröffentlicht werden. Inhaltsdaten sind nicht nur auf den eigentlichen Inhalt beschränkt, sondern beinhalten auch Metadaten, die Informationen über den Inhalt selbst liefern, wie Tags, Beschreibungen, Autoreninformationen und Veröffentlichungsdaten
  • Kontaktdaten: Kontaktdaten sind essentielle Informationen, die die Kommunikation mit Personen oder Organisationen ermöglichen. Sie umfassen u.a. Telefonnummern, postalische Adressen und E-Mail-Adressen, sowie Kommunikationsmittel wie soziale Medien-Handles und Instant-Messaging-Identifikatoren.
  • Meta-, Kommunikations- und Verfahrensdaten: Meta-, Kommunikations- und Verfahrensdaten sind Kategorien, die Informationen über die Art und Weise enthalten, wie Daten verarbeitet, übermittelt und verwaltet werden. Meta-Daten, auch bekannt als Daten über Daten, umfassen Informationen, die den Kontext, die Herkunft und die Struktur anderer Daten beschreiben. Sie können Angaben zur Dateigröße, dem Erstellungsdatum, dem Autor eines Dokuments und den Änderungshistorien beinhalten. Kommunikationsdaten erfassen den Austausch von Informationen zwischen Nutzern über verschiedene Kanäle, wie E-Mail-Verkehr, Anrufprotokolle, Nachrichten in sozialen Netzwerken und Chat-Verläufe, inklusive der beteiligten Personen, Zeitstempel und Übertragungswege. Verfahrensdaten beschreiben die Prozesse und Abläufe innerhalb von Systemen oder Organisationen, einschließlich Workflow-Dokumentationen, Protokolle von Transaktionen und Aktivitäten, sowie Audit-Logs, die zur Nachverfolgung und Überprüfung von Vorgängen verwendet werden.
  • Nutzungsdaten: Nutzungsdaten beziehen sich auf Informationen, die erfassen, wie Nutzer mit digitalen Produkten, Dienstleistungen oder Plattformen interagieren. Diese Daten umfassen eine breite Palette von Informationen, die aufzeigen, wie Nutzer Anwendungen nutzen, welche Funktionen sie bevorzugen, wie lange sie auf bestimmten Seiten verweilen und über welche Pfade sie durch eine Anwendung navigieren. Nutzungsdaten können auch die Häufigkeit der Nutzung, Zeitstempel von Aktivitäten, IP-Adressen, Geräteinformationen und Standortdaten einschließen. Sie sind besonders wertvoll für die Analyse des Nutzerverhaltens, die Optimierung von Benutzererfahrungen, das Personalisieren von Inhalten und das Verbessern von Produkten oder Dienstleistungen. Darüber hinaus spielen Nutzungsdaten eine entscheidende Rolle beim Erkennen von Trends, Vorlieben und möglichen Problembereichen innerhalb digitaler Angebote
  • Personenbezogene Daten: "Personenbezogene Daten" sind alle Informationen, die sich auf eine identifizierte oder identifizierbare natürliche Person (im Folgenden "betroffene Person") beziehen; als identifizierbar wird eine natürliche Person angesehen, die direkt oder indirekt, insbesondere mittels Zuordnung zu einer Kennung wie einem Namen, zu einer Kennnummer, zu Standortdaten, zu einer Online-Kennung (z. B. Cookie) oder zu einem oder mehreren besonderen Merkmalen identifiziert werden kann, die Ausdruck der physischen, physiologischen, genetischen, psychischen, wirtschaftlichen, kulturellen oder sozialen Identität dieser natürlichen Person sind.
  • Protokolldaten: Protokolldaten sind Informationen über Ereignisse oder Aktivitäten, die in einem System oder Netzwerk protokolliert wurden. Diese Daten enthalten typischerweise Informationen wie Zeitstempel, IP-Adressen, Benutzeraktionen, Fehlermeldungen und andere Details über die Nutzung oder den Betrieb eines Systems. Protokolldaten werden oft zur Analyse von Systemproblemen, zur Sicherheitsüberwachung oder zur Erstellung von Leistungsberichten verwendet.
  • Verantwortlicher: Als "Verantwortlicher" wird die natürliche oder juristische Person, Behörde, Einrichtung oder andere Stelle, die allein oder gemeinsam mit anderen über die Zwecke und Mittel der Verarbeitung von personenbezogenen Daten entscheidet, bezeichnet.
  • Verarbeitung: "Verarbeitung" ist jeder mit oder ohne Hilfe automatisierter Verfahren ausgeführte Vorgang oder jede solche Vorgangsreihe im Zusammenhang mit personenbezogenen Daten. Der Begriff reicht weit und umfasst praktisch jeden Umgang mit Daten, sei es das Erheben, das Auswerten, das Speichern, das Übermitteln oder das Löschen.
\ No newline at end of file diff --git a/qmra/templates/dsg-qmra-en.html b/qmra/templates/dsg-qmra-en.html new file mode 100644 index 0000000..8a6bd8b --- /dev/null +++ b/qmra/templates/dsg-qmra-en.html @@ -0,0 +1,54 @@ +

Privacy Policy

+

Preamble

+

With the following privacy policy we would like to inform you about the types of personal data (hereinafter also referred to as "data") we process, for which purposes and to what extent in the context of providing our application.

+

The terms used are not gender-specific.

+ +

Last Update: 15. December 2025

+

Legal text by Dr. Schwenke - please click for further information.

Table of contents

Controller

Tobias Evel
KWB Kompetenzzentrum Wasser Berlin gGmbH
Grunewaldstraße 61-62
10825 Berlin

+

Authorised Representatives: Dr. Pascale Rouault (managing director)

+

E-mail address: datenschutz@kompetenz-wasser.de

+

Legal Notice: https://www.kompetenz-wasser.de/en/impressum

+ +

Contact information of the Data Protection Officer

datenschutz@kompetenz-wasser.de

+ +

Overview of processing operations

The following table summarises the types of data processed, the purposes for which they are processed and the concerned data subjects.

Categories of Processed Data

+
  • Inventory data.
  • Contact data.
  • Content data.
  • Usage data.
  • Meta, communication and process data.
  • Log data.

Categories of Data Subjects

  • Users.

Purposes of Processing

  • Provision of contractual services and fulfillment of contractual obligations.
  • Security measures.
  • Organisational and Administrative Procedures.
  • Provision of our online services and usability.
  • Information technology infrastructure.

Relevant legal bases

Relevant legal bases according to the GDPR: In the following, you will find an overview of the legal basis of the GDPR on which we base the processing of personal data. Please note that in addition to the provisions of the GDPR, national data protection provisions of your or our country of residence or domicile may apply. If, in addition, more specific legal bases are applicable in individual cases, we will inform you of these in the data protection declaration.

+
  • Performance of a contract and prior requests (Article 6 (1) (b) GDPR) - Performance of a contract to which the data subject is party or in order to take steps at the request of the data subject prior to entering into a contract.
  • Legitimate Interests (Article 6 (1) (f) GDPR) - the processing is necessary for the protection of the legitimate interests of the controller or a third party, provided that the interests, fundamental rights, and freedoms of the data subject, which require the protection of personal data, do not prevail.

National data protection regulations in Germany: In addition to the data protection regulations of the GDPR, national regulations apply to data protection in Germany. This includes in particular the Law on Protection against Misuse of Personal Data in Data Processing (Federal Data Protection Act - BDSG). In particular, the BDSG contains special provisions on the right to access, the right to erase, the right to object, the processing of special categories of personal data, processing for other purposes and transmission as well as automated individual decision-making, including profiling. Furthermore, data protection laws of the individual federal states may apply.

+

Relevant legal basis according to the Swiss Data Protection Act: If you are located in Switzerland, we process your data based on the Federal Act on Data Protection (referred to as "Swiss DPA"). Unlike the GDPR, for instance, the Swiss DPA does not generally require that a legal basis for processing personal data be stated and that the processing of personal data is conducted in good faith, lawfully and proportionately (Art. 6 para. 1 and 2 of the Swiss DPA). Furthermore, we only collect personal data for a specific purpose recognizable to the data subject and process it only in a manner compatible with this purpose (Art. 6 para. 3 of the Swiss DPA).

+

Reference to the applicability of the GDPR and the Swiss DPA: These privacy policy serves both to provide information pursuant to the Swiss Federal Act on Data Protection (FADP) and the General Data Protection Regulation (GDPR). For this reason, we ask you to note that due to the broader spatial application and comprehensibility, the terms used in the GDPR are applied. In particular, instead of the terms used in the Swiss FADP such as "processing" of "personal data", "predominant interest", and "particularly sensitive personal data", the terms used in the GDPR, namely "processing" of "personal data", as well as "legitimate interest" and "special categories of data" are used. However, the legal meaning of these terms will continue to be determined according to the Swiss FADP within its scope of application.

+ +

Security Precautions

We take appropriate technical and organisational measures in accordance with the legal requirements, taking into account the state of the art, the costs of implementation and the nature, scope, context and purposes of processing as well as the risk of varying likelihood and severity for the rights and freedoms of natural persons, in order to ensure a level of security appropriate to the risk.

+

The measures include, in particular, safeguarding the confidentiality, integrity and availability of data by controlling physical and electronic access to the data as well as access to, input, transmission, securing and separation of the data. In addition, we have established procedures to ensure that data subjects' rights are respected, that data is erased, and that we are prepared to respond to data threats rapidly. Furthermore, we take the protection of personal data into account as early as the development or selection of hardware, software and service providers, in accordance with the principle of privacy by design and privacy by default.

+

Securing online connections through TLS/SSL encryption technology (HTTPS): To protect the data of users transmitted via our online services from unauthorized access, we employ TLS/SSL encryption technology. Secure Sockets Layer (SSL) and Transport Layer Security (TLS) are the cornerstones of secure data transmission on the internet. These technologies encrypt the information that is transferred between the website or app and the user's browser (or between two servers), thereby safeguarding the data from unauthorized access. TLS, as the more advanced and secure version of SSL, ensures that all data transmissions conform to the highest security standards. When a website is secured with an SSL/TLS certificate, this is indicated by the display of HTTPS in the URL. This serves as an indicator to users that their data is being securely and encryptedly transmitted.

+ +

General Information on Data Retention and Deletion

We delete personal data that we process in accordance with legal regulations as soon as the underlying consents are revoked or no further legal bases for processing exist. This applies to cases where the original purpose of processing is no longer applicable or the data is no longer needed. Exceptions to this rule exist if statutory obligations or special interests require a longer retention or archiving of the data.

+

In particular, data that must be retained for commercial or tax law reasons, or whose storage is necessary for legal prosecution or protection of the rights of other natural or legal persons, must be archived accordingly.

+

Our privacy notices contain additional information on the retention and deletion of data specifically applicable to certain processing processes.

+

In cases where multiple retention periods or deletion deadlines for a date are specified, the longest period always prevails.

+

Data that is no longer stored for its originally intended purpose but due to legal requirements or other reasons are processed exclusively for the reasons justifying their retention.

+

Data Retention and Deletion: The following general deadlines apply for the retention and archiving according to German law:

  • 10 Years - Fiscal Code/Commercial Code - Retention period for books and records, annual financial statements, inventories, management reports, opening balance sheet as well as the necessary work instructions and other organisational documents (Section 147 Paragraph 1 No. 1 in conjunction with Paragraph 3 of the German General Tax Code (AO), Section 14b Paragraph 1 of the German VAT Act (UStG), Section 257 Paragraph 1 No. 1 in conjunction with Paragraph 4 of the German Commercial Code (HGB)).
  • 8 years - Accounting documents, such as invoices, booking and expense receipts (Section 147 Paragraph 1 No. 4 and 4a in conjunction with Paragraph 3 of the German General Tax Code (AO), Section 257 Paragraph 1 No. 4 in conjunction with Paragraph 4 of the German Commercial Code (HGB))
  • 6 Years - Other business documents: received commercial or business letters, copies of dispatched commercial or business letters, and other documents to the extent that they are significant for taxation purposes, for example, hourly wage slips, operating accounting sheets, calculation documents, price tags, as well as payroll accounting documents, provided they are not already accounting vouchers and cash register tapes Section (Section 147 Paragraph 1 No. 2, 3, 5 in conjunction with Paragraph 3 of the German General Tax Code (AO), Section 257 Paragraph 1 No. 2 and 3 in conjunction with Paragraph 4 of the German Commercial Code (HGB)).
  • 3 Years - Data required to consider potential warranty and compensation claims or similar contractual claims and rights, as well as to process related inquiries, based on previous business experiences and common industry practices, will be stored for the duration of the regular statutory limitation period of three years. This period begins at the end of the year in which the relevant contractual transaction took place or the contractual relationship ended in the case of ongoing contracts (Sections 195, 199 of the German Civil Code).
+

Data Retention and Deletion: The following general retention and archiving periods apply under Swiss law:

  • 10 years - Retention period for books and records, annual financial statements, inventories, management reports, opening balances, accounting vouchers and invoices, as well as all necessary working instructions and other organizational documents (Article 958f of the Swiss Code of Obligations (OR)).
  • 10 years - Data necessary to consider potential claims for damages or similar contractual claims and rights, as well as for the processing of related inquiries based on previous business experiences and usual industry practices, will be stored for the statutory limitation period of ten years, unless a shorter period of five years is applicable, which is relevant in certain cases (Articles 127, 130 OR). Claims for rent, lease, and interest on capital, as well as other periodic services, for the delivery of food, for board and lodging, for innkeeper debts, as well as for craftsmanship, small-scale sales of goods, medical care, professional services by lawyers, legal agents, procurators, and notaries, and from the employment relationship of employees, expire after five years (Article 128 OR).
+

Start of the period at the end of the year: If a period does not expressly start on a specific date and lasts at least one year, it automatically begins at the end of the calendar year in which the event triggering the period occurred. In the case of ongoing contractual relationships in the context of which data is stored, the event triggering the deadline is the time at which the termination or other termination of the legal relationship takes effect.

+ +

Rights of Data Subjects

Rights of the Data Subjects under the GDPR: As data subject, you are entitled to various rights under the GDPR, which arise in particular from Articles 15 to 21 of the GDPR:

  • Right to Object: You have the right, on grounds arising from your particular situation, to object at any time to the processing of your personal data which is based on letter (e) or (f) of Article 6(1) GDPR, including profiling based on those provisions. Where personal data are processed for direct marketing purposes, you have the right to object at any time to the processing of the personal data concerning you for the purpose of such marketing, which includes profiling to the extent that it is related to such direct marketing.
  • Right of withdrawal for consents: You have the right to revoke consents at any time.
  • Right of access: You have the right to request confirmation as to whether the data in question will be processed and to be informed of this data and to receive further information and a copy of the data in accordance with the provisions of the law.
  • Right to rectification: You have the right, in accordance with the law, to request the completion of the data concerning you or the rectification of the incorrect data concerning you.
  • Right to Erasure and Right to Restriction of Processing: In accordance with the statutory provisions, you have the right to demand that the relevant data be erased immediately or, alternatively, to demand that the processing of the data be restricted in accordance with the statutory provisions.
  • Right to data portability: You have the right to receive data concerning you which you have provided to us in a structured, common and machine-readable format in accordance with the legal requirements, or to request its transmission to another controller.
  • Complaint to the supervisory authority: In accordance with the law and without prejudice to any other administrative or judicial remedy, you also have the right to lodge a complaint with a data protection supervisory authority, in particular a supervisory authority in the Member State where you habitually reside, the supervisory authority of your place of work or the place of the alleged infringement, if you consider that the processing of personal data concerning you infringes the GDPR.
+

Rights of the data subjects under the Swiss DPA:

+

As the data subject, you have the following rights in accordance with the provisions of the Swiss DPA:

  • Right to information: You have the right to request confirmation as to whether personal data concerning you are being processed, and to receive the information necessary for you to assert your rights under the Swiss DPA and to ensure transparent data processing.
  • Right to data release or transfer: You have the right to request the release of your personal data, which you have provided to us, in a common electronic format, as well as its transfer to another data controller, provided this does not require disproportionate effort.
  • Right to rectification: You have the right to request the rectification of inaccurate personal data concerning you.
  • Right to object, deletion, and destruction: You have the right to object to the processing of your data, as well as to request that personal data concerning you be deleted or destroyed.
+ +

Provision of online services and web hosting

We process user data in order to be able to provide them with our online services. For this purpose, we process the IP address of the user, which is necessary to transmit the content and functions of our online services to the user's browser or terminal device.

+
  • Processed data types: Usage data (e.g. page views and duration of visit, click paths, intensity and frequency of use, types of devices and operating systems used, interactions with content and features); Meta, communication and process data (e.g. IP addresses, timestamps, identification numbers, involved parties). Log data (e.g. log files concerning logins or data retrieval or access times.).
  • Data subjects: Users (e.g. website visitors, users of online services).
  • Purposes of processing: Provision of our online services and usability; Information technology infrastructure (Operation and provision of information systems and technical devices, such as computers, servers, etc.)). Security measures.
  • Retention and deletion: Deletion in accordance with the information provided in the section "General Information on Data Retention and Deletion".
  • Legal Basis: Legitimate Interests (Article 6 (1) (f) GDPR).

Further information on processing methods, procedures and services used:

  • Collection of Access Data and Log Files: Access to our online service is logged in the form of so-called "server log files". Server log files may include the address and name of the accessed web pages and files, date and time of access, transferred data volumes, notification of successful retrieval, browser type along with version, the user's operating system, referrer URL (the previously visited page), and typically IP addresses and the requesting provider. The server log files can be used for security purposes, e.g., to prevent server overload (especially in the case of abusive attacks, known as DDoS attacks), and to ensure server load management and stability; Legal Basis: Legitimate Interests (Article 6 (1) (f) GDPR). Retention period: Log file information is stored for a maximum period of 30 days and then deleted or anonymized. Data, the further storage of which is necessary for evidence purposes, are excluded from deletion until the respective incident has been finally clarified.
  • Hetzner: Services in the field of the provision of information technology infrastructure and related services (e.g. storage space and/or computing capacities); Service provider: Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen, Germany; Legal Basis: Legitimate Interests (Article 6 (1) (f) GDPR); Website: https://www.hetzner.com; Privacy Policy: https://www.hetzner.com/de/rechtliches/datenschutz. Data Processing Agreement: https://docs.hetzner.com/de/general/general-terms-and-conditions/data-privacy-faq/.
+

Use of Cookies

The term "cookies" refers to functions that store information on users' devices and read it from them. Cookies can also be used for different purposes, such as ensuring the functionality, security, and convenience of online services, as well as analyzing visitor traffic. We use cookies in accordance with legal regulations. If necessary, we obtain users' consent in advance. If consent is not required, we rely on our legitimate interests. This applies when storing and reading information is essential to provide explicitly requested content and functions. This includes, for example, saving settings and ensuring the functionality and security of our online services. Consent can be withdrawn at any time. We clearly inform users about the scope of the consent and which cookies are used.

+

Information on legal data protection bases: Whether we process personal data using cookies depends on users' consent. If consent is given, it serves as the legal basis. Without consent, we rely on our legitimate interests, as outlined in this section and in the context of the respective services and procedures.

+

Storage duration: The following types of cookies are distinguished based on their storage duration:

  • Temporary cookies (also: session cookies): Temporary cookies are deleted at the latest after a user leaves an online service and closes their device (e.g., browser or mobile application).
  • Permanent cookies: Permanent cookies remain stored even after the device is closed. For example, the login status can be saved, and preferred content can be displayed directly when the user revisits a website. Additionally, the user data collected with cookies may be used for audience measurement. Unless we provide explicit information to users about the type and storage duration of cookies (e.g., when obtaining consent), users should assume that these are permanent and may have a storage duration of up to two years.

General information on withdrawal and objection (opt-out): Users can withdraw their consent at any time and also object to the processing according to legal regulations, including through the privacy settings of their browser.

+
  • Processed data types: Meta, communication and process data (e.g. IP addresses, timestamps, identification numbers, involved parties).
  • Data subjects: Users (e.g. website visitors, users of online services).
  • Legal Basis: Legitimate Interests (Article 6 (1) (f) GDPR).
+

Processing of Data within the Application (App)

We process the data of the users of our application to the extent necessary to provide the users with the application and its functionalities, to monitor its security and to develop it further. Furthermore, we may contact users in compliance with the statutory provisions if communication is necessary for the purposes of administration or use of the application. In addition, we refer to the data protection information in this privacy policy with regard to the processing of user data.

+

Legal basis: The processing of data necessary for the provision of the functionalities of the application serves to fulfil contractual obligations. This also applies if the provision of the functions requires user authorisation (e.g. release of device functions). If the processing of data is not necessary for the provision of the functionalities of the application, but serves the security of the application or our business interests (e.g. collection of data for the purpose of optimising the application or security purposes), it is carried out on the basis of our legitimate interests. If users are expressly requested to give their consent to the processing of their data, the data covered by the consent is processed on the basis of the consent.

+
  • Processed data types: Inventory data (For example, the full name, residential address, contact information, customer number, etc.); Usage data (e.g. page views and duration of visit, click paths, intensity and frequency of use, types of devices and operating systems used, interactions with content and features). Meta, communication and process data (e.g. IP addresses, timestamps, identification numbers, involved parties).
  • Data subjects: Users (e.g. website visitors, users of online services).
  • Purposes of processing: Provision of contractual services and fulfillment of contractual obligations; Security measures. Provision of our online services and usability.
  • Retention and deletion: Deletion in accordance with the information provided in the section "General Information on Data Retention and Deletion".
  • Legal Basis: Performance of a contract and prior requests (Article 6 (1) (b) GDPR). Legitimate Interests (Article 6 (1) (f) GDPR).

Further information on processing methods, procedures and services used:

  • Location history and movement profiles: The location data is only used selectively and is not processed to create a location history or a movement profile of the devices used or of their users.
+

Registration, Login and User Account

Users can create a user account. Within the scope of registration, the required mandatory information is communicated to the users and processed for the purposes of providing the user account on the basis of contractual fulfilment of obligations. The processed data includes in particular the login information (name, password and an e-mail address).

+

Within the scope of using our registration and login functions as well as the use of the user account, we store the IP address and the time of the respective user action. The storage is based on our legitimate interests, as well as the user's protection against misuse and other unauthorized use. This data will not be passed on to third parties unless it is necessary to pursue our claims or there is a legal obligation to do so.

+

Users may be informed by e-mail of information relevant to their user account, such as technical changes.

+
  • Processed data types: Inventory data (For example, the full name, residential address, contact information, customer number, etc.); Contact data (e.g. postal and email addresses or phone numbers); Content data (e.g. textual or pictorial messages and contributions, as well as information pertaining to them, such as details of authorship or the time of creation.); Usage data (e.g. page views and duration of visit, click paths, intensity and frequency of use, types of devices and operating systems used, interactions with content and features). Log data (e.g. log files concerning logins or data retrieval or access times.).
  • Data subjects: Users (e.g. website visitors, users of online services).
  • Purposes of processing: Provision of contractual services and fulfillment of contractual obligations; Security measures; Organisational and Administrative Procedures. Provision of our online services and usability.
  • Retention and deletion: Deletion in accordance with the information provided in the section "General Information on Data Retention and Deletion". Deletion after termination.
  • Legal Basis: Performance of a contract and prior requests (Article 6 (1) (b) GDPR). Legitimate Interests (Article 6 (1) (f) GDPR).

Further information on processing methods, procedures and services used:

  • Registration with pseudonyms: Users may use pseudonyms as user names instead of real names; Legal Basis: Performance of a contract and prior requests (Article 6 (1) (b) GDPR).
  • Users' profiles are public: The users' profiles are not publicly visible or accessible.
  • Deletion of data after termination: If users have terminated their user account, their data relating to the user account will be deleted, subject to any legal permission, obligation or consent of the users; Legal Basis: Performance of a contract and prior requests (Article 6 (1) (b) GDPR).
  • No obligation to retain data: It is the responsibility of the users to secure their data before the end of the contract in the event of termination. We are entitled to irretrievably delete all user data stored during the term of the contract; Legal Basis: Performance of a contract and prior requests (Article 6 (1) (b) GDPR).
+

Changes and Updates

We kindly ask you to inform yourself regularly about the contents of our data protection declaration. We will adjust the privacy policy as changes in our data processing practices make this necessary. We will inform you as soon as the changes require your cooperation (e.g. consent) or other individual notification.

+

If we provide addresses and contact information of companies and organizations in this privacy policy, we ask you to note that addresses may change over time and to verify the information before contacting us.

+ +

Terminology and Definitions

In this section, you will find an overview of the terminology used in this privacy policy. Where the terminology is legally defined, their legal definitions apply. The following explanations, however, are primarily intended to aid understanding.

+
  • Contact data: Contact details are essential information that enables communication with individuals or organizations. They include, among others, phone numbers, postal addresses, and email addresses, as well as means of communication like social media handles and instant messaging identifiers.
  • Content data: Content data comprise information generated in the process of creating, editing, and publishing content of all types. This category of data may include texts, images, videos, audio files, and other multimedia content published across various platforms and media. Content data are not limited to the content itself but also include metadata providing information about the content, such as tags, descriptions, authorship details, and publication dates.
  • Controller: "Controller" means the natural or legal person, public authority, agency or other body which, alone or jointly with others, determines the purposes and means of the processing of personal data.
  • Inventory data: Inventory data encompass essential information required for the identification and management of contractual partners, user accounts, profiles, and similar assignments. These data may include, among others, personal and demographic details such as names, contact information (addresses, phone numbers, email addresses), birth dates, and specific identifiers (user IDs). Inventory data form the foundation for any formal interaction between individuals and services, facilities, or systems, by enabling unique assignment and communication.
  • Log data: Protocol data, or log data, refer to information regarding events or activities that have been logged within a system or network. These data typically include details such as timestamps, IP addresses, user actions, error messages, and other specifics about the usage or operation of a system. Protocol data is often used for analyzing system issues, monitoring security, or generating performance reports.
  • Meta, communication and process data: Meta-, communication, and procedural data are categories that contain information about how data is processed, transmitted, and managed. Meta-data, also known as data about data, include information that describes the context, origin, and structure of other data. They can include details about file size, creation date, the author of a document, and modification histories. Communication data capture the exchange of information between users across various channels, such as email traffic, call logs, messages in social networks, and chat histories, including the involved parties, timestamps, and transmission paths. Procedural data describe the processes and operations within systems or organisations, including workflow documentations, logs of transactions and activities, and audit logs used for tracking and verifying procedures.
  • Personal Data: "personal data" means any information relating to an identified or identifiable natural person ("data subject"); an identifiable natural person is one who can be identified, directly or indirectly, in particular by reference to an identifier such as a name, an identification number, location data, an online identifier or to one or more factors specific to the physical, physiological, genetic, mental, economic, cultural or social identity of that natural person.
  • Processing: The term "processing" covers a wide range and practically every handling of data, be it collection, evaluation, storage, transmission or erasure.
  • Usage data: Usage data refer to information that captures how users interact with digital products, services, or platforms. These data encompass a wide range of information that demonstrates how users utilise applications, which features they prefer, how long they spend on specific pages, and through what paths they navigate an application. Usage data can also include the frequency of use, timestamps of activities, IP addresses, device information, and location data. They are particularly valuable for analysing user behaviour, optimising user experiences, personalising content, and improving products or services. Furthermore, usage data play a crucial role in identifying trends, preferences, and potential problem areas within digital offerings
\ No newline at end of file diff --git a/qmra/templates/head.html b/qmra/templates/head.html new file mode 100644 index 0000000..dbab03f --- /dev/null +++ b/qmra/templates/head.html @@ -0,0 +1,23 @@ +{% load static %} + + + + + {% block title %} QMRA {% endblock %} + + + + + + + + + + + + + + + {% block script%} + {%endblock%} + \ No newline at end of file diff --git a/qmra/templates/index.html b/qmra/templates/index.html new file mode 100644 index 0000000..f1be1d1 --- /dev/null +++ b/qmra/templates/index.html @@ -0,0 +1,61 @@ +{% extends "layout.html" %} +{% load static %} +{% block body %} +{% if message %} + +{% endif %} +
+
+
+
+ +
+

+

Welcome to the QMRA Tool for Water Reuse
+

This tool is designed to support Quantitative Microbial Risk Assessment (QMRA) for human health in the + context of water reuse. Whether you're evaluating the safety of treated wastewater or other reclaimed + water sources, this tool helps you toassess potential health risks associated with microbial contaminants. + This tool is completely free to use.

+
+
+
Try without an account
+

QMRA is a free and powerful tool to assess microbial + risks in water reuse. Use it without logging in for quick, easy access. +

+ Try out +
+
+
Login
+
+ {% csrf_token %} +
+ +
+
+ +
+ +
+
Don't have an account? Register + here.
+ + + +
+
+ +
Enhanced Features for Registered Users
+

If you choose to log in, you'll unlock additional features that enhance your experience: +

    +
  • Save Your Assessments: Store your risk assessments for future reference.
  • +
  • Compare Results: Analyze and compare multiple risk assessments side by side.
  • +
  • Export Options: Download and share your results in convenient formats.
  • +
+

With or without an account, this tool provides risk estimations. Let's get started on your risk + assessment journey!

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/qmra/templates/layout.html b/qmra/templates/layout.html new file mode 100644 index 0000000..a7eb901 --- /dev/null +++ b/qmra/templates/layout.html @@ -0,0 +1,87 @@ +{% load static %} + + + {% include "head.html" %} + {% block script%} + {%endblock%} + + +
+ {% block body %} + {% endblock %} +
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + + + + + + + diff --git a/qmra/urls.py b/qmra/urls.py new file mode 100644 index 0000000..ceba860 --- /dev/null +++ b/qmra/urls.py @@ -0,0 +1,16 @@ +from django.contrib import admin +from django.urls import include, path +from qmra import views + +urlpatterns = [ + path("", views.index, name="index"), + path("health", views.health, name="health"), + path("ready", views.ready, name="ready"), + path("dsgvo", views.dsgvo, name="dsgvo"), + path("faqs", views.faqs, name="faqs"), + path("imprint", views.imprint, name="imprint"), + path('admin/', admin.site.urls, name="admin-site"), + path("accounts/", include("django.contrib.auth.urls")), + path('', include('qmra.risk_assessment.urls')), + path('', include('qmra.user.urls')), +] diff --git a/qmra/user/__init__.py b/qmra/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qmra/user/admin.py b/qmra/user/admin.py new file mode 100644 index 0000000..d7e28e7 --- /dev/null +++ b/qmra/user/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin + +from qmra.user.models import User + + +# Register your models here. +@admin.register(User) +class UserAdmin(admin.ModelAdmin): + list_display = ("id", "username") diff --git a/qmra/user/apps.py b/qmra/user/apps.py new file mode 100644 index 0000000..bd08efe --- /dev/null +++ b/qmra/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'qmra.user' diff --git a/qmra/user/migrations/0001_initial.py b/qmra/user/migrations/0001_initial.py new file mode 100644 index 0000000..3b26b53 --- /dev/null +++ b/qmra/user/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.0.6 on 2024-07-24 06:40 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/qmra/user/migrations/__init__.py b/qmra/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/qmra/user/models.py b/qmra/user/models.py new file mode 100644 index 0000000..1d67b8d --- /dev/null +++ b/qmra/user/models.py @@ -0,0 +1,6 @@ +from django.contrib.auth.models import AbstractUser + + +# Create your models here. +class User(AbstractUser): + pass diff --git a/tools/qmratool/templates/qmratool/change_password.html b/qmra/user/templates/change_password.html similarity index 81% rename from tools/qmratool/templates/qmratool/change_password.html rename to qmra/user/templates/change_password.html index 1963bbf..5920398 100644 --- a/tools/qmratool/templates/qmratool/change_password.html +++ b/qmra/user/templates/change_password.html @@ -1,4 +1,4 @@ -{% extends "qmratool/layout.html" %} +{% extends "layout.html" %} {% load crispy_forms_tags %} {% block body %} @@ -23,9 +23,6 @@ -
- ... -
{% endblock %} \ No newline at end of file diff --git a/tools/qmratool/templates/qmratool/login.html b/qmra/user/templates/login.html similarity index 59% rename from tools/qmratool/templates/qmratool/login.html rename to qmra/user/templates/login.html index 812efe5..4fd7d62 100644 --- a/tools/qmratool/templates/qmratool/login.html +++ b/qmra/user/templates/login.html @@ -1,4 +1,5 @@ -{% extends "qmratool/layout.html" %} +{% extends "layout.html" %} +{% load static %} {% block body %} {% if message %}