Coverage for webapp/publisher/views.py: 67%
504 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 redis_cache.cache_utility import redis_cache
2from canonicalwebteam.exceptions import (
3 PublisherMacaroonRefreshRequired,
4 StoreApiError,
5 StoreApiResponseError,
6 StoreApiResponseErrorList,
7)
8from flask import (
9 Blueprint,
10 flash,
11 redirect,
12 render_template,
13 request,
14 session,
15 url_for,
16 make_response,
17)
18from flask.json import jsonify
19from flask_wtf.csrf import generate_csrf
20from webapp import authentication
21from webapp.config import DETAILS_VIEW_REGEX
22from webapp.decorators import login_required, cached_redirect
23from webapp.publisher.logic import get_all_architectures, process_releases
24from webapp.observability.utils import trace_function
25from webapp.store_api import device_gateway, publisher_gateway
26from webapp.utils.emailer import get_emailer
27from webapp.solutions.logic import (
28 get_publisher_solutions,
29 register_solution,
30 get_user_teams_for_solutions,
31 get_solution_from_backend,
32 update_solution,
33 solution_name_exists,
34 group_solution_drafts,
35 get_solution_categories,
36)
37from webapp.publisher.form_processors import (
38 process_solution_form_data,
39 create_error_context,
40)
41from datetime import datetime
42import uuid
43from urllib.parse import quote
45preview_cache = {}
48def paginate_pages(current_page, total_pages):
49 truncate_threshold = 10
50 page_numbers = list(range(1, total_pages + 1))
51 truncated = total_pages > truncate_threshold
53 if not truncated:
54 return page_numbers
56 start = current_page - 2
57 end = current_page + 1
59 if current_page in (1, 2):
60 start = 1
61 end = min(total_pages - 1, current_page + 3)
63 if current_page in (total_pages, total_pages - 1):
64 start = max(1, total_pages - 4)
65 end = total_pages - 1
67 visible_pages = page_numbers[start:end]
68 pages: list[int | None] = [1]
70 if 2 not in visible_pages:
71 pages.append(None)
73 pages.extend(visible_pages)
75 if total_pages - 1 not in visible_pages:
76 pages.append(None)
78 pages.append(total_pages)
80 return pages
83def render_solution_form_with_errors(
84 template_name, errors, form_data=None, solution=None, status=400
85):
86 username = session["account"]["username"]
87 user_teams = get_user_teams_for_solutions(username)
89 context = create_error_context(errors, solution, user_teams, form_data)
90 context["categories"] = get_solution_categories()
92 return render_template(template_name, **context), status
95publisher = Blueprint(
96 "publisher",
97 __name__,
98 template_folder="/templates",
99 static_folder="/static",
100)
103@publisher.route("/api/solutions/csrf-token")
104@login_required
105def solutions_csrf_token():
106 response = jsonify({"csrf_token": generate_csrf()})
107 response.headers["Cache-Control"] = "no-store"
108 response.headers.add("Vary", "Cookie")
109 return response
112@trace_function
113@publisher.route("/account/details")
114@login_required
115def get_account_details():
116 return render_template("publisher/account-details.html")
119def get_package_unlisted(entity_name):
120 key = f"package_unlisted:{entity_name}"
121 unlisted = redis_cache.get(key, expected_type=bool)
122 if unlisted is not None:
123 return unlisted
124 try:
125 details = device_gateway.get_item_details(
126 entity_name, fields=["result.unlisted"]
127 )
128 unlisted = details.get("result", {}).get("unlisted", False)
129 except StoreApiResponseErrorList:
130 unlisted = False
131 redis_cache.set(key, unlisted, ttl=300)
132 return unlisted
135@trace_function
136def get_package_metadata(entity_name):
137 key = f"package_metadata:{session['account']['id']}:{entity_name}"
138 package = redis_cache.get(key, expected_type=dict)
139 if not package:
140 package = publisher_gateway.get_package_metadata(session, entity_name)
141 if "unlisted" not in package:
142 package["unlisted"] = get_package_unlisted(entity_name)
143 redis_cache.set(key, package, ttl=300)
144 return package
147def clear_listing_caches(entity_name):
148 public_fields = [
149 "result.media",
150 "default-release",
151 "result.categories",
152 "result.publisher.display-name",
153 "result.title",
154 "result.unlisted",
155 "channel-map",
156 "result.deployable-on",
157 "result.bugs-url",
158 "result.website",
159 "result.summary",
160 "default-release.revision.metadata-yaml",
161 "default-release.revision.readme-md",
162 "result.links",
163 ]
164 public_cache_parts = {
165 "channel": None,
166 "fields": ",".join(sorted(public_fields)),
167 }
169 redis_cache.delete(
170 f"package_metadata:{session['account']['id']}:{entity_name}"
171 )
172 redis_cache.delete((f"package_details:{entity_name}", public_cache_parts))
173 redis_cache.delete((f"package:{entity_name}", public_cache_parts))
174 redis_cache.delete((f"{entity_name}:details-overview", public_cache_parts))
177@trace_function
178@publisher.route(
179 '/<regex("'
180 + DETAILS_VIEW_REGEX
181 + '"):entity_name>/'
182 + '<regex("listing|releases|publicise|collaboration|settings"):path>'
183)
184@login_required
185def get_publisher(entity_name, path):
186 session["developer_token"] = session["account-auth"]
187 package = get_package_metadata(entity_name)
188 context = {
189 "package": package,
190 }
191 return render_template("publisher/publisher.html", **context)
194@trace_function
195@publisher.route(
196 '/api/packages/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>',
197)
198@login_required
199def get_package(entity_name):
200 session["developer_token"] = session["account-auth"]
201 package = get_package_metadata(entity_name)
202 return jsonify({"data": package, "success": True})
205@trace_function
206@publisher.route(
207 '/api/packages/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>',
208 methods=["PATCH"],
209)
210@login_required
211def update_package(entity_name):
212 payload = request.get_json()
214 res = {}
216 try:
217 package = publisher_gateway.update_package_metadata(
218 session["account-auth"], "charm", entity_name, payload
219 )
220 clear_listing_caches(entity_name)
221 res["data"] = package
222 res["success"] = True
223 res["message"] = ""
224 response = make_response(res, 200)
225 except StoreApiResponseErrorList as error_list:
226 error_messages = [
227 f"{error.get('message', 'Unable to update this charm or bundle')}"
228 for error in error_list.errors
229 ]
230 if "unauthorized" in error_messages:
231 res["message"] = "Package not found"
232 else:
233 res["message"] = " ".join(error_messages) or str(error_list)
234 res["success"] = False
235 response = make_response(res, error_list.status_code)
236 except StoreApiError as error:
237 res["message"] = str(error) or "Unable to update this charm or bundle"
238 res["success"] = False
239 response = make_response(res, 500)
241 return response
244@trace_function
245@publisher.route("/charms")
246@publisher.route("/bundles")
247@login_required
248def list_page():
249 items_per_page = 10
250 page_type = request.path[1:-1]
251 page = request.args.get("page", default=1, type=int)
252 if not page or page < 1:
253 page = 1
254 key = f"account_packages:{session['account']['id']}:{page_type}"
255 context = redis_cache.get(key, expected_type=dict)
256 if not context:
257 publisher_charms = publisher_gateway.get_account_packages(
258 session["account-auth"], "charm", include_collaborations=True
259 )
261 acc_id = session["account"]["id"]
262 context = {
263 "published": [
264 {**c, "is_owner": c["publisher"]["id"] == acc_id}
265 for c in publisher_charms
266 if c["status"] == "published" and c["type"] == page_type
267 ],
268 "registered": [
269 {**c, "is_owner": c["publisher"]["id"] == acc_id}
270 for c in publisher_charms
271 if c["status"] == "registered" and c["type"] == page_type
272 ],
273 "page_type": page_type,
274 }
275 redis_cache.set(key, context, ttl=600)
277 total_items = len(context["published"])
278 total_pages = max(1, (total_items + items_per_page - 1) // items_per_page)
279 if page > total_pages:
280 page = total_pages
282 start = (page - 1) * items_per_page
283 end = start + items_per_page
284 context["published"] = [
285 {
286 **package,
287 "unlisted": (
288 package["unlisted"]
289 if "unlisted" in package
290 else get_package_unlisted(package["name"])
291 ),
292 }
293 for package in context["published"][start:end]
294 ]
295 context["pagination"] = {
296 "current_page": page,
297 "total_pages": total_pages,
298 "has_prev": page > 1,
299 "has_next": page < total_pages,
300 "pages": paginate_pages(page, total_pages),
301 }
303 return render_template("publisher/list.html", **context)
306@trace_function
307@publisher.route("/solutions")
308@login_required
309def solutions_page():
310 username = session["account"]["username"]
312 # Get solutions data according to launchpad group
313 solutions = group_solution_drafts(get_publisher_solutions(username))
315 context = {
316 "solutions": solutions,
317 "page_type": "solution",
318 }
320 return render_template("publisher/solutions.html", **context)
323@trace_function
324@publisher.route("/accept-invite")
325@login_required
326@cached_redirect
327def accept_invite():
328 return render_template("publisher/accept-invite.html")
331@trace_function
332@publisher.route("/accept-invite", methods=["POST"])
333@login_required
334def accept_post_invite():
335 res = {}
337 try:
338 token = request.form.get("token")
339 package = request.form.get("package")
340 response = publisher_gateway.accept_invite(
341 session["account-auth"], package, token
342 )
344 if response.status_code == 204:
345 res["success"] = True
346 return make_response(res, 200)
347 else:
348 res["success"] = False
349 errors = response.json().get("error-list", [])
350 res["message"] = (
351 errors[0].get("message") if errors else "Unknown error"
352 )
353 return make_response(res, response.status_code)
355 except PublisherMacaroonRefreshRequired:
356 authentication.empty_session(session)
357 res["success"] = False
358 res["reauth_required"] = True
359 return make_response(res, 401)
360 except StoreApiResponseErrorList as error_list:
361 res["success"] = False
362 error_messages = [
363 f"{error.get('message', 'An error occured')}"
364 for error in error_list.errors
365 ]
366 res["message"] = " ".join(error_messages)
367 return make_response(res, error_list.status_code)
368 except StoreApiResponseError as error:
369 res["success"] = False
370 res["message"] = str(error)
371 return make_response(res, error.status_code)
372 except Exception:
373 res["success"] = False
374 res["message"] = "An error occured"
376 return make_response(res, 500)
379@trace_function
380@publisher.route("/reject-invite", methods=["POST"])
381@login_required
382def reject_post_invite():
383 res = {}
385 try:
386 token = request.form.get("token")
387 package = request.form.get("package")
388 response = publisher_gateway.reject_invite(
389 session["account-auth"], package, token
390 )
392 if response.status_code == 204:
393 res["success"] = True
394 return make_response(res, 200)
395 else:
396 res["success"] = False
397 res["message"] = "An error occured"
398 return make_response(res, 200)
400 except PublisherMacaroonRefreshRequired:
401 authentication.empty_session(session)
402 res["success"] = False
403 res["reauth_required"] = True
404 return make_response(res, 401)
405 except StoreApiResponseErrorList as error_list:
406 res["success"] = False
407 error_messages = [
408 f"{error.get('message', 'An error occured')}"
409 for error in error_list.errors
410 ]
411 res["message"] = " ".join(error_messages)
412 response = make_response(res, 500)
413 except Exception:
414 res["success"] = False
415 res["message"] = "An error occured"
417 return response
420@trace_function
421@publisher.route(
422 '/api/packages/<regex("'
423 + DETAILS_VIEW_REGEX
424 + '"):entity_name>/collaborators',
425)
426@login_required
427def get_collaborators(entity_name):
428 res = {}
430 try:
431 collaborators = publisher_gateway.get_collaborators(
432 session["account-auth"], entity_name
433 )
434 res["success"] = True
435 res["data"] = collaborators
436 response = make_response(res, 200)
437 except StoreApiResponseErrorList as error_list:
438 error_messages = [
439 f"{error.get('message', 'An error occured')}"
440 for error in error_list.errors
441 ]
442 res["message"] = " ".join(error_messages)
443 res["success"] = False
444 response = make_response(res, 500)
446 return response
449@trace_function
450@publisher.route(
451 '/api/packages/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/invites',
452)
453@login_required
454def get_pending_invites(entity_name):
455 res = {}
457 try:
458 invites = publisher_gateway.get_pending_invites(
459 session["account-auth"], entity_name
460 )
461 res["success"] = True
462 res["data"] = invites["invites"]
463 response = make_response(res, 200)
464 except StoreApiResponseErrorList as error_list:
465 error_messages = [
466 f"{error.get('message', 'An error occured')}"
467 for error in error_list.errors
468 ]
469 res["message"] = " ".join(error_messages)
470 res["success"] = False
471 response = make_response(res, 500)
473 return response
476@trace_function
477@publisher.route(
478 '/api/packages/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/invites',
479 methods=["POST"],
480)
481@login_required
482def invite_collaborators(entity_name):
483 res = {}
485 try:
486 collaborators = request.form.get("collaborators")
487 if not collaborators:
488 res["success"] = False
489 res["message"] = "No collaborators provided"
490 return make_response(res, 400)
492 result = publisher_gateway.invite_collaborators(
493 session["account-auth"], entity_name, [collaborators]
494 )
496 token = result["tokens"][0]["token"]
498 invite_link = (
499 f"https://charmhub.io/accept-invite?package={entity_name}"
500 f"&token={quote(token, safe='')}"
501 )
502 emailer = get_emailer()
503 emailer.send_email_template(
504 template_path="emails/collaborator-invite.html",
505 to_email=collaborators,
506 context={
507 "charm_name": entity_name,
508 "invite_link": invite_link,
509 },
510 subject=(
511 f"You have been invited to as a collaborator on "
512 f"{entity_name} in Charmhub"
513 ),
514 )
516 res["success"] = True
517 res["data"] = result["tokens"]
518 return make_response(res, 200)
519 except StoreApiResponseErrorList as error_list:
520 res["success"] = False
521 messages = [
522 f"{error.get('message', 'An error occurred')}"
523 for error in error_list.errors
524 ]
525 res["message"] = (" ").join(messages)
526 except Exception:
527 res["success"] = False
528 res["message"] = "An error occurred"
529 raise
531 return make_response(res, 500)
534@trace_function
535@publisher.route(
536 '/api/packages/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/invites',
537 methods=["DELETE"],
538)
539@login_required
540def revoke_invite(entity_name):
541 res = {}
543 try:
544 collaborator = request.form.get("collaborator")
545 response = publisher_gateway.revoke_invites(
546 session["account-auth"], entity_name, [collaborator]
547 )
549 if response.status_code == 204:
550 res["success"] = True
551 return make_response(res, 200)
552 else:
553 res["success"] = False
554 res["message"] = "An error occurred"
555 return make_response(res, 500)
557 except StoreApiResponseErrorList as error_list:
558 res["success"] = False
559 messages = [
560 f"{error.get('message', 'An error occurred')}"
561 for error in error_list.errors
562 ]
563 res["message"] = (" ").join(messages)
565 return make_response(res, 500)
568@trace_function
569@publisher.route("/register-name")
570@login_required
571def register_name():
572 entity_name = request.args.get("entity_name", default="", type=str)
574 invalid_name_str = request.args.get(
575 "invalid_name", default="False", type=str
576 )
577 invalid_name = invalid_name_str == "True"
579 reserved_name_str = request.args.get(
580 "reserved_name", default="False", type=str
581 )
582 reserved_name = reserved_name_str == "True"
584 already_registered_str = request.args.get(
585 "already_registered", default="False", type=str
586 )
587 already_registered = already_registered_str == "True"
589 already_owned_str = request.args.get(
590 "already_owned", default="False", type=str
591 )
592 already_owned = already_owned_str == "True"
594 api_error_message = request.args.get(
595 "api_error_message", default="", type=str
596 )
598 context = {
599 "entity_name": entity_name,
600 "reserved_name": reserved_name,
601 "invalid_name": invalid_name,
602 "already_owned": already_owned,
603 "already_registered": already_registered,
604 "api_error_message": api_error_message,
605 }
606 return render_template("publisher/register-name.html", **context)
609@trace_function
610@publisher.route("/register-name", methods=["POST"])
611@login_required
612def post_register_name():
613 data = {
614 "name": request.form["name"],
615 "type": "charm",
616 "private": True if request.form.get("private") == "private" else False,
617 }
619 try:
620 result = publisher_gateway.register_package_name(
621 session["account-auth"], data
622 )
623 if result:
624 flash(
625 f"Your {data['type']} name has been successfully registered.",
626 "positive",
627 )
628 # Invalidate the cached /charms or /bundles list so the
629 # newly registered name shows up immediately on redirect,
630 # instead of waiting for the 10 minute cache TTL to expire.
631 redis_cache.delete(
632 f"account_packages:{session['account']['id']}:{data['type']}"
633 )
634 except StoreApiResponseErrorList as api_response_error_list:
635 for error in api_response_error_list.errors:
636 if error["code"] == "reserved-name":
637 return redirect(
638 url_for(
639 ".register_name",
640 entity_name=data["name"],
641 reserved_name=True,
642 )
643 )
644 elif error["code"] == "already-registered":
645 return redirect(
646 url_for(
647 ".register_name",
648 entity_name=data["name"],
649 already_registered=True,
650 )
651 )
652 elif error["code"] == "already-owned":
653 return redirect(
654 url_for(
655 ".register_name",
656 entity_name=data["name"],
657 already_owned=True,
658 )
659 )
660 else:
661 # For any other error code (e.g. the generic "api-error"
662 # the API sometimes uses to wrap more specific failures,
663 # such as the name already being taken by another
664 # publisher), relay the API's own message instead of
665 # guessing what went wrong.
666 return redirect(
667 url_for(
668 ".register_name",
669 entity_name=data["name"],
670 api_error_message=error.get(
671 "message", "Something went wrong."
672 ),
673 )
674 )
676 if data["type"] == "charm":
677 return redirect("/charms")
678 elif data["type"] == "bundle":
679 return redirect("/bundles")
682@trace_function
683@publisher.route("/register-name-dispute")
684@login_required
685def register_name_dispute():
686 entity_name = request.args.get("entity-name", type=str)
688 if not entity_name:
689 return redirect(url_for(".register_name", entity_name=entity_name))
691 return render_template(
692 "publisher/register-name-dispute/index.html", entity_name=entity_name
693 )
696@trace_function
697@publisher.route("/register-name-dispute/thank-you")
698@login_required
699def register_name_dispute_thank_you():
700 entity_name = request.args.get("entity-name", type=str)
702 if not entity_name:
703 return redirect(url_for(".register_name", entity_name=entity_name))
705 return render_template(
706 "publisher/register-name-dispute/thank-you.html",
707 entity_name=entity_name,
708 )
711@trace_function
712@publisher.route("/packages/<package_name>", methods=["DELETE"])
713@login_required
714def delete_package(package_name):
715 resp = publisher_gateway.unregister_package_name(
716 session["account-auth"], package_name
717 )
718 if resp.status_code == 200:
719 # Invalidate the cached /charms and /bundles lists so the
720 # unregistered name disappears immediately on reload, instead
721 # of waiting for the 10 minute cache TTL to expire. We don't
722 # know here whether the package was a charm or bundle, so
723 # clear both.
724 account_id = session["account"]["id"]
725 redis_cache.delete(f"account_packages:{account_id}:charm")
726 redis_cache.delete(f"account_packages:{account_id}:bundle")
727 return ("", 200)
728 return (
729 jsonify({"error": resp.json()["error-list"][0]["message"]}),
730 resp.status_code,
731 )
734@trace_function
735@publisher.route("/<charm_name>/create-track", methods=["POST"])
736@login_required
737def post_create_track(charm_name):
738 track_name = request.form.get("track-name")
739 version_pattern = request.form.get("version-pattern")
740 auto_phasing_percentage = request.form.get("auto-phasing-percentage")
742 if auto_phasing_percentage is not None:
743 auto_phasing_percentage = float(auto_phasing_percentage)
745 response = publisher_gateway.create_track(
746 session,
747 charm_name,
748 track_name,
749 version_pattern,
750 auto_phasing_percentage,
751 )
752 if response.status_code == 201:
753 return response.json(), response.status_code
754 if response.status_code == 409:
755 return (
756 jsonify({"error": "Track already exists."}),
757 response.status_code,
758 )
759 if "error-list" in response.json():
760 return (
761 jsonify({"error": response.json()["error-list"][0]["message"]}),
762 response.status_code,
763 )
764 return response.json(), response.status_code
767@trace_function
768@publisher.route(
769 '/api/packages/<regex("' + DETAILS_VIEW_REGEX + '"):entity_name>/releases',
770)
771@login_required
772def get_releases(entity_name: str):
773 key = f"package_releases:{session['account']['id']}:{entity_name}"
774 res = redis_cache.get(key, expected_type=dict) or {}
776 try:
777 if not res:
778 release_data = publisher_gateway.get_releases(
779 session["account-auth"], entity_name
780 )
781 res["success"] = True
783 res["data"] = {}
785 res["data"]["releases"] = process_releases(
786 release_data["channel-map"],
787 release_data["package"]["channels"],
788 release_data["revisions"],
789 )
790 res["data"]["all_architectures"] = get_all_architectures(
791 res["data"]["releases"]
792 )
793 redis_cache.set(key, res, ttl=300)
794 response = make_response(res, 200)
796 except StoreApiResponseErrorList as error_list:
797 error_messages = [
798 f"{error.get('message', 'An error occured')}"
799 for error in error_list.errors
800 ]
801 res["message"] = " ".join(error_messages)
802 res["success"] = False
803 response = make_response(res, 500)
805 return response
808@publisher.route("/validate-solution-name")
809@login_required
810def validate_solution_name():
811 name = request.args.get("name", "").strip()
813 if not name:
814 return jsonify({"exists": False})
816 exists = solution_name_exists(name)
817 return jsonify({"exists": exists})
820@publisher.route("/register-solution")
821@login_required
822def show_register_solution_form():
823 username = session["account"]["username"]
825 user_teams = get_user_teams_for_solutions(username)
827 context = {
828 "user_teams": user_teams,
829 }
830 return render_template("publisher/register-solution.html", **context)
833@publisher.route("/register-solution", methods=["POST"])
834@login_required
835def submit_register_solution():
836 username = session["account"]["username"]
838 form_data = {
839 field: request.form.get(field, "").strip()
840 for field in [
841 "name",
842 "title",
843 "summary",
844 "publisher",
845 "platform",
846 "creator_email",
847 "mattermost_handle",
848 ]
849 }
851 if not all(
852 [
853 form_data["name"],
854 form_data["title"],
855 form_data["summary"],
856 form_data["publisher"],
857 form_data["creator_email"],
858 ]
859 ):
860 return render_solution_form_with_errors(
861 "publisher/register-solution.html",
862 [
863 {
864 "code": "missing-fields",
865 "message": "All required fields must be filled.",
866 }
867 ],
868 form_data,
869 )
871 if solution_name_exists(form_data["name"]):
872 return render_solution_form_with_errors(
873 "publisher/register-solution.html",
874 [
875 {
876 "code": "name-already-exists",
877 "message": (
878 "A solution with the name "
879 f"'{form_data['name']}' already exists. "
880 "Please choose a different name."
881 ),
882 }
883 ],
884 form_data,
885 )
887 solution_data = {
888 "name": form_data["name"],
889 "title": form_data["title"],
890 "summary": form_data["summary"],
891 "publisher": form_data["publisher"],
892 "platform": form_data["platform"],
893 "creator_email": form_data["creator_email"],
894 }
895 if form_data["mattermost_handle"]:
896 solution_data["mattermost_handle"] = form_data["mattermost_handle"]
898 result = register_solution(username, solution_data)
900 if "error" in result:
901 return render_solution_form_with_errors(
902 "publisher/register-solution.html",
903 [{"code": "api-error", "message": result["error"]}],
904 form_data,
905 )
906 if "error-list" in result and result["error-list"]:
907 return render_solution_form_with_errors(
908 "publisher/register-solution.html",
909 result["error-list"],
910 form_data,
911 status=422,
912 )
914 flash(
915 (
916 f"Your solution '{form_data['title']}' has been successfully "
917 "registered and is pending name review."
918 ),
919 "positive",
920 )
922 return redirect("/solutions")
925@publisher.route("/solutions/edit/<hash>")
926@login_required
927def edit_solution_form(hash):
928 solution = get_solution_from_backend(hash, prefer_authenticated=True)
929 if not solution:
930 flash("Solution not found.", "negative")
931 return redirect("/solutions")
933 if solution.get("status") not in [
934 "draft",
935 "published",
936 "pending_metadata_review",
937 ]:
938 flash(
939 "This solution cannot be edited in its current status.", "negative"
940 )
941 return redirect("/solutions")
943 username = session["account"]["username"]
945 if solution.get("status") == "published" and solution.get("draft_update"):
946 draft = solution["draft_update"]
947 flash(
948 "A draft update already exists for this solution. "
949 "We've opened that draft so you can continue where you left off.",
950 "information",
951 )
952 return redirect(f"/solutions/edit/{draft['hash']}")
954 user_teams = get_user_teams_for_solutions(username)
956 context = {
957 "user_teams": user_teams,
958 "solution": solution,
959 "categories": get_solution_categories(),
960 }
961 return render_template("solutions/edit-solution.html", **context)
964def cleanup_old_previews():
965 now = datetime.now()
966 expired_keys = [
967 key
968 for key, (_, timestamp) in preview_cache.items()
969 if (now - timestamp).total_seconds() > 300
970 ]
971 for key in expired_keys:
972 del preview_cache[key]
975@publisher.route("/solutions/edit/<hash>", methods=["POST"])
976@login_required
977def submit_edit_solution(hash):
978 # Get solution data from backend first
979 solution = get_solution_from_backend(hash, prefer_authenticated=True)
980 if not solution:
981 flash("Solution not found.", "negative")
982 return redirect("/solutions")
984 # Check if solution can be edited
985 # (only draft, published, or pending_metadata_review solutions)
986 if solution.get("status") not in [
987 "draft",
988 "published",
989 "pending_metadata_review",
990 ]:
991 flash(
992 "This solution cannot be edited in its current status.", "negative"
993 )
994 return redirect("/solutions")
996 username = session["account"]["username"]
998 form_data = process_solution_form_data()
1000 action = request.form.get("action", "save_draft")
1002 if action == "preview":
1003 preview_key = str(uuid.uuid4().hex[:16])
1005 cleanup_old_previews()
1007 preview_cache[preview_key] = (
1008 {"solution": solution, "form_data": form_data},
1009 datetime.now(),
1010 )
1012 return jsonify({"success": True, "preview_key": preview_key})
1014 result = update_solution(
1015 username,
1016 solution["name"],
1017 solution["revision"],
1018 form_data,
1019 submit=action != "save_draft",
1020 )
1022 if "error" in result:
1023 flash(f"Failed to update solution: {result['error']}", "negative")
1024 return render_solution_form_with_errors(
1025 "solutions/edit-solution.html",
1026 [{"code": "api-error", "message": result["error"]}],
1027 form_data,
1028 solution,
1029 )
1031 if "error-list" in result and result["error-list"]:
1032 flash(
1033 "Failed to update solution due to validation errors.", "negative"
1034 )
1035 return render_solution_form_with_errors(
1036 "solutions/edit-solution.html",
1037 result["error-list"],
1038 form_data,
1039 solution,
1040 )
1042 if action == "save_draft":
1043 flash(
1044 f"Your draft for '{form_data['title']}' has been saved.",
1045 "positive",
1046 )
1047 elif action == "submit_for_review":
1048 flash(
1049 f"Your solution '{form_data['title']}' "
1050 "has been submitted for metadata review.",
1051 "positive",
1052 )
1053 elif action == "update":
1054 flash(
1055 f"Your solution '{form_data['title']}' "
1056 "has been successfully updated.",
1057 "positive",
1058 )
1060 return redirect("/solutions")