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,
],
)
[docs] def get_permalink(self) -> str:
"""
Return the permalink for this page. This is the URL for the page
with the version number replaced with "latest".
Returns:
The permalink for this page.
"""
return reverse(
"sphinx_hosting:sphinxpage--detail",
args=[self.version.project.machine_name, "latest", self.relative_path], # type: ignore[attr-defined]
)
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")