diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dc598d1..4c291b4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,6 +5,9 @@ on: push: branches: - main + pull_request: + branches: + - main jobs: test: @@ -25,6 +28,7 @@ jobs: environment: dev secrets: inherit build-prod: + if: github.ref_name == 'main' needs: test name: build-prod uses: ./.github/workflows/build.yaml @@ -32,6 +36,7 @@ jobs: environment: prod secrets: inherit deploy-prod: + if: github.ref_name == 'main' needs: - build-prod - deploy-dev diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 1c490ff..b651671 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -25,7 +25,7 @@ jobs: username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SERVER_SSH_KEY }} script: | - cd ${{ secrets.DEPLOY_PATH }} && git pull + 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/.gitignore b/.gitignore index 6d6a646..0241599 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ venv/ .idea .static qmra.db +default_qmra_data.db qmra-prod.db dump* prod-migrations/ -*.tar \ No newline at end of file +*.tar +.vscode \ No newline at end of file diff --git a/infra/helm/qmra/dev.values.yaml b/infra/helm/qmra/dev.values.yaml index a0ee399..0a97e88 100644 --- a/infra/helm/qmra/dev.values.yaml +++ b/infra/helm/qmra/dev.values.yaml @@ -17,6 +17,10 @@ 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 diff --git a/infra/helm/qmra/prod.values.yaml b/infra/helm/qmra/prod.values.yaml index 5fbfdc4..ed3d45f 100644 --- a/infra/helm/qmra/prod.values.yaml +++ b/infra/helm/qmra/prod.values.yaml @@ -17,6 +17,10 @@ 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 diff --git a/infra/helm/qmra/templates/configmap.yaml b/infra/helm/qmra/templates/configmap.yaml index 3f85df0..270665e 100644 --- a/infra/helm/qmra/templates/configmap.yaml +++ b/infra/helm/qmra/templates/configmap.yaml @@ -7,6 +7,7 @@ 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 diff --git a/infra/helm/qmra/templates/deployment.yaml b/infra/helm/qmra/templates/deployment.yaml index 1e3ef03..e4b9c81 100644 --- a/infra/helm/qmra/templates/deployment.yaml +++ b/infra/helm/qmra/templates/deployment.yaml @@ -37,6 +37,15 @@ spec: - 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 ] containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" @@ -68,12 +77,17 @@ spec: 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 diff --git a/infra/helm/qmra/templates/volumes.yaml b/infra/helm/qmra/templates/volumes.yaml index 0094e2c..95859d5 100644 --- a/infra/helm/qmra/templates/volumes.yaml +++ b/infra/helm/qmra/templates/volumes.yaml @@ -31,6 +31,22 @@ spec: 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 @@ -53,6 +69,20 @@ spec: - 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 diff --git a/qmra/management/commands/collect_static_default_entities.py b/qmra/management/commands/collect_static_default_entities.py index 0ade9ae..74d1af4 100644 --- a/qmra/management/commands/collect_static_default_entities.py +++ b/qmra/management/commands/collect_static_default_entities.py @@ -30,7 +30,8 @@ def get_default_inflows(): 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))] - return inflows.loc[:, ["source_name", "pathogen_name", "min", "max", "ReferenceID"]] + inflows["id"] = list(range(len(inflows.index))) + return inflows.loc[:, ["id", "source_name", "pathogen_name", "min", "max", "ReferenceID"]] def get_default_treatments(): 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/risk_assessment/admin.py b/qmra/risk_assessment/admin.py index 56402c8..e554c13 100644 --- a/qmra/risk_assessment/admin.py +++ b/qmra/risk_assessment/admin.py @@ -1,8 +1,73 @@ from django.contrib import admin +from django.core.management import call_command -# Register your models here. -# @admin.register(Health) -# class HealthAdmin(admin.ModelAdmin): -# pass +from qmra.risk_assessment.qmra_models import QMRASource, QMRAPathogen, QMRAInflow, \ + QMRATreatment, QMRAExposure, QMRAReference -# admin.site.register(RiskAssessment) +""" + +""" + + +def save_model(self, request, obj, form, change): + super(type(self), self).save_model(request, obj, form, change) + call_command("export_default") + call_command("collectstatic", "--no-input") + + +@admin.register(QMRAReference) +class QMRAReferenceAdmin(admin.ModelAdmin): + list_display = ["name", "link"] + save_model = save_model + + +class QMRAInflowInline(admin.TabularInline): + model = QMRAInflow + fields = ["pathogen", "min", "max", "reference"] + + +@admin.register(QMRASource) +class QMRASourceAdmin(admin.ModelAdmin): + list_display = ["name", "description"] + inlines = [QMRAInflowInline] + + save_model = save_model + + +@admin.register(QMRAExposure) +class QMRAExposureAdmin(admin.ModelAdmin): + list_display = ["name", "events_per_year", "volume_per_event"] + # inlines = [ReferenceInline] + + save_model = save_model + + +@admin.register(QMRAPathogen) +class QMRAPathogenAdmin(admin.ModelAdmin): + list_display = ["name", "group"] + + save_model = save_model + + +@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_reference", + ("viruses_min", "viruses_max"), + "viruses_reference", + ("protozoa_min", "protozoa_max"), + "protozoa_reference" + ] + + save_model = save_model diff --git a/qmra/risk_assessment/dbrouter.py b/qmra/risk_assessment/dbrouter.py new file mode 100644 index 0000000..ad17999 --- /dev/null +++ b/qmra/risk_assessment/dbrouter.py @@ -0,0 +1,20 @@ +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" + return db == "default" diff --git a/qmra/risk_assessment/forms.py b/qmra/risk_assessment/forms.py index f8c1739..57b1bc7 100644 --- a/qmra/risk_assessment/forms.py +++ b/qmra/risk_assessment/forms.py @@ -5,8 +5,9 @@ from crispy_forms.helper import FormHelper from crispy_forms.layout import Layout, Field, Row, Column, HTML -from qmra.risk_assessment.models import Inflow, DefaultTreatments, Treatment, \ - RiskAssessment, DefaultExposures, DefaultSources +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 @@ -31,8 +32,8 @@ class Meta: 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 = DefaultExposures.choices() - self.fields["source_name"].choices = DefaultSources.choices() + 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" @@ -207,10 +208,11 @@ def clean(self): class AddTreatmentForm(forms.Form): - select_treatment = forms.ChoiceField(choices=DefaultTreatments.choices(), widget=forms.Select()) + 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() @@ -234,9 +236,13 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_tag = False - self.form.base_fields["name"].choices = DefaultTreatments.choices() + 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 = [ 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/models.py b/qmra/risk_assessment/models.py index e0ec463..1522351 100644 --- a/qmra/risk_assessment/models.py +++ b/qmra/risk_assessment/models.py @@ -1,137 +1,19 @@ -import enum -import json import uuid -import numpy as np 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 -from itertools import groupby -from typing import Optional, Any -import abc -import dataclasses as dtc -from django.utils.functional import classproperty -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]]: - if cls._raw_data is None: - 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 PathogenGroup(models.TextChoices): - Bacteria = "Bacteria" - Viruses = "Viruses" - Protozoa = "Protozoa" - - -class ModelDistributionType(enum.Enum): - exponential = "exponential" - beta_poisson = "beta-Poisson" - - -@dtc.dataclass -class DefaultPathogenModel: - group: PathogenGroup - name: str - # fields from "doseResponse.csv" - best_fit_model: ModelDistributionType - k: Optional[float] - alpha: Optional[float] - n50: Optional[float] - # fields from "health.csv" - infection_to_illness: Optional[float] = None - dalys_per_case: Optional[float] = None - - @classmethod - def from_dict(cls, data) -> "DefaultPathogenModel": - return DefaultPathogenModel( - group=PathogenGroup(data["group"]), - name=data["name"], - best_fit_model=ModelDistributionType(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 get_distribution(self): - if self.best_fit_model == ModelDistributionType.exponential: - return ExponentialDistribution(self.k) - elif self.best_fit_model == ModelDistributionType.beta_poisson: - return BetaPoissonDistribution(self.alpha, self.n50) - - -class DefaultPathogens(StaticEntity): - source = "qmra/static/data/default-pathogens.json" - model = DefaultPathogenModel - primary_key = "name" - - @classmethod - def choices(cls): - grouped = {grp.value: 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()], - ] +# @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=DefaultPathogens.choices(), + pathogen = models.CharField(choices=QMRAPathogens.choices(), blank=False, null=False, max_length=256) # reference = models.ForeignKey( # Reference, blank=True, null=True, default=None, @@ -142,110 +24,6 @@ class Inflow(models.Model): # notes = models.CharField(max_length=200, default="unknown") -@dtc.dataclass -class DefaultInflowModel: - pathogen_name: str - source_name: str - min: float - max: float - - @classmethod - def from_dict(cls, data: dict): - return DefaultInflowModel( - pathogen_name=data["pathogen_name"], - source_name=data["source_name"], - min=data["min"], - max=data["max"] - ) - - -class DefaultInflows(StaticEntity): - source = "qmra/static/data/default-inflows.json" - model = DefaultInflowModel - primary_key = None - - @classproperty - def data(cls): - return {k: [DefaultInflowModel.from_dict(d) for d in data] - for k, data in cls.raw_data.items()} - - @classmethod - def choices(cls): - return [] - - -@dtc.dataclass -class DefaultSourceModel: - id: int - name: str - description: str - inflows: list[DefaultInflowModel] - - @classmethod - def from_dict(cls, data: dict): - return DefaultSourceModel( - id=data["id"], - name=data["name"], - description=data["description"], - inflows=DefaultInflows.get(data["name"]) - ) - - -class DefaultSources(StaticEntity): - source = "qmra/static/data/default-sources.json" - model = DefaultSourceModel - 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") - ] - - -@dtc.dataclass -class DefaultTreatmentModel: - name: str - group: str # TextChoices? - description: str - bacteria_min: Optional[float] - bacteria_max: Optional[float] - viruses_min: Optional[float] - viruses_max: Optional[float] - protozoa_min: Optional[float] - protozoa_max: Optional[float] - - @classmethod - def from_dict(cls, data): - return DefaultTreatmentModel( - 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'], - ) - - -class DefaultTreatments(StaticEntity): - source = "qmra/static/data/default-treatments.json" - model = DefaultTreatmentModel - 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 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) @@ -259,7 +37,7 @@ class Treatment(models.Model): protozoa_max = models.FloatField(blank=True, null=True) @classmethod - def from_default(cls, default: DefaultTreatmentModel, risk_assessment): + def from_default(cls, default: QMRATreatment, risk_assessment): return Treatment.objects.create( risk_assessment=risk_assessment, name=default.name, @@ -272,47 +50,13 @@ def from_default(cls, default: DefaultTreatmentModel, risk_assessment): ) -@dtc.dataclass -class DefaultExposureModel: - name: str - description: str - events_per_year: int - volume_per_event: float - - @classmethod - def from_dict(cls, data): - return DefaultExposureModel( - name=data["name"], - description=data["description"], - events_per_year=data["events_per_year"], - volume_per_event=data["volume_per_event"], - ) - - -class DefaultExposures(StaticEntity): - source = "qmra/static/data/default-exposures.json" - model = DefaultExposureModel - 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") - ] - - 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 - ) + source_name = models.CharField(blank=True, max_length=256) inflows: QuerySet[Inflow] treatments: QuerySet[Treatment] exposure_name = models.CharField(blank=True, max_length=256) @@ -348,7 +92,7 @@ def __str__(self): class RiskAssessmentResult(models.Model): risk_assessment = models.ForeignKey(RiskAssessment, on_delete=models.CASCADE, related_name="results") - pathogen = models.CharField(choices=DefaultPathogens.choices(), max_length=256) + 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() diff --git a/qmra/risk_assessment/qmra_models.py b/qmra/risk_assessment/qmra_models.py new file mode 100644 index 0000000..9911bba --- /dev/null +++ b/qmra/risk_assessment/qmra_models.py @@ -0,0 +1,361 @@ +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_reference = models.ForeignKey(QMRAReference, blank=True, null=True, on_delete=models.CASCADE, + related_name="bacteria_lrv") + viruses_min: Optional[float] = models.FloatField(blank=True, null=True) + viruses_max: Optional[float] = models.FloatField(blank=True, null=True) + viruses_reference = models.ForeignKey(QMRAReference, blank=True, null=True, on_delete=models.CASCADE, + related_name="viruses_lrv") + protozoa_min: Optional[float] = models.FloatField(blank=True, null=True) + protozoa_max: Optional[float] = models.FloatField(blank=True, null=True) + protozoa_reference = models.ForeignKey(QMRAReference, blank=True, null=True, on_delete=models.CASCADE, + related_name="protozoa_lrv") + + @classmethod + def from_dict(cls, data): + return QMRATreatment( + pk=data["id"], + name=data['name'], + group=data['group'], + description=data['description'], + bacteria_min=data['bacteria_min'], + bacteria_max=data['bacteria_max'], + bacteria_reference_id=int(data["bacteria_reference"]) \ + if data["bacteria_reference"] is not None else None, + viruses_min=data['viruses_min'], + viruses_max=data['viruses_max'], + viruses_reference_id=int(data["viruses_reference"]) \ + if data["viruses_reference"] is not None else None, + protozoa_min=data['protozoa_min'], + protozoa_max=data['protozoa_max'], + protozoa_reference_id=int(data["protozoa_reference"]) \ + if data["protozoa_reference"] is not None else None, + ) + + def to_dict(self): + data = model_to_dict(self) + data["bacteria_reference"] = str(self.bacteria_reference.pk) if self.bacteria_reference is not None else None + data["viruses_reference"] = str(self.viruses_reference.pk) if self.viruses_reference is not None else None + data["protozoa_reference"] = str(self.protozoa_reference.pk) if self.protozoa_reference is not None else None + return data + + +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 index a365b0b..eaf888b 100644 --- a/qmra/risk_assessment/risk.py +++ b/qmra/risk_assessment/risk.py @@ -2,7 +2,8 @@ import numpy as np -from qmra.risk_assessment.models import RiskAssessment, RiskAssessmentResult, Treatment, PathogenGroup, DefaultPathogens +from qmra.risk_assessment.models import RiskAssessment, RiskAssessmentResult, Treatment +from qmra.risk_assessment.qmra_models import PathogenGroup, QMRAPathogens def get_annual_risk( @@ -54,7 +55,7 @@ def assess_risk(risk_assessment: RiskAssessment, inflows, treatments, save=True) for inflow in inflows: # unpack params - pathogen = DefaultPathogens.get(inflow.pathogen) + pathogen = QMRAPathogens.get(inflow.pathogen) group = pathogen.group dist = pathogen.get_distribution() 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 @@ +