Batman
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""bike-admin package."""
|
||||
@@ -0,0 +1,54 @@
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# Core
|
||||
data_dir: Path = Field(default=BASE_DIR / "data")
|
||||
db_path: Path | None = None # defaults to <data_dir>/bike_admin.db when None
|
||||
secret_key: str = Field(default="change-me", min_length=8) # CSRF/session HMAC
|
||||
username: str = Field(default="admin", min_length=1)
|
||||
password: str | None = None # REQUIRED on first run (see ensure_user)
|
||||
|
||||
# Hosting posture
|
||||
is_production: bool = False # True => Secure cookies + stricter headers
|
||||
bind_host: str = "127.0.0.1"
|
||||
bind_port: int = 8000
|
||||
trust_proxy: bool = True # honor X-Forwarded-For/Proto behind a reverse proxy
|
||||
|
||||
# Sessions
|
||||
session_ttl_seconds: int = 7 * 24 * 3600
|
||||
|
||||
# Login throttling
|
||||
login_max_attempts: int = 8
|
||||
login_window_seconds: int = 300
|
||||
|
||||
# Service hint: flag a bike as "book soon" when it is this close to due.
|
||||
service_due_soon_miles: int = 200
|
||||
|
||||
# Event types (preset)
|
||||
event_types: list[str] = [
|
||||
"service",
|
||||
"fuel",
|
||||
"tyre",
|
||||
"chain",
|
||||
"brake",
|
||||
"repair",
|
||||
"other",
|
||||
]
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="BIKE_ADMIN_", env_file=".env", extra="ignore")
|
||||
|
||||
@property
|
||||
def resolved_db_path(self) -> Path:
|
||||
return self.db_path or (self.data_dir / "bike_admin.db")
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
ip TEXT NOT NULL,
|
||||
ts TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bikes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
service_interval_miles INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bike_id INTEGER NOT NULL REFERENCES bikes(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
event_date TEXT NOT NULL,
|
||||
mileage INTEGER,
|
||||
cost INTEGER,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_bike_date ON events(bike_id, event_date DESC);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bike:
|
||||
id: int
|
||||
name: str
|
||||
service_interval_miles: int | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BikeEvent:
|
||||
id: int
|
||||
bike_id: int
|
||||
type: str
|
||||
event_date: date
|
||||
mileage: int | None
|
||||
cost: int | None
|
||||
notes: str | None
|
||||
|
||||
|
||||
def connect(db_path: Path) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def init_schema(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def ensure_user(db_path: Path, username: str, password: str, password_hash: str) -> None:
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
cur = conn.execute("SELECT id FROM users WHERE username = ? LIMIT 1", (username,))
|
||||
if cur.fetchone() is None:
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash) VALUES (?, ?)",
|
||||
(username, password_hash),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# --- bikes ---
|
||||
def list_bikes(conn: sqlite3.Connection) -> list[Bike]:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, service_interval_miles FROM bikes ORDER BY name"
|
||||
).fetchall()
|
||||
return [Bike(r["id"], r["name"], r["service_interval_miles"]) for r in rows]
|
||||
|
||||
|
||||
def create_bike(conn: sqlite3.Connection, name: str, service_interval_miles: int | None) -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO bikes (name, service_interval_miles) VALUES (?, ?)",
|
||||
(name, service_interval_miles),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_bike(conn: sqlite3.Connection, bike_id: int, name: str, interval: int | None) -> None:
|
||||
conn.execute(
|
||||
"UPDATE bikes SET name = ?, service_interval_miles = ? WHERE id = ?",
|
||||
(name, interval, bike_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_bike(conn: sqlite3.Connection, bike_id: int) -> None:
|
||||
conn.execute("DELETE FROM bikes WHERE id = ?", (bike_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_bike(conn: sqlite3.Connection, bike_id: int) -> Bike | None:
|
||||
r = conn.execute(
|
||||
"SELECT id, name, service_interval_miles FROM bikes WHERE id = ?", (bike_id,)
|
||||
).fetchone()
|
||||
return Bike(r["id"], r["name"], r["service_interval_miles"]) if r else None
|
||||
|
||||
|
||||
# --- events ---
|
||||
def create_event(
|
||||
conn: sqlite3.Connection,
|
||||
bike_id: int,
|
||||
type: str,
|
||||
event_date: date,
|
||||
mileage: int | None,
|
||||
cost: int | None,
|
||||
notes: str | None,
|
||||
) -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO events (bike_id, type, event_date, mileage, cost, notes) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(bike_id, type, event_date.isoformat(), mileage, cost, notes),
|
||||
)
|
||||
conn.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_event(
|
||||
conn: sqlite3.Connection,
|
||||
event_id: int,
|
||||
type: str,
|
||||
event_date: date,
|
||||
mileage: int | None,
|
||||
cost: int | None,
|
||||
notes: str | None,
|
||||
) -> None:
|
||||
conn.execute(
|
||||
"UPDATE events SET type = ?, event_date = ?, mileage = ?, cost = ?, notes = ? WHERE id = ?",
|
||||
(type, event_date.isoformat(), mileage, cost, notes, event_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_event(conn: sqlite3.Connection, event_id: int) -> None:
|
||||
conn.execute("DELETE FROM events WHERE id = ?", (event_id,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _row_to_event(r: sqlite3.Row) -> BikeEvent:
|
||||
return BikeEvent(
|
||||
id=r["id"],
|
||||
bike_id=r["bike_id"],
|
||||
type=r["type"],
|
||||
event_date=date.fromisoformat(r["event_date"]),
|
||||
mileage=r["mileage"],
|
||||
cost=r["cost"],
|
||||
notes=r["notes"],
|
||||
)
|
||||
|
||||
|
||||
def list_events(conn: sqlite3.Connection, bike_id: int) -> list[BikeEvent]:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM events WHERE bike_id = ? ORDER BY event_date DESC, id DESC", (bike_id,)
|
||||
).fetchall()
|
||||
return [_row_to_event(r) for r in rows]
|
||||
|
||||
|
||||
def latest_event_with_mileage(conn: sqlite3.Connection, bike_id: int) -> BikeEvent | None:
|
||||
r = conn.execute(
|
||||
"SELECT * FROM events WHERE bike_id = ? AND mileage IS NOT NULL "
|
||||
"ORDER BY event_date DESC, id DESC LIMIT 1",
|
||||
(bike_id,),
|
||||
).fetchone()
|
||||
return _row_to_event(r) if r else None
|
||||
|
||||
|
||||
def latest_event_of_type(conn: sqlite3.Connection, bike_id: int, type: str) -> BikeEvent | None:
|
||||
r = conn.execute(
|
||||
"SELECT * FROM events WHERE bike_id = ? AND type = ? "
|
||||
"ORDER BY event_date DESC, id DESC LIMIT 1",
|
||||
(bike_id, type),
|
||||
).fetchone()
|
||||
return _row_to_event(r) if r else None
|
||||
|
||||
|
||||
def sum_cost(conn: sqlite3.Connection, bike_id: int) -> int:
|
||||
r = conn.execute(
|
||||
"SELECT COALESCE(SUM(cost), 0) AS s FROM events WHERE bike_id = ?", (bike_id,)
|
||||
).fetchone()
|
||||
return int(r["s"])
|
||||
|
||||
|
||||
def count_events(conn: sqlite3.Connection, bike_id: int) -> int:
|
||||
r = conn.execute("SELECT COUNT(*) AS n FROM events WHERE bike_id = ?", (bike_id,)).fetchone()
|
||||
return int(r["n"])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
db_path = get_settings().resolved_db_path
|
||||
conn = connect(db_path)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
+508
@@ -0,0 +1,508 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, Response, status
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, field_validator
|
||||
from starlette.responses import RedirectResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from . import db as d
|
||||
from . import services as svc
|
||||
from .config import Settings, get_settings
|
||||
from .security import (
|
||||
SESSION_COOKIE,
|
||||
check_csrf,
|
||||
client_ip,
|
||||
create_session,
|
||||
csrf_token,
|
||||
delete_session,
|
||||
get_user_id_for_session,
|
||||
hash_password,
|
||||
is_throttled,
|
||||
record_login_attempt,
|
||||
verify_password,
|
||||
)
|
||||
|
||||
APP_DIR = Path(__file__).resolve().parent
|
||||
templates = Jinja2Templates(directory=str(APP_DIR / "templates"))
|
||||
|
||||
|
||||
# --- shared helpers --------------------------------------------
|
||||
def _s(request: Request) -> Settings:
|
||||
return get_settings()
|
||||
|
||||
|
||||
def _redirect(location: str, status_code: int = status.HTTP_303_SEE_OTHER):
|
||||
return RedirectResponse(location, status_code=status_code)
|
||||
|
||||
|
||||
def _template_env(request: Request) -> dict:
|
||||
s = get_settings()
|
||||
base: dict = {
|
||||
"request": request,
|
||||
"event_types": s.event_types,
|
||||
"today": date.today(),
|
||||
"settings": s,
|
||||
}
|
||||
return base
|
||||
|
||||
|
||||
def _csrf(request: Request) -> str:
|
||||
s = get_settings()
|
||||
return csrf_token(s.secret_key, request.cookies.get(SESSION_COOKIE))
|
||||
|
||||
|
||||
def _parse_int(value: str | None) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip().replace(",", "")
|
||||
if not value:
|
||||
return None
|
||||
return int(value)
|
||||
|
||||
|
||||
def _errors_from(exc: Exception) -> dict[str, str]:
|
||||
if hasattr(exc, "errors"):
|
||||
return {e["loc"][-1]: e["msg"] for e in exc.errors()}
|
||||
return {"_": str(exc)}
|
||||
|
||||
|
||||
def current_user_id(request: Request) -> int | None:
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
with d.get_conn() as conn:
|
||||
return get_user_id_for_session(conn, token)
|
||||
|
||||
|
||||
def require_user(request: Request) -> int:
|
||||
uid = current_user_id(request)
|
||||
if uid is None:
|
||||
raise FastAPIUnauthorized()
|
||||
return uid
|
||||
|
||||
|
||||
class FastAPIUnauthorized(Exception):
|
||||
pass
|
||||
|
||||
|
||||
# --- form models -------------------------------------------------
|
||||
class BikeIn(BaseModel):
|
||||
name: str
|
||||
service_interval_miles: int | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def _name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v or len(v) > 100:
|
||||
raise ValueError("Name must be 1-100 characters.")
|
||||
return v
|
||||
|
||||
@field_validator("service_interval_miles")
|
||||
@classmethod
|
||||
def _interval(cls, v: int | None) -> int | None:
|
||||
if v is not None and not (1 <= v <= 10_000_000):
|
||||
raise ValueError("Interval must be a positive number of miles, or blank.")
|
||||
return v
|
||||
|
||||
|
||||
class EventIn(BaseModel):
|
||||
type: str
|
||||
event_date: date
|
||||
mileage: int | None = None
|
||||
cost: int | None = None
|
||||
notes: str | None = None
|
||||
|
||||
@field_validator("type")
|
||||
@classmethod
|
||||
def _type(cls, v: str) -> str:
|
||||
s = get_settings()
|
||||
if v not in s.event_types:
|
||||
raise ValueError(f"Type must be one of: {', '.join(s.event_types)}.")
|
||||
return v
|
||||
|
||||
@field_validator("event_date")
|
||||
@classmethod
|
||||
def _date(cls, v: date) -> date:
|
||||
if v > date.today():
|
||||
raise ValueError("Date cannot be in the future.")
|
||||
if v < date(1950, 1, 1):
|
||||
raise ValueError("Date looks too old.")
|
||||
return v
|
||||
|
||||
@field_validator("mileage")
|
||||
@classmethod
|
||||
def _mileage(cls, v: int | None) -> int | None:
|
||||
if v is not None and v < 0:
|
||||
raise ValueError("Mileage cannot be negative.")
|
||||
return v
|
||||
|
||||
@field_validator("cost")
|
||||
@classmethod
|
||||
def _cost(cls, v: int | None) -> int | None:
|
||||
if v is not None and v < 0:
|
||||
raise ValueError("Cost cannot be negative.")
|
||||
return v
|
||||
|
||||
|
||||
def _form_str(form, key: str) -> str:
|
||||
return str(form.get(key) or "").strip()
|
||||
|
||||
|
||||
def _form_int(form, key: str) -> int | None:
|
||||
return _parse_int(_form_str(form, key))
|
||||
|
||||
|
||||
def _form_date(form, key: str) -> date:
|
||||
return date.fromisoformat(_form_str(form, key) or date.today().isoformat())
|
||||
|
||||
|
||||
def _form_notes(form) -> str | None:
|
||||
v = _form_str(form, "notes")
|
||||
return v or None
|
||||
|
||||
|
||||
def _bike_in(form) -> BikeIn:
|
||||
return BikeIn(
|
||||
name=_form_str(form, "name"),
|
||||
service_interval_miles=_form_int(form, "service_interval_miles"),
|
||||
)
|
||||
|
||||
|
||||
def _event_in(form) -> EventIn:
|
||||
return EventIn(
|
||||
type=_form_str(form, "type"),
|
||||
event_date=_form_date(form, "event_date"),
|
||||
mileage=_form_int(form, "mileage"),
|
||||
cost=_form_int(form, "cost"),
|
||||
notes=_form_notes(form),
|
||||
)
|
||||
|
||||
|
||||
# --- request context helpers (form data -> dict for re-render) ---
|
||||
def _form_dict(form) -> dict:
|
||||
"""Best-effort form dict for pre-fill on validation errors."""
|
||||
out: dict = {}
|
||||
try:
|
||||
for key, value in form.items():
|
||||
out[key] = value if isinstance(value, str) else str(value)
|
||||
except AttributeError:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
# --- app factory -------------------------------------------------
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
s = get_settings()
|
||||
s.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
d.init_schema(s.resolved_db_path)
|
||||
if s.password is None:
|
||||
raise RuntimeError(
|
||||
"BIKE_ADMIN_PASSWORD is not set. Refusing to start to avoid an "
|
||||
"insecure default. Set BIKE_ADMIN_USERNAME and BIKE_ADMIN_PASSWORD "
|
||||
"in the environment (or a .env file) and restart."
|
||||
)
|
||||
d.ensure_user(
|
||||
s.resolved_db_path,
|
||||
s.username,
|
||||
s.password,
|
||||
hash_password(s.password),
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="bike-admin", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.exception_handler(FastAPIUnauthorized)
|
||||
async def _unauthorized(request: Request, exc: FastAPIUnauthorized):
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(APP_DIR / "static")), name="static")
|
||||
|
||||
templates.env.filters["money"] = lambda v: f"{v:,}" if v is not None else ""
|
||||
|
||||
templates.env.globals["event_types"] = get_settings().event_types
|
||||
templates.env.globals["status_class"] = svc.status_class
|
||||
templates.env.globals["status_text"] = svc.status_text
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
h = response.headers
|
||||
h.setdefault("X-Content-Type-Options", "nosniff")
|
||||
h.setdefault("X-Frame-Options", "DENY")
|
||||
h.setdefault("Referrer-Policy", "same-origin")
|
||||
h.setdefault("Permissions-Policy", "geolocation=(), camera=(), microphone=()")
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"img-src 'self' data:; "
|
||||
"style-src 'self'; "
|
||||
"font-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"form-action 'self'; "
|
||||
"base-uri 'self'; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
h.setdefault("Content-Security-Policy", csp)
|
||||
return response
|
||||
|
||||
|
||||
def _cookie_opts(request: Request, s: Settings) -> dict:
|
||||
secure = bool(s.is_production) or (request.url is not None and request.url.scheme == "https")
|
||||
return {
|
||||
"path": "/",
|
||||
"samesite": "lax",
|
||||
"httponly": True,
|
||||
"secure": secure,
|
||||
"max_age": s.session_ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
def _csrf_ok(request: Request, form) -> bool:
|
||||
s = get_settings()
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
return check_csrf(s.secret_key, token, _form_str(form, "csrf"))
|
||||
|
||||
|
||||
def _login_render(request: Request, error: str | None) -> Response:
|
||||
context = _template_env(request)
|
||||
return templates.TemplateResponse(request, "login.html", {**context, "error": error})
|
||||
|
||||
|
||||
def _bike_render(
|
||||
request: Request, kind: str, bike, errors: dict, old: dict, action: str | None = None
|
||||
) -> Response:
|
||||
is_edit = kind == "edit"
|
||||
if action is None:
|
||||
action = request.url.path
|
||||
context = _template_env(request)
|
||||
context.update(
|
||||
bike=bike,
|
||||
errors=errors,
|
||||
old=old,
|
||||
csrf=_csrf(request),
|
||||
action=action,
|
||||
is_edit=is_edit,
|
||||
)
|
||||
return templates.TemplateResponse(request, f"bikes/{kind}.html", context)
|
||||
|
||||
|
||||
# --- auth routes -------------------------------------------------
|
||||
@app.get("/login", include_in_schema=False)
|
||||
async def login_get(request: Request):
|
||||
return _login_render(request, None)
|
||||
|
||||
|
||||
@app.post("/login", include_in_schema=False)
|
||||
async def login_post(request: Request):
|
||||
s = get_settings()
|
||||
form = await request.form()
|
||||
username = _form_str(form, "username")
|
||||
password = _form_str(form, "password")
|
||||
ip = client_ip(request, s.trust_proxy)
|
||||
|
||||
with d.get_conn() as conn:
|
||||
if is_throttled(conn, ip, s):
|
||||
return _login_render(request, "Too many login attempts. Try again in a few minutes.")
|
||||
cur = conn.execute(
|
||||
"SELECT id, password_hash FROM users WHERE username = ?", (username,)
|
||||
).fetchone()
|
||||
ok = cur is not None and verify_password(password, cur["password_hash"])
|
||||
if not ok:
|
||||
record_login_attempt(conn, ip)
|
||||
return _login_render(request, "Invalid username or password.")
|
||||
token = create_session(conn, cur["id"], s)
|
||||
|
||||
nxt = _form_str(form, "next") or "/"
|
||||
resp = RedirectResponse(nxt, status_code=303)
|
||||
resp.set_cookie(SESSION_COOKIE, token, **_cookie_opts(request, s))
|
||||
return resp
|
||||
|
||||
|
||||
@app.get("/logout", include_in_schema=False)
|
||||
async def logout_get(request: Request):
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
with d.get_conn() as conn:
|
||||
delete_session(conn, token)
|
||||
resp = _login_render(request, None)
|
||||
resp.delete_cookie(SESSION_COOKIE, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
# --- dashboard ---------------------------------------------------
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def dashboard(request: Request):
|
||||
require_user(request)
|
||||
with d.get_conn() as conn:
|
||||
action = svc.action_needed(conn)
|
||||
soon = svc.due_soon(conn)
|
||||
bikes = svc.all_summaries(conn)
|
||||
context = _template_env(request)
|
||||
context.update(
|
||||
action=action,
|
||||
due_soon=soon,
|
||||
bikes=bikes,
|
||||
csrf=_csrf(request),
|
||||
)
|
||||
return templates.TemplateResponse(request, "dashboard.html", context)
|
||||
|
||||
|
||||
# --- bikes -------------------------------------------------------
|
||||
@app.get("/bikes/new", include_in_schema=False)
|
||||
async def bike_new_get(request: Request):
|
||||
require_user(request)
|
||||
return _bike_render(request, "new", None, {}, {})
|
||||
|
||||
|
||||
@app.post("/bikes/new", include_in_schema=False)
|
||||
async def bike_new_post(request: Request):
|
||||
require_user(request)
|
||||
form = await request.form()
|
||||
if not _csrf_ok(request, form):
|
||||
return _bike_render(request, "new", None, {"_": "CSRF check failed."}, _form_dict(form))
|
||||
|
||||
try:
|
||||
bike_in = _bike_in(form)
|
||||
except Exception as exc:
|
||||
return _bike_render(request, "new", None, _errors_from(exc), _form_dict(form))
|
||||
|
||||
with d.get_conn() as conn:
|
||||
bike_id = d.create_bike(conn, bike_in.name, bike_in.service_interval_miles)
|
||||
return _redirect(f"/bikes/{bike_id}")
|
||||
|
||||
|
||||
@app.get("/bikes/{bike_id}", include_in_schema=False)
|
||||
async def bike_detail_get(request: Request, bike_id: int):
|
||||
require_user(request)
|
||||
s = get_settings()
|
||||
with d.get_conn() as conn:
|
||||
bike = d.get_bike(conn, bike_id)
|
||||
if bike is None:
|
||||
return _redirect("/")
|
||||
summary = svc.summarize_bike(conn, bike)
|
||||
events = d.list_events(conn, bike.id)
|
||||
context = _template_env(request)
|
||||
context.update(
|
||||
bike=bike,
|
||||
summary=summary,
|
||||
events=events,
|
||||
csrf=_csrf(request),
|
||||
event_types=s.event_types,
|
||||
)
|
||||
return templates.TemplateResponse(request, "bikes/detail.html", context)
|
||||
|
||||
|
||||
@app.get("/bikes/{bike_id}/edit", include_in_schema=False)
|
||||
async def bike_edit_get(request: Request, bike_id: int):
|
||||
require_user(request)
|
||||
with d.get_conn() as conn:
|
||||
bike = d.get_bike(conn, bike_id)
|
||||
if bike is None:
|
||||
return _redirect("/")
|
||||
return _bike_render(request, "edit", bike, {}, {})
|
||||
|
||||
|
||||
@app.post("/bikes/{bike_id}/edit", include_in_schema=False)
|
||||
async def bike_edit_post(request: Request, bike_id: int):
|
||||
require_user(request)
|
||||
form = await request.form()
|
||||
with d.get_conn() as conn:
|
||||
bike = d.get_bike(conn, bike_id)
|
||||
if bike is None:
|
||||
return _redirect("/")
|
||||
if not _csrf_ok(request, form):
|
||||
errs = {"_": "CSRF check failed."}
|
||||
return _bike_render(request, "edit", bike, errs, _form_dict(form))
|
||||
try:
|
||||
bike_in = _bike_in(form)
|
||||
except Exception as exc:
|
||||
return _bike_render(request, "edit", bike, _errors_from(exc), _form_dict(form))
|
||||
d.update_bike(conn, bike_id, bike_in.name, bike_in.service_interval_miles)
|
||||
return _redirect(f"/bikes/{bike_id}")
|
||||
|
||||
|
||||
@app.post("/bikes/{bike_id}/delete", include_in_schema=False)
|
||||
async def bike_delete(request: Request, bike_id: int):
|
||||
s = get_settings()
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
form = await request.form()
|
||||
if not check_csrf(s.secret_key, token, _form_str(form, "csrf")):
|
||||
return _redirect(f"/bikes/{bike_id}")
|
||||
with d.get_conn() as conn:
|
||||
d.delete_bike(conn, bike_id)
|
||||
return _redirect("/", status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
# --- events ------------------------------------------------------
|
||||
@app.post("/bikes/{bike_id}/events", include_in_schema=False)
|
||||
async def event_create(request: Request, bike_id: int):
|
||||
"""Create an event; return the service card + fresh list (HTMX swap)."""
|
||||
require_user(request)
|
||||
s = get_settings()
|
||||
form = await request.form()
|
||||
|
||||
if not _csrf_ok(request, form):
|
||||
return _render_bike_partial(request, s, bike_id, errors={"_": "CSRF check failed."})
|
||||
|
||||
try:
|
||||
event_in = _event_in(form)
|
||||
except Exception as exc:
|
||||
errors = _errors_from(exc)
|
||||
return _render_bike_partial(request, s, bike_id, errors=errors, old=_form_dict(form))
|
||||
|
||||
with d.get_conn() as conn:
|
||||
if d.get_bike(conn, bike_id) is None:
|
||||
return _redirect("/")
|
||||
d.create_event(
|
||||
conn,
|
||||
bike_id,
|
||||
event_in.type,
|
||||
event_in.event_date,
|
||||
event_in.mileage,
|
||||
event_in.cost,
|
||||
event_in.notes,
|
||||
)
|
||||
return _render_bike_partial(request, s, bike_id)
|
||||
|
||||
|
||||
def _render_bike_partial(
|
||||
request, s: Settings, bike_id: int, errors: dict | None = None, old: dict | None = None
|
||||
) -> Response:
|
||||
"""Render the service card + fresh event list (HTMX swap into #bike-body)."""
|
||||
base: dict = {
|
||||
"request": request,
|
||||
"today": date.today(),
|
||||
"event_types": s.event_types,
|
||||
"settings": s,
|
||||
"bike_id": bike_id,
|
||||
"csrf": csrf_token(s.secret_key, request.cookies.get(SESSION_COOKIE)),
|
||||
}
|
||||
with d.get_conn() as conn:
|
||||
bike = d.get_bike(conn, bike_id) or d.Bike(bike_id, "?", None)
|
||||
summary = svc.summarize_bike(conn, bike)
|
||||
events = d.list_events(conn, bike.id)
|
||||
base.update(bike=bike, summary=summary, events=events, old=old or {}, errors=errors or {})
|
||||
return templates.TemplateResponse(request, "events/partial.html", base)
|
||||
|
||||
|
||||
@app.post("/events/{event_id}/delete", include_in_schema=False)
|
||||
async def event_delete(request: Request, event_id: int):
|
||||
s = get_settings()
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
form = await request.form()
|
||||
if not check_csrf(s.secret_key, token, _form_str(form, "csrf")):
|
||||
return _redirect("/")
|
||||
with d.get_conn() as conn:
|
||||
r = conn.execute("SELECT bike_id FROM events WHERE id = ?", (event_id,)).fetchone()
|
||||
if r is None:
|
||||
return _redirect("/")
|
||||
bike_id = r["bike_id"]
|
||||
d.delete_event(conn, event_id)
|
||||
return _redirect(f"/bikes/{bike_id}")
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
import sqlite3
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
|
||||
from .config import Settings
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
#: Session cookie name.
|
||||
SESSION_COOKIE = "bike_session"
|
||||
CSRF_COOKIE = "bike_csrf"
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
# --- sessions ---
|
||||
def create_session(conn: sqlite3.Connection, user_id: int, settings: Settings) -> str:
|
||||
token = _token()
|
||||
created = _now()
|
||||
expires = created + timedelta(seconds=settings.session_ttl_seconds)
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
token,
|
||||
user_id,
|
||||
created.isoformat(sep=" "),
|
||||
expires.isoformat(sep=" "),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return token
|
||||
|
||||
|
||||
def get_user_id_for_session(conn: sqlite3.Connection, token: str | None) -> int | None:
|
||||
if not token:
|
||||
return None
|
||||
r = conn.execute(
|
||||
"SELECT user_id, expires_at FROM sessions WHERE token = ? LIMIT 1", (token,)
|
||||
).fetchone()
|
||||
if r is None:
|
||||
return None
|
||||
expires = datetime.fromisoformat(r["expires_at"])
|
||||
if expires <= _now():
|
||||
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
||||
conn.commit()
|
||||
return None
|
||||
return int(r["user_id"])
|
||||
|
||||
|
||||
def delete_session(conn: sqlite3.Connection, token: str | None) -> None:
|
||||
if not token:
|
||||
return
|
||||
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
# --- CSRF ---
|
||||
def csrf_token(secret_key: str, session_token: str | None) -> str:
|
||||
h = hmac.new(secret_key.encode(), session_token.encode(), hashlib.sha256).hexdigest()
|
||||
return h[:32]
|
||||
|
||||
|
||||
def check_csrf(secret_key: str, session_token: str | None, provided: str | None) -> bool:
|
||||
if not provided:
|
||||
return False
|
||||
expected = csrf_token(secret_key, session_token)
|
||||
return hmac.compare_digest(expected, provided)
|
||||
|
||||
|
||||
# --- login throttling ---
|
||||
def is_throttled(conn: sqlite3.Connection, ip: str, settings: Settings) -> bool:
|
||||
window_start = (_now() - timedelta(seconds=settings.login_window_seconds)).isoformat(sep=" ")
|
||||
r = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM login_attempts WHERE ip = ? AND ts >= ?",
|
||||
(ip, window_start),
|
||||
).fetchone()
|
||||
return int(r["n"]) >= settings.login_max_attempts
|
||||
|
||||
|
||||
def record_login_attempt(conn: sqlite3.Connection, ip: str) -> None:
|
||||
ts = _now().isoformat(sep=" ")
|
||||
conn.execute("INSERT INTO login_attempts (ip, ts) VALUES (?, ?)", (ip, ts))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def cleanup_old_attempts(conn: sqlite3.Connection, settings: Settings) -> None:
|
||||
cutoff = (_now() - timedelta(seconds=settings.login_window_seconds * 2)).isoformat(sep=" ")
|
||||
conn.execute("DELETE FROM login_attempts WHERE ts < ?", (cutoff,))
|
||||
conn.commit()
|
||||
|
||||
|
||||
def client_ip(request, trust_proxy: bool) -> str:
|
||||
"""Best-effort remote IP. Behind a proxy, trust the first X-Forwarded-For hop."""
|
||||
if trust_proxy:
|
||||
ffor = request.headers.get("x-forwarded-for")
|
||||
if ffor:
|
||||
return ffor.split(",")[0].strip()
|
||||
if request.client is not None:
|
||||
return request.client.host or "unknown"
|
||||
return "unknown"
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
|
||||
from . import db
|
||||
from .config import get_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceDue:
|
||||
"""Service-due snapshot for one bike.
|
||||
|
||||
status:
|
||||
"no-data" -> cannot compute (missing current mileage or last service,
|
||||
or no interval set)
|
||||
"ok" -> still under the interval
|
||||
"due-soon" -> within a short lead time of the interval - book it soon
|
||||
"overdue" -> past the interval - book a service now
|
||||
"""
|
||||
|
||||
current_mileage: int | None
|
||||
last_service: db.BikeEvent | None
|
||||
miles_since: int | None
|
||||
miles_until_due: int | None # negative when overdue
|
||||
status: str
|
||||
|
||||
|
||||
def compute_service_due(conn: sqlite3.Connection, bike: db.Bike) -> ServiceDue:
|
||||
# 'current_mileage' is derived from the most recent event that recorded a
|
||||
# mileage, i.e. the bike's latest known odometer reading.
|
||||
current_evt = db.latest_event_with_mileage(conn, bike.id)
|
||||
current_mileage = current_evt.mileage if current_evt else None
|
||||
|
||||
# The 'last service' is the most recent event typed 'service' that ALSO has
|
||||
# a mileage recorded, so we can compute a delta.
|
||||
last_service = db.latest_event_of_type(conn, bike.id, "service")
|
||||
if last_service is not None and last_service.mileage is None:
|
||||
last_service = None
|
||||
|
||||
no_data = ServiceDue(current_mileage, last_service, None, None, "no-data")
|
||||
if current_mileage is None or last_service is None or bike.service_interval_miles is None:
|
||||
return no_data
|
||||
|
||||
miles_since = current_mileage - last_service.mileage
|
||||
interval = bike.service_interval_miles
|
||||
miles_until_due = interval - miles_since
|
||||
if miles_since > interval:
|
||||
status = "overdue"
|
||||
elif miles_until_due <= get_settings().service_due_soon_miles:
|
||||
status = "due-soon"
|
||||
else:
|
||||
status = "ok"
|
||||
|
||||
return ServiceDue(
|
||||
current_mileage=current_mileage,
|
||||
last_service=last_service,
|
||||
miles_since=miles_since,
|
||||
miles_until_due=miles_until_due,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BikeSummary:
|
||||
bike: db.Bike
|
||||
due: ServiceDue
|
||||
event_count: int
|
||||
total_cost: int
|
||||
|
||||
|
||||
def summarize_bike(conn: sqlite3.Connection, bike: db.Bike) -> BikeSummary:
|
||||
return BikeSummary(
|
||||
bike=bike,
|
||||
due=compute_service_due(conn, bike),
|
||||
event_count=db.count_events(conn, bike.id),
|
||||
total_cost=db.sum_cost(conn, bike.id),
|
||||
)
|
||||
|
||||
|
||||
def all_summaries(conn: sqlite3.Connection) -> list[BikeSummary]:
|
||||
"""All bikes with summaries; bikes needing attention first."""
|
||||
summaries = [summarize_bike(conn, b) for b in db.list_bikes(conn)]
|
||||
rank = {"overdue": 0, "due-soon": 1, "ok": 2, "no-data": 3}
|
||||
summaries.sort(key=lambda s: (rank.get(s.due.status, 4), s.bike.name))
|
||||
return summaries
|
||||
|
||||
|
||||
def action_needed(conn: sqlite3.Connection) -> list[BikeSummary]:
|
||||
"""Bikes past their service interval - book it now."""
|
||||
return [s for s in all_summaries(conn) if s.due.status == "overdue"]
|
||||
|
||||
|
||||
def due_soon(conn: sqlite3.Connection) -> list[BikeSummary]:
|
||||
"""Bikes within the lead time of the interval - book it soon."""
|
||||
return [s for s in all_summaries(conn) if s.due.status == "due-soon"]
|
||||
|
||||
|
||||
def status_class(due: ServiceDue) -> str:
|
||||
return {
|
||||
"overdue": "badge-overdue",
|
||||
"due-soon": "badge-due-soon",
|
||||
"ok": "badge-ok",
|
||||
"no-data": "badge-muted",
|
||||
}[due.status]
|
||||
|
||||
|
||||
def status_text(due: ServiceDue) -> str:
|
||||
if due.status == "no-data":
|
||||
return "no service logged"
|
||||
if due.status == "due-soon":
|
||||
return f"book soon - {due.miles_until_due:,} mi left"
|
||||
if due.status == "ok":
|
||||
return f"due in {due.miles_until_due:,} mi"
|
||||
return f"overdue by {abs(due.miles_until_due):,} mi"
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,193 @@
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--card: #1a1d23;
|
||||
--card-inset: #12151b;
|
||||
--ink: #e6e9ee;
|
||||
--muted: #8b94a1;
|
||||
--line: #2a2f37;
|
||||
--accent: #4c8dff;
|
||||
--accent-ink: #0b0d10;
|
||||
|
||||
--ok-bg: #12231a;
|
||||
--ok-ink: #4ade80;
|
||||
--warn-bg: #2a2113;
|
||||
--warn-ink: #fbbf24;
|
||||
--overdue-bg: #2a1416;
|
||||
--overdue-ink: #f87171;
|
||||
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, .35), 0 1px 3px rgba(0,0,0,.4);
|
||||
max-width: 100%;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, system-ui, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
h1 { font-size: 1.5rem; margin: 0 0 .5rem; }
|
||||
h2 { font-size: 1.15rem; margin: 0 0 .5rem; }
|
||||
h1, h2, h3 { line-height: 1.2; font-weight: 600; }
|
||||
.muted { color: var(--muted); font-size: .9rem; }
|
||||
|
||||
.topbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: .75rem 1.25rem;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--line);
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.brand { font-weight: 700; color: var(--ink); }
|
||||
.brand:hover { text-decoration: none; color: var(--accent); }
|
||||
.nav { display: flex; gap: .5rem; align-items: center; }
|
||||
|
||||
.container { max-width: 960px; margin: 0 auto; padding: 1.25rem; }
|
||||
|
||||
.page-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.page-actions { display: flex; gap: .5rem; align-items: center; }
|
||||
|
||||
/* cards */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.bike-card { display: block; color: inherit; }
|
||||
.bike-card:hover { border-color: var(--accent); text-decoration: none; }
|
||||
.bike-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .5rem; }
|
||||
.bike-name { font-size: 1.05rem; font-weight: 600; }
|
||||
|
||||
.empty { text-align: center; padding: 2rem 1rem; }
|
||||
.empty .btn { margin-top: 1rem; }
|
||||
|
||||
/* callouts */
|
||||
.callout { padding: .75rem 1rem; border-radius: var(--radius); margin-bottom: 1rem; display: flex; flex-wrap: wrap; gap: .25rem .75rem; align-items: center; }
|
||||
.callout-warn { background: var(--warn-bg); color: var(--warn-ink); border: 1px solid #5a4718; }
|
||||
.callout-danger { background: var(--overdue-bg); color: var(--overdue-ink); border: 1px solid #5a2528; }
|
||||
.callout a { color: inherit; font-weight: 600; }
|
||||
.inline-item { display: inline-flex; gap: .4rem; align-items: center; }
|
||||
|
||||
/* service card */
|
||||
.due-top { display: flex; justify-content: space-between; align-items: flex-start; gap: .75rem; margin-bottom: .75rem; }
|
||||
.due-title { margin: 0; }
|
||||
.due-summary { margin: .15rem 0 0; color: var(--muted); font-size: .95rem; }
|
||||
.due-meta { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .4rem .75rem; margin: 0; }
|
||||
.due-meta dt { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.due-meta dd { margin: 0; font-weight: 600; }
|
||||
|
||||
.due-card.badge-ok { border-left: 4px solid var(--ok-ink); }
|
||||
.due-card.badge-due-soon { border-left: 4px solid var(--warn-ink); }
|
||||
.due-card.badge-overdue { border-left: 4px solid var(--overdue-ink); }
|
||||
.due-card.badge-muted { border-left: 4px solid var(--muted); }
|
||||
|
||||
/* badges */
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .15rem .55rem; border-radius: 999px;
|
||||
font-size: .8rem; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.badge-ok { background: var(--ok-bg); color: var(--ok-ink); }
|
||||
.badge-due-soon { background: var(--warn-bg); color: var(--warn-ink); }
|
||||
.badge-overdue { background: var(--overdue-bg); color: var(--overdue-ink); }
|
||||
.badge-muted { background: var(--card-inset); color: var(--muted); }
|
||||
|
||||
/* buttons */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
|
||||
padding: .5rem .9rem; border-radius: 8px;
|
||||
border: 1px solid var(--line); background: var(--card); color: var(--ink);
|
||||
font: inherit; font-weight: 500; cursor: pointer;
|
||||
}
|
||||
.btn:hover { background: var(--card-inset); text-decoration: none; }
|
||||
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
|
||||
.btn-primary:hover { background: #3f7fe6; }
|
||||
.btn-danger { border-color: #5a2528; color: var(--overdue-ink); background: var(--card); }
|
||||
.btn-danger:hover { background: var(--overdue-bg); }
|
||||
.btn-link { background: none; border: none; padding: .1rem; }
|
||||
.btn-block { width: 100%; }
|
||||
.btn-busy { display: none; }
|
||||
.btn.htmx-request .btn-busy { display: inline; }
|
||||
.btn.htmx-request .btn-label { display: none; }
|
||||
|
||||
/* forms */
|
||||
form label { display: flex; flex-direction: column; gap: .3rem; margin: 0.65rem 0; }
|
||||
form label > span { font-weight: 500; font-size: .9rem; }
|
||||
input, select, textarea {
|
||||
font: inherit; color: inherit;
|
||||
padding: .5rem .6rem; border-radius: 8px;
|
||||
border: 1px solid var(--line); background: var(--card-inset);
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent);
|
||||
}
|
||||
.form-row { display: grid; gap: .75rem; }
|
||||
@media (min-width: 560px) { .form-row { grid-template-columns: 1fr 1fr; } }
|
||||
.form-actions { margin-top: 1rem; display: flex; align-items: center; gap: .75rem; }
|
||||
.add-note { margin: 0; font-size: .8rem; color: var(--muted); max-width: 320px; }
|
||||
|
||||
/* events table */
|
||||
.events-table { width: 100%; border-collapse: collapse; }
|
||||
.events-table th, .events-table td { text-align: left; padding: .5rem .4rem; vertical-align: top; }
|
||||
.events-table th { color: var(--muted); font-size: .78rem; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid var(--line); }
|
||||
.events-table tr + tr td { border-top: 1px solid var(--line); }
|
||||
.noted { color: var(--muted); }
|
||||
.events-table td.notes { max-width: 220px; overflow-wrap: anywhere; }
|
||||
.row-actions { text-align: right; }
|
||||
.row-actions form { display: inline; }
|
||||
|
||||
.tag { display: inline-block; padding: .1rem .5rem; border-radius: 10px; font-size: .8rem; font-weight: 500; }
|
||||
.tag-service { background: #16263f; color: #7fb0ff; }
|
||||
.tag-fuel { background: #14291d; color: #7fcfa0; }
|
||||
.tag-tyre { background: #332713; color: #e0b06a; }
|
||||
.tag-chain { background: #241a3a; color: #b39ddb; }
|
||||
.tag-brake { background: #331a1a; color: #f0908c; }
|
||||
.tag-repair { background: #14291d; color: #7fcfa0; }
|
||||
.tag-other { background: #1c2129; color: #9aa5b1; }
|
||||
|
||||
/* alert */
|
||||
.alert { padding: .6rem .8rem; border-radius: 8px; margin: .75rem 0; }
|
||||
.alert-error { background: var(--overdue-bg); color: var(--overdue-ink); }
|
||||
|
||||
/* danger zone */
|
||||
.danger-zone { margin-top: 1rem; padding: 1rem; border: 1px dashed var(--line); border-radius: var(--radius); }
|
||||
|
||||
/* auth */
|
||||
body.auth { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: var(--bg); }
|
||||
.auth-card { max-width: 380px; width: 100%; padding: 2rem 1.5rem; margin: 0 auto; }
|
||||
|
||||
/* toast */
|
||||
.toast {
|
||||
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
|
||||
background: #2a2f37; color: var(--ink); padding: .5rem .9rem; border-radius: 8px;
|
||||
font-size: .9rem; animation: fade-in .2s ease; z-index: 20;
|
||||
}
|
||||
@keyframes fade-in { from { opacity: 0; transform: translate(-50%, .5rem);} to { opacity: 1; transform: translate(-50%, 0);} }
|
||||
|
||||
.busy { display: none; }
|
||||
|
||||
/* busy indicator (htmx sets .htmx-request on the [hx-indicator] target) */
|
||||
.spinner { display: none; width: 1em; height: 1em; border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
|
||||
.htmx-request ~ .spinner, .htmx-request .spinner { display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}bike-admin{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/main.css">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/">bike-admin</a>
|
||||
<nav class="nav">
|
||||
<a href="/bikes/new" class="btn btn-primary">+ Bike</a>
|
||||
<a href="/logout"
|
||||
class="btn"
|
||||
onclick="if(!confirm('Log out?')){event.preventDefault();return false;}">Log out</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ bike.name }} - bike-admin{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>{{ bike.name }}</h1>
|
||||
<a href="/bikes/{{ bike.id }}/edit" class="btn">Edit</a>
|
||||
</div>
|
||||
|
||||
<div id="bike-body">
|
||||
{% include "components/service_card.html" with context %}
|
||||
|
||||
<section class="card list-card">
|
||||
{% include "events/list.html" with context %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Log a new event</h2>
|
||||
<form method="post" action="/bikes/{{ bike.id }}/events" novalidate
|
||||
hx-post="/bikes/{{ bike.id }}/events"
|
||||
hx-target="#bike-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#busy-btn"
|
||||
hx-on::after-request="this.reset()">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span>Type</span>
|
||||
<select name="type" required>
|
||||
{% for t in event_types %}
|
||||
<option value="{{ t }}">{{ t }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Date</span>
|
||||
<input type="date" name="event_date" required max="{{ today.isoformat() }}"
|
||||
value="{{ today.isoformat() }}">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<span>Mileage (optional)</span>
|
||||
<input type="number" name="mileage" min="0" step="1"
|
||||
{% if summary.due.current_mileage is not none %}value="{{ summary.due.current_mileage }}"{% endif %}>
|
||||
<small class="muted">Odometer reading at this event{% if summary.due.current_mileage is not none %}. Prefilled with your last reading of {{ summary.due.current_mileage | money }} mi; just nudge it up{% endif %}.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Cost (optional)</span>
|
||||
<input type="number" name="cost" min="0" step="1">
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Notes</span>
|
||||
<textarea name="notes" rows="2"></textarea>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" id="busy-btn">
|
||||
<span class="btn-label">Add event</span>
|
||||
<span class="btn-busy">Adding…</span>
|
||||
</button>
|
||||
<p class="add-note">
|
||||
Tip: logging a <em>service</em> with the current mileage keeps your due
|
||||
date accurate.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit bike - bike-admin{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head"><h1>Edit {{ bike.name }}</h1></div>
|
||||
<div class="card">
|
||||
{% include "components/bike_form.html" with context %}
|
||||
</div>
|
||||
<div class="danger-zone">
|
||||
<h2 class="muted">Danger zone</h2>
|
||||
<form method="post" action="/bikes/{{ bike.id }}/delete"
|
||||
onsubmit="if(!confirm('Delete this bike and all its events? This cannot be undone.'))return false;">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<button class="btn btn-danger" type="submit">Delete bike</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}New bike - bike-admin{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head"><h1>New bike</h1></div>
|
||||
<div class="card">
|
||||
{% include "components/bike_form.html" with context %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{# Shared bike form. Expects: action (url), csrf, old (dict), errors (dict), is_edit, bike #}
|
||||
<form method="post" action="{{ action }}" novalidate>
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
{% if errors %}
|
||||
<div class="alert alert-error" role="alert">
|
||||
{% for key, val in errors.items() %}
|
||||
<div>{{ val }} {{ ' ' if not loop.last else '' }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input type="text" name="name" value="{{ old.get('name', bike.name if bike else '') }}" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Service interval (miles)</span>
|
||||
<input type="number" name="service_interval_miles" min="1" step="1"
|
||||
value="{{ old.get('service_interval_miles', bike.service_interval_miles if bike else '') }}">
|
||||
<small class="muted">How often this bike needs a full service, in miles.</small>
|
||||
</label>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'Save changes' if is_edit else 'Add bike' }}</button>
|
||||
<a href="javascript:history.back()" class="btn">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
@@ -0,0 +1,23 @@
|
||||
{# Service-status card. Expects: bike, summary (BikeSummary). #}
|
||||
<div class="card due-card {{ status_class(summary.due) }}">
|
||||
<div class="due-top">
|
||||
<div>
|
||||
<h2 class="due-title">Service status</h2>
|
||||
<p class="due-summary">{{ status_text(summary.due) }}</p>
|
||||
</div>
|
||||
<span class="badge {{ status_class(summary.due) }}">{{ status_text(summary.due) }}</span>
|
||||
</div>
|
||||
<dl class="due-meta">
|
||||
{% if summary.due.current_mileage is not none %}
|
||||
<dt>Odometer</dt><dd>{{ summary.due.current_mileage | money }} mi</dd>
|
||||
{% endif %}
|
||||
{% if summary.due.last_service %}
|
||||
{% set ls = summary.due.last_service %}
|
||||
<dt>Last service</dt><dd>{{ ls.event_date }} · {{ ls.mileage | money }} mi</dd>
|
||||
{% endif %}
|
||||
<dt>Interval</dt>
|
||||
<dd>{% if bike.service_interval_miles %}{{ bike.service_interval_miles | money }} mi{% else %}<span class="muted">not set</span>{% endif %}</dd>
|
||||
<dt>Records</dt><dd>{{ summary.event_count }}</dd>
|
||||
<dt>Cost to date</dt><dd>{{ summary.total_cost | money }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - bike-admin{% endblock %}
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1>Your bikes</h1>
|
||||
<a href="/bikes/new" class="btn btn-primary">+ New bike</a>
|
||||
</div>
|
||||
|
||||
{% if action %}
|
||||
<div class="callout callout-danger" role="alert">
|
||||
<strong>Book a service now:</strong>
|
||||
{% for s in action %}
|
||||
<span class="inline-item"><a href="/bikes/{{ s.bike.id }}">{{ s.bike.name }}</a> <span class="badge badge-overdue">{{ status_text(s.due) }}</span></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if due_soon %}
|
||||
<div class="callout callout-warn" role="alert">
|
||||
<strong>Service coming up:</strong>
|
||||
{% for s in due_soon %}
|
||||
<span class="inline-item"><a href="/bikes/{{ s.bike.id }}">{{ s.bike.name }}</a> <span class="badge badge-due-soon">{{ status_text(s.due) }}</span></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not bikes %}
|
||||
<div class="card empty">
|
||||
<p>No bikes yet. Add your first bike to start logging services and repairs.</p>
|
||||
<a href="/bikes/new" class="btn btn-primary">Add a bike</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="grid">
|
||||
{% for s in bikes %}
|
||||
<a class="card bike-card {{ status_class(s.due) }}-card" href="/bikes/{{ s.bike.id }}">
|
||||
<div class="bike-head">
|
||||
<h2 class="bike-name">{{ s.bike.name }}</h2>
|
||||
<span class="badge {{ status_class(s.due) }}">{{ status_text(s.due) }}</span>
|
||||
</div>
|
||||
<dl class="bike-meta">
|
||||
{% if s.due.current_mileage is not none %}
|
||||
<dt>Odometer</dt><dd>{{ s.due.current_mileage | money }} mi</dd>
|
||||
{% endif %}
|
||||
{% if s.due.last_service %}
|
||||
<dt>Last service</dt><dd>{{ s.due.last_service.event_date }} ({{ s.due.last_service.mileage | money }} mi)</dd>
|
||||
{% endif %}
|
||||
{% if s.bike.service_interval_miles %}
|
||||
<dt>Every</dt><dd>{{ s.bike.service_interval_miles | money }} mi</dd>
|
||||
{% endif %}
|
||||
<dt>Records</dt><dd>{{ s.event_count }}</dd>
|
||||
<dt>Cost to date</dt><dd>{{ s.total_cost | money }}</dd>
|
||||
</dl>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,33 @@
|
||||
{# Expects: events, csrf, old, errors (all optional) #}
|
||||
{% if events %}
|
||||
<table class="events-table">
|
||||
<thead>
|
||||
<tr><th>When</th><th>Type</th><th>Miles</th><th>Cost</th><th>Notes</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ev in events %}
|
||||
<tr>
|
||||
<td>{{ ev.event_date }}</td>
|
||||
<td><span class="tag tag-{{ ev.type }}">{{ ev.type }}</span></td>
|
||||
<td>{% if ev.mileage is not none %}{{ ev.mileage | money }}{% else %}-{% endif %}</td>
|
||||
<td>{{ ev.cost | money if ev.cost is not none else '-' }}</td>
|
||||
<td class="notes">{{ ev.notes or '' }}</td>
|
||||
<td class="row-actions">
|
||||
<form method="post" action="/events/{{ ev.id }}/delete"
|
||||
onsubmit="if(!confirm('Delete this event?'))return false;">
|
||||
<input type="hidden" name="csrf" value="{{ csrf }}">
|
||||
<button class="btn btn-link btn-danger" type="submit" aria-label="Delete event">×</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">No events yet. Log your first one below.</p>
|
||||
{% endif %}
|
||||
{% if errors %}
|
||||
<div class="alert alert-error" style="margin-top:1rem">
|
||||
{% for key, val in errors.items() %}<div>{{ val }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{# HTMX swap target for POST /bikes/{id}/events (swaps into #bike-body).
|
||||
Expects: bike, summary, events, old, errors, csrf, event_types. #}
|
||||
{% include "components/service_card.html" with context %}
|
||||
<section class="card list-card">
|
||||
{% include "events/list.html" with context %}
|
||||
</section>
|
||||
{% if errors %}
|
||||
<div id="form-errors" class="alert alert-error" role="alert">
|
||||
{% for key, val in errors.items() %}<div>{{ val }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Log in - bike-admin</title>
|
||||
<link rel="stylesheet" href="/static/main.css">
|
||||
</head>
|
||||
<body class="auth">
|
||||
<main class="container auth-card">
|
||||
<h1 style="margin-top:0">bike-admin</h1>
|
||||
<p class="muted">Log in to manage your bikes.</p>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-error" role="alert">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/login" novalidate>
|
||||
<label>
|
||||
<span>Username</span>
|
||||
<input type="text" name="username" autocomplete="username" required autofocus>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<input type="hidden" name="next" value="/">
|
||||
<button type="submit" class="btn btn-primary btn-block">Log in</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user