Coverage for webapp / store / views.py: 53%
231 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-05 22:09 +0000
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-05 22:09 +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
21from webapp.extensions import csrf
22from webapp.store.logic import (
23 get_categories,
24)
25from cache.cache_utility import redis_cache
27session = requests.Session()
29dashboard = Dashboard(api_session)
30publisher_gateway = PublisherGW("snap", api_publisher_session)
31device_gateway = DeviceGW("snap", api_session)
32snap_recommendations = SnapRecommendations(session)
35def store_blueprint(store_query=None):
36 store = flask.Blueprint(
37 "store",
38 __name__,
39 template_folder="/templates",
40 static_folder="/static",
41 )
42 snap_details_views(store)
44 @store.route("/validation-sets", defaults={"path": ""})
45 @store.route("/validation-sets/<path:path>")
46 @login_required
47 def validation_sets(path):
48 return flask.render_template("store/publisher.html")
50 @store.route("/discover")
51 def discover():
52 return flask.redirect(flask.url_for(".homepage"))
54 def brand_store_view():
55 error_info = {}
56 status_code = 200
58 try:
59 snaps = device_gateway.get_all_items(size=16)["results"]
60 except (StoreApiError, ApiError):
61 snaps = []
63 for snap in snaps:
64 if "media" in snap:
65 snap["icon_url"] = helpers.get_icon(snap["media"])
67 return (
68 flask.render_template(
69 "brand-store/store.html", snaps=snaps, error_info=error_info
70 ),
71 status_code,
72 )
74 def brand_search_snap():
75 status_code = 200
76 snap_searched = flask.request.args.get("q", default="", type=str)
78 if not snap_searched:
79 return flask.redirect(flask.url_for(".homepage"))
81 size = flask.request.args.get("limit", default=25, type=int)
82 offset = flask.request.args.get("offset", default=0, type=int)
84 try:
85 page = floor(offset / size) + 1
86 except ZeroDivisionError:
87 size = 10
88 page = floor(offset / size) + 1
90 error_info = {}
91 searched_results = []
93 searched_results = device_gateway.search(
94 snap_searched, size=size, page=page
95 )
97 snaps_results = searched_results["results"]
99 for snap in snaps_results:
100 snap["icon_url"] = helpers.get_icon(snap["media"])
102 links = logic.get_pages_details(
103 flask.request.base_url,
104 (
105 searched_results["_links"]
106 if "_links" in searched_results
107 else []
108 ),
109 )
111 context = {
112 "query": snap_searched,
113 "snaps": snaps_results,
114 "links": links,
115 "error_info": error_info,
116 }
118 return (
119 flask.render_template("brand-store/search.html", **context),
120 status_code,
121 )
123 @store.route("/store")
124 def store_view():
125 return flask.render_template("store/store.html")
127 @store.route("/explore")
128 def explore_view():
129 try:
130 popular_snaps = redis_cache.get(
131 "explore:popular-snaps", expected_type=list
132 )
133 if not popular_snaps:
134 popular_snaps = snap_recommendations.get_popular()
135 redis_cache.set(
136 "explore:popular-snaps", popular_snaps, ttl=3600
137 )
138 except api_requests.exceptions.RequestException:
139 popular_snaps = []
141 try:
142 recent_snaps = redis_cache.get(
143 "explore:recent-snaps", expected_type=list
144 )
145 if not recent_snaps:
146 recent_snaps = snap_recommendations.get_recent()
147 redis_cache.set("explore:recent-snaps", recent_snaps, ttl=3600)
148 except api_requests.exceptions.RequestException:
149 recent_snaps = []
151 try:
152 trending_snaps = redis_cache.get(
153 "explore:trending-snaps", expected_type=list
154 )
155 if not trending_snaps:
156 trending_snaps = snap_recommendations.get_trending()
157 redis_cache.set(
158 "explore:trending-snaps", trending_snaps, ttl=3600
159 )
160 except api_requests.exceptions.RequestException:
161 trending_snaps = []
163 try:
164 top_rated_snaps = redis_cache.get(
165 "explore:top-rated-snaps", expected_type=list
166 )
167 if not top_rated_snaps:
168 top_rated_snaps = snap_recommendations.get_top_rated()
169 redis_cache.set(
170 "explore:top-rated-snaps", top_rated_snaps, ttl=3600
171 )
172 except api_requests.exceptions.RequestException:
173 top_rated_snaps = []
175 try:
176 categories_results = redis_cache.get(
177 "explore:categories", expected_type=list
178 )
179 if not categories_results:
180 categories_results = device_gateway.get_categories()
181 redis_cache.set(
182 "explore:categories", categories_results, ttl=3600
183 )
184 except StoreApiError:
185 categories_results = []
187 categories = sorted(
188 get_categories(categories_results),
189 key=lambda category: category["slug"],
190 )
192 return flask.render_template(
193 "explore/index.html",
194 categories=categories,
195 popular_snaps=popular_snaps,
196 recent_snaps=recent_snaps,
197 trending_snaps=trending_snaps,
198 top_rated_snaps=top_rated_snaps,
199 )
201 @store.route("/publisher/<regex('[a-z0-9-]*[a-z][a-z0-9-]*'):publisher>")
202 def publisher_details(publisher):
203 """
204 A view to display the publisher details page for specific publisher.
205 """
207 # 404 for the snap-quarantine publisher
208 if publisher == "snap-quarantine":
209 flask.abort(404)
211 publisher_content_path = flask.current_app.config["CONTENT_DIRECTORY"][
212 "PUBLISHER_PAGES"
213 ]
215 # special handling for some featured publishers with custom pages
216 if publisher in ["kde", "snapcrafters", "jetbrains"]:
217 context = helpers.get_yaml(
218 publisher_content_path + publisher + ".yaml", typ="safe"
219 )
221 if not context:
222 flask.abort(404)
224 popular_snaps = helpers.get_yaml(
225 publisher_content_path + publisher + "-snaps.yaml",
226 typ="safe",
227 )
229 context["popular_snaps"] = (
230 popular_snaps["snaps"] if popular_snaps else []
231 )
233 context["snaps"] = []
234 snaps_results = []
235 try:
236 snaps_results = device_gateway.find(
237 publisher=publisher,
238 fields=[
239 "title",
240 "summary",
241 "media",
242 "publisher",
243 ],
244 )["results"]
245 except StoreApiError:
246 pass # proceed with an empty list
248 for snap in snaps_results:
249 item = snap["snap"]
250 item["package_name"] = snap["name"]
251 item["icon_url"] = helpers.get_icon(item["media"])
252 context["snaps"].append(item)
254 featured_snaps = [
255 snap["package_name"] for snap in context["featured_snaps"]
256 ]
258 context["snaps"] = [
259 snap
260 for snap in context["snaps"]
261 if snap["package_name"] not in featured_snaps
262 ]
264 context["snaps_count"] = len(context["snaps"]) + len(
265 featured_snaps
266 )
268 return flask.render_template(
269 "store/publisher-details.html", **context
270 )
272 # standard page for all community publishers
273 status_code = 200
274 error_info = {}
275 snaps_results = []
276 snaps = []
277 snaps_count = 0
278 publisher_details = {"display-name": publisher, "username": publisher}
280 snaps_results = device_gateway.find(
281 publisher=publisher,
282 fields=[
283 "title",
284 "summary",
285 "media",
286 "publisher",
287 ],
288 )["results"]
290 for snap in snaps_results:
291 item = snap["snap"]
292 item["package_name"] = snap["name"]
293 item["icon_url"] = helpers.get_icon(item["media"])
294 snaps.append(item)
296 snaps_count = len(snaps)
298 if snaps_count > 0:
299 publisher_details = snaps[0]["publisher"]
301 context = {
302 "snaps": snaps,
303 "snaps_count": snaps_count,
304 "publisher": publisher_details,
305 "error_info": error_info,
306 }
308 return (
309 flask.render_template(
310 "store/community-publisher-details.html", **context
311 ),
312 status_code,
313 )
315 @store.route("/store/categories/<category>")
316 def store_category(category):
317 status_code = 200
318 error_info = {}
319 snaps_results = []
321 snaps_results = device_gateway.get_category_items(
322 category=category, size=10, page=1
323 )["results"]
324 for snap in snaps_results:
325 snap["icon_url"] = helpers.get_icon(snap["media"])
327 # if the first snap (banner snap) doesn't have an icon, remove the last
328 # snap from the list to avoid a hanging snap (grid of 9)
329 if len(snaps_results) == 10 and snaps_results[0]["icon_url"] == "":
330 snaps_results = snaps_results[:-1]
332 for index in range(len(snaps_results)):
333 snaps_results[index] = logic.get_snap_banner_url(
334 snaps_results[index]
335 )
337 context = {
338 "category": category,
339 "has_featured": True,
340 "snaps": snaps_results,
341 "error_info": error_info,
342 }
344 return (
345 flask.render_template("store/_category-partial.html", **context),
346 status_code,
347 )
349 @store.route("/store/featured-snaps/<category>")
350 def featured_snaps_in_category(category):
351 snaps_results = []
353 snaps_results = device_gateway.get_category_items(
354 category=category, size=3, page=1
355 )["_embedded"]["clickindex:package"]
357 for snap in snaps_results:
358 snap["icon_url"] = helpers.get_icon(snap["media"])
360 return flask.jsonify(snaps_results)
362 @store.route("/store/sitemap.xml")
363 def sitemap():
364 sitemap = redis_cache.get("sitemap:xml", expected_type=str)
365 if sitemap:
366 response = flask.make_response(sitemap)
367 response.headers["Content-Type"] = "application/xml"
368 response.headers["Cache-Control"] = "public, max-age=43200"
369 return response
371 base_url = "https://snapcraft.io/store"
373 snaps = []
374 page = 0
375 url = f"https://api.snapcraft.io/api/v1/snaps/search?page={page}"
376 while url:
377 response = session.get(url)
378 try:
379 snaps_response = response.json()
380 except Exception:
381 continue
383 for snap in snaps_response["_embedded"]["clickindex:package"]:
384 try:
385 last_udpated = (
386 parser.parse(snap["last_updated"])
387 .replace(tzinfo=None)
388 .strftime("%Y-%m-%d")
389 )
390 snaps.append(
391 {
392 "url": "https://snapcraft.io/"
393 + snap["package_name"],
394 "last_udpated": last_udpated,
395 }
396 )
397 except Exception:
398 continue
399 if "next" in snaps_response["_links"]:
400 url = snaps_response["_links"]["next"]["href"]
401 else:
402 url = None
404 xml_sitemap = flask.render_template(
405 "sitemap/sitemap.xml",
406 base_url=base_url,
407 links=snaps,
408 )
410 # Cache the generated sitemap for 12 hours
411 redis_cache.set("sitemap:xml", xml_sitemap, ttl=43200)
413 response = flask.make_response(xml_sitemap)
414 response.headers["Content-Type"] = "application/xml"
415 response.headers["Cache-Control"] = "public, max-age=43200"
417 return response
419 if store_query:
420 store.add_url_rule("/", "homepage", brand_store_view)
421 store.add_url_rule("/search", "search", brand_search_snap)
422 else:
423 store.add_url_rule("/store", "homepage", store_view)
425 store.add_url_rule("/explore", "explore", explore_view)
427 @store.route("/<snap_name>/create-track", methods=["POST"])
428 @login_required
429 @csrf.exempt
430 @exchange_required
431 def post_create_track(snap_name):
432 track_name = flask.request.form["track-name"]
433 version_pattern = flask.request.form.get("version-pattern")
434 auto_phasing_percentage = flask.request.form.get(
435 "automatic-phasing-percentage"
436 )
438 if auto_phasing_percentage is not None:
439 auto_phasing_percentage = float(auto_phasing_percentage)
441 response = publisher_gateway.create_track(
442 flask.session,
443 snap_name,
444 track_name,
445 version_pattern,
446 auto_phasing_percentage,
447 )
448 if response.status_code == 201:
449 return response.json(), response.status_code
450 if response.status_code == 409:
451 return (
452 jsonify({"error": "Track already exists."}),
453 response.status_code,
454 )
455 if "error-list" in response.json():
456 return (
457 jsonify(
458 {"error": response.json()["error-list"][0]["message"]}
459 ),
460 response.status_code,
461 )
462 return response.json(), response.status_code
464 return store