554 lines
17 KiB
Python
554 lines
17 KiB
Python
from django.db import models
|
|
from django.contrib.auth.models import User
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.utils import timezone as tz
|
|
from django.utils.text import slugify
|
|
from django.utils.translation import ugettext as _, ugettext_lazy
|
|
|
|
from django.db.models.signals import Signal, post_save, pre_save
|
|
from django.dispatch import receiver
|
|
|
|
import bleach
|
|
from taggit.managers import TaggableManager
|
|
|
|
from aircox.cms import routes
|
|
from aircox.cms import settings
|
|
|
|
|
|
class Routable:
|
|
@classmethod
|
|
def get_siblings(cl, thread = None, queryset = None,
|
|
thread_model = None, thread_id = None):
|
|
"""
|
|
Return posts of the cl's type that are children of the given thread.
|
|
"""
|
|
if not queryset:
|
|
queryset = cl.objects
|
|
|
|
if thread:
|
|
thread_model = type(thread)
|
|
thread_id = thread.id
|
|
|
|
thread_model = ContentType.objects.get_for_model(thread_model)
|
|
return queryset.filter(
|
|
thread_id = thread_id,
|
|
thread_type__pk = thread_model.id
|
|
)
|
|
|
|
@classmethod
|
|
def reverse(cl, route, use_default = True, **kwargs):
|
|
"""
|
|
Reverse a url using a given route for the model - simple wrapper
|
|
around cl._website.reverse
|
|
"""
|
|
return cl._website.reverse(cl, route, use_default, **kwargs)
|
|
|
|
|
|
class Comment(models.Model, Routable):
|
|
thread_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.SET_NULL,
|
|
blank = True, null = True
|
|
)
|
|
thread_id = models.PositiveIntegerField(
|
|
blank = True, null = True
|
|
)
|
|
thread = GenericForeignKey('thread_type', 'thread_id')
|
|
|
|
published = models.BooleanField(
|
|
verbose_name = _('public'),
|
|
default = False
|
|
)
|
|
|
|
author = models.CharField(
|
|
verbose_name = _('author'),
|
|
max_length = 32,
|
|
)
|
|
email = models.EmailField(
|
|
verbose_name = _('email'),
|
|
blank = True, null = True,
|
|
)
|
|
url = models.URLField(
|
|
verbose_name = _('website'),
|
|
blank = True, null = True,
|
|
)
|
|
date = models.DateTimeField(
|
|
_('date'),
|
|
auto_now_add = True,
|
|
)
|
|
content = models.TextField (
|
|
_('comment'),
|
|
)
|
|
|
|
def make_safe(self):
|
|
self.author = bleach.clean(self.author, tags=[])
|
|
if self.email:
|
|
self.email = bleach.clean(self.email, tags=[])
|
|
if self.url:
|
|
self.url = bleach.clean(self.url, tags=[])
|
|
self.content = bleach.clean(
|
|
self.content,
|
|
tags=settings.AIRCOX_CMS_BLEACH_COMMENT_TAGS,
|
|
attributes=settings.AIRCOX_CMS_BLEACH_COMMENT_ATTRS
|
|
)
|
|
|
|
def save(self, make_safe = True, *args, **kwargs):
|
|
if make_safe:
|
|
self.make_safe()
|
|
return super().save(*args, **kwargs)
|
|
|
|
|
|
class Post (models.Model, Routable):
|
|
"""
|
|
Base model that can be used as is if wanted. Represent a generic
|
|
publication on the website.
|
|
|
|
You can declare an extra property "info" that can be used to append
|
|
info in lists rendering.
|
|
"""
|
|
# used for inherited children
|
|
real_type = models.CharField(
|
|
max_length=32,
|
|
blank = True, null = True,
|
|
)
|
|
|
|
# metadata
|
|
# FIXME: on_delete
|
|
thread_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.SET_NULL,
|
|
related_name = 'thread_type',
|
|
blank = True, null = True
|
|
)
|
|
thread_id = models.PositiveIntegerField(
|
|
blank = True, null = True
|
|
)
|
|
thread = GenericForeignKey('thread_type', 'thread_id')
|
|
|
|
published = models.BooleanField(
|
|
verbose_name = _('public'),
|
|
default = True
|
|
)
|
|
allow_comments = models.BooleanField(
|
|
verbose_name = _('allow comments'),
|
|
default = True,
|
|
)
|
|
|
|
# content
|
|
author = models.ForeignKey(
|
|
User,
|
|
verbose_name = _('author'),
|
|
blank = True, null = True,
|
|
)
|
|
date = models.DateTimeField(
|
|
_('date'),
|
|
default = tz.datetime.now
|
|
)
|
|
title = models.CharField (
|
|
_('title'),
|
|
max_length = 128,
|
|
)
|
|
content = models.TextField (
|
|
_('description'),
|
|
default = '',
|
|
blank = True, null = True,
|
|
)
|
|
image = models.ImageField(
|
|
blank = True, null = True,
|
|
)
|
|
tags = TaggableManager(
|
|
verbose_name = _('tags'),
|
|
blank = True,
|
|
)
|
|
|
|
info = ''
|
|
"""
|
|
Used to be extended: used in template to render contextual information about
|
|
a sub-post item.
|
|
"""
|
|
search_fields = [ 'title', 'content', 'tags__name' ]
|
|
"""
|
|
Fields on which routes.SearchRoute must run the search
|
|
"""
|
|
actions = None
|
|
"""
|
|
Actions are a list of actions available to the end user for this model.
|
|
See aircox.cms.actions for more information
|
|
"""
|
|
|
|
def get_comments(self):
|
|
"""
|
|
Return comments pointing to this post
|
|
"""
|
|
type = ContentType.objects.get_for_model(self)
|
|
qs = Comment.objects.filter(
|
|
thread_id = self.pk,
|
|
thread_type__pk = type.pk
|
|
)
|
|
return qs
|
|
|
|
def url(self):
|
|
"""
|
|
Return an url to the post detail view.
|
|
"""
|
|
return self.reverse(
|
|
routes.DetailRoute,
|
|
pk = self.pk, slug = slugify(self.title)
|
|
)
|
|
|
|
def fill_empty(self):
|
|
"""
|
|
Fill empty values using parent thread. Can be used before saving or
|
|
at loading
|
|
"""
|
|
if not self.thread:
|
|
return
|
|
|
|
if not self.title:
|
|
self.title = _('{name} // {date}').format(
|
|
name = self.thread.title,
|
|
date = self.date.strftime('%d %B %Y')
|
|
)
|
|
if not self.content:
|
|
self.content = self.thread.content
|
|
if not self.image:
|
|
self.image = self.thread.image
|
|
if self.pk and not self.tags:
|
|
self.tags = self.thread.tags
|
|
|
|
def get_object_list(self, request, object, **kwargs):
|
|
# FIXME: wtf
|
|
type = ContentType.objects.get_for_model(object)
|
|
qs = Comment.objects.filter(
|
|
thread_id = object.pk,
|
|
thread_type__pk = type.pk
|
|
)
|
|
return qs
|
|
|
|
def make_safe(self):
|
|
"""
|
|
Ensure that data of the publication are safe from code injection.
|
|
"""
|
|
self.title = bleach.clean(
|
|
self.title,
|
|
tags=settings.AIRCOX_CMS_BLEACH_TITLE_TAGS,
|
|
attributes=settings.AIRCOX_CMS_BLEACH_TITLE_ATTRS
|
|
)
|
|
self.content = bleach.clean(
|
|
self.content,
|
|
tags=settings.AIRCOX_CMS_BLEACH_CONTENT_TAGS,
|
|
attributes=settings.AIRCOX_CMS_BLEACH_CONTENT_ATTRS
|
|
)
|
|
if self.pk:
|
|
self.tags.set(*[
|
|
bleach.clean(tag, tags=[])
|
|
for tag in self.tags.all()
|
|
])
|
|
|
|
def downcast(self):
|
|
"""
|
|
Return a downcasted version of the post if it is from another
|
|
model, or itself
|
|
"""
|
|
if not self.real_type or type(self) != Post:
|
|
return self
|
|
return getattr(self, self.real_type)
|
|
|
|
def save(self, make_safe = True, *args, **kwargs):
|
|
if type(self) != Post and not self.real_type:
|
|
self.real_type = type(self).__name__.lower()
|
|
if self.date and tz.is_naive(self.date):
|
|
self.date = tz.make_aware(self.date)
|
|
if make_safe:
|
|
self.make_safe()
|
|
return super().save(*args, **kwargs)
|
|
|
|
|
|
class RelatedMeta (models.base.ModelBase):
|
|
"""
|
|
Metaclass for RelatedPost children.
|
|
"""
|
|
registry = {}
|
|
|
|
@classmethod
|
|
def register (cl, key, post_model):
|
|
"""
|
|
Register a model and return the key under which it is registered.
|
|
Raise a ValueError if another model is yet associated under this key.
|
|
"""
|
|
if key in cl.registry and cl.registry[key] is not post_model:
|
|
raise ValueError('A model has yet been registered with "{}"'
|
|
.format(key))
|
|
cl.registry[key] = post_model
|
|
return key
|
|
|
|
@classmethod
|
|
def make_relation(cl, name, attrs):
|
|
"""
|
|
Make instance of RelatedPost.Relation
|
|
"""
|
|
rel = RelatedPost.Relation()
|
|
if 'Relation' not in attrs:
|
|
raise ValueError('RelatedPost item has not defined Relation class')
|
|
rel.__dict__.update(attrs['Relation'].__dict__)
|
|
|
|
if not rel.model or not issubclass(rel.model, models.Model):
|
|
raise ValueError('Relation.model is not a django model (None?)')
|
|
|
|
if not rel.bindings:
|
|
rel.bindings = {}
|
|
|
|
# thread model
|
|
if rel.bindings.get('thread'):
|
|
rel.thread_model = rel.bindings.get('thread')
|
|
rel.thread_model = rel.model._meta.get_field(rel.thread_model). \
|
|
rel.to
|
|
rel.thread_model = cl.registry.get(rel.thread_model)
|
|
|
|
if not rel.thread_model:
|
|
raise ValueError(
|
|
'no registered RelatedPost for the bound thread. Is there '
|
|
' a RelatedPost for {} declared before {}?'
|
|
.format(rel.bindings.get('thread').__class__.__name__,
|
|
name)
|
|
)
|
|
|
|
return rel
|
|
|
|
@classmethod
|
|
def make_auto_create(cl, model):
|
|
"""
|
|
Enable auto_create on the given RelatedPost model if it is available.
|
|
"""
|
|
if not model._relation.rel_to_post:
|
|
return
|
|
|
|
def handler_rel(sender, instance, created, *args, **kwargs):
|
|
"""
|
|
handler for the related object
|
|
"""
|
|
rel = model._relation
|
|
# TODO: make the check happen by overriding inline save function
|
|
# this check is done in order to allow creation of multiple
|
|
# models when using admin.inlines: related is saved before
|
|
# the post, so no post is found, then create an extra post
|
|
if hasattr(instance, '__cms_post'):
|
|
return
|
|
|
|
post = model.objects.filter(related = instance)
|
|
if post.count():
|
|
post = post[0]
|
|
elif rel.auto_create(instance) if callable(rel.auto_create) else \
|
|
rel.auto_create:
|
|
post = model(related = instance)
|
|
# TODO: hackish way: model.objects.filter(related=null,...).delete()
|
|
else:
|
|
return
|
|
post.rel_to_post()
|
|
post.fill_empty()
|
|
post.save(avoid_sync = True)
|
|
post_save.connect(handler_rel, model._relation.model, False)
|
|
|
|
def __new__ (cl, name, bases, attrs):
|
|
# TODO: check bindings
|
|
if name == 'RelatedPost':
|
|
return super().__new__(cl, name, bases, attrs)
|
|
|
|
rel = cl.make_relation(name, attrs)
|
|
field_args = rel.field_args or {}
|
|
attrs['_relation'] = rel
|
|
attrs.update({ x:y for x,y in {
|
|
'related': models.ForeignKey(rel.model, **field_args),
|
|
'__str__': lambda self: str(self.related)
|
|
}.items() if not attrs.get(x) })
|
|
|
|
model = super().__new__(cl, name, bases, attrs)
|
|
cl.register(rel.model, model)
|
|
|
|
# auto create and/or update
|
|
cl.make_auto_create(model)
|
|
|
|
# name clashes
|
|
name = rel.model._meta.object_name
|
|
if name == model._meta.object_name:
|
|
model._meta.default_related_name = '{} Post'.format(name)
|
|
return model
|
|
|
|
|
|
class RelatedPost (Post, metaclass = RelatedMeta):
|
|
"""
|
|
Post linked to an object of other model. This object is accessible through
|
|
the field "related".
|
|
|
|
It is possible to map attributes of the Post to the ones of the Related
|
|
Object. It is also possible to automatically update Post's thread based
|
|
on the Related Object's parent if it is required (but not Related Object's
|
|
parent based on Post's thread).
|
|
|
|
Bindings can ensure that the Related Object will be updated when mapped
|
|
fields of the Post are updated.
|
|
|
|
To configure the Related Post, you just need to create set attributes of
|
|
the Relation sub-class.
|
|
|
|
```
|
|
class MyModelPost(RelatedPost):
|
|
class Relation:
|
|
model = MyModel
|
|
bindings = {
|
|
'thread': 'parent_field_name',
|
|
'title': 'name'
|
|
}
|
|
```
|
|
"""
|
|
related = None
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
# FIXME: declare a binding only for init
|
|
class Relation:
|
|
"""
|
|
Relation descriptor used to generate and manage the related object.
|
|
|
|
Be careful with post_to_rel!
|
|
* There is no check of permissions when related object is synchronised
|
|
from the post, so be careful when enabling post_to_rel.
|
|
* In post_to_rel synchronisation, if the parent thread is not a
|
|
(sub-)class thread_model, the related parent is set to None
|
|
"""
|
|
model = None
|
|
"""
|
|
model of the related object
|
|
"""
|
|
bindings = None
|
|
"""
|
|
dict of `post_attr: rel_attr` that represent bindings of values
|
|
between the post and the related object. Fields are updated according
|
|
to `post_to_rel` and `rel_to_post`.
|
|
|
|
If there is a post_attr "thread", the corresponding rel_attr is used
|
|
to update the post thread to the correct Post model (in order to
|
|
establish a parent-child relation between two models)
|
|
|
|
When a callable is set as `rel_attr`, it will be called to retrieve
|
|
the value, as `rel_attr(post, related)`
|
|
|
|
note: bound values can be any value, not only Django field.
|
|
"""
|
|
defaults = None
|
|
"""
|
|
dict of `post_attr: value` that gives default value for the given
|
|
fields.
|
|
"""
|
|
post_to_rel = False
|
|
"""
|
|
update related object when the post is saved, using bindings
|
|
"""
|
|
rel_to_post = False
|
|
"""
|
|
update the post when related object is updated, using bindings
|
|
"""
|
|
thread_model = None
|
|
"""
|
|
generated by the metaclass, points to the RelatedPost model
|
|
generated for the bindings.thread object.
|
|
"""
|
|
field_args = None
|
|
"""
|
|
dict of arguments to pass to the ForeignKey constructor, such as
|
|
`ForeignKey(related_model, **field_args)`
|
|
"""
|
|
auto_create = False
|
|
"""
|
|
automatically create a RelatedPost for each new item of the related
|
|
object and init it with bounded values. Use 'post_save' signal. If
|
|
auto_create is callable, use `auto_create(related_object)`.
|
|
"""
|
|
|
|
def get_rel_attr(self, attr):
|
|
attr = self._relation.bindings.get(attr)
|
|
if callable(attr):
|
|
return attr(self, self.related)
|
|
return getattr(self.related, attr) if attr else None
|
|
|
|
def set_rel_attr(self, attr, value):
|
|
if attr not in self._relation.bindings:
|
|
raise AttributeError('attribute {} is not bound'.format(attr))
|
|
attr = self._relation.bindings.get(attr)
|
|
setattr(self.related, attr, value)
|
|
|
|
def post_to_rel(self, save = True):
|
|
"""
|
|
Change related object using post bound values. Save the related
|
|
object if save = True.
|
|
Note: does not check if Relation.post_to_rel is True
|
|
"""
|
|
rel = self._relation
|
|
if not self.related or not rel.bindings:
|
|
return
|
|
|
|
for attr, rel_attr in rel.bindings.items():
|
|
if attr == 'thread':
|
|
continue
|
|
value = getattr(self, attr) if hasattr(self, attr) else None
|
|
setattr(self.related, rel_attr, value)
|
|
|
|
if rel.thread_model:
|
|
thread = self.thread if not issubclass(thread, rel.thread_model) \
|
|
else None
|
|
self.set_rel_attr('thread', thread.related)
|
|
|
|
if save:
|
|
self.related.save()
|
|
|
|
def rel_to_post(self, save = True):
|
|
"""
|
|
Change the post using the related object bound values. Save the
|
|
post if save = True.
|
|
Note: does not check if Relation.post_to_rel is True
|
|
"""
|
|
rel = self._relation
|
|
if not self.related or not rel.bindings:
|
|
return
|
|
|
|
has_changed = False
|
|
def set_attr(attr, value):
|
|
if getattr(self, attr) != value:
|
|
has_changed = True
|
|
setattr(self, attr, value)
|
|
|
|
for attr, rel_attr in rel.bindings.items():
|
|
if attr == 'thread':
|
|
continue
|
|
value = rel_attr(self, self.related) if callable(rel_attr) else \
|
|
getattr(self.related, rel_attr)
|
|
if type(value) == tz.datetime and tz.is_naive(value):
|
|
value = tz.make_aware(value)
|
|
set_attr(attr, value)
|
|
|
|
if rel.thread_model:
|
|
thread = self.get_rel_attr('thread')
|
|
thread = rel.thread_model.objects.filter(related = thread) \
|
|
if thread else None
|
|
thread = thread[0] if thread else None
|
|
set_attr('thread', thread)
|
|
|
|
if has_changed and save:
|
|
self.save()
|
|
|
|
def save (self, avoid_sync = False, save = True, *args, **kwargs):
|
|
"""
|
|
* avoid_sync: do not synchronise the post/related object;
|
|
* save: if False, does not call parent save functions
|
|
"""
|
|
if not avoid_sync:
|
|
if not self.pk and self._relation.rel_to_post:
|
|
self.rel_to_post(False)
|
|
if self._relation.post_to_rel:
|
|
self.post_to_rel(True)
|
|
if save:
|
|
super().save(*args, **kwargs)
|
|
|