-
Nik | Klampfradler authoredNik | Klampfradler authored
mixins.py 7.41 KiB
from datetime import datetime
from typing import Any, Callable, Optional, Union
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.managers import CurrentSiteManager
from django.contrib.sites.models import Site
from django.db import models
from django.db.models import QuerySet
from django.forms.models import ModelFormMetaclass, ModelForm
from easyaudit.models import CRUDEvent
from guardian.admin import GuardedModelAdmin
from jsonstore.fields import JSONField, JSONFieldMixin
from material.base import LayoutNode, Layout
import reversion
from rules.contrib.admin import ObjectPermissionsModelAdmin
class CRUDMixin(models.Model):
class Meta:
abstract = True
@property
def crud_events(self) -> QuerySet:
"""Get all CRUD events connected to this object from easyaudit."""
content_type = ContentType.objects.get_for_model(self)
return CRUDEvent.objects.filter(
object_id=self.pk, content_type=content_type
).select_related("user")
@reversion.register()
class ExtensibleModel(CRUDMixin):
""" Base model for all objects in AlekSIS apps
This base model ensures all objects in AlekSIS apps fulfill the
following properties:
* crud_events property to retrieve easyaudit's CRUD event log
* created_at and updated_at properties based n CRUD events
* Allow injection of fields and code from AlekSIS apps to extend
model functionality.
Injection of fields and code
============================
After all apps have been loaded, the code in the `model_extensions` module
in every app is executed. All code that shall be injected into a model goes there.
:Example:
.. code-block:: python
from datetime import date, timedelta
from jsonstore import CharField
from aleksis.core.models import Person
@Person.property
def is_cool(self) -> bool:
return True
@Person.property
def age(self) -> timedelta:
return self.date_of_birth - date.today()
Person.field(shirt_size=CharField())
For a more advanced example, using features from the ORM, see AlekSIS-App-Chronos
and AlekSIS-App-Alsijil.
:Date: 2019-11-07
:Authors:
- Dominik George <dominik.george@teckids.org>
"""
# Defines a material design icon associated with this type of model
icon_ = "radio_button_unchecked"
site = models.ForeignKey(Site, on_delete=models.CASCADE)
objects = CurrentSiteManager()
objects_all_sites = models.Manager()
def get_absolute_url(self) -> str:
""" Get the URL o a view representing this model instance """
pass
@property
def crud_event_create(self) -> Optional[CRUDEvent]:
""" Return create event of this object """
return self.crud_events.filter(event_type=CRUDEvent.CREATE).latest("datetime")
@property
def crud_event_update(self) -> Optional[CRUDEvent]:
""" Return last event of this object """
return self.crud_events.latest("datetime")
@property
def created_at(self) -> Optional[datetime]:
""" Determine creation timestamp from CRUD log """
if self.crud_event_create:
return self.crud_event_create.datetime
@property
def updated_at(self) -> Optional[datetime]:
""" Determine last timestamp from CRUD log """
if self.crud_event_update:
return self.crud_event_update.datetime
extended_data = JSONField(default=dict, editable=False)
@property
def created_by(self) -> Optional[models.Model]:
""" Determine user who created this object from CRUD log """
if self.crud_event_create:
return self.crud_event_create.user
@property
def updated_by(self) -> Optional[models.Model]:
""" Determine user who last updated this object from CRUD log """
if self.crud_event_update:
return self.crud_event_update.user
extended_data = JSONField(default=dict, editable=False)
@classmethod
def _safe_add(cls, obj: Any, name: Optional[str]) -> None:
# Decide the name for the attribute
if name is None:
prop_name = obj.__name__
else:
if name.isidentifier():
prop_name = name
else:
raise ValueError("%s is not a valid name." % name)
# Verify that attribute name does not clash with other names in the class
if hasattr(cls, prop_name):
raise ValueError("%s already used." % prop_name)
# Let Django's model magic add the attribute if we got here
cls.add_to_class(name, obj)
@classmethod
def property(cls, func: Callable[[], Any], name: Optional[str] = None) -> None:
""" Adds the passed callable as a property. """
cls._safe_add(property(func), func.__name__)
@classmethod
def method(cls, func: Callable[[], Any], name: Optional[str] = None) -> None:
""" Adds the passed callable as a method. """
cls._safe_add(func, func.__name__)
@classmethod
def class_method(cls, func: Callable[[], Any], name: Optional[str] = None) -> None:
""" Adds the passed callable as a classmethod. """
cls._safe_add(classmethod(func), func.__name__)
@classmethod
def field(cls, **kwargs) -> None:
""" Adds the passed jsonstore field. Must be one of the fields in
django-jsonstore.
Accepts exactly one keyword argument, with the name being the desired
model field name and the value the field instance.
"""
# Force kwargs to be exactly one argument
if len(kwargs) != 1:
raise TypeError("field() takes 1 keyword argument but %d were given" % len(kwargs))
name, field = kwargs.popitem()
# Force the field to be one of the jsonstore fields
if JSONFieldMixin not in field.__class__.__mro__:
raise TypeError("Only jsonstore fields can be added to models.")
# Force use of the one JSONField defined in this mixin
field.json_field_name = "extended_data"
cls._safe_add(field, name)
class Meta:
abstract = True
class PureDjangoModel(object):
""" No-op mixin to mark a model as deliberately not using ExtensibleModel """
pass
class _ExtensibleFormMetaclass(ModelFormMetaclass):
def __new__(mcs, name, bases, dct):
x = super().__new__(mcs, name, bases, dct)
if hasattr(x, "layout"):
base_layout = x.layout.elements
else:
base_layout = []
x.base_layout = base_layout
x.layout = Layout(*base_layout)
return x
class ExtensibleForm(ModelForm, metaclass=_ExtensibleFormMetaclass):
""" Base model for extensible forms
This mixin adds functionality which allows
- apps to add layout nodes to the layout used by django-material
Add layout nodes
================
```
from material import Fieldset
from aleksis.core.forms import ExampleForm
node = Fieldset("field_name")
ExampleForm.add_node_to_layout(node)
```
"""
@classmethod
def add_node_to_layout(cls, node: Union[LayoutNode, str]):
"""
Add a node to `layout` attribute
:param node: django-material layout node (Fieldset, Row etc.)
:type node: LayoutNode
"""
cls.base_layout.append(node)
cls.layout = Layout(*cls.base_layout)
class BaseModelAdmin(GuardedModelAdmin, ObjectPermissionsModelAdmin):
pass