Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1"""
2Re-usable resources for support content.
3"""
5# Imports
7from configparser import ConfigParser
8from django.utils.safestring import mark_safe
9import logging
10import os
11from superdjango.conf import SUPERDJANGO
12from superdjango.shortcuts import parse_template
13from superdjango.utils import read_file, smart_cast, File
14from .compat import ScannerError, frontmatter, markdown
16log = logging.getLogger(__name__)
18# Constants
20CALLOUT_TEMPLATE = os.path.join("support", "includes", "callout.html")
22# Functions
25def factory(path):
26 """Load content based on the given file."""
27 if path.endswith("areas.ini"):
28 return read_ini_file(Area, path)
29 elif path.endswith("faqs.ini"):
30 faqs = read_ini_file(FAQ, path)
31 return Article(faqs, path, area="faqs", content_type="faqs", title="FAQs")
32 elif path.endswith("support.ini"):
33 info = read_ini_file(Support, path)
34 return Article(info, path, content_type="support", title="Contact")
35 elif path.endswith("terms.ini"):
36 terms = read_ini_file(Term, path)
37 return Article(terms, path, area="terms", content_type="terms", title="Terms and Definitions")
38 else:
39 content, meta = read_markdown_file(path)
40 if content is None:
41 return None
43 if "snippets" in path:
44 return Snippet(content, path, **meta)
46 return Article(content, path, **meta)
49def parse_callout(string):
50 """Parse a given string as callout text.
52 :param string: The string that might contain a callout.
53 :type string: str
55 :rtype: str
57 .. note::
58 If not callout is found, the string is returned as is.
60 """
61 if string.startswith("> Danger:"):
62 context = {
63 'css': "danger",
64 'icon': "fas fa-exclamation-triangle",
65 'label': "Danger",
66 'message': string.split(":")[-1],
67 }
68 return parse_template(CALLOUT_TEMPLATE, context)
69 elif string.startswith("> Note:"):
70 context = {
71 'css': "info",
72 'icon': "fas fa-info-circle",
73 'label': "Note",
74 'message': string.split(":")[-1],
75 }
76 return parse_template(CALLOUT_TEMPLATE, context)
77 elif string.startswith("> Tip:"):
78 context = {
79 'css': "success",
80 'icon': "fas fa-thumbs-up",
81 'label': "Tip",
82 'message': string.split(":")[-1],
83 }
84 return parse_template(CALLOUT_TEMPLATE, context)
85 elif string.startswith("> Warning:"):
86 context = {
87 'css': "warning",
88 'icon': "fas fa-bullhorn",
89 'label': "Warning",
90 'message': string.split(":")[-1],
91 }
92 return parse_template(CALLOUT_TEMPLATE, context)
93 else:
94 return string
97def read_ini_file(content_class, path):
98 """Load an INI file.
100 :param content_class: The content class to use.
101 :type content_class: class
103 :param path: The path to the file.
104 :type path: str
106 :returns: A list of Area, FAQ or Term instances, or a Support instance.
108 :raise: TypeError
110 """
111 ini = ConfigParser()
112 ini.read(path)
114 if issubclass(content_class, Area):
115 areas = list()
116 for name in ini.sections():
117 _kwargs = dict()
118 for key, value in ini.items(name):
119 _kwargs[key] = smart_cast(value)
121 area = Area(name, **_kwargs)
122 areas.append(area)
124 return areas
125 elif issubclass(content_class, FAQ):
126 faqs = list()
127 for question in ini.sections():
128 _kwargs = dict()
129 for key, value in ini.items(question):
130 _kwargs[key] = smart_cast(value)
132 faq = FAQ(question, **_kwargs)
133 faqs.append(faq)
135 return faqs
136 elif issubclass(content_class, Support):
137 _kwargs = dict()
138 for section in ini.sections():
139 if section == "support":
140 for key, value in ini.items(section):
141 _kwargs[key] = smart_cast(value)
142 else:
143 _section_kwargs = dict()
144 for key, value in ini.items(section):
145 _section_kwargs[key] = smart_cast(value)
147 _kwargs[section] = Section(section, **_section_kwargs)
149 return Support(**_kwargs)
151 elif issubclass(content_class, Term):
152 terms = list()
153 for name in ini.sections():
154 _kwargs = dict()
155 for key, value in ini.items(name):
156 _kwargs[key] = smart_cast(value)
158 term = Term(name, **_kwargs)
159 terms.append(term)
161 return terms
162 else:
163 raise TypeError("Unsupported content class for INI file: %s" % content_class.__name__)
166def read_markdown_file(path):
167 """Load a Markdown file.
169 :param path: The path to the file.
170 :type path: str
172 :rtype: tuple(str, dict)
173 :returns: The content and meta data.
175 """
176 with open(path, "r") as f:
177 try:
178 page = frontmatter.load(f)
179 meta = page.metadata.copy()
180 content = page.content
182 return content, meta
184 except ScannerError as e:
185 log.warning("Failed to load page front matter: %s (%s)" % (path, e))
186 finally:
187 f.close()
189 return None, None
191# Classes
194class Area(object):
195 """An overall area of content."""
197 def __init__(self, name, **kwargs):
198 self.description = kwargs.pop("description", None)
199 self.icon = kwargs.pop("icon", "fas fa-info-circle")
200 self.name = name
201 self.title = kwargs.pop("title", name.replace("_", " ").title())
202 self._db = None
204 @property
205 def articles(self):
206 """Alias for ``get_articles()``."""
207 return self.get_articles()
209 def get_articles(self):
210 a = list()
211 for file in self._db.get_content_files():
212 if isinstance(file, Article) and self.name == file.area:
213 a.append(file)
215 return a
218class Content(object):
219 """The content database."""
221 def __init__(self, path):
222 self.areas = list()
223 self.info = None
224 self.is_loaded = False
225 self.manifest = Manifest(os.path.join(path, "manifest.txt"))
226 self.path = path
227 self._files = list()
229 def fetch(self, slug):
230 for c in self._files:
231 if c.slug == slug:
232 return c
234 return None
236 def get_content_files(self):
237 """Get the content files acquired using ``load()``.
239 :rtype: list
241 """
242 return self._files
244 def load(self):
245 """Load all help content.
247 :rtype: bool
249 """
250 if not self.manifest.load():
251 log.error("Failed to load manifest.")
252 return False
254 areas_path = os.path.join(self.path, "areas.ini")
255 if os.path.exists(areas_path):
256 self.areas = factory(areas_path)
257 if self.areas:
258 for a in self.areas:
259 a._db = self
261 info_path = os.path.join(self.path, "support.ini")
262 if os.path.exists(info_path):
263 # noinspection PyProtectedMember
264 self.info = factory(info_path)._content
266 self.is_loaded = self._load_content_files()
268 return self.is_loaded
270 def _load_content_files(self):
271 """Load content files found in the manifest.
273 :rtype: bool
275 """
276 for file_path in self.manifest:
277 content = factory(file_path)
278 if content is None:
279 log.warning("Failed to load content file: %s" % file_path)
280 continue
282 self._files.append(content)
284 return len(self._files) > 0
287class Article(File):
289 def __init__(self, content, path, content_type="article", **kwargs):
290 self.attributes = kwargs
291 self.type = content_type
292 self._content = content
294 if 'tags' in kwargs:
295 a = list()
296 for i in kwargs.get("tags").split(","):
297 a.append(i.strip())
299 self.attributes['tags'] = a
301 super().__init__(path)
303 def __getattr__(self, item):
304 return self.attributes.get(item)
306 @property
307 def content(self):
308 """Get the Markdown content of the article.
310 :rtype: str
312 """
313 if self.type == "faqs":
314 a = list()
315 for i in self._content:
316 a.append(i.to_markdown())
318 return "\n".join(a)
319 elif self.type == "support":
320 return self._content.to_markdown()
321 elif self.type == "terms":
322 a = list()
323 for i in self._content:
324 a.append(i.to_markdown())
326 return "\n".join(a)
327 else:
328 return self._content
330 @property
331 def content_list(self):
332 """Get the content when it is a list of FAQ or Term instances.
334 :rtype: list
336 """
337 if self.type in ("faqs", "terms"):
338 return self._content
339 else:
340 return list()
342 def get_word_count(self):
343 """Get the total number of words.
345 :rtype: int
347 """
348 return len(self.content.split(" "))
350 @property
351 def slug(self):
352 return self.name
354 def to_html(self, extensions=SUPERDJANGO.SUPPORT_MARKDOWN_EXTENSIONS):
355 a = list()
356 for line in self.content.split("\n"):
357 a.append(parse_callout(line))
359 return mark_safe(markdown("\n".join(a), extensions=extensions))
361 @property
362 def url(self):
363 """Get the URL of the article.
365 :rtype: str
367 """
368 return "%sarticles/%s" % (SUPERDJANGO.SUPPORT_URL, self.slug)
371class FAQ(object):
373 def __init__(self, question, **kwargs):
374 self.answer = kwargs.pop("answer", None)
375 self.area = kwargs.pop("area", None)
376 self.category = kwargs.pop("category", None)
377 self.is_sticky = kwargs.pop("sticky", False)
378 self.question = question
379 self.tags = list()
381 if "tags" in kwargs:
382 _tags = kwargs.pop("tags")
383 for t in _tags.split(","):
384 self.tags.append(t.strip())
386 @property
387 def content(self):
388 return "%s: %s" % (self.question, self.answer)
390 @property
391 def path(self):
392 return os.path.join(SUPERDJANGO.SUPPORT_PATH, "faqs.ini")
394 @property
395 def slug(self):
396 return "faqs"
398 def to_markdown(self):
399 a = list()
400 a.append("**%s**" % self.question)
401 a.append("")
402 a.append("%s" % self.answer)
403 a.append("")
405 return "\n".join(a)
408class Manifest(File):
410 def __init__(self, path):
411 super().__init__(path)
413 self.is_loaded = False
414 self._files = list()
416 def __iter__(self):
417 return iter(self._files)
419 def load(self):
420 """Load file paths from the manifest.
422 :rtype: bool
424 """
425 if not self.exists:
426 log.warning("Manifest file does not exist: %s" % self.path)
427 return False
429 # Read the contents of the manifest.
430 content = read_file(self.path)
432 if "*" in content:
433 self._auto_load()
435 self.is_loaded = len(self._files) > 0
437 return self.is_loaded
439 # Count is used to identify missing files by line number.
440 count = 0
442 # Split into lines and iterate over the result.
443 lines = content.split("\n")
444 for relative_path in lines:
445 count += 1
447 if len(relative_path) == 0 or relative_path.startswith("#"):
448 continue
450 # Handle INI files.
451 if relative_path.endswith(".ini"):
452 file_path = os.path.join(self.directory, relative_path)
453 elif relative_path.startswith("snippets"):
454 file_path = os.path.join(self.directory, "snippets", relative_path + ".markdown")
455 else:
456 file_path = os.path.join(self.directory, "articles", relative_path + ".markdown")
458 # Determine whether the file exists.
459 if not os.path.exists(file_path):
460 log.warning("Could not locate file named in %s, line %s: %s" % (self.basename, count, relative_path))
461 continue
463 # Add the file to the inventory.
464 self._files.append(file_path)
466 self.is_loaded = len(self._files) > 0
468 return self.is_loaded
470 def _auto_load(self):
471 path = os.path.join(self.directory, "articles")
472 for root, dirs, files in os.walk(path):
473 files.sort()
474 for f in files:
475 self._files.append(os.path.join(path, f))
477 path = os.path.join(self.directory, "faqs.ini")
478 if os.path.exists(path):
479 self._files.append(path)
481 path = os.path.join(self.directory, "snippets")
482 if os.path.exists(path):
483 for root, dirs, files in os.walk(path):
484 files.sort()
485 for f in files:
486 self._files.append(os.path.join(path, f))
488 path = os.path.join(self.directory, "terms.ini")
489 if os.path.exists(path):
490 self._files.append(path)
493class Section(object):
494 """An object-oriented representation of a configuration section from an INI file See :py:class:`INIConfig`."""
496 def __init__(self, section_name, **kwargs):
497 """Initialize the section.
499 :param section_name: The section name.
500 :type section_name: str
502 Keyword arguments are added as context variables.
504 """
505 self._name = section_name
506 self._attributes = kwargs
508 def __getattr__(self, item):
509 return self._attributes.get(item)
512class Snippet(File):
513 """A fragment of content maintained separate from articles."""
515 def __init__(self, content, path, **kwargs):
516 self.attributes = kwargs
517 self.content = content
519 if 'tags' in kwargs:
520 a = list()
521 for i in kwargs.get("tags").split(","):
522 a.append(i.strip())
524 self.attributes['tags'] = a
526 super().__init__(path)
528 def __getattr__(self, item):
529 return self.attributes.get(item)
531 @property
532 def slug(self):
533 return "snippet-%s" % self.name
535 def to_html(self, extensions=SUPERDJANGO.SUPPORT_MARKDOWN_EXTENSIONS):
536 a = list()
537 for line in self.content.split("\n"):
538 a.append(parse_callout(line))
540 return mark_safe(markdown("\n".join(a), extensions=extensions))
543class Support(object):
545 def __init__(self, contact=None, email=None, phone=None, url=None, **kwargs):
546 self.attributes = kwargs
547 self.contact = contact
548 self.email = email
549 self.phone = phone
550 self.url = url
552 def __getattr__(self, item):
553 return self.attributes.get(item)
555 @property
556 def path(self):
557 return os.path.join(SUPERDJANGO.SUPPORT_PATH, "support.ini")
559 @property
560 def slug(self):
561 return "support"
563 def to_markdown(self):
564 a = list()
566 if self.contact:
567 a.append("**%s**" % self.contact)
568 a.append("")
570 if self.email:
571 a.append('<i class="fas fa-envelope-square"></i> %s ' % self.email)
573 if self.phone:
574 a.append('<i class="fas fa-phone-square"></i> %s ' % self.phone)
576 if self.url:
577 a.append('<i class="fas fa-external-link-square-alt"></i> %s ' % self.url)
579 if self.urls:
580 a.append("")
582 if self.urls.download:
583 a.append('- [Dowload](%s)' % self.urls.download)
585 if self.urls.content:
586 a.append('- [Documentation](%s)' % self.urls.content)
588 if self.urls.privacy_policy:
589 a.append("- [Privacy Policy](%s)" % self.urls.privacy_policy)
591 if self.urls.terms_of_use:
592 a.append("- [Terms of Use](%s)" % self.urls.terms_of_use)
594 a.append("")
596 if self.maint:
597 a.append("**Maintenance Window**")
598 a.append("")
600 a.append("%s at %s to %s at %s" % (
601 self.maint.starting_day,
602 self.maint.starting_hour,
603 self.maint.ending_day,
604 self.maint.ending_hour,
605 ))
606 a.append("")
608 return "\n".join(a)
611class Term(object):
613 def __init__(self, name, **kwargs):
614 self.area = kwargs.pop("area", None)
615 self.category = kwargs.pop("category", None)
616 self.definition = kwargs.pop("definition", None)
617 self.is_sticky = kwargs.pop("sticky", False)
618 self.tags = list()
619 self.name = name
621 if "tags" in kwargs:
622 _tags = kwargs.pop("tags")
623 for t in _tags.split(","):
624 self.tags.append(t.strip())
626 @property
627 def content(self):
628 return "%s: %s" % (self.name, self.definition)
630 @property
631 def path(self):
632 return os.path.join(SUPERDJANGO.SUPPORT_PATH, "terms.ini")
634 @property
635 def slug(self):
636 return "terms"
638 def to_markdown(self):
639 a = list()
640 a.append(self.name)
641 a.append(" : %s" % self.definition)
642 a.append("")
644 return "\n".join(a)
646'''
647# Might need this later.
648class Context(object):
649 """Collect variables together."""
651 def __init__(self, name, defaults=None):
652 """Initialize the context.
654 :param name: The name of the context.
655 :type name: str
657 :param defaults: Default values, if any.
658 :type defaults: dict
660 """
661 self._variables = dict()
662 self._name = name
664 if defaults is not None:
665 for key, value in defaults.items():
666 self._variables.setdefault(key, value)
668 def __getattr__(self, item):
669 """Access variables on the context instance."""
670 return self._variables.get(item)
672 def __iter__(self):
673 return iter(self._variables)
675 def __repr__(self):
676 return "<%s %s>" % (self.__class__.__name__, self._name)
678 def add(self, name, value):
679 """Add a variable to the context.
681 :param name: The name of the variable.
682 :type name: str
684 :param value: The value of the variable.
686 :raise: ValueError
687 :raises: ``ValueError`` if the named variable already exists.
689 """
690 if name in self._variables:
691 raise ValueError("The %s context already has a variable named: %s" % (self._name, name))
693 self._variables[name] = value
695 def get(self, name, default=None):
696 """Get the named variable at run time with an optional default.
698 :param name: The name of the variable.
699 :type name: str
701 :param default: The default value if any.
703 """
704 if self.has(name):
705 return self._variables[name]
707 return default
709 def get_name(self):
710 """Get the name of the context.
712 :rtype: str
714 """
715 return self._name
717 def has(self, name):
718 """Indicates the context has the named variable *and* that it is not ``None``.
720 :param name: The name of the variable.
721 :type name: str
723 :rtype: bool
725 """
726 if name in self._variables and self._variables[name] is not None:
727 return True
729 return False
731 def mapping(self):
732 """Get the context as a dictionary.
734 :rtype: dict
736 """
737 return self._variables
739 def set(self, name, value):
740 """Add or update a variable in the context.
742 :param name: The name of the variable.
743 :type name: str
745 :param value: The value of the variable.
747 .. note::
748 Unlike ``add()`` or ``update()`` an exception is *not* raised whether the variable exists or not.
750 """
751 self._variables[name] = value
753 def update(self, name, value):
754 """Update an existing variable in the context.
756 :param name: The name of the variable.
757 :type name: str
759 :param value: The value of the variable.
761 :raise: KeyError
762 :raises: ``KeyError`` if the named variable has not been defined.
764 """
765 if name not in self._variables:
766 raise KeyError("The %s context does not have a variable named: %s" % (self._name, name))
768 self._variables[name] = value
769'''