Fix web/ startup errors and sync with boilerplate

- Load .env via python-dotenv in core.py
- Skip analytics DB open if file doesn't exist
- Guard dashboard analytics calls when DB not available
- Namespace admin templates under admin/ to avoid blueprint conflicts
- Add dev-login routes for user and admin (DEBUG only)
- Update .copier-answers.yml src_path to GitLab remote

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-19 20:37:44 +01:00
parent fa6f3c70dd
commit 6dac8570ad
18 changed files with 615 additions and 75 deletions

View File

@@ -189,6 +189,16 @@ def admin_required(f):
# Routes
# =============================================================================
@bp.route("/dev-login")
async def dev_login():
"""Instant admin login for development. Only works in DEBUG mode."""
if not config.DEBUG:
return "Not available", 404
session["is_admin"] = True
await flash("Dev admin login.", "success")
return redirect(url_for("admin.index"))
@bp.route("/login", methods=["GET", "POST"])
@csrf_protect
async def login():
@@ -213,7 +223,7 @@ async def login():
else:
await flash("Invalid password.", "error")
return await render_template("login.html")
return await render_template("admin/login.html")
@bp.route("/logout", methods=["POST"])
@@ -234,7 +244,7 @@ async def index():
failed_tasks = await get_failed_tasks()
return await render_template(
"index.html",
"admin/index.html",
stats=stats,
recent_users=recent_users,
failed_tasks=failed_tasks,
@@ -253,7 +263,7 @@ async def users():
user_list = await get_users(limit=per_page, offset=offset, search=search or None)
return await render_template(
"users.html",
"admin/users.html",
users=user_list,
search=search,
page=page,
@@ -269,7 +279,7 @@ async def user_detail(user_id: int):
await flash("User not found.", "error")
return redirect(url_for("admin.users"))
return await render_template("user_detail.html", user=user)
return await render_template("admin/user_detail.html", user=user)
@bp.route("/users/<int:user_id>/impersonate", methods=["POST"])
@@ -308,7 +318,7 @@ async def tasks():
failed = await get_failed_tasks()
return await render_template(
"tasks.html",
"admin/tasks.html",
tasks=task_list,
failed_tasks=failed,
)

View File

@@ -33,10 +33,14 @@ _conn: duckdb.DuckDBPyConnection | None = None
def open_analytics_db() -> None:
"""Open read-only DuckDB connection."""
"""Open read-only DuckDB connection. No-op if the database file does not exist."""
global _conn
import pathlib
db_path = os.getenv("DUCKDB_PATH", "")
assert db_path, "DUCKDB_PATH environment variable must be set"
if not pathlib.Path(db_path).exists():
print(f"[analytics] DuckDB not found at {db_path!r} — analytics disabled")
return
_conn = duckdb.connect(db_path, read_only=True)

View File

@@ -120,4 +120,4 @@ app = create_app()
if __name__ == "__main__":
app.run(debug=config.DEBUG, port=5000)
app.run(debug=config.DEBUG, port=5001)

View File

@@ -12,6 +12,10 @@ from functools import wraps
from datetime import datetime, timedelta
from contextvars import ContextVar
from quart import request, session, g
from dotenv import load_dotenv
# web/.env is three levels up from web/src/beanflows/core.py
load_dotenv(Path(__file__).parent.parent.parent / ".env", override=False)
# =============================================================================
# Configuration
@@ -20,7 +24,7 @@ from quart import request, session, g
class Config:
APP_NAME: str = os.getenv("APP_NAME", "BeanFlows")
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production")
BASE_URL: str = os.getenv("BASE_URL", "http://localhost:5000")
BASE_URL: str = os.getenv("BASE_URL", "http://localhost:5001")
DEBUG: bool = os.getenv("DEBUG", "false").lower() == "true"
DATABASE_PATH: str = os.getenv("DATABASE_PATH", "data/app.db")

View File

@@ -99,17 +99,20 @@ async def index():
stats = await get_user_stats(g.user["id"])
plan = user.get("plan") or "free"
# Fetch all analytics data in parallel
time_series, top_producers, stu_trend, balance, yoy = await asyncio.gather(
analytics.get_global_time_series(
analytics.COFFEE_COMMODITY_CODE,
["Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution"],
),
analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=10),
analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE),
analytics.get_supply_demand_balance(analytics.COFFEE_COMMODITY_CODE),
analytics.get_production_yoy_by_country(analytics.COFFEE_COMMODITY_CODE, limit=15),
)
# Fetch all analytics data in parallel (empty lists if DB not available)
if analytics._conn is not None:
time_series, top_producers, stu_trend, balance, yoy = await asyncio.gather(
analytics.get_global_time_series(
analytics.COFFEE_COMMODITY_CODE,
["Production", "Exports", "Imports", "Ending_Stocks", "Total_Distribution"],
),
analytics.get_top_countries(analytics.COFFEE_COMMODITY_CODE, "Production", limit=10),
analytics.get_stock_to_use_trend(analytics.COFFEE_COMMODITY_CODE),
analytics.get_supply_demand_balance(analytics.COFFEE_COMMODITY_CODE),
analytics.get_production_yoy_by_country(analytics.COFFEE_COMMODITY_CODE, limit=15),
)
else:
time_series, top_producers, stu_trend, balance, yoy = [], [], [], [], []
# Latest global snapshot for key metric cards
latest = time_series[-1] if time_series else {}

View File

@@ -0,0 +1,275 @@
@import "tailwindcss";
/* ── BeanFlows Brand Theme ── */
@theme {
--font-display: "DM Sans", ui-sans-serif, system-ui, sans-serif;
--font-sans: "DM Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", monospace;
--color-espresso: #2C1810;
--color-roast: #4A2C1A;
--color-copper: #B45309;
--color-copper-hover: #92400E;
--color-bean-green: #15803D;
--color-forest: #064E3B;
--color-cream: #FFFBF5;
--color-latte: #F5F0EB;
--color-parchment: #E8DFD5;
--color-stone: #78716C;
--color-stone-dark: #57534E;
--color-danger: #EF4444;
--color-danger-hover: #DC2626;
--color-warning: #D97706;
}
/* ── Base layer ── */
@layer base {
body {
@apply bg-cream text-stone-dark font-sans antialiased;
}
h1, h2, h3 {
font-family: var(--font-display);
@apply text-espresso font-bold tracking-tight;
}
h4, h5, h6 {
@apply text-roast font-semibold;
}
a {
@apply text-copper hover:text-copper-hover transition-colors;
}
hr {
@apply border-parchment my-6;
}
}
/* ── Component classes ── */
@layer components {
/* ── Navigation ── */
.nav-bar {
position: sticky;
top: 0;
z-index: 50;
background: rgba(255, 251, 245, 0.85);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(232, 223, 213, 0.7);
}
.nav-inner {
max-width: 72rem;
margin: 0 auto;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
}
.nav-logo {
flex-shrink: 0;
font-family: var(--font-display);
font-weight: 700;
font-size: 1.125rem;
color: var(--color-espresso);
text-decoration: none;
}
.nav-logo:hover {
color: var(--color-espresso);
}
.nav-links {
display: flex;
align-items: center;
gap: 1.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.nav-links a {
color: var(--color-stone-dark);
text-decoration: none;
transition: color 0.15s;
}
.nav-links a:hover {
color: var(--color-copper);
}
a.nav-auth-btn,
button.nav-auth-btn {
display: inline-flex;
align-items: center;
padding: 6px 16px;
border: none;
border-radius: 10px;
font-size: 0.8125rem;
font-weight: 600;
color: #fff;
background: var(--color-copper);
cursor: pointer;
text-decoration: none;
box-shadow: 0 2px 8px rgba(180, 83, 9, 0.25);
transition: background 0.15s;
}
a.nav-auth-btn:hover,
button.nav-auth-btn:hover {
background: var(--color-copper-hover);
color: #fff;
}
.nav-badge {
@apply bg-copper/10 text-copper px-2 py-0.5 text-xs font-semibold rounded-full;
}
.nav-form {
margin: 0;
padding: 0;
display: inline;
}
@media (max-width: 768px) {
.nav-links { display: none; }
.nav-inner { justify-content: center; }
}
/* Page container */
.container-page {
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;
}
/* Cards */
.card {
@apply bg-white border border-parchment rounded-2xl p-6 mb-6 shadow-sm;
}
.card-header {
@apply border-b border-parchment pb-3 mb-4 text-sm text-stone font-medium;
}
/* Buttons — shared base */
.btn, .btn-secondary, .btn-danger {
@apply inline-flex items-center justify-center px-5 py-2.5 rounded-xl
font-semibold text-sm transition-colors cursor-pointer
focus:outline-none focus:ring-2 focus:ring-copper/50;
}
.btn {
@apply bg-copper text-white hover:bg-copper-hover shadow-[0_2px_10px_rgba(180,83,9,0.25)];
}
.btn-secondary {
@apply bg-stone-dark text-white hover:bg-espresso;
}
.btn-danger {
@apply bg-danger text-white hover:bg-danger-hover;
}
.btn-outline {
@apply inline-flex items-center justify-center px-5 py-2.5 rounded-xl
font-semibold text-sm transition-colors cursor-pointer
bg-transparent text-stone-dark border border-parchment
hover:bg-latte hover:text-espresso
focus:outline-none focus:ring-2 focus:ring-copper/50;
}
.btn-sm {
@apply px-3 py-1.5 text-xs;
}
/* Forms */
.form-label {
@apply block text-sm font-medium text-roast mb-1;
}
.form-input {
@apply w-full px-3 py-2 rounded-xl border border-parchment bg-white
text-stone-dark placeholder-stone
focus:outline-none focus:ring-2 focus:ring-copper/50 focus:border-copper
transition-colors;
}
.form-hint {
@apply text-xs text-stone mt-1;
}
/* Tables */
.table {
@apply w-full text-sm;
}
.table th {
@apply text-left px-3 py-2 text-xs font-semibold text-stone uppercase tracking-wider
border-b-2 border-parchment;
}
.table td {
@apply px-3 py-2 border-b border-parchment text-stone-dark;
}
.table tbody tr:hover {
@apply bg-latte;
}
/* Flash messages */
.flash, .flash-error, .flash-success, .flash-warning {
@apply px-4 py-3 rounded-xl mb-4 border-l-4 bg-white text-stone-dark text-sm;
}
.flash {
@apply border-copper;
}
.flash-error {
@apply border-danger;
}
.flash-success {
@apply border-bean-green;
}
.flash-warning {
@apply border-warning;
}
/* Badges */
.badge, .badge-success, .badge-danger, .badge-warning {
@apply inline-block px-2 py-0.5 text-xs font-semibold rounded-full;
}
.badge {
@apply bg-copper/10 text-copper;
}
.badge-success {
@apply bg-bean-green/10 text-bean-green;
}
.badge-danger {
@apply bg-danger/10 text-danger;
}
.badge-warning {
@apply bg-warning/10 text-warning;
}
/* Heading group */
.heading-group {
@apply mb-6;
}
.heading-group h1,
.heading-group h2 {
@apply mb-1;
}
.heading-group p {
@apply text-stone text-lg;
}
/* Grid helpers */
.grid-auto {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
}
.grid-2 {
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
}
.grid-3 {
@apply grid grid-cols-1 md:grid-cols-3 gap-6;
}
.grid-4 {
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6;
}
/* Monospace data display */
.metric {
@apply font-mono text-espresso;
}
.mono {
@apply font-mono;
}
/* HTMX loading indicators */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline;
}
/* Code blocks */
code {
@apply font-mono text-sm bg-latte px-1 py-0.5 rounded;
}
}

View File

@@ -1,97 +1,86 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.APP_NAME }}{% endblock %}</title>
<!-- Pico CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Custom styles -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom.css') }}">
<!-- Tailwind CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/output.css') }}">
{% block head %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="container">
<ul>
<li><a href="{{ url_for('public.landing') }}"><strong>{{ config.APP_NAME }}</strong></a></li>
</ul>
<ul>
<li><a href="{{ url_for('public.features') }}">Features</a></li>
<li><a href="{{ url_for('billing.pricing') }}">Pricing</a></li>
{% if user %}
<li><a href="{{ url_for('dashboard.index') }}">Dashboard</a></li>
{% if session.get('is_admin') %}
<li><a href="{{ url_for('admin.index') }}"><mark>Admin</mark></a></li>
{% endif %}
<li>
<form method="post" action="{{ url_for('auth.logout') }}" style="margin: 0;">
<nav class="nav-bar">
<div class="nav-inner">
<a href="{{ url_for('public.landing') }}" class="nav-logo">{{ config.APP_NAME }}</a>
<div class="nav-links">
<a href="{{ url_for('public.features') }}">Features</a>
<a href="{{ url_for('billing.pricing') }}">Pricing</a>
{% if user %}
<a href="{{ url_for('dashboard.index') }}">Dashboard</a>
{% if session.get('is_admin') %}
<a href="{{ url_for('admin.index') }}"><span class="nav-badge">Admin</span></a>
{% endif %}
<form method="post" action="{{ url_for('auth.logout') }}" class="nav-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="outline secondary" style="padding: 0.5rem 1rem; margin: 0;">Sign Out</button>
<button type="submit" class="btn-outline btn-sm">Sign Out</button>
</form>
</li>
{% else %}
<li><a href="{{ url_for('auth.login') }}">Sign In</a></li>
<li><a href="{{ url_for('auth.signup') }}" role="button">Get Started</a></li>
{% endif %}
</ul>
{% else %}
<a href="{{ url_for('auth.login') }}">Sign In</a>
<a href="{{ url_for('auth.signup') }}" class="nav-auth-btn">Get Started</a>
{% endif %}
</div>
</div>
</nav>
<!-- Flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="container">
<div class="container-page mt-4">
{% for category, message in messages %}
<article
style="padding: 1rem; margin-bottom: 1rem;
{% if category == 'error' %}border-left: 4px solid var(--del-color);
{% elif category == 'success' %}border-left: 4px solid var(--ins-color);
{% elif category == 'warning' %}border-left: 4px solid var(--mark-background-color);
{% else %}border-left: 4px solid var(--primary);{% endif %}"
>
<div class="{% if category == 'error' %}flash-error{% elif category == 'success' %}flash-success{% elif category == 'warning' %}flash-warning{% else %}flash{% endif %}">
{{ message }}
</article>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
<!-- Footer -->
<footer class="container" style="margin-top: 4rem; padding: 2rem 0; border-top: 1px solid var(--muted-border-color);">
<div class="grid">
<footer class="container-page mt-16 py-8 border-t border-parchment">
<div class="grid-3">
<div>
<strong>{{ config.APP_NAME }}</strong>
<p><small>Coffee market intelligence for independent traders.</small></p>
<strong class="text-espresso">{{ config.APP_NAME }}</strong>
<p class="text-sm text-stone mt-1">Coffee market intelligence for independent traders.</p>
</div>
<div>
<strong>Product</strong>
<ul style="list-style: none; padding: 0;">
<strong class="text-espresso text-sm">Product</strong>
<ul class="list-none p-0 mt-1 space-y-1 text-sm">
<li><a href="{{ url_for('public.features') }}">Features</a></li>
<li><a href="{{ url_for('billing.pricing') }}">Pricing</a></li>
<li><a href="{{ url_for('public.about') }}">About</a></li>
</ul>
</div>
<div>
<strong>Legal</strong>
<ul style="list-style: none; padding: 0;">
<strong class="text-espresso text-sm">Legal</strong>
<ul class="list-none p-0 mt-1 space-y-1 text-sm">
<li><a href="{{ url_for('public.terms') }}">Terms</a></li>
<li><a href="{{ url_for('public.privacy') }}">Privacy</a></li>
</ul>
</div>
</div>
<p style="text-align: center; margin-top: 2rem;">
<small>&copy; {{ now.year }} {{ config.APP_NAME }}. All rights reserved.</small>
<p class="text-center mt-8 text-xs text-stone">
&copy; {{ now.year }} {{ config.APP_NAME }}. All rights reserved.
</p>
</footer>
<!-- HTMX (optional) -->
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
{% block scripts %}{% endblock %}
</body>
</html>