Files
StarPunk/starpunk/routes/admin.py
Phil Skentelbery 372064b116 feat(tags): Add tag archive route and admin interface integration
Implement Phase 3 of v1.3.0 tags feature per microformats-tags-design.md:

Routes (starpunk/routes/public.py):
- Add /tag/<tag> archive route with normalization and 404 handling
- Pre-load tags in index route for all notes
- Pre-load tags in note route for individual notes

Admin (starpunk/routes/admin.py):
- Parse comma-separated tag input in create route
- Parse tag input in update route
- Pre-load tags when displaying edit form
- Empty tag field removes all tags

Templates:
- Add tag input field to templates/admin/edit.html
- Add tag input field to templates/admin/new.html
- Use Jinja2 map filter to display existing tags

Implementation details:
- Tag URL parameter normalized to lowercase before lookup
- Tags pre-loaded using object.__setattr__ pattern (like media)
- parse_tag_input() handles trim, dedupe, normalization
- All existing tests pass (micropub categories, admin routes)

Per architect design:
- No pagination on tag archives (acceptable for v1.3.0)
- No autocomplete in admin (out of scope)
- Follows existing media loading patterns

Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 11:42:16 -07:00

603 lines
18 KiB
Python

"""
Admin routes for StarPunk
Handles authenticated admin functionality including dashboard, note creation,
editing, and deletion. All routes require authentication.
"""
from flask import Blueprint, flash, g, jsonify, redirect, render_template, request, url_for
import os
import shutil
from datetime import datetime
from starpunk.auth import require_auth
from starpunk.notes import (
create_note,
delete_note,
list_notes,
get_note,
update_note,
)
# Create blueprint
bp = Blueprint("admin", __name__, url_prefix="/admin")
@bp.route("/")
@require_auth
def dashboard():
"""
Admin dashboard with note list
Displays all notes (published and drafts) with management controls.
Requires authentication.
Returns:
Rendered dashboard template with complete note list
Decorator: @require_auth
Template: templates/admin/dashboard.html
Access: g.user_me (set by require_auth decorator)
"""
# Get all notes (published and drafts)
notes = list_notes()
return render_template("admin/dashboard.html", notes=notes, user_me=g.me)
@bp.route("/new", methods=["GET"])
@require_auth
def new_note_form():
"""
Display create note form
Shows empty form for creating a new note.
Requires authentication.
Returns:
Rendered new note form template
Decorator: @require_auth
Template: templates/admin/new.html
"""
return render_template("admin/new.html")
@bp.route("/new", methods=["POST"])
@require_auth
def create_note_submit():
"""
Handle new note submission
Creates a new note from submitted form data.
Requires authentication.
Form data:
content: Markdown content (required)
published: Checkbox for published status (optional)
custom_slug: Optional custom slug (v1.2.0 Phase 1)
media_files: Multiple file upload (v1.2.0 Phase 3)
captions[]: Captions for each media file (v1.2.0 Phase 3)
tags: Comma-separated tag list (v1.3.0 Phase 3)
Returns:
Redirect to dashboard on success, back to form on error
Decorator: @require_auth
"""
from starpunk.media import save_media, attach_media_to_note
from starpunk.tags import parse_tag_input
content = request.form.get("content", "").strip()
published = "published" in request.form
custom_slug = request.form.get("custom_slug", "").strip()
tags_input = request.form.get("tags", "")
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.new_note_form"))
# Parse tags (v1.3.0 Phase 3)
tags = parse_tag_input(tags_input)
try:
# Create note first (per Q4)
note = create_note(
content,
published=published,
custom_slug=custom_slug if custom_slug else None,
tags=tags if tags else None
)
# Handle media uploads (v1.2.0 Phase 3)
media_files = request.files.getlist('media_files')
captions = request.form.getlist('captions[]')
if media_files and any(f.filename for f in media_files):
# Per Q35: Accept valid, reject invalid (not atomic)
media_ids = []
errors = []
for i, file in enumerate(media_files):
if not file.filename:
continue
try:
# Read file data
file_data = file.read()
# Save and optimize media
media_info = save_media(file_data, file.filename)
media_ids.append(media_info['id'])
except ValueError as e:
errors.append(f"{file.filename}: {str(e)}")
except Exception as e:
errors.append(f"{file.filename}: Upload failed")
if media_ids:
# Ensure captions list matches media_ids length
while len(captions) < len(media_ids):
captions.append('')
# Attach media to note
attach_media_to_note(note.id, media_ids, captions[:len(media_ids)])
if errors:
flash(f"Note created, but some images failed: {'; '.join(errors)}", "warning")
flash(f"Note created: {note.slug}", "success")
return redirect(url_for("admin.dashboard"))
except ValueError as e:
flash(f"Error creating note: {e}", "error")
return redirect(url_for("admin.new_note_form"))
except Exception as e:
flash(f"Unexpected error creating note: {e}", "error")
return redirect(url_for("admin.new_note_form"))
@bp.route("/edit/<int:note_id>", methods=["GET"])
@require_auth
def edit_note_form(note_id: int):
"""
Display edit note form
Shows form pre-filled with existing note content for editing.
Requires authentication.
Args:
note_id: Database ID of note to edit
Returns:
Rendered edit form template or 404 if note not found
Decorator: @require_auth
Template: templates/admin/edit.html
"""
from starpunk.tags import get_note_tags
note = get_note(id=note_id)
if not note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
# Pre-load tags for the edit form (v1.3.0 Phase 3)
tags = get_note_tags(note.id)
object.__setattr__(note, '_cached_tags', tags)
return render_template("admin/edit.html", note=note)
@bp.route("/edit/<int:note_id>", methods=["POST"])
@require_auth
def update_note_submit(note_id: int):
"""
Handle note update submission
Updates existing note with submitted form data.
Requires authentication.
Args:
note_id: Database ID of note to update
Form data:
content: Updated markdown content (required)
published: Checkbox for published status (optional)
tags: Comma-separated tag list (v1.3.0 Phase 3)
Returns:
Redirect to dashboard on success, back to form on error
Decorator: @require_auth
"""
from starpunk.tags import parse_tag_input
# Check if note exists first
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
content = request.form.get("content", "").strip()
published = "published" in request.form
tags_input = request.form.get("tags", "")
if not content:
flash("Content cannot be empty", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
# Parse tags (v1.3.0 Phase 3)
tags = parse_tag_input(tags_input)
try:
note = update_note(
id=note_id,
content=content,
published=published,
tags=tags if tags else None
)
flash(f"Note updated: {note.slug}", "success")
return redirect(url_for("admin.dashboard"))
except ValueError as e:
flash(f"Error updating note: {e}", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
except Exception as e:
flash(f"Unexpected error updating note: {e}", "error")
return redirect(url_for("admin.edit_note_form", note_id=note_id))
@bp.route("/delete/<int:note_id>", methods=["POST"])
@require_auth
def delete_note_submit(note_id: int):
"""
Handle note deletion
Deletes a note after confirmation.
Requires authentication.
Args:
note_id: Database ID of note to delete
Form data:
confirm: Must be 'yes' to proceed with deletion
Returns:
Redirect to dashboard with success/error message
Decorator: @require_auth
"""
# Check if note exists first (per ADR-012)
existing_note = get_note(id=note_id, load_content=False)
if not existing_note:
flash("Note not found", "error")
return redirect(url_for("admin.dashboard")), 404
# Check for confirmation
if request.form.get("confirm") != "yes":
flash("Deletion cancelled", "info")
return redirect(url_for("admin.dashboard"))
try:
delete_note(id=note_id, soft=False)
flash("Note deleted successfully", "success")
except ValueError as e:
flash(f"Error deleting note: {e}", "error")
except Exception as e:
flash(f"Unexpected error deleting note: {e}", "error")
return redirect(url_for("admin.dashboard"))
def transform_metrics_for_template(metrics_stats):
"""
Transform metrics stats to match template structure
The template expects direct access to metrics.database.count, but
get_metrics_stats() returns metrics.by_type.database.count.
This function adapts the data structure to match template expectations.
Args:
metrics_stats: Dict from get_metrics_stats() with nested by_type structure
Returns:
Dict with flattened structure matching template expectations
Per ADR-060: Route Adapter Pattern for template compatibility
"""
transformed = {}
# Map by_type to direct access
for op_type in ['database', 'http', 'render']:
if 'by_type' in metrics_stats and op_type in metrics_stats['by_type']:
type_data = metrics_stats['by_type'][op_type]
transformed[op_type] = {
'count': type_data.get('count', 0),
'avg': type_data.get('avg_duration_ms', 0),
'min': type_data.get('min_duration_ms', 0),
'max': type_data.get('max_duration_ms', 0)
}
else:
# Provide defaults for missing types or when by_type doesn't exist
transformed[op_type] = {
'count': 0,
'avg': 0,
'min': 0,
'max': 0
}
# Keep other top-level stats
transformed['total_count'] = metrics_stats.get('total_count', 0)
transformed['max_size'] = metrics_stats.get('max_size', 1000)
transformed['process_id'] = metrics_stats.get('process_id', 0)
return transformed
@bp.route("/metrics-dashboard")
@require_auth
def metrics_dashboard():
"""
Metrics visualization dashboard (Phase 3)
Displays performance metrics, database statistics, feed statistics,
and system health with visual charts and auto-refresh capability.
Per Q19 requirements:
- Server-side rendering with Jinja2
- htmx for auto-refresh
- Chart.js from CDN for graphs
- Progressive enhancement (works without JS)
Per v1.1.2 Phase 3:
- Feed statistics by format
- Cache hit/miss rates
- Format popularity breakdown
Returns:
Rendered dashboard template with metrics
Decorator: @require_auth
Template: templates/admin/metrics_dashboard.html
"""
# Defensive imports with graceful degradation for missing modules
try:
from starpunk.database.pool import get_pool_stats
from starpunk.monitoring import get_metrics_stats
from starpunk.monitoring.business import get_feed_statistics
monitoring_available = True
except ImportError:
monitoring_available = False
# Provide fallback functions that return error messages
def get_pool_stats():
return {"error": "Database pool monitoring not available"}
def get_metrics_stats():
return {"error": "Monitoring module not implemented"}
def get_feed_statistics():
return {"error": "Feed statistics not available"}
# Get current metrics for initial page load
metrics_data = {}
pool_stats = {}
feed_stats = {}
try:
raw_metrics = get_metrics_stats()
metrics_data = transform_metrics_for_template(raw_metrics)
except Exception as e:
flash(f"Error loading metrics: {e}", "warning")
# Provide safe defaults matching template expectations
metrics_data = {
'database': {'count': 0, 'avg': 0, 'min': 0, 'max': 0},
'http': {'count': 0, 'avg': 0, 'min': 0, 'max': 0},
'render': {'count': 0, 'avg': 0, 'min': 0, 'max': 0},
'total_count': 0,
'max_size': 1000,
'process_id': 0
}
try:
pool_stats = get_pool_stats()
except Exception as e:
flash(f"Error loading pool stats: {e}", "warning")
try:
feed_stats = get_feed_statistics()
except Exception as e:
flash(f"Error loading feed stats: {e}", "warning")
# Provide safe defaults
feed_stats = {
'by_format': {
'rss': {'generated': 0, 'cached': 0, 'total': 0, 'avg_duration_ms': 0.0},
'atom': {'generated': 0, 'cached': 0, 'total': 0, 'avg_duration_ms': 0.0},
'json': {'generated': 0, 'cached': 0, 'total': 0, 'avg_duration_ms': 0.0},
},
'cache': {'hits': 0, 'misses': 0, 'hit_rate': 0.0, 'entries': 0, 'evictions': 0},
'total_requests': 0,
'format_percentages': {'rss': 0.0, 'atom': 0.0, 'json': 0.0},
}
return render_template(
"admin/metrics_dashboard.html",
metrics=metrics_data,
pool=pool_stats,
feeds=feed_stats,
user_me=g.me
)
@bp.route("/metrics")
@require_auth
def metrics():
"""
Performance metrics and database pool statistics endpoint
Per Phase 2 requirements:
- Expose database pool statistics
- Show performance metrics from MetricsBuffer
- Requires authentication
Per v1.1.2 Phase 3:
- Include feed statistics
Returns:
JSON with metrics, pool statistics, and feed statistics
Response codes:
200: Metrics retrieved successfully
Decorator: @require_auth
"""
from flask import current_app
from starpunk.database.pool import get_pool_stats
from starpunk.monitoring import get_metrics_stats
from starpunk.monitoring.business import get_feed_statistics
response = {
"timestamp": datetime.utcnow().isoformat() + "Z",
"process_id": os.getpid(),
"database": {},
"performance": {},
"feeds": {}
}
# Get database pool statistics
try:
pool_stats = get_pool_stats()
response["database"]["pool"] = pool_stats
except Exception as e:
response["database"]["pool"] = {"error": str(e)}
# Get performance metrics
try:
metrics_stats = get_metrics_stats()
response["performance"] = metrics_stats
except Exception as e:
response["performance"] = {"error": str(e)}
# Get feed statistics
try:
feed_stats = get_feed_statistics()
response["feeds"] = feed_stats
except Exception as e:
response["feeds"] = {"error": str(e)}
return jsonify(response), 200
@bp.route("/health")
@require_auth
def health_diagnostics():
"""
Full health diagnostics endpoint for admin use
Per developer Q&A Q10:
- Always requires authentication
- Provides comprehensive diagnostics
- Includes metrics, database pool statistics, and system info
Returns:
JSON with complete system diagnostics
Response codes:
200: Diagnostics retrieved successfully
500: Critical health issues detected
Decorator: @require_auth
"""
from flask import current_app
from starpunk.database.pool import get_pool_stats
diagnostics = {
"status": "healthy",
"version": current_app.config.get("VERSION", "unknown"),
"environment": current_app.config.get("ENV", "unknown"),
"process_id": os.getpid(),
"checks": {},
"metrics": {},
"database": {}
}
overall_healthy = True
# Database connectivity check
try:
from starpunk.database import get_db
db = get_db()
result = db.execute("SELECT 1").fetchone()
db.close()
diagnostics["checks"]["database"] = {
"status": "healthy",
"message": "Database accessible"
}
# Get database pool statistics
try:
pool_stats = get_pool_stats()
diagnostics["database"]["pool"] = pool_stats
except Exception as e:
diagnostics["database"]["pool"] = {"error": str(e)}
except Exception as e:
diagnostics["checks"]["database"] = {
"status": "unhealthy",
"error": str(e)
}
overall_healthy = False
# Filesystem check
try:
data_path = current_app.config.get("DATA_PATH", "data")
if not os.path.exists(data_path):
raise Exception("Data path not accessible")
diagnostics["checks"]["filesystem"] = {
"status": "healthy",
"path": data_path,
"writable": os.access(data_path, os.W_OK),
"readable": os.access(data_path, os.R_OK)
}
except Exception as e:
diagnostics["checks"]["filesystem"] = {
"status": "unhealthy",
"error": str(e)
}
overall_healthy = False
# Disk space check
try:
data_path = current_app.config.get("DATA_PATH", "data")
stat = shutil.disk_usage(data_path)
percent_free = (stat.free / stat.total) * 100
diagnostics["checks"]["disk"] = {
"status": "healthy" if percent_free > 10 else ("warning" if percent_free > 5 else "critical"),
"total_gb": round(stat.total / (1024**3), 2),
"used_gb": round(stat.used / (1024**3), 2),
"free_gb": round(stat.free / (1024**3), 2),
"percent_free": round(percent_free, 2),
"percent_used": round((stat.used / stat.total) * 100, 2)
}
if percent_free <= 5:
overall_healthy = False
except Exception as e:
diagnostics["checks"]["disk"] = {
"status": "unhealthy",
"error": str(e)
}
overall_healthy = False
# Performance metrics
try:
from starpunk.monitoring import get_metrics_stats
metrics_stats = get_metrics_stats()
diagnostics["metrics"] = metrics_stats
except Exception as e:
diagnostics["metrics"] = {"error": str(e)}
# Update overall status
diagnostics["status"] = "healthy" if overall_healthy else "unhealthy"
return jsonify(diagnostics), 200 if overall_healthy else 500