gabriel / musehub public
ui_new_repo.py python
176 lines 6.5 KB
6b53f1af feat: supercharge all pages, full SOC refactor, and Python 3.14 upgrade (#7) Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """MuseHub new repo creation wizard — SSR migration (#562).
2
3 Serves the repository creation wizard at /new.
4
5 Routes:
6 GET /new — SSR creation wizard form (Jinja2 template)
7 POST /new — create repo (JSON body, auth required), returns
8 redirect URL for JS navigation
9 GET /new/check — name availability check; returns HTML fragment
10 when requested by HTMX, JSON otherwise
11
12 Auth contract:
13 - GET renders the SSR form without requiring a JWT. The form fields are
14 rendered server-side; client JS (Alpine.js) handles the visibility toggle
15 and topics tag input only.
16 - POST requires a valid JWT in the Authorization header. Returns
17 ``{"redirect": "/{owner}/{slug}?welcome=1"}`` on success so the
18 JS can navigate; returns 409 on slug collision.
19 - GET /new/check is unauthenticated — slug availability is not secret.
20 When called by HTMX (``HX-Request: true``), returns an HTML fragment
21 (``<span>`` with availability text). Otherwise returns JSON for scripts/agents.
22
23 The POST handler delegates all persistence to
24 ``musehub.services.musehub_repository.create_repo``, keeping this handler
25 thin per the routes-as-thin-adapters architecture rule.
26 """
27
28 import logging
29
30 from fastapi import APIRouter, Depends, HTTPException, Query, Request
31 from fastapi import status as http_status
32 from fastapi.responses import HTMLResponse, JSONResponse
33 from sqlalchemy.exc import IntegrityError
34 from sqlalchemy.ext.asyncio import AsyncSession
35 from starlette.responses import Response
36
37 from musehub.api.routes.musehub._templates import templates as _templates
38 from musehub.api.routes.musehub.htmx_helpers import is_htmx
39 from musehub.auth.dependencies import TokenClaims, require_valid_token
40 from musehub.db import get_db
41 from musehub.models.musehub import CreateRepoRequest
42 from musehub.services import musehub_repository
43
44 logger = logging.getLogger(__name__)
45
46 router = APIRouter(prefix="", tags=["musehub-ui-new-repo"])
47
48 # Licence options surfaced in the wizard dropdown.
49 _LICENSES: list[tuple[str, str]] = [
50 ("", "No license"),
51 ("CC0", "CC0 — Public Domain Dedication"),
52 ("CC BY", "CC BY — Attribution"),
53 ("CC BY-SA", "CC BY-SA — Attribution-ShareAlike"),
54 ("CC BY-NC", "CC BY-NC — Attribution-NonCommercial"),
55 ("ARR", "All Rights Reserved"),
56 ]
57
58
59 @router.get(
60 "/new",
61 response_class=HTMLResponse,
62 summary="New repo creation wizard",
63 operation_id="newRepoWizardPage",
64 )
65 async def new_repo_page(request: Request) -> Response:
66 """Render the SSR new repo creation wizard form.
67
68 License options are rendered server-side into the Jinja2 template so no
69 client-side JS is needed to populate the dropdown. Alpine.js handles only
70 the visibility radio toggle; HTMX handles the live name availability check.
71 Renders without auth so the page is always reachable at a stable URL.
72 """
73 ctx: dict[str, object] = {
74 "title": "Create a new repository",
75 "licenses": _LICENSES,
76 "current_page": "new_repo",
77 }
78 return _templates.TemplateResponse(request, "musehub/pages/new_repo.html", ctx)
79
80
81 @router.post(
82 "/new",
83 summary="Create a new repository via the wizard",
84 operation_id="createRepoWizard",
85 status_code=http_status.HTTP_201_CREATED,
86 )
87 async def create_repo_wizard(
88 body: CreateRepoRequest,
89 db: AsyncSession = Depends(get_db),
90 claims: TokenClaims = Depends(require_valid_token),
91 ) -> JSONResponse:
92 """Create a new repo from the wizard form submission and return the redirect URL.
93
94 Why POST + JSON instead of a browser form POST: all MuseHub UI pages use
95 JavaScript to call authenticated API endpoints. The JWT lives in
96 localStorage, not in a cookie or form field, so keeping the submission
97 client-side avoids requiring a hidden token field or session cookie.
98
99 On success, returns 201 + ``{"redirect": "/{owner}/{slug}?welcome=1"}``
100 so the client-side JS can navigate to the new repo. On slug collision,
101 returns 409 so the wizard can surface a friendly error without a full reload.
102 """
103 owner_user_id: str = claims.get("sub") or ""
104 try:
105 repo = await musehub_repository.create_repo(
106 db,
107 name=body.name,
108 owner=body.owner,
109 visibility=body.visibility,
110 owner_user_id=owner_user_id,
111 description=body.description,
112 tags=body.tags,
113 key_signature=body.key_signature,
114 tempo_bpm=body.tempo_bpm,
115 license=body.license,
116 topics=body.topics,
117 initialize=body.initialize,
118 default_branch=body.default_branch,
119 template_repo_id=body.template_repo_id,
120 )
121 await db.commit()
122 except IntegrityError:
123 await db.rollback()
124 raise HTTPException(
125 status_code=http_status.HTTP_409_CONFLICT,
126 detail="A repository with this owner and name already exists.",
127 )
128 redirect_url = f"/{repo.owner}/{repo.slug}?welcome=1"
129 logger.info(
130 "✅ New repo created via wizard: %s/%s (id=%s)",
131 repo.owner,
132 repo.slug,
133 repo.repo_id,
134 )
135 return JSONResponse(
136 {
137 "redirect": redirect_url,
138 "repoId": repo.repo_id,
139 "slug": repo.slug,
140 "owner": repo.owner,
141 },
142 status_code=http_status.HTTP_201_CREATED,
143 )
144
145
146 @router.get(
147 "/new/check",
148 summary="Check repo name availability",
149 operation_id="checkRepoNameAvailable",
150 )
151 async def check_repo_name(
152 request: Request,
153 owner: str = Query(..., description="Owner username to check under"),
154 slug: str = Query(..., description="URL-safe slug derived from the repo name"),
155 db: AsyncSession = Depends(get_db),
156 ) -> Response:
157 """Return whether a given owner+slug pair is available.
158
159 When called by HTMX (``HX-Request: true``), returns a bare HTML
160 ``<span>`` fragment that HTMX swaps into the ``#name-check`` target
161 element — no JavaScript needed for the availability indicator.
162
163 When called without the HTMX header (scripts, agents, legacy JS),
164 returns JSON: ``{"available": true}`` or ``{"available": false}``.
165
166 No auth required — slug availability is not secret information.
167 """
168 existing = await musehub_repository.get_repo_by_owner_slug(db, owner, slug)
169 available = existing is None
170 if is_htmx(request):
171 return _templates.TemplateResponse(
172 request,
173 "musehub/fragments/slug_check.html",
174 {"available": available},
175 )
176 return JSONResponse({"available": available})