Coverage for webapp/topics/views.py: 32%
109 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 22:11 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-18 22:11 +0000
1import json
2from os import getenv
4from canonicalwebteam.discourse import DocParser
5from canonicalwebteam.discourse.exceptions import (
6 PathNotFoundError,
7 RedirectFoundError,
8)
9from flask import Blueprint, abort, jsonify, render_template, request, redirect
10from redis_cache.cache_utility import redis_cache
11from webapp.helpers import discourse_api
12from jinja2 import Template
13from bs4 import BeautifulSoup
14from urllib.parse import urlparse
16from webapp.config import CATEGORIES
17from webapp.observability.utils import trace_function
19DISCOURSE_API_KEY = getenv("DISCOURSE_API_KEY")
20DISCOURSE_API_USERNAME = getenv("DISCOURSE_API_USERNAME")
21ALLOWED_HOST = "charmhub.io"
23topics = Blueprint(
24 "topics", __name__, template_folder="/templates", static_folder="/static"
25)
27with open("webapp/topics/topics.json") as f:
28 topic_list = json.load(f)
31class TopicParser(DocParser):
32 @trace_function
33 def parse_topic(self, topic, docs_version=""):
34 result = super().parse_topic(topic, docs_version)
36 soup = BeautifulSoup(result["body_html"], features="html.parser")
37 self._parse_packages(soup)
38 result["body_html"] = str(soup)
39 return result
41 @trace_function
42 def _parse_packages(self, soup):
43 """
44 Get a list of packages from all the
45 packages tables in a topic
47 Example:
48 | Charms |
49 | -- |
50 | https://charmhub.io/hello-kubecon |
51 """
52 package_tables = []
54 tables = soup.select("table:has(th:-soup-contains('Charms'))")
56 for table in tables:
57 table_rows = table.select("tr:has(td)")
59 if table_rows:
60 packages_set = {"soup_table": table, "packages": []}
62 # Get all packages URLs in this table
63 for row in table_rows:
64 navlink_href = row.find("a", href=True)
66 if navlink_href:
67 navlink_href = navlink_href.get("href")
68 parsed_url = urlparse(navlink_href)
70 if parsed_url.netloc != ALLOWED_HOST:
71 self.warnings.append("Invalid tutorial URL")
72 continue
74 # To avoid iframe issues with local development, demos
75 navlink_href = navlink_href.replace(
76 "https://charmhub.io/", request.url_root
77 )
79 packages_set["packages"].append(navlink_href)
81 package_tables.append(packages_set)
83 if package_tables:
84 # Remplace tables with cards
85 self._replace_packages(package_tables)
87 @trace_function
88 def _replace_packages(self, package_tables):
89 """
90 Replace charm tables to cards
91 """
92 card_template = Template(
93 (
94 '<div class="row">'
95 "{% for package in packages %}"
96 '<div class="col-small-5 col-medium-3 col-3">'
97 '<iframe src="{{ package }}/embedded?store_design=true" '
98 'frameborder="0" width="100%" height="266px" '
99 'style="border: 0"></iframe>'
100 "</div>"
101 "{% endfor %}"
102 "</div>"
103 )
104 )
106 for table in package_tables:
107 card = card_template.render(
108 packages=table["packages"],
109 )
110 table["soup_table"].replace_with(
111 BeautifulSoup(card, features="html.parser")
112 )
115@trace_function
116@topics.route("/topics.json")
117def topics_json():
118 query = request.args.get("q", default=None, type=str)
119 q = None if query in (None, "", "null") else query
120 key = ("topics-json", {"q": q})
121 results = redis_cache.get(key, expected_type=list)
122 if results:
123 return jsonify(
124 {
125 "topics": results,
126 "q": query,
127 "size": len(results),
128 }
129 )
130 else:
131 if query:
132 query = query.lower()
133 matched = []
134 unmatched = []
136 for t in topic_list:
137 if query in t["name"].lower() or query in t["categories"]:
138 matched.append(t)
139 else:
140 unmatched.append(t)
141 results = matched + unmatched
142 else:
143 results = topic_list
144 redis_cache.set(key, results, ttl=43200)
146 return jsonify(
147 {
148 "topics": results,
149 "q": query,
150 "size": len(results),
151 }
152 )
155@trace_function
156@topics.route("/topics")
157def all_topics():
158 context = {}
159 context["topics"] = topic_list
160 context["categories"] = CATEGORIES
161 return render_template("topics/index.html", **context)
164@trace_function
165@topics.route("/topics/<string:topic_slug>")
166@topics.route("/topics/<string:topic_slug>/<path:path>")
167def topic_page(topic_slug, path=None):
168 key = ("topic-page", {"topic_slug": topic_slug, "path": path})
169 cached_page = redis_cache.get(key, expected_type=dict)
170 if cached_page:
171 return render_template("topics/document.html", **cached_page)
172 topic = next((t for t in topic_list if t["slug"] == topic_slug), None)
174 if not topic:
175 return abort(404)
177 topic_id = topic["topic_id"]
178 docs_url_prefix = f"/topics/{topic_slug}"
180 docs = TopicParser(
181 api=discourse_api,
182 index_topic_id=topic_id,
183 url_prefix=docs_url_prefix,
184 tutorials_index_topic_id=2628,
185 tutorials_url_prefix="https://juju.is/tutorials",
186 )
187 docs.parse()
189 if path:
190 try:
191 topic_id = docs.resolve_path(path)[0]
192 except PathNotFoundError:
193 abort(404)
194 except RedirectFoundError as path_redirect:
195 return redirect(path_redirect.target_url)
197 topic = docs.api.get_topic(topic_id)
198 else:
199 topic = docs.index_topic
201 document = docs.parse_topic(topic)
202 context = {
203 "navigation": docs.navigation,
204 "forum_url": docs.api.base_url,
205 "document": document,
206 }
207 redis_cache.set(key, context, ttl=3600)
209 return render_template("topics/document.html", **context)