Source code for django_help.models

"""Models for django_help."""

import logging
from typing import List
from typing import Union

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.http import HttpRequest
from django.utils.translation import get_language
from django.utils.translation import gettext_lazy as _
from markdownx.models import MarkdownxField
from taggit.managers import TaggableManager
from taggit.models import ItemBase
from taggit.models import TagBase
from translated_fields import TranslatedField

from django_help.app_settings import BASE_FOREIGN_KEY_FIELD
from django_help.app_settings import BASE_MANAGER
from django_help.app_settings import BASE_MODEL
from django_help.app_settings import BASE_QUERYSET
from django_help.app_settings import EXTRA_LANGUAGES
from django_help.choices import IntendedEntityType
from django_help.utils.regex import get_path_regex


logger = logging.getLogger(__name__)

language_codes = [code for code, _ in settings.LANGUAGES]


[docs]def get_only_filters(): """Return the fields to select in a queryset.""" current_language_code = get_language() return [ f"title_{current_language_code}", f"subtitle_{current_language_code}", "slug", "modified", ]
[docs]class Tag(TagBase, BASE_MODEL): """Customized Taggit Tag model.""" class Meta(BASE_MODEL.Meta if hasattr(BASE_MODEL, "Meta") else object): """Meta options for Tag.""" verbose_name = _("tag") verbose_name_plural = _("tags")
[docs]class DjangoHelpCategoryQuerySet(BASE_QUERYSET): """QuerySet for DjangoHelpCategory."""
[docs] def public(self): """Return only categories which are public.""" return self.filter(public=True)
[docs] def private(self): """Return only categories which are not public.""" return self.filter(public=False)
[docs] def intended_for_any(self): """Return categories intended for any entity.""" return self.filter(intended_entity_type=IntendedEntityType.ANY)
[docs]class DjangoHelpCategoryManager(BASE_MANAGER): """Manager for DjangoHelpCategory."""
[docs] def get_queryset(self): """Return the queryset for this manager.""" return super().get_queryset().all()
[docs]class DjangoHelpCategory(BASE_MODEL): """Stores categories.""" title = TranslatedField( models.CharField( _("Title"), max_length=30, # Specifically to fit the cards in the django_help index page help_text=_("The title of this category."), blank=True, ), EXTRA_LANGUAGES, ) subtitle = TranslatedField( models.CharField( _("Subtitle"), max_length=70, blank=True, help_text=_("A subtitle for this category."), ), EXTRA_LANGUAGES, ) slug = models.SlugField( _("Slug"), max_length=50, unique=True, help_text=_("A web address friendly version of the title."), ) description = TranslatedField( models.CharField( _("Description"), max_length=200, blank=True, help_text=_("A description of this category."), ), EXTRA_LANGUAGES, ) icon = models.CharField( max_length=50, blank=True, help_text=_("The icon and text color to use for this category."), default="fa-circle-info text-success", ) public = models.BooleanField( default=True, help_text=_("Check this box to make this category public."), ) intended_entity_type = models.CharField( max_length=20, choices=IntendedEntityType.choices, default=IntendedEntityType.ANY, help_text=_("The type of entity this category applies to."), ) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) objects = DjangoHelpCategoryManager.from_queryset(DjangoHelpCategoryQuerySet)() class Meta(BASE_MODEL.Meta if hasattr(BASE_MODEL, "Meta") else object): """Meta options for DjangoHelpCategory.""" verbose_name = _("DjangoHelp Category") verbose_name_plural = _("DjangoHelp Categories") ordering = [f"title_{settings.LANGUAGE_CODE}"] # Default to the primary language indexes = [ models.Index(fields=["slug"]), ] def __str__(self): return self.title @property def short_description(self, length=50): """Return a short description of this category.""" return self.description[:length] + "..." if len(self.description) > length else self.description
[docs] def add_article(self, article): """Add the specified article to this category.""" article.category = self article.save()
[docs] @staticmethod def remove_article(article): """Remove the specified article from its category.""" article.category = None article.save()
@property def article_count(self): """Return the number of article in this category.""" return self.articles.count()
[docs]class TaggedArticles(ItemBase, BASE_MODEL): """Stores tagged articles.""" content_object = BASE_FOREIGN_KEY_FIELD( "django_help.DjangoHelpArticle", on_delete=models.CASCADE, ) tag = BASE_FOREIGN_KEY_FIELD( Tag, related_name="%(app_label)s_%(class)s_items", on_delete=models.CASCADE, ) class Meta(BASE_MODEL.Meta if hasattr(BASE_MODEL, "Meta") else object): """Meta options for TaggedArticles.""" verbose_name = _("Tagged Article") verbose_name_plural = _("Tagged Articles")
[docs]class DjangoHelpArticleQuerySet(BASE_QUERYSET): """QuerySet for DjangoHelpArticle."""
[docs] def public(self): """Return only article which is public.""" return self.filter(public=True)
[docs] def private(self): """Return only article which is not public.""" return self.filter(public=False)
[docs] def intended_for_any(self): """Return article intended for any entity.""" return self.filter(intended_entity_type=IntendedEntityType.ANY)
[docs] def search(self, search_terms: List[str]): """Search for article matching the specified query in the current language.""" if len(search_terms) == 0: return self.none() # For each search term, create filters in the current language. title_filters = {f"title_{get_language()}__icontains": search_term for search_term in search_terms} subtitle_filters = {f"subtitle_{get_language()}__icontains": search_term for search_term in search_terms} article_content_filters = { f"article_content_{get_language()}__icontains": search_term for search_term in search_terms } tags_filters = {"tags__name__icontains": search_term for search_term in search_terms} filters = Q(**title_filters) | Q(**subtitle_filters) | Q(**article_content_filters) | Q(**tags_filters) logger.debug("Called search in DjangoHelpArticleQuerySet with filters: %s", filters) return self.filter(filters)
[docs] def get_for_tag(self, tag: str, multiple=False): """Return article(s) matching the specified tag. If `single` is True (default), return only the first article. """ qs = self.filter(tags__name__in=[tag]) if multiple: return qs return qs.first()
[docs] def get_for_category(self, category: str, multiple=False): """Return article(s) matching the specified category. If `single` is True (default), return only the first article. """ title_filters = {f"category__title_{code}__icontains": category.lower() for code in language_codes} slug_filters = {"category__slug__icontains": category.lower()} filters = Q(**title_filters) | Q(**slug_filters) logger.debug("Called get_for_category in DjangoHelpArticleQuerySet with filters: %s", filters) qs = self.filter(filters) if multiple: return qs return qs.first()
[docs] def get_for_slug(self, slug: str, multiple=False): """Return article(s) matching the specified slug. If `single` is True (default), return only the first article. """ qs = self.filter(slug__iexact=slug.lower()) if multiple: return qs return qs.first()
[docs] def get_for_path(self, path: Union[str, HttpRequest], multiple=False): """Return article(s) matching the specified path. If `single` is True (default), return only the first article. """ qs = self.filter(relevant_paths__path__iregex=get_path_regex(path)) if multiple: return qs return qs.first()
[docs] def top_three_highlighted(self): """Return the top three most viewed articles which are highlighted.""" return self.filter(highlighted=True).order_by("-views").only(*get_only_filters())[:3]
[docs] def top_three_recent(self): """Return the top three most recent articles.""" return self.order_by("-created").only(*get_only_filters())[:3]
[docs]class DjangoHelpArticleManager(BASE_MANAGER): """Manager for DjangoHelpArticle."""
[docs] def get_queryset(self): """Return the queryset for this manager.""" return super().get_queryset().all()
[docs]class DjangoHelpArticle(BASE_MODEL): """Stores articles.""" title = TranslatedField( models.CharField( _("Title"), max_length=30, blank=True, help_text=_("The title of this article."), ), EXTRA_LANGUAGES, ) subtitle = TranslatedField( models.CharField( _("Subtitle"), max_length=70, blank=True, help_text=_("A subtitle for this article."), ), EXTRA_LANGUAGES, ) slug = models.SlugField( _("Slug"), max_length=50, unique=True, help_text=_("A web address friendly version of the title."), ) category = BASE_FOREIGN_KEY_FIELD( DjangoHelpCategory, on_delete=models.SET_NULL, blank=True, null=True, related_name="articles", help_text=_("The category this article belongs to, if any."), ) article_content = TranslatedField( MarkdownxField( _("Article Content"), help_text=_("The content of this article. Markdown is supported."), blank=True, ), EXTRA_LANGUAGES, ) views = models.PositiveIntegerField( default=0, help_text=_("The number of times this article has been viewed."), ) icon = models.CharField( max_length=50, blank=True, help_text=_("The icon and text color to use for this article."), default="fa-circle-info text-success", ) public = models.BooleanField( default=True, help_text=_("Check this box to make this article public."), ) highlighted = models.BooleanField( default=False, help_text=_("Check this box to highlight this article on the index page."), ) intended_entity_type = models.CharField( max_length=20, choices=IntendedEntityType.choices, default=IntendedEntityType.ANY, help_text=_("The type of entity this article applies to."), ) created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) objects = DjangoHelpArticleManager.from_queryset(DjangoHelpArticleQuerySet)() tags = TaggableManager( through=TaggedArticles, blank=True, help_text=_("A comma-separated list of tags."), ) class Meta(BASE_MODEL.Meta if hasattr(BASE_MODEL, "Meta") else object): """Meta options for DjangoHelpArticle.""" verbose_name = _("DjangoHelp Article") verbose_name_plural = _("DjangoHelp Articles") ordering = [f"title_{settings.LANGUAGE_CODE}"] # Default to the primary language indexes = [ models.Index(fields=["slug"]), ]
[docs] def clean(self): """Ensure the intended entity type matches the category's intended entity type.""" if self.category and not ( self.intended_entity_type == self.category.intended_entity_type or self.intended_entity_type == IntendedEntityType.ANY or self.category.intended_entity_type == IntendedEntityType.ANY ): validation_error_message = _( f"The intended entity type of this article must match the category's intended entity " f"type or be any. {self.intended_entity_type=} {self.category.intended_entity_type=}." ) raise ValidationError(validation_error_message) super().clean()
def __str__(self): return self.title
[docs] def add_relevant_path(self, path: str): """Add the specified path to this article.""" RelevantPath.objects.create(path=path, article=self)
[docs] def content_preview(self, length=100): """Return a preview of the content, truncated to the specified length.""" return self.article_content[:length] + "..." if len(self.article_content) > length else self.article_content
[docs]class RelevantPath(BASE_MODEL): """Stores a relevant path for an article. A relevant path is a path that is relevant to the article. For example, if the article is about creating a new provider, then the relevant path might be `/providers/create`. Wildcards are supported. For example, if the article is about creating a new provider, then the relevant path might be `/providers/*`. """ path = models.CharField( max_length=200, help_text=_("The relevant path for this article. Wildcards are supported."), ) article = BASE_FOREIGN_KEY_FIELD( DjangoHelpArticle, on_delete=models.CASCADE, related_name="relevant_paths", ) class Meta(BASE_MODEL.Meta if hasattr(BASE_MODEL, "Meta") else object): """Meta options for RelevantPath.""" verbose_name = _("Relevant Path") verbose_name_plural = _("Relevant Paths") ordering = ["path"] indexes = [ models.Index(fields=["path"]), ] def __str__(self): return str(self.path) @property def is_wildcard(self): """Return True if the path is a wildcard path.""" return "*" in list(self.path)
[docs]class ArticleUpload(BASE_MODEL): """Stores uploaded files for articles.""" upload = models.FileField(upload_to="uploads/%Y/%m/%d/") class Meta: """Meta options for ArticleUpload.""" managed = False # No database table creation or deletion operations will be performed for this model. verbose_name = _("DjangoHelp Article Upload") verbose_name_plural = _("DjangoHelp Article Uploads") @property def filename(self): """Return the filename of the upload.""" return self.upload.name.split("/")[-1] @property def extension(self): """Return the extension of the upload.""" return self.filename.split(".")[-1] def __str__(self): return self.filename