Coverage for webapp/store/views.py: 49%
484 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
1from urllib.parse import urlparse
3import humanize
4from canonicalwebteam.discourse import DocParser
5from canonicalwebteam.discourse.exceptions import PathNotFoundError
6from canonicalwebteam.flask_base.decorators import (
7 exclude_xframe_options_header,
8)
9from canonicalwebteam.exceptions import StoreApiResponseErrorList
10from flask import Blueprint, Response, abort, url_for
11from flask import jsonify, redirect, render_template, request, make_response
12import requests
13from badgepy import badge
15from redis_cache.cache_utility import redis_cache
16from webapp.store_api import publisher_gateway
17from webapp.config import DETAILS_VIEW_REGEX
18from webapp.decorators import (
19 redirect_uppercase_to_lowercase,
20 store_maintenance,
21)
22from webapp.helpers import discourse_api, markdown_to_html, sanitize_html
23from webapp.store import logic
24from webapp.config import SEARCH_FIELDS
25from webapp.observability.utils import trace_function
26from webapp.store_api import device_gateway, device_gateway_sbom
28store = Blueprint(
29 "store", __name__, template_folder="/templates", static_folder="/static"
30)
33@trace_function
34def get_libraries(entity_name):
35 key = f"{entity_name}:libraries"
36 libraries = redis_cache.get(key, expected_type=list)
37 if libraries:
38 return libraries
39 libraries = logic.process_libraries(
40 publisher_gateway.get_charm_libraries(entity_name)
41 )
42 redis_cache.set(key, libraries, ttl=600)
43 return libraries
46@trace_function
47def get_package_details(entity_name, channel_request=None, fields=[]):
48 key = (
49 f"package_details:{entity_name}",
50 {"channel": channel_request, "fields": ",".join(sorted(fields))},
51 )
52 package_details = redis_cache.get(key, expected_type=dict)
53 if package_details:
54 return package_details
55 package_details = device_gateway.get_item_details(
56 entity_name, channel=channel_request, fields=fields
57 )
58 redis_cache.set(key, package_details, ttl=600)
59 return package_details
62@store.route("/publisher/<regex('[a-z0-9-]*[a-z][a-z0-9-]*'):publisher>")
63def get_publisher_details(publisher):
64 """
65 A view to display the publisher details page for specific publisher.
66 """
68 error_info = {}
69 items = []
70 extra_fields = [
71 "result.publisher",
72 "result.description",
73 ]
75 response = device_gateway.find(
76 publisher=publisher,
77 fields=SEARCH_FIELDS.copy() + extra_fields,
78 )
80 for package in response["results"]:
81 item = package["result"]
82 item["name"] = package["name"]
83 item["type"] = package["type"]
84 item["icon"] = next(
85 (media for media in item["media"] if media["type"] == "icon"), None
86 )
87 items.append(item)
89 context = {
90 "items": items,
91 "items_count": len(items),
92 "publisher": (
93 items[0]["publisher"]
94 if len(items) > 0
95 else {"display-name": publisher}
96 ),
97 "error_info": error_info,
98 }
100 # HTML template will be returned here for the front end
101 return render_template("details/publisher.html", **context)
104@trace_function
105@store.route("/packages.json")
106def get_packages():
107 query = request.args.get("q", default=None, type=str)
108 provides = request.args.get("provides", default=None, type=str)
109 requires = request.args.get("requires", default=None, type=str)
111 key = (
112 "packages.json",
113 ({"q": query, "provides": provides, "requires": requires}),
114 )
115 context = redis_cache.get(key, expected_type=dict)
116 if context:
117 return context
119 context = {"packages": [], "size": 0}
121 if query:
122 results = publisher_gateway.find(
123 query=query, fields=SEARCH_FIELDS
124 ).get("results")
125 context["q"] = query
126 elif provides or requires:
127 if provides:
128 provides = provides.split(",")
129 if requires:
130 requires = requires.split(",")
132 results = publisher_gateway.find(
133 provides=provides, requires=requires, fields=SEARCH_FIELDS
134 ).get("results")
136 context["provides"] = provides
137 context["requires"] = requires
138 else:
139 results = publisher_gateway.find(fields=SEARCH_FIELDS).get(
140 "results", []
141 )
143 packages = []
144 total_packages = 0
146 for i, _ in enumerate(results):
147 total_packages += 1
148 package = logic.add_store_front_data(results[i], False)
149 packages.append(package)
151 context["packages"] = packages
152 context["size"] = total_packages
153 redis_cache.set(key, context, ttl=600)
154 return context
157FIELDS = [
158 "result.media",
159 "default-release",
160 "result.categories",
161 "result.publisher.display-name",
162 "result.title",
163 "result.unlisted",
164 "channel-map",
165 "result.deployable-on",
166]
169@trace_function
170def get_package(entity_name, channel_request=None, fields=FIELDS, path=None):
171 # Get entity info from API
172 key = (
173 f"package:{entity_name}{':' + path if path else ''}",
174 {"channel": channel_request, "fields": ",".join(sorted(fields))},
175 )
177 package = redis_cache.get(key, expected_type=dict)
178 if package:
179 return package
180 package = get_package_details(entity_name, channel_request, fields)
181 # If the package is not published, return a 404
182 if not package.get("default-release"):
183 abort(404)
185 # Fix issue #1010
186 if channel_request:
187 channel_map = get_package_details(entity_name, fields=["channel-map"])
188 package["channel-map"] = channel_map["channel-map"]
190 package = logic.add_store_front_data(package, True)
191 package = logic.add_overlay_data(package)
193 for channel in package["channel-map"]:
194 channel["channel"]["released-at"] = logic.convert_date(
195 channel["channel"]["released-at"]
196 )
197 redis_cache.set(key, package, ttl=600)
198 return package
201@trace_function
202def package_has_sboms(revisions, package_id):
203 if not revisions:
204 return False
206 sbom_path = f"download/sbom_charm_{package_id}_{revisions[0]}.spdx2.3.json"
207 endpoint = device_gateway_sbom.get_endpoint_url(sbom_path)
209 res = requests.head(endpoint)
211 # backend returns 302 instead of 200 for a successful request
212 # adding the check for 200 in case this is changed without us knowing
213 if res.status_code == 200 or res.status_code == 302:
214 return True
216 return False
219@trace_function
220@store.route("/download/sbom_charm_<package_id>_<revision>.spdx2.3.json")
221def get_sbom(package_id, revision):
222 sbom_path = f"download/sbom_charm_{package_id}_{revision}.spdx2.3.json"
223 endpoint = device_gateway_sbom.get_endpoint_url(sbom_path)
225 res = requests.get(endpoint)
227 return jsonify(res.json())
230@trace_function
231@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>')
232@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/docs')
233@store_maintenance
234@redirect_uppercase_to_lowercase
235def details_overview(entity_name):
236 channel_request = request.args.get("channel", default=None, type=str)
237 if request.base_url.endswith("/docs"):
238 url = f"/{entity_name}"
239 if channel_request:
240 url = f"{url}?channel={channel_request}"
241 return redirect(url)
243 extra_fields = [
244 "result.bugs-url",
245 "result.website",
246 "result.summary",
247 "default-release.revision.metadata-yaml",
248 "default-release.revision.readme-md",
249 "result.links",
250 ]
251 all_fields = FIELDS.copy() + extra_fields
252 package = get_package(entity_name, channel_request, all_fields)
253 key = (
254 f"{entity_name}:details-overview",
255 {"channel": channel_request, "fields": ",".join(sorted(all_fields))},
256 )
257 context = redis_cache.get(key, expected_type=dict)
258 if context:
259 return render_template("details/overview.html", **context)
260 context = {
261 "package": package,
262 "channel_requested": channel_request,
263 "navigation": None,
264 "last_update": None,
265 "forum_url": None,
266 "topic_path": None,
267 }
269 description = None
270 summary = None
272 doc = logic.get_doc_link(package)
274 # If the doc link does NOT point at the Discourse instance,
275 # it is accepted as a ReadTheDocs (or other externally-hosted) link.
276 is_rtd = (
277 doc and urlparse(doc).netloc != urlparse(discourse_api.base_url).netloc
278 )
280 docs_topic = package["store_front"].get("docs_topic")
281 if is_rtd:
282 readme = (
283 package.get("default-release", {})
284 .get("revision", {})
285 .get("readme-md")
286 )
287 summary = logic.get_summary(package)
288 description = sanitize_html(
289 markdown_to_html(readme)
290 if readme
291 else logic.get_description(package, parse_to_html=True)
292 )
293 navigation = None
294 elif docs_topic:
295 docs_url_prefix = f"/{package['name']}/docs"
297 docs = DocParser(
298 api=discourse_api,
299 index_topic_id=package["store_front"]["docs_topic"],
300 url_prefix=docs_url_prefix,
301 )
302 try:
303 docs.parse()
304 topic = docs.index_topic
305 docs_content = docs.parse_topic(topic)
306 description = sanitize_html(docs_content.get("body_html", ""))
308 navigation = docs.navigation
310 overview = {
311 "hidden": False,
312 "level": 1,
313 "path": "",
314 "navlink_href": f"/{entity_name}",
315 "navlink_fragment": "",
316 "navlink_text": "Overview",
317 "is_active": True,
318 "has_active_child": False,
319 "children": [],
320 }
322 if len(navigation["nav_items"]) > 0:
323 navigation["nav_items"][0]["children"].insert(0, overview)
324 # If the first item in docs nav is "overview",
325 # prefix with "Docs - "
326 if (
327 len(navigation["nav_items"][0]["children"]) > 1
328 and navigation["nav_items"][0]["children"][1][
329 "navlink_text"
330 ]
331 == "Overview"
332 ):
333 del navigation["nav_items"][0]["children"][1]
334 else:
335 # If there is no navigation but we've got here, there are docs
336 # So add a top level "Docs item". Example: /easyrsa
337 navigation["nav_items"] = [
338 {
339 "level": 0,
340 "children": [
341 overview,
342 ],
343 }
344 ]
346 context["navigation"] = navigation
347 context["forum_url"] = docs.api.base_url
348 context["last_update"] = docs_content["updated"]
349 context["topic_path"] = docs_content["topic_path"]
350 except Exception as e:
351 if e.response.status_code == 404:
352 navigation = None
353 description = sanitize_html(
354 logic.get_description(package, parse_to_html=True)
355 )
356 summary = logic.get_summary(package)
357 else:
358 navigation = None
359 description = sanitize_html(
360 logic.get_description(package, parse_to_html=True)
361 )
362 summary = logic.get_summary(package)
364 revisions = logic.get_revisions(package["channel-map"])
366 context["description"] = description
367 context["summary"] = summary
368 context["package_type"] = package["type"]
369 context["doc_url"] = doc
370 context["is_rtd"] = is_rtd
371 context["has_sboms"] = package_has_sboms(
372 revisions, context["package"]["id"]
373 )
374 redis_cache.set(key, context, ttl=3600)
375 return render_template("details/overview.html", **context)
378@trace_function
379@store.route(
380 '/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/docs/<path:path>'
381)
382@store_maintenance
383@redirect_uppercase_to_lowercase
384def details_docs(entity_name, path=None):
385 channel_request = request.args.get("channel", default=None, type=str)
386 extra_fields = [
387 "default-release.revision.metadata-yaml",
388 "result.bugs-url",
389 "result.website",
390 ]
391 all_fields = FIELDS.copy() + extra_fields
392 package = get_package(entity_name, channel_request, all_fields, path)
394 # If no docs, redirect to main page
395 if not package["store_front"]["docs_topic"]:
396 return redirect(url_for(".details_overview", entity_name=entity_name))
397 key = (
398 f"{entity_name}:details-docs:{path}",
399 {"channel": channel_request, "fields": ",".join(sorted(all_fields))},
400 )
401 context = redis_cache.get(key, expected_type=dict)
402 if context:
403 return render_template("details/docs.html", **context)
404 docs_url_prefix = f"/{package['name']}/docs"
406 docs = DocParser(
407 api=discourse_api,
408 index_topic_id=package["store_front"]["docs_topic"],
409 url_prefix=docs_url_prefix,
410 )
411 docs.parse()
413 if path:
414 topic_id = None
415 try:
416 topic_id = docs.resolve_path(path)[0]
417 except PathNotFoundError:
418 abort(404)
420 topic = docs.api.get_topic(topic_id)
421 else:
422 topic = docs.index_topic
424 document = docs.parse_topic(topic)
426 body_html = sanitize_html(document.get("body_html", ""))
428 navigation = docs.navigation
430 overview = {
431 "hidden": False,
432 "level": 1,
433 "path": "",
434 "navlink_href": f"/{entity_name}",
435 "navlink_fragment": "",
436 "navlink_text": "Overview",
437 "is_active": False,
438 "has_active_child": False,
439 "children": [],
440 }
442 if len(navigation["nav_items"]) > 0:
443 navigation["nav_items"][0]["children"].insert(0, overview)
444 # If the first item in docs nav is "overview",
445 # prefix with "Docs - "
446 if (
447 len(navigation["nav_items"][0]["children"]) > 1
448 and navigation["nav_items"][0]["children"][1]["navlink_text"]
449 == "Overview"
450 ):
451 del navigation["nav_items"][0]["children"][1]
452 else:
453 # If there is no navigation but we've got here, there are docs
454 # So add a top level "Docs item". Example: /easyrsa
455 navigation["nav_items"] = [
456 {
457 "level": 0,
458 "children": [
459 overview,
460 ],
461 }
462 ]
464 context = {
465 "package": package,
466 "navigation": navigation,
467 "body_html": body_html,
468 "last_update": document["updated"],
469 "forum_url": docs.api.base_url,
470 "topic_path": document["topic_path"],
471 "channel_requested": channel_request,
472 }
473 redis_cache.set(key, context, ttl=3600)
474 return render_template("details/docs.html", **context)
477@trace_function
478@store.route(
479 '/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/configurations'
480)
481@store.route(
482 '/<regex("'
483 + DETAILS_VIEW_REGEX
484 + '"):entity_name>/configurations/<path:path>'
485)
486@store_maintenance
487@redirect_uppercase_to_lowercase
488def details_configuration(entity_name, path=None):
489 channel_request = request.args.get("channel", default=None, type=str)
490 extra_fields = [
491 "default-release.revision.config-yaml",
492 ]
494 package = get_package(
495 entity_name, channel_request, FIELDS.copy() + extra_fields, path
496 )
497 subpackage = None
499 if package["type"] == "bundle":
500 bundle_charms = package["store_front"]["bundle"]["charms"]
502 if not path and bundle_charms:
503 default_charm = bundle_charms[0]
504 return redirect(
505 url_for(
506 ".details_configuration",
507 entity_name=entity_name,
508 path=default_charm["name"],
509 channel=channel_request,
510 )
511 )
513 if path:
514 if not any(d["name"] == path for d in bundle_charms):
515 abort(404)
517 try:
518 subpackage = get_package(path)
519 except StoreApiResponseErrorList:
520 subpackage = None
522 return render_template(
523 f"details/configure-{package['type']}.html",
524 package=package,
525 subpackage=subpackage,
526 channel_requested=channel_request,
527 subpackage_path=path,
528 )
531@trace_function
532@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/actions')
533@store_maintenance
534@redirect_uppercase_to_lowercase
535def details_actions(entity_name):
536 channel_request = request.args.get("channel", default=None, type=str)
537 extra_fields = [
538 "default-release.revision.actions-yaml",
539 ]
541 package = get_package(
542 entity_name, channel_request, FIELDS.copy() + extra_fields
543 )
544 return render_template(
545 "details/actions.html",
546 package=package,
547 channel_requested=channel_request,
548 )
551@trace_function
552@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/libraries')
553@store_maintenance
554@redirect_uppercase_to_lowercase
555def details_libraries(entity_name):
556 channel_request = request.args.get("channel", default=None, type=str)
557 package = get_package(entity_name, channel_request, FIELDS)
559 libraries = get_libraries(entity_name)
561 if libraries:
562 first_lib = libraries[0]["name"]
563 return redirect(
564 url_for(
565 ".details_library",
566 entity_name=entity_name,
567 library_name=first_lib,
568 )
569 )
571 return render_template(
572 "details/libraries/no-libraries.html",
573 entity_name=entity_name,
574 package=package,
575 libraries=libraries,
576 channel_requested=channel_request,
577 )
580@trace_function
581@store.route(
582 '/<regex("'
583 + DETAILS_VIEW_REGEX
584 + '"):entity_name>/libraries/<string:library_name>'
585)
586@store_maintenance
587@redirect_uppercase_to_lowercase
588def details_library(entity_name, library_name):
589 channel_request = request.args.get("channel", default=None, type=str)
590 package = get_package(entity_name, channel_request, FIELDS)
592 libraries = get_libraries(entity_name)
594 library_id = logic.get_library(library_name, libraries)
596 if not library_id:
597 abort(404)
599 library = publisher_gateway.get_charm_library(entity_name, library_id)
600 docstrings = logic.process_python_docs(library, module_name=library_name)
602 # Charmcraft string to fetch the library
603 fetch_charm = entity_name.replace("-", "_")
604 fetch_api = library["api"]
605 fetch_string = f"charms.{fetch_charm}.v{fetch_api}.{library_name}"
607 return render_template(
608 "details/libraries/docstring.html",
609 entity_name=entity_name,
610 package=package,
611 libraries=libraries,
612 library=library,
613 docstrings=docstrings,
614 channel_requested=channel_request,
615 library_name=library_name,
616 fetch_string=fetch_string,
617 creation_date=logic.convert_date(library["created-at"]),
618 )
621@trace_function
622@store.route(
623 '/<regex("'
624 + DETAILS_VIEW_REGEX
625 + '"):entity_name>/libraries/<string:library_name>/source-code'
626)
627@store_maintenance
628@redirect_uppercase_to_lowercase
629def details_library_source_code(entity_name, library_name):
630 channel_request = request.args.get("channel", default=None, type=str)
631 package = get_package(entity_name, channel_request, FIELDS)
633 libraries = get_libraries(entity_name)
635 library_id = logic.get_library(library_name, libraries)
636 if not library_id:
637 abort(404)
639 library = publisher_gateway.get_charm_library(entity_name, library_id)
640 source_code = library.get("source-code", "")
642 return render_template(
643 "details/libraries/source-code.html",
644 entity_name=entity_name,
645 package=package,
646 libraries=libraries,
647 library=library,
648 source_code=source_code,
649 channel_requested=channel_request,
650 library_name=library_name,
651 )
654@trace_function
655@store.route(
656 '/<regex("'
657 + DETAILS_VIEW_REGEX
658 + '"):entity_name>/libraries/<string:library_name>/download'
659)
660@store_maintenance
661@redirect_uppercase_to_lowercase
662def download_library(entity_name, library_name):
663 lib_parts = library_name.split(".")
665 if len(lib_parts) > 2:
666 lib_name = "." + ".".join(lib_parts[-2:])
667 else:
668 lib_name = library_name
670 libraries = get_libraries(entity_name)
672 library = next(
673 (lib for lib in libraries if lib.get("name") == lib_name),
674 None,
675 )
677 if not library:
678 abort(404)
680 library = publisher_gateway.get_charm_library(entity_name, library["id"])
682 return Response(
683 library["content"],
684 mimetype="text/x-python",
685 headers={
686 "Content-disposition": f"attachment; filename={library['library-name']}.py"
687 },
688 )
691@trace_function
692@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/integrations')
693@store_maintenance
694@redirect_uppercase_to_lowercase
695def details_integrations(entity_name):
696 channel_request = request.args.get("channel", default=None, type=str)
697 extra_fields = [
698 "default-release.revision.metadata-yaml",
699 ]
700 package = get_package(entity_name, channel_request, FIELDS + extra_fields)
702 return render_template(
703 "details/integrations.html",
704 package=package,
705 channel_requested=channel_request,
706 )
709@trace_function
710@store.route(
711 '/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/integrations.json'
712)
713@store_maintenance
714@redirect_uppercase_to_lowercase
715def details_integrations_data(entity_name):
716 channel_request = request.args.get("channel", default=None, type=str)
717 extra_fields = [
718 "default-release.revision.metadata-yaml",
719 ]
720 package = get_package(entity_name, channel_request, FIELDS + extra_fields)
722 relations = (
723 package.get("default-release", {})
724 .get("revision", {})
725 .get("relations", {})
726 )
728 provides = add_required_fields(
729 package["store_front"]["metadata"].get("provides", {}),
730 relations.get("provides", {}),
731 )
732 requires = add_required_fields(
733 package["store_front"]["metadata"].get("requires", {}),
734 relations.get("requires", {}),
735 )
737 grouped_relations = {
738 "provides": provides,
739 "requires": requires,
740 }
742 return jsonify({"grouped_relations": grouped_relations})
745@trace_function
746def add_required_fields(metadata_relations, relations):
747 processed_relations = [
748 {
749 **relations[key],
750 "key": key,
751 "required": not metadata_relations[key].get("optional", False),
752 }
753 for key in relations.keys()
754 ]
755 return processed_relations
758@trace_function
759@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/resources')
760@store_maintenance
761@redirect_uppercase_to_lowercase
762def details_resources(entity_name):
763 channel_request = request.args.get("channel", default=None, type=str)
764 package = get_package(entity_name, channel_request, FIELDS)
766 # /resources redirect to the first resource
767 if package["default-release"]["resources"]:
768 name = package["default-release"]["resources"][0]["name"]
769 return redirect(
770 url_for(
771 ".details_resource",
772 entity_name=entity_name,
773 resource_name=name,
774 )
775 )
776 else:
777 return render_template(
778 "details/no-resources.html",
779 package=package,
780 channel_requested=channel_request,
781 )
784@trace_function
785@store.route(
786 '/<regex("'
787 + DETAILS_VIEW_REGEX
788 + '"):entity_name>/resources/<string:resource_name>'
789)
790@store_maintenance
791@redirect_uppercase_to_lowercase
792def details_resource(entity_name, resource_name):
793 channel_request = request.args.get("channel", default=None, type=str)
794 package = get_package(entity_name, channel_request, FIELDS)
795 resources = package["default-release"]["resources"]
797 if not resources:
798 return redirect(url_for(".details_resources", entity_name=entity_name))
800 resource = next(
801 (item for item in resources if item["name"] == resource_name), None
802 )
804 if not resource:
805 return redirect(url_for(".details_resources", entity_name=entity_name))
807 # Get OCI image details
808 if resource["type"] == "oci-image":
809 oci_details = publisher_gateway.process_response(
810 publisher_gateway.session.get(resource["download"]["url"])
811 )
812 resource["image_name"], resource["digest"] = oci_details[
813 "ImageName"
814 ].split("@")
815 resource["short_digest"] = resource["digest"].split(":")[1][:12]
817 # Get upstream-source (if available)
818 metadata_resources = package["store_front"]["metadata"].get(
819 "resources", {}
820 )
821 if resource_name in metadata_resources:
822 upstream = metadata_resources[resource_name].get("upstream-source")
823 resource["upstream_source"] = upstream
824 else:
825 resource["upstream_source"] = None
827 revisions = device_gateway.get_resource_revisions(
828 entity_name, resource_name
829 )
830 revisions = sorted(revisions, key=lambda k: k["revision"], reverse=True)
832 # Humanize sizes
833 resource["size"] = humanize.naturalsize(resource["download"]["size"])
834 resource["updated"] = logic.convert_date(resource["created-at"])
836 for revision in revisions:
837 revision["size"] = humanize.naturalsize(revision["download"]["size"])
838 revision["updated"] = logic.convert_date(revision["created-at"])
840 return render_template(
841 "details/resources.html",
842 package=package,
843 channel_requested=channel_request,
844 resource=resource,
845 revisions=revisions,
846 )
849@trace_function
850@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/integrate')
851@store_maintenance
852@redirect_uppercase_to_lowercase
853def details_integrate(entity_name):
854 channel_request = request.args.get("channel", default=None, type=str)
855 package = get_package(entity_name, channel_request, FIELDS)
857 return render_template(
858 "details/integrate.html",
859 package=package,
860 channel_requested=channel_request,
861 )
864@trace_function
865@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/badge.svg')
866def entity_badge(entity_name):
867 package = get_package_details(entity_name, fields=FIELDS)
869 channel_request = request.args.get("channel")
870 revision_request = request.args.get("revision")
872 if not package["default-release"]:
873 abort(404)
875 release = package["default-release"]
876 valid_revision_request = None
878 if channel_request:
879 for release_channel in package["channel-map"]:
880 channel = release_channel["channel"]
881 if f"{channel['track']}/{channel['risk']}" == channel_request:
882 release = release_channel
883 break
885 if revision_request:
886 if str(release["revision"]["revision"]) == revision_request:
887 valid_revision_request = revision_request
888 elif channel_request:
889 for release_channel in package["channel-map"]:
890 channel = release_channel["channel"]
891 if (
892 f"{channel['track']}/{channel['risk']}" == channel_request
893 and str(release_channel["revision"]["revision"])
894 == revision_request
895 ):
896 valid_revision_request = revision_request
897 break
899 entity_link = request.url_root + entity_name
900 right_text = "".join(
901 [
902 release["channel"]["track"],
903 "/",
904 release["channel"]["risk"],
905 " ",
906 valid_revision_request or release["revision"]["version"],
907 ]
908 )
910 svg = badge(
911 left_text=package["name"],
912 right_text=right_text,
913 right_color="#0e8420",
914 left_link=entity_link,
915 right_link=entity_link,
916 logo=(
917 "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' "
918 "viewBox='0 0 64 64'%3E%3Cg fill-rule='evenodd' "
919 "fill='none'%3E%3Cg fill-rule='nonzero'%3E%3Ccircle cy='32' "
920 "cx='32' r='32' fill='%23fff'/%3E%3Cg transform='translate(13.622"
921 " 5.6546)' fill='%23585858'%3E%3Ccircle cy='24.614' cx='20.307' "
922 "r='1.9732'/%3E%3Cpath d='m22.129 20.971h-3.643v-14.571c0-3.5154"
923 " 2.86-6.3753 6.375-6.3753 3.515-0.000025 6.375 2.8599 6.375 6."
924 "375v3.6433h-3.643v-3.6433c0-0.7297-0.284-1.4159-0.8-1.932-0.51"
925 "6-0.5159-1.202-0.8002-1.932-0.8002-1.506 0-2.732 1.2255-2.732 "
926 "2.7322v14.571z'/%3E%3Cpath d='m33.968 27.346c-3.515 0-6.375-2."
927 "859-6.375-6.375v-9.107h3.643v9.107c0 1.507 1.226 2.732 2.732 2."
928 "732 1.507 0 2.733-1.225 2.733-2.732v-9.107h3.642v9.107c0 1.703"
929 "-0.663 3.304-1.867 4.508s-2.805 1.867-4.508 1.867z'/%3E%3Ccircle"
930 " cy='46.471' cx='2.093' r='1.9732'/%3E%3Cpath d='m3.9143 42.829"
931 "h-3.6429l0.00002-20.036c0-3.515 2.86-6.375 6.3751-6.375 3.5155 0"
932 " 6.3755 2.86 6.3755 6.375v3.643h-3.6433v-3.643c0-0.73-0.284-1."
933 "416-0.8001-1.932-0.5159-0.516-1.2022-0.8-1.9319-0.8-1.5064 0-2."
934 "7322 1.225-2.7322 2.732l-0.0002 20.036z'/%3E%3Cpath d='m15.754 "
935 "43.74c-3.516 0-6.3753-2.86-6.3753-6.376v-9.107h3.6433v9.107c0 "
936 "1.506 1.225 2.732 2.732 2.732 1.506 0 2.732-1.226 2.732-2.732v-"
937 "9.107h3.643v9.107c0 1.703-0.663 3.304-1.867 4.508-1.205 1.204-2"
938 ".805 1.868-4.508 1.868z'/%3E%3C/g%3E%3C/g%3E%3C/g%3E%3C/svg%3E"
939 ),
940 )
942 return svg, 200, {"Content-Type": "image/svg+xml"}
945@trace_function
946@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/embedded')
947@exclude_xframe_options_header
948def entity_embedded_card(entity_name):
949 store_design = request.args.get("store_design", default=False, type=bool)
950 channel_request = request.args.get("channel", default=None, type=str)
951 try:
952 package = get_package(entity_name, channel_request, FIELDS)
954 package["default-release"]["channel"]["released-at"] = (
955 logic.convert_date(
956 package["default-release"]["channel"]["released-at"]
957 )
958 )
960 button = request.args.get("button")
961 if button and button not in ["black", "white"]:
962 button = None
964 context = {
965 "store_design": store_design,
966 "button": button,
967 "package": package,
968 "show_channels": request.args.get("channels"),
969 "show_summary": request.args.get("summary"),
970 "show_base": request.args.get("base"),
971 }
973 return render_template(
974 "embeddable-card.html",
975 **context,
976 )
977 except Exception:
978 return (
979 render_template(
980 "embeddable-404.html",
981 store_design=store_design,
982 entity_name=entity_name,
983 ),
984 404,
985 )
988@trace_function
989@store.route(
990 '/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/embedded/interface'
991)
992@exclude_xframe_options_header
993def entity_embedded_interface_card(entity_name):
994 channel_request = request.args.get("channel", default=None, type=str)
995 try:
996 package = get_package(entity_name, channel_request, FIELDS)
998 package["default-release"]["channel"]["released-at"] = (
999 logic.convert_date(
1000 package["default-release"]["channel"]["released-at"]
1001 )
1002 )
1004 libraries = get_libraries(entity_name)
1006 context = {
1007 "package": package,
1008 "libraries": libraries,
1009 }
1011 return render_template(
1012 "interface-card.html",
1013 **context,
1014 )
1015 except Exception:
1016 return (
1017 render_template(
1018 "interface-card-404.html",
1019 entity_name=entity_name,
1020 ),
1021 404,
1022 )
1025@trace_function
1026@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/icon')
1027def entity_icon(entity_name):
1028 icon_url = (
1029 "https://assets.ubuntu.com/v1/be6eb412-snapcraft-missing-icon.svg"
1030 )
1031 package = None
1033 try:
1034 package = get_package_details(entity_name, fields=["result.media"])
1035 except StoreApiResponseErrorList:
1036 pass
1038 if package and package["result"]["media"]:
1039 icon_url = package["result"]["media"][0]["url"]
1041 return redirect(
1042 "https://res.cloudinary.com/canonical/image/fetch/f_auto"
1043 f",q_auto,fl_sanitize,w_64,h_64/{icon_url}"
1044 )
1047@trace_function
1048@store.route(
1049 '/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/icon-no-default'
1050)
1051def entity_icon_missing(entity_name):
1052 package = None
1054 try:
1055 package = get_package_details(entity_name, fields=["result.media"])
1056 except StoreApiResponseErrorList:
1057 pass
1059 if package and package["result"]["media"]:
1060 icon_url = package["result"]["media"][0]["url"]
1062 return redirect(
1063 "https://res.cloudinary.com/canonical/image/fetch/f_auto"
1064 f",q_auto,fl_sanitize,w_64,h_64/{icon_url}"
1065 )
1067 abort(404)
1070# This method is a temporary hack to show bundle icons on the
1071# homepage, and should be removed once the icons are available via the api
1072@trace_function
1073@store.route('/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/charms.json')
1074def get_charms_from_bundle(entity_name):
1075 package = get_package(entity_name)
1077 if package["type"] != "bundle":
1078 return "Requested object should be a bundle", 400
1080 return jsonify({"charms": package["store_front"]["bundle"]["charms"]})
1083@trace_function
1084@store.route("/")
1085def store_index():
1086 response = make_response(render_template("store.html"))
1087 return response