Files
Bennys-Board/app.py
2025-11-27 00:23:14 +00:00

2311 lines
102 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# app.py
from __future__ import annotations
import os, secrets, string, json as _json, csv
from datetime import datetime, timedelta
from io import StringIO
from typing import Optional
from flask import (
Flask, render_template_string, request, redirect, url_for, flash, jsonify,
make_response, abort
)
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
LoginManager, UserMixin, login_user, current_user, login_required, logout_user
)
from werkzeug.security import generate_password_hash, check_password_hash
# CSRF + Rate Limiting
from flask_wtf import CSRFProtect
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
# =============================================================================
# Config
# =============================================================================
def getenv(name: str, default: Optional[str] = None) -> Optional[str]:
return os.environ.get(name, default)
DATABASE_URL = getenv("DATABASE_URL", "sqlite:///rbac.db")
SECRET_KEY = getenv("APP_SECRET_KEY", "dev_change_me")
APP_BRAND = getenv("APP_BRAND", "Streetside Canyon")
# First-run bootstrap admin (used only if there are no users in DB)
BOOTSTRAP_ADMIN_USER = getenv("DEFAULT_ADMIN_USERNAME", "admin")
BOOTSTRAP_ADMIN_PASS = getenv("DEFAULT_ADMIN_PASSWORD", "change_me_now")
# =============================================================================
# App + DB + Login
# =============================================================================
app = Flask(__name__)
app.config.update(
SECRET_KEY=SECRET_KEY,
SQLALCHEMY_DATABASE_URI=DATABASE_URL,
SQLALCHEMY_TRACK_MODIFICATIONS=False,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=True, # assumes HTTPS (you run behind Nginx)
REMEMBER_COOKIE_SECURE=True,
REMEMBER_COOKIE_HTTPONLY=True,
PERMANENT_SESSION_LIFETIME=60 * 60 * 24 * 14, # 14 days in seconds
)
# --- Freeze-safe config ---
app.config.update(
FREEZER_FLAT_URLS=True,
FREEZER_REMOVE_EXTRA_FILES=True,
FREEZER_IGNORE_MIMETYPE_WARNINGS=True
)
db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = "login"
login_manager.session_protection = "strong"
# CSRF + Rate limit
csrf = CSRFProtect(app)
limiter = Limiter(key_func=get_remote_address, app=app, default_limits=["200/day", "50/hour"])
# =============================================================================
# Security headers
# =============================================================================
@app.after_request
def add_security_headers(resp):
csp = (
"default-src 'self'; "
"style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
"img-src 'self' data:; font-src 'self' data:; "
"base-uri 'none'; frame-ancestors 'none'; form-action 'self'"
)
resp.headers.setdefault("Content-Security-Policy", csp)
resp.headers.setdefault("X-Frame-Options", "DENY")
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
resp.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
return resp
# =============================================================================
# Models
# =============================================================================
class User(UserMixin, db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
# 'admin' or 'member'
role = db.Column(db.String(16), nullable=False, default="member", index=True)
is_active = db.Column(db.Boolean, nullable=False, default=True)
# Force password change on first login (or after admin reset)
must_change_password = db.Column(db.Boolean, nullable=False, default=True)
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
def get_id(self):
return str(self.id)
def set_password(self, raw: str) -> None:
self.password_hash = generate_password_hash(raw)
def check_password(self, raw: str) -> bool:
return check_password_hash(self.password_hash, raw)
def is_admin(self) -> bool:
return self.role == "admin"
class AvailabilityWeekly(db.Model):
__tablename__ = "availability_weekly"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False, index=True)
# JSON shape: {"mon":{"avail":true,"start":"08:00","end":"17:00"}, ...}
data_json = db.Column(db.Text, nullable=False, default="{}")
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
user = db.relationship("User", backref=db.backref("availability_weekly", uselist=False))
class TimeOffRequest(db.Model):
__tablename__ = "timeoff_requests"
__table_args__ = (
db.Index("ix_timeoff_user_status", "user_id", "status"),
db.Index("ix_timeoff_created_at", "created_at"),
db.Index("ix_timeoff_date", "date"),
)
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
date = db.Column(db.String(10), nullable=False) # "YYYY-MM-DD"
note = db.Column(db.String(240))
status = db.Column(db.String(16), nullable=False, default="pending", index=True) # pending|approved|denied|cancelled
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
decided_at = db.Column(db.DateTime)
user = db.relationship("User", backref=db.backref("timeoff_requests", lazy="dynamic"))
class Wishlist(db.Model):
__tablename__ = "wishlists"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False, index=True)
wishlist = db.Column(db.Text, nullable=False)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
user = db.relationship("User", backref=db.backref("wishlist", uselist=False))
class SecretSantaEntry(db.Model):
__tablename__ = "secret_santa_entries"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), unique=True, nullable=False, index=True)
# Explicit questions
full_name = db.Column(db.String(120), nullable=False) # “Name (First and Last)”
age = db.Column(db.Integer) # Age
birthday = db.Column(db.String(10)) # YYYY-MM-DD (stored as text for SQLite simplicity)
hobbies = db.Column(db.Text) # List of hobbies
gift_card = db.Column(db.String(120)) # Favorite gift card
fav_movie = db.Column(db.String(120)) # Favorite type of movie
jewelry = db.Column(db.Boolean, nullable=False, default=False) # Yes/No
created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, index=True)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow, index=True)
user = db.relationship("User", backref=db.backref("secret_santa", uselist=False))
class InfoContact(db.Model):
__tablename__ = "info_contacts"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False, index=True)
role = db.Column(db.String(120))
phone = db.Column(db.String(32))
priority = db.Column(db.Integer, default=5, index=True) # 1 = top
team = db.Column(db.String(64), default="Streetside") # e.g., "Streetside"
is_active = db.Column(db.Boolean, default=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class DeptExtension(db.Model):
__tablename__ = "dept_extensions"
id = db.Column(db.Integer, primary_key=True)
ext = db.Column(db.String(16), nullable=False, index=True)
dept = db.Column(db.String(120), nullable=False, index=True)
is_active = db.Column(db.Boolean, default=True, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
class SupportItem(db.Model):
"""
Generic support card: Guest Services, eCommerce Team, IT, etc.
"""
__tablename__ = "support_items"
id = db.Column(db.Integer, primary_key=True)
category = db.Column(db.String(64), nullable=False) # "Guest Services", "IT", "Ecommerce"
email = db.Column(db.String(160))
phone = db.Column(db.String(64))
note = db.Column(db.Text) # free-text note / disclaimer
issues_json = db.Column(db.Text, default="[]") # JSON list of bullet items
audience = db.Column(db.String(16), default="all") # "all" | "admin"
is_active = db.Column(db.Boolean, default=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def issues(self):
try:
return _json.loads(self.issues_json or "[]")
except Exception:
return []
class LocalSecret(db.Model):
"""
Admin-only quick refs (register login, local device passwords, etc.)
"""
__tablename__ = "local_secrets"
id = db.Column(db.Integer, primary_key=True)
label = db.Column(db.String(160), nullable=False) # e.g. "Register Login"
value = db.Column(db.String(400), nullable=False) # e.g. "#291 / 0000"
notes = db.Column(db.Text)
is_active = db.Column(db.Boolean, default=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@login_manager.user_loader
def load_user(user_id: str) -> Optional[User]:
try:
return db.session.get(User, int(user_id)) # type: ignore[arg-type]
except Exception:
return None
# =============================================================================
# Bootstrap (first run) — Flask 3 safe
# =============================================================================
def ensure_bootstrap_admin():
db.create_all()
if not User.query.first():
admin = User(
username=(BOOTSTRAP_ADMIN_USER or "admin").strip(),
role="admin",
is_active=True,
must_change_password=True,
)
admin.set_password((BOOTSTRAP_ADMIN_PASS or "change_me_now").strip())
db.session.add(admin)
db.session.commit()
app.logger.warning(
"Bootstrapped admin user '%s' (must change password on first login).",
admin.username,
)
with app.app_context():
ensure_bootstrap_admin()
db.create_all() # make sure new tables exist
def seed_store_info():
"""
One-time seed using the info Ben provided.
Safe to call repeatedly: it inserts only when each table is empty.
"""
any_inserted = False
# -----------------------------
# Contacts (priority: 1 = top)
# -----------------------------
if InfoContact.query.count() == 0:
contacts = [
{"name": "Ben", "role": None, "phone": "(806) 395-6770", "priority": 1},
{"name": "Terri A", "role": None, "phone": "(702) 776-1791", "priority": 2},
{"name": "Shireen", "role": None, "phone": "(806) 231-4024", "priority": 3},
{"name": "Charles", "role": None, "phone": "(806) 476-8101", "priority": 4},
{"name": "Matt", "role": None, "phone": "(806) 443-2069", "priority": 5},
{"name": "Navayah", "role": None, "phone": "(806) 729-2385", "priority": 6},
{"name": "Jaydaci", "role": None, "phone": "(806) 433-1715", "priority": 7},
{"name": "Diego", "role": None, "phone": "(620) 621-4402", "priority": 8},
{"name": "Kaley", "role": None, "phone": "(806) 292-9060", "priority": 9},
{"name": "Maika", "role": None, "phone": "(806) 557-8154", "priority": 10},
{"name": "Jacob", "role": None, "phone": "(806) 476-9724", "priority": 11},
{"name": "Tammy", "role": None, "phone": "(806) 523-5935", "priority": 12},
{"name": "Kathleen", "role": None, "phone": "(806) 420-9218", "priority": 13},
]
db.session.bulk_save_objects([InfoContact(**c) for c in contacts])
any_inserted = True
# --------------------------------
# Department extensions directory
# --------------------------------
if DeptExtension.query.count() == 0:
exts = [
("532000", "Service Counter"),
("532005", "Produce"),
("532006", "Market"),
("532106", "Market Office"),
("532007", "Bakery 1"),
("532107", "Bakery 2"),
("532012", "Food Service"),
("532013", "Pharmacy 1"),
("532113", "Pharmacy 2"),
("532213", "Pharmacy 3"),
("532313", "Pharmacy 4"),
("532413", "Pharmacy Office"),
("532014", "Dairy"),
("532018", "Fuel (UE)"),
("532373", "DSD"),
("532200", "Store Office"),
("532500", "Cash Office"),
("532600", "Price Coordinator"),
("532899", "Guest (Lobby)"),
("532950", "Streetside"),
]
db.session.bulk_save_objects([DeptExtension(ext=e, dept=d) for e, d in exts])
any_inserted = True
# -----------------------------
# Support / escalation cards
# -----------------------------
if SupportItem.query.count() == 0:
# Guest Services (primary escalation path)
guest_services_issues = [
"Billing Issues",
"Delivery Creations",
"Missing Loyalty Number",
"Waiting for the POS Result",
"Flybuy Activation",
"Order Status Updates",
"Refund Status",
"App/Web Issues",
"Promo Questions",
"Delivery Questions",
"Adding Rewards",
"Constant Export Failing",
"Winshop Not Working",
"Storefront issues (e.g., wrong prices/descriptions, discontinued items)"
]
db.session.add(SupportItem(
category="Guest Services",
email="guestservices@unitedtexas.com",
phone="855-762-7880",
note="Guest Services should be your first step. The address streetsidehelp@unitedtexas.com is no longer active.",
issues_json=_json.dumps(guest_services_issues),
audience="all",
is_active=True,
))
# Ecommerce Team
ecommerce_resp = [
"Equipment needs: Scales, Walkies, Thermo Guns, etc.",
"Marketing materials needs",
"Slot closures — contact SD and RVP first"
]
db.session.add(SupportItem(
category="Ecommerce Team",
email="team_ecommerce@unitedtexas.com",
phone=None,
note=None,
issues_json=_json.dumps(ecommerce_resp),
audience="all",
is_active=True,
))
# IT Support
it_issues = [
"Issues with POS",
"Issues with handhelds or label printer",
"Needing handhelds",
"Troubles logging into the computer or email"
]
db.session.add(SupportItem(
category="IT Support",
email="support@unitedtexas.com",
phone="806-791-8181 Option 1",
note=None,
issues_json=_json.dumps(it_issues),
audience="all",
is_active=True,
))
any_inserted = True
# -----------------------------
# Admin-only quick notes
# -----------------------------
if LocalSecret.query.count() == 0:
secrets = [
{
"label": "Register Login",
"value": "#291 / 0000",
"notes": "Sign off register at night — DO NOT SHUT OFF.",
},
{
"label": "Computer Password",
"value": "532532",
"notes": "Closing: turn off heater/fan if on.",
},
{
"label": "Phone Password",
"value": "1111",
"notes": None,
},
{
"label": "Bottle Deposit Returns (Volleman Glass empty)",
"value": "13573",
"notes": None,
},
]
db.session.bulk_save_objects([LocalSecret(**s) for s in secrets])
any_inserted = True
if any_inserted:
db.session.commit()
app.logger.warning("Seeded Store Info (contacts/extensions/support/secrets).")
else:
app.logger.info("Store Info seed skipped: tables already contain data.")
with app.app_context():
ensure_bootstrap_admin()
db.create_all()
seed_store_info()
# =============================================================================
# Role guard + utilities
# =============================================================================
from functools import wraps
def admin_required(f):
@wraps(f)
@login_required
def wrapper(*args, **kwargs):
if not current_user.is_admin():
flash("Admins only.", "error")
return redirect(url_for("dashboard"))
return f(*args, **kwargs)
return wrapper
def gen_temp_password(length: int = 14) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
DAY_NAMES = [("mon","Mon"),("tue","Tue"),("wed","Wed"),("thu","Thu"),("fri","Fri"),("sat","Sat"),("sun","Sun")]
def _default_week():
return {k: {"avail": False, "start": "08:00", "end": "17:00"} for k,_ in DAY_NAMES}
def _parse_week_from_form(form):
out = {}
for key,_label in DAY_NAMES:
avail = (form.get(f"{key}_avail") == "on")
start = (form.get(f"{key}_start") or "08:00").strip()[:5]
end = (form.get(f"{key}_end") or "17:00").strip()[:5]
out[key] = {"avail": avail, "start": start, "end": end}
return out
def _validate_iso_date(d: str) -> bool:
try:
datetime.strptime(d, "%Y-%m-%d")
return True
except ValueError:
return False
# =============================================================================
# Base layout + render helper
# =============================================================================
TPL_BASE = """
<!doctype html>
<html lang="en" class="h-full bg-slate-950">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>{{ title or app_brand }}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#eff6ff',
100: '#dbeafe',
600: '#2563eb',
700: '#1d4ed8'
}
}
}
}
}
</script>
<style>
/* Every box prints on its own page */
@media print {
.print-block {
page-break-before: always !important;
break-before: page !important;
}
/* remove first blank page */
.print-block:first-child {
page-break-before: avoid !important;
break-before: avoid !important;
}
}
/* Base print reset */
@media print {
html, body {
background: #ffffff !important;
color: #000000 !important;
}
/* Hide app chrome */
header,
nav,
footer,
.fixed,
#mobileNav,
#navToggle {
display: none !important;
visibility: hidden !important;
}
/* Remove Tailwind background classes */
.bg-slate-950,
.bg-slate-900\/60,
.bg-slate-950\/60 {
background: #ffffff !important;
}
.text-slate-100,
.text-slate-200,
.text-slate-300,
.text-slate-400 {
color: #000000 !important;
}
/* Force links to print black */
a {
color: #000000 !important;
text-decoration: underline !important;
}
/* Expand content full width */
main {
padding: 0 !important;
margin: 0 !important;
max-width: 100% !important;
}
/* Page breaks for nice sections */
.print-section {
page-break-before: always;
}
/* Avoid truncation */
.no-break {
page-break-inside: avoid;
}
}
</style>
</head>
<body class="h-full text-slate-100 pt-[env(safe-area-inset-top)] pb-[env(safe-area-inset-bottom)]">
<div class="min-h-screen">
<!-- Topbar -->
<header class="sticky top-0 z-50 backdrop-blur bg-slate-950/70 border-b border-slate-800">
<div class="max-w-6xl mx-auto px-4">
<div class="h-14 flex items-center justify-between">
<!-- Brand -->
<a href="{{ url_for('dashboard') }}" class="flex items-center gap-2">
<div class="h-7 w-7 rounded bg-brand-600/20 border border-brand-600/30 grid place-items-center font-bold">SC</div>
<span class="font-semibold tracking-wide">{{ app_brand }}</span>
</a>
{% if current_user.is_authenticated %}
<!-- Desktop nav -->
<nav class="hidden md:flex items-center gap-2 text-sm">
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('dashboard') }}">Home</a>
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('availability') }}">Your Availability</a>
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('request_off') }}">Request Off</a>
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('secret_santa') }}">Secret Santa</a>
{% if current_user.role == 'admin' %}
<a class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('admin_users') }}">Admin</a>
{% endif %}
<form method="post" action="{{ url_for('logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-3 py-1.5 rounded-lg bg-brand-600 text-white hover:bg-brand-700" type="submit">Logout</button>
</form>
</nav>
<!-- Mobile menu -->
<button id="navToggle" class="md:hidden inline-flex items-center justify-center h-9 w-9 rounded-lg border border-slate-700 hover:border-slate-500" aria-label="Open menu" aria-expanded="false" aria-controls="mobileNav">
<svg id="navIconOpen" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
<svg id="navIconClose" class="h-5 w-5 hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
{% endif %}
</div>
</div>
<!-- Mobile nav panel -->
<div id="mobileNav" class="md:hidden max-w-6xl mx-auto px-4 pb-3 hidden">
<div class="grid gap-2 text-sm">
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('dashboard') }}">Home</a>
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('availability') }}">Availability</a>
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('request_off') }}">Request Off</a>
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('secret_santa') }}">Secret Santa</a>
{% if current_user.role == 'admin' %}
<a class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500" href="{{ url_for('admin_users') }}">Admin</a>
{% endif %}
<form method="post" action="{{ url_for('logout') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-3 py-2 rounded-lg bg-brand-600 text-white hover:bg-brand-700" type="submit">Logout</button>
</form>
</div>
</div>
</header>
<!-- Main content -->
<main class="max-w-6xl mx-auto px-4 pt-6 pb-[6.5rem]"> <!-- ⬅️ room for bottom nav + iOS inset -->
{% with msgs = get_flashed_messages(with_categories=true) %}
{% if msgs %}
<div class="space-y-2 mb-4">
{% for cat,msg in msgs %}
<div class="rounded-lg px-3 py-2 text-sm border {{ 'bg-emerald-500/15 border-emerald-700 text-emerald-200' if cat=='ok' else 'bg-red-500/15 border-red-700 text-red-200' }}">{{ msg }}</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{{ content|safe }}
</main>
<!-- Bottom nav -->
{% if current_user.is_authenticated %}
<nav class="fixed bottom-0 inset-x-0 z-50 bg-[#111827] border-t border-gray-800 md:hidden pb-[env(safe-area-inset-bottom)]">
<div class="flex justify-around text-sm text-gray-400">
<a href="{{ url_for('dashboard') }}" class="flex flex-col items-center justify-center py-2 px-3 {% if 'dashboard' in request.path %}text-white{% endif %}">
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M3 12l2-2 7-7 7 7 2 2"/></svg>
Home
</a>
<a href="{{ url_for('availability') }}" class="flex flex-col items-center justify-center py-2 px-3 {% if 'availability' in request.path %}text-white{% endif %}">
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M8 7V3m8 4V3M3 11h18"/></svg>
Availability
</a>
<a href="{{ url_for('request_off') }}" class="flex flex-col items-center justify-center py-2 px-3 {% if 'request' in request.path %}text-white{% endif %}">
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M9 5v6m4 0h6"/></svg>
Requests
</a>
<a href="{{ url_for('secret_santa') }}" class="flex flex-col items-center justify-center py-2 px-3 {% if 'secret' in request.path %}text-white{% endif %}">
<svg class="w-5 h-5 mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5z"/></svg>
Gifts
</a>
</div>
</nav>
{% endif %}
<footer class="text-xs text-center text-slate-500 py-6"{{ app_brand }}</footer>
</div>
<!-- Nav toggle -->
<script>
(function () {
const btn = document.getElementById('navToggle');
const menu = document.getElementById('mobileNav');
const openI = document.getElementById('navIconOpen');
const closeI = document.getElementById('navIconClose');
if (!btn || !menu) return;
btn.addEventListener('click', () => {
const isHidden = menu.classList.contains('hidden');
menu.classList.toggle('hidden');
btn.setAttribute('aria-expanded', String(isHidden));
openI.classList.toggle('hidden', !isHidden);
closeI.classList.toggle('hidden', isHidden);
});
})();
</script>
</body>
</html>
"""
TPL_LOGIN_BODY = """
<section class="max-w-md mx-auto">
<h1 class="text-2xl font-bold mb-3">Sign in</h1>
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<form method="post" class="grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Username</span>
<input name="username" autocomplete="username" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Password</span>
<input name="password" type="password" autocomplete="current-password" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<button class="mt-2 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Login</button>
<p class="text-xs text-slate-400">First run? Use the bootstrap admin, then change it.</p>
</form>
</div>
</section>
"""
# Dashboard with punchy, clickable tiles
TPL_DASHBOARD_BODY = """
<section class="grid gap-4">
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 flex items-center justify-between">
<div>
<h1 class="text-xl font-bold">Welcome, {{ user.username }}</h1>
<p class="text-slate-300">Role: <span class="font-semibold uppercase">{{ user.role }}</span></p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-brand-600 opacity-80" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Member tiles -->
<a href="{{ url_for('info_page') }}" class="group relative rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-600 focus:ring-offset-2 focus:ring-offset-slate-950 col-span-full">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Department / Store Info</h3>
<p class="mt-1 text-sm text-slate-400">Phones, extensions, support contacts, and notes.</p>
</div>
</div>
</a>
<a href="{{ url_for('availability') }}" class="group relative rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-brand-600 focus:ring-offset-2 focus:ring-offset-slate-950">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10m-7 4h4m-9 5h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v11a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Your Availability</h3>
<p class="mt-1 text-sm text-slate-400">Set your weekly pattern.</p>
</div>
</div>
</a>
<a href="{{ url_for('request_off') }}" class="group relative rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 17l4 4 4-4m0-5l-4-4-4 4" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Request Off</h3>
<p class="mt-1 text-sm text-slate-400">Ask for specific days off.</p>
</div>
</div>
</a>
<a href="{{ url_for('secret_santa') }}" class="group relative rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.5 0-3 1-3 3 0 1.1.9 2 2 2h2v1a2 2 0 01-2 2H8m4-8c1.5 0 3 1 3 3 0 1.1-.9 2-2 2h-2v1a2 2 0 002 2h4" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Secret Santa</h3>
<p class="mt-1 text-sm text-slate-400">Fill out your gift preferences.</p>
</div>
</div>
</a>
{% if user.role == 'admin' %}
<!-- Admin tiles -->
<a href="{{ url_for('admin_availability') }}" class="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Team Availability</h3>
<p class="mt-1 text-sm text-slate-400">View weekly patterns by user.</p>
</div>
</div>
</a>
<a href="{{ url_for('admin_requests') }}" class="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Time-off Requests</h3>
<p class="mt-1 text-sm text-slate-400">Approve or deny requests.</p>
</div>
</div>
</a>
<a href="{{ url_for('admin_secret_santa') }}" class="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 22s8-4 8-10V5a8 8 0 10-16 0v7c0 6 8 10 8 10z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">Secret Santa (Admin)</h3>
<p class="mt-1 text-sm text-slate-400">Browse entries and export data.</p>
</div>
</div>
</a>
<a href="{{ url_for('admin_users') }}" class="group rounded-2xl border border-slate-800 bg-slate-900/60 p-5 hover:border-brand-600/60 hover:shadow-xl hover:shadow-brand-600/10 transition-all duration-200">
<div class="flex items-center gap-3">
<div class="p-2 rounded-xl bg-brand-600/10 border border-brand-600/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.121 17.804A4 4 0 017 17h10a4 4 0 011.879.804M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<h3 class="text-lg font-semibold">User Management</h3>
<p class="mt-1 text-sm text-slate-400">Create accounts and set roles.</p>
</div>
</div>
</a>
{% endif %}
</div>
</section>
"""
TPL_INFO_PAGE = """
<section class="grid gap-6">
<header class="flex items-center justify-between flex-wrap gap-3 print:hidden">
<h1 class="text-2xl font-bold">Department / Store Info</h1>
{% if current_user.role == 'admin' %}
<a href="{{ url_for('admin_info_console') }}" class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500 text-sm">Admin Console</a>
{% endif %}
<button onclick="window.print()" class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500 text-sm">
Print / PDF
</button>
</header>
<!-- Quick Contacts -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 print-block">
<div class="flex items-center justify-between p-4 border-b border-slate-800">
<h2 class="text-lg font-semibold">Quick Contacts</h2>
<input id="q" placeholder="Filter by name…" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 text-sm" oninput="filterContacts(this.value)">
</div>
<div id="contactsGrid" class="p-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
{% for c in contacts %}
<article class="contact-card rounded-xl border border-slate-800 bg-slate-950/60 p-3" data-key="{{ (c.name ~ ' ' ~ (c.role or '') ~ ' ' ~ (c.phone or '')) | lower }}">
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ c.name }}</h3>
{% if c.priority == 1 %}<span class="text-xs px-2 py-0.5 rounded-full border border-amber-600 text-amber-200">Priority</span>{% endif %}
</div>
{% if c.role %}<p class="text-xs text-slate-400">{{ c.role }}</p>{% endif %}
{% if c.phone %}<p class="mt-1 text-sm font-mono">{{ c.phone }}</p>{% endif %}
</article>
{% endfor %}
</div>
</div>
<!-- Department Extensions -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 overflow-x-auto print-block">
<div class="p-4 border-b border-slate-800">
<h2 class="text-lg font-semibold">Department Extensions</h2>
</div>
<table class="min-w-full text-sm">
<thead class="text-slate-300"><tr><th class="py-2 px-3 text-left font-medium">Extension</th><th class="py-2 px-3 text-left font-medium">Department</th></tr></thead>
<tbody class="divide-y divide-slate-800">
{% for d in exts %}
<tr><td class="py-2 px-3 font-mono">{{ d.ext }}</td><td class="py-2 px-3">{{ d.dept }}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Support Items -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 print-block">
<div class="p-4 border-b border-slate-800"><h2 class="text-lg font-semibold">Support & Escalation</h2></div>
<div class="p-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));">
{% for s in supports %}
<article class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<h3 class="font-semibold">{{ s.category }}</h3>
{% if s.email %}<p class="text-sm text-slate-300 mt-1"><span class="text-slate-400">Email:</span> {{ s.email }}</p>{% endif %}
{% if s.phone %}<p class="text-sm text-slate-300"><span class="text-slate-400">Phone:</span> {{ s.phone }}</p>{% endif %}
{% if s.note %}<p class="text-xs text-slate-400 mt-2">{{ s.note }}</p>{% endif %}
{% if s.issues() %}
<ul class="mt-3 text-sm list-disc pl-5 space-y-1">
{% for it in s.issues() %}<li>{{ it }}</li>{% endfor %}
</ul>
{% endif %}
</article>
{% endfor %}
</div>
</div>
{% if admin_secrets %}
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 print-block">
<div class="p-4 border-b border-slate-800 flex items-center justify-between ">
<h2 class="text-lg font-semibold">Admin Quick Notes</h2>
<span class="text-xs text-slate-400">Visible to admins only</span>
</div>
<div class="p-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));">
{% for s in admin_secrets %}
<article class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<h3 class="font-semibold">{{ s.label }}</h3>
<p class="mt-1 font-mono text-sm">{{ s.value }}</p>
{% if s.notes %}<p class="text-xs text-slate-400 mt-2 whitespace-pre-wrap">{{ s.notes }}</p>{% endif %}
</article>
{% endfor %}
</div>
</div>
{% endif %}
</section>
<script>
function filterContacts(q){
q = (q||'').toLowerCase();
document.querySelectorAll('.contact-card').forEach(c=>{
c.style.display = (c.getAttribute('data-key')||'').includes(q) ? '' : 'none';
});
}
</script>
"""
TPL_ADMIN_INFO = """
<section class="grid gap-8">
<header class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Info Admin Console</h1>
<a href="{{ url_for('info_page') }}" class="text-sm underline">Back to Info</a>
</header>
<!-- Contacts -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold">Contacts</h2>
<form method="post" action="{{ url_for('admin_info_contacts_create') }}" class="mt-3 grid gap-2 sm:grid-cols-[1fr,1fr,160px,100px,100px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="name" placeholder="Name" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" required>
<input name="role" placeholder="Role" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="phone" placeholder="Phone" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="priority" type="number" min="1" max="9" value="5" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700">Add</button>
</form>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-slate-300"><tr><th class="py-2 text-left px-2">Name</th><th class="py-2 text-left px-2">Role</th><th class="py-2 text-left px-2">Phone</th><th class="py-2 text-left px-2">Priority</th><th class="py-2 text-left px-2">Active</th><th class="py-2 text-left px-2">Action</th></tr></thead>
<tbody class="divide-y divide-slate-800">
{% for c in contacts %}
<tr>
<td class="py-2 px-2">{{ c.name }}</td>
<td class="py-2 px-2">{{ c.role or '' }}</td>
<td class="py-2 px-2">{{ c.phone or '' }}</td>
<td class="py-2 px-2">{{ c.priority }}</td>
<td class="py-2 px-2">{{ 'yes' if c.is_active else 'no' }}</td>
<td class="py-2 px-2">
<form method="post" action="{{ url_for('admin_info_contacts_toggle', cid=c.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-2 py-1 rounded border border-slate-700 hover:border-slate-500 text-xs">{{ 'Deactivate' if c.is_active else 'Activate' }}</button>
</form>
<form method="post" action="{{ url_for('admin_info_contacts_delete', cid=c.id) }}" class="inline" onsubmit="return confirm('Delete contact?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-2 py-1 rounded border border-red-700 hover:bg-red-700/10 text-xs">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Department Extensions -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold">Department Extensions</h2>
<form method="post" action="{{ url_for('admin_info_exts_create') }}" class="mt-3 grid gap-2 sm:grid-cols-[180px,1fr,120px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="ext" placeholder="532000" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" required>
<input name="dept" placeholder="Service Counter" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" required>
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700">Add</button>
</form>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-slate-300"><tr><th class="py-2 text-left px-2">Ext</th><th class="py-2 text-left px-2">Department</th><th class="py-2 text-left px-2">Active</th><th class="py-2 text-left px-2">Action</th></tr></thead>
<tbody class="divide-y divide-slate-800">
{% for d in exts %}
<tr>
<td class="py-2 px-2 font-mono">{{ d.ext }}</td>
<td class="py-2 px-2">{{ d.dept }}</td>
<td class="py-2 px-2">{{ 'yes' if d.is_active else 'no' }}</td>
<td class="py-2 px-2">
<form method="post" action="{{ url_for('admin_info_exts_toggle', did=d.id) }}" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-2 py-1 rounded border border-slate-700 hover:border-slate-500 text-xs">{{ 'Deactivate' if d.is_active else 'Activate' }}</button>
</form>
<form method="post" action="{{ url_for('admin_info_exts_delete', did=d.id) }}" class="inline" onsubmit="return confirm('Delete extension?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-2 py-1 rounded border border-red-700 hover:bg-red-700/10 text-xs">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Support Items -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold">Support & Escalation</h2>
<form method="post" action="{{ url_for('admin_info_support_create') }}" class="mt-3 grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="grid sm:grid-cols-3 gap-2">
<input name="category" placeholder="Guest Services" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="email" placeholder="guestservices@…" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="phone" placeholder="806-791-8181 Option 1" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</div>
<textarea name="issues" rows="3" placeholder="One issue per line…" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700"></textarea>
<input name="note" placeholder="Notes / disclaimers (optional)" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<div class="flex items-center gap-2">
<label class="text-sm inline-flex items-center gap-2">
<input type="checkbox" name="admin_only" class="rounded border-slate-700 bg-slate-950">
<span>Admin only</span>
</label>
<button class="ml-auto px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700">Add</button>
</div>
</form>
<div class="mt-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));">
{% for s in supports %}
<article class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<div class="flex items-center justify-between">
<h3 class="font-semibold">{{ s.category }}</h3>
<span class="text-xs text-slate-400">{{ s.audience }}</span>
</div>
{% if s.email %}<p class="text-sm text-slate-300 mt-1">{{ s.email }}</p>{% endif %}
{% if s.phone %}<p class="text-sm text-slate-300">{{ s.phone }}</p>{% endif %}
{% if s.note %}<p class="text-xs text-slate-400 mt-2">{{ s.note }}</p>{% endif %}
{% if s.issues() %}
<ul class="mt-3 text-sm list-disc pl-5 space-y-1">{% for it in s.issues() %}<li>{{ it }}</li>{% endfor %}</ul>
{% endif %}
<div class="mt-3 flex gap-2">
<form method="post" action="{{ url_for('admin_info_support_toggle', sid=s.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-2 py-1 rounded border border-slate-700 hover:border-slate-500 text-xs">{{ 'Deactivate' if s.is_active else 'Activate' }}</button></form>
<form method="post" action="{{ url_for('admin_info_support_delete', sid=s.id) }}" onsubmit="return confirm('Delete support item?');"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-2 py-1 rounded border border-red-700 hover:bg-red-700/10 text-xs">Delete</button></form>
</div>
</article>
{% endfor %}
</div>
</div>
<!-- Admin-Only Quick Notes -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold">Admin Quick Notes (Secrets)</h2>
<form method="post" action="{{ url_for('admin_info_secret_create') }}" class="mt-3 grid gap-2 sm:grid-cols-[1fr,1fr]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="label" placeholder="Register Login" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="value" placeholder="#291 / 0000" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<textarea name="notes" rows="2" placeholder="Notes (optional)" class="sm:col-span-2 px-3 py-2 rounded-lg bg-slate-950 border border-slate-700"></textarea>
<div class="sm:col-span-2"><button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700">Add</button></div>
</form>
<div class="mt-4 grid gap-3" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));">
{% for s in secrets %}
<article class="rounded-xl border border-slate-800 bg-slate-950/60 p-4">
<h3 class="font-semibold">{{ s.label }}</h3>
<p class="mt-1 font-mono text-sm">{{ s.value }}</p>
{% if s.notes %}<p class="text-xs text-slate-400 mt-2 whitespace-pre-wrap">{{ s.notes }}</p>{% endif %}
<div class="mt-3 flex gap-2">
<form method="post" action="{{ url_for('admin_info_secret_toggle', sid=s.id) }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-2 py-1 rounded border border-slate-700 hover:border-slate-500 text-xs">{{ 'Deactivate' if s.is_active else 'Activate' }}</button></form>
<form method="post" action="{{ url_for('admin_info_secret_delete', sid=s.id) }}" onsubmit="return confirm('Delete note?');"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-2 py-1 rounded border border-red-700 hover:bg-red-700/10 text-xs">Delete</button></form>
</div>
</article>
{% endfor %}
</div>
</div>
</section>
"""
TPL_CHANGE_PASSWORD_BODY = """
<h1 class="text-xl font-bold mb-3">Change Password</h1>
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 max-w-md">
<form method="post" class="grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="current_password" type="password" placeholder="Current password" required autocomplete="current-password" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="new_password" type="password" placeholder="New password (min 10 chars)" required autocomplete="new-password" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<input name="confirm_password" type="password" placeholder="Confirm new password" required autocomplete="new-password" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Update</button>
</form>
{% if user.must_change_password %}
<p class="text-xs text-slate-400 mt-2">You must set a new password before continuing.</p>
{% endif %}
</div>
"""
# Member: Request Off page (simple, professional)
TPL_REQUEST_OFF_BODY = """
<section class="grid gap-6">
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h1 class="text-xl font-bold mb-3">Request Time Off</h1>
<p class="text-sm text-slate-400 mb-4">
Use this page to request specific days off. Your leader will approve or deny
the request in the admin view.
</p>
<!-- New request form -->
<form method="post" action="{{ url_for('requests_new') }}" class="grid gap-3 max-w-md">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Date</span>
<input type="date"
name="date"
required
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" />
</label>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Note (optional)</span>
<textarea name="note"
rows="3"
maxlength="240"
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700"
placeholder="Reason, details, or anything the scheduler should know."></textarea>
</label>
<button class="mt-1 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold"
type="submit">
Submit Request
</button>
</form>
</div>
<!-- Existing requests -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 overflow-x-auto">
<h2 class="text-lg font-semibold mb-3">Your Requests</h2>
{% if my_reqs %}
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr>
<th class="py-2 pr-3 text-left font-medium">Date</th>
<th class="py-2 pr-3 text-left font-medium">Status</th>
<th class="py-2 pr-3 text-left font-medium">Note</th>
<th class="py-2 pr-3 text-left font-medium">Requested</th>
<th class="py-2 pr-3 text-left font-medium">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for r in my_reqs %}
<tr>
<td class="py-2 pr-3">{{ r.date }}</td>
<td class="py-2 pr-3">
<span class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs capitalize
{% if r.status == 'pending' %}
bg-amber-500/15 text-amber-200 border border-amber-600
{% elif r.status == 'approved' %}
bg-emerald-500/15 text-emerald-200 border border-emerald-600
{% elif r.status == 'denied' %}
bg-red-500/15 text-red-200 border border-red-600
{% else %}
bg-slate-700/40 text-slate-200 border border-slate-600
{% endif %}">
{{ r.status }}
</span>
</td>
<td class="py-2 pr-3 whitespace-pre-wrap">{{ r.note or '' }}</td>
<td class="py-2 pr-3">
{{ r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '' }}
</td>
<td class="py-2 pr-3">
{% if r.status == 'pending' %}
<form method="post"
action="{{ url_for('requests_cancel', req_id=r.id) }}"
onsubmit="return confirm('Cancel this request?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500 text-xs">
Cancel
</button>
</form>
{% else %}
<span class="text-xs text-slate-500">No action</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-sm text-slate-400">You don't have any requests yet.</p>
{% endif %}
</div>
</section>
"""
# Admin: Users
TPL_ADMIN_USERS_BODY = """
<section class="grid gap-6">
<!-- Create User -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-5">
<h1 class="text-xl font-bold">Users</h1>
<p class="text-slate-400 text-sm mt-1">Temp password will be generated and shown as a flash message.</p>
<form method="post" action="{{ url_for('admin_users_create') }}"
class="mt-4 grid gap-3 sm:grid-cols-[1fr,200px,140px]">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input name="username" placeholder="Username (e.g. email)"
required
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-brand-600" />
<select name="role"
class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-brand-600">
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">
Create
</button>
</form>
</div>
<!-- Users table -->
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-5 overflow-x-auto">
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold">All Users</h2>
<input id="userFilter" placeholder="Filter by username…" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" oninput="filterUsers(this.value)" />
</div>
<table class="min-w-full text-sm mt-3">
<thead class="text-slate-300">
<tr>
<th class="py-2 pr-3 text-left font-medium">ID</th>
<th class="py-2 pr-3 text-left font-medium">Username</th>
<th class="py-2 pr-3 text-left font-medium">Role</th>
<th class="py-2 pr-3 text-left font-medium">Active</th>
<th class="py-2 pr-3 text-left font-medium">Must Change PW</th>
<th class="py-2 pr-3 text-left font-medium">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for u in users %}
<tr id="u{{ u.id }}">
<td class="py-2 pr-3 text-slate-300">{{ u.id }}</td>
<td class="py-2 pr-3 font-medium">{{ u.username }}</td>
<!-- Role updater -->
<td class="py-2 pr-3">
<form method="post" action="{{ url_for('admin_users_role', user_id=u.id) }}" class="flex items-center gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<select name="role"
class="px-2 py-1 rounded bg-slate-950 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-brand-600">
<option value="member" {{ 'selected' if u.role=='member' else '' }}>member</option>
<option value="admin" {{ 'selected' if u.role=='admin' else '' }}>admin</option>
</select>
<button class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" type="submit">
Update
</button>
</form>
</td>
<!-- Active toggle -->
<td class="py-2 pr-3">
<form method="post" action="{{ url_for('admin_users_toggle', user_id=u.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
class="px-3 py-1.5 rounded-lg {{ 'bg-emerald-600 hover:bg-emerald-700' if u.is_active else 'bg-slate-700 hover:bg-slate-600' }}"
type="submit">
{{ 'Active' if u.is_active else 'Inactive' }}
</button>
</form>
</td>
<td class="py-2 pr-3">{{ 'yes' if u.must_change_password else 'no' }}</td>
<!-- Reset -->
<td class="py-2 pr-3">
<form method="post" action="{{ url_for('admin_users_reset', user_id=u.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="px-3 py-1.5 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">
Reset Password
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<script>
function filterUsers(q) {
q = (q || '').toLowerCase();
document.querySelectorAll('tbody tr').forEach(tr => {
const name = tr.querySelector('td:nth-child(2)')?.textContent.toLowerCase() || '';
tr.style.display = name.includes(q) ? '' : 'none';
});
}
</script>
</div>
</section>
"""
# Member: availability only
TPL_AVAIL_BODY = """
<section class="grid gap-6">
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h1 class="text-xl font-bold mb-3">Your Weekly Availability</h1>
<form method="post" class="overflow-x-auto">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr>
<th class="py-2 pr-4 text-left font-medium">Day</th>
<th class="py-2 px-2 text-left font-medium">Available</th>
<th class="py-2 px-2 text-left font-medium">Start</th>
<th class="py-2 px-2 text-left font-medium">End</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for key,label in day_names %}
<tr>
<td class="py-2 pr-4"><strong>{{ label }}</strong></td>
<td class="py-2 px-2"><input type="checkbox" name="{{ key }}_avail" {{ 'checked' if week[key].avail else '' }}></td>
<td class="py-2 px-2"><input type="time" name="{{ key }}_start" value="{{ week[key].start }}" class="px-2 py-1 rounded bg-slate-950 border border-slate-700"></td>
<td class="py-2 px-2"><input type="time" name="{{ key }}_end" value="{{ week[key].end }}" class="px-2 py-1 rounded bg-slate-950 border border-slate-700"></td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-3 flex items-center gap-2">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Save Availability</button>
<span class="text-xs text-slate-400">Tip: Use the links in the header to request a specific day off or fill out Secret Santa.</span>
</div>
</form>
</div>
</section>
"""
# Admin: Team Availability grid (includes ✎ edit link)
TPL_ADMIN_AVAIL_BODY = """
<section class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 overflow-x-auto">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold mb-3">Team Availability (Weekly)</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_availability') }}">Refresh</a>
</div>
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr>
<th class="py-2 text-left">User</th>
{% for _k,lab in day_names %}<th class="py-2 text-left">{{ lab }}</th>{% endfor %}
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for row in rows %}
<tr>
<td class="py-2 font-semibold">
<div class="flex items-center gap-2">
<span>{{ row.username }}</span>
<a href="{{ url_for('admin_availability_edit', user_id=row.user_id) }}"
class="text-xs underline text-slate-400 hover:text-slate-200">✎ Edit</a>
</div>
</td>
{% for k,_lab in day_names %}
{% set d = row.week.get(k) %}
<td class="py-2">
{% if d and d.avail %}
<span class="inline-flex items-center gap-1 rounded-md border border-emerald-700 bg-emerald-500/15 px-2 py-0.5 text-emerald-200">
{{ d.start }}{{ d.end }}
</span>
{% else %}
<span class="text-slate-500">—</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</section>
"""
# Admin: Time-off requests moderation
TPL_ADMIN_REQS_BODY = """
<section class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 overflow-x-auto">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold mb-3">Time-off Requests</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_requests_export') }}">Export CSV</a>
</div>
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr><th class="py-2 text-left">User</th><th class="py-2 text-left">Date</th><th class="py-2 text-left">Status</th><th class="py-2 text-left">Note</th><th class="py-2 text-left">Requested</th><th class="py-2 text-left">Action</th></tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for r in rows %}
<tr>
<td class="py-2">{{ r.user.username }}</td>
<td class="py-2">{{ r.date }}</td>
<td class="py-2 capitalize">{{ r.status }}</td>
<td class="py-2">{{ r.note or '' }}</td>
<td class="py-2">{{ r.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="py-2">
{% if r.status == 'pending' %}
<div class="flex gap-2">
<form method="post" action="{{ url_for('admin_requests_action', req_id=r.id, action='approve') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-3 py-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-700" type="submit">Approve</button></form>
<form method="post" action="{{ url_for('admin_requests_action', req_id=r.id, action='deny') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="px-3 py-1.5 rounded-lg border border-slate-700 hover:border-slate-500" type="submit">Deny</button></form>
</div>
{% else %}<span class="text-slate-400">—</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
"""
# Admin: Wishlists table
TPL_ADMIN_WISHLISTS_BODY = """
<section class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4 overflow-x-auto">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold mb-3">Secret Santa / Wishlists</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_secret_santa_export') }}">Export Secret Santa CSV</a>
</div>
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr><th class="py-2 text-left">User</th><th class="py-2 text-left">Last Updated</th><th class="py-2 text-left">Wishlist</th></tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for w in rows %}
<tr>
<td class="py-2">{{ w.user.username }}</td>
<td class="py-2">{{ w.updated_at.strftime('%Y-%m-%d %H:%M') if w.updated_at else '' }}</td>
<td class="py-2 whitespace-pre-wrap">{{ w.wishlist }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
"""
TPL_SECRET_SANTA_BODY = """
<section class="grid gap-6">
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h1 class="text-xl font-bold mb-3">Secret Santa</h1>
<form method="post" class="grid gap-3 max-w-2xl">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Name (First and Last)</span>
<input name="full_name" value="{{ form.full_name or '' }}" required class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Age</span>
<input type="number" min="0" name="age" value="{{ form.age or '' }}" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Birthday</span>
<input type="date" name="birthday" value="{{ form.birthday or '' }}" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
</div>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">List of Hobbies</span>
<textarea name="hobbies" rows="3" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700" placeholder="e.g., fishing, cooking, gaming">{{ form.hobbies or '' }}</textarea>
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Favorite Gift card</span>
<input name="gift_card" value="{{ form.gift_card or '' }}" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
<label class="grid gap-1 text-sm">
<span class="text-slate-300">Favorite Type of movie</span>
<input name="fav_movie" value="{{ form.fav_movie or '' }}" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700">
</label>
</div>
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox" name="jewelry" value="yes" {% if form.jewelry %}checked{% endif %} class="rounded border-slate-700 bg-slate-950">
<span class="text-slate-300">Jewelry (Yes/No)</span>
</label>
<div class="pt-2">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Save</button>
</div>
</form>
</div>
</section>
"""
# Admin: Secret Santa cards
TPL_ADMIN_SS_BODY = """
<section class="grid gap-6">
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold tracking-tight">Secret Santa</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_secret_santa_export') }}">Export CSV</a>
</div>
<div>
<input id="ssFilter"
placeholder="Filter by username or name…"
class="w-full sm:w-[28rem] px-4 py-2.5 rounded-xl bg-slate-950 border border-slate-700"
oninput="filterSS(this.value)">
</div>
<div id="ssGrid" class="grid gap-6"
style="grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));">
{% for e in rows %}
<article
class="ss-card group rounded-3xl border border-slate-800 bg-slate-900/60 p-6 hover:border-brand-600/50 hover:shadow-xl hover:shadow-brand-600/10 transition"
data-keywords="{{ (e.user.username ~ ' ' ~ (e.full_name or '') ) | lower }}"
>
<header class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<h3 class="text-lg font-semibold leading-6">{{ e.full_name or '' }}</h3>
<p class="text-xs text-slate-400 break-all leading-5">{{ e.user.username }}</p>
</div>
<span
class="shrink-0 inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs
{{ 'border-emerald-700 bg-emerald-500/15 text-emerald-200' if e.jewelry else 'border-slate-700 bg-slate-800/60 text-slate-300' }}">
{{ 'Jewelry: Yes' if e.jewelry else 'Jewelry: No' }}
</span>
</header>
<div class="mt-5 grid grid-cols-2 gap-x-6 gap-y-3 text-sm leading-6">
<div>
<p class="text-slate-400 text-xs">Age</p>
<p class="font-medium">{{ e.age or '' }}</p>
</div>
<div>
<p class="text-slate-400 text-xs">Birthday</p>
<p class="font-medium">{{ e.birthday or '' }}</p>
</div>
<div class="col-span-2">
<p class="text-slate-400 text-xs">Favorite Gift Card</p>
<p class="font-medium">{{ e.gift_card or '' }}</p>
</div>
<div class="col-span-2">
<p class="text-slate-400 text-xs">Favorite Type of Movie</p>
<p class="font-medium">{{ e.fav_movie or '' }}</p>
</div>
<div class="col-span-2">
<p class="text-slate-400 text-xs">Hobbies</p>
<p class="whitespace-pre-wrap">{{ e.hobbies or '' }}</p>
</div>
</div>
<footer class="mt-6 pt-4 border-t border-slate-800 flex items-center justify-between text-xs text-slate-400 leading-6">
<span>Updated {{ e.updated_at.strftime('%Y-%m-%d %H:%M') if e.updated_at else '' }}</span>
<span class="opacity-70">ID #{{ e.id }}</span>
</footer>
</article>
{% endfor %}
</div>
{% if not rows %}
<p class="text-slate-400 text-sm">No Secret Santa entries yet.</p>
{% endif %}
</section>
<script>
function filterSS(q){
q = (q || '').toLowerCase();
document.querySelectorAll('.ss-card').forEach(card=>{
const keys = card.getAttribute('data-keywords') || '';
card.style.display = keys.includes(q) ? '' : 'none';
});
}
</script>
"""
# Admin: Availability editor (NEW)
TPL_ADMIN_AVAIL_EDIT_BODY = """
<section class="grid gap-6">
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold">Edit User Availability</h1>
<a class="text-sm underline text-slate-300" href="{{ url_for('admin_availability') }}">Back to Team Grid</a>
</div>
<!-- User picker -->
<form method="get" class="flex items-center gap-2 max-w-xl">
<label class="text-sm text-slate-300">User</label>
<select name="user_id" class="px-3 py-2 rounded-lg bg-slate-950 border border-slate-700 w-full">
<option value="">— Select a user —</option>
{% for u in users %}
<option value="{{ u.id }}" {{ 'selected' if (user and user.id==u.id) else '' }}>
{{ u.username }}
</option>
{% endfor %}
</select>
<button class="px-3 py-2 rounded-lg border border-slate-700 hover:border-slate-500">Load</button>
</form>
{% if user %}
<div class="rounded-2xl border border-slate-800 bg-slate-900/60 p-4">
<h2 class="text-lg font-semibold mb-3">Weekly Pattern for: <span class="font-mono">{{ user.username }}</span></h2>
<form method="post" class="overflow-x-auto">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="user_id" value="{{ user.id }}">
<table class="min-w-full text-sm">
<thead class="text-slate-300">
<tr>
<th class="py-2 pr-4 text-left font-medium">Day</th>
<th class="py-2 px-2 text-left font-medium">Available</th>
<th class="py-2 px-2 text-left font-medium">Start</th>
<th class="py-2 px-2 text-left font-medium">End</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for key,label in day_names %}
<tr>
<td class="py-2 pr-4"><strong>{{ label }}</strong></td>
<td class="py-2 px-2"><input type="checkbox" name="{{ key }}_avail" {{ 'checked' if week[key].avail else '' }}></td>
<td class="py-2 px-2"><input type="time" name="{{ key }}_start" value="{{ week[key].start }}" class="px-2 py-1 rounded bg-slate-950 border border-slate-700"></td>
<td class="py-2 px-2"><input type="time" name="{{ key }}_end" value="{{ week[key].end }}" class="px-2 py-1 rounded bg-slate-950 border border-slate-700"></td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mt-4 flex flex-wrap gap-2">
<button class="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 font-semibold" type="submit">Save for {{ user.username }}</button>
<button type="button" onclick="fillAll('06:00','19:00')" class="px-4 py-2 rounded-lg border border-slate-700 hover:border-slate-500">Quick-fill 06:0019:00</button>
<button type="button" onclick="setAllOff()" class="px-4 py-2 rounded-lg border border-slate-700 hover:border-slate-500">Set All Off</button>
</div>
</form>
</div>
<script>
function fillAll(s,e){
const days = ['mon','tue','wed','thu','fri','sat','sun'];
days.forEach(d=>{
document.querySelector(`input[name="${d}_avail"]`).checked = true;
document.querySelector(`input[name="${d}_start"]`).value = s;
document.querySelector(`input[name="${d}_end"]`).value = e;
});
}
function setAllOff(){
const days = ['mon','tue','wed','thu','fri','sat','sun'];
days.forEach(d=>{
document.querySelector(`input[name="${d}_avail"]`).checked = false;
document.querySelector(`input[name="${d}_start"]`).value = '08:00';
document.querySelector(`input[name="${d}_end"]`).value = '17:00';
});
}
</script>
{% endif %}
</section>
"""
def render_page(body_tpl: str, **ctx):
"""Render a body template into the base layout."""
body_html = render_template_string(body_tpl, **ctx)
title = ctx.get("title") or APP_BRAND
return render_template_string(TPL_BASE, title=title, content=body_html, app_brand=APP_BRAND)
# =============================================================================
# Routes: Auth + Dashboard
# =============================================================================
@app.route("/")
def index():
if current_user.is_authenticated:
return redirect(url_for("dashboard"))
return redirect(url_for("login"))
@limiter.limit("5/minute")
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
user = User.query.filter_by(username=username).first()
if not user or not user.is_active or not user.check_password(password):
flash("Invalid credentials.", "error")
return redirect(url_for("login"))
login_user(user, remember=True)
if user.must_change_password:
flash("Please set a new password to continue.", "ok")
return redirect(url_for("change_password"))
return redirect(url_for("dashboard"))
return render_page(TPL_LOGIN_BODY, title="Login")
@app.post("/logout")
@login_required
def logout():
logout_user()
flash("Signed out.", "ok")
return redirect(url_for("login"))
@app.route("/dashboard")
@login_required
def dashboard():
return render_page(TPL_DASHBOARD_BODY, title="Dashboard", user=current_user)
@app.route("/change-password", methods=["GET", "POST"])
@login_required
def change_password():
if request.method == "POST":
curr = request.form.get("current_password") or ""
new1 = request.form.get("new_password") or ""
new2 = request.form.get("confirm_password") or ""
if not current_user.check_password(curr):
flash("Current password is incorrect.", "error")
return redirect(url_for("change_password"))
if len(new1) < 10:
flash("Choose a stronger password (min 10 chars).", "error")
return redirect(url_for("change_password"))
if new1 != new2:
flash("Passwords do not match.", "error")
return redirect(url_for("change_password"))
current_user.set_password(new1)
current_user.must_change_password = False
db.session.commit()
flash("Password updated.", "ok")
return redirect(url_for("dashboard"))
return render_page(TPL_CHANGE_PASSWORD_BODY, title="Change Password", user=current_user)
# =============================================================================
# Secret Santa
# =============================================================================
@app.route("/secret-santa", methods=["GET", "POST"])
@login_required
def secret_santa():
entry = SecretSantaEntry.query.filter_by(user_id=current_user.id).first()
if request.method == "POST":
full_name = (request.form.get("full_name") or "").strip()
if not full_name:
flash("Name is required.", "error")
return redirect(url_for("secret_santa"))
age_raw = request.form.get("age")
birthday = (request.form.get("birthday") or "").strip()
hobbies = (request.form.get("hobbies") or "").strip()
gift_card = (request.form.get("gift_card") or "").strip()
fav_movie = (request.form.get("fav_movie") or "").strip()
jewelry = request.form.get("jewelry") == "yes"
try:
age = int(age_raw) if (age_raw or "").strip() else None
except ValueError:
age = None
if entry:
entry.full_name = full_name
entry.age = age
entry.birthday = birthday or None
entry.hobbies = hobbies
entry.gift_card = gift_card
entry.fav_movie = fav_movie
entry.jewelry = jewelry
else:
entry = SecretSantaEntry(
user_id=current_user.id,
full_name=full_name, age=age, birthday=birthday or None,
hobbies=hobbies, gift_card=gift_card, fav_movie=fav_movie,
jewelry=jewelry,
)
db.session.add(entry)
db.session.commit()
flash("Secret Santa saved.", "ok")
return redirect(url_for("secret_santa"))
form = {
"full_name": entry.full_name if entry else current_user.username,
"age": entry.age if entry else "",
"birthday": entry.birthday if entry else "",
"hobbies": entry.hobbies if entry else "",
"gift_card": entry.gift_card if entry else "",
"fav_movie": entry.fav_movie if entry else "",
"jewelry": bool(entry.jewelry) if (entry and entry.jewelry is not None) else False,
}
return render_page(TPL_SECRET_SANTA_BODY, title="Secret Santa", form=form)
@app.get("/admin/secret-santa")
@admin_required
def admin_secret_santa():
rows = (SecretSantaEntry.query
.join(User, SecretSantaEntry.user_id == User.id)
.order_by(User.username.asc())
.all())
return render_page(TPL_ADMIN_SS_BODY, title="Secret Santa", rows=rows)
# =============================================================================
# Request Off
# =============================================================================
@app.get("/request-off")
@login_required
def request_off():
my_reqs = (TimeOffRequest.query
.filter_by(user_id=current_user.id)
.order_by(TimeOffRequest.created_at.desc())
.all())
return render_page(TPL_REQUEST_OFF_BODY, title="Request Off", my_reqs=my_reqs)
@app.post("/requests/new")
@login_required
def requests_new():
date = (request.form.get("date") or "").strip()
note = (request.form.get("note") or "").strip()[:240]
if not date or not _validate_iso_date(date):
flash("Please choose a valid date (YYYY-MM-DD).", "error")
return redirect(url_for("request_off"))
r = TimeOffRequest(user_id=current_user.id, date=date, note=note, status="pending")
db.session.add(r); db.session.commit()
flash("Request submitted.", "ok")
return redirect(url_for("request_off"))
@app.post("/requests/<int:req_id>/cancel")
@login_required
def requests_cancel(req_id: int):
r = db.session.get(TimeOffRequest, req_id)
if not r or r.user_id != current_user.id:
flash("Not found.", "error"); return redirect(url_for("request_off"))
if r.status != "pending":
flash("Only pending requests can be cancelled.", "error"); return redirect(url_for("request_off"))
r.status = "cancelled"; r.decided_at = datetime.utcnow(); db.session.commit()
flash("Request cancelled.", "ok"); return redirect(url_for("request_off"))
# =============================================================================
# Availability (member self-service)
# =============================================================================
@app.route("/availability", methods=["GET", "POST"])
@login_required
def availability():
aw = AvailabilityWeekly.query.filter_by(user_id=current_user.id).first()
if request.method == "POST":
week = _parse_week_from_form(request.form)
if not aw:
aw = AvailabilityWeekly(user_id=current_user.id, data_json=_json.dumps(week))
db.session.add(aw)
else:
aw.data_json = _json.dumps(week)
db.session.commit()
flash("Availability saved.", "ok")
return redirect(url_for("availability"))
week = _default_week()
if aw and aw.data_json:
try:
data = _json.loads(aw.data_json) or {}
for k,_ in DAY_NAMES:
if k in data:
week[k] = {
"avail": bool(data[k].get("avail")),
"start": (data[k].get("start") or "08:00")[:5],
"end": (data[k].get("end") or "17:00")[:5],
}
except Exception as e:
app.logger.warning("Failed to parse availability JSON for user %s: %s", current_user.id, e)
return render_page(TPL_AVAIL_BODY, title="Your Availability",
day_names=DAY_NAMES, week=week)
# =============================================================================
# Admin: Availability grid + EDITOR (NEW)
# =============================================================================
@app.get("/admin/availability")
@admin_required
def admin_availability():
rows = []
users = User.query.filter_by(is_active=True).order_by(User.username.asc()).all()
for u in users:
aw = AvailabilityWeekly.query.filter_by(user_id=u.id).first()
wk = _default_week()
if aw and aw.data_json:
try:
data = _json.loads(aw.data_json) or {}
for k,_ in DAY_NAMES:
if k in data:
wk[k] = {
"avail": bool(data[k].get("avail")),
"start": (data[k].get("start") or "08:00")[:5],
"end": (data[k].get("end") or "17:00")[:5],
}
except Exception as e:
app.logger.warning("Failed to parse availability JSON for user %s: %s", u.id, e)
rows.append({"user_id": u.id, "username": u.username, "week": wk})
return render_page(TPL_ADMIN_AVAIL_BODY, title="Team Availability", rows=rows, day_names=DAY_NAMES)
@app.route("/admin/availability/edit", methods=["GET", "POST"])
@admin_required
def admin_availability_edit():
# Save for a specific user
if request.method == "POST":
try:
target_id = int(request.form.get("user_id") or "0")
except ValueError:
flash("Invalid user.", "error")
return redirect(url_for("admin_availability_edit"))
target = db.session.get(User, target_id)
if not target:
flash("User not found.", "error")
return redirect(url_for("admin_availability_edit"))
week = _parse_week_from_form(request.form)
aw = AvailabilityWeekly.query.filter_by(user_id=target.id).first()
if not aw:
aw = AvailabilityWeekly(user_id=target.id, data_json=_json.dumps(week))
db.session.add(aw)
else:
aw.data_json = _json.dumps(week)
db.session.commit()
flash(f"Availability saved for {target.username}.", "ok")
return redirect(url_for("admin_availability_edit", user_id=target.id))
# GET: pick a user then load their week
users = User.query.filter_by(is_active=True).order_by(User.username.asc()).all()
user_id = request.args.get("user_id", type=int)
user = db.session.get(User, user_id) if user_id else None
week = _default_week()
if user:
aw = AvailabilityWeekly.query.filter_by(user_id=user.id).first()
if aw and aw.data_json:
try:
data = _json.loads(aw.data_json) or {}
for k,_ in DAY_NAMES:
if k in data:
week[k] = {
"avail": bool(data[k].get("avail")),
"start": (data[k].get("start") or "08:00")[:5],
"end": (data[k].get("end") or "17:00")[:5],
}
except Exception as e:
app.logger.warning("Admin edit: bad JSON for user %s: %s", user.id, e)
return render_page(TPL_ADMIN_AVAIL_EDIT_BODY,
title="Edit Availability",
users=users, user=user, day_names=DAY_NAMES, week=week)
# =============================================================================
# Admin: Requests + Wishlists
# =============================================================================
@app.get("/admin/requests")
@admin_required
def admin_requests():
rows = (TimeOffRequest.query
.join(User, TimeOffRequest.user_id == User.id)
.order_by(TimeOffRequest.status.asc(), TimeOffRequest.created_at.desc())
.all())
return render_page(TPL_ADMIN_REQS_BODY, title="Time-off Requests", rows=rows)
@app.post("/admin/requests/<int:req_id>/<action>")
@admin_required
def admin_requests_action(req_id: int, action: str):
r = db.session.get(TimeOffRequest, req_id)
if not r:
flash("Request not found.", "error"); return redirect(url_for("admin_requests"))
if r.status != "pending":
flash("Request is already decided.", "error"); return redirect(url_for("admin_requests"))
if action not in ("approve","deny"):
flash("Invalid action.", "error"); return redirect(url_for("admin_requests"))
r.status = "approved" if action == "approve" else "denied"
r.decided_at = datetime.utcnow()
db.session.commit()
flash(f"Request {action}d.", "ok"); return redirect(url_for("admin_requests"))
@app.get("/admin/wishlists")
@admin_required
def admin_wishlists():
rows = (Wishlist.query
.join(User, Wishlist.user_id == User.id)
.order_by(User.username.asc())
.all())
return render_page(TPL_ADMIN_WISHLISTS_BODY, title="Wishlists", rows=rows)
# =============================================================================
# Exports
# =============================================================================
@app.get("/admin/secret-santa/export.csv")
@admin_required
def admin_secret_santa_export():
rows = (SecretSantaEntry.query.join(User).order_by(User.username.asc()).all())
sio = StringIO()
w = csv.writer(sio)
w.writerow(["username","full_name","age","birthday","hobbies","gift_card","fav_movie","jewelry","updated_at"])
for e in rows:
w.writerow([
e.user.username, e.full_name, e.age or "", e.birthday or "", e.hobbies or "",
e.gift_card or "", e.fav_movie or "", "Yes" if e.jewelry else "No",
e.updated_at.isoformat() if e.updated_at else ""
])
resp = make_response(sio.getvalue())
resp.headers["Content-Type"] = "text/csv; charset=utf-8"
resp.headers["Content-Disposition"] = "attachment; filename=secret_santa.csv"
return resp
@app.get("/admin/requests/export.csv")
@admin_required
def admin_requests_export():
rows = TimeOffRequest.query.join(User).order_by(TimeOffRequest.created_at.desc()).all()
sio = StringIO()
w = csv.writer(sio)
w.writerow(["username","date","status","note","created_at","decided_at"])
for r in rows:
w.writerow([
r.user.username, r.date, r.status, r.note or "",
r.created_at.isoformat(),
r.decided_at.isoformat() if r.decided_at else ""
])
resp = make_response(sio.getvalue())
resp.headers["Content-Type"] = "text/csv; charset=utf-8"
resp.headers["Content-Disposition"] = "attachment; filename=time_off_requests.csv"
return resp
# =============================================================================
# Admin: Users CRUD
# =============================================================================
@app.get("/admin/users")
@admin_required
def admin_users():
users = User.query.order_by(User.created_at.desc()).all()
return render_page(TPL_ADMIN_USERS_BODY, title="Users", users=users)
@app.post("/admin/users/create")
@admin_required
def admin_users_create():
username = (request.form.get("username") or "").strip()
role = (request.form.get("role") or "member").strip().lower()
role = "admin" if role == "admin" else "member"
if not username:
flash("Username is required.", "error")
return redirect(url_for("admin_users"))
if User.query.filter_by(username=username).first():
flash("Username already exists.", "error")
return redirect(url_for("admin_users"))
temp_pw = gen_temp_password()
u = User(username=username, role=role, is_active=True, must_change_password=True)
u.set_password(temp_pw)
db.session.add(u)
db.session.commit()
flash(f"User '{username}' created with temporary password: {temp_pw}", "ok")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/reset")
@admin_required
def admin_users_reset(user_id: int):
u = db.session.get(User, user_id)
if not u:
flash("User not found.", "error")
return redirect(url_for("admin_users"))
temp_pw = gen_temp_password()
u.set_password(temp_pw)
u.must_change_password = True
db.session.commit()
flash(f"Password reset for '{u.username}'. New temp: {temp_pw}", "ok")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/role")
@admin_required
def admin_users_role(user_id: int):
u = db.session.get(User, user_id)
if not u:
flash("User not found.", "error")
return redirect(url_for("admin_users"))
new_role = (request.form.get("role") or "member").lower()
u.role = "admin" if new_role == "admin" else "member"
db.session.commit()
flash(f"Role updated for '{u.username}'{u.role}", "ok")
return redirect(url_for("admin_users"))
@app.post("/admin/users/<int:user_id>/toggle")
@admin_required
def admin_users_toggle(user_id: int):
u = db.session.get(User, user_id)
if not u:
flash("User not found.", "error")
return redirect(url_for("admin_users"))
# Prevent locking yourself out of the last admin
if u.id == current_user.id and u.role == "admin":
admins = User.query.filter_by(role="admin", is_active=True).count()
if admins <= 1:
flash("You are the last active admin; cannot deactivate.", "error")
return redirect(url_for("admin_users"))
u.is_active = not u.is_active
db.session.commit()
flash(f"User '{u.username}' active={u.is_active}", "ok")
return redirect(url_for("admin_users"))
# =============================================================================
# JSON API + Health
# =============================================================================
@app.get("/api/me")
@login_required
def api_me():
u: User = current_user # type: ignore
return jsonify(
id=u.id, username=u.username, role=u.role,
must_change_password=u.must_change_password, is_active=u.is_active
)
# ---------------------------
# Info: user view
# ---------------------------
@app.get("/info")
@login_required
def info_page():
contacts = (InfoContact.query
.filter_by(is_active=True)
.order_by(InfoContact.priority.asc(), InfoContact.name.asc())
.all())
exts = (DeptExtension.query
.filter_by(is_active=True)
.order_by(DeptExtension.ext.asc())
.all())
supports = (SupportItem.query
.filter_by(is_active=True)
.filter((SupportItem.audience == "all") | (SupportItem.audience == ("admin" if current_user.is_admin() else "zzz")))
.order_by(SupportItem.category.asc())
.all())
admin_secrets = []
if current_user.is_admin():
admin_secrets = (LocalSecret.query
.filter_by(is_active=True)
.order_by(LocalSecret.updated_at.desc())
.all())
return render_page(TPL_INFO_PAGE, title="Store Info",
contacts=contacts, exts=exts, supports=supports, admin_secrets=admin_secrets)
# ---------------------------
# Info: admin console
# ---------------------------
@app.get("/admin/info")
@admin_required
def admin_info_console():
contacts = InfoContact.query.order_by(InfoContact.priority.asc(), InfoContact.name.asc()).all()
exts = DeptExtension.query.order_by(DeptExtension.ext.asc()).all()
supports = SupportItem.query.order_by(SupportItem.category.asc()).all()
secrets = LocalSecret.query.order_by(LocalSecret.updated_at.desc()).all()
return render_page(TPL_ADMIN_INFO, title="Info Admin",
contacts=contacts, exts=exts, supports=supports, secrets=secrets)
# ---- Contacts CRUD
@app.post("/admin/info/contacts/create")
@admin_required
def admin_info_contacts_create():
name = (request.form.get("name") or "").strip()
role = (request.form.get("role") or "").strip()
phone = (request.form.get("phone") or "").strip()
priority = request.form.get("priority", type=int) or 5
if not name:
flash("Name is required.", "error"); return redirect(url_for("admin_info_console"))
db.session.add(InfoContact(name=name, role=role, phone=phone, priority=max(1, min(priority, 9))))
db.session.commit()
flash("Contact added.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/contacts/<int:cid>/toggle")
@admin_required
def admin_info_contacts_toggle(cid:int):
c = db.session.get(InfoContact, cid)
if not c: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
c.is_active = not c.is_active; db.session.commit()
flash("Contact updated.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/contacts/<int:cid>/delete")
@admin_required
def admin_info_contacts_delete(cid:int):
c = db.session.get(InfoContact, cid)
if not c: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
db.session.delete(c); db.session.commit()
flash("Contact deleted.", "ok"); return redirect(url_for("admin_info_console"))
# ---- Dept Extensions CRUD
@app.post("/admin/info/exts/create")
@admin_required
def admin_info_exts_create():
ext = (request.form.get("ext") or "").strip()
dept = (request.form.get("dept") or "").strip()
if not ext or not dept:
flash("Extension and department are required.", "error"); return redirect(url_for("admin_info_console"))
db.session.add(DeptExtension(ext=ext, dept=dept))
db.session.commit()
flash("Extension added.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/exts/<int:did>/toggle")
@admin_required
def admin_info_exts_toggle(did:int):
d = db.session.get(DeptExtension, did)
if not d: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
d.is_active = not d.is_active; db.session.commit()
flash("Extension updated.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/exts/<int:did>/delete")
@admin_required
def admin_info_exts_delete(did:int):
d = db.session.get(DeptExtension, did)
if not d: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
db.session.delete(d); db.session.commit()
flash("Extension deleted.", "ok"); return redirect(url_for("admin_info_console"))
# ---- Support Items CRUD
@app.post("/admin/info/support/create")
@admin_required
def admin_info_support_create():
cat = (request.form.get("category") or "").strip()
email = (request.form.get("email") or "").strip()
phone = (request.form.get("phone") or "").strip()
note = (request.form.get("note") or "").strip()
admin_only = request.form.get("admin_only") == "on"
issues_text = (request.form.get("issues") or "").strip()
issues = [ln.strip() for ln in issues_text.splitlines() if ln.strip()]
if not cat:
flash("Category is required.", "error"); return redirect(url_for("admin_info_console"))
db.session.add(SupportItem(category=cat, email=email, phone=phone, note=note,
issues_json=_json.dumps(issues), audience=("admin" if admin_only else "all")))
db.session.commit()
flash("Support item added.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/support/<int:sid>/toggle")
@admin_required
def admin_info_support_toggle(sid:int):
s = db.session.get(SupportItem, sid)
if not s: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
s.is_active = not s.is_active; db.session.commit()
flash("Support item updated.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/support/<int:sid>/delete")
@admin_required
def admin_info_support_delete(sid:int):
s = db.session.get(SupportItem, sid)
if not s: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
db.session.delete(s); db.session.commit()
flash("Support item deleted.", "ok"); return redirect(url_for("admin_info_console"))
# ---- Secrets CRUD (admin-only)
@app.post("/admin/info/secret/create")
@admin_required
def admin_info_secret_create():
label = (request.form.get("label") or "").strip()
value = (request.form.get("value") or "").strip()
notes = (request.form.get("notes") or "").strip()
if not label or not value:
flash("Label and value are required.", "error"); return redirect(url_for("admin_info_console"))
db.session.add(LocalSecret(label=label, value=value, notes=notes))
db.session.commit()
flash("Secret added.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/secret/<int:sid>/toggle")
@admin_required
def admin_info_secret_toggle(sid:int):
s = db.session.get(LocalSecret, sid)
if not s: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
s.is_active = not s.is_active; db.session.commit()
flash("Secret updated.", "ok"); return redirect(url_for("admin_info_console"))
@app.post("/admin/info/secret/<int:sid>/delete")
@admin_required
def admin_info_secret_delete(sid:int):
s = db.session.get(LocalSecret, sid)
if not s: flash("Not found.", "error"); return redirect(url_for("admin_info_console"))
db.session.delete(s); db.session.commit()
flash("Secret deleted.", "ok"); return redirect(url_for("admin_info_console"))
@app.get("/healthz")
def healthz():
return {"ok": True}, 200
# =============================================================================
# Error handlers
# =============================================================================
@app.errorhandler(403)
def error_403(e):
return render_page("<section><h1 class='text-xl font-bold'>Forbidden</h1><p class='text-slate-300 mt-2'>You don't have access to this resource.</p></section>"), 403
@app.errorhandler(404)
def error_404(e):
return render_page("<section><h1 class='text-xl font-bold'>Not Found</h1><p class='text-slate-300 mt-2'>We couldn't find what you were looking for.</p></section>"), 404
@app.errorhandler(500)
def error_500(e):
return render_page("<section><h1 class='text-xl font-bold'>Server Error</h1><p class='text-slate-300 mt-2'>Something went wrong. Please try again.</p></section>"), 500
# =============================================================================
# Main
# =============================================================================
if __name__ == "__main__":
app.run(debug=True, port=5000)