import re import bleach from django.db import models from django.urls import reverse from django.utils import timezone as tz from django.utils.functional import cached_property from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from filer.fields.image import FilerImageField from model_utils.managers import InheritanceQuerySet from ..conf import settings from .station import Station __all__ = ( "Renderable", "Category", "PageQuerySet", "Page", "StaticPage", "Comment", "NavItem", ) headline_clean_re = re.compile(r"\n(\s| )+", re.MULTILINE) headline_re = re.compile(r"(?P([\S+]|\s+){1,240}\S+)", re.MULTILINE) class Renderable: template_prefix = "page" template_name = "aircox/widgets/{prefix}.html" def get_template_name(self, widget): """Return template name for the provided widget.""" return self.template_name.format(prefix=self.template_prefix, widget=widget) class Category(models.Model): title = models.CharField(_("title"), max_length=64) slug = models.SlugField(_("slug"), max_length=64, db_index=True) class Meta: verbose_name = _("Category") verbose_name_plural = _("Categories") def __str__(self): return self.title class BasePageQuerySet(InheritanceQuerySet): def draft(self): return self.filter(status=Page.STATUS_DRAFT) def published(self): return self.filter(status=Page.STATUS_PUBLISHED) def trash(self): return self.filter(status=Page.STATUS_TRASH) def by_last(self): return self.order_by("-pub_date") def parent(self, parent=None, id=None): """Return pages having this parent.""" return self.filter(parent=parent) if id is None else self.filter(parent__id=id) def search(self, q, search_content=True): if search_content: return self.filter(models.Q(title__icontains=q) | models.Q(content__icontains=q)) return self.filter(title__icontains=q) class BasePage(Renderable, models.Model): """Base class for publishable content.""" STATUS_DRAFT = 0x00 STATUS_PUBLISHED = 0x10 STATUS_TRASH = 0x20 STATUS_CHOICES = ( (STATUS_DRAFT, _("draft")), (STATUS_PUBLISHED, _("published")), (STATUS_TRASH, _("trash")), ) title = models.CharField(max_length=100) slug = models.SlugField(_("slug"), max_length=120, blank=True, unique=True, db_index=True) status = models.PositiveSmallIntegerField( _("status"), default=STATUS_DRAFT, choices=STATUS_CHOICES, ) cover = FilerImageField( on_delete=models.SET_NULL, verbose_name=_("cover"), null=True, blank=True, ) content = models.TextField(_("content"), blank=True, null=True) objects = BasePageQuerySet.as_manager() detail_url_name = None class Meta: abstract = True @property def cover_url(self): return self.cover_id and self.cover.url def __str__(self): return "{}".format(self.title or self.pk) def save(self, *args, **kwargs): if self.content: self.content = bleach.clean( self.content, tags=settings.ALLOWED_TAGS, attributes=settings.ALLOWED_ATTRIBUTES, protocols=settings.ALLOWED_PROTOCOLS, ) if not self.slug: self.slug = slugify(self.title)[:100] count = Page.objects.filter(slug__startswith=self.slug).count() if count: self.slug += "-" + str(count) super().save(*args, **kwargs) def get_absolute_url(self): if self.is_published: return reverse(self.detail_url_name, kwargs={"slug": self.slug}) return "" @property def is_draft(self): return self.status == self.STATUS_DRAFT @property def is_published(self): return self.status == self.STATUS_PUBLISHED @property def is_trash(self): return self.status == self.STATUS_TRASH @property def display_title(self): return self.is_published and self.title or "" @cached_property def display_headline(self): content = bleach.clean(self.content, tags=[], strip=True) content = headline_clean_re.sub("\n", content) if content.startswith("\n"): content = content[1:] headline = headline_re.search(content) if not headline: return "" headline = headline.groupdict()["headline"] suffix = "..." if len(headline) < len(content) else "" headline = headline.split("\n")[:3] headline[-1] += suffix return mark_safe(" ".join(headline)) @classmethod def get_init_kwargs_from(cls, page, **kwargs): kwargs.setdefault("cover", page.cover) kwargs.setdefault("category", page.category) return kwargs @classmethod def from_page(cls, page, **kwargs): return cls(**cls.get_init_kwargs_from(page, **kwargs)) # FIXME: rename class PageQuerySet(BasePageQuerySet): def published(self): return self.filter(status=Page.STATUS_PUBLISHED, pub_date__lte=tz.now()) class Page(BasePage): """Base Page model used for articles and other dated content.""" category = models.ForeignKey( Category, models.SET_NULL, verbose_name=_("category"), blank=True, null=True, db_index=True, ) pub_date = models.DateTimeField(_("publication date"), blank=True, null=True, db_index=True) featured = models.BooleanField( _("featured"), default=False, ) allow_comments = models.BooleanField( _("allow comments"), default=True, ) objects = PageQuerySet.as_manager() detail_url_name = "" list_url_name = "page-list" edit_url_name = "" @classmethod def get_list_url(cls, kwargs={}): return reverse(cls.list_url_name, kwargs=kwargs) class Meta: verbose_name = _("Publication") verbose_name_plural = _("Publications") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__initial_cover = self.cover def save(self, *args, **kwargs): if self.is_published and self.pub_date is None: self.pub_date = tz.now() elif not self.is_published: self.pub_date = None super().save(*args, **kwargs) class ChildPage(Page): parent = models.ForeignKey(Page, models.CASCADE, blank=True, null=True, db_index=True, related_name="%(class)s_set") class Meta: abstract = True @property def display_title(self): if self.is_published: return self.title return self.parent and self.parent.title or "" @property def display_headline(self): if not self.content or not self.is_published: return self.parent and self.parent.display_headline or "" return super().display_headline @cached_property def parent_subclass(self): if self.parent_id: return Page.objects.get_subclass(id=self.parent_id) return None def get_absolute_url(self): if not self.is_published and self.parent_subclass: return self.parent_subclass.get_absolute_url() return super().get_absolute_url() def save(self, *args, **kwargs): if self.parent: if self.parent == self: self.parent = None if not self.cover: self.cover = self.parent.cover if not self.category: self.category = self.parent.category super().save(*args, **kwargs) class StaticPage(BasePage): """Static page that eventually can be attached to a specific view.""" detail_url_name = "static-page-detail" class Target(models.TextChoices): NONE = "", _("None") HOME = "home", _("Home Page") TIMETABLE = "timetable-list", _("Timetable") PROGRAMS = "program-list", _("Programs list") EPISODES = "episode-list", _("Episodes list") ARTICLES = "article-list", _("Articles list") PAGES = "page-list", _("Publications list") PODCASTS = "podcast-list", _("Podcasts list") attach_to = models.CharField( _("attach to"), choices=Target.choices, max_length=32, blank=True, null=True, help_text=_("display this page content to related element"), ) def get_related_view(self): from ..views.page import attached_views return self.attach_to and attached_views.get(self.attach_to) or None def get_absolute_url(self): if self.attach_to: return reverse(self.attach_to) return super().get_absolute_url() class Comment(Renderable, models.Model): page = models.ForeignKey( Page, models.CASCADE, verbose_name=_("related page"), db_index=True, # TODO: allow_comment filter ) nickname = models.CharField(_("nickname"), max_length=32) email = models.EmailField(_("email"), max_length=32) date = models.DateTimeField(auto_now_add=True) content = models.TextField(_("content"), max_length=1024) template_prefix = "comment" @cached_property def parent(self): """Return Page as its subclass.""" return Page.objects.select_subclasses().filter(id=self.page_id).first() def get_absolute_url(self): return self.parent.get_absolute_url() + f"#{self._meta.label_lower}-{self.pk}" class Meta: verbose_name = _("Comment") verbose_name_plural = _("Comments") class NavItem(models.Model): """Navigation menu items.""" station = models.ForeignKey(Station, models.CASCADE, verbose_name=_("station")) menu = models.SlugField(_("menu"), max_length=24) order = models.PositiveSmallIntegerField(_("order")) text = models.CharField(_("title"), max_length=64, blank=True, null=True) url = models.CharField(_("url"), max_length=256, blank=True, null=True) page = models.ForeignKey( StaticPage, models.CASCADE, db_index=True, verbose_name=_("page"), blank=True, null=True, ) class Meta: verbose_name = _("Menu item") verbose_name_plural = _("Menu items") ordering = ("order", "pk") def get_url(self): return self.url if self.url else self.page.get_absolute_url() if self.page else None def get_label(self): if self.text: return self.text elif self.page: return self.page.title def render(self, request, css_class="", active_class=""): url = self.get_url() label = self.get_label() if active_class and request.path.startswith(url): css_class += " " + active_class if not url: return label elif not css_class: return format_html('{}', url, label) else: return format_html('{}', url, css_class, label)