diff --git a/apis_core/relations/__init__.py b/apis_core/relations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apis_core/relations/admin.py b/apis_core/relations/admin.py new file mode 100644 index 000000000..308d0b15d --- /dev/null +++ b/apis_core/relations/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin + +from .models import Relation + +admin.site.register(Relation) diff --git a/apis_core/relations/apps.py b/apis_core/relations/apps.py new file mode 100644 index 000000000..1ffdb1f4b --- /dev/null +++ b/apis_core/relations/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class ApisRelations2Config(AppConfig): + default_auto_field = "django.db.models.AutoField" + name = "apis_core.relations" + + def ready(self): + from . import signals diff --git a/apis_core/relations/forms.py b/apis_core/relations/forms.py new file mode 100644 index 000000000..17aa3c07c --- /dev/null +++ b/apis_core/relations/forms.py @@ -0,0 +1,84 @@ +from django.forms import ModelForm +from django.urls import reverse +from django.shortcuts import get_object_or_404 +from django.contrib.contenttypes.models import ContentType + + +from crispy_forms.layout import Submit, Layout, Div, HTML +from crispy_forms.helper import FormHelper + + +class RelationForm(ModelForm): + def __init__( + self, + frominstance=None, + tocontenttype=None, + inverted=False, + embedded=True, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + + subj, obj = "subj", "obj" + if inverted: + subj = "obj" + obj = "subj" + + if frominstance: + self.fields[subj].disabled = True + self.fields[subj].initial = frominstance + self.fields[subj].label = ContentType.objects.get_for_model( + frominstance + ).name + + if tocontenttype: + self.fields[obj].queryset = tocontenttype.model_class().objects.all() + self.fields[obj].label = tocontenttype.name + + self.helper = FormHelper(self) + + relcontenttype = ContentType.objects.get_for_model(self._meta.model) + + args = [ + relcontenttype.pk, + ] + if frominstance: + args.append(ContentType.objects.get_for_model(frominstance).pk) + args.append(frominstance.pk) + if tocontenttype: + args.append(tocontenttype.pk) + hx_post = reverse("apis:relation", args=args) + if inverted: + hx_post = reverse("apis:relationinverted", args=args) + + hx_post += "?partial" + + if embedded: + self.helper.attrs = { + "hx-post": hx_post, + "hx-swap": "outerHTML", + } + + # layout stuff: + div = Div( + Div("subj", css_class="col-md-6"), + Div("obj", css_class="col-md-6"), + css_class="row", + ) + if inverted: + div = Div( + Div("obj", css_class="col-md-6"), + Div("subj", css_class="col-md-6"), + css_class="row", + ) + + # we have to explicetly add the rest of the fields + fields = {k: v for k, v in self.fields.items() if k not in ["obj", "subj"]} + + self.helper.layout = Layout( + HTML(f"

{self._meta.model.__name__}

"), + div, + *fields, + ) + self.helper.add_input(Submit("submit", "Submit", css_class="btn-primary")) diff --git a/apis_core/relations/management/commands/converttemptriples.py b/apis_core/relations/management/commands/converttemptriples.py new file mode 100644 index 000000000..a84e21980 --- /dev/null +++ b/apis_core/relations/management/commands/converttemptriples.py @@ -0,0 +1,10 @@ +from django.core.management.base import BaseCommand +from apis_core.apis_relations.models import TempTriple + + +class Command(BaseCommand): + help = "Create relations based on all existing TempTriples" + + def handle(self, *args, **options): + for tt in TempTriple.objects.all(): + tt.save() diff --git a/apis_core/relations/migrations/0001_initial.py b/apis_core/relations/migrations/0001_initial.py new file mode 100644 index 000000000..8c766a33e --- /dev/null +++ b/apis_core/relations/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 4.1.13 on 2023-12-18 06:09 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("apis_metainfo", "0006_delete_text"), + ] + + operations = [ + migrations.CreateModel( + name="Relation", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "obj", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="relations_as_obj", + to="apis_metainfo.rootobject", + ), + ), + ( + "subj", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="relations_as_subj", + to="apis_metainfo.rootobject", + ), + ), + ], + ), + ] diff --git a/apis_core/relations/migrations/__init__.py b/apis_core/relations/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apis_core/relations/models.py b/apis_core/relations/models.py new file mode 100644 index 000000000..be700c070 --- /dev/null +++ b/apis_core/relations/models.py @@ -0,0 +1,104 @@ +from apis_core.apis_metainfo.models import RootObject +from django.db import models +from django.db.models.base import ModelBase +from django.core.exceptions import ValidationError +from model_utils.managers import InheritanceManager + + +# This ModelBase is simply there to check if the needed attributes +# are set in the Relation child classes. +class RelationModelBase(ModelBase): + def __new__(metacls, name, bases, attrs): + if name == "Relation": + return super().__new__(metacls, name, bases, attrs) + else: + new_class = super().__new__(metacls, name, bases, attrs) + if not hasattr(new_class, "subj_model"): + raise ValueError( + "%s inherits from Relation and must therefore specify subj_model" + % name + ) + if not hasattr(new_class, "obj_model"): + raise ValueError( + "%s inherits from Relation and must therefore specify obj_model" + % name + ) + + return new_class + + +class Relation(models.Model, metaclass=RelationModelBase): + subj = models.ForeignKey( + RootObject, + on_delete=models.SET_NULL, + null=True, + related_name="relations_as_subj", + ) + obj = models.ForeignKey( + RootObject, + on_delete=models.SET_NULL, + null=True, + related_name="relations_as_obj", + ) + + objects = InheritanceManager() + + def save(self, *args, **kwargs): + if self.subj: + subj = RootObject.objects_inheritance.get_subclass(id=self.subj.id) + if not type(subj) in self.subj_list(): + raise ValidationError( + f"{self.subj} is not of any type in {self.subj_model}" + ) + if self.obj: + obj = RootObject.objects_inheritance.get_subclass(id=self.obj.id) + if not type(obj) in self.obj_list(): + raise ValidationError( + f"{self.obj} is not of any type in {self.obj_model}" + ) + super().save(*args, **kwargs) + + @property + def subj_to_obj_text(self): + if hasattr(self, "name"): + return f"{self.subj} {self.name} {self.obj}" + return f"{self.subj} relation to {self.obj}" + + @property + def obj_to_subj_text(self): + if hasattr(self, "reverse_name"): + return f"{self.subj} {self.reverse_name} {self.obj}" + return f"{self.obj} relation to {self.subj}" + + def __str__(self): + return self.subj_to_obj_text + + @classmethod + def is_subj(cls, something): + return something in cls.subj_list() + + @classmethod + def is_obj(cls, something): + return something in cls.obj_list() + + @classmethod + def subj_list(cls): + return cls.subj_model if isinstance(cls.subj_model, list) else [cls.subj_model] + + @classmethod + def obj_list(cls): + return cls.obj_model if isinstance(cls.obj_model, list) else [cls.obj_model] + + def clean(self): + if self.subj: + subj = RootObject.objects_inheritance.get_subclass(id=self.subj.id) + if not type(subj) in self.subj_list(): + raise ValidationError( + f"{self.subj} is not of any type in {self.subj_model}" + ) + if self.obj: + obj = RootObject.objects_inheritance.get_subclass(id=self.obj.id) + if not type(obj) in self.obj_list(): + raise ValidationError( + f"{self.obj} is not of any type in {self.obj_model}" + ) diff --git a/apis_core/relations/signals.py b/apis_core/relations/signals.py new file mode 100644 index 000000000..77f68e1b8 --- /dev/null +++ b/apis_core/relations/signals.py @@ -0,0 +1,37 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from apis_core.apis_relations.models import TempTriple +from django.apps import apps + + +def find_relationtype(tt: TempTriple): + obj_model = type(tt.obj) + subj_model = type(tt.subj) + name = tt.prop.name_forward + reverse = tt.prop.name_reverse + for model in apps.get_models(): + if ( + getattr(model, "obj_model", None) == obj_model + and getattr(model, "subj_model", None) == subj_model + and getattr(model, "temptriple_name", None) == name + and getattr(model, "temptriple_name_reverse", None) == reverse + ): + print(f"TempTriple {tt.pk} matches with {model}") + return model + print("Found none.") + return None + + +# this function is a helper function that adds a relation for every legacy temptriple that is created or updated +@receiver(post_save, sender=TempTriple) +def addrelation(sender, instance, created, **kwargs): + model = find_relationtype(instance) + if model is not None: + rel, created = model.objects.get_or_create( + subj=instance.subj, obj=instance.obj, metadata={"temptriple": instance.pk} + ) + for field in getattr(model, "temptriple_field_list", []): + setattr(rel, field, getattr(instance, field, None)) + for field, newfield in getattr(model, "temptriple_field_mapping", {}).items(): + setattr(rel, newfield, getattr(instance, field, None)) + rel.save() diff --git a/apis_core/relations/static/relations.css b/apis_core/relations/static/relations.css new file mode 100644 index 000000000..ae2cb7c9e --- /dev/null +++ b/apis_core/relations/static/relations.css @@ -0,0 +1,5 @@ +tr.htmx-swapping td { + opacity: 0; + transition: opacity 1s ease-out; + color: red; +} diff --git a/apis_core/relations/tables.py b/apis_core/relations/tables.py new file mode 100644 index 000000000..55e2c2e83 --- /dev/null +++ b/apis_core/relations/tables.py @@ -0,0 +1,23 @@ +import django_tables2 as tables + +from django.contrib.contenttypes.models import ContentType + +from .models import Relation + + +class RelationTable(tables.Table): + + id = tables.TemplateColumn( + "{{ record.id }}" + ) + + description = tables.TemplateColumn("{{ record }}") + edit = tables.TemplateColumn( + "Edit" + ) + delete = tables.TemplateColumn(template_name="tables/delete.html") + + class Meta: + model = Relation + fields = ["id", "description", "edit"] + sequence = tuple(fields) diff --git a/apis_core/relations/templates/relations/list.html b/apis_core/relations/templates/relations/list.html new file mode 100644 index 000000000..21baaae62 --- /dev/null +++ b/apis_core/relations/templates/relations/list.html @@ -0,0 +1,24 @@ +{% extends basetemplate %} +{% load relations %} + +{% block content %} + + {% if model or object %} +

{{ model.name }}{{ object }}

+ {% else %} + {% relations_links %} + {% endif %} + + {% if table %} + {% load render_table from django_tables2 %} + {% render_table table %} + {% endif %} + + {% if form %} + {{ form.errors }} + {{ form.non_field_errors }} + {% load crispy_forms_tags %} + {% crispy form %} + {% endif %} + +{% endblock content %} diff --git a/apis_core/relations/templates/relations/partial.html b/apis_core/relations/templates/relations/partial.html new file mode 100644 index 000000000..2b42cfd95 --- /dev/null +++ b/apis_core/relations/templates/relations/partial.html @@ -0,0 +1,9 @@ +{% if table %} + {% load render_table from django_tables2 %} + {% render_table table %} +{% endif %} + +{% if form %} + {% load crispy_forms_tags %} + {% crispy form %} +{% endif %} diff --git a/apis_core/relations/templates/tables/delete.html b/apis_core/relations/templates/tables/delete.html new file mode 100644 index 000000000..2ee29982f --- /dev/null +++ b/apis_core/relations/templates/tables/delete.html @@ -0,0 +1,5 @@ +Delete diff --git a/apis_core/relations/templates/templatetags/relations_links.html b/apis_core/relations/templates/templatetags/relations_links.html new file mode 100644 index 000000000..3704d827e --- /dev/null +++ b/apis_core/relations/templates/templatetags/relations_links.html @@ -0,0 +1,32 @@ +{% load relations %} +{% load crispy_forms_tags %} +{% for rel, model in relation_types %} + + {% if instance %} + {% url 'apis:relation' rel.pk instancect.pk instance.pk contenttype.pk as hrefurl %} + + {% if rel in relation_types_reverse %} + {% url 'apis:relationinverted' rel.pk instancect.pk instance.pk contenttype.pk as hrefurl %} + {% endif %} + + {% else %} + {% url 'apis:relation' rel.pk as hrefurl %} + {% endif %} + + + + +{% endfor %} +{% for rel, model in relation_types %} +
+{% endfor %} diff --git a/apis_core/relations/templatetags/relations.py b/apis_core/relations/templatetags/relations.py new file mode 100644 index 000000000..08da997ae --- /dev/null +++ b/apis_core/relations/templatetags/relations.py @@ -0,0 +1,139 @@ +from django import template +from django.db.models import Q +from django.contrib.contenttypes.models import ContentType +from django.templatetags.static import static +from django.utils.html import format_html +from django_tables2.tables import table_factory + +from apis_core.relations.tables import RelationTable +from apis_core.relations.models import Relation +from apis_core.relations import utils + +register = template.Library() + + +@register.simple_tag +def relations_table(relationtype=None, instance=None, tocontenttype=None): + """ + List all relations of type `relationtype` that go from `instance` to + something with type `contenttype`. + If no `tocontenttype` is passed, it lists all relations from and to + instance. + If no `relationtype` is passed, it lists all relations. + """ + model = None + existing_relations = list() + + if tocontenttype: + model = tocontenttype.model_class() + + if relationtype: + relation_types = [relationtype] + else: + # special case: when the contenttype is the same as the contenttype of + # the instance, we don't want *all* the relations where the instance + # occurs, but only those where it occurs together with another of its + # type + if instance and ContentType.objects.get_for_model(instance) == tocontenttype: + relation_types = utils.relation_content_types(combination=(model, model)) + else: + relation_types = utils.relation_content_types(any_model=model) + + for rel in relation_types: + if instance: + existing_relations.extend( + list( + rel.model_class().objects.filter(Q(subj=instance) | Q(obj=instance)) + ) + ) + else: + existing_relations.extend(list(rel.model_class().objects.all())) + + cssid = "table" + if model: + cssid += f"_{tocontenttype.name}" + else: + cssid += "_relations" + attrs = { + "class": "table table-hover table-striped table-condensed", + "hx-swap-oob": "true", + "id": cssid, + } + + table = RelationTable + if model: + table = table_factory(model, RelationTable) + return table(existing_relations, attrs=attrs) + + +@register.inclusion_tag("templatetags/relations_links.html") +def relations_links(instance=None, tocontenttype=None, htmx=False): + """ + Provide a list of links to relation views; If `instance` is passed, + it only links to relations where an `instance` type can be part of. + If `contenttype` is passed, it links only to relations that can occur + between the `instance` contenttype and the `contentttype`. + """ + tomodel = None + if tocontenttype: + tomodel = tocontenttype.model_class() + + frommodel = None + instancect = None + if instance: + frommodel = type(instance) + instancect = ContentType.objects.get_for_model(instance) + + return { + "relation_types": [ + (ct, ct.model_class()) + for ct in utils.relation_content_types(combination=(frommodel, tomodel)) + ], + "relation_types_reverse": utils.relation_content_types( + subj_model=tomodel, obj_model=frommodel + ), + "instance": instance, + "instancect": instancect, + "contenttype": tocontenttype, + "htmx": htmx, + } + + +def contenttype_can_be_related_to(ct: ContentType) -> list[ContentType]: + models = set() + for rel in utils.relation_content_types(any_model=ct.model_class()): + for x in rel.model_class().subj_list(): + models.add(x) + for x in rel.model_class().obj_list(): + models.add(x) + contenttypes = ContentType.objects.get_for_models(*models) + models = sorted(contenttypes.items(), key=lambda item: item[1].name) + return [item[1] for item in models] + + +@register.simple_tag +def instance_can_be_related_to(instance: object) -> list[ContentType]: + return contenttype_can_be_related_to(ContentType.objects.get_for_model(instance)) + + +@register.simple_tag +def instance_is_related_to(instance: object) -> list[ContentType]: + models = set() + for rel in Relation.objects.filter(subj=instance).select_subclasses(): + for model in rel.obj_list(): + models.add(model) + for rel in Relation.objects.filter(obj=instance).select_subclasses(): + for model in rel.subj_list(): + models.add(model) + contenttypes = ContentType.objects.get_for_models(*models) + models = sorted(contenttypes.items(), key=lambda item: item[1].name) + return [item[1] for item in models] + + +@register.simple_tag +def relations_css() -> str: + """ + include a custom `relations.css` file + """ + cssfile = static("relations.css") + return format_html('', cssfile) diff --git a/apis_core/relations/urls.py b/apis_core/relations/urls.py new file mode 100644 index 000000000..ea9647e2f --- /dev/null +++ b/apis_core/relations/urls.py @@ -0,0 +1,41 @@ +from django.urls import include, path +from . import views + +# app_name = "apis_relations2" + +urlpatterns = [ + path("relations/all", views.RelationView.as_view(), name="relation"), + path( + "relations//", + include( + [ + path("", views.RelationView.as_view(), name="relation"), + path( + "/", + views.RelationView.as_view(), + name="relation", + ), + path( + "//", + views.RelationView.as_view(), + name="relation", + ), + path( + "///inverted", + views.RelationView.as_view(inverted=True), + name="relationinverted", + ), + ] + ), + ), + path( + "relation//update", + views.RelationUpdate.as_view(), + name="relationupdate", + ), + path( + "relation//delete", + views.RelationDelete.as_view(), + name="relationdelete", + ), +] diff --git a/apis_core/relations/utils.py b/apis_core/relations/utils.py new file mode 100644 index 000000000..f870c9eb4 --- /dev/null +++ b/apis_core/relations/utils.py @@ -0,0 +1,67 @@ +import functools +from django.contrib.contenttypes.models import ContentType +from apis_core.relations.models import Relation + + +def is_relation(ct: ContentType) -> bool: + mc = ct.model_class() + return ( + issubclass(mc, Relation) + and hasattr(mc, "subj_model") + and hasattr(mc, "obj_model") + ) + + +@functools.cache +def relation_content_types( + subj_model=None, obj_model=None, any_model=None, combination=(None, None) +): + allcts = list( + filter( + lambda contenttype: contenttype.model_class() is not None, + ContentType.objects.all(), + ) + ) + relationcts = list(filter(lambda contenttype: is_relation(contenttype), allcts)) + if subj_model is not None: + relationcts = list( + filter( + lambda contenttype: contenttype.model_class().is_subj(subj_model), + relationcts, + ) + ) + if obj_model is not None: + relationcts = list( + filter( + lambda contenttype: contenttype.model_class().is_obj(obj_model), + relationcts, + ) + ) + if any_model is not None: + relationcts = list( + filter( + lambda contenttype: contenttype.model_class().is_obj(any_model) + or contenttype.model_class().is_subj(any_model), + relationcts, + ) + ) + if all(combination): + left, right = combination + rels = list( + filter( + lambda contenttype: contenttype.model_class().is_obj(right) + and contenttype.model_class().is_subj(left), + relationcts, + ) + ) + rels.extend( + list( + filter( + lambda contenttype: contenttype.model_class().is_obj(left) + and contenttype.model_class().is_subj(right), + relationcts, + ) + ) + ) + relationcts = rels + return set(relationcts) diff --git a/apis_core/relations/views.py b/apis_core/relations/views.py new file mode 100644 index 000000000..7ea96d01a --- /dev/null +++ b/apis_core/relations/views.py @@ -0,0 +1,141 @@ +from django.views.generic.edit import CreateView, UpdateView, DeleteView +from django.contrib.contenttypes.models import ContentType +from django.forms import modelform_factory +from django.urls import reverse +from django.http import Http404, HttpResponse + +from .models import Relation +from .forms import RelationForm +from .utils import relation_content_types +from .templatetags import relations + + +################################################ +# Views working with a specific Relation type: # +################################################ + + +class RelationView(CreateView): + contenttype = None + frominstance = None + inverted = False + partial = False + template_name = "relations/list.html" + tocontenttype = None + + def dispatch(self, request, *args, **kwargs): + if contenttype := kwargs.get("contenttype"): + self.contenttype = ContentType.objects.get_for_id(contenttype) + if self.contenttype not in relation_content_types(): + raise Http404( + f"Relation with id {kwargs['contenttype']} does not exist" + ) + if fromoid := self.kwargs.get("fromoid"): + if fromcontenttype := self.kwargs.get("fromcontenttype"): + self.frominstance = ContentType.objects.get_for_id( + fromcontenttype + ).get_object_for_this_type(pk=fromoid) + if tocontenttype := self.kwargs.get("tocontenttype"): + self.tocontenttype = ContentType.objects.get_for_id(tocontenttype) + if "partial" in self.request.GET: + self.template_name = "relations/partial.html" + self.partial = True + return super().dispatch(request, *args, **kwargs) + + def get_form_class(self): + model_class = Relation + if self.contenttype: + model_class = self.contenttype.model_class() + return modelform_factory(model_class, form=RelationForm, exclude=[]) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + + kwargs["frominstance"] = self.frominstance + kwargs["tocontenttype"] = self.tocontenttype + kwargs["inverted"] = self.inverted + return kwargs + + def get_success_url(self): + url = reverse("apis:relation", kwargs=self.request.resolver_match.kwargs) + if self.inverted: + url = reverse( + "apis:relationinverted", kwargs=self.request.resolver_match.kwargs + ) + if self.partial: + url += "?partial" + return url + + def get_context_data(self, *args, **kwargs): + ctx = super().get_context_data() + if self.contenttype: + ctx["model"] = self.contenttype.model_class() + if "formonly" in self.request.GET: + return ctx + if "tableonly" in self.request.GET: + ctx["form"] = None + # if we are not working with a specific relation + # there is no need to present a form: + if not self.contenttype: + ctx["form"] = None + + if tocontenttype := self.kwargs.get("tocontenttype"): + tocontenttype = ContentType.objects.get_for_id(tocontenttype) + ctx["table"] = relations.relations_table( + relationtype=self.contenttype, + instance=self.frominstance, + tocontenttype=tocontenttype, + ) + return ctx + + +################################################# +# Views working with single Relation instances: # +################################################# + + +class RelationUpdate(UpdateView): + template_name = "relations/list.html" + + def get_object(self): + return Relation.objects.get_subclass(id=self.kwargs["pk"]) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["embedded"] = False + return kwargs + + def get_form_class(self): + return modelform_factory(type(self.get_object()), form=RelationForm, exclude=[]) + + def get_success_url(self): + return reverse( + "apis:relationupdate", + args=[ + self.get_object().id, + ], + ) + + +class RelationDelete(DeleteView): + template_name = "confirm_delete.html" + + def delete(self, request, *args, **kwargs): + res = super().delete(request, args, kwargs) + if "status_only" in self.request.GET: + return HttpResponse() + return res + + def get_object(self): + return Relation.objects.get_subclass(id=self.kwargs["pk"]) + + def get_success_url(self): + if self.request.GET.get("next"): + return self.request.GET.get("next") + contenttype = ContentType.objects.get_for_model(self.get_object()) + return reverse( + "apis:relation", + args=[ + contenttype.id, + ], + ) diff --git a/apis_core/urls.py b/apis_core/urls.py index 94d7ed6af..7d109e5cd 100644 --- a/apis_core/urls.py +++ b/apis_core/urls.py @@ -91,6 +91,11 @@ path("api/dumpdata", Dumpdata.as_view()), path("", include("apis_core.generic.urls", namespace="generic")), ] + +from apis_core.relations.urls import urlpatterns as relurlpatterns + +urlpatterns = urlpatterns + relurlpatterns + if "apis_core.history" in settings.INSTALLED_APPS: urlpatterns.append( path("history/", include("apis_core.history.urls", namespace="history"))