Coverage for webapp/topics/views.py: 35%

97 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-27 22:07 +0000

1import json 

2from os import getenv 

3 

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 webapp.helpers import discourse_api 

11from jinja2 import Template 

12from bs4 import BeautifulSoup 

13from urllib.parse import urlparse 

14 

15from webapp.config import CATEGORIES 

16from webapp.observability.utils import trace_function 

17 

18DISCOURSE_API_KEY = getenv("DISCOURSE_API_KEY") 

19DISCOURSE_API_USERNAME = getenv("DISCOURSE_API_USERNAME") 

20ALLOWED_HOST = "charmhub.io" 

21 

22topics = Blueprint( 

23 "topics", __name__, template_folder="/templates", static_folder="/static" 

24) 

25 

26with open("webapp/topics/topics.json") as f: 

27 topic_list = json.load(f) 

28 

29 

30class TopicParser(DocParser): 

31 @trace_function 

32 def parse_topic(self, topic, docs_version=""): 

33 result = super().parse_topic(topic, docs_version) 

34 

35 soup = BeautifulSoup(result["body_html"], features="html.parser") 

36 self._parse_packages(soup) 

37 result["body_html"] = soup 

38 return result 

39 

40 @trace_function 

41 def _parse_packages(self, soup): 

42 """ 

43 Get a list of packages from all the 

44 packages tables in a topic 

45 

46 Example: 

47 | Charms | 

48 | -- | 

49 | https://charmhub.io/hello-kubecon | 

50 """ 

51 package_tables = [] 

52 

53 tables = soup.select("table:has(th:-soup-contains('Charms'))") 

54 

55 for table in tables: 

56 table_rows = table.select("tr:has(td)") 

57 

58 if table_rows: 

59 packages_set = {"soup_table": table, "packages": []} 

60 

61 # Get all packages URLs in this table 

62 for row in table_rows: 

63 navlink_href = row.find("a", href=True) 

64 

65 if navlink_href: 

66 navlink_href = navlink_href.get("href") 

67 parsed_url = urlparse(navlink_href) 

68 

69 if parsed_url.netloc != ALLOWED_HOST: 

70 self.warnings.append("Invalid tutorial URL") 

71 continue 

72 

73 # To avoid iframe issues with local development, demos 

74 navlink_href = navlink_href.replace( 

75 "https://charmhub.io/", request.url_root 

76 ) 

77 

78 packages_set["packages"].append(navlink_href) 

79 

80 package_tables.append(packages_set) 

81 

82 if package_tables: 

83 # Remplace tables with cards 

84 self._replace_packages(package_tables) 

85 

86 @trace_function 

87 def _replace_packages(self, package_tables): 

88 """ 

89 Replace charm tables to cards 

90 """ 

91 card_template = Template( 

92 ( 

93 '<div class="row">' 

94 "{% for package in packages %}" 

95 '<div class="col-small-5 col-medium-3 col-3">' 

96 '<iframe src="{{ package }}/embedded?store_design=true" ' 

97 'frameborder="0" width="100%" height="266px" ' 

98 'style="border: 0"></iframe>' 

99 "</div>" 

100 "{% endfor %}" 

101 "</div>" 

102 ) 

103 ) 

104 

105 for table in package_tables: 

106 card = card_template.render( 

107 packages=table["packages"], 

108 ) 

109 table["soup_table"].replace_with( 

110 BeautifulSoup(card, features="html.parser") 

111 ) 

112 

113 

114@trace_function 

115@topics.route("/topics.json") 

116def topics_json(): 

117 query = request.args.get("q", default=None, type=str) 

118 

119 if query: 

120 query = query.lower() 

121 matched = [] 

122 unmatched = [] 

123 

124 for t in topic_list: 

125 if query in t["name"].lower() or query in t["categories"]: 

126 matched.append(t) 

127 else: 

128 unmatched.append(t) 

129 results = matched + unmatched 

130 else: 

131 results = topic_list 

132 

133 return jsonify( 

134 { 

135 "topics": results, 

136 "q": query, 

137 "size": len(results), 

138 } 

139 ) 

140 

141 

142@trace_function 

143@topics.route("/topics") 

144def all_topics(): 

145 context = {} 

146 context["topics"] = topic_list 

147 context["categories"] = CATEGORIES 

148 return render_template("topics/index.html", **context) 

149 

150 

151@trace_function 

152@topics.route("/topics/<string:topic_slug>") 

153@topics.route("/topics/<string:topic_slug>/<path:path>") 

154def topic_page(topic_slug, path=None): 

155 topic = next((t for t in topic_list if t["slug"] == topic_slug), None) 

156 

157 if not topic: 

158 return abort(404) 

159 

160 topic_id = topic["topic_id"] 

161 docs_url_prefix = f"/topics/{topic_slug}" 

162 

163 docs = TopicParser( 

164 api=discourse_api, 

165 index_topic_id=topic_id, 

166 url_prefix=docs_url_prefix, 

167 tutorials_index_topic_id=2628, 

168 tutorials_url_prefix="https://juju.is/tutorials", 

169 ) 

170 docs.parse() 

171 

172 if path: 

173 try: 

174 topic_id = docs.resolve_path(path)[0] 

175 except PathNotFoundError: 

176 abort(404) 

177 except RedirectFoundError as path_redirect: 

178 return redirect(path_redirect.target_url) 

179 

180 topic = docs.api.get_topic(topic_id) 

181 else: 

182 topic = docs.index_topic 

183 

184 document = docs.parse_topic(topic) 

185 

186 context = { 

187 "navigation": docs.navigation, 

188 "forum_url": docs.api.base_url, 

189 "document": document, 

190 } 

191 

192 return render_template("topics/document.html", **context)