Source code for apis_core.relations.forms
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Submit
from dal import autocomplete
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.http import urlencode
from apis_core.generic.forms import GenericModelForm
from apis_core.generic.forms.fields import ModelImportChoiceField
[docs]
class CustomSelect2ListChoiceField(autocomplete.Select2ListChoiceField):
"""
We use a custom Select2ListChoiceField in our Relation form,
because we don't want the form value to be validated. The field
uses the `choices` setting as a basis for validating, but our
choices span over multiple querysets, so its easier to simply not
validate (some validation happens in the `clean` method anyway).
"""
[docs]
def validate(self, value):
if "_" not in value:
raise ValidationError("please choose a correct value")
[docs]
class RelationForm(GenericModelForm):
"""
This form overrides generic form for relation editing.
Relations have generic relations to subj and obj, but we
hide those ForeignKey form fields and instead show
autocomplete choices fields.
In addition, one can pass a hx_post_route argument to the
form to make the form set the `hx-post` attribute to the
given value.
We also pass a `reverse` boolean, wich gets passed on
to the htmx POST endpoint using url parameters (the endpoint
can then select the success_url based on the `reverse` state).
"""
[docs]
class Meta:
fields = "__all__"
widgets = {
"subj_content_type": forms.HiddenInput(),
"subj_object_id": forms.HiddenInput(),
"obj_content_type": forms.HiddenInput(),
"obj_object_id": forms.HiddenInput(),
}
def __entities_autocomplete_with_params(self, content_types=[ContentType]) -> str:
"""helper method to generate the entities autocomplete url with contentype parameters"""
url = reverse("apis_core:apis_entities:autocomplete")
params = [f"entities={ct.app_label}.{ct.model}" for ct in content_types]
return url + "?" + "&".join(params)
def __subj_autocomplete_url(self) -> str:
"""generate the autocomplete url for the subj field, using the subject
types configured in the relation"""
subj_content_types = [
ContentType.objects.get_for_model(model)
for model in self.Meta.model.subj_list()
]
return self.__entities_autocomplete_with_params(subj_content_types)
def __obj_autocomplete_url(self) -> str:
"""generate the autocomplete url for the obj field, using the object
types configured in the relation"""
obj_content_types = [
ContentType.objects.get_for_model(model)
for model in self.Meta.model.obj_list()
]
return self.__entities_autocomplete_with_params(obj_content_types)
def __init__(self, *args, **kwargs):
"""
Initialize the form and add the `subj` and `obj` fields using the
generic apis_entities autocomplete with the correct parameters.
"""
self.is_reverse = kwargs.pop("reverse", False)
hx_post_route = kwargs.pop("hx_post_route", False)
super().__init__(*args, **kwargs)
subj_content_type = kwargs["initial"].get("subj_content_type", None)
subj_object_id = kwargs["initial"].get("subj_object_id", None)
obj_content_type = kwargs["initial"].get("obj_content_type", None)
obj_object_id = kwargs["initial"].get("obj_object_id", None)
self.subj_instance, self.obj_instance = None, None
if instance := kwargs.get("instance"):
self.subj_instance = instance.subj
self.obj_instance = instance.obj
else:
if subj_content_type and subj_object_id:
model = get_object_or_404(ContentType, pk=subj_content_type)
self.subj_instance = get_object_or_404(
model.model_class(), pk=subj_object_id
)
if obj_content_type and obj_object_id:
model = get_object_or_404(ContentType, pk=obj_content_type)
self.obj_instance = get_object_or_404(
model.model_class(), pk=obj_object_id
)
self.fields["subj_object_id"].required = False
self.fields["subj_content_type"].required = False
if not subj_object_id:
if subj_content_type:
""" If we know the content type the subject will have, we
use another autocomplete field that allows us to paste links
and provides external autocomplete results.
"""
ct = ContentType.objects.get(pk=subj_content_type)
self.fields["subj"] = ModelImportChoiceField(
queryset=ct.model_class().objects.all()
)
self.fields["subj"].widget = autocomplete.ListSelect2(
url=reverse("apis_core:generic:autocomplete", args=[ct])
+ "?create=True"
)
self.fields["subj"].widget.choices = self.fields["subj"].choices
else:
""" If we don't know the content type, we use a generic autocomplete
field that autocompletes any content type the relation can have as a
subject.
"""
self.fields["subj_ct_and_id"] = CustomSelect2ListChoiceField()
self.fields["subj_ct_and_id"].widget = autocomplete.ListSelect2(
url=self.__subj_autocomplete_url()
)
if self.subj_instance:
content_type = ContentType.objects.get_for_model(self.subj_instance)
select_identifier = f"{content_type.id}_{self.subj_instance.id}"
self.fields["subj_ct_and_id"].initial = select_identifier
self.fields["subj_ct_and_id"].choices = [
(select_identifier, self.subj_instance)
]
self.fields["obj_object_id"].required = False
self.fields["obj_content_type"].required = False
if not obj_object_id:
if obj_content_type:
""" If we know the content type the object will have, we
use another autocomplete field that allows us to paste links
and provides external autocomplete results.
"""
ct = ContentType.objects.get(pk=obj_content_type)
self.fields["obj"] = ModelImportChoiceField(
queryset=ct.model_class().objects.all()
)
self.fields["obj"].widget = autocomplete.ListSelect2(
url=reverse("apis_core:generic:autocomplete", args=[ct])
+ "?create=True"
)
self.fields["obj"].widget.choices = self.fields["obj"].choices
else:
""" If we don't know the content type, we use a generic autocomplete
field that autocompletes any content type the relation can have as a
object.
"""
self.fields["obj_ct_and_id"] = CustomSelect2ListChoiceField()
self.fields["obj_ct_and_id"].widget = autocomplete.ListSelect2(
url=self.__obj_autocomplete_url()
)
if self.obj_instance:
content_type = ContentType.objects.get_for_model(self.obj_instance)
select_identifier = f"{content_type.id}_{self.obj_instance.id}"
self.fields["obj_ct_and_id"].initial = select_identifier
self.fields["obj_ct_and_id"].choices = [
(select_identifier, self.obj_instance)
]
self.order_fields(self.field_order)
self.helper = FormHelper(self)
model_ct = ContentType.objects.get_for_model(self.Meta.model)
self.helper.form_id = f"relation_{model_ct.model}_form"
self.helper.add_input(Submit("submit", "Submit"))
if hx_post_route:
urlparams = kwargs["initial"]
urlparams["reverse"] = self.is_reverse
urlparams = {k: v for k, v in urlparams.items() if v}
self.helper.attrs = {
"hx-post": hx_post_route + "?" + urlencode(urlparams),
"hx-swap": "outerHTML",
"hx-target": f"#{self.helper.form_id}",
}
[docs]
def clean(self) -> dict:
"""
We check if there are `subj` or `obj` fields in the form data
and if so, we use the data to create objects for the real fields of
the Relation
"""
cleaned_data = super().clean()
if "subj_ct_and_id" in cleaned_data:
subj_content_type, subj_object_id = cleaned_data["subj_ct_and_id"].split(
"_"
)
cleaned_data["subj_content_type"] = ContentType.objects.get(
pk=subj_content_type
)
cleaned_data["subj_object_id"] = subj_object_id
del cleaned_data["subj_ct_and_id"]
if "obj_ct_and_id" in cleaned_data:
obj_content_type, obj_object_id = cleaned_data["obj_ct_and_id"].split("_")
cleaned_data["obj_content_type"] = ContentType.objects.get(
pk=obj_content_type
)
cleaned_data["obj_object_id"] = obj_object_id
del cleaned_data["obj_ct_and_id"]
if "subj" in cleaned_data:
cleaned_data["subj_object_id"] = cleaned_data.pop("subj").id
if "obj" in cleaned_data:
cleaned_data["obj_object_id"] = cleaned_data.pop("obj").id
return cleaned_data
@property
def relation_name(self) -> str:
"""A helper method to access the correct name of the relation"""
if self.is_reverse:
return self._meta.model.reverse_name
return self._meta.model.name