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

1from urllib.parse import urlparse 

2 

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 

14 

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 

27 

28store = Blueprint( 

29 "store", __name__, template_folder="/templates", static_folder="/static" 

30) 

31 

32 

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 

44 

45 

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 

60 

61 

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 """ 

67 

68 error_info = {} 

69 items = [] 

70 extra_fields = [ 

71 "result.publisher", 

72 "result.description", 

73 ] 

74 

75 response = device_gateway.find( 

76 publisher=publisher, 

77 fields=SEARCH_FIELDS.copy() + extra_fields, 

78 ) 

79 

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) 

88 

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 } 

99 

100 # HTML template will be returned here for the front end 

101 return render_template("details/publisher.html", **context) 

102 

103 

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) 

110 

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 

118 

119 context = {"packages": [], "size": 0} 

120 

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(",") 

131 

132 results = publisher_gateway.find( 

133 provides=provides, requires=requires, fields=SEARCH_FIELDS 

134 ).get("results") 

135 

136 context["provides"] = provides 

137 context["requires"] = requires 

138 else: 

139 results = publisher_gateway.find(fields=SEARCH_FIELDS).get( 

140 "results", [] 

141 ) 

142 

143 packages = [] 

144 total_packages = 0 

145 

146 for i, _ in enumerate(results): 

147 total_packages += 1 

148 package = logic.add_store_front_data(results[i], False) 

149 packages.append(package) 

150 

151 context["packages"] = packages 

152 context["size"] = total_packages 

153 redis_cache.set(key, context, ttl=600) 

154 return context 

155 

156 

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] 

167 

168 

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 ) 

176 

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) 

184 

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"] 

189 

190 package = logic.add_store_front_data(package, True) 

191 package = logic.add_overlay_data(package) 

192 

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 

199 

200 

201@trace_function 

202def package_has_sboms(revisions, package_id): 

203 if not revisions: 

204 return False 

205 

206 sbom_path = f"download/sbom_charm_{package_id}_{revisions[0]}.spdx2.3.json" 

207 endpoint = device_gateway_sbom.get_endpoint_url(sbom_path) 

208 

209 res = requests.head(endpoint) 

210 

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 

215 

216 return False 

217 

218 

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) 

224 

225 res = requests.get(endpoint) 

226 

227 return jsonify(res.json()) 

228 

229 

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) 

242 

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 } 

268 

269 description = None 

270 summary = None 

271 

272 doc = logic.get_doc_link(package) 

273 

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 ) 

279 

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" 

296 

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", "")) 

307 

308 navigation = docs.navigation 

309 

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 } 

321 

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 ] 

345 

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) 

363 

364 revisions = logic.get_revisions(package["channel-map"]) 

365 

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) 

376 

377 

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) 

393 

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" 

405 

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() 

412 

413 if path: 

414 topic_id = None 

415 try: 

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

417 except PathNotFoundError: 

418 abort(404) 

419 

420 topic = docs.api.get_topic(topic_id) 

421 else: 

422 topic = docs.index_topic 

423 

424 document = docs.parse_topic(topic) 

425 

426 body_html = sanitize_html(document.get("body_html", "")) 

427 

428 navigation = docs.navigation 

429 

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 } 

441 

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 ] 

463 

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) 

475 

476 

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 ] 

493 

494 package = get_package( 

495 entity_name, channel_request, FIELDS.copy() + extra_fields, path 

496 ) 

497 subpackage = None 

498 

499 if package["type"] == "bundle": 

500 bundle_charms = package["store_front"]["bundle"]["charms"] 

501 

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 ) 

512 

513 if path: 

514 if not any(d["name"] == path for d in bundle_charms): 

515 abort(404) 

516 

517 try: 

518 subpackage = get_package(path) 

519 except StoreApiResponseErrorList: 

520 subpackage = None 

521 

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 ) 

529 

530 

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 ] 

540 

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 ) 

549 

550 

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) 

558 

559 libraries = get_libraries(entity_name) 

560 

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 ) 

570 

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 ) 

578 

579 

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) 

591 

592 libraries = get_libraries(entity_name) 

593 

594 library_id = logic.get_library(library_name, libraries) 

595 

596 if not library_id: 

597 abort(404) 

598 

599 library = publisher_gateway.get_charm_library(entity_name, library_id) 

600 docstrings = logic.process_python_docs(library, module_name=library_name) 

601 

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}" 

606 

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 ) 

619 

620 

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) 

632 

633 libraries = get_libraries(entity_name) 

634 

635 library_id = logic.get_library(library_name, libraries) 

636 if not library_id: 

637 abort(404) 

638 

639 library = publisher_gateway.get_charm_library(entity_name, library_id) 

640 source_code = library.get("source-code", "") 

641 

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 ) 

652 

653 

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(".") 

664 

665 if len(lib_parts) > 2: 

666 lib_name = "." + ".".join(lib_parts[-2:]) 

667 else: 

668 lib_name = library_name 

669 

670 libraries = get_libraries(entity_name) 

671 

672 library = next( 

673 (lib for lib in libraries if lib.get("name") == lib_name), 

674 None, 

675 ) 

676 

677 if not library: 

678 abort(404) 

679 

680 library = publisher_gateway.get_charm_library(entity_name, library["id"]) 

681 

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 ) 

689 

690 

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) 

701 

702 return render_template( 

703 "details/integrations.html", 

704 package=package, 

705 channel_requested=channel_request, 

706 ) 

707 

708 

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) 

721 

722 relations = ( 

723 package.get("default-release", {}) 

724 .get("revision", {}) 

725 .get("relations", {}) 

726 ) 

727 

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 ) 

736 

737 grouped_relations = { 

738 "provides": provides, 

739 "requires": requires, 

740 } 

741 

742 return jsonify({"grouped_relations": grouped_relations}) 

743 

744 

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 

756 

757 

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) 

765 

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 ) 

782 

783 

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"] 

796 

797 if not resources: 

798 return redirect(url_for(".details_resources", entity_name=entity_name)) 

799 

800 resource = next( 

801 (item for item in resources if item["name"] == resource_name), None 

802 ) 

803 

804 if not resource: 

805 return redirect(url_for(".details_resources", entity_name=entity_name)) 

806 

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] 

816 

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 

826 

827 revisions = device_gateway.get_resource_revisions( 

828 entity_name, resource_name 

829 ) 

830 revisions = sorted(revisions, key=lambda k: k["revision"], reverse=True) 

831 

832 # Humanize sizes 

833 resource["size"] = humanize.naturalsize(resource["download"]["size"]) 

834 resource["updated"] = logic.convert_date(resource["created-at"]) 

835 

836 for revision in revisions: 

837 revision["size"] = humanize.naturalsize(revision["download"]["size"]) 

838 revision["updated"] = logic.convert_date(revision["created-at"]) 

839 

840 return render_template( 

841 "details/resources.html", 

842 package=package, 

843 channel_requested=channel_request, 

844 resource=resource, 

845 revisions=revisions, 

846 ) 

847 

848 

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) 

856 

857 return render_template( 

858 "details/integrate.html", 

859 package=package, 

860 channel_requested=channel_request, 

861 ) 

862 

863 

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) 

868 

869 channel_request = request.args.get("channel") 

870 revision_request = request.args.get("revision") 

871 

872 if not package["default-release"]: 

873 abort(404) 

874 

875 release = package["default-release"] 

876 valid_revision_request = None 

877 

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 

884 

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 

898 

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 ) 

909 

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 ) 

941 

942 return svg, 200, {"Content-Type": "image/svg+xml"} 

943 

944 

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) 

953 

954 package["default-release"]["channel"]["released-at"] = ( 

955 logic.convert_date( 

956 package["default-release"]["channel"]["released-at"] 

957 ) 

958 ) 

959 

960 button = request.args.get("button") 

961 if button and button not in ["black", "white"]: 

962 button = None 

963 

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 } 

972 

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 ) 

986 

987 

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) 

997 

998 package["default-release"]["channel"]["released-at"] = ( 

999 logic.convert_date( 

1000 package["default-release"]["channel"]["released-at"] 

1001 ) 

1002 ) 

1003 

1004 libraries = get_libraries(entity_name) 

1005 

1006 context = { 

1007 "package": package, 

1008 "libraries": libraries, 

1009 } 

1010 

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 ) 

1023 

1024 

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 

1032 

1033 try: 

1034 package = get_package_details(entity_name, fields=["result.media"]) 

1035 except StoreApiResponseErrorList: 

1036 pass 

1037 

1038 if package and package["result"]["media"]: 

1039 icon_url = package["result"]["media"][0]["url"] 

1040 

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 ) 

1045 

1046 

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 

1053 

1054 try: 

1055 package = get_package_details(entity_name, fields=["result.media"]) 

1056 except StoreApiResponseErrorList: 

1057 pass 

1058 

1059 if package and package["result"]["media"]: 

1060 icon_url = package["result"]["media"][0]["url"] 

1061 

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 ) 

1066 

1067 abort(404) 

1068 

1069 

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) 

1076 

1077 if package["type"] != "bundle": 

1078 return "Requested object should be a bundle", 400 

1079 

1080 return jsonify({"charms": package["store_front"]["bundle"]["charms"]}) 

1081 

1082 

1083@trace_function 

1084@store.route("/") 

1085def store_index(): 

1086 response = make_response(render_template("store.html")) 

1087 return response