ui_new_repo.py
python
| 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}) |