Coverage for webapp/store/logic.py: 77%

234 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-10 22:08 +0000

1import datetime 

2import random 

3import re 

4from urllib.parse import parse_qs, urlparse 

5 

6import humanize 

7from dateutil import parser 

8from dateutil.relativedelta import relativedelta 

9from canonicalwebteam.exceptions import StoreApiError 

10from cache.cache_utility import redis_cache 

11from webapp import helpers 

12 

13 

14def get_n_random_snaps(snaps, choice_number): 

15 if len(snaps) > choice_number: 

16 return random.sample(snaps, choice_number) 

17 

18 return snaps 

19 

20 

21def get_publisher_snaps(device_gateway, publisher): 

22 """Return a publisher's snaps from the store API, cached per publisher. 

23 

24 Uses the v2 "find" endpoint, which only returns currently-listed 

25 snaps (unlisted/removed snaps are excluded). The result is cached so 

26 we don't fetch the full publisher catalogue on every page view. 

27 """ 

28 cache_key = f"publisher-snaps:{publisher}" 

29 snaps = redis_cache.get(cache_key, expected_type=list) 

30 if not snaps: 

31 try: 

32 snaps = device_gateway.find( 

33 publisher=publisher, 

34 fields=["title", "summary", "media", "publisher"], 

35 ).get("results", []) 

36 except StoreApiError: 

37 snaps = [] 

38 if snaps: 

39 redis_cache.set(cache_key, snaps, ttl=3600) 

40 return snaps 

41 

42 

43def hydrate_featured_snaps(featured_snaps, snaps_by_name): 

44 """Hydrate curated featured snaps with live store API data. 

45 

46 'featured_snaps' is the editorial list from a publisher's YAML 

47 (package_name, background, description). title/summary/icon come from 

48 'snaps_by_name' (built from the API). Snaps missing from the API 

49 (unlisted/private/removed) are dropped. 

50 """ 

51 return [ 

52 { 

53 **snaps_by_name[snap["package_name"]], 

54 "background": snap.get("background"), 

55 "description": snap.get("description"), 

56 } 

57 for snap in featured_snaps or [] 

58 if snap["package_name"] in snaps_by_name 

59 ] 

60 

61 

62def get_snap_banner_url(snap_result): 

63 """Get snaps banner url from media object 

64 

65 :param snap_result: the snap dictionnary 

66 :returns: the snap dict with banner key 

67 """ 

68 for media in snap_result["media"]: 

69 if media["type"] == "banner": 

70 snap_result["banner_url"] = media["url"] 

71 break 

72 

73 return snap_result 

74 

75 

76def get_pages_details(url, links): 

77 """Transform returned navigation links from search API from limit/offset 

78 to size/page 

79 

80 :param url: The url to build 

81 :param links: The links returned by the API 

82 

83 :returns: A dictionnary with all the navigation links 

84 """ 

85 links_result = {} 

86 

87 if "first" in links: 

88 links_result["first"] = convert_navigation_url( 

89 url, links["first"]["href"] 

90 ) 

91 

92 if "last" in links: 

93 links_result["last"] = convert_navigation_url( 

94 url, links["last"]["href"] 

95 ) 

96 

97 if "next" in links: 

98 links_result["next"] = convert_navigation_url( 

99 url, links["next"]["href"] 

100 ) 

101 

102 if "prev" in links: 

103 links_result["prev"] = convert_navigation_url( 

104 url, links["prev"]["href"] 

105 ) 

106 

107 if "self" in links: 

108 links_result["self"] = convert_navigation_url( 

109 url, links["self"]["href"] 

110 ) 

111 

112 return links_result 

113 

114 

115def convert_navigation_url(url, link): 

116 """Convert navigation link from offest/limit to size/page 

117 

118 Example: 

119 - input: http://example.com?q=test&category=finance&size=10&page=3 

120 - output: http://example2.com?q=test&category=finance&limit=10&offset=30 

121 

122 :param url: The new url 

123 :param link: The navigation url returned by the API 

124 

125 :returns: The new navigation link 

126 """ 

127 url_parsed = urlparse(link) 

128 host_url = "{base_url}" "?q={q}&limit={limit}&offset={offset}" 

129 

130 url_queries = parse_qs(url_parsed.query) 

131 

132 if "q" in url_queries: 

133 q = url_queries["q"][0] 

134 else: 

135 q = "" 

136 

137 if "section" in url_queries: 

138 category = url_queries["section"][0] 

139 else: 

140 category = "" 

141 

142 size = int(url_queries["size"][0]) 

143 page = int(url_queries["page"][0]) 

144 

145 url = host_url.format( 

146 base_url=url, q=q, limit=size, offset=size * (page - 1) 

147 ) 

148 

149 if category != "": 

150 url += "&category=" + category 

151 

152 return url 

153 

154 

155def build_pagination_link(snap_searched, snap_category, page): 

156 """Build pagination link 

157 

158 :param snap_searched: Name of the search query 

159 :param snap_category: The category being searched in 

160 :param page: The page of results 

161 

162 :returns: A url string 

163 """ 

164 params = [] 

165 

166 if snap_searched: 

167 params.append("q=" + snap_searched) 

168 

169 if snap_category: 

170 params.append("category=" + snap_category) 

171 

172 if page: 

173 params.append("page=" + str(page)) 

174 

175 return "/search?" + "&".join(params) 

176 

177 

178def convert_channel_maps(channel_map): 

179 """Converts channel maps list to format easier to manipulate 

180 

181 Example: 

182 - Input: 

183 [ 

184 { 

185 'architecture': 'arch' 

186 'map': [{'info': 'release', ...}, ...], 

187 'track': 'track 1' 

188 }, 

189 ... 

190 ] 

191 - Output: 

192 { 

193 'arch': { 

194 'track 1': [{'info': 'release', ...}, ...], 

195 ... 

196 }, 

197 ... 

198 } 

199 

200 :param channel_maps_list: The channel maps list returned by the API 

201 

202 :returns: The channel maps reshaped 

203 """ 

204 channel_map_restruct = {} 

205 

206 for channel in channel_map: 

207 arch = channel.get("channel").get("architecture") 

208 track = channel.get("channel").get("track") 

209 if arch not in channel_map_restruct: 

210 channel_map_restruct[arch] = {} 

211 if track not in channel_map_restruct[arch]: 

212 channel_map_restruct[arch][track] = [] 

213 

214 sboms = None 

215 

216 if "sboms" in channel: 

217 sboms = channel["sboms"] 

218 

219 info = { 

220 "released-at": convert_date(channel["channel"].get("released-at")), 

221 "version": channel.get("version"), 

222 "channel": channel["channel"].get("name"), 

223 "risk": channel["channel"].get("risk"), 

224 "confinement": channel.get("confinement"), 

225 "size": channel["download"].get("size"), 

226 "revision": channel["revision"], 

227 "sboms": sboms, 

228 } 

229 

230 channel_map_restruct[arch][track].append(info) 

231 

232 return channel_map_restruct 

233 

234 

235def convert_date(date_to_convert): 

236 """Convert date to human readable format: Month Day Year 

237 

238 If date is less than a day return: today or yesterday 

239 

240 Format of date to convert: 2019-01-12T16:48:41.821037+00:00 

241 Output: Jan 12 2019 

242 

243 :param date_to_convert: Date to convert 

244 :returns: Readable date 

245 """ 

246 local_timezone = datetime.datetime.utcnow().tzinfo 

247 date_parsed = parser.parse(date_to_convert).replace(tzinfo=local_timezone) 

248 delta = datetime.datetime.utcnow() - datetime.timedelta(days=1) 

249 

250 if delta < date_parsed: 

251 return humanize.naturalday(date_parsed).title() 

252 else: 

253 return date_parsed.strftime("%-d %B %Y") 

254 

255 

256def is_snap_old(last_updated_date, old_threshold_years=2.0): 

257 """Check if a snap is considered 'old' based on its last update date 

258 

259 A snap is considered old if it hasn't been updated in the specified 

260 number of years (default: 2 years). 

261 

262 :param last_updated_date: The last updated date string in ISO format 

263 :param old_threshold_years: Number of years to consider a snap old 

264 (default: 2) 

265 :returns: True if snap is old, False otherwise 

266 """ 

267 if not last_updated_date: 

268 return False 

269 

270 try: 

271 date_parsed = parser.parse(last_updated_date) 

272 if date_parsed.tzinfo is None: 

273 date_parsed = date_parsed.replace(tzinfo=datetime.timezone.utc) 

274 

275 now = datetime.datetime.now(datetime.timezone.utc) 

276 

277 delta = relativedelta(now, date_parsed) 

278 years_since_update = delta.years 

279 

280 return years_since_update >= old_threshold_years 

281 except (ValueError, TypeError): 

282 # If we can't parse the date, assume it's not old 

283 return False 

284 

285 

286categories_list = [ 

287 "development", 

288 "games", 

289 "social", 

290 "productivity", 

291 "utilities", 

292 "photo-and-video", 

293 "server-and-cloud", 

294 "security", 

295 "devices-and-iot", 

296 "music-and-audio", 

297 "entertainment", 

298 "art-and-design", 

299] 

300 

301blacklist = ["featured"] 

302 

303 

304def format_category_name(slug): 

305 """Format category name into a standard title format 

306 

307 :param slug: The hypen spaced, lowercase slug to be formatted 

308 :return: The formatted string 

309 """ 

310 return ( 

311 slug.title() 

312 .replace("-", " ") 

313 .replace("And", "and") 

314 .replace("Iot", "IoT") 

315 ) 

316 

317 

318def get_categories(categories_json): 

319 """Retrieve and flatten the nested array from the legacy API response. 

320 

321 :param categories_json: The returned json 

322 :returns: A list of categories 

323 """ 

324 

325 categories = [] 

326 

327 if "categories" in categories_json: 

328 for cat in categories_json["categories"]: 

329 if cat["name"] not in categories_list: 

330 if cat["name"] not in blacklist: 

331 categories_list.append(cat["name"]) 

332 

333 for category in categories_list: 

334 categories.append( 

335 {"slug": category, "name": format_category_name(category)} 

336 ) 

337 

338 return categories 

339 

340 

341def get_snap_categories(snap_categories): 

342 """Retrieve list of categories with names for a snap. 

343 

344 :param snap_categories: List of snap categories from snap info API 

345 :returns: A list of categories with names 

346 """ 

347 categories = [] 

348 

349 for cat in snap_categories: 

350 if cat["name"] not in blacklist: 

351 categories.append( 

352 { 

353 "slug": cat["name"], 

354 "name": format_category_name(cat["name"]), 

355 } 

356 ) 

357 

358 return categories 

359 

360 

361def get_latest_versions( 

362 channel_maps, default_track, lowest_risk, supported_architectures=None 

363): 

364 """Get the latest versions of both default/stable and the latest of 

365 all other channels, unless it's default/stable 

366 

367 :param channel_map: Channel map list 

368 

369 :returns: A tuple of default/stable, track/risk channel map objects 

370 """ 

371 ordered_versions = get_last_updated_versions(channel_maps) 

372 

373 default_stable = None 

374 other = None 

375 for channel in ordered_versions: 

376 if ( 

377 not supported_architectures 

378 or channel["architecture"] in supported_architectures 

379 ): 

380 if ( 

381 channel["track"] == default_track 

382 and channel["risk"] == lowest_risk 

383 ): 

384 if not default_stable: 

385 default_stable = channel 

386 elif not other: 

387 other = channel 

388 

389 if default_stable: 

390 default_stable["released-at-display"] = convert_date( 

391 default_stable["released-at"] 

392 ) 

393 if other: 

394 other["released-at-display"] = convert_date(other["released-at"]) 

395 return default_stable, other 

396 

397 

398def get_revisions(channel_maps: list) -> list: 

399 """Gets a sorted list of unique revisions 

400 

401 :param channel_map: Channel map list 

402 

403 :returns: A sorted list of unique revisions 

404 """ 

405 revisions = {channel_map["revision"] for channel_map in channel_maps} 

406 return list(reversed(sorted(revisions))) 

407 

408 

409def get_last_updated_versions(channel_maps): 

410 """Get all channels in order of updates 

411 

412 :param channel_map: Channel map list 

413 

414 :returns: A list of channels ordered by last updated time 

415 """ 

416 releases = [] 

417 for channel_map in channel_maps: 

418 releases.append(channel_map["channel"]) 

419 

420 return list(reversed(sorted(releases, key=lambda c: c["released-at"]))) 

421 

422 

423def get_last_updated_version(channel_maps): 

424 """Get the oldest channel that was created 

425 

426 :param channel_map: Channel map list 

427 

428 :returns: The latest stable version, if no stable, the latest risk updated 

429 """ 

430 newest_channel = None 

431 for channel_map in channel_maps: 

432 if not newest_channel: 

433 newest_channel = channel_map 

434 else: 

435 if channel_map["channel"]["risk"] == "stable": 

436 newest_channel = channel_map 

437 

438 if newest_channel["channel"]["risk"] == "stable": 

439 break 

440 

441 return newest_channel 

442 

443 

444def has_stable(channel_maps_list): 

445 """Use the channel map to find out if the snap has a stable release 

446 

447 :param channel_maps_list: Channel map list 

448 

449 :returns: True or False 

450 """ 

451 if channel_maps_list: 

452 for arch in channel_maps_list: 

453 for track in channel_maps_list[arch]: 

454 for release in channel_maps_list[arch][track]: 

455 if release["risk"] == "stable": 

456 return True 

457 

458 return False 

459 

460 

461def get_lowest_available_risk(channel_map, track): 

462 """Get the lowest available risk for the default track 

463 

464 :param channel_map: Channel map list 

465 :param track: The track of the channel 

466 

467 :returns: The lowest available risk 

468 """ 

469 risk_order = ["stable", "candidate", "beta", "edge"] 

470 lowest_available_risk = None 

471 for arch in channel_map: 

472 if arch in channel_map and track in channel_map[arch]: 

473 releases = channel_map[arch][track] 

474 for release in releases: 

475 if not lowest_available_risk: 

476 lowest_available_risk = release["risk"] 

477 else: 

478 risk_index = risk_order.index(release["risk"]) 

479 lowest_index = risk_order.index(lowest_available_risk) 

480 if risk_index < lowest_index: 

481 lowest_available_risk = release["risk"] 

482 

483 return lowest_available_risk 

484 

485 

486def get_default_architecture(architectures): 

487 """Pick the default architecture from a list of architectures. 

488 

489 Prefer ``amd64`` when published, otherwise the first architecture 

490 sorted. This is the single source of truth for the default-architecture 

491 preference, shared by the provenance badge endpoint and the security tab. 

492 

493 :param architectures: Iterable of architecture names 

494 

495 :returns: The default architecture, or ``None`` if the list is empty 

496 """ 

497 architectures = list(architectures) 

498 if not architectures: 

499 return None 

500 if "amd64" in architectures: 

501 return "amd64" 

502 return sorted(architectures)[0] 

503 

504 

505def extract_info_channel_map(channel_map, track, risk): 

506 """Get the confinement and version for a channel 

507 

508 :param channel_map: Channel map list 

509 :param track: The track of the channel 

510 :param risk: The risk of the channel 

511 

512 :returns: Dict containing confinement and version 

513 """ 

514 context = { 

515 "confinement": None, 

516 "version": None, 

517 } 

518 

519 for arch in channel_map: 

520 if track in channel_map[arch]: 

521 releases = channel_map[arch][track] 

522 for release in releases: 

523 if release["risk"] == risk: 

524 context["confinement"] = release.get("confinement") 

525 context["version"] = release.get("version") 

526 

527 return context 

528 

529 return context 

530 

531 

532def get_video_embed_code(url): 

533 """Get the embed code for videos 

534 

535 :param url: The url of the video 

536 

537 :returns: Embed code 

538 """ 

539 if "youtube" in url: 

540 return { 

541 "type": "youtube", 

542 "url": url.replace("watch?v=", "embed/"), 

543 "id": url.rsplit("?v=", 1)[-1], 

544 } 

545 if "youtu.be" in url: 

546 return { 

547 "type": "youtube", 

548 "url": url.replace("youtu.be/", "youtube.com/embed/"), 

549 "id": url.rsplit("/", 1)[-1], 

550 } 

551 if "vimeo" in url: 

552 return { 

553 "type": "vimeo", 

554 "url": url.replace("vimeo.com/", "player.vimeo.com/video/"), 

555 "id": url.rsplit("/", 1)[-1], 

556 } 

557 if "asciinema" in url: 

558 return { 

559 "type": "asciinema", 

560 "url": url + ".js", 

561 "id": url.rsplit("/", 1)[-1], 

562 } 

563 

564 

565def filter_screenshots(media): 

566 banner_regex = r"/banner(\-icon)?(_.*)?\.(png|jpg)" 

567 

568 return [ 

569 m 

570 for m in media 

571 if m["type"] == "screenshot" and not re.search(banner_regex, m["url"]) 

572 ][:5] 

573 

574 

575def get_video(media): 

576 video = None 

577 for m in media: 

578 if m["type"] == "video": 

579 video = get_video_embed_code(m["url"]) 

580 break 

581 return video 

582 

583 

584def promote_snap_with_icon(snaps): 

585 """Move the first snap with an icon to the front of the list 

586 

587 :param snaps: The list of snaps 

588 

589 :returns: A list of snaps 

590 """ 

591 try: 

592 snap_with_icon = next(snap for snap in snaps if snap["icon_url"] != "") 

593 

594 if snap_with_icon: 

595 snap_with_icon_index = snaps.index(snap_with_icon) 

596 

597 snaps.insert(0, snaps.pop(snap_with_icon_index)) 

598 except StopIteration: 

599 pass 

600 

601 return snaps 

602 

603 

604def get_snap_developer(snap_name): 

605 """Is this a special snap published by Canonical? 

606 Show some developer information 

607 

608 :param snap_name: The name of a snap 

609 

610 :returns: a list of [display_name, url] 

611 

612 """ 

613 filename = "store/content/developers/snaps.yaml" 

614 snaps = helpers.get_yaml(filename, typ="rt") 

615 

616 if snaps and snap_name in snaps: 

617 return snaps[snap_name] 

618 

619 return None