Source code for sphinx_hosting.models

import re
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Dict, Final, List, TypeAlias, cast  # noqa: UP035
from urllib.parse import unquote, urlparse

import lxml.html
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from lxml import etree
from lxml.html import HtmlElement
from wildewidgets.models import ViewSetMixin

from .fields import MachineNameField
from .settings import MAX_GLOBAL_TOC_TREE_DEPTH
from .validators import NoHTMLValidator

F: TypeAlias = models.Field
M2M: TypeAlias = models.ManyToManyField
FK: TypeAlias = models.ForeignKey


# --------------------------
# Dataclasses
# --------------------------


[docs]@dataclass class TreeNode: """ A :py:class:`dataclass` that we use with :py:class:`SphinxPageTree` to build out the global navigation structure for a set of documentation for a :py:class:`Version`. """ #: The page title title: str #: This page page: "SphinxPage | None" = None #: The :py:class:`SphinxPage` after this one prev: "SphinxPage | None" = None #: The :py:class:`SphinxPage` before this one next: "SphinxPage | None" = None #: The :py:class:`SphinxPage` that is this page's parent parent: "SphinxPage | None" = None #: The :py:class:`TreeNode` objects that are this page's children children: list["TreeNode"] = field(default_factory=list)
[docs] @classmethod def from_page(cls, page: "SphinxPage") -> "TreeNode": """ Build a :py:class:`TreeNode` from ``page``. Note: This does not populate :py:attr:`children`; :py:class:`SphinxPageTree` will populate it as appropriate as it ingests pages. Args: page: the :py:class:`SphinxPage` from which to build a node Returns: A configured node. """ return cls( page=page, title=page.title, next=page.next_page, # type: ignore[arg-type] prev=page.previous_page.first(), parent=page.parent, # type: ignore[arg-type] )
[docs]@dataclass class ClassifierNode: # The title of the classifier node title: str # The classifier for the classifier node classifier: "Classifier | None" = None # The items for the classifier node items: dict[str, "ClassifierNode"] = field(default_factory=dict)
# -------------------------- # Helper classes # --------------------------
[docs]class SphinxPageTree: """ A class that holds the page hierarchy for the set of :py:class:`SphinxPage` pages in a :py:class:`Version`.as a linked set of :py:class:`TreeNode` objects. The page heirarchy is built by starting at :py:attr:`Version.head` and following the page linkages by looking at :py:attr:`SphinxPage.next_page`, stopping the traversal when we find a :py:attr:`SphinxPage.next_page` that is ``None``. As we traverse, if a :py:attr:`SphinxPage.parent` is not ``None``, find the :py:class:`TreeNode` for that parent, and add the page to :py:attr:`TreeNode.children`. For pages who have no :py:attr:`SpinxPage.parent`, assume they are top level children of the set, and make them children of :py:class:`Version.head`. Load it like so:: >>> project = Project.objects.get(machine_name='my-project') >>> version = project.versions.get(version='1.0.0') >>> tree = SphinxPageTree(version) You can then traverse the built hierarchy by starting at :py:attr:`SphinxPageTree.head`, looking at its children, then looking at their children, etc.. Args: version: the :py:class:`Version` to build the tree for """
[docs] def __init__(self, version: "Version") -> None: #: The :py:class:`Version` that this tree examines self.version: Version = version self.nodes: Dict[int, TreeNode] = {} self.nodes[self.version.head.id] = TreeNode.from_page(self.version.head) # type: ignore[attr-defined, arg-type] #: The top page in the page hierarchy self.head: TreeNode = self.nodes[self.version.head.id] # type: ignore[attr-defined] self.build(version)
def build(self, version: "Version"): self.add_page(version.head) # type: ignore[arg-type] def add_page(self, page: "SphinxPage"): node = TreeNode.from_page(page) self.nodes[page.id] = node if node.parent: if node.parent.id in self.nodes: self.nodes[node.parent.id].children.append(node) elif node != self.head: # The top level pages that are not head will not have any parent # because Sphinx doesn't think that way self.head.children.append(node) if node.next: self.add_page(node.next) def _traverse_level(self, pages, nodes): for node in nodes: if node.page: pages.append(node.page) if node.children: self._traverse_level(pages, node.children)
[docs] def traverse(self) -> list["SphinxPage"]: """ Return a list of the pages represented in this tree. """ pages: List["SphinxPage"] = [cast("SphinxPage", self.head.page)] # noqa: UP037 self._traverse_level(pages, self.head.children) return pages
[docs]class SphinxPageTreeProcessor:
[docs] def build_item(self, node: TreeNode) -> Dict[str, Any]: """ Build a :py:class:`wildewdigets.MenuItem` compatible dict representing ``node``. Args: node: the current node in our page tree Returns: A dict suitable for loading into a :py:class:`wildewidgets.MenuItem`. """ item: Dict[str, Any] = { "text": node.title, "url": None, "icon": None, "items": [], } if node.page: item["url"] = node.page.get_absolute_url() return item
[docs] def build(self, items: List[Dict[str, Any]], node: TreeNode) -> None: """ Build a :py:class:`wildewdigets.MenuItem` compatible dict representing ``node``, and append it to ``items``. if ``node`` has children, recurse into those children, building out our submenus. Args: items: the current list of ``MenuItem`` compatible dicts for the current level of the menu node: the current node in our page tree """ item = self.build_item(node) if node.children: for child in node.children: self.build(item["items"], child) items.append(item)
[docs] def run(self, version: "Version") -> List[Dict[str, Any]]: """ Parse the :py:func:`Version.page_tree` and return a struct that works with :py:class:`sphinx_hosting.wildewidgets.SphinxPageGlobalTableOfContentsMenu.parse_obj` The returned struct should look something like this:: [ {'text': 'foo'}, {'text': 'bar', 'url': '/foo', 'icon': None} {'text': 'bar', 'url': '/foo', 'icon': None, items: [{'text': 'blah' ...} ...]} ... ] Args: version: the version whose global table of contents we are parsing Returns: A list of dicts representing the global menu structure """ # noqa: E501 self.sphinx_tree = version.page_tree items: List[Dict[str, Any]] = [] items.append(self.build_item(self.sphinx_tree.head)) for child in self.sphinx_tree.head.children: self.build(items, child) return items
[docs]class SphinxGlobalTOCHTMLProcessor: """ **Usage**: ``SphinxGlobalTOCHTMLProcessor().run(version, globaltoc_html)``` This importer is used to parse the ``globaltoc`` key in JSON output of Sphinx pages built with the `sphinxcontrib-jsonglobaltoc <https://github.com/caltechads/sphinxcontrib-jsonglobaltoc>`_ extension. Sphinx uses your ``.. toctree:`` declarations in your ``.rst`` files to build site navigation for your document tree, and ``sphinxcontrib-jsonglobaltoc`` saves the Sphinx HTML produced by those ``..toctree`` as the ``globaltoc`` key in the `.fjson` output. Note: Sphinx ``.. toctree:`` are ad-hoc -- they're up to how the author wants to organize their content, and may not reflect how files are filled out in the filesystem. """
[docs] def __init__(self, max_level: int = 2) -> None: #: Generate at most this many levels of menus and submenus self.max_level: int = max_level
def fix_href(self, href: str) -> str: p = urlparse(unquote(href)) url = reverse( "sphinx_hosting:sphinxpage--detail", kwargs={ "project_slug": self.version.project.machine_name, # type: ignore[attr-defined] "version": self.version.version, "path": p.path.strip("/"), }, ) if p.fragment: url += f"#{p.fragment}" return url
[docs] def parse_ul(self, html: HtmlElement, level: int = 1) -> List[Dict[str, Any]]: """ Process ``html``, an ``lxml`` parsed set of elements representing the contents of a ``<ul>`` from a Sphinx table of contents and return a list of :py:class:`sphinx_hosting.wildewidgets.MenuItem` objects. Any ``href`` in links found will be converted to its full ``django-sphinx-hosting`` path. If we find another ``<ul>`` inside ``html``, process it by passing its contents to :py:meth:`parse_ul` again, incrementing the menu level. If ``level`` is greater than :py:attr:`max_level`, return an empty list, stopping our recursion. Args: html: the list of elements that are the contents of the parent ``<ul>`` Keyword Args: level: the current menu level Returns: The ``<ul>`` contents as a list of dicts """ items: List[Dict[str, Any]] = [] if level <= self.max_level: for li in html.iterchildren(): item: Dict[str, Any] = { "text": "placeholder", "url": None, "icon": None, "items": [], } for elem in li: if elem.tag == "a": item["text"] = elem.text_content() item["url"] = self.fix_href(elem.attrib["href"]) if elem.tag == "ul": item["items"].extend(self.parse_ul(elem, level=level + 1)) items.append(item) return items
[docs] def parse_globaltoc(self, html: HtmlElement) -> List[Dict[str, Any]]: """ Parse our global table of contents HTML blob and return a list of :py:class:`sphinx_hosting.wildewidgets.MenuItem` objects. Add a first node that points to the root doc, also. The root doc can't add itself to its ``toctree`` blocks, so we need to do it ourselves. How our mapping works: * Multiple top level ``<ul>`` tags separated by ``<p class="caption">`` tags will be merged into a single list. * ``<p class="caption ...">CONTENTS</p>`` becomes ``{'text': 'CONTENTS'}``` * Any ``href`` will be converted to its full ``django-sphinx-hosting`` path Args: version: the version whose global table of contents we are parsing html: the lxml parsed HTML of the global table of contents from Sphinx """ root_url = reverse( "sphinx_hosting:sphinxpage--detail", kwargs={ "project_slug": self.version.project.machine_name, # type: ignore[attr-defined] "version": self.version.version, "path": self.version.head.relative_path, # type: ignore[attr-defined] }, ) items: List[Dict[str, Any]] = [ {"text": "Home", "url": root_url, "icon": None, "items": []} ] for elem in html.iterchildren(): if elem.tag == "p" and "caption" in elem.classes: # Captions only appear at the top level, even if you assign # captions in your toctree declaration in your sub levels with # :caption:, so we only process them here. items.append({"text": elem.text_content()}) if elem.tag == "ul": items.extend(self.parse_ul(elem)) return items
[docs] def run(self, version: "Version", verbose: bool = False) -> List[Dict[str, Any]]: """ Parse the global table of contents found as ``version.head.orig_global_toc`` into a data struct suitable for use with :py:class:`sphinx_hosting.wildewidgets.SphinxPageGlobalTableOfContentsMenu.parse_obj` and return it. How our mapping works: * Multiple top level ``<ul>`` tags separated by ``<p class="caption">`` tags will be merged into a single list. * ``<p class="caption ...">CONTENTS</p>`` becomes ``{'text': 'CONTENTS'}``` * Any ``href`` for links found will be converted to its full ``django-sphinx-hosting`` path The returned struct should look something like this:: [ {'text': 'foo'}, {'text': 'bar', 'url': '/project/version/foo', 'icon': None} {'text': 'bar', 'url': '/project/version/bar', 'icon': None, items: [{'text': 'blah' ...} ...]} ... ] Args: version: the version whose global table of contents we are parsing Keyword Args: verbose: if ``True``, pretty print the HTML of the globaltoc Returns: A list of dicts representing the global menu structure """ # noqa: E501 self.version = version if self.version.head.orig_global_toc: # type: ignore[attr-defined] # This is really only necessary to get the pretty printer below to # work properly. If the \n chars are not removed, lxml won't indent # properly global_toc_html = re.sub(r"\n", "", self.version.head.orig_global_toc) # type: ignore[attr-defined] html = lxml.html.fromstring(global_toc_html) if verbose: print( etree.tostring( # pylint: disable=c-extension-no-member html, method="xml", encoding="unicode", pretty_print=True ) ) return self.parse_globaltoc(html) return []
# -------------------------- # FileField upload functions # --------------------------
[docs]def sphinx_image_upload_to(instance: "SphinxImage", filename: str) -> str: """ Set the upload path within our ``MEDIA_ROOT`` for any images used by our Sphinx documentation to be:: {project machine_name}/{version}/images/{image basename} Args: instance: the :py:class:`SphinxImage` object filename: the original path to the file Returns: The properly formatted path to the file """ path = ( Path(instance.version.project.machine_name) # type: ignore[attr-defined] / Path(instance.version.version) # type: ignore[attr-defined] / "images" ) path = path / Path(filename).name return str(path)
def sphinx_document_upload_to(instance: "SphinxDocument", filename: str) -> str: """ Set the upload path within our ``MEDIA_ROOT`` for any images used by our Sphinx documentation to be:: {project machine_name}/{version}/document/{image basename} Args: instance: the :py:class:`SphinxDocument` object filename: the original path to the file Returns: The properly formatted path to the file """ path = ( Path(instance.version.project.machine_name) # type: ignore[attr-defined] / Path(instance.version.version) # type: ignore[attr-defined] / "documents" ) path = path / Path(filename).name return str(path) # -------------------------- # Managers # --------------------------
[docs]class ClassifierManager(models.Manager): """ Manager for :py:class:`Classifier` models. """
[docs] def tree(self) -> Dict[str, ClassifierNode]: """ Given our classifiers, which are ``::`` separated lists of terms like:: Section :: Subsection :: Name Section :: Subsection :: Name2 Section :: Subsection :: Name3 Section :: Subsection Return a tree-like data structure that looks like:: { 'Section': ClassifierNode( title='Section' items={ 'Subsection': ClassifierNode( title='Subsection', classifier=Classifier(name="Section :: Subsection"), items: { 'Name': ClassifierNode( title='Name', classifier=Classifier( name='Section :: Subsection :: Name' ) ), ... } ) } ) } """ nodes: Dict[str, ClassifierNode] = {} current: ClassifierNode | None = None for classifier in self.get_queryset().all(): parts = classifier.name.split(" :: ") if parts[0] not in nodes: nodes[parts[0]] = ClassifierNode(title=parts[0]) current = nodes[parts[0]] if len(parts) > 1: for part in parts[1:]: if part not in current.items: current.items[part] = ClassifierNode(title=part) current = current.items[part] current.classifier = classifier return nodes
# -------------------------- # Models # --------------------------
[docs]class Classifier(ViewSetMixin, models.Model): """ A :py:class:`Project` can be tagged with one or more :py:class:`Classifier` tags. This allows you to group projects by ecosystem, or type, for example. Use `PyPI <www.pypi.org>`_ classifiers as an example of how to use a single field for classifying across many dimensions. Examples:: Ecosystem :: CMS Language :: Python Owner :: DevOps :: AWS """ objects = ClassifierManager() name: F = models.CharField( "Classifier Name", help_text=_( 'The classifier spec for this classifier, e.g. "Language :: Python"' ), max_length=255, unique=True, )
[docs] def save(self, *args, **kwargs) -> None: """ Overrides :py:meth:`django.db.models.Model.save`. Override save to create any missing classifiers in our chain. For example, if we want to create this classifier:: Foo :: Bar :: Baz But ``Foo :: Bar`` does not yet exist in the database, create that before creating ``Foo :: Bar :: Baz``. We do this so that when we filter our projects by classifier, we can filter by ``Foo :: Bar`` and ``Foo :: Bar :: Baz``. Args: args: the arguments to pass to :py:meth:`django.db.models.Model.save` kwargs: the keyword arguments to pass to :py:meth:`django.db.models.Model.save` """ parts = [p.strip() for p in self.name.split("::")] if len(parts) > 2: # noqa: PLR2004 name = parts[0] for part in parts[1:-1]: name = f"{name} :: {part}" if not Classifier.objects.filter(name=name).exists(): new_classifiier = Classifier(name=name) new_classifiier.save( using=kwargs.get("using", settings.DEFAULT_DB_ALIAS) ) # Rejoin our parts to ensure we always get a classifier that looks like # "part :: part :: part" instead of "part:: part::part" self.name = " :: ".join(parts) super().save(*args, **kwargs)
def __str__(self) -> str: return str(self.name) class Meta: verbose_name = _("classifier") verbose_name_plural = _("classifiers")
class ProjectPermissionGroup(ViewSetMixin, TimeStampedModel, models.Model): """ A :py:class:`Project` can be assigned to one or more :py:class:`ProjectPermissionGroup` groups. This restricts viewing of the project to users which belong to those groups. Examples: .. code-block:: text Ecosystem :: CMS Language :: Python Owner :: DevOps :: AWS """ name: F = models.CharField( "Permission Group Name", help_text=_("The name for this permission group"), max_length=100, unique=True, ) description: F = models.CharField( "Brief Description", max_length=256, null=True, blank=True, help_text=_("A brief description of this permission group"), validators=[NoHTMLValidator()], ) users: M2M = models.ManyToManyField( settings.AUTH_USER_MODEL, related_name="project_permission_groups" ) class Meta: verbose_name = _("project permission group") verbose_name_plural = _("project permission groups")
[docs]class Project(ViewSetMixin, TimeStampedModel, models.Model): """ A Project is what a set of Sphinx docs describes: an application, a library, etc. Projects have versions (:py:class:`Version`) and versions have Sphinx pages (:py:class:`SphinxPage`). """ title: F = models.CharField( "Project Name", help_text=_("The human name for this project"), max_length=100 ) description: F = models.CharField( "Brief Description", max_length=256, null=True, blank=True, help_text=_("A brief description of this project"), validators=[NoHTMLValidator()], ) machine_name: F = MachineNameField( "Machine Name", unique=True, help_text=_( """Must be unique. Set this to the slugified value of "project" in """ """Sphinx's. conf.py""" ), ) latest_version: FK = models.ForeignKey( "Version", help_text=_( "The latest version of this project. " "This is the version that will be shown when you click " '"Read Docs" on the project page.' ), null=True, blank=True, related_name="+", on_delete=models.SET_NULL, ) permission_groups: M2M = models.ManyToManyField( ProjectPermissionGroup, related_name="projects", ) classifiers: M2M = models.ManyToManyField( Classifier, related_name="projects", ) def __str__(self) -> str: # pylint: disable=invalid-str-returned return self.title
[docs] def get_absolute_url(self) -> str: return reverse("sphinx_hosting:project--detail", args=[self.machine_name])
[docs] def get_update_url(self) -> str: return reverse("sphinx_hosting:project--update", args=[self.machine_name])
[docs] def get_latest_version_url(self) -> str | None: if self.latest_version: return reverse( "sphinx_hosting:sphinxpage--detail", args=[ self.machine_name, self.latest_version.version, # type: ignore[attr-defined] self.latest_version.head.relative_path, # type: ignore[attr-defined] ], ) return None
class Meta: verbose_name = _("project") verbose_name_plural = _("projects")
[docs]class Version(TimeStampedModel, models.Model): """ A ``Version`` is a specific version of a :py:class:`Project`. Versions own :py:class:`SphinxPage` objects. """ project: FK = models.ForeignKey( Project, on_delete=models.CASCADE, related_name="versions", help_text=_("The Project to which this Version belongs"), ) version: F = models.CharField( "Version", max_length=64, null=False, help_text=_("The version number for this release of the Project"), ) sphinx_version: F = models.CharField( "Sphinx Version", max_length=64, null=True, blank=True, default=None, help_text=_("The version of Sphinx used to create this documentation set"), ) archived: F = models.BooleanField( "Archived?", default=False, help_text=_("Whether this version should be excluded from search indexes"), ) head: FK = models.OneToOneField( "SphinxPage", on_delete=models.SET_NULL, null=True, related_name="+", # disable our related_name for this one help_text=_( "The top page of the documentation set for this version of our project" ), ) def __str__(self) -> str: return f"{self.project.title}-{self.version}" # type: ignore[attr-defined] @property def is_latest(self) -> bool: return self == self.project.latest_version # type: ignore[attr-defined] @property def page_tree(self) -> SphinxPageTree: """ Return the page hierarchy for the set of :py:class:`SphinxPage` pages in this version. The page hierarchy is build by traversing the pages in the set, starting with :py:attr:`head`. Returns: The page hierarchy for this version. """ return SphinxPageTree(self)
[docs] def mark_searchable_pages(self) -> None: """ Set the :py:attr:`SphinxPage.searchable` flag on the searchable pages in this version. Searchable pages are ones that: * Are not in :py:attr:`SphinxPage.SPECIAL_PAGES` * Do not have a part of their relative path that starts with ``_``. Go through the pages in this version, and set :py:attr:`SphinxPage.searchable` to ``True`` for all those which meet the above requirements, ``False`` otherwise. """ ignored_paths = list(SphinxPage.SPECIAL_PAGES.keys()) for page in self.pages.all(): if ( page.relative_path not in ignored_paths and not page.relative_path.startswith("_") and "/_" not in page.relative_path ): page.searchable = True else: page.searchable = False page.save()
@cached_property def globaltoc(self) -> Dict[str, List[Dict[str, Any]]]: """ Build a struct that looks like this:: { items: [ {'text': 'foo'}, {'text': 'bar', 'url': '/foo', 'icon': None} {'text': 'bar', 'url': '/foo', 'icon': None, items: [{'text': 'blah' ...} ...]} ... ] } suitable for constructing a :py:class:`sphinx_hosting.wildewidgets.SphinxPageGlobalTableOfContentsMenu` for this :py:class:`Version`. """ # noqa: E501 items = SphinxGlobalTOCHTMLProcessor(max_level=MAX_GLOBAL_TOC_TREE_DEPTH).run( self ) if not items: items = SphinxPageTreeProcessor().run(self) return {"items": items}
[docs] def purge_cached_globaltoc(self) -> None: """ Purge the cached output from our :py:meth:`globaltoc` property. """ with suppress(AttributeError): # If we get AttributeError, this means self.globaltoc hasn't been # accessed yet del self.globaltoc
[docs] def get_absolute_url(self) -> str: return reverse( "sphinx_hosting:version--detail", args=[self.project.machine_name, self.version], # type: ignore[attr-defined] )
[docs] def save(self, *args, **kwargs): """ Overriding :py:meth:`django.db.models.Model.save` here so that we can purge our cached global table of contents. """ super().save(*args, **kwargs) self.purge_cached_globaltoc()
[docs]class SphinxPage(TimeStampedModel, models.Model): """ A ``SphinxPage`` is a single page of a set of Sphinx documentation. ``SphinxPage`` objects are owned by :py:class:`Version` objects, which are in turn owned by :py:class:`Project` objects. """ #: This is a mapping between filename and title that identifies the #: special pages that Sphinx produces on its own and gives them #: reasonable titles. These pages have no ``title`` key in their #: json data, but ``title`` is required for pages SPECIAL_PAGES: Final[Dict[str, str]] = { "genindex": "General Index", "py-modindex": "Module Index", "np-modindex": "Module Index", "search": "Search", "_modules/index": "Module code", } version: FK = models.ForeignKey( Version, on_delete=models.CASCADE, related_name="pages", help_text=_("The Version to which this page belongs"), ) relative_path: F = models.CharField( "Relative page path", help_text=_("The path to the page under our top slug"), max_length=255, ) content: F = models.TextField( "Content", help_text=_("The full JSON payload for the page") ) title: F = models.CharField( "Title", max_length=255, help_text=_("Just the title for the page, extracted from the page JSON"), ) orig_body: F = models.TextField( "Body (Original)", blank=True, help_text=_( "The original body for the page, extracted from the page JSON. Some pages " "have no body. We save this here in case we need to reprocess the body at " "some later date." ), ) body: F = models.TextField( "Body", blank=True, help_text=_( "The body for the page, extracted from the page JSON, and modified to " "suit us. Some pages have no body. The body is actually stored as a " "Django template." ), ) orig_local_toc: F = models.TextField( "Local Table of Contents (original)", blank=True, null=True, default=None, help_text=_( "The original table of contents for headings in this page." "We save this here in case we need to reprocess the table of contents " "at some later date." ), ) local_toc: F = models.TextField( "Local Table of Contents", blank=True, null=True, default=None, help_text=_( "Table of Contents for headings in this page, modified to work in " "our templates" ), ) orig_global_toc: F = models.TextField( "Global Table of Contents (original)", blank=True, null=True, default=None, help_text=_( "The original global table of contents HTML attached to this page, if any. " ' This will only be present if you had "sphinxcontrib-jsonglobaltoc"' 'installed in your "extensions" in the Sphinx conf.py' ), ) searchable: F = models.BooleanField( "Searchable", default=False, help_text=_("Should this page be included in the search index?"), ) parent: FK = models.ForeignKey( "SphinxPage", on_delete=models.CASCADE, null=True, related_name="children", help_text=_("The parent page of this page"), ) # This has to be a ForeignKey here and not a OneToOneField becuase # more than one page can have the same next_page. next_page: FK = models.ForeignKey( "SphinxPage", on_delete=models.CASCADE, null=True, related_name="previous_page", help_text=_("The next page in the documentation set"), ) def __str__(self) -> str: return ( f"{self.version.project.title}-{self.version.version}: {self.relative_path}" # type: ignore[attr-defined] )
[docs] def get_absolute_url(self) -> str: return reverse( "sphinx_hosting:sphinxpage--detail", args=[ self.version.project.machine_name, # type: ignore[attr-defined] self.version.version, # type: ignore[attr-defined] self.relative_path, ], )
class Meta: verbose_name = _("sphinx page") verbose_name_plural = _("sphinx pages") unique_together = ("version", "relative_path")
[docs]class SphinxImage(TimeStampedModel, models.Model): """ A ``SphinxImage`` is an image file referenced in a Sphinx document. When importing documenation packages, we extract all images from the package, upload them into Django storage and update the Sphinx HTML in :py:attr:`SphinxPage.body` to reference the URL for the uploaded image instead of its original url. """ version: FK = models.ForeignKey( Version, on_delete=models.CASCADE, related_name="images", help_text=_( "The version of our project documentation with which this image is " "associated" ), ) orig_path: F = models.CharField( _("Original Path"), max_length=256, help_text=_( "The original path to this file in the Sphinx documentation package" ), ) file: F = models.FileField( _("An image file"), upload_to=sphinx_image_upload_to, help_text=_("The actual image file"), ) class Meta: verbose_name = _("sphinx image") verbose_name_plural = _("sphinx images") unique_together = ("version", "orig_path")
class SphinxDocument(TimeStampedModel, models.Model): """ A ``SphinxDocument`` is an downloadable document file referenced in a Sphinx document. When importing documenation packages, we extract all such document from the package, upload them into Django storage and update the Sphinx HTML in :py:attr:`SphinxPage.body` to reference the URL for the uploaded document instead of its original url. """ version: FK = models.ForeignKey( Version, on_delete=models.CASCADE, related_name="documents", help_text=_( "The version of our project documentation with which this document is " "associated" ), ) orig_path: F = models.CharField( _("Original Path"), max_length=256, help_text=_( "The original path to this file in the Sphinx documentation package" ), ) file: F = models.FileField( _("An image file"), upload_to=sphinx_document_upload_to, help_text=_("The actual document file"), ) class Meta: verbose_name = _("sphinx document") verbose_name_plural = _("sphinx documents") unique_together = ("version", "orig_path")