diff --git a/client/hawc_client/animal.py b/client/hawc_client/animal.py index 4c9d077a0..d6c1f2008 100644 --- a/client/hawc_client/animal.py +++ b/client/hawc_client/animal.py @@ -152,6 +152,30 @@ def bmds_endpoints(self, assessment_id: int, unpublished: bool = False) -> pd.Da response_json = self.session.get(url, params=params).json() return pd.DataFrame(response_json) + def update_endpoint(self, endpoint_id: int, data: dict) -> dict: + """ + Update an existing endpoint via PATCH. + + Args: + endpoint_id (int): Endpoint ID + data (dict): fields to update + + Returns: + dict: The resulting object, if update was successful + """ + url = f"{self.session.root_url}/ani/api/endpoint/{endpoint_id}/" + return self.session.patch(url, data).json() + + def delete_endpoint(self, endpoint_id: int) -> None: + """ + Delete an endpoint. + + Args: + endpoint_id (int): Endpoint ID + """ + url = f"{self.session.root_url}/ani/api/endpoint/{endpoint_id}/" + self.session.delete(url) + def metadata(self) -> dict: """ Retrieves field choices for all animal models. diff --git a/client/hawc_client/assessment.py b/client/hawc_client/assessment.py index ae8c3efcc..f01c0c347 100644 --- a/client/hawc_client/assessment.py +++ b/client/hawc_client/assessment.py @@ -107,6 +107,125 @@ def delete(self, assessment_id: int) -> Response: url = f"{self.session.root_url}/assessment/api/assessment/{assessment_id}/" return self.session.delete(url) + def list_species(self) -> list[dict]: + """ + List all species. + + Returns: + list[dict]: Species data + """ + url = f"{self.session.root_url}/assessment/api/species/" + return self.session.get(url).json() + + def create_species(self, data: dict) -> dict: + """ + Create a new species. + + Args: + data (dict): required metadata (name) + + Returns: + dict: The created species + """ + url = f"{self.session.root_url}/assessment/api/species/" + return self.session.post(url, data).json() + + def update_species(self, species_id: int, data: dict) -> dict: + """ + Update an existing species. + + Args: + species_id (int): species ID + data (dict): fields to update + + Returns: + dict: The updated species + """ + url = f"{self.session.root_url}/assessment/api/species/{species_id}/" + return self.session.patch(url, data).json() + + def delete_species(self, species_id: int) -> Response: + """ + Delete a species. + + Args: + species_id (int): species ID + + Returns: + Response: The response object. + """ + url = f"{self.session.root_url}/assessment/api/species/{species_id}/" + return self.session.delete(url) + + def list_strains(self, species_id: int | None = None) -> list[dict]: + """ + List all strains, optionally filtered by species. + + Args: + species_id (int, optional): Filter by species ID + + Returns: + list[dict]: Strain data + """ + url = f"{self.session.root_url}/assessment/api/strain/" + params = {} + if species_id is not None: + params["species"] = species_id + return self.session.get(url, params=params).json() + + def create_strain(self, data: dict) -> dict: + """ + Create a new strain. + + Args: + data (dict): required metadata (name, species) + + Returns: + dict: The created strain + """ + url = f"{self.session.root_url}/assessment/api/strain/" + return self.session.post(url, data).json() + + def update_strain(self, strain_id: int, data: dict) -> dict: + """ + Update an existing strain. + + Args: + strain_id (int): strain ID + data (dict): fields to update + + Returns: + dict: The updated strain + """ + url = f"{self.session.root_url}/assessment/api/strain/{strain_id}/" + return self.session.patch(url, data).json() + + def delete_strain(self, strain_id: int) -> Response: + """ + Delete a strain. + + Args: + strain_id (int): strain ID + + Returns: + Response: The response object. + """ + url = f"{self.session.root_url}/assessment/api/strain/{strain_id}/" + return self.session.delete(url) + + def create_dose_units(self, name: str) -> dict: + """ + Create a new dose unit. + + Args: + name (str): Name of the dose unit + + Returns: + dict: The created dose unit + """ + url = f"{self.session.root_url}/ani/api/dose-units/" + return self.session.post(url, {"name": name}).json() + def effect_tag_create(self, name: str, slug: str) -> dict: """Create an effect tag. diff --git a/client/hawc_client/literature.py b/client/hawc_client/literature.py index 7a88bddab..c14e087b1 100644 --- a/client/hawc_client/literature.py +++ b/client/hawc_client/literature.py @@ -36,6 +36,20 @@ def _import( url = f"{self.session.root_url}/lit/api/search/" return self.session.post(url, payload).json() + def create_reference(self, data: dict) -> dict: + """ + Create a manual reference (no external ID required). + + Args: + data (dict): Reference data. Required: assessment, title. + Optional: authors_short, authors, year, journal, abstract, full_text_url. + + Returns: + dict: The created reference + """ + url = f"{self.session.root_url}/lit/api/reference/" + return self.session.post(url, data).json() + def import_hero(self, assessment_id: int, title: str, description: str, ids: list[int]) -> dict: """ Imports a list of HERO IDs as literature references for the given assessment. diff --git a/client/hawc_client/vocab.py b/client/hawc_client/vocab.py index 58a64b955..7260960c3 100644 --- a/client/hawc_client/vocab.py +++ b/client/hawc_client/vocab.py @@ -1,3 +1,5 @@ +from requests import Response + from .client import BaseClient @@ -32,6 +34,112 @@ def bulk_update(self, terms: list[dict]) -> list[dict]: url = f"{self.session.root_url}/vocab/api/term/bulk-update/" return self.session.patch(url, terms).json() + def list_guidelines(self) -> list[dict]: + """ + List all guidelines. + + Returns: + list[dict]: Guideline data + """ + url = f"{self.session.root_url}/vocab/api/guideline/" + return self.session.get(url).json() + + def create_guideline(self, data: dict) -> dict: + """ + Create a new guideline. + + Args: + data (dict): required metadata + + Returns: + dict: The created guideline + """ + url = f"{self.session.root_url}/vocab/api/guideline/" + return self.session.post(url, data).json() + + def update_guideline(self, guideline_id: int, data: dict) -> dict: + """ + Update an existing guideline. + + Args: + guideline_id (int): guideline ID + data (dict): fields to update + + Returns: + dict: The updated guideline + """ + url = f"{self.session.root_url}/vocab/api/guideline/{guideline_id}/" + return self.session.patch(url, data).json() + + def delete_guideline(self, guideline_id: int) -> Response: + """ + Delete a guideline. + + Args: + guideline_id (int): guideline ID + + Returns: + Response: The response object. + """ + url = f"{self.session.root_url}/vocab/api/guideline/{guideline_id}/" + return self.session.delete(url) + + def list_guideline_profiles(self, guideline_id: int | None = None) -> list[dict]: + """ + List all guideline profiles, optionally filtered by guideline. + + Args: + guideline_id (int, optional): Filter by guideline ID + + Returns: + list[dict]: GuidelineProfile data + """ + url = f"{self.session.root_url}/vocab/api/guideline-profile/" + params = {} + if guideline_id is not None: + params["guideline"] = guideline_id + return self.session.get(url, params=params).json() + + def create_guideline_profile(self, data: dict) -> dict: + """ + Create a new guideline profile. + + Args: + data (dict): required metadata + + Returns: + dict: The created guideline profile + """ + url = f"{self.session.root_url}/vocab/api/guideline-profile/" + return self.session.post(url, data).json() + + def update_guideline_profile(self, profile_id: int, data: dict) -> dict: + """ + Update an existing guideline profile. + + Args: + profile_id (int): guideline profile ID + data (dict): fields to update + + Returns: + dict: The updated guideline profile + """ + url = f"{self.session.root_url}/vocab/api/guideline-profile/{profile_id}/" + return self.session.patch(url, data).json() + + def delete_guideline_profile(self, profile_id: int) -> Response: + """ + Delete a guideline profile. + + Args: + profile_id (int): guideline profile ID + + Returns: + Response: The response object. + """ + url = f"{self.session.root_url}/vocab/api/guideline-profile/{profile_id}/" + return self.session.delete(url) + def uids(self) -> list[tuple[int, int]]: """ Get all term ids and uids. diff --git a/docs/docs/database.md b/docs/docs/database.md index 5ba9df5ff..171e94fff 100644 --- a/docs/docs/database.md +++ b/docs/docs/database.md @@ -18,6 +18,11 @@ The HAWC database is a [PostgreSQL](https://www.postgresql.org/) database. See d ## Animal bioassay schema +
+ ![HAWC animal bioassay v2 data schema](./static/img/hawc-schema-animalv2.png) +
Animal bioassay v2 schema. The image is very large; please save/or open in another tab.
+
+
![HAWC animal bioassay data schema](./static/img/hawc-schema-animal.png)
Animal bioassay schema. The image is very large; please save/or open in another tab.
@@ -84,6 +89,7 @@ pip install -U pydot manage graph_models -g --pydot -o ./docs/docs/static/img/hawc-schema-lit.png lit study manage graph_models -g --pydot -o ./docs/docs/static/img/hawc-schema-study.png study riskofbias manage graph_models -g --pydot -o ./docs/docs/static/img/hawc-schema-animal.png animal +manage graph_models -g --pydot -o ./docs/docs/static/img/hawc-schema-animalv2.png animalv2 manage graph_models -g --pydot -o ./docs/docs/static/img/hawc-schema-bmd.png bmd manage graph_models -g --pydot -o ./docs/docs/static/img/hawc-schema-epi.png epi manage graph_models -g --pydot -o ./docs/docs/static/img/hawc-schema-epiv2.png epiv2 diff --git a/docs/docs/development.md b/docs/docs/development.md index cc35d37ff..3f4623b95 100644 --- a/docs/docs/development.md +++ b/docs/docs/development.md @@ -44,6 +44,10 @@ uv pip install -e client # create a PostgreSQL database and superuser createuser --superuser --no-password hawc createdb -E UTF-8 -U hawc hawc + +# create test databases (required for running unit tests) +createdb -T template0 -E UTF8 hawc-fixture +createdb -T template0 -E UTF8 hawc-test ``` For Windows, using Anaconda or Miniconda is preferred for additional dependencies: @@ -76,9 +80,12 @@ pg_ctl -D pgdata initdb mkdir pgdata\logs pg_ctl -D pgdata -l pgdata\logs\logfile start -:: create our superuser and main/test databases +:: create a PostgreSQL database and superuser createuser --superuser --no-password hawc createdb -T template0 -E UTF8 hawc + +:: create test databases (required for running unit tests) +createdb -T template0 -E UTF8 hawc-fixture createdb -T template0 -E UTF8 hawc-test ``` diff --git a/frontend/animal/EndpointForm/constants.js b/frontend/animal/EndpointForm/constants.js index 5fedc0ab5..b9341f99e 100644 --- a/frontend/animal/EndpointForm/constants.js +++ b/frontend/animal/EndpointForm/constants.js @@ -62,14 +62,14 @@ const termUrlLookupMap = { endpoint_name_parent: "effect_subtype_term_id", }, helpText: { - system_popup: `The health effect category/biological system an endpoint/outcome or group of related endpoints/outcomes within a health effect category. "Multi-system" is an option for widespread effects. If the Endpoint is measured in Blood, Urine, or biological media other than the affected system, extract the media term in the Effect Subtype field.`, + system_popup: `The health effect category/biological system an endpoint/outcome or group of related endpoints/outcomes within a health effect category. 'System' maps to 'endpoint_category' in ToxRefDB as the broadest descriptive term for an endpoint. Possible endpoint categories include: systemic, developmental, reproductive, and cholinesterase.`, system: `The health effect category/biological system an endpoint/outcome or group of related endpoints/outcomes within a health effect category. Please use a controlled vocabulary term if possible.`, - effect_popup: `A group of related outcomes considered as a health effect category and/or unit of analysis typically considered together during evidence synthesis.`, + effect_popup: `A group of related outcomes considered as a health effect category and/or unit of analysis typically considered together during evidence synthesis. 'Effect' maps to 'endpoint_type' in ToxRefDB as a subcategory for endpoint_category, which is more descriptive for a particular endpoint (e.g. pathology gross, clinical chemistry, reproductive performance, etc.`, effect: `Related outcomes (e.g., unit of analysis) considered together during evidence synthesis. Please use a controlled vocabulary term if possible.`, - effect_subtype_popup: `An outcome or measurement within an effect.`, + effect_subtype_popup: `An outcome or measurement within an effect. 'Effect_subtype' maps to 'Endpoint_target' in ToxRefDB indicating where or how the sample was collected to supply data for a particular endpoint. Typically describes an organ/tissue or metabolite/protein measured.`, effect_subtype: `Please use a controlled vocabulary term if possible.`, endpoint_name_popup: `An observable or measurable biological change used as an index of a potential health effect of an exposure. Endpoint may also be referred to as effect, outcome, or event. Search for the best match based on the author reported term. Use the field "Diagnostic (as reported)" to capture as reported by study authors. If no existing term matches, deselect use EHV for endpoint and enter the name as reported. Do not include units. If an endpoint is a repeated measure, indicate the time in parentheses, [e.g., running wheel activity (6 wk)], using the abbreviated format: seconds = sec, minutes = min, hours = h, days = d, weeks = wk, months = mon, years = y.`, - endpoint_name: `An observable or measurable biological change used as an index of a potential health effect of an exposure. Endpoint may also be referred to as effect or outcome. For a searchable list of ToxRefDB Vocabulary terms, see the ToxRefDB. Enter the term ID and all relationships to this term will automatically populate. Please use a controlled vocabulary term if possible.`, + endpoint_name: `An observable or measurable biological change used as an index of a potential health effect of an exposure. 'Endpoint_name' maps to 'Effect_desc' in ToxRefDB, detailing a specific condition associated with an endpoint_target (e.g. dysplasia, atrophy, necrosis, etc.`, }, }, defaultHelpText = { diff --git a/frontend/animal/VocabBrowser/ToxRefDBBrowser/Table.js b/frontend/animal/VocabBrowser/ToxRefDBBrowser/Table.js index ce54b1f00..04300a2ff 100644 --- a/frontend/animal/VocabBrowser/ToxRefDBBrowser/Table.js +++ b/frontend/animal/VocabBrowser/ToxRefDBBrowser/Table.js @@ -44,28 +44,28 @@ class Table extends Component { - Endpoint Category + System - Endpoint Type + Effect - Endpoint Target + Effect Subtype - Effect Description + Endpoint/Outcome ${v.name}`; + }).join(""); + + $("#id_strain").html(opts); + $(`#id_strain option[value="${selected}"]`).prop("selected", true); + + if (onStrainUpdateComplete !== undefined) { + onStrainUpdateComplete(); + } + }; + $.get("/assessment/api/strain", {species: $("#id_species").val()}, update_strain_opts); + }; + $(form).find("#id_species").change(onSpeciesChange).trigger("change"); + + // refresh species after "Add new strain" popup closes. Wait half a second + // to give the addition, if any, time to register. + $("a[title='Create strain']").on( + window.app.HAWCUtils.HAWC_NEW_WINDOW_POPUP_CLOSING, + function (_e) { + setTimeout(function () { + let numStrainsBefore = $("#id_strain option").length; + + // reload the species + onSpeciesChange(null, function () { + let strains = $("#id_strain option"); + + if (strains.length > numStrainsBefore) { + // a new one was added; let's select it. + let highestId = -1; + strains.each(function () { + highestId = Math.max(Number($(this).val()), highestId); + }); + + $("#id_strain").val(highestId); + } + }); + }, 500); + } + ); + }, + dataExtractionFormStartup = function (form) { + // TODO fix - names are dataextraction-1-is_qualitative_only + let onQualitativeChange = function () { + let isQualOnly = $(form).find("#id_is_qualitative_only").is(":checked"); + + let quantitativeFields = [ + "data_location", + "dataset_type", + "variance_type", + "statistical_method", + "statistical_power", + "method_to_control_for_litter_effects", + "values_estimated", + "response_units", + "dose_response_observations", + "result_details", + ]; + + quantitativeFields.forEach(function (fieldName) { + // ok to just hide values; the server will validate + let parentDiv = $(`#id_${fieldName}`).parents("div.form-group"); + if (isQualOnly) { + parentDiv.addClass("hidden"); + } else { + parentDiv.removeClass("hidden"); + } + }); + }; + + $(form).find("#id_is_qualitative_only").change(onQualitativeChange).trigger("change"); + }, + formsetSetup = function (form, prefixes) { + if (!Array.isArray(prefixes)) { + prefixes = [prefixes]; + } + + $(form) + .find("button.add-subobject") + .each(function (index, _element) { + $(this).click(function () { + const formPrefix = prefixes[index], + parentWrapper = $(this).parent("div.formset_wrapper"), + lastRow = parentWrapper.find("tr:last"), + totalFormField = parentWrapper.find( + `input[name="${formPrefix}-TOTAL_FORMS"]` + ); + cloneSubformRow(lastRow, totalFormField); + }); + }); + }; + +export default document => { + document.body.addEventListener("htmx:load", e => { + if (e.target.querySelector(".form-experiment")) { + experimentFormStartup(e.target); + } else if (e.target.querySelector(".form-animalgroup")) { + animalGroupFormStartup(e.target); + } else if (e.target.querySelector(".form-treatment")) { + formsetSetup(e.target, "dosegroupform"); + } else if (e.target.querySelector(".form-dataextraction")) { + dataExtractionFormStartup(e.target); + formsetSetup(e.target, ["groupleveldataform", "animalleveldataform"]); + } + }); +}; diff --git a/frontend/animalv2/index.js b/frontend/animalv2/index.js new file mode 100644 index 000000000..a8a6f63c2 --- /dev/null +++ b/frontend/animalv2/index.js @@ -0,0 +1,5 @@ +import formStartup from "./form"; + +export default { + formStartup, +}; diff --git a/frontend/splits.js b/frontend/splits.js index 82867c78a..57f2a297a 100644 --- a/frontend/splits.js +++ b/frontend/splits.js @@ -7,6 +7,9 @@ const startup = (name, cb) => { case "animalStartup": import("./animal/index.js").then(app => cb(app.default)); break; + case "animalv2Startup": + import("./animalv2/index.js").then(app => cb(app.default)); + break; case "bmds2Startup": import("./bmd/bmds2/index.js").then(app => cb(app.default)); break; diff --git a/hawc/apps/animal/api.py b/hawc/apps/animal/api.py index 19d03bfc4..1014bc4a2 100644 --- a/hawc/apps/animal/api.py +++ b/hawc/apps/animal/api.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from ..assessment.api import ( + METHODS_NO_PUT, AssessmentViewSet, BaseAssessmentViewSet, CleanupFieldsBaseViewSet, @@ -264,10 +265,16 @@ def create(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_201_CREATED) -class Endpoint(mixins.CreateModelMixin, AssessmentViewSet): +class Endpoint( + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + AssessmentViewSet, +): assessment_filter_args = "assessment" model = models.Endpoint serializer_class = serializers.EndpointSerializer + http_method_names = METHODS_NO_PUT list_actions = ["list", "effects", "rob_filter", "update_terms"] def get_queryset(self): @@ -283,6 +290,24 @@ def perform_create(self, serializer): self.request.user.id, ) + @transaction.atomic + def perform_update(self, serializer): + super().perform_update(serializer) + create_object_log( + "Updated", + serializer.instance, + serializer.instance.get_assessment().id, + self.request.user.id, + ) + serializer.instance.get_assessment().bust_cache() + + @transaction.atomic + def perform_destroy(self, instance): + assessment = instance.get_assessment() + create_object_log("Deleted", instance, assessment.id, self.request.user.id) + super().perform_destroy(instance) + assessment.bust_cache() + @action(detail=False, action_perms=AssessmentViewSetPermissions.CAN_VIEW_OBJECT) def effects(self, request): effects = models.Endpoint.objects.get_effects(self.assessment.id) diff --git a/hawc/apps/animal/serializers.py b/hawc/apps/animal/serializers.py index 1540a0904..891c199b7 100644 --- a/hawc/apps/animal/serializers.py +++ b/hawc/apps/animal/serializers.py @@ -305,14 +305,26 @@ def _validate_term_and_text(self, data, term_field: str, text_field: str, term_t data[text_field] = term.name def validate(self, data): - # name or name_term must be given - if data.get("name") is None and data.get("name_term") is None: - raise serializers.ValidationError({"name": ["'name' or 'name_term' is required."]}) + is_update = self.instance is not None + + # name or name_term must be given (skip on partial update not touching these fields) + if not is_update: + if data.get("name") is None and data.get("name_term") is None: + raise serializers.ValidationError({"name": ["'name' or 'name_term' is required."]}) + elif "name" in data or "name_term" in data: + # on update, if either is being set, at least one must be non-null + name = data.get("name", self.instance.name) + name_term = data.get("name_term", self.instance.name_term) + if name is None and name_term is None: + raise serializers.ValidationError({"name": ["'name' or 'name_term' is required."]}) # Validate parent object - self.animal_group = get_matching_instance( - models.AnimalGroup, self.initial_data, "animal_group_id" - ) + if is_update: + self.animal_group = self.instance.animal_group + else: + self.animal_group = get_matching_instance( + models.AnimalGroup, self.initial_data, "animal_group_id" + ) user_can_edit_object(self.animal_group, self.context["request"].user, raise_exception=True) self.assessment = self.animal_group.get_assessment() data["animal_group_id"] = self.animal_group.id @@ -328,8 +340,26 @@ def validate(self, data): self._validate_term_and_text(data, "name_term", "name", "endpoint_name") # set animal_group on instance for cleaning rules - instance = models.Endpoint(animal_group=self.animal_group) - errors = forms.EndpointForm.clean_endpoint(instance, data) + if is_update: + clean_instance = self.instance + # For partial updates, use existing values for fields not being patched + clean_data = {} + for field_name in ( + "observation_time", + "observation_time_units", + "litter_effects", + "litter_effect_notes", + "confidence_interval", + "variance_type", + "data_type", + "response_units", + "data_extracted", + ): + clean_data[field_name] = data.get(field_name, getattr(self.instance, field_name)) + errors = forms.EndpointForm.clean_endpoint(clean_instance, clean_data) + else: + instance = models.Endpoint(animal_group=self.animal_group) + errors = forms.EndpointForm.clean_endpoint(instance, data) if errors: err = {k: [v] for k, v in errors.items()} raise serializers.ValidationError(err) @@ -374,6 +404,27 @@ def create(self, validated_data): instance.effects.set(effects) return instance + @transaction.atomic + def update(self, instance, validated_data): + validated_data.pop("groups", None) + effects = validated_data.pop("effects", None) + + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + + # replace groups if provided + if self.group_serializers: + instance.groups.all().delete() + for group_serializer in self.group_serializers: + group_serializer.save(endpoint_id=instance.id) + + # replace effects if provided + if effects is not None: + instance.effects.set(effects) + + return instance + class Meta: model = models.Endpoint fields = "__all__" diff --git a/hawc/apps/animalv2/admin.py b/hawc/apps/animalv2/admin.py new file mode 100644 index 000000000..bf34103c6 --- /dev/null +++ b/hawc/apps/animalv2/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from reversion.admin import VersionAdmin + +from . import models + + +@admin.register(models.StudyLevelValue) +class StudyValuesAdmin(VersionAdmin, admin.ModelAdmin): + list_display = ("id", "study", "system", "value_type", "value", "units") + list_filter = ("system", "value_type") + list_select_related = ("units",) + search_fields = ("study__short_citation", "system") diff --git a/hawc/apps/animalv2/api.py b/hawc/apps/animalv2/api.py new file mode 100644 index 000000000..0b3626bea --- /dev/null +++ b/hawc/apps/animalv2/api.py @@ -0,0 +1,98 @@ +from ..assessment.api import AssessmentEditViewSet, EditPermissionsCheckMixin +from . import models, serializers + + +class ExperimentViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["study"] + assessment_filter_args = "study__assessment" + model = models.Experiment + serializer_class = serializers.ExperimentSerializer + filterset_fields = ("study",) + + +class ChemicalViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["experiment"] + assessment_filter_args = "experiment__study__assessment" + model = models.Chemical + serializer_class = serializers.ChemicalSerializer + filterset_fields = ("experiment",) + + +class AnimalGroupViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["experiment"] + assessment_filter_args = "experiment__study__assessment" + model = models.AnimalGroup + serializer_class = serializers.AnimalGroupSerializer + filterset_fields = ("experiment",) + + +class TreatmentViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["experiment"] + assessment_filter_args = "experiment__study__assessment" + model = models.Treatment + serializer_class = serializers.TreatmentSerializer + filterset_fields = ("experiment",) + + +class DoseGroupViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["treatment"] + assessment_filter_args = "treatment__experiment__study__assessment" + model = models.DoseGroup + serializer_class = serializers.DoseGroupSerializer + filterset_fields = ("treatment",) + + +class EndpointViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["experiment"] + assessment_filter_args = "experiment__study__assessment" + model = models.Endpoint + serializer_class = serializers.EndpointSerializer + filterset_fields = ("experiment",) + + +class ObservationTimeViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["endpoint"] + assessment_filter_args = "endpoint__experiment__study__assessment" + model = models.ObservationTime + serializer_class = serializers.ObservationTimeSerializer + filterset_fields = ("endpoint",) + + +class DataExtractionViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["experiment"] + assessment_filter_args = "experiment__study__assessment" + model = models.DataExtraction + serializer_class = serializers.DataExtractionSerializer + filterset_fields = ("experiment",) + + +class DoseResponseGroupLevelDataViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["data_extraction"] + assessment_filter_args = "data_extraction__experiment__study__assessment" + model = models.DoseResponseGroupLevelData + serializer_class = serializers.DoseResponseGroupLevelDataSerializer + filterset_fields = ("data_extraction",) + + +class DoseResponseAnimalLevelDataViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["data_extraction"] + assessment_filter_args = "data_extraction__experiment__study__assessment" + model = models.DoseResponseAnimalLevelData + serializer_class = serializers.DoseResponseAnimalLevelDataSerializer + filterset_fields = ("data_extraction",) + + +class ObservationViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["experiment"] + assessment_filter_args = "experiment__study__assessment" + model = models.Observation + serializer_class = serializers.ObservationSerializer + filterset_fields = ("experiment",) + + +class StudyLevelValueViewSet(EditPermissionsCheckMixin, AssessmentEditViewSet): + edit_check_keys = ["study"] + assessment_filter_args = "study__assessment" + model = models.StudyLevelValue + serializer_class = serializers.StudyLevelValueSerializer + filterset_fields = ("study",) diff --git a/hawc/apps/animalv2/autocomplete.py b/hawc/apps/animalv2/autocomplete.py new file mode 100644 index 000000000..89d4b87f9 --- /dev/null +++ b/hawc/apps/animalv2/autocomplete.py @@ -0,0 +1,8 @@ +from ..common.autocomplete import BaseAutocomplete, register +from . import models + + +@register +class ChemicalAutocomplete(BaseAutocomplete): + model = models.Chemical + search_fields = ["name"] diff --git a/hawc/apps/animalv2/constants.py b/hawc/apps/animalv2/constants.py new file mode 100644 index 000000000..fb88107f1 --- /dev/null +++ b/hawc/apps/animalv2/constants.py @@ -0,0 +1,109 @@ +from django.db import models + + +class ExperimentDesign(models.TextChoices): + AA = "AA", "TODO A" + BB = "BB", "TODO B" + + +class Sex(models.TextChoices): + MALE = "M", "Male" + FEMALE = "F", "Female" + COMBINED = "C", "Combined" + NR = "R", "Not reported" + + +class Generation(models.TextChoices): + NA = "", "N/A (not generational-study)" + P0 = "P0", "Parent-generation (P0)" + F1 = "F1", "First-generation (F1)" + F2 = "F2", "Second-generation (F2)" + F3 = "F3", "Third-generation (F3)" + F4 = "F4", "Fourth-generation (F4)" + OT = "Ot", "Other" + + +class RouteExposure(models.TextChoices): + OR = "OR", "Oral" + OC = "OC", "Oral capsule" + OD = "OD", "Oral diet" + OG = "OG", "Oral gavage" + OW = "OW", "Oral drinking water" + I = "I", "Inhalation" # noqa: E741 + IG = "IG", "Inhalation - gas" + IR = "IR", "Inhalation - particle" + IA = "IA", "Inhalation - vapor" + D = "D", "Dermal" + SI = "SI", "Subcutaneous injection" + IP = "IP", "Intraperitoneal injection" + IV = "IV", "Intravenous injection" + IO = "IO", "in ovo" + P = "P", "Parental" + W = "W", "Whole body" + M = "M", "Multiple" + U = "U", "Unknown" + O = "O", "Other" # noqa: E741 + + +class ObservationTimeUnits(models.IntegerChoices): + NR = 0, "not reported" + SEC = 1, "seconds" + MIN = 2, "minutes" + HR = 3, "hours" + DAY = 4, "days" + WK = 5, "weeks" + MON = 6, "months" + YR = 9, "years" + PND = 7, "post-natal day (PND)" + GD = 8, "gestational day (GD)" + + +class VarianceType(models.IntegerChoices): + NA = 0, "NA" + SD = 1, "SD" + SE = 2, "SE" + NR = 3, "NR" + + +class TreatmentRelatedEffect(models.IntegerChoices): + YES = 0, "Yes" + NO = 1, "No" + NA = 2, "NA" + NR = 3, "NR" + + +class MethodToControlForLitterEffects(models.IntegerChoices): + YES = 0, "Yes" + NR = 1, "NR" + NA = 2, "NA" + + +class DatasetType(models.TextChoices): # TODO - nested dichotomous? + CONTINUOUS = "C", "Continuous" + DICHOTOMOUS = "D", "Dichotomous" + PERCENT_DIFFERENCE = "PD", "Percent Difference" + DICHOTOMOUS_CANCER = "DC", "Dichotomous Cancer" # TODO - remove cancer? + NOT_REPORTED = "NR", "Not reported" + + +class StatisticallySignificant(models.IntegerChoices): + YES = 0, "Yes" + NO = 1, "No" + NA = 2, "NA" + + +class Lifestage(models.TextChoices): + DEV = "DEV", "Developmental" + JUV = "JUV", "Juvenile" + ADULT = "ADULT", "Adult" + AG = "AG", "Adult (gestation)" + ML = "ML", "Multi-lifestage" + + +class StudyLevelTypeChoices(models.IntegerChoices): + LOEL = 0, "LOEL" + NOEL = 1, "NOEL" + LOAEL = 2, "LOAEL" + NOAEL = 3, "NOAEL" + BMDL = 4, "BMDL" + BMD = 5, "BMD" diff --git a/hawc/apps/animalv2/filterset.py b/hawc/apps/animalv2/filterset.py new file mode 100644 index 000000000..e65fdf9c5 --- /dev/null +++ b/hawc/apps/animalv2/filterset.py @@ -0,0 +1,77 @@ +import django_filters as df + +from ..common.filterset import ArrowOrderingFilter, BaseFilterSet, InlineFilterForm +from . import models + + +class ObservationOrderingFilter(ArrowOrderingFilter): + def filter(self, qs, value): + ordering = [self.get_ordering_value(param) for param in value] + return qs.order_by(*ordering) + + +class ObservationFilterSet(BaseFilterSet): + endpoint_target = df.CharFilter( + lookup_expr="icontains", + field_name="endpoint__name", + label="Effect Subtype", + help_text="Filter by effect subtype", + ) + tested_status = df.ChoiceFilter( + empty_label="- Tested Status -", + choices=[(True, True), (False, False)], + ) + reported_status = df.ChoiceFilter( + empty_label="- Reported Status -", + choices=[(True, True), (False, False)], + ) + order_by = ObservationOrderingFilter( + label="Ordering", + fields=( + ("endpoint__parent__parent__name", "system"), + ("endpoint__parent__name", "effect"), + ("endpoint__name", "effect subtype"), + ), + initial="system", + ) + + class Meta: + model = models.Observation + form = InlineFilterForm + fields = ["endpoint_target", "tested_status", "reported_status", "order_by"] + main_field = "endpoint_target" + appended_fields = ["tested_status", "reported_status", "order_by"] + + def filter(self, items: list[models.Observation]) -> list[models.Observation]: + # custom method that acts on a list, not a QuerySet; mirrors parent implementation + if self.is_bound: + self.errors # noqa: B018 - this is not a noop; checks for validation errors + items = self._filter(items) + return items + + def _filter(self, items): + # filter + for name, value in self.form.cleaned_data.items(): + if not value: + continue + if name == "tested_status": + status = False if value == "False" else True + items = [item for item in items if item.tested_status == status] + elif name == "reported_status": + status = False if value == "False" else True + items = [item for item in items if item.reported_status == status] + elif name == "endpoint_target": + items = [item for item in items if value in item.endpoint.name] + + # sort + ordering = self.form.cleaned_data.get("order_by", ["system"])[0] + descending = "-" in ordering + sort_by = ordering.replace("-", "") + sort_key_functions = { + "effect subtype": lambda x: x.endpoint.name, + "effect": lambda x: x.endpoint.parent.name, + "system": lambda x: x.endpoint.parent.parent.name, + } + items = sorted(items, key=sort_key_functions[sort_by], reverse=descending) + + return items diff --git a/hawc/apps/animalv2/forms.py b/hawc/apps/animalv2/forms.py new file mode 100644 index 000000000..40705f3d1 --- /dev/null +++ b/hawc/apps/animalv2/forms.py @@ -0,0 +1,386 @@ +from crispy_forms import layout as cfl +from django import forms +from django.forms import ModelForm +from django.urls import reverse + +from ..assessment.autocomplete import DSSToxAutocomplete +from ..common.autocomplete import AutocompleteSelectWidget, AutocompleteTextWidget +from ..common.forms import BaseFormHelper +from . import autocomplete, constants, models + + +def set_textarea_height(fields: dict, n_rows: int = 3): + for field in fields.values(): + if isinstance(field.widget, forms.Textarea): + field.widget.attrs["rows"] = n_rows + + +class StudyLevelValueForm(forms.ModelForm): + class Meta: + model = models.StudyLevelValue + exclude = ("study", "created", "last_updated") + widgets = {} + + def __init__(self, *args, **kwargs): + study = kwargs.pop("parent", None) + prefix = f"studylevelvalue-{kwargs.get('instance').pk if 'instance' in kwargs else 'new'}" + super().__init__(*args, prefix=prefix, **kwargs) + if study: + self.instance.study = study + self.instance.assessment = study.get_assessment() + + self.fields["comments"].widget.attrs["rows"] = 3 + + @property + def helper(self): + helper = BaseFormHelper(self) + helper.form_tag = False + helper.layout = cfl.Layout( + cfl.Row( + cfl.Column("system"), + ), + cfl.Row( + cfl.Column("value_type"), + cfl.Column("value"), + cfl.Column("units"), + ), + cfl.Row( + cfl.Column("comments"), + ), + ) + return helper + + +class ExperimentForm(ModelForm): + class Meta: + model = models.Experiment + exclude = ("study",) + + def __init__(self, *args, **kwargs): + parent = kwargs.pop("parent", None) + super().__init__(*args, **kwargs) + if parent: + self.instance.study = parent + + if self.instance.study.assessment.enable_observations: + self.fields["guideline"].required = True + self.fields["guideline"].empty_label = None + + # change checkbox to select box + self.fields["has_multiple_generations"].widget = forms.Select( + choices=((True, "Yes"), (False, "No")) + ) + + @property + def helper(self): + # by default take-up the whole row + for fld in list(self.fields.keys()): + widget = self.fields[fld].widget + if type(widget) is not forms.CheckboxInput: + widget.attrs["class"] = "form-control" + + if self.instance.id: + inputs = { + "legend_text": f"Update {self.instance}", + } + helper = BaseFormHelper(self, **inputs) + helper.form_tag = False + else: + inputs = { + "legend_text": "Create new experiment", + "help_text": """ + Create a new experiment. Each experiment is a associated with a + study, and may have one or more collections of animals. For + example, one experiment may be a 2-year cancer bioassay, + while another multi-generational study. It is possible to + create multiple separate experiments within a single study, + with different study-designs, durations, or test-species.""", + "cancel_url": self.instance.study.get_absolute_url(), + } + helper = BaseFormHelper(self, **inputs) + + helper.form_id = "experiment-v2-form" + helper.add_row("name", 3, "col-md-4") + helper.add_row("guideline_compliance", 2, "col-md-6") + set_textarea_height(self.fields) + + return helper + + +class ChemicalForm(forms.ModelForm): + class Meta: + model = models.Chemical + exclude = ("experiment",) + widgets = { + "name": AutocompleteTextWidget( + autocomplete_class=autocomplete.ChemicalAutocomplete, field="name" + ), + "dtxsid": AutocompleteSelectWidget(autocomplete_class=DSSToxAutocomplete), + } + + def __init__(self, *args, **kwargs): + experiment = kwargs.pop("parent", None) + prefix = f"chemical-{kwargs.get('instance').pk if 'instance' in kwargs else 'new'}" + super().__init__(*args, prefix=prefix, **kwargs) + if experiment: + self.instance.experiment = experiment + + @property + def helper(self): + helper = BaseFormHelper(self) + helper.form_tag = False + helper.add_row("name", 3, "col-md-4") + helper.add_row("source", 3, "col-md-4") + helper.add_create_btn("dtxsid", reverse("assessment:dtxsid_create"), "Add new DTXSID") + set_textarea_height(self.fields) + return helper + + +class AnimalGroupForm(forms.ModelForm): + class Meta: + model = models.AnimalGroup + exclude = ("experiment",) + + def __init__(self, *args, **kwargs): + experiment = kwargs.pop("parent", None) + prefix = f"animalgroup-{kwargs.get('instance').pk if 'instance' in kwargs else 'new'}" + super().__init__(*args, prefix=prefix, **kwargs) + if experiment: + self.instance.experiment = experiment + + @property + def helper(self): + helper = BaseFormHelper(self) + helper.form_tag = False + helper.add_row("species", 3, "col-md-4") + helper.add_row("lifestage_at_exposure", 2, "col-md-6") + helper.add_row("generation", 2, "col-md-6") + helper.add_row("husbandry_and_diet", 2, "col-md-6") + set_textarea_height(self.fields) + + assessment_id = self.instance.experiment.study.assessment.pk + helper.add_create_btn( + "species", + reverse("assessment:species_create", args=(assessment_id,)), + "Create species", + ) + helper.add_create_btn( + "strain", + reverse("assessment:strain_create", args=(assessment_id,)), + "Create strain", + ) + return helper + + def clean(self): + cleaned_data = super().clean() + if "species" in cleaned_data and "strain" in cleaned_data: + species_obj = cleaned_data["species"] + strain_obj = cleaned_data["strain"] + if species_obj.id != strain_obj.species.id: + raise forms.ValidationError( + {"strain": "Strain must be of the same species as the selected species"} + ) + return cleaned_data + + +class TreatmentForm(forms.ModelForm): + class Meta: + model = models.Treatment + exclude = ("experiment",) + + def __init__(self, *args, **kwargs): + experiment = kwargs.pop("parent", None) + prefix = f"treatment-{kwargs.get('instance').pk if 'instance' in kwargs else 'new'}" + super().__init__(*args, prefix=prefix, **kwargs) + if experiment: + self.instance.experiment = experiment + self.fields["chemical"].queryset = self.instance.experiment.v2_chemicals.all() + + @property + def helper(self): + helper = BaseFormHelper(self) + helper.form_tag = False + helper.add_row("name", 3, "col-md-4") + helper.add_row("exposure_duration", 3, "col-md-4") + set_textarea_height(self.fields) + + return helper + + +class DoseGroupForm(forms.ModelForm): + formset_parent_key = "treatment_id" + + class Meta: + model = models.DoseGroup + exclude = ("treatment",) + + def __init__(self, *args, **kwargs): + treatment = kwargs.pop("parent", None) + super().__init__(*args, **kwargs) + if treatment: + self.instance.treatment = treatment + + +class EndpointForm(forms.ModelForm): + class Meta: + model = models.Endpoint + # TODO - for now, we've got EHV fields for controlled vocab in the model, but we'll hide them from UI + exclude = ( + "experiment", + "name_term", + "system_term", + "organ_term", + "effect_term", + "effect_subtype_term", + ) + + def __init__(self, *args, **kwargs): + experiment = kwargs.pop("parent", None) + prefix = f"endpoint-{kwargs.get('instance').pk if 'instance' in kwargs else 'new'}" + super().__init__(*args, prefix=prefix, **kwargs) + if experiment: + self.instance.experiment = experiment + # TODO - if/when we add EHV terms back in, we'll need this kind of filtering... + # self.fields["name_term"].queryset = Term.objects.filter(type=VocabularyTermType.endpoint_name) + # self.fields["system_term"].queryset = Term.objects.filter(type=VocabularyTermType.system) + # self.fields["organ_term"].queryset = Term.objects.filter(type=VocabularyTermType.organ) + # self.fields["effect_term"].queryset = Term.objects.filter(type=VocabularyTermType.effect) + # self.fields["effect_subtype_term"].queryset = Term.objects.filter(type=VocabularyTermType.effect_subtype) + + @property + def helper(self): + helper = BaseFormHelper(self) + helper.form_tag = False + helper.add_row("system", 4, "col-md-3") + helper.add_row("effect_modifier_timing", 4, "col-md-3") + helper.add_row("additional_tags", 2, "col-md-6") + set_textarea_height(self.fields) + + return helper + + +class ObservationTimeForm(forms.ModelForm): + class Meta: + model = models.ObservationTime + exclude = () + + def __init__(self, *args, **kwargs): + experiment = kwargs.pop("parent", None) + prefix = f"observationtime-{kwargs.get('instance').pk if 'instance' in kwargs else 'new'}" + super().__init__(*args, prefix=prefix, **kwargs) + # TODO - right now with name/name_term, the associated dropdown for picking an endpoint shows + # an empty string for endpoints with a name_term but no freetext name. + # + # if we go back to using EHV name_term etc., we need to address this. + # + # but also, maybe the ObservationTime UI should be more like the Treatment/DoseGroup formset + # style, as opposed to separate entry as in the mockup? If we do it that way, then we don't + # need a UI widget for picking the endpoint at all, b/c it's implicit in the nested structure. + # + # in short, right now it's a minor issue, but not worth fixing til we make some other decisions. + if self.instance.id: + # editing an existing timepoint + self.fields["endpoint"].queryset = self.instance.endpoint.experiment.v2_endpoints.all() + else: + # creating a new one + self.fields["endpoint"].queryset = experiment.v2_endpoints.all() + + @property + def helper(self): + helper = BaseFormHelper(self) + helper.form_tag = False + helper.add_row("observation_time", 3, "col-md-4") + set_textarea_height(self.fields) + + return helper + + +class DataExtractionForm(forms.ModelForm): + class Meta: + model = models.DataExtraction + exclude = ("experiment",) + + def __init__(self, *args, **kwargs): + experiment = kwargs.pop("parent", None) + prefix = f"dataextraction-{kwargs.get('instance').pk if 'instance' in kwargs else 'new'}" + super().__init__(*args, prefix=prefix, **kwargs) + if experiment: + self.instance.experiment = experiment + self.fields["endpoint"].queryset = self.instance.experiment.v2_endpoints.all() + self.fields["treatment"].queryset = self.instance.experiment.v2_treatments.all() + self.fields["observation_timepoint"].queryset = models.ObservationTime.objects.filter( + endpoint__in=self.instance.experiment.v2_endpoints.all() + ) + + @property + def helper(self): + helper = BaseFormHelper(self) + helper.form_tag = False + helper.add_row("endpoint", 3, "col-md-4") + helper.add_row("data_location", 3, "col-md-4") + helper.add_row("statistical_method", 2, "col-md-6") + helper.add_row("method_to_control_for_litter_effects", 3, "col-md-4") + set_textarea_height(self.fields) + + return helper + + def is_valid(self): + if "is_qualitative_only" in self.data and self.data["is_qualitative_only"] == "on": + # TODO - if "is qualitative only" is checked, we are hiding other fields... + # so relax the required'ness of some... + for usually_required_field in ["dose_response_observations", "result_details"]: + self.fields[usually_required_field].required = False + + # and in clean we'll set some defaults on those hidden fields. + + return super().is_valid() + + def clean(self): + cleaned_data = super().clean() + if cleaned_data.get("is_qualitative_only") is True: + # TODO - set sensible values on the hidden fields + hidden_defaults = { + "data_location": "", + "dataset_type": "", + "variance_type": constants.VarianceType.NA, + "statistical_method": "", + "statistical_power": "", + "method_to_control_for_litter_effects": constants.MethodToControlForLitterEffects.NA, + "values_estimated": False, + "response_units": "", + "dose_response_observations": "", + "result_details": "", + } + + for field_name in hidden_defaults: + cleaned_data[field_name] = hidden_defaults[field_name] + + return cleaned_data + + +class DoseResponseGroupLevelDataForm(forms.ModelForm): + formset_parent_key = "data_extraction_id" + + class Meta: + model = models.DoseResponseGroupLevelData + exclude = ("data_extraction",) + + def __init__(self, *args, **kwargs): + data_extraction = kwargs.pop("parent", None) + super().__init__(*args, **kwargs) + if data_extraction: + self.instance.data_extraction = data_extraction + + +class DoseResponseAnimalLevelDataForm(forms.ModelForm): + formset_parent_key = "data_extraction_id" + + class Meta: + model = models.DoseResponseAnimalLevelData + exclude = ("data_extraction",) + + def __init__(self, *args, **kwargs): + data_extraction = kwargs.pop("parent", None) + super().__init__(*args, **kwargs) + if data_extraction: + self.instance.data_extraction = data_extraction diff --git a/hawc/apps/animalv2/managers.py b/hawc/apps/animalv2/managers.py new file mode 100644 index 000000000..2bb22ed69 --- /dev/null +++ b/hawc/apps/animalv2/managers.py @@ -0,0 +1,45 @@ +from ..common.models import BaseManager + + +class ExperimentManager(BaseManager): + assessment_relation = "study__assessment" + + +class ChemicalManager(BaseManager): + assessment_relation = "experiment__study__assessment" + + +class AnimalGroupManager(BaseManager): + assessment_relation = "experiment__study__assessment" + + +class TreatmentManager(BaseManager): + assessment_relation = "experiment__study__assessment" + + +class DoseGroupManager(BaseManager): + assessment_relation = "treatment__experiment__study__assessment" + + +class EndpointManager(BaseManager): + assessment_relation = "experiment__study__assessment" + + +class ObservationTimeManager(BaseManager): + assessment_relation = "endpoint__experiment__study__assessment" + + +class DataExtractionManager(BaseManager): + assessment_relation = "experiment__study__assessment" + + +class DoseResponseGroupLevelDataManager(BaseManager): + assessment_relation = "data_extraction__experiment__study__assessment" + + +class DoseResponseAnimalLevelDataManager(BaseManager): + assessment_relation = "data_extraction__experiment__study__assessment" + + +class ObservationManager(BaseManager): + assessment_relation = "experiment__study_assessment" diff --git a/hawc/apps/animalv2/migrations/0001_initial.py b/hawc/apps/animalv2/migrations/0001_initial.py new file mode 100644 index 000000000..55de2eaae --- /dev/null +++ b/hawc/apps/animalv2/migrations/0001_initial.py @@ -0,0 +1,762 @@ +# Generated by Django 5.0.6 on 2024-07-10 22:02 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("assessment", "0048_assessment_animal_version"), + ("study", "0012_study_eco"), + ("vocab", "0006_require_uid"), + ] + + operations = [ + migrations.CreateModel( + name="DataExtraction", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("is_qualitative_only", models.BooleanField(default=False)), + ( + "data_location", + models.CharField( + blank=True, + help_text='Details on where the data are found in the literature (ex: "Figure 1", "Table 2", "Text, p. 24", "Figure 1 and Text, p.24")', + max_length=128, + ), + ), + ( + "dataset_type", + models.CharField( + blank=True, + choices=[ + ("C", "Continuous"), + ("D", "Dichotomous"), + ("PD", "Percent Difference"), + ("DC", "Dichotomous Cancer"), + ("NR", "Not reported"), + ], + default="", + max_length=2, + ), + ), + ( + "variance_type", + models.PositiveSmallIntegerField( + choices=[(0, "NA"), (1, "SD"), (2, "SE"), (3, "NR")], default=1 + ), + ), + ( + "statistical_method", + models.CharField(blank=True, help_text="TODO", max_length=128), + ), + ( + "statistical_power", + models.CharField(blank=True, help_text="TODO", max_length=128), + ), + ( + "method_to_control_for_litter_effects", + models.PositiveSmallIntegerField(choices=[(0, "Yes"), (1, "NR"), (2, "NA")]), + ), + ( + "values_estimated", + models.BooleanField( + default=False, + help_text="Response values were estimated using a digital ruler or other methods", + ), + ), + ( + "response_units", + models.CharField( + blank=True, + help_text="Units the response was measured in (i.e., μg/dL, % control, etc.)", + max_length=32, + ), + ), + ("dose_response_observations", models.TextField(help_text="TODO")), + ("result_details", models.TextField(help_text="TODO")), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="DoseResponseAnimalLevelData", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("cage_id", models.CharField(blank=True, help_text="TODO", max_length=128)), + ("animal_id", models.CharField(blank=True, help_text="TODO", max_length=128)), + ("dose", models.CharField(help_text="TODO", max_length=128)), + ("response", models.FloatField()), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "data_extraction", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_animal_level_data", + to="animalv2.dataextraction", + ), + ), + ], + ), + migrations.CreateModel( + name="DoseResponseGroupLevelData", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("treatment_name", models.CharField(help_text="TODO", max_length=256)), + ("dose", models.CharField(help_text="TODO", max_length=128)), + ( + "n", + models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ("response", models.FloatField()), + ( + "variance", + models.FloatField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "treatment_related_effect", + models.PositiveSmallIntegerField( + choices=[(0, "Yes"), (1, "No"), (2, "NA"), (3, "NR")] + ), + ), + ( + "statistically_significant", + models.PositiveSmallIntegerField(choices=[(0, "Yes"), (1, "No"), (2, "NA")]), + ), + ("p_value", models.CharField(blank=True, help_text="TODO", max_length=128)), + ( + "NOEL", + models.SmallIntegerField(default=-999, help_text="No observed effect level"), + ), + ( + "LOEL", + models.SmallIntegerField( + default=-999, help_text="Lowest observed effect level" + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "data_extraction", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_group_level_data", + to="animalv2.dataextraction", + ), + ), + ], + ), + migrations.CreateModel( + name="Endpoint", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + blank=True, help_text="Endpoint/Adverse Outcome", max_length=128 + ), + ), + ( + "system", + models.CharField( + blank=True, help_text="Relevant biological system", max_length=128 + ), + ), + ( + "organ", + models.CharField( + blank=True, + help_text="Relevant organ or tissue", + max_length=128, + verbose_name="Organ (and tissue)", + ), + ), + ( + "effect", + models.CharField( + blank=True, help_text="Effect, using common-vocabulary", max_length=128 + ), + ), + ( + "effect_subtype", + models.CharField( + blank=True, + help_text="Effect subtype, using common-vocabulary", + max_length=128, + ), + ), + ("effect_modifier_timing", models.CharField(blank=True, max_length=128)), + ("effect_modifier_reference", models.CharField(blank=True, max_length=128)), + ("effect_modifier_anatomical", models.CharField(blank=True, max_length=128)), + ("effect_modifier_location", models.CharField(blank=True, max_length=128)), + ("comments", models.TextField(blank=True, help_text="TODO")), + ("additional_tags", models.ManyToManyField(blank=True, to="assessment.effecttag")), + ( + "effect_subtype_term", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="v2_endpoint_effect_subtype_terms", + to="vocab.term", + ), + ), + ( + "effect_term", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="v2_endpoint_effect_terms", + to="vocab.term", + ), + ), + ( + "name_term", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="v2_endpoint_name_terms", + to="vocab.term", + ), + ), + ( + "organ_term", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="v2_endpoint_organ_terms", + to="vocab.term", + ), + ), + ( + "system_term", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="v2_endpoint_system_terms", + to="vocab.term", + ), + ), + ], + ), + migrations.AddField( + model_name="dataextraction", + name="endpoint", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_data_extractions", + to="animalv2.endpoint", + ), + ), + migrations.CreateModel( + name="Experiment", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + help_text="Short-text used to describe the experiment (i.e. 2-Year Cancer Bioassay, 10-Day Oral, 28-Day Inhalation, etc.) using title style (all words capitalized). If study contains more than one chemical, then also include the chemical name (e.g. 28-Day Oral PFBS).", + max_length=80, + verbose_name="Experiment name", + ), + ), + ( + "design", + models.CharField( + choices=[("AA", "TODO A"), ("BB", "TODO B")], + help_text="Design of study being performed", + max_length=2, + ), + ), + ("has_multiple_generations", models.BooleanField(default=False)), + ( + "guideline_compliance", + models.CharField( + blank=True, + help_text='Description of any compliance methods used (i.e. use of EPA OECD, NTP, or other guidelines; conducted under GLP guideline conditions, non-GLP but consistent with guideline study, etc.). This field response should match any description used in study evaluation in the reporting quality domain, e.g., GLP study (OECD guidelines 414 and 412, 1981 versions). If not reported, then use state "not reported."', + max_length=128, + ), + ), + ( + "comments", + models.TextField( + blank=True, + help_text="Additional comments (eg., description, animal husbandry, etc.)", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "study", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_experiments", + to="study.study", + ), + ), + ], + ), + migrations.AddField( + model_name="endpoint", + name="experiment", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_endpoints", + to="animalv2.experiment", + ), + ), + migrations.AddField( + model_name="dataextraction", + name="experiment", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_data_extractions", + to="animalv2.experiment", + ), + ), + migrations.CreateModel( + name="Chemical", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + help_text="This field may get displayed in visualizations, so consider using a common acronym, e.g., BPA instead of Bisphenol A", + max_length=80, + verbose_name="Chemical name", + ), + ), + ( + "cas", + models.CharField( + blank=True, + help_text="CAS number for chemical-tested. Use N/A if not applicable. If more than one CAS number is applicable, then use a common one here and indicate others in the comment field below.", + max_length=40, + verbose_name="Chemical identifier (CAS)", + ), + ), + ( + "source", + models.CharField(blank=True, max_length=128, verbose_name="Source of chemical"), + ), + ( + "purity", + models.CharField(blank=True, max_length=128, verbose_name="Chemical purity"), + ), + ( + "vehicle", + models.CharField( + blank=True, + help_text='Describe vehicle (use name as described in methods but also add the common name if the vehicle was described in a non-standard way). Enter "not reported" if the vehicle is not described. For inhalation studies, air can be inferred if not explicitly reported. Examples: "corn oil," "filtered air," "not reported, but assumed clean air."', + max_length=64, + verbose_name="Chemical vehicle", + ), + ), + ( + "comments", + models.TextField( + blank=True, + help_text="Additional comments (eg., description, animal husbandry, etc.)", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "dtxsid", + models.ForeignKey( + blank=True, + help_text='DssTox substance identifier (recommended). When using an identifier, chemical name and CASRN are standardized using the DTXSID.', + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="v2_chemicals", + to="assessment.dsstox", + verbose_name="DSSTox substance identifier (DTXSID)", + ), + ), + ( + "experiment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_chemicals", + to="animalv2.experiment", + ), + ), + ], + ), + migrations.CreateModel( + name="AnimalGroup", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + help_text="Name should be: sex, common strain name, species (plural) and use Title Style (e.g. Male Sprague Dawley Rat, Female C57BL/6 Mice, Male and Female C57BL/6 Mice). For developmental studies, include the generation before sex in title (e.g., F1 Male Sprague Dawley Rat or P0 Female C57 Mice)", + max_length=80, + verbose_name="Animal group name", + ), + ), + ( + "sex", + models.CharField( + choices=[ + ("M", "Male"), + ("F", "Female"), + ("C", "Combined"), + ("R", "Not reported"), + ], + max_length=1, + ), + ), + ( + "animal_source", + models.CharField( + blank=True, + help_text="Source from where animals were acquired", + max_length=128, + ), + ), + ( + "lifestage_at_exposure", + models.CharField( + blank=True, + choices=[ + ("DEV", "Developmental"), + ("JUV", "Juvenile"), + ("ADULT", "Adult"), + ("AG", "Adult (gestation)"), + ("ML", "Multi-lifestage"), + ], + default="", + help_text="Definitions: Developmental: Prenatal and perinatal exposure in dams or postnatal exposure in offspring until sexual maturity (~6 weeks in rats and mice). Include studies with pre-mating exposure if the endpoint focus is developmental. Juvenile: Exposure between weaned and sexual maturity. Adult: Exposure in sexually mature males or females. Adult (gestation): Exposure in dams during pregnancy. Multi-lifestage: includes both developmental and adult (i.e., multi-generational studies, exposure that start before sexual maturity and continue to adulthood)", + max_length=5, + ), + ), + ( + "lifestage_at_assessment", + models.CharField( + blank=True, + choices=[ + ("DEV", "Developmental"), + ("JUV", "Juvenile"), + ("ADULT", "Adult"), + ("AG", "Adult (gestation)"), + ("ML", "Multi-lifestage"), + ], + default="", + help_text="Definitions: Developmental: Prenatal and perinatal exposure in dams or postnatal exposure in offspring until sexual maturity (~6 weeks in rats and mice). Include studies with pre-mating exposure if the endpoint focus is developmental. Juvenile: Exposure between weaned and sexual maturity. Adult: Exposure in sexually mature males or females. Adult (gestation): Exposure in dams during pregnancy. Multi-lifestage: includes both developmental and adult (i.e., multi-generational studies, exposure that start before sexual maturity and continue to adulthood)", + max_length=5, + ), + ), + ( + "generation", + models.CharField( + blank=True, + choices=[ + ("", "N/A (not generational-study)"), + ("P0", "Parent-generation (P0)"), + ("F1", "First-generation (F1)"), + ("F2", "Second-generation (F2)"), + ("F3", "Third-generation (F3)"), + ("F4", "Fourth-generation (F4)"), + ("Ot", "Other"), + ], + default="", + max_length=2, + ), + ), + ( + "husbandry_and_diet", + models.TextField( + blank=True, + help_text='Copy paste animal husbandry information from materials and methods, use quotation marks around all text directly copy/pasted from paper. Describe diet as presented in the paper (e.g., "soy-protein free 2020X Teklad," "Atromin 1310", "standard rodent chow").', + verbose_name="Animal Husbandry and Diet", + ), + ), + ("comments", models.TextField(blank=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "parents", + models.ManyToManyField( + blank=True, related_name="children", to="animalv2.animalgroup" + ), + ), + ( + "species", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_animal_groups", + to="assessment.species", + ), + ), + ( + "strain", + models.ForeignKey( + help_text='When adding a new strain, put the stock in parenthesis, e.g., "Sprague-Dawley (Harlan)."', + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_animal_groups", + to="assessment.strain", + ), + ), + ( + "experiment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_animal_groups", + to="animalv2.experiment", + ), + ), + ], + ), + migrations.CreateModel( + name="ObservationTime", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "observation_time", + models.FloatField( + blank=True, + help_text="Numeric value of the time an observation was reported; optional, should be recorded if the same effect was measured multiple times.", + null=True, + verbose_name="Observation timepoint", + ), + ), + ( + "observation_time_units", + models.PositiveSmallIntegerField( + choices=[ + (0, "not reported"), + (1, "seconds"), + (2, "minutes"), + (3, "hours"), + (4, "days"), + (5, "weeks"), + (6, "months"), + (9, "years"), + (7, "post-natal day (PND)"), + (8, "gestational day (GD)"), + ], + default=0, + ), + ), + ( + "observation_time_text", + models.CharField( + blank=True, + help_text='Text for reported observation time (ex: "60-90 PND")', + max_length=64, + ), + ), + ("comments", models.TextField(blank=True, help_text="TODO")), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "endpoint", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_timepoints", + to="animalv2.endpoint", + ), + ), + ], + ), + migrations.AddField( + model_name="dataextraction", + name="observation_timepoint", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_data_extractions", + to="animalv2.observationtime", + ), + ), + migrations.CreateModel( + name="Treatment", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "name", + models.CharField( + help_text="TODO", max_length=80, verbose_name="Treatment name" + ), + ), + ( + "route_of_exposure", + models.CharField( + choices=[ + ("OR", "Oral"), + ("OC", "Oral capsule"), + ("OD", "Oral diet"), + ("OG", "Oral gavage"), + ("OW", "Oral drinking water"), + ("I", "Inhalation"), + ("IG", "Inhalation - gas"), + ("IR", "Inhalation - particle"), + ("IA", "Inhalation - vapor"), + ("D", "Dermal"), + ("SI", "Subcutaneous injection"), + ("IP", "Intraperitoneal injection"), + ("IV", "Intravenous injection"), + ("IO", "in ovo"), + ("P", "Parental"), + ("W", "Whole body"), + ("M", "Multiple"), + ("U", "Unknown"), + ("O", "Other"), + ], + help_text="Primary route of exposure. If multiple primary-exposures, describe in notes-field below", + max_length=2, + ), + ), + ( + "exposure_duration", + models.FloatField( + blank=True, + help_text="Length of exposure period (fractions allowed), used for sorting in visualizations. For single-dose or multiple-dose/same day gavage studies, 1.", + null=True, + verbose_name="Exposure duration (days)", + ), + ), + ( + "exposure_duration_description", + models.CharField( + blank=True, + help_text='Length of time between start of exposure and outcome assessment, in days when <7 (e.g., 5d), weeks when ≥7 days to 12 weeks (e.g., 1wk, 12wk), or months when >12 weeks (e.g., 15mon). For repeated measures use descriptions such as "1, 2 and 3 wk". For inhalations studies, also include hours per day and days per week, e.g., "13wk (6h/d, 7d/wk)." This field is commonly used in visualizations, so use abbreviations (h, d, wk, mon, y) and no spaces between numbers to save space. For reproductive and developmental studies, where possible instead include abbreviated age descriptions such as "GD1-10" or "GD2-PND10". For gavage studies, include the number of doses, e.g. "1wk (1dose/d, 5d/wk)" or "2doses" for a single-day experiment.', + max_length=128, + verbose_name="Exposure duration (text)", + ), + ), + ( + "exposure_outcome_duration", + models.FloatField( + blank=True, + help_text="Optional: Numeric length of time between start of exposure and outcome assessment in days. This field may be used to sort studies which is why days are used as a common metric.", + null=True, + verbose_name="Exposure-outcome duration (days)", + ), + ), + ("comments", models.TextField(blank=True)), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "chemical", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_treatments", + to="animalv2.chemical", + ), + ), + ( + "experiment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_treatments", + to="animalv2.experiment", + ), + ), + ], + ), + migrations.CreateModel( + name="DoseGroup", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("dose_group_id", models.PositiveSmallIntegerField()), + ( + "dose", + models.FloatField(validators=[django.core.validators.MinValueValidator(0)]), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "dose_units", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_dose_groups", + to="assessment.doseunits", + ), + ), + ( + "treatment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_dose_groups", + to="animalv2.treatment", + ), + ), + ], + ), + migrations.AddField( + model_name="dataextraction", + name="treatment", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="v2_data_extractions", + to="animalv2.treatment", + ), + ), + ] diff --git a/hawc/apps/animalv2/migrations/0002_studylevelvalue.py b/hawc/apps/animalv2/migrations/0002_studylevelvalue.py new file mode 100644 index 000000000..54a88dcb4 --- /dev/null +++ b/hawc/apps/animalv2/migrations/0002_studylevelvalue.py @@ -0,0 +1,77 @@ +# Generated by Django 5.0.6 on 2024-08-06 12:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("animalv2", "0001_initial"), + ("assessment", "0038_alter_assessmentdetail_qa_id"), + ("study", "0012_study_eco"), + ] + + operations = [ + migrations.CreateModel( + name="StudyLevelValue", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "system", + models.CharField( + blank=True, + help_text="Identify the health system of concern (e.g., Hepatic, Nervous, Reproductive)", + max_length=128, + verbose_name="System or health effect basis", + ), + ), + ( + "value_type", + models.PositiveSmallIntegerField( + choices=[ + (0, "LOEL"), + (1, "NOEL"), + (2, "LOAEL"), + (3, "NOAEL"), + (4, "BMDL"), + (5, "BMD"), + ], + help_text="Type of derived value", + ), + ), + ("value", models.FloatField(help_text="The value")), + ( + "comments", + models.TextField( + blank=True, + help_text="General comments related to the derivation of this value", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "study", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="values", + to="study.study", + ), + ), + ( + "units", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="assessment.doseunits", + ), + ), + ], + ), + ] diff --git a/hawc/apps/animalv2/migrations/0003_add_ordering.py b/hawc/apps/animalv2/migrations/0003_add_ordering.py new file mode 100644 index 000000000..0ebfb97c0 --- /dev/null +++ b/hawc/apps/animalv2/migrations/0003_add_ordering.py @@ -0,0 +1,56 @@ +# Generated by Django 5.1.9 on 2025-06-02 20:58 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("animalv2", "0002_studylevelvalue"), + ] + + operations = [ + migrations.AlterModelOptions( + name="animalgroup", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="chemical", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="dataextraction", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="dosegroup", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="doseresponseanimalleveldata", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="doseresponsegroupleveldata", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="endpoint", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="experiment", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="observationtime", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="studylevelvalue", + options={"ordering": ("id",)}, + ), + migrations.AlterModelOptions( + name="treatment", + options={"ordering": ("id",)}, + ), + ] diff --git a/hawc/apps/animalv2/migrations/0004_experiment_guideline.py b/hawc/apps/animalv2/migrations/0004_experiment_guideline.py new file mode 100644 index 000000000..f4ba11a48 --- /dev/null +++ b/hawc/apps/animalv2/migrations/0004_experiment_guideline.py @@ -0,0 +1,49 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("animalv2", "0003_add_ordering"), + ("vocab", "0010_load_guideline_profiles"), + ] + + operations = [ + migrations.AddField( + model_name="experiment", + name="guideline", + field=models.ForeignKey( + blank=True, + help_text="Guideline protocol used to describe this experiment.", + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="vocab.guideline", + ), + ), + migrations.CreateModel( + name="Observation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ( + "experiment", + models.ForeignKey(on_delete=models.deletion.CASCADE, to="animalv2.experiment"), + ), + ( + "endpoint", + models.ForeignKey(on_delete=models.deletion.PROTECT, to="vocab.term"), + ), + ("tested_status", models.BooleanField(default=False)), + ("reported_status", models.BooleanField(default=False)), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ("id",), + }, + ), + ] diff --git a/hawc/apps/animalv2/migrations/__init__.py b/hawc/apps/animalv2/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/hawc/apps/animalv2/models.py b/hawc/apps/animalv2/models.py new file mode 100644 index 000000000..f3d7fe831 --- /dev/null +++ b/hawc/apps/animalv2/models.py @@ -0,0 +1,686 @@ +from typing import Self + +import reversion +from django.core.validators import MinValueValidator +from django.db import models +from django.urls import reverse + +from ..assessment.models import DSSTox, EffectTag +from ..common.models import clone_name +from ..vocab.constants import ObservationStatus +from ..vocab.models import Guideline, GuidelineProfile, Term +from . import constants, managers + + +class Experiment(models.Model): + objects = managers.ExperimentManager() + + study = models.ForeignKey( + "study.Study", on_delete=models.CASCADE, related_name="v2_experiments" + ) + name = models.CharField( + max_length=80, + verbose_name="Experiment name", + help_text="""Short-text used to describe the experiment (i.e. 2-Year Cancer Bioassay, 10-Day Oral, 28-Day Inhalation, etc.) using title style (all words capitalized). If study contains more than one chemical, then also include the chemical name (e.g. 28-Day Oral PFBS).""", + ) + design = models.CharField( + max_length=2, + choices=constants.ExperimentDesign.choices, + help_text="Design of study being performed", + ) + has_multiple_generations = models.BooleanField(default=False) + guideline_compliance = models.CharField( + max_length=128, + blank=True, + help_text="""Description of any compliance methods used (i.e. use of EPA OECD, NTP, or other guidelines; conducted under GLP guideline conditions, non-GLP but consistent with guideline study, etc.). This field response should match any description used in study evaluation in the reporting quality domain, e.g., GLP study (OECD guidelines 414 and 412, 1981 versions). If not reported, then use state \"not reported.\"""", + ) + guideline = models.ForeignKey( + Guideline, + blank=True, + null=True, + on_delete=models.PROTECT, + help_text="""Guideline protocol used to describe this experiment.""", + ) + comments = models.TextField( + blank=True, + help_text="Additional comments (eg., description, animal husbandry, etc.)", + ) + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + BREADCRUMB_PARENT = "study" + + class Meta: + ordering = ("id",) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("animalv2:experiment_detail", args=(self.pk,)) + + def get_update_url(self): + return reverse("animalv2:experiment_update", args=(self.pk,)) + + def get_delete_url(self): + return reverse("animalv2:experiment_delete", args=(self.pk,)) + + def get_assessment(self): + return self.study.get_assessment() + + def get_study(self): + return self.study + + def get_has_multiple_generations_display(self) -> str: + return "Yes" if self.has_multiple_generations else "No" + + def get_v2_timepoints(self): + return ObservationTime.objects.filter(endpoint__experiment=self).order_by("endpoint__name") + + +class Chemical(models.Model): + objects = managers.ChemicalManager() + + experiment = models.ForeignKey( + Experiment, on_delete=models.CASCADE, related_name="v2_chemicals" + ) + name = models.CharField( + max_length=80, + verbose_name="Chemical name", + help_text="""This field may get displayed in visualizations, so consider using a common acronym, e.g., BPA instead of Bisphenol A""", + ) + cas = models.CharField( + max_length=40, + blank=True, + verbose_name="Chemical identifier (CAS)", + help_text="""CAS number for chemical-tested. Use N/A if not applicable. If more than one CAS number is applicable, then use a common one here and indicate others in the comment field below.""", + ) + dtxsid = models.ForeignKey( + DSSTox, + blank=True, + null=True, + on_delete=models.SET_NULL, + verbose_name="DSSTox substance identifier (DTXSID)", + related_name="v2_chemicals", + help_text=DSSTox.help_text(), + ) + source = models.CharField(max_length=128, verbose_name="Source of chemical", blank=True) + purity = models.CharField(max_length=128, verbose_name="Chemical purity", blank=True) + vehicle = models.CharField( + max_length=64, + verbose_name="Chemical vehicle", + help_text="""Describe vehicle (use name as described in methods but also add the common name if the vehicle was described in a non-standard way). Enter "not reported" if the vehicle is not described. For inhalation studies, air can be inferred if not explicitly reported. Examples: "corn oil," "filtered air," \"not reported, but assumed clean air.\"""", + blank=True, + ) + comments = models.TextField( + blank=True, + help_text="Additional comments (eg., description, animal husbandry, etc.)", + ) + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def __str__(self): + return self.name + + def get_assessment(self): + return self.experiment.get_assessment() + + def get_study(self): + return self.experiment.get_study() + + def clone(self): + self.id = None + self.name = clone_name(self, "name") + self.save() + return self + + +class AnimalGroup(models.Model): + objects = managers.AnimalGroupManager() + + experiment = models.ForeignKey( + Experiment, on_delete=models.CASCADE, related_name="v2_animal_groups" + ) + name = models.CharField( + max_length=80, + verbose_name="Animal group name", + help_text="""Name should be: sex, common strain name, species (plural) and use Title Style (e.g. Male Sprague Dawley Rat, Female C57BL/6 Mice, Male and Female C57BL/6 Mice). For developmental studies, include the generation before sex in title (e.g., F1 Male Sprague Dawley Rat or P0 Female C57 Mice)""", + ) + species = models.ForeignKey( + "assessment.Species", related_name="v2_animal_groups", on_delete=models.CASCADE + ) + strain = models.ForeignKey( + "assessment.Strain", + on_delete=models.CASCADE, + related_name="v2_animal_groups", + help_text='When adding a new strain, put the stock in parenthesis, e.g., "Sprague-Dawley (Harlan)."', + ) + sex = models.CharField(max_length=1, choices=constants.Sex.choices) + animal_source = models.CharField( + max_length=128, help_text="Source from where animals were acquired", blank=True + ) + lifestage_at_exposure = models.CharField( + blank=True, + default="", + max_length=5, + choices=constants.Lifestage.choices, + help_text="""Definitions: Developmental: Prenatal and perinatal exposure in dams or postnatal exposure in offspring until sexual maturity (~6 weeks in rats and mice). Include studies with pre-mating exposure if the endpoint focus is developmental. Juvenile: Exposure between weaned and sexual maturity. Adult: Exposure in sexually mature males or females. Adult (gestation): Exposure in dams during pregnancy. Multi-lifestage: includes both developmental and adult (i.e., multi-generational studies, exposure that start before sexual maturity and continue to adulthood)""", + ) + lifestage_at_assessment = models.CharField( + blank=True, + default="", + max_length=5, + choices=constants.Lifestage.choices, + help_text="""Definitions: Developmental: Prenatal and perinatal exposure in dams or postnatal exposure in offspring until sexual maturity (~6 weeks in rats and mice). Include studies with pre-mating exposure if the endpoint focus is developmental. Juvenile: Exposure between weaned and sexual maturity. Adult: Exposure in sexually mature males or females. Adult (gestation): Exposure in dams during pregnancy. Multi-lifestage: includes both developmental and adult (i.e., multi-generational studies, exposure that start before sexual maturity and continue to adulthood)""", + ) + generation = models.CharField( + blank=True, default="", max_length=2, choices=constants.Generation.choices + ) + parents = models.ManyToManyField("self", related_name="children", symmetrical=False, blank=True) + husbandry_and_diet = models.TextField( + help_text="""Copy paste animal husbandry information from materials and methods, use quotation marks around all text directly copy/pasted from paper. Describe diet as presented in the paper (e.g., "soy-protein free 2020X Teklad," "Atromin 1310", "standard rodent chow").""", + verbose_name="Animal Husbandry and Diet", + blank=True, + ) + comments = models.TextField(blank=True) + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def __str__(self): + return self.name + + def get_assessment(self): + return self.experiment.get_assessment() + + def get_study(self): + return self.experiment.get_study() + + def clone(self): + self.id = None + self.name = clone_name(self, "name") + self.save() + return self + + +class Treatment(models.Model): + objects = managers.TreatmentManager() + + experiment = models.ForeignKey( + Experiment, on_delete=models.CASCADE, related_name="v2_treatments" + ) + name = models.CharField( + max_length=80, + verbose_name="Treatment name", + help_text="TODO", + ) + chemical = models.ForeignKey(Chemical, on_delete=models.CASCADE, related_name="v2_treatments") + route_of_exposure = models.CharField( + max_length=2, + choices=constants.RouteExposure.choices, + help_text="Primary route of exposure. If multiple primary-exposures, describe in notes-field below", + ) + exposure_duration = models.FloatField( + verbose_name="Exposure duration (days)", + help_text="Length of exposure period (fractions allowed), used for sorting in visualizations. For single-dose or multiple-dose/same day gavage studies, 1.", + blank=True, + null=True, + ) + exposure_duration_description = models.CharField( + verbose_name="Exposure duration (text)", + max_length=128, + blank=True, + help_text="""Length of time between start of exposure and outcome assessment, in days when <7 (e.g., 5d), weeks when ≥7 days to 12 weeks (e.g., 1wk, 12wk), or months when >12 weeks (e.g., 15mon). For repeated measures use descriptions such as "1, 2 and 3 wk". For inhalations studies, also include hours per day and days per week, e.g., "13wk (6h/d, 7d/wk)." This field is commonly used in visualizations, so use abbreviations (h, d, wk, mon, y) and no spaces between numbers to save space. For reproductive and developmental studies, where possible instead include abbreviated age descriptions such as "GD1-10" or "GD2-PND10". For gavage studies, include the number of doses, e.g. "1wk (1dose/d, 5d/wk)" or "2doses" for a single-day experiment.""", + ) + exposure_outcome_duration = models.FloatField( + verbose_name="Exposure-outcome duration (days)", + help_text="""Optional: Numeric length of time between start of exposure and outcome assessment in days. This field may be used to sort studies which is why days are used as a common metric.""", + blank=True, + null=True, + ) + comments = models.TextField(blank=True) + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def __str__(self): + return self.name + + def get_assessment(self): + return self.experiment.get_assessment() + + def get_study(self): + return self.experiment.get_study() + + # also clone dose groups assigned to this treatment + def clone(self): + associated_dose_groups = DoseGroup.objects.filter(treatment_id=self.id).order_by( + "dose_group_id" + ) + + self.id = None + self.name = clone_name(self, "name") + self.save() + for dose_group in associated_dose_groups: + dose_group.id = None + dose_group.treatment_id = self.id + dose_group.save() + + return self + + +class DoseGroup(models.Model): + objects = managers.DoseGroupManager() + + treatment = models.ForeignKey( + Treatment, on_delete=models.CASCADE, related_name="v2_dose_groups" + ) + dose_group_id = models.PositiveSmallIntegerField() + dose = models.FloatField(validators=[MinValueValidator(0)]) + dose_units = models.ForeignKey( + "assessment.DoseUnits", on_delete=models.CASCADE, related_name="v2_dose_groups" + ) + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def get_assessment(self): + return self.treatment.get_assessment() + + def get_study(self): + return self.treatment.get_study() + + +class Endpoint(models.Model): + objects = managers.EndpointManager() + + experiment = models.ForeignKey( + Experiment, on_delete=models.CASCADE, related_name="v2_endpoints" + ) + name = models.CharField(max_length=128, blank=True, help_text="Endpoint/Adverse Outcome") + name_term = models.ForeignKey( + Term, + related_name="v2_endpoint_name_terms", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + system = models.CharField(max_length=128, blank=True, help_text="Relevant biological system") + system_term = models.ForeignKey( + Term, + related_name="v2_endpoint_system_terms", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + organ = models.CharField( + max_length=128, + blank=True, + verbose_name="Organ (and tissue)", + help_text="Relevant organ or tissue", + ) + organ_term = models.ForeignKey( + Term, + related_name="v2_endpoint_organ_terms", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + effect = models.CharField( + max_length=128, blank=True, help_text="Effect, using common-vocabulary" + ) + effect_term = models.ForeignKey( + Term, + related_name="v2_endpoint_effect_terms", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + effect_subtype = models.CharField( + max_length=128, blank=True, help_text="Effect subtype, using common-vocabulary" + ) + effect_subtype_term = models.ForeignKey( + Term, + related_name="v2_endpoint_effect_subtype_terms", + on_delete=models.SET_NULL, + blank=True, + null=True, + ) + # these next 4 are so-called flexible dropdowns. e.g. anatomical is + # "left/right/anterior/Posterior/Cranial/Caudal/Lateral/medial" but we want to let + # ppl type something else. do we want to store these (and add custom ones) in a table? + # Or just build the UI to show options from a list but also let user type custom ones + # (in that case just store it as a string)? leave as string for now. + effect_modifier_timing = models.CharField(max_length=128, blank=True) + effect_modifier_reference = models.CharField(max_length=128, blank=True) + effect_modifier_anatomical = models.CharField(max_length=128, blank=True) + effect_modifier_location = models.CharField(max_length=128, blank=True) + additional_tags = models.ManyToManyField(EffectTag, blank=True) + comments = models.TextField(blank=True, help_text="TODO") + + class Meta: + ordering = ("id",) + + def __str__(self): + return self.name + + def get_assessment(self): + return self.experiment.get_assessment() + + def get_study(self): + return self.experiment.get_study() + + def clone(self): + self.id = None + self.name = clone_name(self, "name") + self.save() + return self + + +class ObservationTime(models.Model): + objects = managers.ObservationTimeManager() + + endpoint = models.ForeignKey(Endpoint, on_delete=models.CASCADE, related_name="v2_timepoints") + observation_time = models.FloatField( + blank=True, + null=True, + verbose_name="Observation timepoint", + help_text="""Numeric value of the time an observation was reported; optional, should be recorded if the same effect was measured multiple times.""", + ) + observation_time_units = models.PositiveSmallIntegerField( + default=constants.ObservationTimeUnits.NR, + choices=constants.ObservationTimeUnits.choices, + ) + observation_time_text = models.CharField( + max_length=64, + blank=True, + help_text='Text for reported observation time (ex: "60-90 PND")', + ) + comments = models.TextField(blank=True, help_text="TODO") + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def get_assessment(self): + return self.endpoint.get_assessment() + + def get_study(self): + return self.endpoint.get_study() + + def __str__(self): + return ( + f"{self.endpoint}: {self.observation_time} {self.get_observation_time_units_display()}" + ) + + def clone(self): + self.id = None + self.save() + return self + + +class DataExtraction(models.Model): + objects = managers.DataExtractionManager() + + experiment = models.ForeignKey( + Experiment, on_delete=models.CASCADE, related_name="v2_data_extractions" + ) + endpoint = models.ForeignKey( + Endpoint, on_delete=models.CASCADE, related_name="v2_data_extractions" + ) + treatment = models.ForeignKey( + Treatment, on_delete=models.CASCADE, related_name="v2_data_extractions" + ) + observation_timepoint = models.ForeignKey( + ObservationTime, on_delete=models.CASCADE, related_name="v2_data_extractions" + ) + + # specific fields + is_qualitative_only = models.BooleanField(default=False) + data_location = models.CharField( + max_length=128, + blank=True, + help_text="""Details on where the data are found in the literature (ex: "Figure 1", "Table 2", "Text, p. 24", "Figure 1 and Text, p.24")""", + ) + dataset_type = models.CharField( + blank=True, default="", max_length=2, choices=constants.DatasetType.choices + ) + variance_type = models.PositiveSmallIntegerField( + default=constants.VarianceType.SD, choices=constants.VarianceType.choices + ) + statistical_method = models.CharField(max_length=128, blank=True, help_text="TODO") + statistical_power = models.CharField(max_length=128, blank=True, help_text="TODO") + method_to_control_for_litter_effects = models.PositiveSmallIntegerField( + choices=constants.MethodToControlForLitterEffects.choices + ) + values_estimated = models.BooleanField( + default=False, + help_text="Response values were estimated using a digital ruler or other methods", + ) + response_units = models.CharField( + max_length=32, + blank=True, + help_text="Units the response was measured in (i.e., \u03bcg/dL, % control, etc.)", + ) + dose_response_observations = models.TextField(help_text="TODO") + result_details = models.TextField(help_text="TODO") + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def get_assessment(self): + return self.experiment.get_assessment() + + def get_study(self): + return self.experiment.get_study() + + def clone(self): + self.id = None + self.save() + return self + + +class DoseResponseGroupLevelData(models.Model): + objects = managers.DoseResponseGroupLevelDataManager() + + data_extraction = models.ForeignKey( + DataExtraction, on_delete=models.CASCADE, related_name="v2_group_level_data" + ) + treatment_name = models.CharField(max_length=256, help_text="TODO") + dose = models.CharField(max_length=128, help_text="TODO") + # as per guidance, intentionally making this text, not numeric, in case extractors want to note units. + # could split into separate dose/dose_units instead if desired? See also DoseResponseAnimalLevelData.dose + + n = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(0)]) + response = models.FloatField() + variance = models.FloatField(blank=True, null=True, validators=[MinValueValidator(0)]) + treatment_related_effect = models.PositiveSmallIntegerField( + choices=constants.TreatmentRelatedEffect.choices + ) + statistically_significant = models.PositiveSmallIntegerField( + choices=constants.StatisticallySignificant.choices + ) + p_value = models.CharField(max_length=128, blank=True, help_text="TODO") + NOEL = models.SmallIntegerField(default=-999, help_text="No observed effect level") + LOEL = models.SmallIntegerField(default=-999, help_text="Lowest observed effect level") + + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def get_assessment(self): + return self.data_extraction.get_assessment() + + def get_study(self): + return self.data_extraction.get_study() + + +class DoseResponseAnimalLevelData(models.Model): + objects = managers.DoseResponseAnimalLevelDataManager() + + data_extraction = models.ForeignKey( + DataExtraction, on_delete=models.CASCADE, related_name="v2_animal_level_data" + ) + cage_id = models.CharField(max_length=128, blank=True, help_text="TODO") + animal_id = models.CharField(max_length=128, blank=True, help_text="TODO") + dose = models.CharField(max_length=128, help_text="TODO") + response = models.FloatField() + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def get_assessment(self): + return self.data_extraction.get_assessment() + + def get_study(self): + return self.data_extraction.get_study() + + +class StudyLevelValue(models.Model): + study = models.ForeignKey("study.Study", on_delete=models.CASCADE, related_name="values") + system = models.CharField( + verbose_name="System or health effect basis", + max_length=128, + blank=True, + help_text="Identify the health system of concern (e.g., Hepatic, Nervous, Reproductive)", + ) + value_type = models.PositiveSmallIntegerField( + choices=constants.StudyLevelTypeChoices.choices, + help_text="Type of derived value", + ) + value = models.FloatField(help_text="The value") + units = models.ForeignKey("assessment.DoseUnits", on_delete=models.CASCADE) + comments = models.TextField( + blank=True, help_text="General comments related to the derivation of this value" + ) # TODO - review schema, add new comment types? + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def get_assessment(self): + return self.study.get_assessment() + + def get_absolute_url(self): + return reverse("animalv2:studylevelvalues-htmx", args=[self.pk, "read"]) + + def get_edit_url(self): + return reverse("animalv2:studylevelvalues-htmx", args=[self.pk, "update"]) + + def get_delete_url(self): + return reverse("animalv2:studylevelvalues-htmx", args=[self.pk, "delete"]) + + +class Observation(models.Model): + objects = managers.ObservationManager() + + experiment = models.ForeignKey(Experiment, on_delete=models.CASCADE) + endpoint = models.ForeignKey( + Term, on_delete=models.PROTECT + ) # TODO - should this save guideline profile instead? + tested_status = models.BooleanField(default=False) + reported_status = models.BooleanField(default=False) + created_on = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("id",) + + def __str__(self) -> str: + return f"{self.experiment}:{self.endpoint}" + + def get_assessment(self): + return self.experiment.get_assessment() + + @classmethod + def default( + cls, experiment: Experiment, profile: GuidelineProfile, reported: bool = False + ) -> Self: + # Return a instance of the observation based on the profile + reported_status = False + tested_status = False + if reported or profile.obs_status == ObservationStatus.REQUIRED: + reported_status = True + tested_status = True + elif profile.obs_status in (ObservationStatus.RECOMMENDED, ObservationStatus.TRIGGERED): + reported_status = True + tested_status = False + return cls( + experiment=experiment, + endpoint=profile.endpoint, + tested_status=tested_status, + reported_status=reported_status, + ) + + @classmethod + def generate_observations(cls, experiment: Experiment) -> list[Self]: + """Given an experiment, return all observations""" + observations = [] + + # if no guideline; no observations required + if experiment.guideline_id is None: + return observations + + # get guideline profile instances + profiles = GuidelineProfile.objects.filter(guideline=experiment.guideline) + + # get all extracted experimental endpoints + endpoints = Endpoint.objects.filter( + experiment=experiment, effect_subtype_term_id__in=profiles.values_list("endpoint") + ) + endpoint_dict = {e.effect_subtype_term_id: e for e in endpoints} + + # add saved observations + # TODO select related for parents b/c of the filtering in filterset + # (can this be improved to separate view from model logic?) + saved_observations = cls.objects.filter(experiment=experiment).select_related( + "endpoint__parent__parent__parent" + ) + saved_observations_dict = {el.endpoint_id: el for el in saved_observations} + + # generate observations for all profiles + # TODO select related for parents b/c of the filtering in filterset + # (can this be improved to separate view from model logic?) + for profile in profiles.select_related("endpoint__parent__parent__parent"): + observation = saved_observations_dict.get(profile.endpoint_id) + matched_endpoint = endpoint_dict.get(profile.endpoint_id) + + # generate and append unsaved observations + if observation is None: + observation = cls.default(experiment, profile, matched_endpoint is not None) + + observation.endpoint_object = matched_endpoint + observations.append(observation) + + return observations + + +reversion.register(Experiment) +reversion.register(Chemical) +reversion.register(AnimalGroup) +reversion.register(Treatment) +reversion.register(DoseGroup) +reversion.register(Endpoint) +reversion.register(ObservationTime) +reversion.register(DataExtraction) +reversion.register(DoseResponseGroupLevelData) +reversion.register(DoseResponseAnimalLevelData) +reversion.register(StudyLevelValue) +reversion.register(Observation) diff --git a/hawc/apps/animalv2/serializers.py b/hawc/apps/animalv2/serializers.py new file mode 100644 index 000000000..29732b957 --- /dev/null +++ b/hawc/apps/animalv2/serializers.py @@ -0,0 +1,250 @@ +from rest_framework import serializers + +from ..assessment.models import DoseUnits, DSSTox, EffectTag, Species, Strain +from ..assessment.serializers import DSSToxSerializer, SpeciesSerializer, StrainSerializer +from ..common.serializers import FlexibleChoiceField, IdLookupMixin +from . import constants, models + + +class ExperimentSerializer(IdLookupMixin, serializers.ModelSerializer): + design = FlexibleChoiceField(choices=constants.ExperimentDesign.choices) + + class Meta: + model = models.Experiment + fields = "__all__" + + +class ChemicalSerializer(serializers.ModelSerializer): + dtxsid_id = serializers.PrimaryKeyRelatedField( + write_only=True, + source="dtxsid", + queryset=DSSTox.objects.all(), + required=False, + allow_null=True, + ) + dtxsid = DSSToxSerializer(read_only=True) + + class Meta: + model = models.Chemical + fields = "__all__" + + +class SimpleAnimalGroupSerializer(serializers.ModelSerializer): + # Simplified serializer to display Animal Group parents + class Meta: + model = models.AnimalGroup + fields = ("id", "name") + + +class AnimalGroupSerializer(serializers.ModelSerializer): + species_id = serializers.PrimaryKeyRelatedField( + write_only=True, + source="species", + queryset=Species.objects.all(), + required=True, + allow_null=False, + ) + species = SpeciesSerializer(read_only=True) + + strain_id = serializers.PrimaryKeyRelatedField( + write_only=True, + source="strain", + queryset=Strain.objects.all(), + required=True, + allow_null=False, + ) + strain = StrainSerializer(read_only=True) + + sex = FlexibleChoiceField(choices=constants.Sex.choices) + lifestage_at_exposure = FlexibleChoiceField( + required=False, choices=constants.Lifestage.choices, allow_blank=True + ) + lifestage_at_assessment = FlexibleChoiceField( + required=False, choices=constants.Lifestage.choices, allow_blank=True + ) + generation = FlexibleChoiceField(required=False, choices=constants.Generation.choices) + + parent_ids = serializers.PrimaryKeyRelatedField( + write_only=True, + source="parents", + queryset=models.AnimalGroup.objects.all(), + required=False, + allow_null=True, + many=True, + ) + parents = SimpleAnimalGroupSerializer(required=False, many=True, read_only=True) + + def validate(self, data): + errors = {} + + if self.instance is None: + experiment = data.get("experiment") + species = data.get("species") + strain = data.get("strain") + parents = data.get("parents") + else: + experiment = data.get("experiment", self.instance.experiment) + species = data.get("species", self.instance.species) + strain = data.get("strain", self.instance.strain) + parents = data.get("parents", self.instance.parents.all()) + + if species.id != strain.species_id: + errors["strain"] = "Strain must be valid for species" + + parent_errors = [] + if "parents" in data and self.instance is not None: + # ensure they didn't supply the animal-group itself as a parent + if self.instance.id in [p.id for p in parents]: + parent_errors.append("Self cannot be parent of self") + + # ensure experiment/parents match + if parents is not None and len(parents) > 0: + if any(p.experiment_id != experiment.id for p in parents): + parent_errors.append( + "This animal group's experiment and one or more parent's experiment are mismatched." + ) + + if len(parent_errors) > 0: + errors["parent_ids"] = parent_errors + + if len(errors.keys()) > 0: + raise serializers.ValidationError(errors) + + return data + + class Meta: + model = models.AnimalGroup + fields = "__all__" + + +class TreatmentSerializer(serializers.ModelSerializer): + chemical_id = serializers.PrimaryKeyRelatedField( + write_only=True, + source="chemical", + queryset=models.Chemical.objects.all(), + required=True, + allow_null=False, + ) + chemical = ChemicalSerializer(read_only=True) + + route_of_exposure = FlexibleChoiceField(choices=constants.RouteExposure.choices) + + class Meta: + model = models.Treatment + fields = "__all__" + + +class DoseGroupSerializer(serializers.ModelSerializer): + dose_units = serializers.SlugRelatedField( + slug_field="name", + queryset=DoseUnits.objects.all(), + ) + + class Meta: + model = models.DoseGroup + fields = "__all__" + + +class EndpointSerializer(serializers.ModelSerializer): + # TODO - implement EHV integration + + additional_tags = serializers.SlugRelatedField( + slug_field="slug", + many=True, + allow_null=True, + required=False, + queryset=EffectTag.objects.all(), + ) + + class Meta: + model = models.Endpoint + fields = "__all__" + + +class ObservationTimeSerializer(serializers.ModelSerializer): + endpoint_id = serializers.PrimaryKeyRelatedField( + write_only=True, + source="endpoint", + queryset=models.Endpoint.objects.all(), + required=True, + allow_null=False, + ) + endpoint = EndpointSerializer(read_only=True) + + observation_time_units = FlexibleChoiceField(choices=constants.ObservationTimeUnits.choices) + + class Meta: + model = models.ObservationTime + fields = "__all__" + + +class DataExtractionSerializer(serializers.ModelSerializer): + dataset_type = FlexibleChoiceField(choices=constants.DatasetType.choices) + variance_type = FlexibleChoiceField( + choices=constants.VarianceType.choices, required=False, allow_blank=True + ) + method_to_control_for_litter_effects = FlexibleChoiceField( + choices=constants.MethodToControlForLitterEffects.choices + ) + + def validate(self, attrs): + errors = [] + if self.instance is None: + experiment = attrs.get("experiment") + endpoint = attrs.get("endpoint") + treatment = attrs.get("treatment") + observation_timepoint = attrs.get("observation_timepoint") + else: + experiment = attrs.get("experiment", self.instance.experiment) + endpoint = attrs.get("endpoint", self.instance.endpoint) + treatment = attrs.get("treatment", self.instance.treatment) + observation_timepoint = attrs.get( + "observation_timepoint", self.instance.observation_timepoint + ) + + if ( + endpoint.experiment_id != treatment.experiment_id + or experiment.id != endpoint.experiment_id + ): + errors.append("Endpoint/Treatment/Experiment mismatch") + + if endpoint.id != observation_timepoint.endpoint_id: + errors.append("Observation Time/Endpoint mismatch") + + if errors: + raise serializers.ValidationError({"general": errors}) + + return attrs + + class Meta: + model = models.DataExtraction + fields = "__all__" + + +class DoseResponseGroupLevelDataSerializer(serializers.ModelSerializer): + treatment_related_effect = FlexibleChoiceField(choices=constants.TreatmentRelatedEffect.choices) + + class Meta: + model = models.DoseResponseGroupLevelData + fields = "__all__" + + +class DoseResponseAnimalLevelDataSerializer(serializers.ModelSerializer): + class Meta: + model = models.DoseResponseAnimalLevelData + fields = "__all__" + + +class ObservationSerializer(serializers.ModelSerializer): + class Meta: + model = models.Observation + fields = "__all__" + + +class StudyLevelValueSerializer(IdLookupMixin, serializers.ModelSerializer): + value_type = FlexibleChoiceField(choices=constants.StudyLevelTypeChoices.choices) + units = serializers.SlugRelatedField(slug_field="name", queryset=DoseUnits.objects.all()) + + class Meta: + model = models.StudyLevelValue + fields = "__all__" diff --git a/hawc/apps/animalv2/templates/animalv2/experiment_confirm_delete.html b/hawc/apps/animalv2/templates/animalv2/experiment_confirm_delete.html new file mode 100644 index 000000000..97a1a8023 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/experiment_confirm_delete.html @@ -0,0 +1,6 @@ +{% extends 'animalv2/experiment_detail.html' %} + +{% block content %} + {% include "hawc/_delete_block.html" with name="experiment" notes="This will remove all content associated with this experiment." %} + {{ block.super }} +{% endblock content %} diff --git a/hawc/apps/animalv2/templates/animalv2/experiment_detail.html b/hawc/apps/animalv2/templates/animalv2/experiment_detail.html new file mode 100644 index 000000000..17980cb96 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/experiment_detail.html @@ -0,0 +1,122 @@ +{% extends 'assessment-rooted.html' %} + +{% block content %} +
+

{{object.name}}

+ {% if obj_perms.edit and crud == "Read" %} + {% actions %} + Experiment editing + Update + Delete + {% if assessment.enable_observations and object.guideline %} + Guideline Observations + View + {% endif %} + {% endactions %} + {% endif %} +
+ {% include "animalv2/fragments/_experiment_table.html" %} + +
+
+

Chemicals

+ + {% bs4_colgroup '40,30,30' %} + {% bs4_thead 'Name,CAS,DTXSID' %} + + {% for object in object.v2_chemicals.all %} + {% include "animalv2/fragments/_chemical_row.html" %} + {% empty %} + {% bs4_fullrow 'No chemicals available.' %} + {% endfor %} + +
+
+
+ +
+
+

Animal Groups

+ + {% bs4_colgroup '40,20,20,20' %} + {% bs4_thead 'Name,Species,Strain,Sex' %} + + {% for object in object.v2_animal_groups.all %} + {% include "animalv2/fragments/_animalgroup_row.html" %} + {% empty %} + {% bs4_fullrow 'No animal groups available.' %} + {% endfor %} + +
+
+
+ +
+
+

Treatments

+ + {% bs4_colgroup '60,40' %} + {% bs4_thead 'Name,Chemical' %} + + {% for object in object.v2_treatments.all %} + {% include "animalv2/fragments/_treatment_row.html" %} + {% empty %} + {% bs4_fullrow 'No treatments available.' %} + {% endfor %} + +
+
+
+ +
+
+

Endpoints

+ + {% bs4_colgroup '40,20,20,20' %} + {% bs4_thead 'Name,System,Organ,Effect' %} + + {% for object in object.v2_endpoints.all %} + {% include "animalv2/fragments/_endpoint_row.html" %} + {% empty %} + {% bs4_fullrow 'No endpoints available.' %} + {% endfor %} + +
+
+
+ +
+
+

Endpoint Timepoints

+ + {% bs4_colgroup '40,30,30' %} + {% bs4_thead 'Endpoint,Time,Units' %} + + {% for object in object.get_v2_timepoints %} + {% include "animalv2/fragments/_observationtime_row.html" %} + {% empty %} + {% bs4_fullrow 'No timepoints available.' %} + {% endfor %} + +
+
+
+ +
+
+

Data Extractions

+ + {% bs4_colgroup '50,50' %} + {% bs4_thead 'Endpoint,Timepoint' %} + + {% for object in object.v2_data_extractions.all %} + {% include "animalv2/fragments/_dataextraction_row.html" %} + {% empty %} + {% bs4_fullrow 'No data extractions available.' %} + {% endfor %} + +
+
+
+ +{% endblock %} diff --git a/hawc/apps/animalv2/templates/animalv2/experiment_form.html b/hawc/apps/animalv2/templates/animalv2/experiment_form.html new file mode 100644 index 000000000..79cd99120 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/experiment_form.html @@ -0,0 +1,4 @@ +{% extends 'assessment-rooted.html' %} +{% block content %} + {% crispy form %} +{% endblock %} diff --git a/hawc/apps/animalv2/templates/animalv2/experiment_update.html b/hawc/apps/animalv2/templates/animalv2/experiment_update.html new file mode 100644 index 000000000..985786190 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/experiment_update.html @@ -0,0 +1,134 @@ +{% extends 'assessment-rooted.html' %} + +{% block content %} +

Update {{object.summary}}

+ + + {% include "animalv2/fragments/_experiment_table.html" %} + +
+
+ {% url 'animalv2:chemical-htmx' object.pk 'create' as chemical_create %} + {% include "common/fragments/_create_card.html" with title="Chemicals" hx_get=chemical_create hx_target=".chemical-edit-row.create-row" model="chemical" btn_text="Create" %} + + {% bs4_colgroup '30,28,30,12' %} + {% bs4_thead 'Name,CAS,DTXSID,Edit' %} + + {% for object in object.v2_chemicals.all %} + {% include "animalv2/fragments/_chemical_row.html" %} + {% endfor %} + {% include "common/fragments/_create_one.html" with text="No chemicals. Create one?" %} + {% include "common/fragments/_create_row.html" with model="chemical" first=True %} + +
+
+
+ +
+
+ {% url 'animalv2:animalgroup-htmx' object.pk 'create' as animalgroup_create %} + {% include "common/fragments/_create_card.html" with title="Animal Groups" hx_get=animalgroup_create hx_target=".animalgroup-edit-row.create-row" model="animalgroup" btn_text="Create" %} + + {% bs4_colgroup '34,18,18,18,12' %} + {% bs4_thead 'Name,Species,Strain,Sex,Edit' %} + + {% for object in object.v2_animal_groups.all %} + {% include "animalv2/fragments/_animalgroup_row.html" %} + {% endfor %} + {% include "common/fragments/_create_one.html" with text="No animal groups. Create one?" %} + {% include "common/fragments/_create_row.html" with model="animalgroup" first=True %} + +
+
+
+ +
+
+ {% url 'animalv2:treatment-htmx' object.pk 'create' as treatment_create %} + {% include "common/fragments/_create_card.html" with title="Treatments" hx_get=treatment_create hx_target=".treatment-edit-row.create-row" model="treatment" btn_text="Create" %} + + {% bs4_colgroup '44,44,12' %} + {% bs4_thead 'Name,Chemical,Edit' %} + + {% for object in object.v2_treatments.all %} + {% include "animalv2/fragments/_treatment_row.html" %} + {% endfor %} + {% include "common/fragments/_create_one.html" with text="No treatments. Create one?" %} + {% include "common/fragments/_create_row.html" with model="treatment" first=True %} + +
+
+
+ +
+
+ {% url 'animalv2:endpoint-htmx' object.pk 'create' as endpoint_create %} + {% include "common/fragments/_create_card.html" with title="Endpoints" hx_get=endpoint_create hx_target=".endpoint-edit-row.create-row" model="endpoint" btn_text="Create" %} + + {% bs4_colgroup '30,20,20,18,12' %} + {% bs4_thead 'Name,System,Organ,Effect,Edit' %} + + {% for object in object.v2_endpoints.all %} + {% include "animalv2/fragments/_endpoint_row.html" %} + {% endfor %} + {% include "common/fragments/_create_one.html" with text="No endpoints. Create one?" %} + {% include "common/fragments/_create_row.html" with model="endpoint" first=True %} + +
+
+
+ +
+
+ {% url 'animalv2:observationtime-htmx' object.pk 'create' as observationtime_create %} + {% include "common/fragments/_create_card.html" with title="Endpoint Timepoints" hx_get=observationtime_create hx_target=".observationtime-edit-row.create-row" model="observationtime" btn_text="Create" %} + + {% bs4_colgroup '30,30,28,12' %} + {% bs4_thead 'Endpoint,Time,Units,Edit' %} + + {% for object in object.get_v2_timepoints %} + {% include "animalv2/fragments/_observationtime_row.html" %} + {% endfor %} + {% include "common/fragments/_create_one.html" with text="No endpoint timepoints. Create one?" %} + {% include "common/fragments/_create_row.html" with model="observationtime" first=True %} + +
+
+
+ +
+
+ {% url 'animalv2:dataextraction-htmx' object.pk 'create' as dataextraction_create %} + {% include "common/fragments/_create_card.html" with title="Data Extractions" hx_get=dataextraction_create hx_target=".dataextraction-edit-row.create-row" btn_text="Create" %} + + {% bs4_colgroup '45,43,12' %} + {% bs4_thead 'Endpoint,Timepoint,Edit' %} + + {% for object in object.v2_data_extractions.all %} + {% include "animalv2/fragments/_dataextraction_row.html" %} + {% endfor %} + {% include "common/fragments/_create_one.html" with text="No data extractions. Create one?" %} + {% include "common/fragments/_create_row.html" with model="dataextraction" first=True %} + +
+
+
+ +
+ Cancel +
+ +{% endblock %} + +{% block extrajs %} + {% include "common/htmx_autocomplete.html" %} + +{% endblock %} diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_animalgroup_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/_animalgroup_row.html new file mode 100644 index 000000000..dbcc15bed --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_animalgroup_row.html @@ -0,0 +1,8 @@ + + {{object.name}} + {{object.species}} + {{object.strain}} + {{object.sex}} + {% include "common/fragments/_object_row.html" with model="animalgroup" app="animalv2"%} + +{% include "common/fragments/_create_row.html" with model="animalgroup" %} diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_chemical_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/_chemical_row.html new file mode 100644 index 000000000..c86f1d4c3 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_chemical_row.html @@ -0,0 +1,7 @@ + + {{object.name}} + {{object.cas}} + {{object.dtxsid.verbose_link|safe|default:"-" }} + {% include "common/fragments/_object_row.html" with model="chemical" app="animalv2" %} + +{% include "common/fragments/_create_row.html" with model="chemical" %} \ No newline at end of file diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_formset_animalleveldata.html b/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_formset_animalleveldata.html new file mode 100644 index 000000000..e0824bee1 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_formset_animalleveldata.html @@ -0,0 +1,9 @@ +{{ formset.management_form|crispy }} + +
+

Dose Response Animal Level Data

+ + {% crispy formset_instance formset_helper %} + + +
diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_formset_groupleveldata.html b/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_formset_groupleveldata.html new file mode 100644 index 000000000..4155a5991 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_formset_groupleveldata.html @@ -0,0 +1,9 @@ +{{ formset.management_form|crispy }} + +
+

Dose Response Group Level Data

+ + {% crispy formset_instance formset_helper %} + + +
diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_row.html new file mode 100644 index 000000000..a8437501a --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_dataextraction_row.html @@ -0,0 +1,6 @@ + + {{object.experiment}} + {{object.endpoint}} + {% include "common/fragments/_object_row.html" with model="dataextraction" app="animalv2" %} + +{% include "common/fragments/_create_row.html" with model="dataextraction" %} \ No newline at end of file diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_endpoint_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/_endpoint_row.html new file mode 100644 index 000000000..90e92405c --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_endpoint_row.html @@ -0,0 +1,8 @@ + + {{object.name}} + {{object.system}} + {{object.organ}} + {{object.effect}} + {% include "common/fragments/_object_row.html" with model="endpoint" app="animalv2" %} + +{% include "common/fragments/_create_row.html" with model="endpoint" %} diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_edit.html b/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_edit.html new file mode 100644 index 000000000..1eacdf6ac --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_edit.html @@ -0,0 +1,15 @@ +
+
+
+ {% include "common/scientific_pulldown.html" %} +
+ {% crispy form %} +
+
+ + +
+
+
+ {{form.media}} +
diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_list_tbl.html b/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_list_tbl.html new file mode 100644 index 000000000..03d9d0bb1 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_list_tbl.html @@ -0,0 +1,18 @@ +{% if object_list or obj_perms.edit %} +

Animal bioassay experiments

+ + {% bs4_colgroup '25,25,50' %} + {% bs4_thead 'Name,Design,Comments' %} + + {% for object in object_list %} + + + + + + {% empty %} + {% bs4_fullrow 'No experiments available.' %} + {% endfor %} + +
{{object}}{{object.get_design_display}}{{object.comments|safe}}
+{% endif %} diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_table.html b/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_table.html new file mode 100644 index 000000000..6165318f3 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_experiment_table.html @@ -0,0 +1,11 @@ + + {% bs4_colgroup '20,80' %} + + {% optional_table_row "Experiment name" object.name %} + {% optional_table_row "Design" object.get_design_display %} + {% optional_table_row "Multiple generations" object.get_has_multiple_generations_display %} + {% optional_table_row "Guideline compliance" object.guideline_compliance %} + {% optional_table_row "Guideline" object.guideline %} + {% optional_table_row "Comments" object.comments %} + +
diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_observationtime_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/_observationtime_row.html new file mode 100644 index 000000000..b47ddd0e7 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_observationtime_row.html @@ -0,0 +1,7 @@ + + {{object.endpoint.name}} + {{object.observation_time}} + {{object.get_observation_time_units_display}} + {% include "common/fragments/_object_row.html" with model="observationtime" app="animalv2" %} + +{% include "common/fragments/_create_row.html" with model="observationtime" %} diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_treatment_formset.html b/hawc/apps/animalv2/templates/animalv2/fragments/_treatment_formset.html new file mode 100644 index 000000000..9743b1f7f --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_treatment_formset.html @@ -0,0 +1,9 @@ +{{ formset.management_form|crispy }} + +
+

Dose Groups

+ + {% crispy formset_instance formset_helper %} + + +
diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/_treatment_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/_treatment_row.html new file mode 100644 index 000000000..48ae4b0bf --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/_treatment_row.html @@ -0,0 +1,6 @@ + + {{object.name}} + {{object.chemical}} + {% include "common/fragments/_object_row.html" with model="treatment" app="animalv2" %} + +{% include "common/fragments/_create_row.html" with model="treatment" %} \ No newline at end of file diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/observation_help_text.html b/hawc/apps/animalv2/templates/animalv2/fragments/observation_help_text.html new file mode 100644 index 000000000..6834a4f93 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/observation_help_text.html @@ -0,0 +1,31 @@ + + {% bs4_colgroup "15,15,70" %} + {% bs4_thead 'Tested Status,Reported Status,Assumption' %} + + + + + + + + + + + + + + + + + + + + +
YesYes + The text of the study document explicitly stated the endpoint was measured, or data was presented in tables for the endpoint. This is the combination if required by the guideline for that study type and data is provided within the document, even the effects measured were not significant. +
NoYes + This is the combination if the study document explicitly states the endpoint was not measured or data was not collected. For a guideline study, Tested Status should be changed to No, and Reported Status changed to Yes when this is true for an endpoint required by the study guidelines, but not for endpoints that are not required by guideline +
YesNo + The text of the study document does not state the endpoint was measured and data for the endpoint is not present. However, other evidence suggests that the endpoint was measured. This is the default for endpoints required by the study guideline and should only be changed in the face of direct evidence from the document. +
NoNo A long table of observations from all study guidelines is displayed. This is the default setting for the endpoints not required by the alternative study guidelines and they should only be changed in the face of direct evidence from the document that the endpoint was tested. Interpret these observations as irrelevant since they are not serving the selected guideline, therefore not required to be tested nor reported. +
diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/observation_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/observation_row.html new file mode 100644 index 000000000..b97258a32 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/observation_row.html @@ -0,0 +1,8 @@ + + {{ object.endpoint.parent.parent.name }} + {{ object.endpoint.parent.name }} + {{ object.endpoint.name }} + {{object.id|yesno:'-,✔'}} + {{object.tested_status|yesno:'✔,𐄂,?'}} + {{object.reported_status|yesno:'✔,𐄂,?'}} + diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_edit_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_edit_row.html new file mode 100644 index 000000000..662ee32dc --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_edit_row.html @@ -0,0 +1,34 @@ + + {% if form %} + +
+ {% crispy form %} +
+ {% if form.instance.id %} + + + {% else %} + + + {% endif %} +
+
+ + {% endif %} + diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_row.html b/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_row.html new file mode 100644 index 000000000..b0445c5b4 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_row.html @@ -0,0 +1,35 @@ + + {{object.system}} + {{object.get_value_type_display}} + {{object.value}} {{object.units}} + {{object.comments}} + {% if action == 'delete' %} + +
+ {% csrf_token %} +  Are you sure you want to delete? +
+ + +
+
+ + {% elif can_edit or permissions.edit %} + {% if obj_perms.edit or permissions.edit %} + + + + + {% endif %} + {% endif %} + +{% include "common/fragments/_create_row.html" with model="studylevelvalue" %} diff --git a/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_tbl.html b/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_tbl.html new file mode 100644 index 000000000..d101329cc --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/fragments/studylevelvalue_tbl.html @@ -0,0 +1,18 @@ + + {% if can_edit %} + {% bs4_thead 'System,Value Type,Value,Comments,Editing' %} + {% bs4_colgroup '20,20,20,20,20' %} + {% else %} + {% bs4_thead 'System,Value Type,Value,Comments' %} + {% bs4_colgroup '25,25,25,25' %} + {% endif %} + + {% for object in object_list %} + {% include "animalv2/fragments/studylevelvalue_row.html" %} + {% endfor %} + {% if can_edit %} + {% include "common/fragments/_create_one.html" with text='No study level values to display. Click "Create new study level value" to add one.' %} + {% include "common/fragments/_create_row.html" with model="studylevelvalue" first=True %} + {% endif %} + +
diff --git a/hawc/apps/animalv2/templates/animalv2/observation_list.html b/hawc/apps/animalv2/templates/animalv2/observation_list.html new file mode 100644 index 000000000..c29e0625b --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/observation_list.html @@ -0,0 +1,80 @@ +{% extends 'assessment-rooted.html' %} + +{% block content %} +
+

Observations

+
+
+

+ The Observation module allows for distinction between missing (not tested) and negative (tested with no effect seen) endpoints for studies that adhere to a guideline. A table of observed endpoints is populated with tested statuses based on the “Study Type” selection from {{ guideline }}. The tested status is derived from whether the endpoint is required, recommended, triggered, not required, or mentioned by the study’s most closely related OCSPP Health Effects 870 Series guideline or guideline profile developed for studies from theNational Toxicology Program (NTP). By default, “Tested Status = Yes” indicates observations are required by the guideline, whereas all others will show as “Tested Status = No”, and all observations are initially assumed “Reported Status = No”. When the curator enters the module, endpoints that have extracted effects data within the experiment will automatically get set as ““Reported Status = Yes”. For the remaining observations, curators will manually change these selections according to the details provided in the document. Updating to “Reported Status = Yes” should be done when the document indicates whether the effect was measured or not, regardless of whether the reported results indicated positive effects or not. Typically, only endpoints showing positive effects are reported. Only observations with changes from default statuses will be saved. +

+ Expand overview +
+ {% include "animalv2/fragments/observation_help_text.html" %} +
+
+ {% include 'common/inline_filter_form.html' %} + + + {% bs4_colgroup "20,20,30,10,10,10" %} + + + + + + + + + + + + {% for object in object_list %} + {% include "animalv2/fragments/observation_row.html" with experiment=experiment %} + {% empty %} + + + + {% endfor %} + +
System {% icon "fa-question-circle" %} + Effect {% icon "fa-question-circle" %} + Effect Subtype {% icon "fa-question-circle" %} + DefaultTested Status {% icon "fa-question-circle" %} + Reported Status {% icon "fa-question-circle" %} +
+ Observations have not been populated for this study yet: no guideline has been selected +
+ +{% endblock %} + +{% block extrajs %} + {{ config|json_script:"config" }} + +{% endblock %} diff --git a/hawc/apps/animalv2/templates/animalv2/studylevelvalues.html b/hawc/apps/animalv2/templates/animalv2/studylevelvalues.html new file mode 100644 index 000000000..9d0a4dfd7 --- /dev/null +++ b/hawc/apps/animalv2/templates/animalv2/studylevelvalues.html @@ -0,0 +1,11 @@ +{% extends 'assessment-rooted.html' %} + +{% block content %} +
+

Study Level Values

+ {% if obj_perms.edit %} + Create New + {% endif %} +
+ {% include "animalv2/fragments/studylevelvalue_tbl.html" with object_list=object_list can_edit=obj_perms.edit %} +{% endblock content %} diff --git a/hawc/apps/animalv2/urls.py b/hawc/apps/animalv2/urls.py new file mode 100644 index 000000000..142968612 --- /dev/null +++ b/hawc/apps/animalv2/urls.py @@ -0,0 +1,105 @@ +from django.urls import include, path +from rest_framework.routers import SimpleRouter + +from . import api, views + +router = SimpleRouter() +router.register("experiment", api.ExperimentViewSet, basename="experiment") +router.register("chemical", api.ChemicalViewSet, basename="chemical") +router.register("animal-group", api.AnimalGroupViewSet, basename="animal-group") +router.register("treatment", api.TreatmentViewSet, basename="treatment") +router.register("dose-group", api.DoseGroupViewSet, basename="dose-group") +router.register("endpoint", api.EndpointViewSet, basename="endpoint") +router.register("observation-time", api.ObservationTimeViewSet, basename="observation-time") +router.register("data-extraction", api.DataExtractionViewSet, basename="data-extraction") +router.register( + "dose-response-group-level-data", + api.DoseResponseGroupLevelDataViewSet, + basename="dose-response-group-level-data", +) +router.register( + "dose-response-animal-level-data", + api.DoseResponseAnimalLevelDataViewSet, + basename="dose-response-animal-level-data", +) +router.register("observation", api.ObservationViewSet, basename="observation") +router.register("study-level-value", api.StudyLevelValueViewSet, basename="study-level-value") + +app_name = "animalv2" +urlpatterns = [ + path("api/", include((router.urls, "api"))), + # Experiment + path( + "study//experiment/create/", + views.ExperimentCreate.as_view(), + name="experiment_create", + ), + path( + "experiment//update/", + views.ExperimentUpdate.as_view(), + name="experiment_update", + ), + path( + "experiment//", + views.ExperimentDetail.as_view(), + name="experiment_detail", + ), + path( + "experiment//delete/", + views.ExperimentDelete.as_view(), + name="experiment_delete", + ), + # HTMX ViewSet + path( + "experimentv2///", + views.ExperimentViewSet.as_view(), + name="experiment-htmx", + ), + path( + "chemical///", + views.ChemicalViewSet.as_view(), + name="chemical-htmx", + ), + path( + "animalgroup///", + views.AnimalGroupViewSet.as_view(), + name="animalgroup-htmx", + ), + path( + "treatment///", + views.TreatmentViewSet.as_view(), + name="treatment-htmx", + ), + path( + "endpoint///", + views.EndpointViewSet.as_view(), + name="endpoint-htmx", + ), + path( + "observationtime///", + views.ObservationTimeViewSet.as_view(), + name="observationtime-htmx", + ), + path( + "dataextraction///", + views.DataExtractionViewSet.as_view(), + name="dataextraction-htmx", + ), + # Study Level Values + path( + "study//study-level-values/", + views.StudyLevelValues.as_view(), + name="studylevelvalues", + ), + path( + "study-level-values///", + views.StudyLevelValueViewSet.as_view(), + name="studylevelvalues-htmx", + ), + # Observations + path( + "experiment//observations/", + views.ObservationList.as_view(), + name="observation-list", + ), +] diff --git a/hawc/apps/animalv2/views.py b/hawc/apps/animalv2/views.py new file mode 100644 index 000000000..94354181b --- /dev/null +++ b/hawc/apps/animalv2/views.py @@ -0,0 +1,414 @@ +from django.db import transaction +from django.db.models import Model, Q +from django.forms import BaseForm, modelformset_factory +from django.http import HttpRequest +from django.shortcuts import render + +from ..assessment.constants import AssessmentViewPermissions +from ..common.forms import FormsetGenericFormHelper +from ..common.htmx import HtmxViewSet, Item, action, can_edit, can_view +from ..common.views import ( + BaseCreate, + BaseDelete, + BaseDetail, + BaseFilterList, + BaseList, + BaseUpdate, + FormsetConfiguration, + create_object_log, +) +from ..mgmt.views import EnsureExtractionStartedMixin +from ..study.models import Study +from . import filterset, forms, models + +# TODO - make sure HTML views efficiently query database, HTMX views lower priority + + +class ExperimentCreate(EnsureExtractionStartedMixin, BaseCreate): + success_message = "Experiment created." + parent_model = Study + parent_template_name = "study" + model = models.Experiment + form_class = forms.ExperimentForm + + +class ExperimentUpdate(BaseUpdate): + success_message = "Experiment updated." + parent_model = Study + parent_template_name = "study" + model = models.Experiment + form_class = forms.ExperimentForm + template_name = "animalv2/experiment_update.html" + + +class ExperimentDetail(BaseDetail): + model = models.Experiment + + +class ExperimentDelete(BaseDelete): + success_message = "Experiment deleted." + model = models.Experiment + + def get_success_url(self): + return self.object.study.get_absolute_url() + + +class ExperimentViewSet(HtmxViewSet): + actions = {"read", "update"} + parent_model = Study + model = models.Experiment + form_fragment = "animalv2/fragments/_experiment_edit.html" + detail_fragment = "animalv2/fragments/_experiment_table.html" + + @action(permission=can_view) + def read(self, request: HttpRequest, *args, **kwargs): + return render(request, self.detail_fragment, self.get_context_data()) + + @action(methods=("get", "post"), permission=can_edit) + def update(self, request: HttpRequest, *args, **kwargs): + template = self.form_fragment + data = request.POST if request.method == "POST" else None + form = forms.ExperimentForm(data=data, instance=request.item.object) + if request.method == "POST" and form.is_valid(): + self.perform_update(request.item, form) + template = self.detail_fragment + context = self.get_context_data(form=form) + return render(request, template, context) + + +class ExperimentChildViewSet(HtmxViewSet): + actions = {"create", "read", "update", "delete", "clone"} + parent_model = models.Experiment + model: type[Model] + form_class: type[BaseForm] + form_fragment = "common/fragments/_object_edit_row.html" + detail_fragment: str + formset_configurations = [] + + @action(permission=can_view) + def read(self, request: HttpRequest, *args, **kwargs): + return render(request, self.detail_fragment, self.get_context_data()) + + @action(methods=("get", "post"), permission=can_edit) + def create(self, request: HttpRequest, *args, **kwargs): + template = self.form_fragment + formsets = [] + formsets_valid_if_present = True + # TODO - refactor shared code between `create` and `update`? + # TODO - errors in formset forms not rendering - show? + if request.method == "POST": + # make a copy; if we do any is_valid modifying of the data we need this... + request.POST = request.POST.copy() + + form = self.form_class(request.POST, parent=request.item.parent) + + for formset_config in self.formset_configurations: + formset = modelformset_factory( + formset_config.model_class, + form=formset_config.form_class, + can_delete=True, + )(request.POST, prefix=formset_config.form_prefix) + formsets.append(formset) + + if not formset.is_valid(): + formsets_valid_if_present = False + + if form.is_valid() and formsets_valid_if_present: + self.perform_create(request.item, form, formsets) + template = self.detail_fragment + else: + form = self.form_class(parent=request.item.parent) + context = self.get_context_data(form=form, formsets=formsets) + return render(request, template, context) + + @action(methods=("get", "post"), permission=can_edit) + def update(self, request: HttpRequest, *args, **kwargs): + template = self.form_fragment + if request.method == "POST": + # make a copy; if we do any is_valid modifying of the data we need this... + request.POST = request.POST.copy() + + data = request.POST if request.method == "POST" else None + form = self.form_class(data=data, instance=request.item.object) + + formsets = [] + formsets_valid_if_present = True + + if request.method == "POST" and form.is_valid(): + for formset_config in self.formset_configurations: + formset = modelformset_factory( + formset_config.model_class, + form=formset_config.form_class, + can_delete=True, + )(request.POST, prefix=formset_config.form_prefix) + formsets.append(formset) + + if not formset.is_valid(): + formsets_valid_if_present = False + + if formsets_valid_if_present: + self.perform_update(request.item, form, formsets) + template = self.detail_fragment + context = self.get_context_data(form=form, formsets=formsets) + return render(request, template, context) + + @transaction.atomic + def perform_update(self, item: Item, form, formsets=None): + if formsets is None: + formsets = [] + + instance = form.save() + create_object_log("Updated", instance, item.assessment.id, self.request.user.id) + + formset_idx = 0 + for formset in formsets: + formset_config = self.formset_configurations[formset_idx] + self.perform_formset_cud_operations(formset, formset_config, item.object) + formset_idx += 1 + + @transaction.atomic + def perform_create(self, item: Item, form, formsets=None): + if formsets is None: + formsets = [] + + item.object = form.save() + create_object_log("Created", item.object, item.assessment.id, self.request.user.id) + + formset_idx = 0 + for formset in formsets: + formset_config = self.formset_configurations[formset_idx] + self.perform_formset_cud_operations(formset, formset_config, item.object) + formset_idx += 1 + + def perform_formset_cud_operations(self, formset, formset_config, parent_obj_instance): + # creates/updates/deletes instances represented in the sub formset; logs them; etc. + temp_instances = formset.save(commit=False) + + for obj in formset.deleted_objects: + create_object_log("Deleted", obj, obj.get_assessment().id, self.request.user.id) + obj.delete() + + parent_key = formset_config.form_class.formset_parent_key + for temp_instance in temp_instances: + setattr(temp_instance, parent_key, parent_obj_instance.id) + is_create = temp_instance.id is None + temp_instance.save() + + create_object_log( + "Created" if is_create else "Updated", + temp_instance, + temp_instance.get_assessment().id, + self.request.user.id, + ) + + @action(methods=("get", "post"), permission=can_edit) + def delete(self, request: HttpRequest, *args, **kwargs): + if request.method == "POST": + context = { + "attribute": self.model.__name__.lower(), + "id": request.item.object.id, + } + self.perform_delete(request.item) + return render(request, "common/fragments/_delete_rows.html", context) + return render(request, self.detail_fragment, self.get_context_data()) + + @action(methods=("post",), permission=can_edit) + def clone(self, request: HttpRequest, *args, **kwargs): + self.perform_clone(request.item) + return render(request, self.detail_fragment, self.get_context_data()) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["model"] = self.model.__name__.lower() + context["app"] = "animalv2" + + formsets = kwargs.get("formsets", []) + formsets = [] + for formset_idx, formset_config in enumerate(self.formset_configurations): + formset = formsets[formset_idx] if formset_idx < len(formsets) else None + + if formset is None: + formset = modelformset_factory( + formset_config.model_class, + form=formset_config.form_class, + can_delete=True, + )( + # only show the subobjects related to this parent. + queryset=formset_config.model_class.objects.filter( + Q( + ( + formset_config.form_class.formset_parent_key, + self.request.item.object.id, + ) + ) + ).order_by(formset_config.sort_field) + if self.request.item.object is not None + else formset_config.model_class.objects.none(), + prefix=formset_config.form_prefix, + ) + + formsets.append( + { + "fragment": formset_config.template, + "instance": formset, + "helper": formset_config.helper_class(), + } + ) + + context["formsets"] = formsets + + return context + + +class ChemicalViewSet(ExperimentChildViewSet): + model = models.Chemical + form_class = forms.ChemicalForm + detail_fragment = "animalv2/fragments/_chemical_row.html" + + +class AnimalGroupViewSet(ExperimentChildViewSet): + model = models.AnimalGroup + form_class = forms.AnimalGroupForm + detail_fragment = "animalv2/fragments/_animalgroup_row.html" + + +class TreatmentViewSet(ExperimentChildViewSet): + model = models.Treatment + form_class = forms.TreatmentForm + detail_fragment = "animalv2/fragments/_treatment_row.html" + formset_configurations = [ + FormsetConfiguration( + models.DoseGroup, + forms.DoseGroupForm, + FormsetGenericFormHelper, + "dosegroupform", + "dose_group_id", + "animalv2/fragments/_treatment_formset.html", + ) + ] + + +class EndpointViewSet(ExperimentChildViewSet): + model = models.Endpoint + form_class = forms.EndpointForm + detail_fragment = "animalv2/fragments/_endpoint_row.html" + + +class ObservationTimeViewSet(ExperimentChildViewSet): + model = models.ObservationTime + form_class = forms.ObservationTimeForm + detail_fragment = "animalv2/fragments/_observationtime_row.html" + + +class DataExtractionViewSet(ExperimentChildViewSet): + model = models.DataExtraction + form_class = forms.DataExtractionForm + detail_fragment = "animalv2/fragments/_dataextraction_row.html" + formset_configurations = [ + FormsetConfiguration( + models.DoseResponseGroupLevelData, + forms.DoseResponseGroupLevelDataForm, + FormsetGenericFormHelper, + "groupleveldataform", + "id", + "animalv2/fragments/_dataextraction_formset_groupleveldata.html", + ), + FormsetConfiguration( + models.DoseResponseAnimalLevelData, + forms.DoseResponseAnimalLevelDataForm, + FormsetGenericFormHelper, + "animalleveldataform", + "id", + "animalv2/fragments/_dataextraction_formset_animalleveldata.html", + ), + ] + + +class StudyLevelValues(BaseList): + parent_model = Study + model = models.StudyLevelValue + template_name = "animalv2/studylevelvalues.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context.update(study=self.parent) + return context + + def get_queryset(self): + queryset = ( + super() + .get_queryset() + .filter(study=self.parent) + .select_related("units") + .order_by("-created") + ) + return queryset + + +class StudyLevelValueViewSet(HtmxViewSet): + actions = {"create", "read", "update", "delete"} + parent_model = Study + model = models.StudyLevelValue + + form_fragment = "animalv2/fragments/studylevelvalue_edit_row.html" + detail_fragment = "animalv2/fragments/studylevelvalue_row.html" + + @action(permission=can_view) + def read(self, request: HttpRequest, *args, **kwargs): + return render(request, self.detail_fragment, self.get_context_data()) + + @action(methods=("get", "post"), permission=can_edit) + def create(self, request: HttpRequest, *args, **kwargs): + template = self.form_fragment + form_data = request.POST if request.method == "POST" else None + form = forms.StudyLevelValueForm(data=form_data, parent=request.item.parent) + context = self.get_context_data(form=form) + if request.method == "POST" and form.is_valid(): + self.perform_create(request.item, form) + template = self.detail_fragment + context.update(object=request.item.object) + return render(request, template, context) + + @action(methods=("get", "post"), permission=can_edit) + def update(self, request: HttpRequest, *args, **kwargs): + template = self.form_fragment + form_data = request.POST if request.method == "POST" else None + form = forms.StudyLevelValueForm(data=form_data, instance=request.item.object) + if request.method == "POST" and form.is_valid(): + self.perform_update(request.item, form) + template = self.detail_fragment + context = self.get_context_data(form=form) + return render(request, template, context) + + @action(methods=("get", "post"), permission=can_edit) + def delete(self, request: HttpRequest, *args, **kwargs): + if request.method == "POST": + self.perform_delete(request.item) + return self.str_response() + form = forms.StudyLevelValueForm(data=None, instance=request.item.object) + context = self.get_context_data(form=form) + return render(request, self.detail_fragment, context) + + +class ObservationList(BaseFilterList): + parent_model = models.Experiment + model = models.Observation + filterset_class = filterset.ObservationFilterSet + template_name = "animalv2/observation_list.html" + paginate_by = None + assessment_permission = AssessmentViewPermissions.TEAM_MEMBER_EDITABLE + + def get_queryset(self) -> list[models.Observation]: + # This custom queryset method returns both instances save din the db and dynamically + # created instances; thus the handling logic is slightly different han a conventional + # filter list which relies exclusively on database queries + if self.parent.guideline is None: + return super().get_queryset().none() + self._filterset = self.filterset_class( + data=self.request.GET, + queryset=self.model.objects.none(), + request=self.request, + form_kwargs=self.get_filterset_form_kwargs(), + ) + observations = self.model.generate_observations(self.parent) + return self._filterset.filter(observations) diff --git a/hawc/apps/assessment/api/viewsets.py b/hawc/apps/assessment/api/viewsets.py index ddc039fbe..f0afc7203 100644 --- a/hawc/apps/assessment/api/viewsets.py +++ b/hawc/apps/assessment/api/viewsets.py @@ -24,7 +24,11 @@ from ..filterset import EffectTagFilterSet, GlobalChemicalsFilterSet from .filters import InAssessmentFilter from .helper import get_assessment_from_query -from .permissions import AssessmentLevelPermissions, CleanupFieldsPermissions, user_can_edit_object +from .permissions import ( + AssessmentLevelPermissions, + CleanupFieldsPermissions, + user_can_edit_object, +) # all http methods except PUT METHODS_NO_PUT = ["get", "post", "patch", "delete", "head", "options", "trace"] @@ -244,12 +248,17 @@ def move(self, request, *args, **kwargs): return Response({"status": True}) -class DoseUnitsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +class DoseUnitsViewSet(mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet): model = models.DoseUnits serializer_class = serializers.DoseUnitsSerializer pagination_class = DisabledPagination lookup_value_regex = re_digits + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [permissions.IsAuthenticated()] + return [permissions.IsAdminUser()] + def get_queryset(self): return self.model.objects.all() @@ -587,10 +596,31 @@ def get_queryset(self): return self.model.objects.all() -class StrainViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +class SpeciesViewSet(viewsets.ModelViewSet): + model = models.Species + queryset = models.Species.objects.all() + serializer_class = serializers.SpeciesSerializer + pagination_class = DisabledPagination + http_method_names = METHODS_NO_PUT + lookup_value_regex = re_digits + + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [permissions.IsAuthenticated()] + return [permissions.IsAdminUser()] + + +class StrainViewSet(viewsets.ModelViewSet): model = models.Strain queryset = models.Strain.objects.all() serializer_class = serializers.StrainSerializer - pagination_class = None + pagination_class = DisabledPagination + http_method_names = METHODS_NO_PUT filter_backends = (DjangoFilterBackend,) filterset_fields = ("species",) + lookup_value_regex = re_digits + + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [permissions.IsAuthenticated()] + return [permissions.IsAdminUser()] diff --git a/hawc/apps/assessment/constants.py b/hawc/apps/assessment/constants.py index 6b143ace3..cac96495d 100644 --- a/hawc/apps/assessment/constants.py +++ b/hawc/apps/assessment/constants.py @@ -64,6 +64,11 @@ class EpiVersion(models.IntegerChoices): V2 = 2, "v2" +class AnimalVersion(models.IntegerChoices): + V1 = 1, "v1" + V2 = 2, "v2" + + class Status(models.IntegerChoices): SCOPING = 0, "Scoping" PROBLEM_FORM = 5, "Problem Formulation" diff --git a/hawc/apps/assessment/forms.py b/hawc/apps/assessment/forms.py index 64622837c..ad2e2d15c 100644 --- a/hawc/apps/assessment/forms.py +++ b/hawc/apps/assessment/forms.py @@ -316,10 +316,12 @@ class Meta: "enable_summary_tables", "enable_visuals", "enable_downloads", + "enable_observations", "noel_name", "rob_name", "vocabulary", "epi_version", + "animal_version", ) model = models.Assessment @@ -330,6 +332,9 @@ def __init__(self, *args, **kwargs): ].label = f"Enable {self.instance.get_rob_name_display().lower()}" self.fields["vocabulary"].choices = VocabularyNamespace.display_choices() + if not settings.HAWC_FEATURES.ENABLE_BIOASSAY_V2: + self.fields.pop("animal_version") + @property def helper(self): helper = BaseFormHelper( @@ -347,11 +352,25 @@ def helper(self): ) helper.add_row("enable_literature_review", 3, "col-lg-4") helper.add_row("enable_risk_of_bias", 3, "col-lg-4") - helper.add_row("enable_visuals", 2, "col-lg-6") + helper.add_row("enable_visuals", 3, "col-lg-4") helper.add_row("noel_name", 3, "col-lg-4") - helper.add_row("epi_version", 1, "col-lg-4") + helper.add_row( + "epi_version", 2 if settings.HAWC_FEATURES.ENABLE_BIOASSAY_V2 else 1, "col-lg-4" + ) return helper + def clean(self): + cleaned_data = super().clean() + + enable_observations = cleaned_data.get("enable_observations") + vocabulary = cleaned_data.get("vocabulary") + + if enable_observations and vocabulary != VocabularyNamespace.ToxRefDB: + message = f"To enable observations, {VocabularyNamespace.ToxRefDB.label} must be used." + self.add_error("enable_observations", message) + + return cleaned_data + class AttachmentForm(forms.ModelForm): class Meta: diff --git a/hawc/apps/assessment/migrations/0048_assessment_animal_version.py b/hawc/apps/assessment/migrations/0048_assessment_animal_version.py new file mode 100644 index 000000000..dce6011d6 --- /dev/null +++ b/hawc/apps/assessment/migrations/0048_assessment_animal_version.py @@ -0,0 +1,31 @@ +from django.db import migrations, models +from django.db.migrations.operations.special import RunPython +from django.db.models import Count + + +def set_animal_version(apps, schema_editor): + # set animal_version to 1 on existing assessments that have animal studies + Assessment = apps.get_model("assessment", "Assessment") + Assessment.objects.annotate(n_sp=Count("references__study__experiments")).filter( + n_sp__gt=0 + ).update(animal_version=1) + + +class Migration(migrations.Migration): + dependencies = [ + ("assessment", "0047_alter_labeleditem_options"), + ] + + operations = [ + migrations.AddField( + model_name="assessment", + name="animal_version", + field=models.PositiveSmallIntegerField( + choices=[(1, "v1"), (2, "v2")], + default=1, # TODO - change to V2 + help_text="Data extraction schema version used for animal studies", + verbose_name="Animal schema version", + ), + ), + migrations.RunPython(set_animal_version, RunPython.noop), + ] diff --git a/hawc/apps/assessment/migrations/0049_assessment_enable_observations.py b/hawc/apps/assessment/migrations/0049_assessment_enable_observations.py new file mode 100644 index 000000000..2b6c831fc --- /dev/null +++ b/hawc/apps/assessment/migrations/0049_assessment_enable_observations.py @@ -0,0 +1,18 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("assessment", "0048_assessment_animal_version"), + ] + + operations = [ + migrations.AddField( + model_name="assessment", + name="enable_observations", + field=models.BooleanField( + default=False, + help_text="Observations can be used to identify negative effects in animal bioassay studies. The project must use the Toxicity Reference Database Vocabulary to use Observations.", + ), + ), + ] diff --git a/hawc/apps/assessment/models.py b/hawc/apps/assessment/models.py index dc81630a3..7f755ebe1 100644 --- a/hawc/apps/assessment/models.py +++ b/hawc/apps/assessment/models.py @@ -258,6 +258,10 @@ def get_rob_name_default(): default=True, help_text="Show the downloads link on the assessment sidebar.", ) + enable_observations = models.BooleanField( + default=False, + help_text="Observations can be used to identify negative effects in animal bioassay studies. The project must use the Toxicity Reference Database Vocabulary to use Observations.", + ) conflicts_of_interest = models.TextField( blank=True, help_text="Describe any conflicts of interest by the assessment-team.", @@ -301,6 +305,12 @@ def get_rob_name_default(): verbose_name="Epidemiology schema version", help_text="Data extraction schema version used for epidemiology studies", ) + animal_version = models.PositiveSmallIntegerField( + choices=constants.AnimalVersion, + default=constants.AnimalVersion.V1, # TODO - change to V2 + verbose_name="Animal schema version", + help_text="Data extraction schema version used for animal studies", + ) admin_notes = models.TextField( blank=True, help_text="Additional information about this assessment; only visible to HAWC admins", @@ -489,7 +499,12 @@ def has_rob_data(self) -> bool: @property def has_animal_data(self) -> bool: - return self._has_data("animal", "Experiment") + if self.animal_version == constants.AnimalVersion.V1: + return self._has_data("animal", "Experiment") + elif self.animal_version == constants.AnimalVersion.V2: + return self._has_data("animalv2", "Experiment") + else: + raise ValueError("Unknown animal version") @property def has_epi_data(self) -> bool: diff --git a/hawc/apps/assessment/serializers.py b/hawc/apps/assessment/serializers.py index 7a96bfb66..0c1394402 100644 --- a/hawc/apps/assessment/serializers.py +++ b/hawc/apps/assessment/serializers.py @@ -213,3 +213,9 @@ class StrainSerializer(serializers.ModelSerializer): class Meta: model = models.Strain fields = "__all__" + + +class SpeciesSerializer(serializers.ModelSerializer): + class Meta: + model = models.Species + fields = "__all__" diff --git a/hawc/apps/assessment/urls.py b/hawc/apps/assessment/urls.py index ea79111b2..5cf7279de 100644 --- a/hawc/apps/assessment/urls.py +++ b/hawc/apps/assessment/urls.py @@ -10,6 +10,7 @@ router.register("value", api.AssessmentValueViewSet, basename="value") router.register("dataset", api.DatasetViewSet, basename="dataset") router.register("dsstox", api.DssToxViewSet, basename="dsstox") +router.register("species", api.SpeciesViewSet, basename="species") router.register("strain", api.StrainViewSet, basename="strain") router.register("effect-tag", api.EffectTagViewSet, basename="effect-tag") diff --git a/hawc/apps/common/forms.py b/hawc/apps/common/forms.py index cdce7bee5..ae0eed6ee 100644 --- a/hawc/apps/common/forms.py +++ b/hawc/apps/common/forms.py @@ -483,3 +483,17 @@ def __call__(self, value): """Validate the field with the pydantic model.""" with PydanticToDjangoError(include_field=False): self.schema.model_validate(value) + + +class FormsetGenericFormHelper(BaseFormHelper): + """Custom helper method to style a row form in a table inline formset. + + Related links: + * https://stackoverflow.com/questions/42615357/ + * https://stackoverflow.com/a/25656273/906385 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.form_tag = False + self.template = "bootstrap4/table_inline_formset.html" diff --git a/hawc/apps/common/templates/common/fragments/_object_edit_row.html b/hawc/apps/common/templates/common/fragments/_object_edit_row.html index aee2947c2..713473e2b 100644 --- a/hawc/apps/common/templates/common/fragments/_object_edit_row.html +++ b/hawc/apps/common/templates/common/fragments/_object_edit_row.html @@ -6,6 +6,9 @@ {% include "common/scientific_pulldown.html" %} {% crispy form %} + {% for fs in formsets %} + {% include fs.fragment with formset_instance=fs.instance formset_helper=fs.helper %} + {% endfor %}
{% if form.instance.id %}