Source code for borgcube.web.core.publishers

import logging
from urllib.parse import quote as urlquote

from borgcube.utils import hook

from django.http import Http404, HttpResponse
from django.shortcuts import redirect
from django.template.loader import get_template
from django.template.response import TemplateResponse

log = logging.getLogger(__name__)


[docs]class PublisherMenu: """ Defines the interface for exposing menu entries directly by publishers. Note that there is no way to specify an URL; the menu entry always points to the default view of the instance. """ #: Whether this publisher should be included. When absent, False. menu_descend = True #: The text of the item. Normally a lazy translation object (eg. `ugettext_lazy`). menu_text = ''
[docs]class Publisher: """ Core class of the object publishing system. Since the core of BorgCube is not tied to the web interface, objects do not directly implement object publishing protocols like usually done in eg. Zope or Pyramid. Instead a second hierarchy of object exists, the *publishers*, that only contain web- related functionality. Every publisher instance is bound to a *companion*, the core object that it renders. The `companion` attribute defines how the instance attribute shall be named. A publisher can have multiple views, but by default only has it's default view. A publisher can either have a "relatively static" number of children, by implementing `children` and returning a mapping of segments to child publisher instances or factories, or a "rather dynamic" number of children, by implementing `__getitem__`. The first case is usually used if the children are static, eg. the `RootPublisher` has fixed children (`ClientsPublisher`, `SchedulesPublisher`, ...), while the second case is best applied if the children are sourced from the database, since only one publisher, for the requested child, needs to be constructed. An example best illustrates this:: class RootPublisher(Publisher): companion = 'dr' def children(self): return { 'clients': ClientsPublisher(self.dr.clients), 'schedules': SchedulesPublisher(self.dr.schedules), # ... } On the other hand, here is how `ClientsPublisher` handles it's children:: class ClientsPublisher(Publisher): companion = 'clients' def __getitem__(self, hostname): client = self.clients[hostname] return ClientPublisher(client) Note that, since `ClientsPublisher` was provided by `RootPublisher` the companion of `ClientsPublisher` is `data_root().clients` -- so `__getitem__` here only loads the required client from the database. Also note how no extra error handling is required: *clients* is already a mapping itself, so if no client with *hostname* exists it will raise `KeyError`. This might seem a bit confusing and convoluted, however, it allows implicit URL generation and avoids having to define many URL patterns by hand. It also decouples components very efficiently, since URLs are both resolved and generated by the hierarchy, so plugins can just "hook into" the system and don't need to bother defining URLs that don't conflict with core URLs. .. rubric:: Additional views Additional views can be added by adding *something_view* methods and adding it to the `views` property:: class MyPublisher(Publisher): views = ('edit', ) def view(self, request): ... def edit_view(self, request): ... In the URL hierarchy these are addressed through the ``view`` query parameter, eg. */clients/foo/?view=edit*. The query parameter converts dashes to underscores, eg. *?view=latest_job* and *?view=latest-job* are identical. """ #: The name of the companion attribute companion = 'companion' #: Exposed views (the default view is *always* exposed), base names, no trailing `_view`. views = () def __init__(self, companion): setattr(self, type(self).companion, companion) self.parent = None self.segment = None @property def name(self): """ Name of the publisher for hookspec purposes. Defaults to *class.companion*. """ return type(self).companion
[docs] def get_companion(self): """Returns the companion object.""" return getattr(self, type(self).companion)
################### # Children ###################
[docs] def get(self, segment): """ Return child from *segment* or raise KeyError. First tries subscription (`__getitem__`), then looks up in `children`. Ensures that *child.segment* and *child.parent* are set correctly. """ try: child = self[segment] except KeyError: child = self.children()[segment] child.segment = segment child.parent = self return child
[docs] def children(self): """ Return a mapping of child names to child publishers. Make sure to call into `children_hook`, like so:: def children(self): return self.children_hook({ ... }) """ return self.children_hook({})
[docs] def children_hook(self, children): """ Post-process result of `children`. This adds plugin children via `borgcube_web_children` and ensures that all children know their parent and segment. """ list_of_children = hook.borgcube_web_children(publisher=self, children=children) for c in list_of_children: for k, v in c.items(): if k in children: log.warning('%s: duplicate child %s (%s)', self, k, v) continue children.update(c) for k, v in children.items(): v.segment = k v.parent = self return children
def __getitem__(self, item): """ Return published child object or raise KeyError Defaults to always raising `KeyError`. """ raise KeyError ################### # Traversal ###################
[docs] def redirect_to(self, view=None, permanent=False): """Return a HTTP redirect response to this publisher and *view*.""" return redirect(self.reverse(view), permanent=permanent)
[docs] def reverse(self, view=None): """Return the path to this publisher and *view*.""" assert self.parent, 'Cannot reverse Publisher without a parent' assert self.segment, 'Cannot reverse Publisher without segment' path = self.parent.reverse() assert path.endswith('/'), 'Incorrect Publisher.reverse result: did not end in a slash?' path += urlquote(self.segment) + '/' if view: view = view.replace('_', '-') path += '?view=' + view return path
[docs] def resolve(self, path_segments, view=None): """ Resolve reversed *path_segments* to a view or raise `Http404`. Note: *path_segments* is destroyed in the process. """ try: segment = path_segments.pop() if not segment: return self.view except IndexError: # End of the path -> resolve view if view: # Canonicalize the view name, replacing HTTP-style dashes with underscores, # eg. /client/foo/?view=latest-job means the same as /client/foo/?view=latest_job view = view.replace('-', '_') try: # Make sure that this is an intentionally accessible view, not some coincidentally named method. self.views.index(view) except ValueError: raise Http404 # Append view_ namespace eg. latest_job_view view_name = view + '_view' return getattr(self, view_name) else: return self.view try: return self.get(segment).resolve(path_segments, view) except KeyError: raise Http404
################### # Views ###################
[docs] def render(self, request, template=None, context={}): """ Return a TemplateResponse for *request*, *template* and *context*. The actual context is constructed by obtaining the return of `context` and updating that with the passed *context*. If *template* is None, then the `base_template` is used. """ base_context = self.context(request) base_context.update(context) return TemplateResponse(request, template or self.base_template(request), base_context)
[docs] def base_template(self, request): """Return base template name. Default: base.html""" return 'base.html'
[docs] def context(self, request): """ Return the "base context" for *request*. Make sure to always call the base implementation, which provides the following keys: - *publisher* (self) - *cls.companion* (correctly named companion obtained from self.get_companion()) - *base_template* - *secondary_menu* (None, overridden in subclasses) """ base_template = self.base_template(request) return { 'publisher': self, type(self).companion: self.get_companion(), 'base_template': base_template, 'secondary_menu': None, }
[docs] def view(self, request): """ The default view of this object. This implementation raises `Http404`. """ raise Http404
[docs]class ExtensiblePublisher(Publisher, PublisherMenu): """ An extensible publisher implements publishers which are extended by `ExtendingPublisher` s. This allows to decouple the publishing of child objects from the publisher of an object, by allowing other parts and plugins to hook into the display and present their content wrapped up in your visual framework. An extensible publisher always has a menu entry that is used for secondary navigation between the ExtensiblePublisher and a number of ExtendingPublishers, which appear at the same level (although the are technically subordinate). The extending publishers are attached in the regular way through `children_hook`. .. rubric:: Templates and contexts An extensible publisher should designate a template with `default_template` whose `title` and `ctitle` blocks are renderable with the standard `context`. ExtendingPublishers will use this to render their content into the ccontent block (indirectly). """ menu_text = ''
[docs] def context(self, request): context = super().context(request) context['secondary_menu'] = self._construct_menu(request) return context
[docs] def base_template(self, request): return 'extensible.html'
[docs] def default_template(self, request): """Return default template name (see class docstring). Default: `base_template`""" return self.base_template(request)
def _construct_menu(self, request): def item(publisher): return { 'url': publisher.reverse(), 'text': publisher.menu_text, 'items': [] } menu = [item(self)] for child in self.children().values(): if not getattr(child, 'menu_descend', False): continue menu.append(item(child)) return menu
[docs]class ExtendingPublisher(Publisher, PublisherMenu):
[docs] def render(self, request, template=None, context=None): chrome_template = get_template('core/_chrome_glue.html') chrome_context = self.parent.context(request) chrome_context['ext_template'] = self.parent.default_template(request) chrome = chrome_template.render(chrome_context, request) content = super().render(request, template, context).rendered_content page = chrome.replace('<extending-publisher-content/>', content) return HttpResponse(page, charset='utf-8')