"""
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
'''