Source code for sphinx_hosting.wildewidgets.project

from __future__ import annotations

from typing import TYPE_CHECKING, Final

from wildewidgets import (
    ActionButtonModelTable,
    BasicModelTable,
    Block,
    CardWidget,
    CrispyFormModalWidget,
    CrispyFormWidget,
    Datagrid,
    FormButton,
    HorizontalLayoutBlock,
    Link,
    ListModelWidget,
    ModalButton,
    RowActionButton,
    RowEditButton,
    RowModelUrlButton,
    ToggleableManyToManyFieldBlock,
    TwoColumnLayout,
    Widget,
    WidgetListLayoutHeader,
)

from ..forms import (
    ProjectCreateForm,
    ProjectRelatedLinkCreateForm,
    ProjectRelatedLinkUpdateForm,
)
from ..models import Classifier, Project, ProjectRelatedLink, Version
from .classifier import ClassifierFilterBlock

if TYPE_CHECKING:
    from django.contrib.auth.models import AbstractUser
    from django.db.models import Model, QuerySet

# ------------------------------------------------------
# Modals
# ------------------------------------------------------


[docs]class ProjectCreateModalWidget(CrispyFormModalWidget): """ A modal dialog that holds the :py:class:`sphinx_hosting.forms.ProjectCreateForm`. Args: args: the arguments to pass to the modal widget Keyword Args: kwargs: the keyword arguments to pass to the modal widget """ #: The ID of the modal dialog. modal_id: str = "project__create" #: The title of the modal dialog. modal_title: str = "Add Project"
[docs] def __init__(self, *args, **kwargs): form = ProjectCreateForm() super().__init__(*args, form=form, **kwargs)
[docs]class ProjectRelatedLinkCreateModalWidget(CrispyFormModalWidget): """ A modal dialog that holds the :py:class:`sphinx_hosting.forms.ProjectRelatedLinkForm` for creating links. Args: project: the project to create the link for args: the arguments to pass to the modal widget Keyword Args: kwargs: the keyword arguments to pass to the modal widget """ modal_id: str = "projectrelatedlink__create" modal_title: str = "Add Related Link"
[docs] def __init__(self, project: Project, *args, **kwargs): form = ProjectRelatedLinkCreateForm(project=project) super().__init__(*args, form=form, **kwargs)
[docs]class ProjectRelatedLinkUpdateModalWidget(CrispyFormModalWidget): """ A modal dialog that holds the :py:class:`sphinx_hosting.forms.ProjectRelatedLinkForm` for updating links. One of these is created for each :py:class:`sphinx_hosting.models.ProjectRelatedLink` object, and lives in the :py:class:`sphinx_hosting.widgets.ProjectRelatedLinksWidget`. """ modal_title: str = "Update Related Link"
[docs] def __init__(self, link: ProjectRelatedLink, *args, **kwargs): form = ProjectRelatedLinkUpdateForm(instance=link) super().__init__(*args, form=form, **kwargs)
# ------------------------------------------------------ # Project related widgets # ------------------------------------------------------ class ProjectInfoWidget(CardWidget): """ A :py:class:`wildewidgets.CardWidget` containing a Tabler datagrid that gives information about a :py:class:`sphinx_hosting.models.Project`. """ title: str = "Project Info" icon: str = "info-square" def __init__(self, project: Project, **kwargs): super().__init__(**kwargs) grid = Datagrid() grid.add_item(title="Machine Name", content=project.machine_name) grid.add_item( title="Created", content=project.created.strftime("%Y-%m-%d %H:%M %Z") ) grid.add_item( title="Last Modified", content=project.modified.strftime("%Y-%m-%d %H:%M %Z"), ) self.set_widget(grid)
[docs]class ProjectTableWidget(Block): """ A :py:class:`wildewidgets.CardWidget` that gives our :py:class:`ProjectTable` dataTable a nice header with a total book count and an "Add Project" button that opens a modal dialog. """
[docs] def __init__(self, user: AbstractUser, **kwargs): super().__init__(**kwargs) self.add_block(self.get_title(user)) layout = TwoColumnLayout(left_column_width=9) table = ProjectTable() layout.add_to_left(CardWidget(widget=table)) layout.add_to_right( ClassifierFilterBlock( table_name=table.table_name, column_number=table.get_column_number("classifiers"), ) ) self.add_block(layout)
[docs] def get_title(self, user: AbstractUser) -> WidgetListLayoutHeader: # pylint: disable=arguments-differ header = WidgetListLayoutHeader( header_text="Projects", badge_text=Project.objects.count(), ) if user.has_perm("sphinxhostingcore.add_project"): header.add_modal_button( text="New Project", color="primary", target=f"#{ProjectCreateModalWidget.modal_id}", ) return header
[docs]class ProjectVersionsTableWidget(CardWidget): """ A :py:class:`wildewidgets.CardWidget` that gives our :py:class:`ProjectVersionTable` dataTable a nice header with a total version count. """ title: str = "Versions" icon: str = "bookmark-star"
[docs] def __init__(self, project_id: int, **kwargs): self.project_id = project_id super().__init__( widget=ProjectVersionTable(project_id=project_id), **kwargs, )
[docs] def get_title(self) -> WidgetListLayoutHeader: return WidgetListLayoutHeader( header_text="Versions", badge_text=Project.objects.get(pk=self.project_id).versions.count(), )
class ProjectClassifierSelectorWidget(ToggleableManyToManyFieldBlock): """ An editable listing of :py:class:`sphinx_hosting.models.Classifier` objects for a particular :py:class:`sphinx_hosting.models.Project`. It is used in the project edit view. """ model: type[Model] = Project field_name: str = "classifiers" title: str = "Classifiers" icon: str = "collection" class ProjectClassifierListWidget(ListModelWidget): """ A :py:class:`wildewidgets.ListModelWidget` that renders a list of :py:class:`sphinx_hosting.models.Classifier` objects. It is used in the project detail view as a read-only list of classifiers. The editable version of this widget is :py:class:`ProjectClassifierSelectorWidget`. """ paginate_by: int = 100 item_label: str = "Classifier" title = "Classifiers" icon = "collection" def get_object_text(self, instance: Classifier) -> str: return instance.name def get_model_subblock(self, instance: Classifier) -> Block: return Block(self.get_object_text(instance), tag="label")
[docs]class ProjectDetailWidget(CrispyFormWidget, Widget): """ Renders an update form for a :py:class:`sphinx_hosting.models.Project`. Use directly it like so:: >>> project = Project.objects.get(pk=1) >>> form = ProjectUpdateForm(instance=project) >>> widget = ProjectDetailWidget(form=form) Or you can simply add the form to your view context and :py:class:`ProjectDetailWidget` will pick it up automatically. """ title: str = "General Settings" name: str = "project-detail__section" modifier: str = "general" icon: str = "card-checklist" css_class: str = CrispyFormWidget.css_class + " p-4"
# ------------------------------------------------------ # ProjectRelatedLink related widgets # ------------------------------------------------------
[docs]class ProjectRelatedLinkListItemWidget(HorizontalLayoutBlock): """ Used by :py:class:`ProjectRelatedLinksWidget` to render a single :py:class:`sphinx_hosting.models.ProjectRelatedLink` object in its list. """ css_class: str = "mb-2"
[docs] def __init__(self, object: ProjectRelatedLink): # noqa: A002 modal_id = f"projectrelatedlink__update__{object.pk}" super().__init__( Link(object.title, url=object.uri, title=object.title), Block( ModalButton(text="Edit", color="azure", target=f"#{modal_id}"), FormButton( text="Delete", color="outline-secondary", css_class="d-inline", action=object.get_delete_url(), confirm_text="Are you sure you want to delete this link?", ), ), ProjectRelatedLinkUpdateModalWidget(object, modal_id=modal_id), )
[docs]class ProjectRelatedLinksWidget(CardWidget): """ A :py:class:`wildewidgets.CardWidget` that allows us to manage the :py:class:`sphinx_hosting.models.ProjectRelatedLink` objects for this :py:class:`sphinx_hosting.models.Project`. It renders a list of :py:class:`ProjectRelatedLinkListItemWidget` objects and a "Add Related Link" button that opens a modal dialog. This is used on the project update page, and is the editable version of :py:class:`ProjectRelatedLinksListWidget`. """ title: str = "Related Links" icon: str = "box-arrow-up-right"
[docs] def __init__(self, project: Project, **kwargs): self.project = project super().__init__( widget=ListModelWidget( queryset=self.project.related_links, model_widget=ProjectRelatedLinkListItemWidget, ), **kwargs, )
[docs] def get_title(self) -> WidgetListLayoutHeader: header = WidgetListLayoutHeader( header_text="Related Links", badge_text=self.project.related_links.count(), ) header.add_modal_button( text="Add Related Link", color="primary", target=f"#{ProjectRelatedLinkCreateModalWidget.modal_id}", ) return header
[docs]class ProjectRelatedLinksListWidget(ListModelWidget): """ Renders a list of :py:class:`sphinx_hosting.models.ProjectRelatedLink` objects as a list of :py:class:`wildewidgets.Link` objects. This is used on the project detail page, and is the read-only version of :py:class:`ProjectRelatedLinksWidget`. """ paginate_by: int = 100 item_label: str = "Related Link" title: str = "Related Links" icon: str = "external-link"
[docs] def get_model_subblock(self, instance: ProjectRelatedLink) -> Block: return Link(instance.title, url=instance.uri)
# ------------------------------------------------------ # Datatables # ------------------------------------------------------ class LatestVersionButton(RowModelUrlButton): attribute: str = "get_latest_version_url" text: str = "Read Docs" color: str = "orange" def is_visible(self, row: Project, user: AbstractUser) -> bool: if row.latest_version is None: return False return super().is_visible(row, user)
[docs]class ProjectTable(ActionButtonModelTable): """ Displays a `dataTable <https://datatables.net>`_ of our :py:class:`sphinx_hosting.models.Project` instances. It's used as a the main widget in by :py:class:`ProjectTableWidget`. """ model: type[Model] = Project #: Show this many rows per page page_length: int = 25 #: Set to ``True`` to stripe our table rows striped: bool = True default_action_button_label: str = "Edit" default_action_button_color_class: str = "outline-secondary" #: A list of fields that we will list as columns. These are either fields #: on our :py:attr:`model`, or defined as ``render_FIELD_NAME_column`` methods #: on this class fields: Final[list[str]] = [ "title", "machine_name", "classifiers", "latest_version", "description", "latest_version_date", ] #: A list of names of columns to hide by default. hidden: Final[list[str]] = [ "classifiers", "machine_name", "latest_version_date", ] #: A list of names of columns that will will not be sortable #: (i.e. clicking on the column header will not sort the table) unsortable: Final[list[str]] = [ "latest_version", "latest_version_date", ] #: A list of names of columns that will will not be searched when doing a #: **global** search unsearchable: Final[list[str]] = [ "classifiers", "latest_version", "latest_version_date", ] #: A dict of column name to column label. We use it to override the #: default labels for the named columns verbose_names: Final[dict[str, str]] = { "title": "Project Name", "machine_name": "Machine Name", "latest_version": "Latest Version", "latest_version_date": "Import Date", } #: A dict of column names to alignment ("left", "right", "center") alignment: Final[dict[str, str]] = { "title": "left", "description": "left", "classifiers": "left", "machine_name": "left", "latest_version": "left", "latest_version_date": "left", } actions: Final[list[RowActionButton]] = [ LatestVersionButton(), RowModelUrlButton( attribute="get_absolute_url", text="View", color="outline-secondary" ), RowEditButton(permission="sphinxhostingcore.change_project", color="azure"), ]
[docs] def render_latest_version_column(self, row: Project, _: str) -> str: """ Render our ``latest_version`` column. This is the version string of the :py:class:`sphinx_hosting.models.Version` that has the most recent :py:attr:`sphinx_hosting.models.Version.modified` timestamp. If there are not yet any :py:class:`sphinx_hosting.models.Version` instances for this project, return empty string. Args: row: the ``Project`` we are rendering colunn: the name of the column to render Returns: The version string of the most recently published version, or empty string. """ version = row.latest_version if version: return version.version # type: ignore[attr-defined] return ""
[docs] def render_latest_version_date_column(self, row: Project, _: str) -> str: """ Render our ``latest_version_date`` column. This is the last modified date of the :py:class:`sphinx_hosting.models.Version` that has the most recent :py:attr:`sphinx_hosting.models.Version.modified` timestamp. If there are not yet any :py:class:`sphinx_hosting.models.Version` instances for this project, return empty string. Args: row: the ``Project`` we are rendering colunn: the name of the column to render Returns: The of the most recently published version, or empty string. """ version = row.latest_version if version: return self.render_datetime_type_column(version.modified) # type: ignore[attr-defined] return ""
[docs] def render_classifiers_column(self, row: Project, _: str) -> str: """ Render our ``classifiers`` column. Args: row: the ``Project`` we are rendering colunn: the name of the column to render Returns: A ``<br>`` separated list of classifier names """ return "<br>".join(row.classifiers.values_list("name", flat=True)) # type: ignore[attr-defined]
[docs] def filter_classifiers_column(self, qs: QuerySet, _: str, value: str) -> QuerySet: """ Filter our results by the ``value``, a comma separated list of :py:class:`sphinx_hosting.models.Classifier` names. Args: qs: the current :py:class:`QuerySet` colunn: the name of the column to filter on value: a comma-separated list of classifier names Returns: A :py:class:`QuerySet` filtered for rows that contain the selected classifiers. """ classifier_ids = value.split(",") return qs.filter(classifiers__id__in=classifier_ids).distinct()
[docs]class ProjectVersionTable(BasicModelTable): """ Displays a `dataTable <https://datatables.net>`_ of our :py:class:`sphinx_hosting.models.Version` instances for a particular :py:class:`sphinx_hosting.models.Project`. It's used as a the main widget in by :py:class:`ProjectVersionTableWidget`. """ model: type[Model] = Version #: Show this many rows per page page_length: int = 25 #: Set to ``True`` to stripe our table rows striped: bool = True actions: bool = True #: A list of fields that we will list as columns. These are either fields #: on our :py:attr:`model`, or defined as ``render_FIELD_NAME_column`` methods #: on this class fields: Final[list[str]] = [ "version", "num_pages", "num_images", "created", "modified", ] unsortable: Final[list[str]] = [ "num_pages", "num_images", ] #: A list of names of columns that will will not be searched when doing a #: **global** search unsearchable: Final[list[str]] = [ "num_pages", "num_images", "created", "modified", ] #: A dict of column name to column label. We use it to override the #: default labels for the named columns verbose_names: Final[dict[str, str]] = { "title": "Version", "num_pages": "# Pages", "num_images": "# Images", } #: A dict of column names to alignment ("left", "right", "center") alignment: Final[dict[str, str]] = { "version": "left", "num_pages": "right", "num_images": "right", "created": "left", "modified": "left", } #: Sort so that the highest version number is on top sort_ascending: bool = False
[docs] def __init__(self, *args, **kwargs) -> None: """ One of our ``kwargs`` must be ``project_id``, the ``pk`` of the :py:class:`sphinx_hosting.models.Project` for which we want to list :py:class:`sphinx_hosting.models.Version` objects. This will get added to the :py:attr:`extra_data` dict in the ``kwargs`` key, from which we reference it. """ #: The pk of the :py:class:`sphinx_hosting.models.Project` for which to #: list versions self.project_id: int | None = kwargs.get("project_id") super().__init__(*args, **kwargs) if "project_id" in self.extra_data["kwargs"]: self.project_id = int(self.extra_data["kwargs"]["project_id"])
[docs] def get_initial_queryset(self) -> QuerySet[Version]: """ Filter our :py:class:`sphinx_hosting.models.Version` objects by :py:attr:`project_id`. Returns: A filtered :py:class:`QuerySet` on :py:class:`sphinx_hosting.models.Version` """ return ( super() .get_initial_queryset() .filter(project_id=self.project_id) .order_by("-version") )
[docs] def render_num_pages_column(self, row: Version, _: str) -> str: """ Render our ``num_pages`` column. This is the number of :py:class:`sphinx_hosting.models.SphinxPage` objects imported for this version. Args: row: the ``Version`` we are rendering colunn: the name of the column to render Returns: The number of pages for this version. """ return row.pages.count()
[docs] def render_num_images_column(self, row: Version, _: str) -> str: """ Render our ``num_images`` column. This is the number of :py:class:`sphinx_hosting.models.SphinxImage` objects imported for this version. Args: row: the ``Version`` we are rendering colunn: the name of the column to render Returns: The number of images for this version. """ return row.images.count()