Coverage for webapp/admin/views.py : 76%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Packages
2import os
3import json
4import flask
5from flask import make_response
6from canonicalwebteam.store_api.exceptions import (
7 StoreApiResponseErrorList,
8 StoreApiResourceNotFound,
9)
10from canonicalwebteam.store_api.stores.snapstore import (
11 SnapStoreAdmin,
12 SnapPublisher,
13)
14from flask.json import jsonify
16# Local
17from webapp.decorators import login_required, exchange_required
18from webapp.helpers import api_publisher_session
20admin_api = SnapStoreAdmin(api_publisher_session)
21publisher_api = SnapPublisher(api_publisher_session)
23admin = flask.Blueprint(
24 "admin", __name__, template_folder="/templates", static_folder="/static"
25)
27SNAPSTORE_DASHBOARD_API_URL = os.getenv(
28 "SNAPSTORE_DASHBOARD_API_URL", "https://dashboard.snapcraft.io/"
29)
31context = {"api_url": SNAPSTORE_DASHBOARD_API_URL}
34@admin.route("/admin", defaults={"path": ""})
35@admin.route("/admin/<path:path>")
36@login_required
37@exchange_required
38def get_admin(path):
39 return flask.render_template("admin/admin.html", **context)
42@admin.route("/admin/stores")
43@login_required
44@exchange_required
45def get_stores():
46 """
47 In this view we get all the stores the user is an admin or we show a 403
48 """
49 stores = admin_api.get_stores(flask.session)
51 return jsonify(stores)
54@admin.route("/admin/store/<store_id>")
55@login_required
56@exchange_required
57def get_settings(store_id):
58 store = admin_api.get_store(flask.session, store_id)
60 return jsonify(store)
63@admin.route("/admin/store/<store_id>/settings", methods=["PUT"])
64@login_required
65@exchange_required
66def post_settings(store_id):
67 settings = {}
68 settings["private"] = json.loads(flask.request.form.get("private"))
69 settings["manual-review-policy"] = flask.request.form.get(
70 "manual-review-policy"
71 )
73 res = {}
75 admin_api.change_store_settings(flask.session, store_id, settings)
76 res["msg"] = "Changes saved"
78 return jsonify({"success": True})
81@admin.route("/admin/<store_id>/snaps/search")
82@login_required
83@exchange_required
84def get_snaps_search(store_id):
85 snaps = admin_api.get_store_snaps(
86 flask.session,
87 store_id,
88 flask.request.args.get("q"),
89 flask.request.args.get("allowed_for_inclusion"),
90 )
92 return jsonify(snaps)
95@admin.route("/admin/store/<store_id>/snaps")
96@login_required
97@exchange_required
98def get_store_snaps(store_id):
99 snaps = admin_api.get_store_snaps(flask.session, store_id)
100 store = admin_api.get_store(flask.session, store_id)
101 if "store-whitelist" in store:
102 included_stores = []
103 for item in store["store-whitelist"]:
104 try:
105 store_item = admin_api.get_store(flask.session, item)
106 if store_item:
107 included_stores.append(
108 {
109 "id": store_item["id"],
110 "name": store_item["name"],
111 "userHasAccess": True,
112 }
113 )
114 except Exception:
115 included_stores.append(
116 {
117 "id": item,
118 "name": "Private store",
119 "userHasAccess": False,
120 }
121 )
123 if included_stores:
124 snaps.append({"included-stores": included_stores})
125 return jsonify(snaps)
128@admin.route("/admin/store/<store_id>/snaps", methods=["POST"])
129@login_required
130@exchange_required
131def post_manage_store_snaps(store_id):
132 snaps = json.loads(flask.request.form.get("snaps"))
134 res = {}
136 admin_api.update_store_snaps(flask.session, store_id, snaps)
137 res["msg"] = "Changes saved"
139 return jsonify({"success": True})
142@admin.route("/admin/store/<store_id>/members")
143@login_required
144@exchange_required
145def get_manage_members(store_id):
146 members = admin_api.get_store_members(flask.session, store_id)
148 for item in members:
149 if item["email"] == flask.session["publisher"]["email"]:
150 item["current_user"] = True
152 return jsonify(members)
155@admin.route("/admin/store/<store_id>/members", methods=["POST"])
156@login_required
157@exchange_required
158def post_manage_members(store_id):
159 members = json.loads(flask.request.form.get("members"))
161 res = {}
163 try:
164 admin_api.update_store_members(flask.session, store_id, members)
165 res["msg"] = "Changes saved"
166 except StoreApiResponseErrorList as api_response_error_list:
167 codes = [error.get("code") for error in api_response_error_list.errors]
169 msgs = [
170 f"{error.get('message', 'An error occurred')}"
171 for error in api_response_error_list.errors
172 ]
174 for code in codes:
175 account_id = ""
177 if code == "store-users-no-match":
178 if account_id:
179 res["msg"] = code
180 else:
181 res["msg"] = "invite"
183 elif code == "store-users-multiple-matches":
184 res["msg"] = code
185 else:
186 for msg in msgs:
187 flask.flash(msg, "negative")
189 return jsonify(res)
192@admin.route("/admin/store/<store_id>/invites")
193@login_required
194@exchange_required
195def get_invites(store_id):
196 invites = admin_api.get_store_invites(flask.session, store_id)
198 return jsonify(invites)
201@admin.route("/admin/store/<store_id>/invite", methods=["POST"])
202@login_required
203@exchange_required
204def post_invite_members(store_id):
205 members = json.loads(flask.request.form.get("members"))
207 res = {}
209 try:
210 admin_api.invite_store_members(flask.session, store_id, members)
211 res["msg"] = "Changes saved"
212 except StoreApiResponseErrorList as api_response_error_list:
213 msgs = [
214 f"{error.get('message', 'An error occurred')}"
215 for error in api_response_error_list.errors
216 ]
218 msgs = list(dict.fromkeys(msgs))
220 for msg in msgs:
221 flask.flash(msg, "negative")
223 return jsonify(res)
226@admin.route("/admin/store/<store_id>/invite/update", methods=["POST"])
227@login_required
228@exchange_required
229def update_invite_status(store_id):
230 invites = json.loads(flask.request.form.get("invites"))
232 res = {}
234 try:
235 admin_api.update_store_invites(flask.session, store_id, invites)
236 res["msg"] = "Changes saved"
237 except StoreApiResponseErrorList as api_response_error_list:
238 msgs = [
239 f"{error.get('message', 'An error occurred')}"
240 for error in api_response_error_list.errors
241 ]
243 msgs = list(dict.fromkeys(msgs))
245 for msg in msgs:
246 flask.flash(msg, "negative")
248 return jsonify(res)
251# ---------------------- MODELS SERVICES ----------------------
252@admin.route("/admin/store/<store_id>/models")
253@login_required
254@exchange_required
255def get_models(store_id):
256 """
257 Retrieves models associated with a given store ID.
259 Args:
260 store_id (int): The ID of the store for which to retrieve models.
262 Returns:
263 dict: A dictionary containing the response message, success status,
264 and data.
265 """
266 res = {}
267 try:
268 models = admin_api.get_store_models(flask.session, store_id)
269 res["success"] = True
270 res["data"] = models
271 response = make_response(res, 200)
272 response.cache_control.max_age = "3600"
273 except StoreApiResponseErrorList as error_list:
274 error_messages = [
275 f"{error.get('message', 'An error occurred')}"
276 for error in error_list.errors
277 ]
278 if "unauthorized" in error_messages:
279 res["message"] = "Store not found"
280 else:
281 res["message"] = " ".join(error_messages)
282 res["success"] = False
283 response = make_response(res, 500)
285 return response
288@admin.route("/admin/store/<store_id>/models", methods=["POST"])
289@login_required
290@exchange_required
291def create_models(store_id: str):
292 """
293 Create a model for a given store.
295 Args:
296 store_id (str): The ID of the store.
298 Returns:
299 dict: A dictionary containing the response message and success
300 status.
301 """
303 # TO DO: Addn validation that name does not exist already
305 res = {}
307 try:
308 name = flask.request.form.get("name")
309 api_key = flask.request.form.get("api_key", "")
311 if len(name) > 128:
312 res["message"] = "Name is too long. Limit 128 characters"
313 res["success"] = False
314 return make_response(res, 500)
316 if api_key and len(api_key) != 50 and not api_key.isalpha():
317 res["message"] = "Invalid API key"
318 res["success"] = False
319 return make_response(res, 500)
321 admin_api.create_store_model(flask.session, store_id, name, api_key)
322 res["success"] = True
324 return make_response(res, 201)
325 except StoreApiResponseErrorList as error_list:
326 res["success"] = False
327 messages = [
328 f"{error.get('message', 'An error occurred')}"
329 for error in error_list.errors
330 ]
331 res["message"] = (" ").join(messages)
333 except Exception:
334 res["success"] = False
335 res["message"] = "An error occurred"
337 return make_response(res, 500)
340@admin.route("/admin/store/<store_id>/models/<model_name>", methods=["PATCH"])
341@login_required
342@exchange_required
343def update_model(store_id: str, model_name: str):
344 """
345 Update a model for a given store.
347 Args:
348 store_id (str): The ID of the store.
349 model_name (str): The name of the model.
351 Returns:
352 dict: A dictionary containing the response message and success
353 status.
354 """
355 res = {}
357 try:
358 api_key = flask.request.form.get("api_key", "")
360 if len(api_key) != 50 and not api_key.isalpha():
361 res["message"] = "Invalid API key"
362 res["success"] = False
363 return make_response(res, 500)
365 admin_api.update_store_model(
366 flask.session, store_id, model_name, api_key
367 )
368 res["success"] = True
370 except StoreApiResponseErrorList as error_list:
371 res["success"] = False
372 res["message"] = error_list.errors[0]["message"]
374 except StoreApiResourceNotFound:
375 res["success"] = False
376 res["message"] = "Model not found"
377 if res["success"]:
378 return make_response(res, 200)
379 return make_response(res, 500)
382@admin.route("/admin/store/<store_id>/models/<model_name>/policies")
383@login_required
384@exchange_required
385def get_policies(store_id: str, model_name: str):
386 """
387 Get the policies for a given store model.
389 Args:
390 store_id (str): The ID of the store.
391 model_name (str): The name of the model.
393 Returns:
394 dict: A dictionary containing the response message and success
395 """
396 res = {}
398 try:
399 policies = admin_api.get_store_model_policies(
400 flask.session, store_id, model_name
401 )
402 res["success"] = True
403 res["data"] = policies
404 response = make_response(res, 200)
405 response.cache_control.max_age = "3600"
406 return response
407 except StoreApiResponseErrorList as error_list:
408 res["success"] = False
409 res["message"] = " ".join(
410 [
411 f"{error.get('message', 'An error occurred')}"
412 for error in error_list.errors
413 ]
414 )
415 except Exception:
416 res["success"] = False
417 res["message"] = "An error occurred"
419 return make_response(res, 500)
422@admin.route(
423 "/admin/store/<store_id>/models/<model_name>/policies", methods=["POST"]
424)
425@login_required
426@exchange_required
427def create_policy(store_id: str, model_name: str):
428 """
429 Creat policy for a store model.
431 Args:
432 store_id (str): The ID of the store.
433 model_name (str): The name of the model.
435 Returns:
436 dict: A dictionary containing the response message and success
437 """
438 signing_key = flask.request.form.get("signing_key")
439 res = {}
440 try:
441 signing_keys_data = admin_api.get_store_signing_keys(
442 flask.session, store_id
443 )
444 signing_keys = [key["sha3-384"] for key in signing_keys_data]
446 if not signing_key:
447 res["message"] = "Signing key required"
448 res["success"] = False
449 return make_response(res, 500)
451 if signing_key in signing_keys:
452 admin_api.create_store_model_policy(
453 flask.session, store_id, model_name, signing_key
454 )
455 res["success"] = True
456 else:
457 res["message"] = "Invalid signing key"
458 res["success"] = False
459 except StoreApiResponseErrorList as error_list:
460 res["success"] = False
461 res["message"] = error_list.errors[0]["message"]
463 if res["success"]:
464 return make_response(res, 200)
465 return make_response(res, 500)
468@admin.route(
469 "/admin/store/<store_id>/models/<model_name>/policies/<revision>",
470 methods=["DELETE"],
471)
472@login_required
473@exchange_required
474def delete_policy(store_id: str, model_name: str, revision: str):
475 res = {}
476 try:
477 response = admin_api.delete_store_model_policy(
478 flask.session, store_id, model_name, revision
479 )
480 if response.status_code == 204:
481 res = {"success": True}
482 if response.status_code == 404:
483 res = {"success": False, "message": "Policy not found"}
484 except StoreApiResponseErrorList as error_list:
485 res["success"] = False
486 res["message"] = error_list.errors[0]["message"]
487 if res["success"]:
488 return make_response(res, 200)
489 return make_response(res, 500)
492@admin.route("/admin/store/<store_id>/brand")
493@login_required
494@exchange_required
495def get_brand_store(store_id: str):
496 res = {}
497 try:
498 brand = admin_api.get_brand(flask.session, store_id)
500 res["data"] = brand
501 res["success"] = True
503 except StoreApiResponseErrorList as error_list:
504 res["success"] = False
505 res["message"] = " ".join(
506 [
507 f"{error.get('message', 'An error occurred')}"
508 for error in error_list.errors
509 ]
510 )
511 res["data"] = []
513 response = make_response(res)
514 response.cache_control.max_age = 3600
516 return response
519@admin.route("/admin/store/<store_id>/signing-keys")
520@login_required
521@exchange_required
522def get_signing_keys(store_id: str):
523 res = {}
524 try:
525 signing_keys = admin_api.get_store_signing_keys(
526 flask.session, store_id
527 )
528 res["data"] = signing_keys
529 res["success"] = True
530 response = make_response(res, 200)
531 response.cache_control.max_age = 3600
532 return response
533 except StoreApiResponseErrorList as error_list:
534 res["success"] = False
535 res["success"] = False
536 res["message"] = " ".join(
537 [
538 f"{error.get('message', 'An error occurred')}"
539 for error in error_list.errors
540 ]
541 )
542 res["data"] = []
543 return make_response(res, 500)
546@admin.route("/admin/store/<store_id>/signing-keys", methods=["POST"])
547@login_required
548@exchange_required
549def create_signing_key(store_id: str):
550 name = flask.request.form.get("name")
551 res = {}
553 try:
554 if name and len(name) <= 128:
555 admin_api.create_store_signing_key(flask.session, store_id, name)
556 res["success"] = True
557 return make_response(res, 200)
558 else:
559 res["message"] = "Invalid signing key. Limit 128 characters"
560 res["success"] = False
561 make_response(res, 500)
562 except StoreApiResponseErrorList as error_list:
563 res["success"] = False
564 res["message"] = error_list.errors[0]["message"]
566 return make_response(res, 500)
569@admin.route(
570 "/admin/store/<store_id>/signing-keys/<signing_key_sha3_384>",
571 methods=["DELETE"],
572)
573@login_required
574@exchange_required
575def delete_signing_key(store_id: str, signing_key_sha3_384: str):
576 """
577 Deletes a signing key from the store.
579 Args:
580 store_id (str): The ID of the store.
581 signing_key_sha3_384 (str): The signing key to delete.
583 Returns:
584 Response: A response object with the following fields:
585 - success (bool): True if the signing key was deleted successfully,
586 False otherwise.
587 - message (str): A message describing the result of the deletion.
588 - data (dict): A dictionary containing models where the signing
589 key is used.
590 """
591 res = {}
593 try:
594 response = admin_api.delete_store_signing_key(
595 flask.session, store_id, signing_key_sha3_384
596 )
598 if response.status_code == 204:
599 res["success"] = True
600 return make_response(res, 200)
601 elif response.status_code == 404:
602 res["success"] = False
603 res["message"] = "Signing key not found"
604 return make_response(res, 404)
605 except StoreApiResponseErrorList as error_list:
606 message = error_list.errors[0]["message"]
607 if (
608 error_list.status_code == 409
609 and "used to sign at least one serial policy" in message
610 ):
611 matching_models = []
612 models_response = get_models(store_id).json
613 models = models_response.get("data", [])
615 for model in models:
616 policies_resp = get_policies(store_id, model["name"]).json
617 policies = policies_resp.get("data", [])
618 matching_policies = [
619 {"revision": policy["revision"]}
620 for policy in policies
621 if policy["signing-key-sha3-384"] == signing_key_sha3_384
622 ]
623 if matching_policies:
624 matching_models.append(
625 {
626 "name": model["name"],
627 "policies": matching_policies,
628 }
629 )
630 res["data"] = {"models": matching_models}
631 res["message"] = "Signing key is used in at least one policy"
632 res["success"] = False
633 else:
634 res["success"] = False
635 res["message"] = error_list.errors[0]["message"]
637 return make_response(res, 500)
640# ---------------------- END MODELS SERVICES ----------------------
643# -------------------- FEATURED SNAPS AUTOMATION ------------------
644@admin.route("/admin/featured", methods=["POST"])
645@login_required
646@exchange_required
647def post_featured_snaps():
648 """
649 In this view, we do three things:
650 1. Fetch all currently featured snaps
651 2. Delete the currently featured snaps
652 3. Update featured snaps to be newly featured
654 Args:
655 None
657 Returns:
658 dict: A dictionary containing the response message and success status.
659 """
661 # new_featured_snaps is the list of featured snaps to be updated
662 featured_snaps = flask.request.form.get("snaps")
664 if not featured_snaps:
665 response = {
666 "success": False,
667 "message": "Snaps cannot be empty",
668 }
669 return make_response(response, 500)
670 new_featured_snaps = featured_snaps.split(",")
672 # currently_featured_snap is the list of featured snaps to be deleted
673 currently_featured_snaps = []
675 next = True
676 while next:
677 featured_snaps = admin_api.get_featured_snaps(flask.session)
678 currently_featured_snaps.extend(
679 featured_snaps.get("_embedded", {}).get("clickindex:package", [])
680 )
681 next = featured_snaps.get("_links", {}).get("next", False)
683 currently_featured_snap_ids = [
684 snap["snap_id"] for snap in currently_featured_snaps
685 ]
687 delete_response = admin_api.delete_featured_snaps(
688 flask.session, {"packages": currently_featured_snap_ids}
689 )
690 if delete_response.status_code != 201:
691 response = {
692 "success": False,
693 "message": "An error occurred while deleting featured snaps",
694 }
695 return make_response(response, 500)
696 snap_ids = [
697 publisher_api.get_snap_id(snap_name, flask.session)
698 for snap_name in new_featured_snaps
699 ]
701 update_response = admin_api.update_featured_snaps(
702 flask.session, {"packages": snap_ids}
703 )
704 if update_response.status_code != 201:
705 response = {
706 "success": False,
707 "message": "An error occured while updating featured snaps",
708 }
709 return make_response(response, 500)
710 return make_response({"success": True}, 200)