Hide keyboard shortcuts

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

4 

5# Imports 

6 

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 

15 

16log = logging.getLogger(__name__) 

17 

18# Constants 

19 

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

21 

22# Functions 

23 

24 

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 

42 

43 if "snippets" in path: 

44 return Snippet(content, path, **meta) 

45 

46 return Article(content, path, **meta) 

47 

48 

49def parse_callout(string): 

50 """Parse a given string as callout text. 

51 

52 :param string: The string that might contain a callout. 

53 :type string: str 

54 

55 :rtype: str 

56 

57 .. note:: 

58 If not callout is found, the string is returned as is. 

59 

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 

95 

96 

97def read_ini_file(content_class, path): 

98 """Load an INI file. 

99 

100 :param content_class: The content class to use. 

101 :type content_class: class 

102 

103 :param path: The path to the file. 

104 :type path: str 

105 

106 :returns: A list of Area, FAQ or Term instances, or a Support instance. 

107 

108 :raise: TypeError 

109 

110 """ 

111 ini = ConfigParser() 

112 ini.read(path) 

113 

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) 

120 

121 area = Area(name, **_kwargs) 

122 areas.append(area) 

123 

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) 

131 

132 faq = FAQ(question, **_kwargs) 

133 faqs.append(faq) 

134 

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) 

146 

147 _kwargs[section] = Section(section, **_section_kwargs) 

148 

149 return Support(**_kwargs) 

150 

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) 

157 

158 term = Term(name, **_kwargs) 

159 terms.append(term) 

160 

161 return terms 

162 else: 

163 raise TypeError("Unsupported content class for INI file: %s" % content_class.__name__) 

164 

165 

166def read_markdown_file(path): 

167 """Load a Markdown file. 

168 

169 :param path: The path to the file. 

170 :type path: str 

171 

172 :rtype: tuple(str, dict) 

173 :returns: The content and meta data. 

174 

175 """ 

176 with open(path, "r") as f: 

177 try: 

178 page = frontmatter.load(f) 

179 meta = page.metadata.copy() 

180 content = page.content 

181 

182 return content, meta 

183 

184 except ScannerError as e: 

185 log.warning("Failed to load page front matter: %s (%s)" % (path, e)) 

186 finally: 

187 f.close() 

188 

189 return None, None 

190 

191# Classes 

192 

193 

194class Area(object): 

195 """An overall area of content.""" 

196 

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 

203 

204 @property 

205 def articles(self): 

206 """Alias for ``get_articles()``.""" 

207 return self.get_articles() 

208 

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) 

214 

215 return a 

216 

217 

218class Content(object): 

219 """The content database.""" 

220 

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() 

228 

229 def fetch(self, slug): 

230 for c in self._files: 

231 if c.slug == slug: 

232 return c 

233 

234 return None 

235 

236 def get_content_files(self): 

237 """Get the content files acquired using ``load()``. 

238 

239 :rtype: list 

240 

241 """ 

242 return self._files 

243 

244 def load(self): 

245 """Load all help content. 

246 

247 :rtype: bool 

248 

249 """ 

250 if not self.manifest.load(): 

251 log.error("Failed to load manifest.") 

252 return False 

253 

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 

260 

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 

265 

266 self.is_loaded = self._load_content_files() 

267 

268 return self.is_loaded 

269 

270 def _load_content_files(self): 

271 """Load content files found in the manifest. 

272 

273 :rtype: bool 

274 

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 

281 

282 self._files.append(content) 

283 

284 return len(self._files) > 0 

285 

286 

287class Article(File): 

288 

289 def __init__(self, content, path, content_type="article", **kwargs): 

290 self.attributes = kwargs 

291 self.type = content_type 

292 self._content = content 

293 

294 if 'tags' in kwargs: 

295 a = list() 

296 for i in kwargs.get("tags").split(","): 

297 a.append(i.strip()) 

298 

299 self.attributes['tags'] = a 

300 

301 super().__init__(path) 

302 

303 def __getattr__(self, item): 

304 return self.attributes.get(item) 

305 

306 @property 

307 def content(self): 

308 """Get the Markdown content of the article. 

309 

310 :rtype: str 

311 

312 """ 

313 if self.type == "faqs": 

314 a = list() 

315 for i in self._content: 

316 a.append(i.to_markdown()) 

317 

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()) 

325 

326 return "\n".join(a) 

327 else: 

328 return self._content 

329 

330 @property 

331 def content_list(self): 

332 """Get the content when it is a list of FAQ or Term instances. 

333 

334 :rtype: list 

335 

336 """ 

337 if self.type in ("faqs", "terms"): 

338 return self._content 

339 else: 

340 return list() 

341 

342 def get_word_count(self): 

343 """Get the total number of words. 

344 

345 :rtype: int 

346 

347 """ 

348 return len(self.content.split(" ")) 

349 

350 @property 

351 def slug(self): 

352 return self.name 

353 

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)) 

358 

359 return mark_safe(markdown("\n".join(a), extensions=extensions)) 

360 

361 @property 

362 def url(self): 

363 """Get the URL of the article. 

364 

365 :rtype: str 

366 

367 """ 

368 return "%sarticles/%s" % (SUPERDJANGO.SUPPORT_URL, self.slug) 

369 

370 

371class FAQ(object): 

372 

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() 

380 

381 if "tags" in kwargs: 

382 _tags = kwargs.pop("tags") 

383 for t in _tags.split(","): 

384 self.tags.append(t.strip()) 

385 

386 @property 

387 def content(self): 

388 return "%s: %s" % (self.question, self.answer) 

389 

390 @property 

391 def path(self): 

392 return os.path.join(SUPERDJANGO.SUPPORT_PATH, "faqs.ini") 

393 

394 @property 

395 def slug(self): 

396 return "faqs" 

397 

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("") 

404 

405 return "\n".join(a) 

406 

407 

408class Manifest(File): 

409 

410 def __init__(self, path): 

411 super().__init__(path) 

412 

413 self.is_loaded = False 

414 self._files = list() 

415 

416 def __iter__(self): 

417 return iter(self._files) 

418 

419 def load(self): 

420 """Load file paths from the manifest. 

421 

422 :rtype: bool 

423 

424 """ 

425 if not self.exists: 

426 log.warning("Manifest file does not exist: %s" % self.path) 

427 return False 

428 

429 # Read the contents of the manifest. 

430 content = read_file(self.path) 

431 

432 if "*" in content: 

433 self._auto_load() 

434 

435 self.is_loaded = len(self._files) > 0 

436 

437 return self.is_loaded 

438 

439 # Count is used to identify missing files by line number. 

440 count = 0 

441 

442 # Split into lines and iterate over the result. 

443 lines = content.split("\n") 

444 for relative_path in lines: 

445 count += 1 

446 

447 if len(relative_path) == 0 or relative_path.startswith("#"): 

448 continue 

449 

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

457 

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 

462 

463 # Add the file to the inventory. 

464 self._files.append(file_path) 

465 

466 self.is_loaded = len(self._files) > 0 

467 

468 return self.is_loaded 

469 

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)) 

476 

477 path = os.path.join(self.directory, "faqs.ini") 

478 if os.path.exists(path): 

479 self._files.append(path) 

480 

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)) 

487 

488 path = os.path.join(self.directory, "terms.ini") 

489 if os.path.exists(path): 

490 self._files.append(path) 

491 

492 

493class Section(object): 

494 """An object-oriented representation of a configuration section from an INI file See :py:class:`INIConfig`.""" 

495 

496 def __init__(self, section_name, **kwargs): 

497 """Initialize the section. 

498 

499 :param section_name: The section name. 

500 :type section_name: str 

501 

502 Keyword arguments are added as context variables. 

503 

504 """ 

505 self._name = section_name 

506 self._attributes = kwargs 

507 

508 def __getattr__(self, item): 

509 return self._attributes.get(item) 

510 

511 

512class Snippet(File): 

513 """A fragment of content maintained separate from articles.""" 

514 

515 def __init__(self, content, path, **kwargs): 

516 self.attributes = kwargs 

517 self.content = content 

518 

519 if 'tags' in kwargs: 

520 a = list() 

521 for i in kwargs.get("tags").split(","): 

522 a.append(i.strip()) 

523 

524 self.attributes['tags'] = a 

525 

526 super().__init__(path) 

527 

528 def __getattr__(self, item): 

529 return self.attributes.get(item) 

530 

531 @property 

532 def slug(self): 

533 return "snippet-%s" % self.name 

534 

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)) 

539 

540 return mark_safe(markdown("\n".join(a), extensions=extensions)) 

541 

542 

543class Support(object): 

544 

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 

551 

552 def __getattr__(self, item): 

553 return self.attributes.get(item) 

554 

555 @property 

556 def path(self): 

557 return os.path.join(SUPERDJANGO.SUPPORT_PATH, "support.ini") 

558 

559 @property 

560 def slug(self): 

561 return "support" 

562 

563 def to_markdown(self): 

564 a = list() 

565 

566 if self.contact: 

567 a.append("**%s**" % self.contact) 

568 a.append("") 

569 

570 if self.email: 

571 a.append('<i class="fas fa-envelope-square"></i> %s ' % self.email) 

572 

573 if self.phone: 

574 a.append('<i class="fas fa-phone-square"></i> %s ' % self.phone) 

575 

576 if self.url: 

577 a.append('<i class="fas fa-external-link-square-alt"></i> %s ' % self.url) 

578 

579 if self.urls: 

580 a.append("") 

581 

582 if self.urls.download: 

583 a.append('- [Dowload](%s)' % self.urls.download) 

584 

585 if self.urls.content: 

586 a.append('- [Documentation](%s)' % self.urls.content) 

587 

588 if self.urls.privacy_policy: 

589 a.append("- [Privacy Policy](%s)" % self.urls.privacy_policy) 

590 

591 if self.urls.terms_of_use: 

592 a.append("- [Terms of Use](%s)" % self.urls.terms_of_use) 

593 

594 a.append("") 

595 

596 if self.maint: 

597 a.append("**Maintenance Window**") 

598 a.append("") 

599 

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("") 

607 

608 return "\n".join(a) 

609 

610 

611class Term(object): 

612 

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 

620 

621 if "tags" in kwargs: 

622 _tags = kwargs.pop("tags") 

623 for t in _tags.split(","): 

624 self.tags.append(t.strip()) 

625 

626 @property 

627 def content(self): 

628 return "%s: %s" % (self.name, self.definition) 

629 

630 @property 

631 def path(self): 

632 return os.path.join(SUPERDJANGO.SUPPORT_PATH, "terms.ini") 

633 

634 @property 

635 def slug(self): 

636 return "terms" 

637 

638 def to_markdown(self): 

639 a = list() 

640 a.append(self.name) 

641 a.append(" : %s" % self.definition) 

642 a.append("") 

643 

644 return "\n".join(a) 

645 

646''' 

647# Might need this later. 

648class Context(object): 

649 """Collect variables together.""" 

650 

651 def __init__(self, name, defaults=None): 

652 """Initialize the context. 

653 

654 :param name: The name of the context. 

655 :type name: str 

656 

657 :param defaults: Default values, if any. 

658 :type defaults: dict 

659 

660 """ 

661 self._variables = dict() 

662 self._name = name 

663 

664 if defaults is not None: 

665 for key, value in defaults.items(): 

666 self._variables.setdefault(key, value) 

667 

668 def __getattr__(self, item): 

669 """Access variables on the context instance.""" 

670 return self._variables.get(item) 

671 

672 def __iter__(self): 

673 return iter(self._variables) 

674 

675 def __repr__(self): 

676 return "<%s %s>" % (self.__class__.__name__, self._name) 

677 

678 def add(self, name, value): 

679 """Add a variable to the context. 

680 

681 :param name: The name of the variable. 

682 :type name: str 

683 

684 :param value: The value of the variable. 

685 

686 :raise: ValueError 

687 :raises: ``ValueError`` if the named variable already exists. 

688 

689 """ 

690 if name in self._variables: 

691 raise ValueError("The %s context already has a variable named: %s" % (self._name, name)) 

692 

693 self._variables[name] = value 

694 

695 def get(self, name, default=None): 

696 """Get the named variable at run time with an optional default. 

697 

698 :param name: The name of the variable. 

699 :type name: str 

700 

701 :param default: The default value if any. 

702 

703 """ 

704 if self.has(name): 

705 return self._variables[name] 

706 

707 return default 

708 

709 def get_name(self): 

710 """Get the name of the context. 

711 

712 :rtype: str 

713 

714 """ 

715 return self._name 

716 

717 def has(self, name): 

718 """Indicates the context has the named variable *and* that it is not ``None``. 

719 

720 :param name: The name of the variable. 

721 :type name: str 

722 

723 :rtype: bool 

724 

725 """ 

726 if name in self._variables and self._variables[name] is not None: 

727 return True 

728 

729 return False 

730 

731 def mapping(self): 

732 """Get the context as a dictionary. 

733 

734 :rtype: dict 

735 

736 """ 

737 return self._variables 

738 

739 def set(self, name, value): 

740 """Add or update a variable in the context. 

741 

742 :param name: The name of the variable. 

743 :type name: str 

744 

745 :param value: The value of the variable. 

746 

747 .. note:: 

748 Unlike ``add()`` or ``update()`` an exception is *not* raised whether the variable exists or not. 

749 

750 """ 

751 self._variables[name] = value 

752 

753 def update(self, name, value): 

754 """Update an existing variable in the context. 

755 

756 :param name: The name of the variable. 

757 :type name: str 

758 

759 :param value: The value of the variable. 

760 

761 :raise: KeyError 

762 :raises: ``KeyError`` if the named variable has not been defined. 

763 

764 """ 

765 if name not in self._variables: 

766 raise KeyError("The %s context does not have a variable named: %s" % (self._name, name)) 

767 

768 self._variables[name] = value 

769'''