This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
docs/src/backend/core/models.py

332 lines
10 KiB
Python
Raw Normal View History

"""
Declare and configure the models for the publish core application
"""
import textwrap
import uuid
from django.conf import settings
from django.contrib.auth import models as auth_models
from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.db import models
from django.template.base import Template as DjangoTemplate
from django.template.context import Context
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
import frontmatter
import markdown
from timezone_field import TimeZoneField
from weasyprint import CSS, HTML
from weasyprint.text.fonts import FontConfiguration
class RoleChoices(models.TextChoices):
"""Defines the possible roles a user can have in a template."""
MEMBER = "member", _("Member")
ADMIN = "administrator", _("Administrator")
OWNER = "owner", _("Owner")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
before saving as Django doesn't do it by default.
Includes fields common to all models: a UUID primary key and creation/update timestamps.
"""
id = models.UUIDField(
verbose_name=_("id"),
help_text=_("primary key for the record as UUID"),
primary_key=True,
default=uuid.uuid4,
editable=False,
)
created_at = models.DateTimeField(
verbose_name=_("created on"),
help_text=_("date and time at which a record was created"),
auto_now_add=True,
editable=False,
)
updated_at = models.DateTimeField(
verbose_name=_("updated on"),
help_text=_("date and time at which a record was last updated"),
auto_now=True,
editable=False,
)
class Meta:
abstract = True
def save(self, *args, **kwargs):
"""Call `full_clean` before saving."""
self.full_clean()
super().save(*args, **kwargs)
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField(
_("sub"),
help_text=_(
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
),
max_length=255,
unique=True,
validators=[sub_validator],
blank=True,
null=True,
)
email = models.EmailField(_("identity email address"), blank=True, null=True)
# Unlike the "email" field which stores the email coming from the OIDC token, this field
# stores the email used by staff users to login to the admin site
admin_email = models.EmailField(
_("admin email address"), unique=True, blank=True, null=True
)
language = models.CharField(
max_length=10,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
)
timezone = TimeZoneField(
choices_display="WITH_GMT_OFFSET",
use_pytz=False,
default=settings.TIME_ZONE,
help_text=_("The timezone in which the user wants to see times."),
)
is_device = models.BooleanField(
_("device"),
default=False,
help_text=_("Whether the user is a device or a real user."),
)
is_staff = models.BooleanField(
_("staff status"),
default=False,
help_text=_("Whether the user can log into this admin site."),
)
is_active = models.BooleanField(
_("active"),
default=True,
help_text=_(
"Whether this user should be treated as active. "
"Unselect this instead of deleting accounts."
),
)
objects = auth_models.UserManager()
USERNAME_FIELD = "admin_email"
REQUIRED_FIELDS = []
class Meta:
db_table = "publish_user"
verbose_name = _("user")
verbose_name_plural = _("users")
def __str__(self):
return self.email or self.admin_email or str(self.id)
def email_user(self, subject, message, from_email=None, **kwargs):
"""Email this user."""
if not self.email:
raise ValueError("User has no email address.")
mail.send_mail(subject, message, from_email, [self.email], **kwargs)
class Team(BaseModel):
"""Team used for role based access control when matched with templates in OIDC tokens."""
name = models.CharField(max_length=100, unique=True)
class Meta:
db_table = "publish_role"
ordering = ("name",)
verbose_name = _("Team")
verbose_name_plural = _("Teams")
def __str__(self):
return self.name
class Template(BaseModel):
"""HTML and CSS code used for formatting the print around the MarkDown body."""
title = models.CharField(_("title"), max_length=255)
description = models.TextField(_("description"), blank=True)
code = models.TextField(_("code"), blank=True)
css = models.TextField(_("css"), blank=True)
is_public = models.BooleanField(
_("public"),
default=False,
help_text=_("Whether this template is public for anyone to use."),
)
class Meta:
db_table = "publish_template"
ordering = ("title",)
verbose_name = _("Template")
verbose_name_plural = _("Templates")
def __str__(self):
return self.title
def generate_document(self, body):
"""
Generate and return a PDF document for this template around the
markdown body passed as argument.
"""
document = frontmatter.loads(body)
metadata = document.metadata
markdown_body = document.content.strip()
body_html = (
markdown.markdown(textwrap.dedent(markdown_body)) if markdown_body else ""
)
document_html = HTML(
string=DjangoTemplate(self.code).render(
Context({"body": body_html, **metadata})
)
)
css = CSS(
string=self.css,
font_config=FontConfiguration(),
)
return document_html.write_pdf(stylesheets=[css], zoom=1)
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the template.
"""
# Compute user role
role = None
if user.is_authenticated:
try:
role = self.user_role
except AttributeError:
try:
role = self.accesses.filter(user=user).values("role")[0]["role"]
except (TemplateAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
can_get = self.is_public or role is not None
return {
"destroy": role == RoleChoices.OWNER,
"generate_document": can_get,
"manage_accesses": is_owner_or_admin,
"update": is_owner_or_admin,
"retrieve": can_get,
}
class TemplateAccess(BaseModel):
"""Relation model to give access to a template for a user or a team with a role."""
template = models.ForeignKey(
Template,
on_delete=models.CASCADE,
related_name="accesses",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="accesses",
null=True,
blank=True,
)
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="accesses",
null=True,
blank=True,
)
role = models.CharField(
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
)
class Meta:
db_table = "publish_template_access"
verbose_name = _("Template/user relation")
verbose_name_plural = _("Template/user relations")
constraints = [
models.UniqueConstraint(
fields=["user", "template"],
name="unique_template_user",
violation_error_message=_("This user is already in this template."),
),
models.UniqueConstraint(
fields=["team", "template"],
name="unique_template_team",
violation_error_message=_("This team is already in this template."),
),
]
def __str__(self):
return f"{self.user!s} is {self.role:s} in template {self.template!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user taking into account
the current state of the object.
"""
is_template_owner_or_admin = False
role = None
if user.is_authenticated:
try:
role = self.user_role
except AttributeError:
try:
role = self._meta.model.objects.filter(
template=self.template_id,
user=user,
).values("role")[0]["role"]
except (self._meta.model.DoesNotExist, IndexError):
role = None
is_template_owner_or_admin = role in [RoleChoices.OWNER, RoleChoices.ADMIN]
if self.role == RoleChoices.OWNER:
can_delete = (
role == RoleChoices.OWNER
and self.template.accesses.filter(role=RoleChoices.OWNER).count() > 1
)
set_role_to = [RoleChoices.ADMIN, RoleChoices.MEMBER] if can_delete else []
else:
can_delete = is_template_owner_or_admin
set_role_to = []
if role == RoleChoices.OWNER:
set_role_to.append(RoleChoices.OWNER)
if is_template_owner_or_admin:
set_role_to.extend([RoleChoices.ADMIN, RoleChoices.MEMBER])
# Remove the current role as we don't want to propose it as an option
try:
set_role_to.remove(self.role)
except ValueError:
pass
return {
"destroy": can_delete,
"update": bool(set_role_to),
"retrieve": bool(role),
"set_role_to": set_role_to,
}