Remove manual course creation; import-only with per-tee stroke indices
Courses can now only be added via Golf Course API import, which provides the full data model: per-tee slope/rating/gender/SI (stroke_indices JSON) and front/back split ratings. Manual creation required admins to enter SI by hand and didn't match the API model. Changes: - Remove GET/POST /admin/courses/new routes and new_course.html - Remove _parse_hole_form helper (no longer needed) - Edit course: par-only editing; stroke_index preserved from import, not exposed or overwritten in the admin form - courses.html: replace "+ New course" with "+ Import course" button - course_search.html: remove "+ Manual" shortcut - edit_course.html: remove SI column from hole grid Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+14
-91
@@ -51,36 +51,6 @@ async def _get_course_detail(cid: str, db: aiosqlite.Connection):
|
|||||||
return dict(course), holes, tees
|
return dict(course), holes, tees
|
||||||
|
|
||||||
|
|
||||||
def _parse_hole_form(form, hole_count: int) -> tuple[dict, dict, dict]:
|
|
||||||
"""Parse par/SI fields from a form. Returns (pars, stroke_indices, errors)."""
|
|
||||||
pars: dict[int, int] = {}
|
|
||||||
stroke_indices: dict[int, int | None] = {}
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
for i in range(1, hole_count + 1):
|
|
||||||
raw = form.get(f"par_{i}", "4")
|
|
||||||
try:
|
|
||||||
p = int(raw)
|
|
||||||
if p < 3 or p > 5:
|
|
||||||
errors[f"par_{i}"] = f"Hole {i}: par must be 3–5."
|
|
||||||
else:
|
|
||||||
pars[i] = p
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
errors[f"par_{i}"] = f"Hole {i}: invalid par."
|
|
||||||
raw_si = form.get(f"si_{i}", "")
|
|
||||||
if raw_si:
|
|
||||||
try:
|
|
||||||
si = int(raw_si)
|
|
||||||
if si < 1 or si > hole_count:
|
|
||||||
errors[f"si_{i}"] = f"Hole {i}: SI must be 1–{hole_count}."
|
|
||||||
else:
|
|
||||||
stroke_indices[i] = si
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
errors[f"si_{i}"] = f"Hole {i}: invalid SI."
|
|
||||||
else:
|
|
||||||
stroke_indices[i] = None
|
|
||||||
return pars, stroke_indices, errors
|
|
||||||
|
|
||||||
|
|
||||||
# ── List ──────────────────────────────────────────────────────────────────────
|
# ── List ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/admin/courses", response_class=HTMLResponse)
|
@router.get("/admin/courses", response_class=HTMLResponse)
|
||||||
@@ -96,61 +66,6 @@ async def courses_page(request: Request, user: dict = Depends(require_admin),
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Create ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@router.get("/admin/courses/new", response_class=HTMLResponse)
|
|
||||||
async def new_course_page(request: Request, user: dict = Depends(require_admin)):
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/new_course.html", {"request": request, "user": user}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/courses/new")
|
|
||||||
async def create_course(request: Request,
|
|
||||||
name: str = Form(...), holes: int = Form(...),
|
|
||||||
location: str = Form(""),
|
|
||||||
user: dict = Depends(require_admin),
|
|
||||||
db: aiosqlite.Connection = Depends(get_db)):
|
|
||||||
form = await request.form()
|
|
||||||
name = name.strip()
|
|
||||||
location = location.strip()
|
|
||||||
errors = {}
|
|
||||||
if not name:
|
|
||||||
errors["name"] = "Name is required."
|
|
||||||
if holes not in (9, 18):
|
|
||||||
errors["holes"] = "Holes must be 9 or 18."
|
|
||||||
|
|
||||||
hole_count = holes if holes in (9, 18) else 18
|
|
||||||
pars, stroke_indices, hole_errors = _parse_hole_form(form, hole_count)
|
|
||||||
errors.update(hole_errors)
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
return templates.TemplateResponse(
|
|
||||||
"admin/new_course.html",
|
|
||||||
{"request": request, "user": user, "errors": errors,
|
|
||||||
"form": {"name": name, "holes": holes, "location": location,
|
|
||||||
"pars": pars, "stroke_indices": stroke_indices}},
|
|
||||||
status_code=422,
|
|
||||||
)
|
|
||||||
|
|
||||||
cid = secrets.token_urlsafe(10)
|
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
await db.execute(
|
|
||||||
"INSERT INTO courses (id, name, holes, location, created_at) VALUES (?,?,?,?,?)",
|
|
||||||
(cid, name, holes, location or None, now),
|
|
||||||
)
|
|
||||||
for hole_num, par in pars.items():
|
|
||||||
await db.execute(
|
|
||||||
"INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)",
|
|
||||||
(secrets.token_urlsafe(10), cid, hole_num, par, stroke_indices.get(hole_num)),
|
|
||||||
)
|
|
||||||
await db.commit()
|
|
||||||
return RedirectResponse(
|
|
||||||
f"/admin/courses/{cid}/edit?success=Course+created.+Add+tees+below.",
|
|
||||||
status_code=302,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ── API Search & Import ───────────────────────────────────────────────────────
|
# ── API Search & Import ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/admin/courses/search", response_class=HTMLResponse)
|
@router.get("/admin/courses/search", response_class=HTMLResponse)
|
||||||
@@ -290,8 +205,17 @@ async def edit_course_submit(cid: str, request: Request,
|
|||||||
errors["holes"] = "Holes must be 9 or 18."
|
errors["holes"] = "Holes must be 9 or 18."
|
||||||
|
|
||||||
hole_count = holes if holes in (9, 18) else 18
|
hole_count = holes if holes in (9, 18) else 18
|
||||||
pars, stroke_indices, hole_errors = _parse_hole_form(form, hole_count)
|
pars: dict[int, int] = {}
|
||||||
errors.update(hole_errors)
|
for i in range(1, hole_count + 1):
|
||||||
|
raw = form.get(f"par_{i}", "4")
|
||||||
|
try:
|
||||||
|
p = int(raw)
|
||||||
|
if p < 3 or p > 5:
|
||||||
|
errors[f"par_{i}"] = f"Hole {i}: par must be 3–5."
|
||||||
|
else:
|
||||||
|
pars[i] = p
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
errors[f"par_{i}"] = f"Hole {i}: invalid par."
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
@@ -307,10 +231,9 @@ async def edit_course_submit(cid: str, request: Request,
|
|||||||
)
|
)
|
||||||
for hole_num, par in pars.items():
|
for hole_num, par in pars.items():
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO course_holes (id, course_id, hole_number, par, stroke_index) VALUES (?,?,?,?,?)
|
"""INSERT INTO course_holes (id, course_id, hole_number, par) VALUES (?,?,?,?)
|
||||||
ON CONFLICT(course_id, hole_number) DO UPDATE SET
|
ON CONFLICT(course_id, hole_number) DO UPDATE SET par = excluded.par""",
|
||||||
par=excluded.par, stroke_index=excluded.stroke_index""",
|
(secrets.token_urlsafe(10), cid, hole_num, par),
|
||||||
(secrets.token_urlsafe(10), cid, hole_num, par, stroke_indices.get(hole_num)),
|
|
||||||
)
|
)
|
||||||
# Remove holes beyond new count if holes was reduced
|
# Remove holes beyond new count if holes was reduced
|
||||||
await db.execute(
|
await db.execute(
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
|
|
||||||
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:1rem;">
|
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:1rem;">
|
||||||
<a href="/admin/courses" class="btn" style="width:auto; padding:0.5rem 1rem; background:var(--surface2); color:var(--text);">← Courses</a>
|
<a href="/admin/courses" class="btn" style="width:auto; padding:0.5rem 1rem; background:var(--surface2); color:var(--text);">← Courses</a>
|
||||||
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ Manual</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not api_configured %}
|
{% if not api_configured %}
|
||||||
|
|||||||
@@ -18,8 +18,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:1rem;">
|
<div style="display:flex; gap:0.5rem; justify-content:flex-end; margin-bottom:1rem;">
|
||||||
<a href="/admin/courses/search" class="btn" style="width:auto; padding:0.5rem 1rem; background:var(--surface2); color:var(--text);">Search & Import</a>
|
<a href="/admin/courses/search" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ Import course</a>
|
||||||
<a href="/admin/courses/new" class="btn btn-primary" style="width:auto; padding:0.5rem 1rem;">+ New course</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if courses %}
|
{% if courses %}
|
||||||
@@ -50,7 +49,7 @@
|
|||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-icon">⛳</div>
|
<div class="empty-icon">⛳</div>
|
||||||
<p>No courses yet.</p>
|
<p>No courses yet.</p>
|
||||||
<a href="/admin/courses/new" class="btn btn-primary" style="margin-top:1rem; max-width:200px;">Add a course</a>
|
<a href="/admin/courses/search" class="btn btn-primary" style="margin-top:1rem; max-width:200px;">Import a course</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -58,12 +58,6 @@
|
|||||||
<option value="4" {% if h.par == 4 %}selected{% endif %}>4</option>
|
<option value="4" {% if h.par == 4 %}selected{% endif %}>4</option>
|
||||||
<option value="5" {% if h.par == 5 %}selected{% endif %}>5</option>
|
<option value="5" {% if h.par == 5 %}selected{% endif %}>5</option>
|
||||||
</select>
|
</select>
|
||||||
<select name="si_{{ h.hole_number }}" class="hole-par-select">
|
|
||||||
<option value="">—</option>
|
|
||||||
{% for s in range(1, course.holes + 1) %}
|
|
||||||
<option value="{{ s }}" {% if h.stroke_index == s %}selected{% endif %}>{{ s }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}New Course – Skins & Pins{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<a href="/admin/courses" style="color:var(--text-muted); font-size:0.9rem;">← Courses</a>
|
|
||||||
<h1 style="margin-top:0.25rem;">New course</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" action="/admin/courses/new"
|
|
||||||
x-data="{
|
|
||||||
holes: {{ (form.holes if form else 18)|int }},
|
|
||||||
initPars: {{ form.pars | tojson if (form and form.pars) else '{}' }},
|
|
||||||
initSIs: {{ form.stroke_indices | tojson if (form and form.stroke_indices) else '{}' }},
|
|
||||||
pars: {},
|
|
||||||
coursePar() { return Object.values(this.pars).reduce((a, b) => a + parseInt(b || 4), 0); },
|
|
||||||
initParState() { this.pars = {}; for (let i = 1; i <= this.holes; i++) this.pars[i] = this.initPars[String(i)] || 4; }
|
|
||||||
}"
|
|
||||||
x-init="initParState(); $watch('holes', () => initParState())">
|
|
||||||
<div class="card">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="name">Course name</label>
|
|
||||||
{% if errors and errors.name %}<div class="alert alert-error">{{ errors.name }}</div>{% endif %}
|
|
||||||
<input type="text" id="name" name="name"
|
|
||||||
value="{{ form.name if form else '' }}" placeholder="e.g. Augusta National" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="location">Location <span style="color:var(--text-muted); font-size:0.8rem;">(optional)</span></label>
|
|
||||||
<input type="text" id="location" name="location"
|
|
||||||
value="{{ form.location if form else '' }}" placeholder="e.g. Augusta, Georgia">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Holes</label>
|
|
||||||
{% if errors and errors.holes %}<div class="alert alert-error">{{ errors.holes }}</div>{% endif %}
|
|
||||||
<div style="display:flex; gap:0.75rem;">
|
|
||||||
<label class="radio-card">
|
|
||||||
<input type="radio" name="holes" value="9" x-model.number="holes">
|
|
||||||
<span>9 holes</span>
|
|
||||||
</label>
|
|
||||||
<label class="radio-card">
|
|
||||||
<input type="radio" name="holes" value="18" x-model.number="holes">
|
|
||||||
<span>18 holes</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2 style="font-size:0.95rem; color:var(--text-muted); margin-bottom:0.75rem;">
|
|
||||||
Hole details
|
|
||||||
<span style="font-weight:400; font-size:0.85rem;">
|
|
||||||
— Course par: <strong x-text="coursePar()"></strong>
|
|
||||||
</span>
|
|
||||||
</h2>
|
|
||||||
<div class="hole-pars-grid">
|
|
||||||
<template x-for="n in Array.from({length: holes}, (_, i) => i + 1)" :key="n">
|
|
||||||
<div class="hole-par-cell">
|
|
||||||
<div class="hole-par-label" x-text="n"></div>
|
|
||||||
<select :name="'par_' + n" class="hole-par-select" x-model="pars[n]">
|
|
||||||
<option value="3">3</option>
|
|
||||||
<option value="4">4</option>
|
|
||||||
<option value="5">5</option>
|
|
||||||
</select>
|
|
||||||
<select :name="'si_' + n" class="hole-par-select">
|
|
||||||
<option value="">—</option>
|
|
||||||
<template x-for="s in Array.from({length: holes}, (_, i) => i + 1)" :key="s">
|
|
||||||
<option :value="s" :selected="(initSIs[String(n)] || null) == s" x-text="s"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Create course</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
Reference in New Issue
Block a user