Skip to content

Instantly share code, notes, and snippets.

@nrbnlulu
Created February 25, 2024 18:31
Show Gist options
  • Save nrbnlulu/623628a65f3b98a3b415d51999a41480 to your computer and use it in GitHub Desktop.
Save nrbnlulu/623628a65f3b98a3b415d51999a41480 to your computer and use it in GitHub Desktop.
Django Generic Relation "Table-per-relation"
from __future__ import annotations
import contextlib
import dataclasses
import enum
from importlib import import_module
from typing import TYPE_CHECKING, Generic, TypeVar, get_type_hints
from django.db import models
from django.utils.translation import gettext_lazy as _
Container = TypeVar("Container", bound=models.Model)
GenericModel = TypeVar("GenericModel", bound=models.Model)
if TYPE_CHECKING:
class _GeneratedModelBase(models.Model, Generic[Container]):
objects: models.Manager[Container] = models.Manager() # type: ignore
related: Container
__related_model__: type[Container]
@dataclasses.dataclass(slots=True, kw_only=True)
class ModelGenerator(Generic[Container, GenericModel]):
generic_model: type[GenericModel] | None = None
models_to_inject: dict[str, type[Container]] = dataclasses.field(default_factory=dict)
generated_models: dict[str, type[_GeneratedModelBase[Container]]] = dataclasses.field(
default_factory=dict,
)
def add(self, t: type[Container]) -> type[Container]:
self.models_to_inject[t.__name__] = t
return t
def get_for_model(self, model: type[Container]) -> type[_GeneratedModelBase[Container]]:
for generated in self.generated_models.values():
if generated.__related_model__ is model: # type: ignore
return generated
msg = f"generic model: {self.generic_model} was not found for model: {model}"
raise KeyError(msg)
def assign_generic(self, t: type[GenericModel]) -> type[_GeneratedModelBase[Container]]:
assert t._meta.abstract
self.generic_model = t
return t # type: ignore
def generate(self) -> None:
"""Call this at the end of models.py to generate models."""
assert self.generic_model
for model_to_inject in self.models_to_inject.values():
cls_name = model_to_inject.__name__ + self.generic_model.__name__
related_name: str | None = None
for name, annot in get_type_hints(model_to_inject).items():
if annot is self.generic_model:
related_name = name
assert (
related_name
), "the generic model was not type hinted at the injected model or in one of it bases"
class Meta:
verbose_name = _(cls_name + f" {related_name}")
verbose_name_plural = _(cls_name + f" {related_name}s")
new_model = type(
cls_name,
(self.generic_model,),
{
"related": models.ForeignKey(
model_to_inject,
on_delete=models.CASCADE,
related_name=related_name,
),
"__related_model__": model_to_inject,
"Meta": Meta,
"__module__": model_to_inject.__module__,
},
)
self.generated_models[cls_name] = new_model # type: ignore
module = import_module(self.generic_model.__module__)
module.__dict__.update(self.generated_models)
class GenericTableGeneratorEnumMeta(Generic[Container], enum.EnumMeta):
def __new__(metacls, klass_name, bases, ns, **kwargs): # noqa: ANN003, ANN001, ANN204
if klass_name == "GenericTableGeneratorEnum":
return super().__new__(metacls, klass_name, bases, ns, **kwargs)
enumerations = {member: value for member, value in ns.items() if not member.startswith("_")}
model_map = {}
ns["__model_map__"] = model_map
for name, model in enumerations.items():
assert issubclass(model, models.Model)
assert name == model.__name__
model_map[name] = model
enumerations[name] = name
with contextlib.suppress(TypeError):
# django imported this module twice?
ns.update(enumerations)
return super().__new__(metacls, klass_name, bases, ns, **kwargs)
class GenericTableGeneratorEnum(
Generic[Container, GenericModel],
enum.Enum,
metaclass=GenericTableGeneratorEnumMeta,
):
__model_map__: dict[str, type[_GeneratedModelBase[Container]]]
def get_klass(self) -> type[_GeneratedModelBase[Container]]:
return self.__model_map__[self.name]
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
import attrs
from attr import define
import factory.django
if TYPE_CHECKING:
from django.db import models
from djtools.model_generator import ModelGenerator, _GeneratedModelBase
T = TypeVar("T", bound=factory.django.DjangoModelFactory)
@define
class ModelGeneratorTester:
generator: ModelGenerator
factories_by_related_model: dict[models.Model, factory.django.DjangoModelFactory]
factories_by_generated_model: dict[type[_GeneratedModelBase], factory.django.DjangoModelFactory]
factories_by_related_factory: dict[
factory.django.DjangoModelFactory,
factory.django.DjangoModelFactory,
] = attrs.Factory(dict)
"""hash of related factory with its generated factory."""
@classmethod
def from_model_generator(
cls,
generator: ModelGenerator,
generic_factory: type[factory.django.DjangoModelFactory],
) -> ModelGeneratorTester:
factory = None
generated_map: dict[type[_GeneratedModelBase], factory.django.DjangoModelFactory] = {}
related_map: dict[models.Model, factory.django.DjangoModelFactory] = {}
for generated in generator.generated_models.values():
class Meta:
model = generated._meta.model
factory = type(generated.__name__ + "Factory", (generic_factory,), {"Meta": Meta})
generated_map[generated._meta.model] = factory
related_map[generated.__related_model__] = factory
return cls(
factories_by_generated_model=generated_map,
factories_by_related_model=related_map,
generator=generator,
)
def add(self, related_factory: type[T]) -> type[T]:
match: factory.django.DjangoModelFactory | None = None
for generated_model, generated_factory in self.factories_by_generated_model.items():
if generated_model.__related_model__ is related_factory._meta.model:
match = generated_factory
assert match, f"could not find corresponding model for {related_factory}"
self.factories_by_related_factory[related_factory] = match
return related_factory
@nrbnlulu
Copy link
Author

This won't work for you out of the box and the typing are not 100% correct though this proves a concept that you don't need
Django's GFK framework in order to create generic relations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment