Coverage for webapp/store/views.py: 49%

190 statements  

« prev     ^ index     » next       coverage.py v7.10.2, created at 2025-08-05 22:06 +0000

1from math import floor 

2import talisker.requests 

3import flask 

4from dateutil import parser 

5from webapp.decorators import exchange_required, login_required 

6import webapp.helpers as helpers 

7import webapp.store.logic as logic 

8from webapp.api import requests 

9 

10from canonicalwebteam.exceptions import StoreApiError 

11from canonicalwebteam.store_api.dashboard import Dashboard 

12from canonicalwebteam.store_api.publishergw import PublisherGW 

13from canonicalwebteam.store_api.devicegw import DeviceGW 

14 

15from webapp.api.exceptions import ApiError 

16from webapp.store.snap_details_views import snap_details_views 

17from webapp.helpers import api_publisher_session, api_session 

18from flask.json import jsonify 

19import os 

20from webapp.extensions import csrf 

21 

22session = talisker.requests.get_session(requests.Session) 

23 

24YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY") 

25dashboard = Dashboard(api_session) 

26publisher_gateway = PublisherGW("snap", api_publisher_session) 

27device_gateway = DeviceGW("snap", api_session) 

28 

29 

30def store_blueprint(store_query=None): 

31 store = flask.Blueprint( 

32 "store", 

33 __name__, 

34 template_folder="/templates", 

35 static_folder="/static", 

36 ) 

37 snap_details_views(store) 

38 

39 @store.route("/validation-sets", defaults={"path": ""}) 

40 @store.route("/validation-sets/<path:path>") 

41 @login_required 

42 def validation_sets(path): 

43 return flask.render_template("store/publisher.html") 

44 

45 @store.route("/discover") 

46 def discover(): 

47 return flask.redirect(flask.url_for(".homepage")) 

48 

49 def brand_store_view(): 

50 error_info = {} 

51 status_code = 200 

52 

53 try: 

54 snaps = device_gateway.get_all_items(size=16)["results"] 

55 except (StoreApiError, ApiError): 

56 snaps = [] 

57 

58 for snap in snaps: 

59 if "media" in snap: 

60 snap["icon_url"] = helpers.get_icon(snap["media"]) 

61 

62 return ( 

63 flask.render_template( 

64 "brand-store/store.html", snaps=snaps, error_info=error_info 

65 ), 

66 status_code, 

67 ) 

68 

69 def brand_search_snap(): 

70 status_code = 200 

71 snap_searched = flask.request.args.get("q", default="", type=str) 

72 

73 if not snap_searched: 

74 return flask.redirect(flask.url_for(".homepage")) 

75 

76 size = flask.request.args.get("limit", default=25, type=int) 

77 offset = flask.request.args.get("offset", default=0, type=int) 

78 

79 try: 

80 page = floor(offset / size) + 1 

81 except ZeroDivisionError: 

82 size = 10 

83 page = floor(offset / size) + 1 

84 

85 error_info = {} 

86 searched_results = [] 

87 

88 searched_results = device_gateway.search( 

89 snap_searched, size=size, page=page 

90 ) 

91 

92 snaps_results = searched_results["results"] 

93 

94 for snap in snaps_results: 

95 snap["icon_url"] = helpers.get_icon(snap["media"]) 

96 

97 links = logic.get_pages_details( 

98 flask.request.base_url, 

99 ( 

100 searched_results["_links"] 

101 if "_links" in searched_results 

102 else [] 

103 ), 

104 ) 

105 

106 context = { 

107 "query": snap_searched, 

108 "snaps": snaps_results, 

109 "links": links, 

110 "error_info": error_info, 

111 } 

112 

113 return ( 

114 flask.render_template("brand-store/search.html", **context), 

115 status_code, 

116 ) 

117 

118 @store.route("/store") 

119 def store_view(): 

120 return flask.render_template("store/store.html") 

121 

122 @store.route("/youtube", methods=["POST"]) 

123 def get_video_thumbnail_data(): 

124 body = flask.request.form 

125 thumbnail_url = "https://www.googleapis.com/youtube/v3/videos" 

126 thumbnail_data = session.get( 

127 ( 

128 f"{thumbnail_url}?id={body['videoId']}" 

129 f"&part=snippet&key={YOUTUBE_API_KEY}" 

130 ) 

131 ) 

132 

133 if thumbnail_data: 

134 return thumbnail_data.json() 

135 

136 return {} 

137 

138 @store.route("/publisher/<regex('[a-z0-9-]*[a-z][a-z0-9-]*'):publisher>") 

139 def publisher_details(publisher): 

140 """ 

141 A view to display the publisher details page for specific publisher. 

142 """ 

143 

144 # 404 for the snap-quarantine publisher 

145 if publisher == "snap-quarantine": 

146 flask.abort(404) 

147 

148 publisher_content_path = flask.current_app.config["CONTENT_DIRECTORY"][ 

149 "PUBLISHER_PAGES" 

150 ] 

151 

152 if publisher in ["kde", "snapcrafters", "jetbrains"]: 

153 context = helpers.get_yaml( 

154 publisher_content_path + publisher + ".yaml", typ="safe" 

155 ) 

156 

157 if not context: 

158 flask.abort(404) 

159 

160 popular_snaps = helpers.get_yaml( 

161 publisher_content_path + publisher + "-snaps.yaml", 

162 typ="safe", 

163 ) 

164 

165 context["popular_snaps"] = ( 

166 popular_snaps["snaps"] if popular_snaps else [] 

167 ) 

168 

169 if "publishers" in context: 

170 context["snaps"] = [] 

171 for publisher in context["publishers"]: 

172 snaps_results = [] 

173 try: 

174 snaps_results = device_gateway.get_publisher_items( 

175 publisher, size=500, page=1 

176 )["_embedded"]["clickindex:package"] 

177 except StoreApiError: 

178 pass 

179 

180 for snap in snaps_results: 

181 snap["icon_url"] = helpers.get_icon(snap["media"]) 

182 

183 context["snaps"].extend( 

184 [snap for snap in snaps_results if snap["apps"]] 

185 ) 

186 

187 featured_snaps = [ 

188 snap["package_name"] for snap in context["featured_snaps"] 

189 ] 

190 

191 context["snaps"] = [ 

192 snap 

193 for snap in context["snaps"] 

194 if snap["package_name"] not in featured_snaps 

195 ] 

196 

197 context["snaps_count"] = len(context["snaps"]) + len( 

198 featured_snaps 

199 ) 

200 

201 return flask.render_template( 

202 "store/publisher-details.html", **context 

203 ) 

204 

205 status_code = 200 

206 error_info = {} 

207 snaps_results = [] 

208 snaps = [] 

209 snaps_count = 0 

210 publisher_details = {"display-name": publisher, "username": publisher} 

211 

212 snaps_results = device_gateway.find( 

213 publisher=publisher, 

214 fields=[ 

215 "title", 

216 "summary", 

217 "media", 

218 "publisher", 

219 ], 

220 )["results"] 

221 

222 for snap in snaps_results: 

223 item = snap["snap"] 

224 item["package_name"] = snap["name"] 

225 item["icon_url"] = helpers.get_icon(item["media"]) 

226 snaps.append(item) 

227 

228 snaps_count = len(snaps) 

229 

230 if snaps_count > 0: 

231 publisher_details = snaps[0]["publisher"] 

232 

233 context = { 

234 "snaps": snaps, 

235 "snaps_count": snaps_count, 

236 "publisher": publisher_details, 

237 "error_info": error_info, 

238 } 

239 

240 return ( 

241 flask.render_template( 

242 "store/community-publisher-details.html", **context 

243 ), 

244 status_code, 

245 ) 

246 

247 @store.route("/store/categories/<category>") 

248 def store_category(category): 

249 status_code = 200 

250 error_info = {} 

251 snaps_results = [] 

252 

253 snaps_results = device_gateway.get_category_items( 

254 category=category, size=10, page=1 

255 )["results"] 

256 for snap in snaps_results: 

257 snap["icon_url"] = helpers.get_icon(snap["media"]) 

258 

259 # if the first snap (banner snap) doesn't have an icon, remove the last 

260 # snap from the list to avoid a hanging snap (grid of 9) 

261 if len(snaps_results) == 10 and snaps_results[0]["icon_url"] == "": 

262 snaps_results = snaps_results[:-1] 

263 

264 for index in range(len(snaps_results)): 

265 snaps_results[index] = logic.get_snap_banner_url( 

266 snaps_results[index] 

267 ) 

268 

269 context = { 

270 "category": category, 

271 "has_featured": True, 

272 "snaps": snaps_results, 

273 "error_info": error_info, 

274 } 

275 

276 return ( 

277 flask.render_template("store/_category-partial.html", **context), 

278 status_code, 

279 ) 

280 

281 @store.route("/store/featured-snaps/<category>") 

282 def featured_snaps_in_category(category): 

283 snaps_results = [] 

284 

285 snaps_results = device_gateway.get_category_items( 

286 category=category, size=3, page=1 

287 )["_embedded"]["clickindex:package"] 

288 

289 for snap in snaps_results: 

290 snap["icon_url"] = helpers.get_icon(snap["media"]) 

291 

292 return flask.jsonify(snaps_results) 

293 

294 @store.route("/store/sitemap.xml") 

295 def sitemap(): 

296 base_url = "https://snapcraft.io/store" 

297 

298 snaps = [] 

299 page = 0 

300 url = f"https://api.snapcraft.io/api/v1/snaps/search?page={page}" 

301 while url: 

302 response = session.get(url) 

303 try: 

304 snaps_response = response.json() 

305 except Exception: 

306 continue 

307 

308 for snap in snaps_response["_embedded"]["clickindex:package"]: 

309 try: 

310 last_udpated = ( 

311 parser.parse(snap["last_updated"]) 

312 .replace(tzinfo=None) 

313 .strftime("%Y-%m-%d") 

314 ) 

315 snaps.append( 

316 { 

317 "url": "https://snapcraft.io/" 

318 + snap["package_name"], 

319 "last_udpated": last_udpated, 

320 } 

321 ) 

322 except Exception: 

323 continue 

324 if "next" in snaps_response["_links"]: 

325 url = snaps_response["_links"]["next"]["href"] 

326 else: 

327 url = None 

328 

329 xml_sitemap = flask.render_template( 

330 "sitemap/sitemap.xml", 

331 base_url=base_url, 

332 links=snaps, 

333 ) 

334 

335 response = flask.make_response(xml_sitemap) 

336 response.headers["Content-Type"] = "application/xml" 

337 response.headers["Cache-Control"] = "public, max-age=43200" 

338 

339 return response 

340 

341 if store_query: 

342 store.add_url_rule("/", "homepage", brand_store_view) 

343 store.add_url_rule("/search", "search", brand_search_snap) 

344 else: 

345 store.add_url_rule("/store", "homepage", store_view) 

346 

347 @store.route("/<snap_name>/create-track", methods=["POST"]) 

348 @login_required 

349 @csrf.exempt 

350 @exchange_required 

351 def post_create_track(snap_name): 

352 track_name = flask.request.form["track-name"] 

353 version_pattern = flask.request.form.get("version-pattern") 

354 auto_phasing_percentage = flask.request.form.get( 

355 "automatic-phasing-percentage" 

356 ) 

357 

358 if auto_phasing_percentage is not None: 

359 auto_phasing_percentage = float(auto_phasing_percentage) 

360 

361 response = publisher_gateway.create_track( 

362 flask.session, 

363 snap_name, 

364 track_name, 

365 version_pattern, 

366 auto_phasing_percentage, 

367 ) 

368 if response.status_code == 201: 

369 return response.json(), response.status_code 

370 if response.status_code == 409: 

371 return ( 

372 jsonify({"error": "Track already exists."}), 

373 response.status_code, 

374 ) 

375 if "error-list" in response.json(): 

376 return ( 

377 jsonify( 

378 {"error": response.json()["error-list"][0]["message"]} 

379 ), 

380 response.status_code, 

381 ) 

382 return response.json(), response.status_code 

383 

384 return store