Coverage for webapp/helpers.py: 66%
130 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 re
2import json
3from urllib.parse import urlparse, urlencode
5from bs4 import BeautifulSoup
6from ruamel.yaml import YAML
7from slugify import slugify
8from datetime import datetime, timedelta
9import mistune
10import bleach
11from canonicalwebteam.discourse import DiscourseAPI
12from dateutil import parser
13import requests
15session = requests.Session()
16discourse_api = DiscourseAPI(
17 base_url="https://discourse.charmhub.io/",
18 session=session,
19)
21_yaml = YAML(typ="rt")
22_yaml_safe = YAML(typ="safe")
25def get_yaml_loader(typ="safe"):
26 if typ == "safe":
27 return _yaml_safe
28 return _yaml
31def is_safe_url(url):
32 """
33 Return True if the URL is inside the same app
34 """
35 if not url:
36 return False
38 cleaned_url = url.strip()
40 if cleaned_url.startswith(("/", "\\")) and not cleaned_url.startswith(
41 ("//", "\\\\", "/\\", "\\/")
42 ):
43 parsed = urlparse(cleaned_url)
44 return not parsed.scheme and not parsed.netloc
46 return False
49def get_soup(html_content):
50 soup = BeautifulSoup(html_content, "html.parser")
51 return soup
54# Change all the headers (if step=2: eg h1 => h3)
55def decrease_header(header, step):
56 level = int(header.name[1:]) + step
57 if level > 6:
58 level = 6
59 header.name = f"h{str(level)}"
61 return header
64def add_header_id(h, levels):
65 id = slugify(h.get_text())
66 level = int(h.name[1:])
68 # Go through previous headings and find any that are lower
69 levels.append((level, id))
70 reversed_levels = list(reversed(levels))
71 parents = []
72 level_cache = None
73 for i in reversed_levels:
74 if i[0] < level and not level_cache:
75 parents.append(i)
76 level_cache = i[0]
77 elif i[0] < level and i[0] < level_cache:
78 parents.append(i)
79 level_cache = i[0]
80 parents.reverse()
81 if "id" not in h.attrs:
82 parent_path_id = ""
83 if len(parents) > 0:
84 parent_path_id = "--".join([i[1] for i in parents]) + "--"
85 h["id"] = parent_path_id + id
87 return h
90def modify_headers(soup, decrease_step=2):
91 levels = []
93 for header in soup.find_all(re.compile("^h[1-6]$")):
94 decrease_header(header, decrease_step)
95 add_header_id(header, levels)
97 return soup
100def schedule_banner(start_date: str, end_date: str):
101 try:
102 end = datetime.strptime(end_date, "%Y-%m-%d")
103 start = datetime.strptime(start_date, "%Y-%m-%d")
104 present = datetime.now()
105 return start <= present < end
106 except ValueError:
107 return False
110def markdown_to_html(markdown_text):
111 markdown = mistune.create_markdown(renderer=mistune.HTMLRenderer())
112 return markdown(markdown_text)
115ALLOWED_HTML_TAGS = set(bleach.sanitizer.ALLOWED_TAGS).union(
116 {
117 "br",
118 "code",
119 "div",
120 "h1",
121 "h2",
122 "h3",
123 "h4",
124 "h5",
125 "h6",
126 "hr",
127 "img",
128 "p",
129 "pre",
130 "span",
131 "table",
132 "tbody",
133 "td",
134 "tfoot",
135 "th",
136 "thead",
137 "tr",
138 }
139)
140ALLOWED_HTML_ATTRIBUTES = {
141 **bleach.sanitizer.ALLOWED_ATTRIBUTES,
142 "*": ["class", "id"],
143 "a": ["href", "rel", "target", "title"],
144 "img": ["alt", "height", "src", "title", "width"],
145 "td": ["colspan", "rowspan"],
146 "th": ["colspan", "rowspan"],
147}
148ALLOWED_HTML_PROTOCOLS = ["http", "https", "mailto", "tel"]
151def sanitize_html(html_content):
152 if not html_content:
153 return ""
154 return bleach.clean(
155 html_content,
156 tags=ALLOWED_HTML_TAGS,
157 attributes=ALLOWED_HTML_ATTRIBUTES,
158 protocols=ALLOWED_HTML_PROTOCOLS,
159 strip=True,
160 )
163def param_redirect_capture(req, resp):
164 """
165 Functions that captures params and sets a cookie based on a match
166 with a predefined list.
167 """
168 # Signatures to capture in a cookie
169 param_signatures = [
170 {"endpoint": "/accept-invite", "params": ["package", "token"]}
171 ]
172 path = req.path
173 params = req.args
175 for item in param_signatures:
176 # If the endpoint and all required params are present
177 if item["endpoint"] == path and set(item["params"]).issubset(
178 set(params)
179 ):
180 param_values = {}
181 for param in item["params"]:
182 param_values[param] = params[param]
183 # Set the cookie
184 resp.set_cookie(
185 "param_redirect",
186 json.dumps(
187 {"endpoint": item["endpoint"], "params": param_values}
188 ),
189 # Set expiration for 10 days in the future
190 expires=datetime.now() + timedelta(days=10),
191 secure=True,
192 httponly=True,
193 )
195 return resp
198def param_redirect_exec(req, make_response, redirect):
199 """
200 Function that returns a response, redirecting based on
201 a matched cookie
202 """
203 # Get cookie data
204 encoded_redirect_data = req.cookies.get("param_redirect")
206 if encoded_redirect_data:
207 redirect_data = json.loads(encoded_redirect_data)
208 # Only redirect if the current path matches the redirect endpoint
209 if req.path == redirect_data["endpoint"]:
210 query_string = urlencode(redirect_data["params"])
211 response = make_response(
212 redirect(f"{redirect_data['endpoint']}?{query_string}")
213 )
214 response.set_cookie(
215 "param_redirect", "", expires=0, secure=True, httponly=True
216 )
217 return response
218 return None
221def get_csp_as_str(csp={}):
222 csp_str = ""
223 for key, values in csp.items():
224 csp_value = " ".join(values)
225 csp_str += f"{key} {csp_value}; "
226 return csp_str.strip()
229def humanize_date(date_str):
230 if not date_str:
231 return ""
232 date_obj = parser.parse(date_str)
233 return date_obj.strftime("%-d %B %Y")
236def format_solution_status(status):
237 if not status:
238 return status
239 return status.replace("_", " ").title()
242def get_solution_form_value(
243 form_data, solution, form_field_name, solution_path=None, default=""
244):
245 """
246 When updating a solution, get form field value
247 When first loading edit form, shows current solution values
248 When re-displaying form after validation errors,
249 shows user's submitted values
251 E.g.:
252 get_solution_form_value(form_data, solution, 'title')
253 # checks: form_data.title -> solution.title -> ""
254 """
255 if form_data and form_field_name in form_data:
256 return form_data.get(form_field_name, default)
258 if solution:
259 path = solution_path if solution_path else form_field_name
261 current_value = solution
262 for key in path.split("."):
263 if isinstance(current_value, dict) and key in current_value:
264 current_value = current_value[key]
265 else:
266 return default
268 return current_value if current_value is not None else default
270 return default