Coverage for webapp / store / views.py: 53%
241 statements
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-29 22:06 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2025-12-29 22:06 +0000
1from math import floor
3import requests as api_requests
4import flask
5from dateutil import parser
6from webapp.decorators import exchange_required, login_required
7import webapp.helpers as helpers
8import webapp.store.logic as logic
9from webapp.api import requests
11from canonicalwebteam.exceptions import StoreApiError
12from canonicalwebteam.store_api.dashboard import Dashboard
13from canonicalwebteam.store_api.publishergw import PublisherGW
14from canonicalwebteam.store_api.devicegw import DeviceGW
15from canonicalwebteam.snap_recommendations import SnapRecommendations
17from webapp.api.exceptions import ApiError
18from webapp.store.snap_details_views import snap_details_views
19from webapp.helpers import api_publisher_session, api_session
20from flask.json import jsonify
21import os
22from webapp.extensions import csrf
23from webapp.store.logic import (
24 get_categories,
25)
26from cache.cache_utility import redis_cache
28session = requests.Session()
30YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY")
31dashboard = Dashboard(api_session)
32publisher_gateway = PublisherGW("snap", api_publisher_session)
33device_gateway = DeviceGW("snap", api_session)
34snap_recommendations = SnapRecommendations(session)
37def store_blueprint(store_query=None):
38 store = flask.Blueprint(
39 "store",
40 __name__,
41 template_folder="/templates",
42 static_folder="/static",
43 )
44 snap_details_views(store)
46 @store.route("/validation-sets", defaults={"path": ""})
47 @store.route("/validation-sets/<path:path>")
48 @login_required
49 def validation_sets(path):
50 return flask.render_template("store/publisher.html")
52 @store.route("/discover")
53 def discover():
54 return flask.redirect(flask.url_for(".homepage"))
56 def brand_store_view():
57 error_info = {}
58 status_code = 200
60 try:
61 snaps = device_gateway.get_all_items(size=16)["results"]
62 except (StoreApiError, ApiError):
63 snaps = []
65 for snap in snaps:
66 if "media" in snap:
67 snap["icon_url"] = helpers.get_icon(snap["media"])
69 return (
70 flask.render_template(
71 "brand-store/store.html", snaps=snaps, error_info=error_info
72 ),
73 status_code,
74 )
76 def brand_search_snap():
77 status_code = 200
78 snap_searched = flask.request.args.get("q", default="", type=str)
80 if not snap_searched:
81 return flask.redirect(flask.url_for(".homepage"))
83 size = flask.request.args.get("limit", default=25, type=int)
84 offset = flask.request.args.get("offset", default=0, type=int)
86 try:
87 page = floor(offset / size) + 1
88 except ZeroDivisionError:
89 size = 10
90 page = floor(offset / size) + 1
92 error_info = {}
93 searched_results = []
95 searched_results = device_gateway.search(
96 snap_searched, size=size, page=page
97 )
99 snaps_results = searched_results["results"]
101 for snap in snaps_results:
102 snap["icon_url"] = helpers.get_icon(snap["media"])
104 links = logic.get_pages_details(
105 flask.request.base_url,
106 (
107 searched_results["_links"]
108 if "_links" in searched_results
109 else []
110 ),
111 )
113 context = {
114 "query": snap_searched,
115 "snaps": snaps_results,
116 "links": links,
117 "error_info": error_info,
118 }
120 return (
121 flask.render_template("brand-store/search.html", **context),
122 status_code,
123 )
125 @store.route("/store")
126 def store_view():
127 return flask.render_template("store/store.html")
129 @store.route("/explore")
130 def explore_view():
131 try:
132 popular_snaps = redis_cache.get(
133 "explore:popular-snaps", expected_type=list
134 )
135 if not popular_snaps:
136 popular_snaps = snap_recommendations.get_popular()
137 redis_cache.set(
138 "explore:popular-snaps", popular_snaps, ttl=3600
139 )
140 except api_requests.exceptions.RequestException:
141 popular_snaps = []
143 try:
144 recent_snaps = redis_cache.get(
145 "explore:recent-snaps", expected_type=list
146 )
147 if not recent_snaps:
148 recent_snaps = snap_recommendations.get_recent()
149 redis_cache.set("explore:recent-snaps", recent_snaps, ttl=3600)
150 except api_requests.exceptions.RequestException:
151 recent_snaps = []
153 try:
154 trending_snaps = redis_cache.get(
155 "explore:trending-snaps", expected_type=list
156 )
157 if not trending_snaps:
158 trending_snaps = snap_recommendations.get_trending()
159 redis_cache.set(
160 "explore:trending-snaps", trending_snaps, ttl=3600
161 )
162 except api_requests.exceptions.RequestException:
163 trending_snaps = []
165 try:
166 top_rated_snaps = redis_cache.get(
167 "explore:top-rated-snaps", expected_type=list
168 )
169 if not top_rated_snaps:
170 top_rated_snaps = snap_recommendations.get_top_rated()
171 redis_cache.set(
172 "explore:top-rated-snaps", top_rated_snaps, ttl=3600
173 )
174 except api_requests.exceptions.RequestException:
175 top_rated_snaps = []
177 try:
178 categories_results = redis_cache.get(
179 "explore:categories", expected_type=list
180 )
181 if not categories_results:
182 categories_results = device_gateway.get_categories()
183 redis_cache.set(
184 "explore:categories", categories_results, ttl=3600
185 )
186 except StoreApiError:
187 categories_results = []
189 categories = sorted(
190 get_categories(categories_results),
191 key=lambda category: category["slug"],
192 )
194 return flask.render_template(
195 "explore/index.html",
196 categories=categories,
197 popular_snaps=popular_snaps,
198 recent_snaps=recent_snaps,
199 trending_snaps=trending_snaps,
200 top_rated_snaps=top_rated_snaps,
201 )
203 @store.route("/youtube", methods=["POST"])
204 def get_video_thumbnail_data():
205 body = flask.request.form
206 thumbnail_url = "https://www.googleapis.com/youtube/v3/videos"
207 thumbnail_data = session.get(
208 (
209 f"{thumbnail_url}?id={body['videoId']}"
210 f"&part=snippet&key={YOUTUBE_API_KEY}"
211 )
212 )
214 if thumbnail_data:
215 return thumbnail_data.json()
217 return {}
219 @store.route("/publisher/<regex('[a-z0-9-]*[a-z][a-z0-9-]*'):publisher>")
220 def publisher_details(publisher):
221 """
222 A view to display the publisher details page for specific publisher.
223 """
225 # 404 for the snap-quarantine publisher
226 if publisher == "snap-quarantine":
227 flask.abort(404)
229 publisher_content_path = flask.current_app.config["CONTENT_DIRECTORY"][
230 "PUBLISHER_PAGES"
231 ]
233 # special handling for some featured publishers with custom pages
234 if publisher in ["kde", "snapcrafters", "jetbrains"]:
235 context = helpers.get_yaml(
236 publisher_content_path + publisher + ".yaml", typ="safe"
237 )
239 if not context:
240 flask.abort(404)
242 popular_snaps = helpers.get_yaml(
243 publisher_content_path + publisher + "-snaps.yaml",
244 typ="safe",
245 )
247 context["popular_snaps"] = (
248 popular_snaps["snaps"] if popular_snaps else []
249 )
251 context["snaps"] = []
252 snaps_results = []
253 try:
254 snaps_results = device_gateway.find(
255 publisher=publisher,
256 fields=[
257 "title",
258 "summary",
259 "media",
260 "publisher",
261 ],
262 )["results"]
263 except StoreApiError:
264 pass # proceed with an empty list
266 for snap in snaps_results:
267 item = snap["snap"]
268 item["package_name"] = snap["name"]
269 item["icon_url"] = helpers.get_icon(item["media"])
270 context["snaps"].append(item)
272 featured_snaps = [
273 snap["package_name"] for snap in context["featured_snaps"]
274 ]
276 context["snaps"] = [
277 snap
278 for snap in context["snaps"]
279 if snap["package_name"] not in featured_snaps
280 ]
282 context["snaps_count"] = len(context["snaps"]) + len(
283 featured_snaps
284 )
286 return flask.render_template(
287 "store/publisher-details.html", **context
288 )
290 # standard page for all community publishers
291 status_code = 200
292 error_info = {}
293 snaps_results = []
294 snaps = []
295 snaps_count = 0
296 publisher_details = {"display-name": publisher, "username": publisher}
298 snaps_results = device_gateway.find(
299 publisher=publisher,
300 fields=[
301 "title",
302 "summary",
303 "media",
304 "publisher",
305 ],
306 )["results"]
308 for snap in snaps_results:
309 item = snap["snap"]
310 item["package_name"] = snap["name"]
311 item["icon_url"] = helpers.get_icon(item["media"])
312 snaps.append(item)
314 snaps_count = len(snaps)
316 if snaps_count > 0:
317 publisher_details = snaps[0]["publisher"]
319 context = {
320 "snaps": snaps,
321 "snaps_count": snaps_count,
322 "publisher": publisher_details,
323 "error_info": error_info,
324 }
326 return (
327 flask.render_template(
328 "store/community-publisher-details.html", **context
329 ),
330 status_code,
331 )
333 @store.route("/store/categories/<category>")
334 def store_category(category):
335 status_code = 200
336 error_info = {}
337 snaps_results = []
339 snaps_results = device_gateway.get_category_items(
340 category=category, size=10, page=1
341 )["results"]
342 for snap in snaps_results:
343 snap["icon_url"] = helpers.get_icon(snap["media"])
345 # if the first snap (banner snap) doesn't have an icon, remove the last
346 # snap from the list to avoid a hanging snap (grid of 9)
347 if len(snaps_results) == 10 and snaps_results[0]["icon_url"] == "":
348 snaps_results = snaps_results[:-1]
350 for index in range(len(snaps_results)):
351 snaps_results[index] = logic.get_snap_banner_url(
352 snaps_results[index]
353 )
355 context = {
356 "category": category,
357 "has_featured": True,
358 "snaps": snaps_results,
359 "error_info": error_info,
360 }
362 return (
363 flask.render_template("store/_category-partial.html", **context),
364 status_code,
365 )
367 @store.route("/store/featured-snaps/<category>")
368 def featured_snaps_in_category(category):
369 snaps_results = []
371 snaps_results = device_gateway.get_category_items(
372 category=category, size=3, page=1
373 )["_embedded"]["clickindex:package"]
375 for snap in snaps_results:
376 snap["icon_url"] = helpers.get_icon(snap["media"])
378 return flask.jsonify(snaps_results)
380 @store.route("/store/sitemap.xml")
381 def sitemap():
382 sitemap = redis_cache.get("sitemap:xml", expected_type=str)
383 if sitemap:
384 response = flask.make_response(sitemap)
385 response.headers["Content-Type"] = "application/xml"
386 response.headers["Cache-Control"] = "public, max-age=43200"
387 return response
389 base_url = "https://snapcraft.io/store"
391 snaps = []
392 page = 0
393 url = f"https://api.snapcraft.io/api/v1/snaps/search?page={page}"
394 while url:
395 response = session.get(url)
396 try:
397 snaps_response = response.json()
398 except Exception:
399 continue
401 for snap in snaps_response["_embedded"]["clickindex:package"]:
402 try:
403 last_udpated = (
404 parser.parse(snap["last_updated"])
405 .replace(tzinfo=None)
406 .strftime("%Y-%m-%d")
407 )
408 snaps.append(
409 {
410 "url": "https://snapcraft.io/"
411 + snap["package_name"],
412 "last_udpated": last_udpated,
413 }
414 )
415 except Exception:
416 continue
417 if "next" in snaps_response["_links"]:
418 url = snaps_response["_links"]["next"]["href"]
419 else:
420 url = None
422 xml_sitemap = flask.render_template(
423 "sitemap/sitemap.xml",
424 base_url=base_url,
425 links=snaps,
426 )
428 # Cache the generated sitemap for 12 hours
429 redis_cache.set("sitemap:xml", xml_sitemap, ttl=43200)
431 response = flask.make_response(xml_sitemap)
432 response.headers["Content-Type"] = "application/xml"
433 response.headers["Cache-Control"] = "public, max-age=43200"
435 return response
437 if store_query:
438 store.add_url_rule("/", "homepage", brand_store_view)
439 store.add_url_rule("/search", "search", brand_search_snap)
440 else:
441 store.add_url_rule("/store", "homepage", store_view)
443 store.add_url_rule("/explore", "explore", explore_view)
445 @store.route("/<snap_name>/create-track", methods=["POST"])
446 @login_required
447 @csrf.exempt
448 @exchange_required
449 def post_create_track(snap_name):
450 track_name = flask.request.form["track-name"]
451 version_pattern = flask.request.form.get("version-pattern")
452 auto_phasing_percentage = flask.request.form.get(
453 "automatic-phasing-percentage"
454 )
456 if auto_phasing_percentage is not None:
457 auto_phasing_percentage = float(auto_phasing_percentage)
459 response = publisher_gateway.create_track(
460 flask.session,
461 snap_name,
462 track_name,
463 version_pattern,
464 auto_phasing_percentage,
465 )
466 if response.status_code == 201:
467 return response.json(), response.status_code
468 if response.status_code == 409:
469 return (
470 jsonify({"error": "Track already exists."}),
471 response.status_code,
472 )
473 if "error-list" in response.json():
474 return (
475 jsonify(
476 {"error": response.json()["error-list"][0]["message"]}
477 ),
478 response.status_code,
479 )
480 return response.json(), response.status_code
482 return store