Source code for superdjango.contrib.support.library

"""
Re-usable resources for support content.
"""

# Imports

from configparser import ConfigParser
from django.utils.safestring import mark_safe
import logging
import os
from superdjango.conf import SUPERDJANGO
from superdjango.shortcuts import parse_template
from superdjango.utils import read_file, smart_cast, File
from .compat import ScannerError, frontmatter, markdown

log = logging.getLogger(__name__)

# Constants

CALLOUT_TEMPLATE = os.path.join("support", "includes", "callout.html")

# Functions


[docs]def factory(path): """Load content based on the given file.""" if path.endswith("areas.ini"): return read_ini_file(Area, path) elif path.endswith("faqs.ini"): faqs = read_ini_file(FAQ, path) return Article(faqs, path, area="faqs", content_type="faqs", title="FAQs") elif path.endswith("support.ini"): info = read_ini_file(Support, path) return Article(info, path, content_type="support", title="Contact") elif path.endswith("terms.ini"): terms = read_ini_file(Term, path) return Article(terms, path, area="terms", content_type="terms", title="Terms and Definitions") else: content, meta = read_markdown_file(path) if content is None: return None if "snippets" in path: return Snippet(content, path, **meta) return Article(content, path, **meta)
[docs]def parse_callout(string): """Parse a given string as callout text. :param string: The string that might contain a callout. :type string: str :rtype: str .. note:: If not callout is found, the string is returned as is. """ if string.startswith("> Danger:"): context = { 'css': "danger", 'icon': "fas fa-exclamation-triangle", 'label': "Danger", 'message': string.split(":")[-1], } return parse_template(CALLOUT_TEMPLATE, context) elif string.startswith("> Note:"): context = { 'css': "info", 'icon': "fas fa-info-circle", 'label': "Note", 'message': string.split(":")[-1], } return parse_template(CALLOUT_TEMPLATE, context) elif string.startswith("> Tip:"): context = { 'css': "success", 'icon': "fas fa-thumbs-up", 'label': "Tip", 'message': string.split(":")[-1], } return parse_template(CALLOUT_TEMPLATE, context) elif string.startswith("> Warning:"): context = { 'css': "warning", 'icon': "fas fa-bullhorn", 'label': "Warning", 'message': string.split(":")[-1], } return parse_template(CALLOUT_TEMPLATE, context) else: return string
[docs]def read_ini_file(content_class, path): """Load an INI file. :param content_class: The content class to use. :type content_class: class :param path: The path to the file. :type path: str :returns: A list of Area, FAQ or Term instances, or a Support instance. :raise: TypeError """ ini = ConfigParser() ini.read(path) if issubclass(content_class, Area): areas = list() for name in ini.sections(): _kwargs = dict() for key, value in ini.items(name): _kwargs[key] = smart_cast(value) area = Area(name, **_kwargs) areas.append(area) return areas elif issubclass(content_class, FAQ): faqs = list() for question in ini.sections(): _kwargs = dict() for key, value in ini.items(question): _kwargs[key] = smart_cast(value) faq = FAQ(question, **_kwargs) faqs.append(faq) return faqs elif issubclass(content_class, Support): _kwargs = dict() for section in ini.sections(): if section == "support": for key, value in ini.items(section): _kwargs[key] = smart_cast(value) else: _section_kwargs = dict() for key, value in ini.items(section): _section_kwargs[key] = smart_cast(value) _kwargs[section] = Section(section, **_section_kwargs) return Support(**_kwargs) elif issubclass(content_class, Term): terms = list() for name in ini.sections(): _kwargs = dict() for key, value in ini.items(name): _kwargs[key] = smart_cast(value) term = Term(name, **_kwargs) terms.append(term) return terms else: raise TypeError("Unsupported content class for INI file: %s" % content_class.__name__)
[docs]def read_markdown_file(path): """Load a Markdown file. :param path: The path to the file. :type path: str :rtype: tuple(str, dict) :returns: The content and meta data. """ with open(path, "r") as f: try: page = frontmatter.load(f) meta = page.metadata.copy() content = page.content return content, meta except ScannerError as e: log.warning("Failed to load page front matter: %s (%s)" % (path, e)) finally: f.close() return None, None
# Classes
[docs]class Area(object): """An overall area of content."""
[docs] def __init__(self, name, **kwargs): self.description = kwargs.pop("description", None) self.icon = kwargs.pop("icon", "fas fa-info-circle") self.name = name self.title = kwargs.pop("title", name.replace("_", " ").title()) self._db = None
@property def articles(self): """Alias for ``get_articles()``.""" return self.get_articles()
[docs] def get_articles(self): """Get all articles for the area. :rtype: list[Article] """ a = list() for file in self._db.get_content_files(): if isinstance(file, Article) and self.name == file.area: a.append(file) return a
[docs]class Content(object): """The content database."""
[docs] def __init__(self, path): """Initialize the database. :param path: The path to the database. :type path: str """ self.areas = list() self.info = None self.is_loaded = False self.manifest = Manifest(os.path.join(path, "manifest.txt")) self.path = path self._files = list()
[docs] def fetch(self, slug): """Get an object from the database. :param slug: The slug of the object. :type slug: str :returns: The object, if found. Otherwise, ``None``. """ for c in self._files: if c.slug == slug: return c return None
[docs] def get_content_files(self): """Get the content files acquired using ``load()``. :rtype: list """ return self._files
[docs] def load(self): """Load all help content. :rtype: bool """ if not self.manifest.load(): log.error("Failed to load manifest.") return False areas_path = os.path.join(self.path, "areas.ini") if os.path.exists(areas_path): self.areas = factory(areas_path) if self.areas: for a in self.areas: a._db = self info_path = os.path.join(self.path, "support.ini") if os.path.exists(info_path): # noinspection PyProtectedMember self.info = factory(info_path)._content self.is_loaded = self._load_content_files() return self.is_loaded
def _load_content_files(self): """Load content files found in the manifest. :rtype: bool """ for file_path in self.manifest: content = factory(file_path) if content is None: log.warning("Failed to load content file: %s" % file_path) continue self._files.append(content) return len(self._files) > 0
[docs]class Article(File): """A support article."""
[docs] def __init__(self, content, path, content_type="article", **kwargs): """Initialize an article. :param content: The raw content of the article. :type content: str :param path: The path to the article file. :type path: str :param content_type: The content type of the article. :type content_type: str keyword arguments are available dynamically as attributes of the article. """ self.attributes = kwargs self.type = content_type self._content = content if 'tags' in kwargs: a = list() for i in kwargs.get("tags").split(","): a.append(i.strip()) self.attributes['tags'] = a super().__init__(path)
def __getattr__(self, item): return self.attributes.get(item) @property def content(self): """Get the Markdown content of the article. :rtype: str """ if self.type == "faqs": a = list() for i in self._content: # noinspection PyUnresolvedReferences a.append(i.to_markdown()) return "\n".join(a) elif self.type == "support": # noinspection PyUnresolvedReferences return self._content.to_markdown() elif self.type == "terms": a = list() for i in self._content: # noinspection PyUnresolvedReferences a.append(i.to_markdown()) return "\n".join(a) else: return self._content @property def content_list(self): """Get the content when it is a list of FAQ or Term instances. :rtype: list """ if self.type in ("faqs", "terms"): return self._content else: return list()
[docs] def get_word_count(self): """Get the total number of words. :rtype: int """ return len(self.content.split(" "))
@property def slug(self): """Alias for ``name``.""" return self.name
[docs] def to_html(self, extensions=SUPERDJANGO.SUPPORT_MARKDOWN_EXTENSIONS): """Export to HTML. :param extensions: A list of Markdown extensions to apply. :type extensions: list[str] :rtype: str :returns: The content passed through ``mark_safe()``. """ a = list() for line in self.content.split("\n"): a.append(parse_callout(line)) return mark_safe(markdown("\n".join(a), extensions=extensions))
@property def url(self): """Get the URL of the article. :rtype: str """ return "%sarticles/%s" % (SUPERDJANGO.SUPPORT_URL, self.slug)
[docs]class FAQ(object): """A frequently asked question."""
[docs] def __init__(self, question, answer=None, area=None, category=None, sticky=False, tags=None): """Initialize the question. :param question: The question text. :type question: str :param answer: The answer text. :type answer: str :param area: The area to which the FAQ is belongs. :type area: str :param category: The category into which FAQ is organized. :type category: str :param sticky: Indicates the FAQ should appear at the top of lists. :type sticky: bool :param tags: A comma separated list of tags used to classify the FAQ. :type tags: str """ self.answer = answer self.area = area self.category = category self.is_sticky = sticky self.question = question self.tags = list() if tags is not None: for t in tags.split(","): self.tags.append(t.strip())
@property def content(self): """Get the question and its answer. Complies with the overall API. :rtype: str """ return "%s: %s" % (self.question, self.answer) @property def path(self): """Get the path to the FAQ file. Complies with the overall API. :rtype: str """ return os.path.join(SUPERDJANGO.SUPPORT_PATH, "faqs.ini") @property def slug(self): """Always returns ``faqs``.""" return "faqs"
[docs] def to_markdown(self): """Export the FAQ to Markdown. :rtype: str """ a = list() a.append("**%s**" % self.question) a.append("") a.append("%s" % self.answer) a.append("") return "\n".join(a)
[docs]class Manifest(File): """The manifest controls what content is to be included in the database."""
[docs] def __init__(self, path): """Initialize the manifest. :param path: The path to the manifest file. :type path: str """ super().__init__(path) self.is_loaded = False self._files = list()
def __iter__(self): return iter(self._files)
[docs] def load(self): """Load file paths from the manifest. :rtype: bool """ if not self.exists: log.warning("Manifest file does not exist: %s" % self.path) return False # Read the contents of the manifest. content = read_file(self.path) if "*" in content: self._auto_load() self.is_loaded = len(self._files) > 0 return self.is_loaded # Count is used to identify missing files by line number. count = 0 # Split into lines and iterate over the result. lines = content.split("\n") for relative_path in lines: count += 1 if len(relative_path) == 0 or relative_path.startswith("#"): continue # Handle INI files. if relative_path.endswith(".ini"): file_path = os.path.join(self.directory, relative_path) elif relative_path.startswith("snippets"): file_path = os.path.join(self.directory, "snippets", relative_path + ".markdown") else: file_path = os.path.join(self.directory, "articles", relative_path + ".markdown") # Determine whether the file exists. if not os.path.exists(file_path): log.warning("Could not locate file named in %s, line %s: %s" % (self.basename, count, relative_path)) continue # Add the file to the inventory. self._files.append(file_path) self.is_loaded = len(self._files) > 0 return self.is_loaded
def _auto_load(self): """Attempt to autoload the manifest.""" path = os.path.join(self.directory, "articles") for root, dirs, files in os.walk(path): files.sort() for f in files: self._files.append(os.path.join(path, f)) path = os.path.join(self.directory, "faqs.ini") if os.path.exists(path): self._files.append(path) path = os.path.join(self.directory, "snippets") if os.path.exists(path): for root, dirs, files in os.walk(path): files.sort() for f in files: self._files.append(os.path.join(path, f)) path = os.path.join(self.directory, "terms.ini") if os.path.exists(path): self._files.append(path)
[docs]class Section(object): """An object-oriented representation of a configuration section from an INI file See :py:class:`INIConfig`."""
[docs] def __init__(self, section_name, **kwargs): """Initialize the section. :param section_name: The section name. :type section_name: str Keyword arguments are added as context variables. """ self._name = section_name self._attributes = kwargs
def __getattr__(self, item): return self._attributes.get(item)
[docs]class Snippet(File): """A fragment of content maintained separate from articles."""
[docs] def __init__(self, content, path, **kwargs): """Initialize a content snippet. :param content: The content of the snippet. :type content: str :param path: The path to the snippet file. :type path: str Keyword arguments are available as dynamic attributes of the instance. ``tags``, if provided, are a comma separated list. """ self.attributes = kwargs self.content = content if 'tags' in kwargs: a = list() for i in kwargs.get("tags").split(","): a.append(i.strip()) self.attributes['tags'] = a super().__init__(path)
def __getattr__(self, item): return self.attributes.get(item) @property def slug(self): """Get the snippet slug. Complies with the overall API. :rtype: str """ return "snippet-%s" % self.name
[docs] def to_html(self, extensions=SUPERDJANGO.SUPPORT_MARKDOWN_EXTENSIONS): """Export to HTML. :param extensions: A list of Markdown extensions to apply. :type extensions: list[str] :rtype: str :returns: The content passed through ``mark_safe()``. """ a = list() for line in self.content.split("\n"): a.append(parse_callout(line)) return mark_safe(markdown("\n".join(a), extensions=extensions))
[docs]class Support(object): """Encapsulates support information."""
[docs] def __init__(self, contact=None, email=None, phone=None, url=None, **kwargs): """Initialize support info. :param contact: The contact name. :type contact: str :param email: The email address used for contacting support. :type email: str :param phone: The phone number used for contact support. :type phone: str :param url: The URL for support docs, self-service, etc. :type url: str Keyword arguments are available as dynamic attributes. """ self.attributes = kwargs self.contact = contact self.email = email self.phone = phone self.url = url
def __getattr__(self, item): return self.attributes.get(item) @property def path(self): """Get the path to the support file. Complies with the overall API. :rtype: str """ return os.path.join(SUPERDJANGO.SUPPORT_PATH, "support.ini") @property def slug(self): """Always returns ``support``. Complies with the overall API. :rtype: str """ return "support"
[docs] def to_markdown(self): """Export support info as Markdown. :rtype: str """ a = list() if self.contact: a.append("**%s**" % self.contact) a.append("") if self.email: a.append('<i class="fas fa-envelope-square"></i> %s ' % self.email) if self.phone: a.append('<i class="fas fa-phone-square"></i> %s ' % self.phone) if self.url: a.append('<i class="fas fa-external-link-square-alt"></i> %s ' % self.url) if self.urls: a.append("") if self.urls.download: a.append('- [Dowload](%s)' % self.urls.download) if self.urls.content: a.append('- [Documentation](%s)' % self.urls.content) if self.urls.privacy_policy: a.append("- [Privacy Policy](%s)" % self.urls.privacy_policy) if self.urls.terms_of_use: a.append("- [Terms of Use](%s)" % self.urls.terms_of_use) a.append("") if self.maint: a.append("**Maintenance Window**") a.append("") a.append("%s at %s to %s at %s" % ( self.maint.starting_day, self.maint.starting_hour, self.maint.ending_day, self.maint.ending_hour, )) a.append("") return "\n".join(a)
[docs]class Term(object): """A term and definition."""
[docs] def __init__(self, name, area=None, category=None, definition=None, sticky=False, tags=None): """Initialize the term. :param name: The name of the term being defined. :type name: str :param area: The area to which the FAQ is belongs. :type area: str :param category: The category into which FAQ is organized. :type category: str :param sticky: Indicates the FAQ should appear at the top of lists. :type sticky: bool :param tags: A comma separated list of tags used to classify the FAQ. :type tags: str """ self.area = area self.category = category self.definition = definition self.is_sticky = sticky self.tags = list() self.name = name if tags is not None: for t in tags.split(","): self.tags.append(t.strip())
@property def content(self): """Get the term and its definition. Complies with the overall API. :rtype: str """ return "%s: %s" % (self.name, self.definition) @property def path(self): """Get the path to the terms file. Complies with the overall API. :rtype: str """ return os.path.join(SUPERDJANGO.SUPPORT_PATH, "terms.ini") @property def slug(self): """Always returns ``terms``.""" return "terms"
[docs] def to_markdown(self): """Export the term to Markdown. :rtype: str """ a = list() a.append(self.name) a.append(" : %s" % self.definition) a.append("") return "\n".join(a)
''' # Might need this later. class Context(object): """Collect variables together.""" def __init__(self, name, defaults=None): """Initialize the context. :param name: The name of the context. :type name: str :param defaults: Default values, if any. :type defaults: dict """ self._variables = dict() self._name = name if defaults is not None: for key, value in defaults.items(): self._variables.setdefault(key, value) def __getattr__(self, item): """Access variables on the context instance.""" return self._variables.get(item) def __iter__(self): return iter(self._variables) def __repr__(self): return "<%s %s>" % (self.__class__.__name__, self._name) def add(self, name, value): """Add a variable to the context. :param name: The name of the variable. :type name: str :param value: The value of the variable. :raise: ValueError :raises: ``ValueError`` if the named variable already exists. """ if name in self._variables: raise ValueError("The %s context already has a variable named: %s" % (self._name, name)) self._variables[name] = value def get(self, name, default=None): """Get the named variable at run time with an optional default. :param name: The name of the variable. :type name: str :param default: The default value if any. """ if self.has(name): return self._variables[name] return default def get_name(self): """Get the name of the context. :rtype: str """ return self._name def has(self, name): """Indicates the context has the named variable *and* that it is not ``None``. :param name: The name of the variable. :type name: str :rtype: bool """ if name in self._variables and self._variables[name] is not None: return True return False def mapping(self): """Get the context as a dictionary. :rtype: dict """ return self._variables def set(self, name, value): """Add or update a variable in the context. :param name: The name of the variable. :type name: str :param value: The value of the variable. .. note:: Unlike ``add()`` or ``update()`` an exception is *not* raised whether the variable exists or not. """ self._variables[name] = value def update(self, name, value): """Update an existing variable in the context. :param name: The name of the variable. :type name: str :param value: The value of the variable. :raise: KeyError :raises: ``KeyError`` if the named variable has not been defined. """ if name not in self._variables: raise KeyError("The %s context does not have a variable named: %s" % (self._name, name)) self._variables[name] = value '''