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

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 

44 

45preview_cache = {} 

46 

47 

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 

52 

53 if not truncated: 

54 return page_numbers 

55 

56 start = current_page - 2 

57 end = current_page + 1 

58 

59 if current_page in (1, 2): 

60 start = 1 

61 end = min(total_pages - 1, current_page + 3) 

62 

63 if current_page in (total_pages, total_pages - 1): 

64 start = max(1, total_pages - 4) 

65 end = total_pages - 1 

66 

67 visible_pages = page_numbers[start:end] 

68 pages: list[int | None] = [1] 

69 

70 if 2 not in visible_pages: 

71 pages.append(None) 

72 

73 pages.extend(visible_pages) 

74 

75 if total_pages - 1 not in visible_pages: 

76 pages.append(None) 

77 

78 pages.append(total_pages) 

79 

80 return pages 

81 

82 

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) 

88 

89 context = create_error_context(errors, solution, user_teams, form_data) 

90 context["categories"] = get_solution_categories() 

91 

92 return render_template(template_name, **context), status 

93 

94 

95publisher = Blueprint( 

96 "publisher", 

97 __name__, 

98 template_folder="/templates", 

99 static_folder="/static", 

100) 

101 

102 

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 

110 

111 

112@trace_function 

113@publisher.route("/account/details") 

114@login_required 

115def get_account_details(): 

116 return render_template("publisher/account-details.html") 

117 

118 

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 

133 

134 

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 

145 

146 

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 } 

168 

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

175 

176 

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) 

192 

193 

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

203 

204 

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

213 

214 res = {} 

215 

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) 

240 

241 return response 

242 

243 

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 ) 

260 

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) 

276 

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 

281 

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 } 

302 

303 return render_template("publisher/list.html", **context) 

304 

305 

306@trace_function 

307@publisher.route("/solutions") 

308@login_required 

309def solutions_page(): 

310 username = session["account"]["username"] 

311 

312 # Get solutions data according to launchpad group 

313 solutions = group_solution_drafts(get_publisher_solutions(username)) 

314 

315 context = { 

316 "solutions": solutions, 

317 "page_type": "solution", 

318 } 

319 

320 return render_template("publisher/solutions.html", **context) 

321 

322 

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

329 

330 

331@trace_function 

332@publisher.route("/accept-invite", methods=["POST"]) 

333@login_required 

334def accept_post_invite(): 

335 res = {} 

336 

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 ) 

343 

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) 

354 

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" 

375 

376 return make_response(res, 500) 

377 

378 

379@trace_function 

380@publisher.route("/reject-invite", methods=["POST"]) 

381@login_required 

382def reject_post_invite(): 

383 res = {} 

384 

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 ) 

391 

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) 

399 

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" 

416 

417 return response 

418 

419 

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 = {} 

429 

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) 

445 

446 return response 

447 

448 

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 = {} 

456 

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) 

472 

473 return response 

474 

475 

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 = {} 

484 

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) 

491 

492 result = publisher_gateway.invite_collaborators( 

493 session["account-auth"], entity_name, [collaborators] 

494 ) 

495 

496 token = result["tokens"][0]["token"] 

497 

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 ) 

515 

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 

530 

531 return make_response(res, 500) 

532 

533 

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 = {} 

542 

543 try: 

544 collaborator = request.form.get("collaborator") 

545 response = publisher_gateway.revoke_invites( 

546 session["account-auth"], entity_name, [collaborator] 

547 ) 

548 

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) 

556 

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) 

564 

565 return make_response(res, 500) 

566 

567 

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) 

573 

574 invalid_name_str = request.args.get( 

575 "invalid_name", default="False", type=str 

576 ) 

577 invalid_name = invalid_name_str == "True" 

578 

579 reserved_name_str = request.args.get( 

580 "reserved_name", default="False", type=str 

581 ) 

582 reserved_name = reserved_name_str == "True" 

583 

584 already_registered_str = request.args.get( 

585 "already_registered", default="False", type=str 

586 ) 

587 already_registered = already_registered_str == "True" 

588 

589 already_owned_str = request.args.get( 

590 "already_owned", default="False", type=str 

591 ) 

592 already_owned = already_owned_str == "True" 

593 

594 api_error_message = request.args.get( 

595 "api_error_message", default="", type=str 

596 ) 

597 

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) 

607 

608 

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 } 

618 

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 ) 

675 

676 if data["type"] == "charm": 

677 return redirect("/charms") 

678 elif data["type"] == "bundle": 

679 return redirect("/bundles") 

680 

681 

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) 

687 

688 if not entity_name: 

689 return redirect(url_for(".register_name", entity_name=entity_name)) 

690 

691 return render_template( 

692 "publisher/register-name-dispute/index.html", entity_name=entity_name 

693 ) 

694 

695 

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) 

701 

702 if not entity_name: 

703 return redirect(url_for(".register_name", entity_name=entity_name)) 

704 

705 return render_template( 

706 "publisher/register-name-dispute/thank-you.html", 

707 entity_name=entity_name, 

708 ) 

709 

710 

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 ) 

732 

733 

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

741 

742 if auto_phasing_percentage is not None: 

743 auto_phasing_percentage = float(auto_phasing_percentage) 

744 

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 

765 

766 

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

775 

776 try: 

777 if not res: 

778 release_data = publisher_gateway.get_releases( 

779 session["account-auth"], entity_name 

780 ) 

781 res["success"] = True 

782 

783 res["data"] = {} 

784 

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) 

795 

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) 

804 

805 return response 

806 

807 

808@publisher.route("/validate-solution-name") 

809@login_required 

810def validate_solution_name(): 

811 name = request.args.get("name", "").strip() 

812 

813 if not name: 

814 return jsonify({"exists": False}) 

815 

816 exists = solution_name_exists(name) 

817 return jsonify({"exists": exists}) 

818 

819 

820@publisher.route("/register-solution") 

821@login_required 

822def show_register_solution_form(): 

823 username = session["account"]["username"] 

824 

825 user_teams = get_user_teams_for_solutions(username) 

826 

827 context = { 

828 "user_teams": user_teams, 

829 } 

830 return render_template("publisher/register-solution.html", **context) 

831 

832 

833@publisher.route("/register-solution", methods=["POST"]) 

834@login_required 

835def submit_register_solution(): 

836 username = session["account"]["username"] 

837 

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 } 

850 

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 ) 

870 

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 ) 

886 

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

897 

898 result = register_solution(username, solution_data) 

899 

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 ) 

913 

914 flash( 

915 ( 

916 f"Your solution '{form_data['title']}' has been successfully " 

917 "registered and is pending name review." 

918 ), 

919 "positive", 

920 ) 

921 

922 return redirect("/solutions") 

923 

924 

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

932 

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

942 

943 username = session["account"]["username"] 

944 

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']}") 

953 

954 user_teams = get_user_teams_for_solutions(username) 

955 

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) 

962 

963 

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] 

973 

974 

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

983 

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

995 

996 username = session["account"]["username"] 

997 

998 form_data = process_solution_form_data() 

999 

1000 action = request.form.get("action", "save_draft") 

1001 

1002 if action == "preview": 

1003 preview_key = str(uuid.uuid4().hex[:16]) 

1004 

1005 cleanup_old_previews() 

1006 

1007 preview_cache[preview_key] = ( 

1008 {"solution": solution, "form_data": form_data}, 

1009 datetime.now(), 

1010 ) 

1011 

1012 return jsonify({"success": True, "preview_key": preview_key}) 

1013 

1014 result = update_solution( 

1015 username, 

1016 solution["name"], 

1017 solution["revision"], 

1018 form_data, 

1019 submit=action != "save_draft", 

1020 ) 

1021 

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 ) 

1030 

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 ) 

1041 

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 ) 

1059 

1060 return redirect("/solutions")