start project
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""Vite manifest reader — maps a source entry to its hashed dist path (Ch05)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
|
||||
|
||||
class ManifestNotFound(RuntimeError):
|
||||
"""app/static/dist/manifest.json is missing — `npm run build` hasn't been run."""
|
||||
|
||||
|
||||
def _load_manifest(static_dist_dir: Path) -> dict:
|
||||
for candidate in (static_dist_dir / ".vite" / "manifest.json", static_dist_dir / "manifest.json"):
|
||||
if candidate.exists():
|
||||
return json.loads(candidate.read_text())
|
||||
raise ManifestNotFound(f"No Vite manifest under {static_dist_dir} — run `npm run build`.")
|
||||
|
||||
|
||||
def register_asset_helper(app: Flask) -> None:
|
||||
"""Register `asset(name)` as a Jinja global (Chapter 05)."""
|
||||
static_dist_dir = Path(app.static_folder) / "dist"
|
||||
|
||||
@app.context_processor
|
||||
def inject_asset_helper():
|
||||
def asset(name: str) -> str:
|
||||
try:
|
||||
manifest = _load_manifest(static_dist_dir)
|
||||
except ManifestNotFound:
|
||||
if app.debug:
|
||||
return f"/static/dist/{name}" # tolerate an unbuilt dev checkout
|
||||
raise
|
||||
entry = manifest.get(name)
|
||||
if entry is None:
|
||||
raise KeyError(f"{name!r} not in Vite manifest.")
|
||||
return f"/static/dist/{entry['file']}"
|
||||
|
||||
return {"asset": asset}
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Shared from/to date-range parsing (Chapter 11: consistent across every
|
||||
chart/KPI endpoint; never defaults to "all time" per Chapter 03 rule 7).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from flask import Request
|
||||
|
||||
DEFAULT_RANGE_DAYS = 7 # within Ch11's stated "7 or 30 day" default allowance
|
||||
|
||||
|
||||
def parse_date_range(request: Request) -> tuple[date, date]:
|
||||
"""Parse `?from=&to=` (ISO 8601 dates), defaulting to the last 7 days."""
|
||||
to_raw = request.args.get("to")
|
||||
from_raw = request.args.get("from")
|
||||
to_date = date.fromisoformat(to_raw) if to_raw else date.today()
|
||||
from_date = date.fromisoformat(from_raw) if from_raw else to_date - timedelta(days=DEFAULT_RANGE_DAYS - 1)
|
||||
if from_date > to_date:
|
||||
from_date, to_date = to_date, from_date
|
||||
return from_date, to_date
|
||||
|
||||
|
||||
def day_bounds(from_date: date, to_date: date) -> tuple[datetime, datetime]:
|
||||
"""Inclusive [from_date, to_date] -> half-open [start, end) datetime range.
|
||||
|
||||
Shared by overview/queries.py, seo/queries.py, and security/queries.py
|
||||
(consolidated here rather than each keeping its own local copy).
|
||||
"""
|
||||
start = datetime.combine(from_date, datetime.min.time())
|
||||
end = datetime.combine(to_date, datetime.min.time()) + timedelta(days=1)
|
||||
return start, end
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Shared JSON envelope helpers (Chapter 11).
|
||||
|
||||
Every existing /api/... endpoint already hand-builds this exact shape via
|
||||
jsonify(data=..., meta=...) — audited for compliance in docs/api-contract-
|
||||
final.md. This module exists so NEW endpoints (e.g. this chapter's
|
||||
unauthorized_handler) have one call site instead of re-typing the shape.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import jsonify
|
||||
|
||||
|
||||
def api_ok(data, meta: dict | None = None):
|
||||
return jsonify(data=data, meta=meta or {})
|
||||
|
||||
|
||||
def api_error(code: str, message: str, status: int):
|
||||
return jsonify(error={"code": code, "message": message}), status
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Shared HTMX full-page-vs-fragment response helper (Chapter 04).
|
||||
|
||||
Every blueprint serving a dashboard tab (Ch08/09/10) uses this instead of
|
||||
duplicating the `if request.headers.get("HX-Request")` check, so the
|
||||
convention is identical everywhere.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Request, render_template
|
||||
|
||||
|
||||
def is_htmx(request: Request) -> bool:
|
||||
"""True if this request was triggered by an hx-* attribute."""
|
||||
return request.headers.get("HX-Request", "").lower() == "true"
|
||||
|
||||
|
||||
def render_htmx_aware(request: Request, *, full_template: str, partial_template: str, **ctx):
|
||||
"""Render `full_template` on first load, `partial_template` on HTMX swaps."""
|
||||
template = partial_template if is_htmx(request) else full_template
|
||||
return render_template(template, **ctx)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Shared HTTP status-code bucketing — used by the aggregator (per-IP
|
||||
rollup) and available for any endpoint needing the same 2xx/3xx/4xx/5xx
|
||||
buckets, so there's one bucketing rule, not several copies."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def status_bucket(status_code: int) -> str:
|
||||
if status_code < 300:
|
||||
return "2xx"
|
||||
if status_code < 400:
|
||||
return "3xx"
|
||||
if status_code < 500:
|
||||
return "4xx"
|
||||
return "5xx"
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Shared page/per_page parsing (Chapter 11: consistent across every
|
||||
Grid.js-backed endpoint)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Request
|
||||
|
||||
DEFAULT_PER_PAGE = 20
|
||||
MAX_PER_PAGE = 100
|
||||
|
||||
|
||||
def parse_pagination(request: Request) -> tuple[int, int]:
|
||||
page = max(1, request.args.get("page", 1, type=int))
|
||||
per_page = request.args.get("per_page", DEFAULT_PER_PAGE, type=int)
|
||||
return page, max(1, min(per_page, MAX_PER_PAGE))
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Shared upload-path helper (Chapter 04) — used by the uploads blueprint,
|
||||
the optional CLI processor, and the automatic background-thread processor,
|
||||
so there's one definition of where a given LogFile's raw upload lives on
|
||||
disk. Pulled out of the uploads blueprint so services/ doesn't have to
|
||||
import from blueprints/ (the wrong direction) to reach it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from app.models.log_file import LogFile
|
||||
|
||||
|
||||
def upload_path_for(log_file: LogFile) -> Path:
|
||||
"""Deterministic on-disk path for a LogFile row — avoids a new DB column."""
|
||||
ext = Path(log_file.filename).suffix.lower()
|
||||
upload_dir = Path(current_app.config["UPLOAD_DIR"])
|
||||
return upload_dir / f"{log_file.id}{ext}"
|
||||
Reference in New Issue
Block a user