From 573d242cd5b27b9f14b5a98ab659e2d9026cc7e5 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Wed, 27 Sep 2023 15:27:13 -0400 Subject: [PATCH 01/40] render udf form inside study form --- hawc/apps/assessment/models.py | 14 ++++++++++++++ hawc/apps/study/forms.py | 5 +++++ hawc/apps/udf/models.py | 29 ++++++++++++++++++++++++----- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/hawc/apps/assessment/models.py b/hawc/apps/assessment/models.py index a456d103ce..a65ee39e86 100644 --- a/hawc/apps/assessment/models.py +++ b/hawc/apps/assessment/models.py @@ -10,6 +10,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.cache import cache +from django.core.exceptions import ObjectDoesNotExist from django.core.validators import MinValueValidator from django.db import models from django.http import HttpRequest @@ -314,6 +315,19 @@ def get_assessment_logs_url(self): def get_udf_list_url(self): return reverse("udf:binding-list", args=(self.id,)) + def get_model_udf(self, model: type[models.Model] | models.Model, *args, **kwargs): + """Get the form instance from this assessment's UDF for the given model class/instance. + + Args: + model: a model class or an instance of a model that has a UDF bound to it in this + assessment. + """ + content_type = ContentType.objects.get_for_model(model) + try: + return self.udf_bindings.get(content_type=content_type).form_instance(*args, **kwargs) + except ObjectDoesNotExist: + return None + def get_clear_cache_url(self): return reverse("assessment:clear_cache", args=(self.id,)) diff --git a/hawc/apps/study/forms.py b/hawc/apps/study/forms.py index d58823ffa0..bf08c735bf 100644 --- a/hawc/apps/study/forms.py +++ b/hawc/apps/study/forms.py @@ -16,6 +16,7 @@ class BaseStudyForm(forms.ModelForm): required=False, help_text="Internal communications regarding this study; this field is only displayed to assessment team members. Could be to describe extraction notes to e.g., reference to full study reports or indicating which outcomes/endpoints in a study were not extracted.", ) + udf = forms.JSONField(widget=forms.HiddenInput(), required=False) class Meta: model = models.Study @@ -46,6 +47,10 @@ def __init__(self, *args, **kwargs): self.instance.assessment = parent elif type(parent) is Reference: self.instance.reference_ptr = parent + assessment = self.instance.get_assessment() + udf = assessment.get_model_udf(self.Meta.model, label="User defined fields") + if udf: + self.fields["udf"] = udf if self.instance: self.fields["internal_communications"].initial = self.instance.get_communications() diff --git a/hawc/apps/udf/models.py b/hawc/apps/udf/models.py index 6c26f94966..33e478f207 100644 --- a/hawc/apps/udf/models.py +++ b/hawc/apps/udf/models.py @@ -2,11 +2,12 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.db import models -from django.forms import Form +from django.forms import Form, JSONField from django.urls import reverse from ..assessment.models import Assessment from ..common import dynamic_forms +from ..common.forms import DynamicFormField from ..lit.models import ReferenceFilterTag @@ -33,6 +34,9 @@ class Meta: unique_together = (("creator", "name"),) ordering = ("-last_updated",) + def __str__(self): + return f"{self.name}" + def get_absolute_url(self): return reverse("udf:udf_detail", args=(self.pk,)) @@ -58,8 +62,15 @@ class Meta: indexes = (models.Index(fields=["assessment", "content_type"]),) unique_together = (("assessment", "content_type"),) - def form_instance(self) -> Form: - return dynamic_forms.Schema.parse_obj(self.form.schema).to_form() + def __str__(self): + return f"{self.assessment}/{self.content_type.model} form" + + def form_instance(self, *args, **kwargs) -> JSONField | DynamicFormField: + prefix = kwargs.pop("prefix", "udf") + form_kwargs = kwargs.pop("form_kwargs", None) + return dynamic_forms.Schema.parse_obj(self.form.schema).to_form_field( + prefix, form_kwargs, *args, **kwargs + ) def get_assessment(self): return self.assessment @@ -86,8 +97,12 @@ class Meta: indexes = (models.Index(fields=["assessment", "tag"]),) unique_together = (("assessment", "tag"),) - def form_instance(self) -> Form: - return dynamic_forms.Schema.parse_obj(self.form.schema).to_form() + def form_instance( + self, prefix="", form_kwargs=None, *args, **kwargs + ) -> JSONField | DynamicFormField: + return dynamic_forms.Schema.parse_obj(self.form.schema).to_form_field( + prefix, form_kwargs, *args, **kwargs + ) def get_assessment(self): return self.assessment @@ -96,6 +111,10 @@ def get_absolute_url(self): return reverse("udf:tag_detail", args=(self.id,)) +# class ModelUDFContent(models.Model): +# model_binding = models.ForeignKey(ModelBinding, on_delete=models.CASCADE, related_name=) + + reversion.register(TagBinding) reversion.register(ModelBinding) reversion.register(UserDefinedForm) From 251ca10c910ff9338b1caf58a5d4c54513ed0a8a Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Wed, 27 Sep 2023 16:59:37 -0400 Subject: [PATCH 02/40] Add model for saved data from UDF --- .../udf/migrations/0003_modeludfcontent.py | 43 +++++++++++++++++++ hawc/apps/udf/models.py | 15 +++++-- 2 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 hawc/apps/udf/migrations/0003_modeludfcontent.py diff --git a/hawc/apps/udf/migrations/0003_modeludfcontent.py b/hawc/apps/udf/migrations/0003_modeludfcontent.py new file mode 100644 index 0000000000..167b2410ca --- /dev/null +++ b/hawc/apps/udf/migrations/0003_modeludfcontent.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.4 on 2023-09-27 20:58 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("contenttypes", "0002_remove_content_type_name"), + ("udf", "0002_tagbinding_modelbinding"), + ] + + operations = [ + migrations.CreateModel( + name="ModelUDFContent", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("object_id", models.PositiveIntegerField(null=True)), + ("content", models.JSONField(blank=True, default=dict)), + ("created", models.DateTimeField(auto_now_add=True)), + ("last_updated", models.DateTimeField(auto_now=True)), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype" + ), + ), + ( + "model_binding", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="saved_contents", + to="udf.modelbinding", + ), + ), + ], + ), + ] diff --git a/hawc/apps/udf/models.py b/hawc/apps/udf/models.py index 33e478f207..fdd9462af7 100644 --- a/hawc/apps/udf/models.py +++ b/hawc/apps/udf/models.py @@ -1,8 +1,9 @@ import reversion from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models -from django.forms import Form, JSONField +from django.forms import JSONField from django.urls import reverse from ..assessment.models import Assessment @@ -111,8 +112,16 @@ def get_absolute_url(self): return reverse("udf:tag_detail", args=(self.id,)) -# class ModelUDFContent(models.Model): -# model_binding = models.ForeignKey(ModelBinding, on_delete=models.CASCADE, related_name=) +class ModelUDFContent(models.Model): + model_binding = models.ForeignKey( + ModelBinding, on_delete=models.CASCADE, related_name="saved_contents" + ) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField(null=True) + content_object = GenericForeignKey("content_type", "object_id") + content = models.JSONField(blank=True, default=dict) + created = models.DateTimeField(auto_now_add=True) + last_updated = models.DateTimeField(auto_now=True) reversion.register(TagBinding) From 5d415a32a66b734da5b86e7a1f9ecdc8625152c7 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Wed, 27 Sep 2023 17:00:33 -0400 Subject: [PATCH 03/40] remove duplicate widget --- hawc/apps/common/dynamic_forms/forms.py | 32 ------------------------- 1 file changed, 32 deletions(-) diff --git a/hawc/apps/common/dynamic_forms/forms.py b/hawc/apps/common/dynamic_forms/forms.py index e11cd75f73..70e3768a09 100644 --- a/hawc/apps/common/dynamic_forms/forms.py +++ b/hawc/apps/common/dynamic_forms/forms.py @@ -90,35 +90,3 @@ def auto_wrap_fields(self): self[:].wrap_together(cfl.Row) self.add_field_wraps() - - -class DynamicFormWidget(forms.Widget): - """Widget to display dynamic form inline.""" - - template_name = "common/widgets/dynamic_form.html" - - def __init__(self, prefix, form_class, form_kwargs=None, *args, **kwargs): - """Create dynamic form widget.""" - super().__init__(*args, **kwargs) - self.prefix = prefix - self.form_class = form_class - if form_kwargs is None: - form_kwargs = {} - self.form_kwargs = {"prefix": prefix, **form_kwargs} - - def add_prefix(self, field_name): - """Add prefix in the same way Django forms add prefixes.""" - return f"{self.prefix}-{field_name}" - - def format_value(self, value): - """Value used in rendering.""" - value = json.loads(value) - if value: - value = {self.add_prefix(k): v for k, v in value.items()} - return self.form_class(data=value, **self.form_kwargs) - - def value_from_datadict(self, data, files, name): - """Parse value from POST request.""" - form = self.form_class(data=data, **self.form_kwargs) - form.full_clean() - return form.cleaned_data From 6bb8aa9292224b57e82e4ded15736726127d9e3a Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Wed, 27 Sep 2023 17:02:58 -0400 Subject: [PATCH 04/40] add todo for form tag issue --- hawc/apps/study/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hawc/apps/study/forms.py b/hawc/apps/study/forms.py index bf08c735bf..4c1a699ebb 100644 --- a/hawc/apps/study/forms.py +++ b/hawc/apps/study/forms.py @@ -16,7 +16,6 @@ class BaseStudyForm(forms.ModelForm): required=False, help_text="Internal communications regarding this study; this field is only displayed to assessment team members. Could be to describe extraction notes to e.g., reference to full study reports or indicating which outcomes/endpoints in a study were not extracted.", ) - udf = forms.JSONField(widget=forms.HiddenInput(), required=False) class Meta: model = models.Study @@ -56,6 +55,7 @@ def __init__(self, *args, **kwargs): self.helper = self.setHelper() + # TODO: For some reason, form actions div is being 'pushed' outside the form tag def setHelper(self, inputs: dict | None = None): if inputs is None: inputs = {} From 8b2e557fe9b35e4165ea90d506e08c2f12aec226 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 29 Sep 2023 16:48:30 -0400 Subject: [PATCH 05/40] render saved UDF data on study detail page --- .../study/templates/study/study_detail.html | 4 +++ hawc/apps/study/views.py | 9 ++++++- hawc/apps/udf/admin.py | 6 +++++ hawc/apps/udf/models.py | 27 ++++++++++++++++++- .../templates/udf/fragments/_udf_content.html | 21 +++++++++++++++ 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 hawc/apps/udf/templates/udf/fragments/_udf_content.html diff --git a/hawc/apps/study/templates/study/study_detail.html b/hawc/apps/study/templates/study/study_detail.html index d509eeaaeb..cbe4bb591c 100644 --- a/hawc/apps/study/templates/study/study_detail.html +++ b/hawc/apps/study/templates/study/study_detail.html @@ -133,6 +133,10 @@

{{assessment.get_rob_name_display}}

{% include "eco/fragments/_design_list.html" with object_list=object.eco_designs.all %} {% endif %} + {% if udf_content %} + {% include "udf/fragments/_udf_content.html" with object=udf_content %} + {% endif %} + {% endif %} {% endblock %} diff --git a/hawc/apps/study/views.py b/hawc/apps/study/views.py index 0c50b45933..5153368861 100644 --- a/hawc/apps/study/views.py +++ b/hawc/apps/study/views.py @@ -1,5 +1,6 @@ from django.apps import apps -from django.core.exceptions import PermissionDenied +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import PermissionDenied, ObjectDoesNotExist from django.db import transaction from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect @@ -128,6 +129,12 @@ def get_context_data(self, **kwargs): "attachments": self.object.get_attachments_dict() if attachments_viewable else None, } context["internal_communications"] = self.object.get_communications() + content_type = ContentType.objects.get_for_model(self.model) + try: + udf_binding = self.assessment.udf_bindings.get(content_type=content_type) + context["udf_content"] = udf_binding.saved_contents.get(object_id=self.object.pk) + except ObjectDoesNotExist: + context["udf_content"] = None return context diff --git a/hawc/apps/udf/admin.py b/hawc/apps/udf/admin.py index e98ac417ca..f2c5edaf5b 100644 --- a/hawc/apps/udf/admin.py +++ b/hawc/apps/udf/admin.py @@ -18,6 +18,12 @@ class TagBindingInline(admin.TabularInline): extra = 0 +class ModelUDFContentInline(admin.TabularInline): + model = models.ModelUDFContent + extra = 0 + + admin.site.register(models.UserDefinedForm) admin.site.register(models.ModelBinding) admin.site.register(models.TagBinding) +admin.site.register(models.ModelUDFContent) diff --git a/hawc/apps/udf/models.py b/hawc/apps/udf/models.py index fdd9462af7..c11160414b 100644 --- a/hawc/apps/udf/models.py +++ b/hawc/apps/udf/models.py @@ -118,11 +118,36 @@ class ModelUDFContent(models.Model): ) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField(null=True) - content_object = GenericForeignKey("content_type", "object_id") + content_object = GenericForeignKey( + "content_type", + "object_id", + ) content = models.JSONField(blank=True, default=dict) created = models.DateTimeField(auto_now_add=True) last_updated = models.DateTimeField(auto_now=True) + def get_content_as_list(self): + schema = dynamic_forms.Schema.parse_obj(self.model_binding.form.schema) + + items = [] + for field in schema.fields: + field_value = self.content.get(field.name) + field_kwargs = field.get_form_field_kwargs() + if "choices" in field_kwargs and field_value is not None: + choice_map = dict(field_kwargs["choices"]) + if field.type == "multiple_choice": + value = [choice_map[i] for i in field_value] + else: + value = choice_map[field_value] + else: + value = field_value + if value: + label = field.get_verbose_name() + if isinstance(value, list) and field.type != "multiple_choice": + value = "|".join(map(str, value)) + items.append((label, value)) + return items + reversion.register(TagBinding) reversion.register(ModelBinding) diff --git a/hawc/apps/udf/templates/udf/fragments/_udf_content.html b/hawc/apps/udf/templates/udf/fragments/_udf_content.html new file mode 100644 index 0000000000..9cbe5645b6 --- /dev/null +++ b/hawc/apps/udf/templates/udf/fragments/_udf_content.html @@ -0,0 +1,21 @@ +

User defined fields

+ + + + + + + + + + + + + {% for key, value in object.get_content_as_list %} + + + + + {% endfor %} + +
FieldValue
{{key}}{{value}}
From 9b29d5d7c4fb954de2cfcba59b4487d46bc10709 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 29 Sep 2023 16:48:44 -0400 Subject: [PATCH 06/40] add mixin for adding UDF to detail pages --- hawc/apps/udf/views.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/hawc/apps/udf/views.py b/hawc/apps/udf/views.py index eec0fe2907..c084fd95b0 100644 --- a/hawc/apps/udf/views.py +++ b/hawc/apps/udf/views.py @@ -1,4 +1,5 @@ -from django.core.exceptions import PermissionDenied +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.views.generic import DetailView, ListView @@ -200,3 +201,17 @@ class DeleteTagBindingView(BaseDelete): def get_success_url(self): return self.assessment.get_udf_list_url() + + +class UDFDetailMixin: + """Mixin to add saved UDF contents to the context of a BaseDetail view.""" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + content_type = ContentType.objects.get_for_model(self.model) + try: + udf_binding = self.assessment.udf_bindings.get(content_type=content_type) + context["udf_content"] = udf_binding.saved_contents.get(object_id=self.object.pk) + except ObjectDoesNotExist: + context["udf_content"] = None + return context From d1aa36d8a9bae679efd576eb036daa18c79a9ec6 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 29 Sep 2023 16:52:10 -0400 Subject: [PATCH 07/40] lint --- hawc/apps/study/views.py | 2 +- hawc/apps/udf/migrations/0003_modeludfcontent.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hawc/apps/study/views.py b/hawc/apps/study/views.py index 5153368861..25f0b6d059 100644 --- a/hawc/apps/study/views.py +++ b/hawc/apps/study/views.py @@ -1,6 +1,6 @@ from django.apps import apps from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import PermissionDenied, ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect diff --git a/hawc/apps/udf/migrations/0003_modeludfcontent.py b/hawc/apps/udf/migrations/0003_modeludfcontent.py index 167b2410ca..c69428f6cd 100644 --- a/hawc/apps/udf/migrations/0003_modeludfcontent.py +++ b/hawc/apps/udf/migrations/0003_modeludfcontent.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.4 on 2023-09-27 20:58 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): From a13297be0bbd8d9a66bb7b25b6d21237f5f277e1 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 27 Oct 2023 16:06:32 -0400 Subject: [PATCH 08/40] backport changes for demo --- frontend/shared/utils/HAWCUtils.js | 85 +++++++++++++++++++ hawc/apps/animal/forms.py | 31 +++++++ hawc/apps/animal/models.py | 3 + .../templates/animal/endpoint_detail.html | 1 + hawc/apps/animal/views.py | 8 ++ hawc/apps/common/forms.py | 10 +++ .../templates/udf/fragments/_udf_content.html | 2 + 7 files changed, 140 insertions(+) diff --git a/frontend/shared/utils/HAWCUtils.js b/frontend/shared/utils/HAWCUtils.js index ccdec5f3a7..3ffda253c2 100644 --- a/frontend/shared/utils/HAWCUtils.js +++ b/frontend/shared/utils/HAWCUtils.js @@ -342,5 +342,90 @@ class HAWCUtils { }) .trigger("change"); } + + static dynamicFormListeners() { + function compare(comparison, x, y) { + // comparisons are done on flat arrays + x = _.isArray(x) ? x : [x]; + y = _.isArray(y) ? y : [y]; + switch (comparison) { + case "equals": + return _.isEqual(x, y); + case "in": + return _.intersection(x, y).length > 0; + case "contains": + return _.intersection(x, y).length === y.length; + } + } + + function getValue(el) { + const type = $(el).attr("type"); + // checkbox/radio inputs return values based on checked property + // and value attribute + if (type === "checkbox" || type === "radio") { + const value = $(el).attr("value"), + checked = $(el).prop("checked"); + // if the input has a value attribute... + if (value !== undefined) { + // then the checked property determines + // whether to use this value attribute + return checked ? value : undefined; + } + // if there is no value attribute, + // the checked property is used + return checked; + } + // number inputs need to be parsed from their string value + if (type === "number") { + return parseFloat($(el).val()); + } + // all other inputs return their computed value + return $(el).val(); + } + + function getValues($inputs) { + // get array of values from inputs + return _.chain($inputs) + .map(getValue) + .filter(v => v !== undefined) + .flatten() + .value(); + } + + // conditions are passed into the django template as scripts + const $ = window.$, + $conditionsScripts = $('script[id^="conditions-"]'); + $conditionsScripts.remove(); // we only want these handled once, so remove them from dom + for (const $conditions of $conditionsScripts) { + // parse the conditions from script + const conditions = JSON.parse($conditions.textContent); + for (const condition of conditions) { + const $subject = $(`#div_${condition.subject_id}`), + $subjectInput = $subject.find(":input"); + // add listeners on all inputs in the subject div + $subjectInput.on("input", () => { + for (const observerId of condition.observer_ids) { + const $observer = $(`#div_${observerId}`), + $observerInput = $observer.find(":input"), + // get array of values from subject inputs + value = getValues($subjectInput), + // compare subject values against comparison value + check = compare( + condition.comparison, + value, + condition.comparison_value + ), + // determine whether observers should be shown or hidden + show = condition.behavior === "show" ? check : !check; + // hide the observer parent div (ie bootstrap column), and disable the observer inputs + $observer.parent().prop("hidden", !show); + $observerInput.prop("disabled", !show); + } + }); + // trigger input on subject to hide/show based on initial data + $subjectInput.trigger("input"); + } + } + } } export default HAWCUtils; diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 2736c4988d..afb9a6128d 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -3,10 +3,13 @@ from crispy_forms import layout as cfl from django import forms +from django.contrib.contenttypes.models import ContentType from django.forms import ModelForm from django.forms.models import BaseModelFormSet, modelformset_factory from django.urls import reverse +from hawc.apps.udf.models import ModelUDFContent + from ..assessment.autocomplete import DSSToxAutocomplete, EffectTagAutocomplete from ..common.autocomplete import ( AutocompleteSelectMultipleWidget, @@ -432,6 +435,19 @@ def __init__(self, *args, **kwargs): self.noel_names = json.dumps(self.instance.get_noel_names()._asdict()) + # TODO: tidy up queries in init and save + if assessment is None: + assessment = self.instance.get_assessment() + try: + content = self.instance.udf_content.first().content + except Exception: + content = None + udf = assessment.get_model_udf( + self.Meta.model, label="User defined fields", initial=content + ) + if udf: + self.fields["udf"] = udf + @property def helper(self): vocab_enabled = self.instance.assessment.vocabulary == VocabularyNamespace.EHV @@ -578,6 +594,21 @@ def clean(self): return cleaned_data + def save(self, commit=True): + instance = super().save(commit=commit) + udf = self.cleaned_data.pop("udf", None) + if udf: + # TODO: clean up these queries + content_type = ContentType.objects.get_for_model(self.Meta.model) + model_binding = instance.assessment.udf_bindings.get(content_type=content_type) + ModelUDFContent.objects.update_or_create( + defaults=dict(content=udf), + model_binding=model_binding, + content_type=content_type, + object_id=instance.id, + ) + return instance + class EndpointGroupForm(forms.ModelForm): class Meta: diff --git a/hawc/apps/animal/models.py b/hawc/apps/animal/models.py index b79e03f58b..518171fbda 100644 --- a/hawc/apps/animal/models.py +++ b/hawc/apps/animal/models.py @@ -5,6 +5,7 @@ import numpy as np import pandas as pd from django.apps import apps +from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -760,6 +761,8 @@ class Endpoint(BaseEndpoint): ) additional_fields = models.TextField(default="{}") + udf_content = GenericRelation("udf.ModelUDFContent") + BREADCRUMB_PARENT = "animal_group" class Meta: diff --git a/hawc/apps/animal/templates/animal/endpoint_detail.html b/hawc/apps/animal/templates/animal/endpoint_detail.html index 7b965f0480..de3f6efcc2 100644 --- a/hawc/apps/animal/templates/animal/endpoint_detail.html +++ b/hawc/apps/animal/templates/animal/endpoint_detail.html @@ -42,6 +42,7 @@

{{object}}

Endpoint Details

+ {% include "udf/fragments/_udf_content.html" with object=udf_content %}

Dataset

diff --git a/hawc/apps/animal/views.py b/hawc/apps/animal/views.py index 8973876808..88fc7a4295 100644 --- a/hawc/apps/animal/views.py +++ b/hawc/apps/animal/views.py @@ -1,5 +1,7 @@ import json +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.forms.models import modelformset_factory from django.http import HttpResponseRedirect @@ -450,6 +452,12 @@ class EndpointDetail(BaseDetail): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["bmd_session"] = self.object.get_latest_bmd_session() + content_type = ContentType.objects.get_for_model(models.Endpoint) + try: + udf_binding = self.assessment.udf_bindings.get(content_type=content_type) + context["udf_content"] = udf_binding.saved_contents.get(object_id=self.object.pk) + except ObjectDoesNotExist: + context["udf_content"] = None return context diff --git a/hawc/apps/common/forms.py b/hawc/apps/common/forms.py index 0ef45a4b6a..15a344590c 100644 --- a/hawc/apps/common/forms.py +++ b/hawc/apps/common/forms.py @@ -159,6 +159,16 @@ def add_refresh_page_note(self): ) self.layout.insert(len(self.layout) - 1, note) + def add_field_wraps(self): + """Wrap django fields with crispy field wrappers. + This should be performed after wrapping fields in rows and + columns, since it can add additional wraps around a django + field that can beyond the row/column containers. + """ + for field_name, field in self.form.fields.items(): + if hasattr(field, "crispy_field_class"): + self[field_name].wrap(field.crispy_field_class) + class InlineFilterFormHelper(BaseFormHelper): """Helper class for creating an inline filtering form with a primary field.""" diff --git a/hawc/apps/udf/templates/udf/fragments/_udf_content.html b/hawc/apps/udf/templates/udf/fragments/_udf_content.html index 9cbe5645b6..626382b86b 100644 --- a/hawc/apps/udf/templates/udf/fragments/_udf_content.html +++ b/hawc/apps/udf/templates/udf/fragments/_udf_content.html @@ -1,3 +1,4 @@ +{% if object %}

User defined fields

@@ -19,3 +20,4 @@

User defined fields

{% endfor %}
+{% endif %} From 2ba08ccab65c8b5e7e8b70f786fce74886a724e4 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 27 Oct 2023 17:09:27 -0400 Subject: [PATCH 09/40] reduce queries in forms w/ UDF --- hawc/apps/animal/forms.py | 31 ++++++++++++++----------------- hawc/apps/assessment/models.py | 4 ++-- hawc/apps/study/forms.py | 24 +++++++++++++++++++++--- 3 files changed, 37 insertions(+), 22 deletions(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index afb9a6128d..2c9c1e2917 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -435,17 +435,18 @@ def __init__(self, *args, **kwargs): self.noel_names = json.dumps(self.instance.get_noel_names()._asdict()) - # TODO: tidy up queries in init and save + # User Defined Form if assessment is None: assessment = self.instance.get_assessment() - try: - content = self.instance.udf_content.first().content - except Exception: - content = None - udf = assessment.get_model_udf( - self.Meta.model, label="User defined fields", initial=content - ) - if udf: + self.model_binding = assessment.get_model_binding(self.Meta.model) + if self.model_binding: + try: + udf_content = self.model_binding.saved_contents.get(object_id=self.instance.id) + initial = udf_content.content + except ModelUDFContent.DoesNotExist: + initial = None + + udf = self.model_binding.form_instance(label="User defined fields", initial=initial) self.fields["udf"] = udf @property @@ -596,15 +597,11 @@ def clean(self): def save(self, commit=True): instance = super().save(commit=commit) - udf = self.cleaned_data.pop("udf", None) - if udf: - # TODO: clean up these queries - content_type = ContentType.objects.get_for_model(self.Meta.model) - model_binding = instance.assessment.udf_bindings.get(content_type=content_type) + if commit and "udf" in self.changed_data: ModelUDFContent.objects.update_or_create( - defaults=dict(content=udf), - model_binding=model_binding, - content_type=content_type, + defaults=dict(content=self.cleaned_data["udf"]), + model_binding=self.model_binding, + content_type=self.model_binding.content_type, object_id=instance.id, ) return instance diff --git a/hawc/apps/assessment/models.py b/hawc/apps/assessment/models.py index a65ee39e86..b4019dcfef 100644 --- a/hawc/apps/assessment/models.py +++ b/hawc/apps/assessment/models.py @@ -315,7 +315,7 @@ def get_assessment_logs_url(self): def get_udf_list_url(self): return reverse("udf:binding-list", args=(self.id,)) - def get_model_udf(self, model: type[models.Model] | models.Model, *args, **kwargs): + def get_model_binding(self, model: type[models.Model] | models.Model): """Get the form instance from this assessment's UDF for the given model class/instance. Args: @@ -324,7 +324,7 @@ def get_model_udf(self, model: type[models.Model] | models.Model, *args, **kwarg """ content_type = ContentType.objects.get_for_model(model) try: - return self.udf_bindings.get(content_type=content_type).form_instance(*args, **kwargs) + return self.udf_bindings.get(content_type=content_type) except ObjectDoesNotExist: return None diff --git a/hawc/apps/study/forms.py b/hawc/apps/study/forms.py index 4c1a699ebb..8f9a6a3bff 100644 --- a/hawc/apps/study/forms.py +++ b/hawc/apps/study/forms.py @@ -3,6 +3,8 @@ from django.forms.widgets import TextInput from django.urls import reverse +from hawc.apps.udf.models import ModelUDFContent + from ..assessment.models import Assessment from ..common.forms import BaseFormHelper, QuillField, check_unique_for_assessment from ..lit.constants import ReferenceDatabase @@ -46,16 +48,25 @@ def __init__(self, *args, **kwargs): self.instance.assessment = parent elif type(parent) is Reference: self.instance.reference_ptr = parent + + # User Definied Form assessment = self.instance.get_assessment() - udf = assessment.get_model_udf(self.Meta.model, label="User defined fields") - if udf: + self.model_binding = assessment.get_model_binding(self.Meta.model) + if self.model_binding: + try: + udf_content = self.model_binding.saved_contents.get(object_id=self.instance.id) + initial = udf_content.content + except ModelUDFContent.DoesNotExist: + initial = None + + udf = self.model_binding.form_instance(label="User defined fields", initial=initial) self.fields["udf"] = udf + if self.instance: self.fields["internal_communications"].initial = self.instance.get_communications() self.helper = self.setHelper() - # TODO: For some reason, form actions div is being 'pushed' outside the form tag def setHelper(self, inputs: dict | None = None): if inputs is None: inputs = {} @@ -97,6 +108,13 @@ def save(self, commit=True): instance = super().save(commit=commit) if commit and "internal_communications" in self.changed_data: instance.set_communications(self.cleaned_data["internal_communications"]) + if commit and "udf" in self.changed_data: + ModelUDFContent.objects.update_or_create( + defaults=dict(content=self.cleaned_data["udf"]), + model_binding=self.model_binding, + content_type=self.model_binding.content_type, + object_id=instance.id, + ) return instance From 8df57db75562ae38972c80ce74f9c3cf7a509cdc Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 27 Oct 2023 17:09:51 -0400 Subject: [PATCH 10/40] changes from review --- hawc/apps/animal/views.py | 12 +++--------- .../study/templates/study/study_detail.html | 4 +--- hawc/apps/study/views.py | 12 +++--------- .../templates/udf/fragments/_udf_content.html | 17 +++++------------ 4 files changed, 12 insertions(+), 33 deletions(-) diff --git a/hawc/apps/animal/views.py b/hawc/apps/animal/views.py index 88fc7a4295..f6c953bddb 100644 --- a/hawc/apps/animal/views.py +++ b/hawc/apps/animal/views.py @@ -1,7 +1,5 @@ import json -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.forms.models import modelformset_factory from django.http import HttpResponseRedirect @@ -27,6 +25,7 @@ from ..mgmt.views import EnsureExtractionStartedMixin from ..study.models import Study from ..study.views import StudyDetail +from ..udf.views import UDFDetailMixin from . import filterset, forms, models @@ -441,7 +440,8 @@ def get_app_config(self, context) -> WebappConfig: ) -class EndpointDetail(BaseDetail): +class EndpointDetail(UDFDetailMixin, BaseDetail): + model = models.Endpoint queryset = models.Endpoint.objects.select_related( "animal_group", "animal_group__dosing_regime", @@ -452,12 +452,6 @@ class EndpointDetail(BaseDetail): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) context["bmd_session"] = self.object.get_latest_bmd_session() - content_type = ContentType.objects.get_for_model(models.Endpoint) - try: - udf_binding = self.assessment.udf_bindings.get(content_type=content_type) - context["udf_content"] = udf_binding.saved_contents.get(object_id=self.object.pk) - except ObjectDoesNotExist: - context["udf_content"] = None return context diff --git a/hawc/apps/study/templates/study/study_detail.html b/hawc/apps/study/templates/study/study_detail.html index cbe4bb591c..8c0cbedece 100644 --- a/hawc/apps/study/templates/study/study_detail.html +++ b/hawc/apps/study/templates/study/study_detail.html @@ -133,9 +133,7 @@

{{assessment.get_rob_name_display}}

{% include "eco/fragments/_design_list.html" with object_list=object.eco_designs.all %} {% endif %} - {% if udf_content %} - {% include "udf/fragments/_udf_content.html" with object=udf_content %} - {% endif %} + {% include "udf/fragments/_udf_content.html" with object=udf_content %} {% endif %} diff --git a/hawc/apps/study/views.py b/hawc/apps/study/views.py index 25f0b6d059..635a32f72a 100644 --- a/hawc/apps/study/views.py +++ b/hawc/apps/study/views.py @@ -1,6 +1,5 @@ from django.apps import apps -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.core.exceptions import PermissionDenied from django.db import transaction from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect @@ -12,6 +11,7 @@ from ..common.views import BaseCreate, BaseDelete, BaseDetail, BaseFilterList, BaseUpdate from ..lit.models import Reference from ..mgmt.views import EnsurePreparationStartedMixin +from ..udf.views import UDFDetailMixin from . import filterset, forms, models @@ -116,7 +116,7 @@ def get_context_data(self, **kwargs): return context -class StudyDetail(BaseDetail): +class StudyDetail(UDFDetailMixin, BaseDetail): model = models.Study def get_context_data(self, **kwargs): @@ -129,12 +129,6 @@ def get_context_data(self, **kwargs): "attachments": self.object.get_attachments_dict() if attachments_viewable else None, } context["internal_communications"] = self.object.get_communications() - content_type = ContentType.objects.get_for_model(self.model) - try: - udf_binding = self.assessment.udf_bindings.get(content_type=content_type) - context["udf_content"] = udf_binding.saved_contents.get(object_id=self.object.pk) - except ObjectDoesNotExist: - context["udf_content"] = None return context diff --git a/hawc/apps/udf/templates/udf/fragments/_udf_content.html b/hawc/apps/udf/templates/udf/fragments/_udf_content.html index 626382b86b..126df6c9f2 100644 --- a/hawc/apps/udf/templates/udf/fragments/_udf_content.html +++ b/hawc/apps/udf/templates/udf/fragments/_udf_content.html @@ -1,19 +1,12 @@ {% if object %} +{% load bs4 %}

User defined fields

- - - - - - - - - - - +
FieldValue
+ {% bs4_colgroup '30,70' %} + {% bs4_thead 'Field, Value' %} {% for key, value in object.get_content_as_list %} - + From 4fc4bfe9a7156fb1256a0a9659a5d3d5ad198300 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 27 Oct 2023 17:13:00 -0400 Subject: [PATCH 11/40] remove generic relation --- hawc/apps/animal/models.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/hawc/apps/animal/models.py b/hawc/apps/animal/models.py index 518171fbda..b79e03f58b 100644 --- a/hawc/apps/animal/models.py +++ b/hawc/apps/animal/models.py @@ -5,7 +5,6 @@ import numpy as np import pandas as pd from django.apps import apps -from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models @@ -761,8 +760,6 @@ class Endpoint(BaseEndpoint): ) additional_fields = models.TextField(default="{}") - udf_content = GenericRelation("udf.ModelUDFContent") - BREADCRUMB_PARENT = "animal_group" class Meta: From 7c56a66cd1f72d70bed217a41c3f9d62b0ebef81 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 27 Oct 2023 17:14:03 -0400 Subject: [PATCH 12/40] remove import --- hawc/apps/animal/forms.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 2c9c1e2917..c3b235d3a9 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -3,7 +3,6 @@ from crispy_forms import layout as cfl from django import forms -from django.contrib.contenttypes.models import ContentType from django.forms import ModelForm from django.forms.models import BaseModelFormSet, modelformset_factory from django.urls import reverse From c74a8aaf0b809d7d6daa7ff1edaab078e72ced4c Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 27 Oct 2023 17:25:57 -0400 Subject: [PATCH 13/40] fix test now that rendering works properly --- .../apps/common/dynamic_forms/test_forms.py | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/tests/hawc/apps/common/dynamic_forms/test_forms.py b/tests/hawc/apps/common/dynamic_forms/test_forms.py index e1444a28c2..b4d55332c8 100644 --- a/tests/hawc/apps/common/dynamic_forms/test_forms.py +++ b/tests/hawc/apps/common/dynamic_forms/test_forms.py @@ -19,21 +19,19 @@ def test_yesno_rendering(self, complete_schema): yesno["fields"] = [field for field in yesno["fields"] if field["name"] == "yesno"] schema = Schema.parse_obj(yesno) form_rendering = render_crispy_form(schema.to_form({})) - expected = """ -
- -
-
- - -
-
- - -
- Help text -
-
""" + expected = """
+
+
+
+ + +
+
+ + +
+ Help text +
""" assertInHTML(expected, form_rendering) def test_validation(self): From 03acb88fcea7c9942d7a8222a9a36433a7ae4d19 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 27 Oct 2023 17:42:18 -0400 Subject: [PATCH 14/40] fix udf detail pages --- hawc/apps/animal/forms.py | 2 +- hawc/apps/study/forms.py | 2 +- hawc/apps/udf/models.py | 10 ++++++++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 4b4256bc64..7f8ea0d58c 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -463,7 +463,7 @@ def __init__(self, *args, **kwargs): except ModelUDFContent.DoesNotExist: initial = None - udf = self.model_binding.form_instance(label="User defined fields", initial=initial) + udf = self.model_binding.form_field(label="User defined fields", initial=initial) self.fields["udf"] = udf @property diff --git a/hawc/apps/study/forms.py b/hawc/apps/study/forms.py index 8f9a6a3bff..fb8335ce0b 100644 --- a/hawc/apps/study/forms.py +++ b/hawc/apps/study/forms.py @@ -59,7 +59,7 @@ def __init__(self, *args, **kwargs): except ModelUDFContent.DoesNotExist: initial = None - udf = self.model_binding.form_instance(label="User defined fields", initial=initial) + udf = self.model_binding.form_field(label="User defined fields", initial=initial) self.fields["udf"] = udf if self.instance: diff --git a/hawc/apps/udf/models.py b/hawc/apps/udf/models.py index c11160414b..4a728e0b43 100644 --- a/hawc/apps/udf/models.py +++ b/hawc/apps/udf/models.py @@ -66,13 +66,16 @@ class Meta: def __str__(self): return f"{self.assessment}/{self.content_type.model} form" - def form_instance(self, *args, **kwargs) -> JSONField | DynamicFormField: + def form_field(self, *args, **kwargs) -> JSONField | DynamicFormField: prefix = kwargs.pop("prefix", "udf") form_kwargs = kwargs.pop("form_kwargs", None) return dynamic_forms.Schema.parse_obj(self.form.schema).to_form_field( prefix, form_kwargs, *args, **kwargs ) + def form_instance(self, *args, **kwargs) -> dynamic_forms.DynamicForm: + return dynamic_forms.Schema.parse_obj(self.form.schema).to_form(*args, **kwargs) + def get_assessment(self): return self.assessment @@ -98,13 +101,16 @@ class Meta: indexes = (models.Index(fields=["assessment", "tag"]),) unique_together = (("assessment", "tag"),) - def form_instance( + def form_field( self, prefix="", form_kwargs=None, *args, **kwargs ) -> JSONField | DynamicFormField: return dynamic_forms.Schema.parse_obj(self.form.schema).to_form_field( prefix, form_kwargs, *args, **kwargs ) + def form_instance(self, *args, **kwargs) -> dynamic_forms.DynamicForm: + return dynamic_forms.Schema.parse_obj(self.form.schema).to_form(*args, **kwargs) + def get_assessment(self): return self.assessment From 595463978c57470c39635d0c4d6ace35488abc66 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Fri, 27 Oct 2023 17:49:49 -0400 Subject: [PATCH 15/40] move fixture modelbinding to different assessment --- tests/data/fixtures/db.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/data/fixtures/db.yaml b/tests/data/fixtures/db.yaml index 5421dd4b2e..0fd20c63bb 100644 --- a/tests/data/fixtures/db.yaml +++ b/tests/data/fixtures/db.yaml @@ -10272,7 +10272,7 @@ - model: udf.ModelBinding pk: 1 fields: - assessment: 1 + assessment: 2 content_type: - animal - endpoint From 8cb1286ecd0863b01cbdd0c9ab402ff843d08034 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Tue, 31 Oct 2023 17:14:02 -0400 Subject: [PATCH 16/40] fix tests --- tests/data/fixtures/db.yaml | 2 +- .../apps/common/dynamic_forms/test_forms.py | 24 +++++++++---------- tests/hawc/apps/udf/test_views.py | 6 ++--- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/data/fixtures/db.yaml b/tests/data/fixtures/db.yaml index 0fd20c63bb..62882e7e12 100644 --- a/tests/data/fixtures/db.yaml +++ b/tests/data/fixtures/db.yaml @@ -10272,7 +10272,7 @@ - model: udf.ModelBinding pk: 1 fields: - assessment: 2 + assessment: 4 content_type: - animal - endpoint diff --git a/tests/hawc/apps/common/dynamic_forms/test_forms.py b/tests/hawc/apps/common/dynamic_forms/test_forms.py index b4d55332c8..cf6b37345b 100644 --- a/tests/hawc/apps/common/dynamic_forms/test_forms.py +++ b/tests/hawc/apps/common/dynamic_forms/test_forms.py @@ -19,19 +19,17 @@ def test_yesno_rendering(self, complete_schema): yesno["fields"] = [field for field in yesno["fields"] if field["name"] == "yesno"] schema = Schema.parse_obj(yesno) form_rendering = render_crispy_form(schema.to_form({})) - expected = """
-
-
-
- - -
-
- - -
- Help text -
""" + expected = """
+
+
+
+
Help text +
""" assertInHTML(expected, form_rendering) def test_validation(self): diff --git a/tests/hawc/apps/udf/test_views.py b/tests/hawc/apps/udf/test_views.py index a78a9e9efb..901ddcde15 100644 --- a/tests/hawc/apps/udf/test_views.py +++ b/tests/hawc/apps/udf/test_views.py @@ -14,14 +14,14 @@ def test_permissions(self, db_keys): ("login", reverse("udf:udf_detail", args=(1,))), ("owner", reverse("udf:udf_update", args=(1,))), # model + tag bindings - ("read", reverse("udf:binding-list", args=(db_keys.assessment_working,))), + ("read", reverse("udf:binding-list", args=(db_keys.assessment_conflict_resolution,))), # model bindings - ("update", reverse("udf:model_create", args=(db_keys.assessment_working,))), + ("update", reverse("udf:model_create", args=(db_keys.assessment_conflict_resolution,))), ("read", reverse("udf:model_detail", args=(1,))), ("update", reverse("udf:model_update", args=(1,))), ("update", reverse("udf:model_delete", args=(1,))), # tag bindings - ("update", reverse("udf:tag_create", args=(db_keys.assessment_working,))), + ("update", reverse("udf:tag_create", args=(db_keys.assessment_conflict_resolution,))), ("read", reverse("udf:tag_detail", args=(1,))), ("update", reverse("udf:tag_update", args=(1,))), ("update", reverse("udf:tag_delete", args=(1,))), From e811501c7e6524c8460822ac9754e8585e301059 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Wed, 1 Nov 2023 10:53:23 -0400 Subject: [PATCH 17/40] add dynamicformlisteners to necessary pages --- hawc/apps/animal/templates/animal/endpoint_form.html | 1 + hawc/apps/study/templates/study/study_form.html | 1 + hawc/apps/udf/templates/udf/modelbinding_detail.html | 9 +++++++++ hawc/apps/udf/templates/udf/udf_form.html | 6 ++++++ 4 files changed, 17 insertions(+) diff --git a/hawc/apps/animal/templates/animal/endpoint_form.html b/hawc/apps/animal/templates/animal/endpoint_form.html index 9b0a9fc2cf..c53a863d76 100644 --- a/hawc/apps/animal/templates/animal/endpoint_form.html +++ b/hawc/apps/animal/templates/animal/endpoint_form.html @@ -133,6 +133,7 @@ }); }); }); + window.app.HAWCUtils.dynamicFormListeners(); {% include "common/helptext_popup_js.html" %} {% endblock %} diff --git a/hawc/apps/study/templates/study/study_form.html b/hawc/apps/study/templates/study/study_form.html index 511e8ef8b5..efd79cb265 100644 --- a/hawc/apps/study/templates/study/study_form.html +++ b/hawc/apps/study/templates/study/study_form.html @@ -21,5 +21,6 @@ $(document).ready(function () { document.getElementById("id_short_citation").focus() }); +window.app.HAWCUtils.dynamicFormListeners(); {% endblock extrajs %} diff --git a/hawc/apps/udf/templates/udf/modelbinding_detail.html b/hawc/apps/udf/templates/udf/modelbinding_detail.html index cca87e9cbe..39f4814cbf 100644 --- a/hawc/apps/udf/templates/udf/modelbinding_detail.html +++ b/hawc/apps/udf/templates/udf/modelbinding_detail.html @@ -22,9 +22,11 @@

Model/Form binding

@@ -42,3 +44,10 @@

Model/Form binding

{{key}} {{value}}
Form +

{{object.form.name}}

Form Preview:

{% crispy object.form_instance %} +
{% endblock %} + +{% block extrajs %} + +{% endblock extrajs %} diff --git a/hawc/apps/udf/templates/udf/udf_form.html b/hawc/apps/udf/templates/udf/udf_form.html index f4bf4617c4..c1c7f8a2af 100644 --- a/hawc/apps/udf/templates/udf/udf_form.html +++ b/hawc/apps/udf/templates/udf/udf_form.html @@ -3,3 +3,9 @@ {% block content %} {% crispy form %} {% endblock %} + +{% block extrajs %} + +{% endblock extrajs %} From 4b8a0cd4822bf9f6333d9f0eef3220e9d462fbb0 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 11:56:24 -0500 Subject: [PATCH 18/40] create UDFCache class --- hawc/apps/udf/cache.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 hawc/apps/udf/cache.py diff --git a/hawc/apps/udf/cache.py b/hawc/apps/udf/cache.py new file mode 100644 index 0000000000..47bb745c08 --- /dev/null +++ b/hawc/apps/udf/cache.py @@ -0,0 +1,59 @@ +# Cache class for User Defined Forms. +from hawc.apps.udf.models import ModelUDFContent + +from ..common.helper import cacheable + + +class UDFCache: + @classmethod + def _get_model_binding(cls, assessment, model): + # get UDF model binding for given assessment/model combo + return assessment.get_model_binding(model) + + @classmethod + def _get_udf_contents(cls, model_binding, object_id): + # get saved UDF contents for this object id, if it exists + try: + udf_content = model_binding.saved_contents.get(object_id=object_id) + return udf_content.content + except ModelUDFContent.DoesNotExist: + return None + + @classmethod + def get_model_binding_cache( + cls, + assessment, + model, + flush: bool = False, + cache_duration: int = -1, + ): + cache_key = f"assessment-{assessment.pk}-{model}-model-binding" + return cacheable( + cls._get_model_binding, + cache_key, + flush, + cache_duration, + assessment=assessment, + model=model, + ) + + @classmethod + def get_udf_contents_cache( + cls, + model_binding, + object_id, + flush: bool = False, + cache_duration: int = -1, + ): + # if this is a new instance, don't bother trying to fetch from the cache + if object_id is None: + return None + cache_key = f"model-binding-{model_binding.pk}-object-{object_id}-udf-contents" + return cacheable( + cls._get_udf_contents, + cache_key, + flush, + cache_duration, + model_binding=model_binding, + object_id=object_id, + ) From ef2de3cd7a0f24eb41d425db4cfc5bf283f5b0eb Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 11:56:39 -0500 Subject: [PATCH 19/40] add cache to endpoint form --- hawc/apps/animal/forms.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 2b3ea7621c..584cf686da 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -6,6 +6,7 @@ from django.forms import ModelForm from django.forms.models import BaseModelFormSet, modelformset_factory from django.urls import reverse +from hawc.apps.udf.cache import UDFCache from hawc.apps.udf.models import ModelUDFContent @@ -16,6 +17,7 @@ AutocompleteTextWidget, ) from ..common.forms import BaseFormHelper, CopyForm, QuillField +from ..common.helper import cacheable from ..vocab.constants import VocabularyNamespace from . import autocomplete, constants, models @@ -455,13 +457,14 @@ def __init__(self, *args, **kwargs): # User Defined Form if assessment is None: assessment = self.instance.get_assessment() - self.model_binding = assessment.get_model_binding(self.Meta.model) + + self.model_binding = UDFCache.get_model_binding_cache( + assessment=assessment, model=self.Meta.model + ) if self.model_binding: - try: - udf_content = self.model_binding.saved_contents.get(object_id=self.instance.id) - initial = udf_content.content - except ModelUDFContent.DoesNotExist: - initial = None + initial = UDFCache.get_udf_contents_cache( + model_binding=self.model_binding, object_id=self.instance.id + ) udf = self.model_binding.form_field(label="User defined fields", initial=initial) self.fields["udf"] = udf From b546ddf9f402478f4bf2113078231861f50968e0 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 12:55:12 -0500 Subject: [PATCH 20/40] set udf contents when form is saved --- hawc/apps/animal/forms.py | 3 ++- hawc/apps/udf/cache.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 584cf686da..5d0a6062bd 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -618,12 +618,13 @@ def clean(self): def save(self, commit=True): instance = super().save(commit=commit) if commit and "udf" in self.changed_data: - ModelUDFContent.objects.update_or_create( + content, _ = ModelUDFContent.objects.update_or_create( defaults=dict(content=self.cleaned_data["udf"]), model_binding=self.model_binding, content_type=self.model_binding.content_type, object_id=instance.id, ) + UDFCache.set_udf_contents_cache(self.model_binding, instance.id, content.content) return instance diff --git a/hawc/apps/udf/cache.py b/hawc/apps/udf/cache.py index 47bb745c08..7c5b506eae 100644 --- a/hawc/apps/udf/cache.py +++ b/hawc/apps/udf/cache.py @@ -57,3 +57,8 @@ def get_udf_contents_cache( model_binding=model_binding, object_id=object_id, ) + + @classmethod + def set_udf_contents_cache(cls, model_binding, object_id, content): + cache_key = f"model-binding-{model_binding.pk}-object-{object_id}-udf-contents" + return cacheable(lambda content: content, cache_key=cache_key, flush=True, content=content) From d4229cdb1a4bf130a77588706cfeeb86dc7627fe Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 13:11:58 -0500 Subject: [PATCH 21/40] integrate cache into detail pages --- hawc/apps/udf/cache.py | 2 +- hawc/apps/udf/views.py | 12 ++++-------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/hawc/apps/udf/cache.py b/hawc/apps/udf/cache.py index 7c5b506eae..c8f8506250 100644 --- a/hawc/apps/udf/cache.py +++ b/hawc/apps/udf/cache.py @@ -61,4 +61,4 @@ def get_udf_contents_cache( @classmethod def set_udf_contents_cache(cls, model_binding, object_id, content): cache_key = f"model-binding-{model_binding.pk}-object-{object_id}-udf-contents" - return cacheable(lambda content: content, cache_key=cache_key, flush=True, content=content) + return cacheable(lambda c: c, cache_key=cache_key, flush=True, c=content) diff --git a/hawc/apps/udf/views.py b/hawc/apps/udf/views.py index a2909c2948..6349b6d8d5 100644 --- a/hawc/apps/udf/views.py +++ b/hawc/apps/udf/views.py @@ -1,5 +1,4 @@ -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.core.exceptions import PermissionDenied from django.urls import reverse_lazy from django.utils.decorators import method_decorator from django.views.generic import DetailView, ListView @@ -19,6 +18,7 @@ ) from . import forms, models +from .cache import UDFCache # UDF views @@ -208,10 +208,6 @@ class UDFDetailMixin: def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - content_type = ContentType.objects.get_for_model(self.model) - try: - udf_binding = self.assessment.udf_bindings.get(content_type=content_type) - context["udf_content"] = udf_binding.saved_contents.get(object_id=self.object.pk) - except ObjectDoesNotExist: - context["udf_content"] = None + model_binding = UDFCache.get_model_binding_cache(self.assessment, self.model) + context["udf_content"] = UDFCache.get_udf_contents_cache(model_binding, self.object.pk) return context From e9660007127444b24415cd7c7497209e3907c003 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 13:38:06 -0500 Subject: [PATCH 22/40] delete cache for modelbinding post save --- hawc/apps/animal/forms.py | 5 ++--- hawc/apps/udf/cache.py | 19 ++++++++++++++++--- hawc/apps/udf/signals.py | 11 +++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 hawc/apps/udf/signals.py diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 5d0a6062bd..f5e4fe0efb 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -457,7 +457,6 @@ def __init__(self, *args, **kwargs): # User Defined Form if assessment is None: assessment = self.instance.get_assessment() - self.model_binding = UDFCache.get_model_binding_cache( assessment=assessment, model=self.Meta.model ) @@ -618,13 +617,13 @@ def clean(self): def save(self, commit=True): instance = super().save(commit=commit) if commit and "udf" in self.changed_data: - content, _ = ModelUDFContent.objects.update_or_create( + udf_content, _ = ModelUDFContent.objects.update_or_create( defaults=dict(content=self.cleaned_data["udf"]), model_binding=self.model_binding, content_type=self.model_binding.content_type, object_id=instance.id, ) - UDFCache.set_udf_contents_cache(self.model_binding, instance.id, content.content) + UDFCache.set_udf_contents_cache(udf_content) return instance diff --git a/hawc/apps/udf/cache.py b/hawc/apps/udf/cache.py index c8f8506250..686facc100 100644 --- a/hawc/apps/udf/cache.py +++ b/hawc/apps/udf/cache.py @@ -1,4 +1,6 @@ # Cache class for User Defined Forms. +from django.core.cache import cache + from hawc.apps.udf.models import ModelUDFContent from ..common.helper import cacheable @@ -37,6 +39,11 @@ def get_model_binding_cache( model=model, ) + @classmethod + def clear_model_binding_cache(cls, model_binding): + cache_key = f"assessment-{model_binding.assessment_id}-{model_binding.content_type.model}-model-binding" + cache.delete(cache_key) + @classmethod def get_udf_contents_cache( cls, @@ -59,6 +66,12 @@ def get_udf_contents_cache( ) @classmethod - def set_udf_contents_cache(cls, model_binding, object_id, content): - cache_key = f"model-binding-{model_binding.pk}-object-{object_id}-udf-contents" - return cacheable(lambda c: c, cache_key=cache_key, flush=True, c=content) + def set_udf_contents_cache( + cls, + udf_content: ModelUDFContent, + cache_duration: int = -1, + ): + cache_key = f"model-binding-{udf_content.model_binding_id}-object-{udf_content.object_id}-udf-contents" + return cacheable( + lambda c: c, cache_key, flush=True, cache_duration=cache_duration, c=udf_content.content + ) diff --git a/hawc/apps/udf/signals.py b/hawc/apps/udf/signals.py new file mode 100644 index 0000000000..c443898c86 --- /dev/null +++ b/hawc/apps/udf/signals.py @@ -0,0 +1,11 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from . import models +from .cache import UDFCache + + +@receiver(post_save, sender=models.ModelBinding) +def delete_cache(sender, instance, created, **kwargs): + if not created: + UDFCache.clear_model_binding_cache(instance) From 3c9b5f1c0d4526a853929958a52091c45986da05 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 13:46:29 -0500 Subject: [PATCH 23/40] add caching to study form --- hawc/apps/animal/forms.py | 3 +-- hawc/apps/study/forms.py | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index f5e4fe0efb..8ea6adfe57 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -6,8 +6,8 @@ from django.forms import ModelForm from django.forms.models import BaseModelFormSet, modelformset_factory from django.urls import reverse -from hawc.apps.udf.cache import UDFCache +from hawc.apps.udf.cache import UDFCache from hawc.apps.udf.models import ModelUDFContent from ..assessment.autocomplete import DSSToxAutocomplete, EffectTagAutocomplete @@ -17,7 +17,6 @@ AutocompleteTextWidget, ) from ..common.forms import BaseFormHelper, CopyForm, QuillField -from ..common.helper import cacheable from ..vocab.constants import VocabularyNamespace from . import autocomplete, constants, models diff --git a/hawc/apps/study/forms.py b/hawc/apps/study/forms.py index ab7244ebf9..af6dc69ab9 100644 --- a/hawc/apps/study/forms.py +++ b/hawc/apps/study/forms.py @@ -3,13 +3,13 @@ from django.forms.widgets import TextInput from django.urls import reverse -from hawc.apps.udf.models import ModelUDFContent - from ..assessment.models import Assessment from ..common.forms import BaseFormHelper, QuillField, check_unique_for_assessment from ..lit.constants import ReferenceDatabase from ..lit.forms import create_external_id, validate_external_id from ..lit.models import Reference +from ..udf.cache import UDFCache +from ..udf.models import ModelUDFContent from . import models @@ -51,13 +51,13 @@ def __init__(self, *args, **kwargs): # User Definied Form assessment = self.instance.get_assessment() - self.model_binding = assessment.get_model_binding(self.Meta.model) + self.model_binding = UDFCache.get_model_binding_cache( + assessment=assessment, model=self.Meta.model + ) if self.model_binding: - try: - udf_content = self.model_binding.saved_contents.get(object_id=self.instance.id) - initial = udf_content.content - except ModelUDFContent.DoesNotExist: - initial = None + initial = UDFCache.get_udf_contents_cache( + model_binding=self.model_binding, object_id=self.instance.id + ) udf = self.model_binding.form_field(label="User defined fields", initial=initial) self.fields["udf"] = udf @@ -108,12 +108,13 @@ def save(self, commit=True): if commit and "internal_communications" in self.changed_data: instance.set_communications(self.cleaned_data["internal_communications"]) if commit and "udf" in self.changed_data: - ModelUDFContent.objects.update_or_create( + udf_content, _ = ModelUDFContent.objects.update_or_create( defaults=dict(content=self.cleaned_data["udf"]), model_binding=self.model_binding, content_type=self.model_binding.content_type, object_id=instance.id, ) + UDFCache.set_udf_contents_cache(udf_content) return instance From 26516d8c3fde547252b01de1af68e17e65f027a0 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 14:02:28 -0500 Subject: [PATCH 24/40] clean up cache class --- hawc/apps/udf/cache.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/hawc/apps/udf/cache.py b/hawc/apps/udf/cache.py index 686facc100..7933a0bc14 100644 --- a/hawc/apps/udf/cache.py +++ b/hawc/apps/udf/cache.py @@ -7,20 +7,6 @@ class UDFCache: - @classmethod - def _get_model_binding(cls, assessment, model): - # get UDF model binding for given assessment/model combo - return assessment.get_model_binding(model) - - @classmethod - def _get_udf_contents(cls, model_binding, object_id): - # get saved UDF contents for this object id, if it exists - try: - udf_content = model_binding.saved_contents.get(object_id=object_id) - return udf_content.content - except ModelUDFContent.DoesNotExist: - return None - @classmethod def get_model_binding_cache( cls, @@ -29,9 +15,13 @@ def get_model_binding_cache( flush: bool = False, cache_duration: int = -1, ): + def _get_model_binding(assessment, model): + # get UDF model binding for given assessment/model combo + return assessment.get_model_binding(model) + cache_key = f"assessment-{assessment.pk}-{model}-model-binding" return cacheable( - cls._get_model_binding, + _get_model_binding, cache_key, flush, cache_duration, @@ -52,12 +42,20 @@ def get_udf_contents_cache( flush: bool = False, cache_duration: int = -1, ): + def _get_udf_contents(model_binding, object_id): + # get saved UDF contents for this object id, if it exists + try: + udf_content = model_binding.saved_contents.get(object_id=object_id) + return udf_content.content + except ModelUDFContent.DoesNotExist: + return None + # if this is a new instance, don't bother trying to fetch from the cache if object_id is None: return None cache_key = f"model-binding-{model_binding.pk}-object-{object_id}-udf-contents" return cacheable( - cls._get_udf_contents, + _get_udf_contents, cache_key, flush, cache_duration, From d2240d5d0f2a7914e7d02cc898c3a735004385a4 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 14:51:20 -0500 Subject: [PATCH 25/40] fix content cache to work on detail pages --- hawc/apps/animal/forms.py | 3 ++- hawc/apps/study/forms.py | 3 ++- hawc/apps/udf/cache.py | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 8ea6adfe57..4cb75ba48b 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -460,9 +460,10 @@ def __init__(self, *args, **kwargs): assessment=assessment, model=self.Meta.model ) if self.model_binding: - initial = UDFCache.get_udf_contents_cache( + udf_content = UDFCache.get_udf_contents_cache( model_binding=self.model_binding, object_id=self.instance.id ) + initial = udf_content.content if udf_content is not None else None udf = self.model_binding.form_field(label="User defined fields", initial=initial) self.fields["udf"] = udf diff --git a/hawc/apps/study/forms.py b/hawc/apps/study/forms.py index af6dc69ab9..0101679039 100644 --- a/hawc/apps/study/forms.py +++ b/hawc/apps/study/forms.py @@ -55,9 +55,10 @@ def __init__(self, *args, **kwargs): assessment=assessment, model=self.Meta.model ) if self.model_binding: - initial = UDFCache.get_udf_contents_cache( + udf_content = UDFCache.get_udf_contents_cache( model_binding=self.model_binding, object_id=self.instance.id ) + initial = udf_content.content if udf_content is not None else None udf = self.model_binding.form_field(label="User defined fields", initial=initial) self.fields["udf"] = udf diff --git a/hawc/apps/udf/cache.py b/hawc/apps/udf/cache.py index 7933a0bc14..386dd0eaa2 100644 --- a/hawc/apps/udf/cache.py +++ b/hawc/apps/udf/cache.py @@ -46,7 +46,7 @@ def _get_udf_contents(model_binding, object_id): # get saved UDF contents for this object id, if it exists try: udf_content = model_binding.saved_contents.get(object_id=object_id) - return udf_content.content + return udf_content except ModelUDFContent.DoesNotExist: return None @@ -71,5 +71,5 @@ def set_udf_contents_cache( ): cache_key = f"model-binding-{udf_content.model_binding_id}-object-{udf_content.object_id}-udf-contents" return cacheable( - lambda c: c, cache_key, flush=True, cache_duration=cache_duration, c=udf_content.content + lambda c: c, cache_key, flush=True, cache_duration=cache_duration, c=udf_content ) From 4b5cd46220532c32b04024148ec1d0139ea7c971 Mon Sep 17 00:00:00 2001 From: munnsmunns Date: Mon, 8 Jan 2024 15:13:42 -0500 Subject: [PATCH 26/40] fix attribute error and add type hints --- hawc/apps/udf/cache.py | 18 ++++++++++-------- hawc/apps/udf/views.py | 6 +++++- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/hawc/apps/udf/cache.py b/hawc/apps/udf/cache.py index 386dd0eaa2..821728ab6e 100644 --- a/hawc/apps/udf/cache.py +++ b/hawc/apps/udf/cache.py @@ -1,7 +1,9 @@ # Cache class for User Defined Forms. from django.core.cache import cache +from django.db.models import Model -from hawc.apps.udf.models import ModelUDFContent +from hawc.apps.assessment.models import Assessment +from hawc.apps.udf.models import ModelBinding, ModelUDFContent from ..common.helper import cacheable @@ -10,12 +12,12 @@ class UDFCache: @classmethod def get_model_binding_cache( cls, - assessment, - model, + assessment: Assessment, + model: type[Model], flush: bool = False, cache_duration: int = -1, ): - def _get_model_binding(assessment, model): + def _get_model_binding(assessment: Assessment, model: type[Model]): # get UDF model binding for given assessment/model combo return assessment.get_model_binding(model) @@ -30,15 +32,15 @@ def _get_model_binding(assessment, model): ) @classmethod - def clear_model_binding_cache(cls, model_binding): + def clear_model_binding_cache(cls, model_binding: ModelBinding): cache_key = f"assessment-{model_binding.assessment_id}-{model_binding.content_type.model}-model-binding" cache.delete(cache_key) @classmethod def get_udf_contents_cache( cls, - model_binding, - object_id, + model_binding: ModelBinding, + object_id: int | None, flush: bool = False, cache_duration: int = -1, ): @@ -50,7 +52,7 @@ def _get_udf_contents(model_binding, object_id): except ModelUDFContent.DoesNotExist: return None - # if this is a new instance, don't bother trying to fetch from the cache + # if this is a new instance don't bother trying to fetch from the cache if object_id is None: return None cache_key = f"model-binding-{model_binding.pk}-object-{object_id}-udf-contents" diff --git a/hawc/apps/udf/views.py b/hawc/apps/udf/views.py index 6349b6d8d5..0c86401b1f 100644 --- a/hawc/apps/udf/views.py +++ b/hawc/apps/udf/views.py @@ -209,5 +209,9 @@ class UDFDetailMixin: def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) model_binding = UDFCache.get_model_binding_cache(self.assessment, self.model) - context["udf_content"] = UDFCache.get_udf_contents_cache(model_binding, self.object.pk) + context["udf_content"] = ( + UDFCache.get_udf_contents_cache(model_binding, self.object.pk) + if model_binding is not None + else None + ) return context From 3edb16e2a0abf5f2cb2bd9ec4109084023b30776 Mon Sep 17 00:00:00 2001 From: casey1173 Date: Fri, 9 Feb 2024 17:24:46 -0500 Subject: [PATCH 27/40] format templates --- .../animal/templates/animal/endpoint_form.html | 4 ++-- hawc/apps/study/templates/study/study_form.html | 12 ++++++------ .../templates/udf/fragments/_udf_content.html | 16 ++++++++-------- hawc/apps/udf/templates/udf/udf_form.html | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/hawc/apps/animal/templates/animal/endpoint_form.html b/hawc/apps/animal/templates/animal/endpoint_form.html index cfa7e52575..4bd6dadcd4 100644 --- a/hawc/apps/animal/templates/animal/endpoint_form.html +++ b/hawc/apps/animal/templates/animal/endpoint_form.html @@ -133,8 +133,8 @@ }); }); }); - }); - window.app.HAWCUtils.dynamicFormListeners(); + }); + window.app.HAWCUtils.dynamicFormListeners(); {% include "common/helptext_popup_js.html" %} {% endblock %} diff --git a/hawc/apps/study/templates/study/study_form.html b/hawc/apps/study/templates/study/study_form.html index 40a33f5f49..3181d2590c 100644 --- a/hawc/apps/study/templates/study/study_form.html +++ b/hawc/apps/study/templates/study/study_form.html @@ -17,10 +17,10 @@ {% endblock %} {% block extrajs %} - + {% endblock extrajs %} diff --git a/hawc/apps/udf/templates/udf/fragments/_udf_content.html b/hawc/apps/udf/templates/udf/fragments/_udf_content.html index 126df6c9f2..53acfb7494 100644 --- a/hawc/apps/udf/templates/udf/fragments/_udf_content.html +++ b/hawc/apps/udf/templates/udf/fragments/_udf_content.html @@ -1,16 +1,16 @@ {% if object %} -{% load bs4 %} -

User defined fields

- + {% load bs4 %} +

User defined fields

+
{% bs4_colgroup '30,70' %} {% bs4_thead 'Field, Value' %} - {% for key, value in object.get_content_as_list %} + {% for key, value in object.get_content_as_list %} - - + + - {% endfor %} + {% endfor %} -
{{key}}{{value}}{{key}}{{value}}
+ {% endif %} diff --git a/hawc/apps/udf/templates/udf/udf_form.html b/hawc/apps/udf/templates/udf/udf_form.html index 469f46ec0e..7f4d3ff0bf 100644 --- a/hawc/apps/udf/templates/udf/udf_form.html +++ b/hawc/apps/udf/templates/udf/udf_form.html @@ -5,7 +5,7 @@ {% endblock %} {% block extrajs %} - + {% endblock extrajs %} From ea14922284d3907463d7fbc1f2d96dd82b57a033 Mon Sep 17 00:00:00 2001 From: casey1173 Date: Wed, 14 Feb 2024 14:12:01 -0500 Subject: [PATCH 28/40] fix curlies, change field name --- hawc/apps/animal/forms.py | 2 +- hawc/apps/animal/templates/animal/endpoint_form.html | 1 - hawc/apps/study/forms.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 90ee7480f1..8733e39f8e 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -465,7 +465,7 @@ def __init__(self, *args, **kwargs): ) initial = udf_content.content if udf_content is not None else None - udf = self.model_binding.form_field(label="User defined fields", initial=initial) + udf = self.model_binding.form_field(label="User Defined Form fields", initial=initial) self.fields["udf"] = udf @property diff --git a/hawc/apps/animal/templates/animal/endpoint_form.html b/hawc/apps/animal/templates/animal/endpoint_form.html index 4bd6dadcd4..6093b53dc3 100644 --- a/hawc/apps/animal/templates/animal/endpoint_form.html +++ b/hawc/apps/animal/templates/animal/endpoint_form.html @@ -133,7 +133,6 @@ }); }); }); - }); window.app.HAWCUtils.dynamicFormListeners(); {% include "common/helptext_popup_js.html" %} diff --git a/hawc/apps/study/forms.py b/hawc/apps/study/forms.py index 0101679039..c814277306 100644 --- a/hawc/apps/study/forms.py +++ b/hawc/apps/study/forms.py @@ -60,7 +60,7 @@ def __init__(self, *args, **kwargs): ) initial = udf_content.content if udf_content is not None else None - udf = self.model_binding.form_field(label="User defined fields", initial=initial) + udf = self.model_binding.form_field(label="User Defined Form fields", initial=initial) self.fields["udf"] = udf if self.instance: From c242a7dad9c16e9a7a1ee8864fdf29cd0b2bd31a Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Mon, 11 Mar 2024 21:04:49 -0400 Subject: [PATCH 29/40] reformat test html --- .../apps/common/dynamic_forms/test_forms.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/tests/hawc/apps/common/dynamic_forms/test_forms.py b/tests/hawc/apps/common/dynamic_forms/test_forms.py index 1251605a0e..7f58eaeb0d 100644 --- a/tests/hawc/apps/common/dynamic_forms/test_forms.py +++ b/tests/hawc/apps/common/dynamic_forms/test_forms.py @@ -19,17 +19,26 @@ def test_yesno_rendering(self, complete_schema): yesno["fields"] = [field for field in yesno["fields"] if field["name"] == "yesno"] schema = Schema.model_validate(yesno) form_rendering = render_crispy_form(schema.to_form({})) - expected = """
-
-
-
-
Help text -
""" + expected = """ +
+
+
+ +
+
+ + +
+
+ + +
+ Help text +
+
+
+
+ """ assertInHTML(expected, form_rendering) def test_validation(self): From 9a2f730569cf8257dcbf20fbe5caa28ebc0e0bf9 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Mon, 11 Mar 2024 22:48:54 -0400 Subject: [PATCH 30/40] refactor common code into a form mixin --- hawc/apps/animal/forms.py | 34 +++------------------------------- hawc/apps/study/forms.py | 29 +++-------------------------- hawc/apps/udf/forms.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 37 insertions(+), 58 deletions(-) diff --git a/hawc/apps/animal/forms.py b/hawc/apps/animal/forms.py index 8733e39f8e..3aef69f563 100644 --- a/hawc/apps/animal/forms.py +++ b/hawc/apps/animal/forms.py @@ -7,9 +7,6 @@ from django.forms.models import BaseModelFormSet, modelformset_factory from django.urls import reverse -from hawc.apps.udf.cache import UDFCache -from hawc.apps.udf.models import ModelUDFContent - from ..assessment.autocomplete import DSSToxAutocomplete, EffectTagAutocomplete from ..common.autocomplete import ( AutocompleteSelectMultipleWidget, @@ -17,6 +14,7 @@ AutocompleteTextWidget, ) from ..common.forms import BaseFormHelper, CopyForm, QuillField +from ..udf.forms import UDFModelFormMixin from ..vocab.constants import VocabularyNamespace from . import autocomplete, constants, models @@ -373,7 +371,7 @@ def dosegroup_formset_factory(groups, num_dose_groups): return FS(data) -class EndpointForm(ModelForm): +class EndpointForm(UDFModelFormMixin, ModelForm): class Meta: model = models.Endpoint fields = ( @@ -451,23 +449,9 @@ def __init__(self, *args, **kwargs): self.instance.animal_group = animal_group self.instance.assessment = assessment + self.set_udf_field(self.instance.assessment) self.noel_names = json.dumps(self.instance.get_noel_names()._asdict()) - # User Defined Form - if assessment is None: - assessment = self.instance.get_assessment() - self.model_binding = UDFCache.get_model_binding_cache( - assessment=assessment, model=self.Meta.model - ) - if self.model_binding: - udf_content = UDFCache.get_udf_contents_cache( - model_binding=self.model_binding, object_id=self.instance.id - ) - initial = udf_content.content if udf_content is not None else None - - udf = self.model_binding.form_field(label="User Defined Form fields", initial=initial) - self.fields["udf"] = udf - @property def helper(self): vocab_enabled = self.instance.assessment.vocabulary == VocabularyNamespace.EHV @@ -614,18 +598,6 @@ def clean(self): return cleaned_data - def save(self, commit=True): - instance = super().save(commit=commit) - if commit and "udf" in self.changed_data: - udf_content, _ = ModelUDFContent.objects.update_or_create( - defaults=dict(content=self.cleaned_data["udf"]), - model_binding=self.model_binding, - content_type=self.model_binding.content_type, - object_id=instance.id, - ) - UDFCache.set_udf_contents_cache(udf_content) - return instance - class EndpointGroupForm(forms.ModelForm): class Meta: diff --git a/hawc/apps/study/forms.py b/hawc/apps/study/forms.py index c814277306..9045122b84 100644 --- a/hawc/apps/study/forms.py +++ b/hawc/apps/study/forms.py @@ -8,12 +8,11 @@ from ..lit.constants import ReferenceDatabase from ..lit.forms import create_external_id, validate_external_id from ..lit.models import Reference -from ..udf.cache import UDFCache -from ..udf.models import ModelUDFContent +from ..udf.forms import UDFModelFormMixin from . import models -class BaseStudyForm(forms.ModelForm): +class BaseStudyForm(UDFModelFormMixin, forms.ModelForm): internal_communications = QuillField( required=False, help_text="Internal communications regarding this study; this field is only displayed to assessment team members. Could be to describe extraction notes to e.g., reference to full study reports or indicating which outcomes/endpoints in a study were not extracted.", @@ -48,24 +47,10 @@ def __init__(self, *args, **kwargs): self.instance.assessment = parent elif type(parent) is Reference: self.instance.reference_ptr = parent - - # User Definied Form - assessment = self.instance.get_assessment() - self.model_binding = UDFCache.get_model_binding_cache( - assessment=assessment, model=self.Meta.model - ) - if self.model_binding: - udf_content = UDFCache.get_udf_contents_cache( - model_binding=self.model_binding, object_id=self.instance.id - ) - initial = udf_content.content if udf_content is not None else None - - udf = self.model_binding.form_field(label="User Defined Form fields", initial=initial) - self.fields["udf"] = udf - if self.instance: self.fields["internal_communications"].initial = self.instance.get_communications() + self.set_udf_field(self.instance.assessment) self.helper = self.setHelper() def setHelper(self, inputs: dict | None = None): @@ -108,14 +93,6 @@ def save(self, commit=True): instance = super().save(commit=commit) if commit and "internal_communications" in self.changed_data: instance.set_communications(self.cleaned_data["internal_communications"]) - if commit and "udf" in self.changed_data: - udf_content, _ = ModelUDFContent.objects.update_or_create( - defaults=dict(content=self.cleaned_data["udf"]), - model_binding=self.model_binding, - content_type=self.model_binding.content_type, - object_id=instance.id, - ) - UDFCache.set_udf_contents_cache(udf_content) return instance diff --git a/hawc/apps/udf/forms.py b/hawc/apps/udf/forms.py index b7d24a3c63..b8cff38613 100644 --- a/hawc/apps/udf/forms.py +++ b/hawc/apps/udf/forms.py @@ -12,7 +12,7 @@ from hawc.apps.myuser.autocomplete import UserAutocomplete from ..assessment.models import Assessment -from . import constants, models +from . import cache, constants, models class UDFForm(forms.ModelForm): @@ -144,3 +144,33 @@ def helper(self): legend_text = "Update a tag binding" if self.instance.id else "Create a tag binding" helper = BaseFormHelper(self, legend_text=legend_text, cancel_url=cancel_url) return helper + + +class UDFModelFormMixin: + """Add UDF to model form.""" + + def set_udf_field(self, assessment: Assessment): + """Set UDF field on model form in a binding exists.""" + self.model_binding = cache.UDFCache.get_model_binding_cache( + assessment=assessment, model=self.Meta.model + ) + if self.model_binding: + udf_content = cache.UDFCache.get_udf_contents_cache( + model_binding=self.model_binding, object_id=self.instance.id + ) + initial = udf_content.content if udf_content is not None else None + + udf = self.model_binding.form_field(label="User Defined Fields", initial=initial) + self.fields["udf"] = udf + + def save(self, commit=True): + instance = super().save(commit=commit) + if commit and "udf" in self.changed_data: + udf_content, _ = models.ModelUDFContent.objects.update_or_create( + defaults=dict(content=self.cleaned_data["udf"]), + model_binding=self.model_binding, + content_type=self.model_binding.content_type, + object_id=instance.id, + ) + cache.UDFCache.set_udf_contents_cache(udf_content) + return instance From e445f724ab7fe8990c00ed574fb821b547de1e33 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Mon, 11 Mar 2024 22:52:35 -0400 Subject: [PATCH 31/40] load JS with DynamicForm or DynamicFormWidget --- hawc/apps/animal/templates/animal/endpoint_form.html | 1 - hawc/apps/common/dynamic_forms/forms.py | 3 +++ hawc/apps/common/widgets.py | 3 +++ hawc/apps/study/templates/study/study_form.html | 1 - hawc/apps/udf/templates/udf/udf_form.html | 4 +++- hawc/static/js/udf.js | 3 +++ 6 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 hawc/static/js/udf.js diff --git a/hawc/apps/animal/templates/animal/endpoint_form.html b/hawc/apps/animal/templates/animal/endpoint_form.html index 6093b53dc3..7ed7387968 100644 --- a/hawc/apps/animal/templates/animal/endpoint_form.html +++ b/hawc/apps/animal/templates/animal/endpoint_form.html @@ -133,7 +133,6 @@ }); }); }); - window.app.HAWCUtils.dynamicFormListeners(); {% include "common/helptext_popup_js.html" %} {% endblock %} diff --git a/hawc/apps/common/dynamic_forms/forms.py b/hawc/apps/common/dynamic_forms/forms.py index 70e3768a09..e6e47643b2 100644 --- a/hawc/apps/common/dynamic_forms/forms.py +++ b/hawc/apps/common/dynamic_forms/forms.py @@ -72,6 +72,9 @@ def full_clean(self): if self.is_bound: self.fields = fields + class Media: + js = ["js/udf.js"] + class DynamicFormHelper(BaseFormHelper): """Django crispy form helper.""" diff --git a/hawc/apps/common/widgets.py b/hawc/apps/common/widgets.py index 424a4e221f..8ba3e2b45e 100644 --- a/hawc/apps/common/widgets.py +++ b/hawc/apps/common/widgets.py @@ -158,3 +158,6 @@ def value_from_datadict(self, data, files, name): form = self.form_class(data=data, **self.form_kwargs) form.full_clean() return form.cleaned_data + + class Media: + js = ["js/udf.js"] diff --git a/hawc/apps/study/templates/study/study_form.html b/hawc/apps/study/templates/study/study_form.html index 3181d2590c..ae557d563d 100644 --- a/hawc/apps/study/templates/study/study_form.html +++ b/hawc/apps/study/templates/study/study_form.html @@ -21,6 +21,5 @@ $(document).ready(function () { document.getElementById("id_short_citation").focus() }); - window.app.HAWCUtils.dynamicFormListeners(); {% endblock extrajs %} diff --git a/hawc/apps/udf/templates/udf/udf_form.html b/hawc/apps/udf/templates/udf/udf_form.html index 7f4d3ff0bf..c67a53d7e4 100644 --- a/hawc/apps/udf/templates/udf/udf_form.html +++ b/hawc/apps/udf/templates/udf/udf_form.html @@ -6,6 +6,8 @@ {% block extrajs %} {% endblock extrajs %} diff --git a/hawc/static/js/udf.js b/hawc/static/js/udf.js new file mode 100644 index 0000000000..5758ce2be1 --- /dev/null +++ b/hawc/static/js/udf.js @@ -0,0 +1,3 @@ +document.addEventListener("DOMContentLoaded", function() { + window.app.HAWCUtils.dynamicFormListeners(); +}); From 5534d617ebe929ec0fdce0d07a10b2fbd989db24 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Mon, 11 Mar 2024 23:07:25 -0400 Subject: [PATCH 32/40] add db constraint --- hawc/apps/udf/migrations/0003_modeludfcontent.py | 4 ++++ hawc/apps/udf/models.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/hawc/apps/udf/migrations/0003_modeludfcontent.py b/hawc/apps/udf/migrations/0003_modeludfcontent.py index c69428f6cd..83a49a9a3f 100644 --- a/hawc/apps/udf/migrations/0003_modeludfcontent.py +++ b/hawc/apps/udf/migrations/0003_modeludfcontent.py @@ -40,4 +40,8 @@ class Migration(migrations.Migration): ), ], ), + migrations.AlterUniqueTogether( + name="modeludfcontent", + unique_together={("model_binding", "object_id")}, + ), ] diff --git a/hawc/apps/udf/models.py b/hawc/apps/udf/models.py index 0253a3529c..bf2680a8a5 100644 --- a/hawc/apps/udf/models.py +++ b/hawc/apps/udf/models.py @@ -124,14 +124,14 @@ class ModelUDFContent(models.Model): ) content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) object_id = models.PositiveIntegerField(null=True) - content_object = GenericForeignKey( - "content_type", - "object_id", - ) + content_object = GenericForeignKey("content_type", "object_id") content = models.JSONField(blank=True, default=dict) created = models.DateTimeField(auto_now_add=True) last_updated = models.DateTimeField(auto_now=True) + class Meta: + unique_together = (("model_binding", "object_id"),) + def get_content_as_list(self): schema = dynamic_forms.Schema.parse_obj(self.model_binding.form.schema) From b667ecba1d5bb21ef82e3f93bfa1dbab60f2340d Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Mon, 11 Mar 2024 23:13:01 -0400 Subject: [PATCH 33/40] rename UDF table fragment --- hawc/apps/animal/templates/animal/endpoint_detail.html | 2 +- hawc/apps/study/templates/study/study_detail.html | 4 ++-- .../templates/udf/fragments/{_udf_content.html => table.html} | 3 +-- hawc/apps/udf/templates/udf/modelbinding_detail.html | 2 +- hawc/apps/udf/views.py | 2 +- 5 files changed, 6 insertions(+), 7 deletions(-) rename hawc/apps/udf/templates/udf/fragments/{_udf_content.html => table.html} (86%) diff --git a/hawc/apps/animal/templates/animal/endpoint_detail.html b/hawc/apps/animal/templates/animal/endpoint_detail.html index 27af7847b5..99b7150454 100644 --- a/hawc/apps/animal/templates/animal/endpoint_detail.html +++ b/hawc/apps/animal/templates/animal/endpoint_detail.html @@ -39,7 +39,7 @@

{{object}}

Endpoint Details

- {% include "udf/fragments/_udf_content.html" with object=udf_content %} + {% include "udf/fragments/table.html" with object=udf_content %}

Dataset

diff --git a/hawc/apps/study/templates/study/study_detail.html b/hawc/apps/study/templates/study/study_detail.html index 98fe770597..12493efdae 100644 --- a/hawc/apps/study/templates/study/study_detail.html +++ b/hawc/apps/study/templates/study/study_detail.html @@ -70,6 +70,8 @@

{{object}}

+ {% include "udf/fragments/table.html" with object=udf_content %} + {% if obj_perms.edit and internal_communications|hastext %} @@ -130,8 +132,6 @@

{{assessment.get_rob_name_display}}

{% include "eco/fragments/_design_list.html" with object_list=object.eco_designs.all %} {% endif %} - {% include "udf/fragments/_udf_content.html" with object=udf_content %} - {% endif %} {% endblock %} diff --git a/hawc/apps/udf/templates/udf/fragments/_udf_content.html b/hawc/apps/udf/templates/udf/fragments/table.html similarity index 86% rename from hawc/apps/udf/templates/udf/fragments/_udf_content.html rename to hawc/apps/udf/templates/udf/fragments/table.html index 53acfb7494..c3d8e71e09 100644 --- a/hawc/apps/udf/templates/udf/fragments/_udf_content.html +++ b/hawc/apps/udf/templates/udf/fragments/table.html @@ -1,9 +1,8 @@ {% if object %} - {% load bs4 %}

User defined fields

{% bs4_colgroup '30,70' %} - {% bs4_thead 'Field, Value' %} + {% bs4_thead 'Attribute, Value' %} {% for key, value in object.get_content_as_list %} diff --git a/hawc/apps/udf/templates/udf/modelbinding_detail.html b/hawc/apps/udf/templates/udf/modelbinding_detail.html index 2d6ad1dff6..529f573ae5 100644 --- a/hawc/apps/udf/templates/udf/modelbinding_detail.html +++ b/hawc/apps/udf/templates/udf/modelbinding_detail.html @@ -41,4 +41,4 @@

Model/Form binding

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/hawc/apps/udf/views.py b/hawc/apps/udf/views.py index 0c86401b1f..0207265358 100644 --- a/hawc/apps/udf/views.py +++ b/hawc/apps/udf/views.py @@ -204,7 +204,7 @@ def get_success_url(self): class UDFDetailMixin: - """Mixin to add saved UDF contents to the context of a BaseDetail view.""" + """Add UDF content to a BaseDetail.""" def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) From 2fe1f621df8255df849205de56dee9d5c953f5d7 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Tue, 12 Mar 2024 10:41:05 -0400 Subject: [PATCH 34/40] fix table template layout --- hawc/apps/animal/templates/animal/endpoint_detail.html | 1 - hawc/apps/udf/templates/udf/fragments/table.html | 6 +----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/hawc/apps/animal/templates/animal/endpoint_detail.html b/hawc/apps/animal/templates/animal/endpoint_detail.html index 99b7150454..1ba9985c0a 100644 --- a/hawc/apps/animal/templates/animal/endpoint_detail.html +++ b/hawc/apps/animal/templates/animal/endpoint_detail.html @@ -40,7 +40,6 @@

{{object}}

Endpoint Details

{% include "udf/fragments/table.html" with object=udf_content %} -

Dataset

diff --git a/hawc/apps/udf/templates/udf/fragments/table.html b/hawc/apps/udf/templates/udf/fragments/table.html index c3d8e71e09..661676e6a6 100644 --- a/hawc/apps/udf/templates/udf/fragments/table.html +++ b/hawc/apps/udf/templates/udf/fragments/table.html @@ -2,13 +2,9 @@

User defined fields

{% bs4_colgroup '30,70' %} - {% bs4_thead 'Attribute, Value' %} {% for key, value in object.get_content_as_list %} - - - - + {% optional_table_row key value %} {% endfor %}
{{key}}{{value}}
From 6cc928db38e8cd4a430fa134b208c45c4c924607 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Tue, 12 Mar 2024 10:41:32 -0400 Subject: [PATCH 35/40] fix unique-together constraint --- hawc/apps/udf/migrations/0003_modeludfcontent.py | 2 +- hawc/apps/udf/models.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hawc/apps/udf/migrations/0003_modeludfcontent.py b/hawc/apps/udf/migrations/0003_modeludfcontent.py index 83a49a9a3f..d9f86b732c 100644 --- a/hawc/apps/udf/migrations/0003_modeludfcontent.py +++ b/hawc/apps/udf/migrations/0003_modeludfcontent.py @@ -42,6 +42,6 @@ class Migration(migrations.Migration): ), migrations.AlterUniqueTogether( name="modeludfcontent", - unique_together={("model_binding", "object_id")}, + unique_together={("model_binding", "content_type", "object_id")}, ), ] diff --git a/hawc/apps/udf/models.py b/hawc/apps/udf/models.py index bf2680a8a5..345750dd3c 100644 --- a/hawc/apps/udf/models.py +++ b/hawc/apps/udf/models.py @@ -130,7 +130,7 @@ class ModelUDFContent(models.Model): last_updated = models.DateTimeField(auto_now=True) class Meta: - unique_together = (("model_binding", "object_id"),) + unique_together = (("model_binding", "content_type", "object_id"),) def get_content_as_list(self): schema = dynamic_forms.Schema.parse_obj(self.model_binding.form.schema) From 780ef15c7a99fe6b20f0a67c6136334c7aa6b577 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Tue, 12 Mar 2024 10:42:04 -0400 Subject: [PATCH 36/40] fix signals --- hawc/apps/udf/apps.py | 3 +++ hawc/apps/udf/signals.py | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/hawc/apps/udf/apps.py b/hawc/apps/udf/apps.py index 970b831028..9cab112670 100644 --- a/hawc/apps/udf/apps.py +++ b/hawc/apps/udf/apps.py @@ -5,3 +5,6 @@ class FormLibraryConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "hawc.apps.udf" verbose_name = "User Defined Forms" + + def ready(self): + from . import signals # noqa: F401 diff --git a/hawc/apps/udf/signals.py b/hawc/apps/udf/signals.py index c443898c86..873c0c7bbd 100644 --- a/hawc/apps/udf/signals.py +++ b/hawc/apps/udf/signals.py @@ -1,4 +1,4 @@ -from django.db.models.signals import post_save +from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver from . import models @@ -6,6 +6,6 @@ @receiver(post_save, sender=models.ModelBinding) -def delete_cache(sender, instance, created, **kwargs): - if not created: - UDFCache.clear_model_binding_cache(instance) +@receiver(pre_delete, sender=models.ModelBinding) +def delete_cache(sender, instance, **kwargs): + UDFCache.clear_model_binding_cache(instance) From 518b4f5814133e186e2ab6296b181481ef0cb1b0 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Tue, 12 Mar 2024 10:42:48 -0400 Subject: [PATCH 37/40] bonus - improve type annotations for cacheable --- hawc/apps/common/helper.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/hawc/apps/common/helper.py b/hawc/apps/common/helper.py index 4e85f66364..a881fb3197 100644 --- a/hawc/apps/common/helper.py +++ b/hawc/apps/common/helper.py @@ -6,7 +6,7 @@ from datetime import timedelta from itertools import chain from math import inf -from typing import Any, NamedTuple +from typing import Any, NamedTuple, TypeVar import matplotlib.pyplot as plt import numpy as np @@ -504,9 +504,12 @@ def __exit__(self, exc_type, exc_value, traceback): raise ValidationError({self.field: self.msg} if self.include_field else self.msg) +T = TypeVar("T") + + def cacheable( - callable: Callable, cache_key: str, flush: bool = False, cache_duration: int = -1, **kw -) -> Any: + callable: Callable[..., T], cache_key: str, flush: bool = False, cache_duration: int = -1, **kw +) -> T: """Get the result from cache or call method to recreate and cache. Args: @@ -514,6 +517,7 @@ def cacheable( cache_key (str): the cache key to get/set flush (bool, default False): Force flush the cache and re-evaluate. cache_duration (int, default -1): cache key duration; if negative, use settings.CACHE_1_HR. + **kw: keyword arguments passed to callable Returns: The result from the callable, either from cache or regenerated. From 1f369636fd12e4acde4dcdb0d1f737d3adaf6af8 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Tue, 12 Mar 2024 10:45:13 -0400 Subject: [PATCH 38/40] refactor cache --- hawc/apps/assessment/models.py | 14 ----- hawc/apps/udf/cache.py | 94 +++++++++++----------------------- hawc/apps/udf/forms.py | 15 +++--- hawc/apps/udf/models.py | 25 ++++++++- hawc/apps/udf/views.py | 12 ++--- 5 files changed, 62 insertions(+), 98 deletions(-) diff --git a/hawc/apps/assessment/models.py b/hawc/apps/assessment/models.py index 0b42229215..90c2b40bb3 100644 --- a/hawc/apps/assessment/models.py +++ b/hawc/apps/assessment/models.py @@ -10,7 +10,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.cache import cache -from django.core.exceptions import ObjectDoesNotExist from django.core.validators import MinValueValidator from django.db import models from django.http import HttpRequest @@ -315,19 +314,6 @@ def get_assessment_logs_url(self): def get_udf_list_url(self): return reverse("udf:binding-list", args=(self.id,)) - def get_model_binding(self, model: type[models.Model] | models.Model): - """Get the form instance from this assessment's UDF for the given model class/instance. - - Args: - model: a model class or an instance of a model that has a UDF bound to it in this - assessment. - """ - content_type = ContentType.objects.get_for_model(model) - try: - return self.udf_bindings.get(content_type=content_type) - except ObjectDoesNotExist: - return None - def get_clear_cache_url(self): return reverse("assessment:clear_cache", args=(self.id,)) diff --git a/hawc/apps/udf/cache.py b/hawc/apps/udf/cache.py index 821728ab6e..3368a6f70c 100644 --- a/hawc/apps/udf/cache.py +++ b/hawc/apps/udf/cache.py @@ -1,77 +1,41 @@ # Cache class for User Defined Forms. +from django.contrib.contenttypes.models import ContentType from django.core.cache import cache -from django.db.models import Model - -from hawc.apps.assessment.models import Assessment -from hawc.apps.udf.models import ModelBinding, ModelUDFContent +from django.db import models +from ..assessment.models import Assessment from ..common.helper import cacheable +from .models import ModelBinding -class UDFCache: - @classmethod - def get_model_binding_cache( - cls, - assessment: Assessment, - model: type[Model], - flush: bool = False, - cache_duration: int = -1, - ): - def _get_model_binding(assessment: Assessment, model: type[Model]): - # get UDF model binding for given assessment/model combo - return assessment.get_model_binding(model) +def _get_cache_key(assessment: Assessment, content_type: ContentType): + return f"assessment-{assessment.id}-udf-model-binding-{content_type.id}" - cache_key = f"assessment-{assessment.pk}-{model}-model-binding" - return cacheable( - _get_model_binding, - cache_key, - flush, - cache_duration, - assessment=assessment, - model=model, - ) - @classmethod - def clear_model_binding_cache(cls, model_binding: ModelBinding): - cache_key = f"assessment-{model_binding.assessment_id}-{model_binding.content_type.model}-model-binding" - cache.delete(cache_key) +def _get_model_binding(assessment: Assessment, Model: type[models.Model]): + return ModelBinding.get_binding(assessment, Model) - @classmethod - def get_udf_contents_cache( - cls, - model_binding: ModelBinding, - object_id: int | None, - flush: bool = False, - cache_duration: int = -1, - ): - def _get_udf_contents(model_binding, object_id): - # get saved UDF contents for this object id, if it exists - try: - udf_content = model_binding.saved_contents.get(object_id=object_id) - return udf_content - except ModelUDFContent.DoesNotExist: - return None - # if this is a new instance don't bother trying to fetch from the cache - if object_id is None: - return None - cache_key = f"model-binding-{model_binding.pk}-object-{object_id}-udf-contents" - return cacheable( - _get_udf_contents, - cache_key, - flush, - cache_duration, - model_binding=model_binding, - object_id=object_id, - ) +class UDFCache: + @classmethod + def get_model_binding( + cls, assessment: Assessment, Model: type[models.Model] + ) -> ModelBinding | None: + """Get model binding instance if one exists + + Args: + assessment (Assessment): assessment instance + Model (type[models.Model]): the model class + + Returns: + A ModelBinding instance or None + """ + ct = ContentType.objects.get_for_model(Model) + key = _get_cache_key(assessment, ct) + return cacheable(_get_model_binding, key, assessment=assessment, Model=Model) @classmethod - def set_udf_contents_cache( - cls, - udf_content: ModelUDFContent, - cache_duration: int = -1, - ): - cache_key = f"model-binding-{udf_content.model_binding_id}-object-{udf_content.object_id}-udf-contents" - return cacheable( - lambda c: c, cache_key, flush=True, cache_duration=cache_duration, c=udf_content - ) + def clear_model_binding_cache(cls, model_binding: ModelBinding): + """Clear ModelBinding cache""" + key = _get_cache_key(model_binding.assessment, model_binding.content_type) + cache.delete(key) diff --git a/hawc/apps/udf/forms.py b/hawc/apps/udf/forms.py index b8cff38613..23c8c76def 100644 --- a/hawc/apps/udf/forms.py +++ b/hawc/apps/udf/forms.py @@ -151,26 +151,23 @@ class UDFModelFormMixin: def set_udf_field(self, assessment: Assessment): """Set UDF field on model form in a binding exists.""" - self.model_binding = cache.UDFCache.get_model_binding_cache( - assessment=assessment, model=self.Meta.model + + self.model_binding = cache.UDFCache.get_model_binding( + assessment=assessment, Model=self.Meta.model ) if self.model_binding: - udf_content = cache.UDFCache.get_udf_contents_cache( - model_binding=self.model_binding, object_id=self.instance.id - ) - initial = udf_content.content if udf_content is not None else None - + udf_content = models.ModelUDFContent.get_instance(assessment, self.instance) + initial = udf_content.content if udf_content else None udf = self.model_binding.form_field(label="User Defined Fields", initial=initial) self.fields["udf"] = udf def save(self, commit=True): instance = super().save(commit=commit) if commit and "udf" in self.changed_data: - udf_content, _ = models.ModelUDFContent.objects.update_or_create( + models.ModelUDFContent.objects.update_or_create( defaults=dict(content=self.cleaned_data["udf"]), model_binding=self.model_binding, content_type=self.model_binding.content_type, object_id=instance.id, ) - cache.UDFCache.set_udf_contents_cache(udf_content) return instance diff --git a/hawc/apps/udf/models.py b/hawc/apps/udf/models.py index 345750dd3c..3bede43cf5 100644 --- a/hawc/apps/udf/models.py +++ b/hawc/apps/udf/models.py @@ -1,3 +1,5 @@ +from typing import Self + import reversion from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey @@ -36,7 +38,7 @@ class Meta: ordering = ("-last_updated",) def __str__(self): - return f"{self.name}" + return self.name def get_absolute_url(self): return reverse("udf:udf_detail", args=(self.pk,)) @@ -64,7 +66,7 @@ class Meta: unique_together = (("assessment", "content_type"),) def __str__(self): - return f"{self.assessment}/{self.content_type.model} form" + return f"{self.assessment} / {self.content_type.model} form" def form_field(self, *args, **kwargs) -> JSONField | DynamicFormField: prefix = kwargs.pop("prefix", "udf") @@ -82,6 +84,11 @@ def get_assessment(self): def get_absolute_url(self): return reverse("udf:model_detail", args=(self.id,)) + @classmethod + def get_binding(cls, assessment: Assessment, Model: type[models.Model]) -> Self | None: + content_type = ContentType.objects.get_for_model(Model) + return assessment.udf_bindings.filter(content_type=content_type).first() + class TagBinding(models.Model): assessment = models.ForeignKey( @@ -154,6 +161,20 @@ def get_content_as_list(self): items.append((label, value)) return items + @classmethod + def get_instance(cls, assessment_id, object: models.Model) -> Self | None: + if object.pk is None: + return + return ( + cls.objects.filter( + model_binding__assessment=assessment_id, + content_type=ContentType.objects.get_for_model(object), + object_id=object.pk, + ) + .select_related("model_binding") + .first() + ) + reversion.register(TagBinding) reversion.register(ModelBinding) diff --git a/hawc/apps/udf/views.py b/hawc/apps/udf/views.py index 0207265358..b17438a4d4 100644 --- a/hawc/apps/udf/views.py +++ b/hawc/apps/udf/views.py @@ -18,7 +18,6 @@ ) from . import forms, models -from .cache import UDFCache # UDF views @@ -206,12 +205,9 @@ def get_success_url(self): class UDFDetailMixin: """Add UDF content to a BaseDetail.""" - def get_context_data(self, **kwargs): - context = super().get_context_data(**kwargs) - model_binding = UDFCache.get_model_binding_cache(self.assessment, self.model) - context["udf_content"] = ( - UDFCache.get_udf_contents_cache(model_binding, self.object.pk) - if model_binding is not None - else None + def get_context_data(self, **kw): + context = super().get_context_data(**kw) + context.update( + udf_content=models.ModelUDFContent.get_instance(self.assessment, self.object) ) return context From 0a85e9dbdce686ef00e0e5a0de79c35b7473cb65 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Tue, 12 Mar 2024 11:06:07 -0400 Subject: [PATCH 39/40] refactor content list --- hawc/apps/udf/models.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/hawc/apps/udf/models.py b/hawc/apps/udf/models.py index 3bede43cf5..0a6f3c7d4f 100644 --- a/hawc/apps/udf/models.py +++ b/hawc/apps/udf/models.py @@ -71,7 +71,7 @@ def __str__(self): def form_field(self, *args, **kwargs) -> JSONField | DynamicFormField: prefix = kwargs.pop("prefix", "udf") form_kwargs = kwargs.pop("form_kwargs", None) - return dynamic_forms.Schema.parse_obj(self.form.schema).to_form_field( + return dynamic_forms.Schema.model_validate(self.form.schema).to_form_field( prefix, form_kwargs, *args, **kwargs ) @@ -111,7 +111,7 @@ class Meta: def form_field( self, prefix="", form_kwargs=None, *args, **kwargs ) -> JSONField | DynamicFormField: - return dynamic_forms.Schema.parse_obj(self.form.schema).to_form_field( + return dynamic_forms.Schema.model_validate(self.form.schema).to_form_field( prefix, form_kwargs, *args, **kwargs ) @@ -140,24 +140,21 @@ class Meta: unique_together = (("model_binding", "content_type", "object_id"),) def get_content_as_list(self): - schema = dynamic_forms.Schema.parse_obj(self.model_binding.form.schema) - + schema = dynamic_forms.Schema.model_validate(self.model_binding.form.schema) items = [] for field in schema.fields: field_value = self.content.get(field.name) field_kwargs = field.get_form_field_kwargs() + value = field_value if "choices" in field_kwargs and field_value is not None: choice_map = dict(field_kwargs["choices"]) - if field.type == "multiple_choice": - value = [choice_map[i] for i in field_value] - else: - value = choice_map[field_value] - else: - value = field_value + value = ( + "|".join([choice_map[i] for i in field_value]) + if isinstance(value, list) + else choice_map[field_value] + ) if value: label = field.get_verbose_name() - if isinstance(value, list) and field.type != "multiple_choice": - value = "|".join(map(str, value)) items.append((label, value)) return items From 10cb86341a3518687564bfcb6fe96f668e1c60a3 Mon Sep 17 00:00:00 2001 From: Andy Shapiro Date: Tue, 12 Mar 2024 11:30:07 -0400 Subject: [PATCH 40/40] add tests --- .../common/management/commands/dump_test_db.py | 1 + tests/data/fixtures/db.yaml | 17 +++++++++++++++-- tests/hawc/apps/udf/test_models.py | 17 +++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/hawc/apps/udf/test_models.py diff --git a/hawc/apps/common/management/commands/dump_test_db.py b/hawc/apps/common/management/commands/dump_test_db.py index 75b6f7874f..bd6c543bbf 100644 --- a/hawc/apps/common/management/commands/dump_test_db.py +++ b/hawc/apps/common/management/commands/dump_test_db.py @@ -49,6 +49,7 @@ def handle(self, *args, **options): call_command("dumpdata", "invitro", **shared_kwargs) call_command("dumpdata", "epimeta", **shared_kwargs) call_command("dumpdata", "summary", **shared_kwargs) + call_command("dumpdata", "udf", **shared_kwargs) call_command("dumpdata", "docs", **shared_kwargs) call_command("dumpdata", "mgmt", **shared_kwargs) diff --git a/tests/data/fixtures/db.yaml b/tests/data/fixtures/db.yaml index 69ca98277c..0164851974 100644 --- a/tests/data/fixtures/db.yaml +++ b/tests/data/fixtures/db.yaml @@ -11115,8 +11115,8 @@ fields: assessment: 4 content_type: - - animal - - endpoint + - study + - study form: 1 creator: - pm@hawcproject.org @@ -11132,6 +11132,19 @@ - pm@hawcproject.org created: 2023-09-17 04:10:29.880052+00:00 last_updated: 2023-09-17 04:15:33.309627+00:00 +- model: udf.modeludfcontent + pk: 1 + fields: + model_binding: 1 + content_type: + - study + - study + object_id: 9 + content: + field1: test + field2: 123 + created: 2024-03-12 15:19:07.533314+00:00 + last_updated: 2024-03-12 15:19:07.533329+00:00 - model: reversion.revision pk: 1 fields: diff --git a/tests/hawc/apps/udf/test_models.py b/tests/hawc/apps/udf/test_models.py new file mode 100644 index 0000000000..19a45374b9 --- /dev/null +++ b/tests/hawc/apps/udf/test_models.py @@ -0,0 +1,17 @@ +import pytest + +from hawc.apps.udf.models import ModelUDFContent + + +@pytest.mark.django_db +class TestModelUDFContent: + def test_get_content_as_list(self): + content = ModelUDFContent.objects.get(id=1) + assert content.get_content_as_list() == [("Field1", "test"), ("Field2", 123)] + + def test_get_instance(self): + content = ModelUDFContent.objects.get(id=1) + assert ( + content.get_instance(content.content_object.assessment, content.content_object) + == content + )