removed old UI

This commit is contained in:
Justin Visser 2026-02-17 15:21:08 +01:00
parent 4433d7d2c4
commit f71841e922
62 changed files with 411 additions and 5815 deletions

View file

@ -4,16 +4,16 @@ from fastapi import Depends, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import ApiException
from app.auth import runtime as auth_runtime
from app.db.models import User
from app.db.session import get_db_session
from app.web import common as web_common
async def get_api_current_user(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
) -> User:
current_user = await web_common.get_authenticated_user(request, db_session)
current_user = await auth_runtime.get_authenticated_user(request, db_session)
if current_user is None:
raise ApiException(
status_code=401,
@ -33,4 +33,3 @@ async def get_api_admin_user(
message="Admin access required.",
)
return current_user

View file

@ -18,13 +18,13 @@ from app.api.schemas import (
)
from app.auth.deps import get_auth_service, get_login_rate_limiter
from app.auth.rate_limit import SlidingWindowRateLimiter
from app.auth import runtime as auth_runtime
from app.auth.service import AuthService
from app.auth.session import set_session_user
from app.db.models import User
from app.db.session import get_db_session
from app.security.csrf import ensure_csrf_token
from app.services import users as user_service
from app.web import common as web_common
logger = logging.getLogger(__name__)
@ -42,7 +42,7 @@ async def login(
auth_service: AuthService = Depends(get_auth_service),
rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter),
):
limiter_key = web_common.login_rate_limit_key(request, payload.email)
limiter_key = auth_runtime.login_rate_limit_key(request, payload.email)
decision = rate_limiter.check(limiter_key)
normalized_email = payload.email.strip().lower()
if not decision.allowed:
@ -142,7 +142,7 @@ async def get_csrf_bootstrap(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await web_common.get_authenticated_user(request, db_session)
current_user = await auth_runtime.get_authenticated_user(request, db_session)
return success_payload(
request,
data={
@ -213,8 +213,8 @@ async def logout(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await web_common.get_authenticated_user(request, db_session)
web_common.invalidate_session(request)
current_user = await auth_runtime.get_authenticated_user(request, db_session)
auth_runtime.invalidate_session(request)
logger.info(
"api.auth.logout",
extra={

View file

@ -24,7 +24,7 @@ from app.services import ingestion as ingestion_service
from app.services import runs as run_service
from app.services import user_settings as user_settings_service
from app.settings import settings
from app.web.deps import get_ingestion_service
from app.api.runtime_deps import get_ingestion_service
logger = logging.getLogger(__name__)

View file

@ -14,4 +14,3 @@ def get_ingestion_service(
source: scholar_source_service.ScholarSource = Depends(get_scholar_source),
) -> ingestion_service.ScholarIngestionService:
return ingestion_service.ScholarIngestionService(source=source)

55
app/auth/runtime.py Normal file
View file

@ -0,0 +1,55 @@
from __future__ import annotations
import logging
from fastapi import Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.session import clear_session_user, get_session_user, set_session_user
from app.db.models import User
from app.security.csrf import CSRF_SESSION_KEY
from app.services import users as user_service
logger = logging.getLogger(__name__)
def invalidate_session(request: Request) -> None:
clear_session_user(request)
request.session.pop(CSRF_SESSION_KEY, None)
async def get_authenticated_user(
request: Request,
db_session: AsyncSession,
) -> User | None:
session_user = get_session_user(request)
if session_user is None:
return None
user = await user_service.get_user_by_id(db_session, session_user.id)
if user is None or not user.is_active:
logger.info(
"auth.session_invalidated",
extra={
"event": "auth.session_invalidated",
"session_user_id": session_user.id,
},
)
invalidate_session(request)
return None
if user.email != session_user.email or user.is_admin != session_user.is_admin:
set_session_user(
request,
user_id=user.id,
email=user.email,
is_admin=user.is_admin,
)
return user
def login_rate_limit_key(request: Request, email: str) -> str:
client_host = request.client.host if request.client is not None else "unknown"
normalized_email = email.strip().lower()
return f"{client_host}:{normalized_email or '<empty>'}"

1
app/http/__init__.py Normal file
View file

@ -0,0 +1 @@
from __future__ import annotations

View file

@ -1,8 +1,8 @@
from __future__ import annotations
from secrets import token_urlsafe
import time
import logging
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request

View file

@ -2,30 +2,19 @@ from __future__ import annotations
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi import FastAPI, HTTPException
from starlette.middleware.sessions import SessionMiddleware
from app.api.errors import register_api_exception_handlers
from app.api.router import router as api_router
from app.api.runtime_deps import get_ingestion_service, get_scholar_source
from app.db.session import check_database
from app.db.session import close_engine
from app.http.middleware import RequestLoggingMiddleware, parse_skip_paths
from app.logging_config import configure_logging, parse_redact_fields
from app.security.csrf import CSRFMiddleware
from app.services.scheduler import SchedulerService
from app.settings import settings
from app.web import common as web_common
from app.web.deps import get_ingestion_service, get_scholar_source
from app.web.middleware import RequestLoggingMiddleware, parse_skip_paths
from app.web.routers import (
admin,
auth,
dashboard,
health,
publications,
runs,
scholars,
settings as settings_router,
)
configure_logging(
level=settings.log_level,
@ -71,17 +60,11 @@ app.add_middleware(
log_requests=settings.log_requests,
skip_paths=parse_skip_paths(settings.log_request_skip_paths),
)
app.mount("/static", StaticFiles(directory="app/static"), name="static")
app.include_router(api_router)
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(scholars.router)
app.include_router(settings_router.router)
app.include_router(runs.router)
app.include_router(publications.router)
app.include_router(dashboard.router)
app.include_router(health.router)
# Backward-compatible export kept for tests and any existing local scripts.
_get_authenticated_user = web_common.get_authenticated_user
@app.get("/healthz")
async def healthz() -> dict[str, str]:
if await check_database():
return {"status": "ok"}
raise HTTPException(status_code=500, detail="database unavailable")

View file

@ -1,2 +0,0 @@
"""Presentation-layer view models used by template routes."""

View file

@ -1,108 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Sequence
from app.db.models import CrawlRun
from app.services.publications import UnreadPublicationItem
SECTION_TEMPLATES: tuple[str, ...] = (
"dashboard/_run_controls.html",
"dashboard/_unread_publications.html",
"dashboard/_run_history.html",
)
@dataclass(frozen=True)
class RunControlsViewModel:
request_delay_seconds: int
queue_queued_count: int
queue_retrying_count: int
queue_dropped_count: int
run_manual_action: str = "/runs/manual"
mark_all_read_action: str = "/publications/mark-all-read"
@dataclass(frozen=True)
class UnreadPublicationViewModel:
title: str
scholar_label: str
year_display: str
citation_count: int
venue_text: str | None
pub_url: str | None
@dataclass(frozen=True)
class RunHistoryItemViewModel:
run_id: int | None
detail_url: str | None
started_at_display: str
status_label: str
status_badge: str
scholar_count: int
new_publication_count: int
@dataclass(frozen=True)
class DashboardViewModel:
section_templates: tuple[str, ...]
run_controls: RunControlsViewModel
unread_publications: list[UnreadPublicationViewModel]
run_history: list[RunHistoryItemViewModel]
def build_dashboard_view_model(
*,
unread_publications: Sequence[UnreadPublicationItem],
recent_runs: Sequence[CrawlRun],
request_delay_seconds: int,
queue_counts: dict[str, int] | None = None,
) -> DashboardViewModel:
queue_counts = queue_counts or {}
return DashboardViewModel(
section_templates=SECTION_TEMPLATES,
run_controls=RunControlsViewModel(
request_delay_seconds=request_delay_seconds,
queue_queued_count=int(queue_counts.get("queued", 0)),
queue_retrying_count=int(queue_counts.get("retrying", 0)),
queue_dropped_count=int(queue_counts.get("dropped", 0)),
),
unread_publications=[
UnreadPublicationViewModel(
title=item.title,
scholar_label=item.scholar_label,
year_display=str(item.year) if item.year is not None else "-",
citation_count=item.citation_count,
venue_text=item.venue_text,
pub_url=item.pub_url,
)
for item in unread_publications
],
run_history=[
RunHistoryItemViewModel(
run_id=run.id,
detail_url=f"/runs/{run.id}" if run.id is not None else None,
started_at_display=_format_started_at(run.start_dt),
status_label=run.status.value,
status_badge=_status_badge(run.status.value),
scholar_count=run.scholar_count,
new_publication_count=run.new_pub_count,
)
for run in recent_runs
],
)
def _status_badge(status: str) -> str:
if status == "success":
return "ok"
if status == "failed":
return "danger"
return "warn"
def _format_started_at(dt: datetime) -> str:
local_aware = dt.astimezone(timezone.utc)
return local_aware.strftime("%Y-%m-%d %H:%M UTC")

View file

@ -77,8 +77,6 @@ class CSRFMiddleware(BaseHTTPMiddleware):
if request.method in SAFE_METHODS:
return True
path = request.url.path
if path.startswith("/static/"):
return True
return path in self._exempt_paths
def _is_form_payload(self, request: Request) -> bool:

View file

@ -48,7 +48,7 @@ class Settings:
log_format: str = _env_str("LOG_FORMAT", "console")
log_requests: bool = _env_bool("LOG_REQUESTS", True)
log_uvicorn_access: bool = _env_bool("LOG_UVICORN_ACCESS", False)
log_request_skip_paths: str = _env_str("LOG_REQUEST_SKIP_PATHS", "/healthz,/static/")
log_request_skip_paths: str = _env_str("LOG_REQUEST_SKIP_PATHS", "/healthz")
log_redact_fields: str = os.getenv("LOG_REDACT_FIELDS", "")
scheduler_enabled: bool = _env_bool("SCHEDULER_ENABLED", True)
scheduler_tick_seconds: int = _env_int("SCHEDULER_TICK_SECONDS", 60)

View file

@ -1,501 +0,0 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
color: var(--md-sys-color-on-surface);
font-family: var(--font-body);
background:
radial-gradient(circle at 10% 5%, var(--md-sys-color-primary-container) 0%, transparent 30%),
radial-gradient(circle at 90% 0%, var(--md-sys-color-secondary-container) 0%, transparent 28%),
var(--md-sys-color-surface);
}
.loading-indicator {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
z-index: 40;
transform: scaleX(0);
transform-origin: left;
background: linear-gradient(90deg, var(--md-sys-color-primary), var(--md-sys-color-tertiary));
opacity: 0;
}
body.is-page-loading .loading-indicator {
opacity: 1;
animation: loading-bar 1.2s ease-in-out infinite;
}
.app-backdrop {
position: fixed;
inset: 0;
pointer-events: none;
background:
linear-gradient(130deg, var(--md-sys-color-primary-tint), transparent 40%),
linear-gradient(230deg, var(--md-sys-color-surface-tint), transparent 45%);
opacity: 0.28;
}
.top-app-bar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
padding: 0.8rem 1.1rem;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
backdrop-filter: blur(9px);
background: color-mix(in srgb, var(--md-sys-color-surface) 88%, white 12%);
}
.brand-wrap {
display: flex;
align-items: center;
gap: 0.65rem;
}
.brand {
color: var(--md-sys-color-primary);
text-decoration: none;
font-family: var(--font-heading);
font-weight: 700;
font-size: 1.2rem;
letter-spacing: 0.02em;
}
.session-user {
margin: 0;
padding: 0.24rem 0.58rem;
border-radius: 999px;
border: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface-variant);
font-size: 0.78rem;
}
.site-nav {
display: flex;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
}
.nav-link {
color: var(--md-sys-color-on-surface-variant);
text-decoration: none;
padding: 0.42rem 0.78rem;
border-radius: 999px;
border: 1px solid transparent;
font-size: 0.92rem;
}
.nav-link:hover {
background: var(--md-sys-color-surface-container);
border-color: var(--md-sys-color-outline-variant);
}
.nav-link.is-active {
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border-color: transparent;
}
.header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.7rem;
}
.theme-control-wrap {
display: flex;
align-items: center;
gap: 0.45rem;
}
.theme-control-wrap label {
color: var(--md-sys-color-on-surface-variant);
font-size: 0.82rem;
}
.theme-control {
border: 1px solid var(--md-sys-color-outline);
border-radius: 999px;
background: var(--md-sys-color-surface-container-low);
color: var(--md-sys-color-on-surface);
padding: 0.32rem 0.64rem;
font: inherit;
}
.logout-form {
margin: 0;
}
.page-shell {
position: relative;
display: grid;
gap: 0.9rem;
width: min(1100px, calc(100vw - 1.4rem));
margin: 0 auto;
padding: 1.1rem 0 1.6rem;
}
.flash {
margin: 0;
padding: 0.72rem 0.82rem;
border-radius: 14px;
border: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface-container);
}
.flash-notice {
color: var(--md-sys-color-on-tertiary-container);
border-color: var(--md-sys-color-tertiary-container);
background: color-mix(in srgb, var(--md-sys-color-tertiary-container) 30%, white 70%);
}
.flash-error {
color: var(--md-sys-color-on-error-container);
border-color: var(--md-sys-color-error-container);
background: color-mix(in srgb, var(--md-sys-color-error-container) 25%, white 75%);
}
.hero,
.sandbox {
border-radius: 24px;
border: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface-container-low);
box-shadow: var(--elevation-card);
}
.hero {
padding: 1.3rem;
}
.sandbox {
padding: 1.05rem;
}
.eyebrow {
margin: 0;
font-size: 0.78rem;
color: var(--md-sys-color-primary);
text-transform: uppercase;
letter-spacing: 0.11em;
font-weight: 600;
}
h1 {
margin: 0.28rem 0 0.5rem;
color: var(--md-sys-color-on-surface);
font-family: var(--font-heading);
font-size: clamp(1.45rem, 3vw, 2.05rem);
line-height: 1.15;
}
h2 {
margin: 0;
color: var(--md-sys-color-on-surface);
font-family: var(--font-heading);
font-size: 1.08rem;
}
.lede {
margin: 0;
color: var(--md-sys-color-on-surface-variant);
max-width: 68ch;
}
.section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.55rem;
margin-bottom: 0.7rem;
flex-wrap: wrap;
}
.stat-strip {
margin-top: 0.95rem;
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.stat-item {
margin: 0;
padding: 0.72rem 0.78rem;
border-radius: 16px;
border: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface-container);
}
.stat-label {
margin: 0;
color: var(--md-sys-color-on-surface-variant);
font-size: 0.8rem;
}
.stat-value {
margin: 0.2rem 0 0;
color: var(--md-sys-color-on-surface);
font-size: 1.36rem;
font-weight: 700;
}
form {
display: grid;
gap: 0.45rem;
}
.stack-form {
max-width: 560px;
}
label {
font-size: 0.9rem;
color: var(--md-sys-color-on-surface-variant);
}
input,
select {
width: 100%;
border: 1px solid var(--md-sys-color-outline);
border-radius: 12px;
padding: 0.56rem 0.64rem;
font: inherit;
color: var(--md-sys-color-on-surface);
background: var(--md-sys-color-surface);
}
input:focus,
select:focus {
outline: 2px solid color-mix(in srgb, var(--md-sys-color-primary) 35%, transparent);
border-color: var(--md-sys-color-primary);
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.54rem;
}
.checkbox-row input {
width: auto;
}
button,
.button {
border: 0;
border-radius: 999px;
padding: 0.54rem 0.86rem;
font: inherit;
font-weight: 600;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
cursor: pointer;
}
button:hover,
.button:hover {
filter: brightness(0.97);
}
button:disabled,
.button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.button-text {
color: var(--md-sys-color-primary);
background: transparent;
border: 1px solid var(--md-sys-color-outline-variant);
}
.danger-button {
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
}
.inline-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
}
.inline-actions form {
display: inline-flex;
gap: 0.42rem;
align-items: center;
}
.inline-reset-form input {
min-width: 160px;
}
form.is-loading button[type="submit"],
form.is-loading input[type="submit"] {
opacity: 0.75;
}
table {
width: 100%;
border-collapse: collapse;
border-radius: 12px;
overflow: hidden;
background: var(--md-sys-color-surface);
}
th,
td {
padding: 0.54rem;
text-align: left;
border-bottom: 1px solid var(--md-sys-color-outline-variant);
vertical-align: top;
}
thead th {
color: var(--md-sys-color-on-surface-variant);
font-size: 0.84rem;
font-weight: 600;
background: var(--md-sys-color-surface-container-high);
}
tbody tr:last-child td {
border-bottom: 0;
}
a {
color: var(--md-sys-color-primary);
}
.link-button {
display: inline-flex;
align-items: center;
padding: 0.45rem 0.78rem;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: 999px;
color: var(--md-sys-color-primary);
text-decoration: none;
background: var(--md-sys-color-surface-container-low);
}
.link-button.is-active {
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border-color: transparent;
}
.badge {
display: inline-block;
border-radius: 999px;
padding: 0.16rem 0.48rem;
font-size: 0.76rem;
text-transform: lowercase;
border: 1px solid var(--md-sys-color-outline-variant);
}
.badge.ok {
color: var(--md-sys-color-on-tertiary-container);
background: var(--md-sys-color-tertiary-container);
border-color: transparent;
}
.badge.warn,
.badge.danger {
color: var(--md-sys-color-on-error-container);
background: var(--md-sys-color-error-container);
border-color: transparent;
}
.helper-text {
margin: 0.24rem 0 0;
color: var(--md-sys-color-on-surface-variant);
font-size: 0.82rem;
}
.auth-card {
max-width: 520px;
}
.auth-form {
max-width: 360px;
}
.form-error {
margin: 0.5rem 0 0;
color: var(--md-sys-color-error);
font-weight: 600;
}
.form-note {
margin: 0.35rem 0 0;
color: var(--md-sys-color-on-surface-variant);
}
code {
font-family: var(--font-code);
background: var(--md-sys-color-surface-container-high);
border-radius: 6px;
padding: 0.1rem 0.25rem;
}
pre {
margin: 0.6rem 0 0;
border-radius: 12px;
border: 1px solid var(--md-sys-color-outline-variant);
background: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
padding: 0.75rem;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 880px) {
.top-app-bar {
padding: 0.7rem 0.8rem;
}
.header-actions {
width: 100%;
justify-content: space-between;
margin-left: 0;
}
.page-shell {
width: min(100vw, calc(100vw - 0.9rem));
padding-top: 0.8rem;
}
.hero,
.sandbox {
border-radius: 18px;
padding: 0.9rem;
}
.inline-actions form {
width: 100%;
justify-content: flex-start;
}
}
@keyframes loading-bar {
0% {
transform: scaleX(0.1);
}
50% {
transform: scaleX(0.7);
}
100% {
transform: scaleX(1);
}
}

View file

@ -1,76 +0,0 @@
(() => {
const body = document.body;
if (!body) {
return;
}
const defaultLoadingText = "Working...";
const shouldHandleAnchor = (anchor) => {
if (!anchor || !anchor.getAttribute) {
return false;
}
const href = anchor.getAttribute("href");
if (!href) {
return false;
}
if (href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("javascript:")) {
return false;
}
if (anchor.hasAttribute("download")) {
return false;
}
if ((anchor.getAttribute("target") || "").toLowerCase() === "_blank") {
return false;
}
return true;
};
document.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest("a");
if (!shouldHandleAnchor(anchor)) {
return;
}
body.classList.add("is-page-loading");
});
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement)) {
return;
}
if (form.dataset.noLoading === "1") {
return;
}
body.classList.add("is-page-loading");
form.classList.add("is-loading");
const submitElements = form.querySelectorAll("button[type='submit'], input[type='submit']");
submitElements.forEach((element) => {
if (element.hasAttribute("disabled")) {
return;
}
element.setAttribute("disabled", "disabled");
if (element instanceof HTMLInputElement) {
const loadingText = element.dataset.loadingText || defaultLoadingText;
element.dataset.originalValue = element.value;
element.value = loadingText;
return;
}
if (element instanceof HTMLButtonElement) {
const loadingText = element.dataset.loadingText || defaultLoadingText;
element.dataset.originalText = element.textContent || "";
element.textContent = loadingText;
}
});
});
window.addEventListener("pageshow", () => {
body.classList.remove("is-page-loading");
});
})();

View file

@ -1,98 +0,0 @@
:root {
--font-body: "Plus Jakarta Sans", "Segoe UI", sans-serif;
--font-heading: "DM Serif Text", "Georgia", serif;
--font-code: "JetBrains Mono", "Fira Code", monospace;
--md-sys-color-primary: #73594d;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-primary-container: #fbded0;
--md-sys-color-on-primary-container: #2b160f;
--md-sys-color-primary-tint: rgba(115, 89, 77, 0.18);
--md-sys-color-secondary: #6f5b52;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-secondary-container: #f8ddd2;
--md-sys-color-on-secondary-container: #281913;
--md-sys-color-tertiary: #5b6550;
--md-sys-color-on-tertiary: #ffffff;
--md-sys-color-tertiary-container: #ddebcf;
--md-sys-color-on-tertiary-container: #17200f;
--md-sys-color-error: #b3261e;
--md-sys-color-on-error: #ffffff;
--md-sys-color-error-container: #f9dedc;
--md-sys-color-on-error-container: #410e0b;
--md-sys-color-surface: #fff8f5;
--md-sys-color-surface-tint: rgba(111, 87, 74, 0.12);
--md-sys-color-surface-container-low: #fffaf8;
--md-sys-color-surface-container: #fdf2ec;
--md-sys-color-surface-container-high: #f8e9e1;
--md-sys-color-on-surface: #221a17;
--md-sys-color-on-surface-variant: #55423a;
--md-sys-color-outline: #85736b;
--md-sys-color-outline-variant: #d8c2b8;
--elevation-card: 0 10px 30px rgba(55, 39, 28, 0.08);
}
:root[data-theme="fjord"] {
--md-sys-color-primary: #2f6678;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-primary-container: #cde9f4;
--md-sys-color-on-primary-container: #051f28;
--md-sys-color-primary-tint: rgba(47, 102, 120, 0.2);
--md-sys-color-secondary: #4d626b;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-secondary-container: #d0e6f2;
--md-sys-color-on-secondary-container: #091f27;
--md-sys-color-tertiary: #456179;
--md-sys-color-on-tertiary: #ffffff;
--md-sys-color-tertiary-container: #d2e5ff;
--md-sys-color-on-tertiary-container: #031d33;
--md-sys-color-surface: #f5fbff;
--md-sys-color-surface-tint: rgba(47, 102, 120, 0.12);
--md-sys-color-surface-container-low: #fcfeff;
--md-sys-color-surface-container: #ecf5fa;
--md-sys-color-surface-container-high: #dfeef6;
--md-sys-color-on-surface: #172126;
--md-sys-color-on-surface-variant: #3f4f57;
--md-sys-color-outline: #6f7f88;
--md-sys-color-outline-variant: #becfd8;
--elevation-card: 0 10px 30px rgba(22, 43, 53, 0.09);
}
:root[data-theme="spruce"] {
--md-sys-color-primary: #39684c;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-primary-container: #bcefc9;
--md-sys-color-on-primary-container: #062111;
--md-sys-color-primary-tint: rgba(57, 104, 76, 0.18);
--md-sys-color-secondary: #4f6354;
--md-sys-color-on-secondary: #ffffff;
--md-sys-color-secondary-container: #d2e8d4;
--md-sys-color-on-secondary-container: #0d1f12;
--md-sys-color-tertiary: #3f655f;
--md-sys-color-on-tertiary: #ffffff;
--md-sys-color-tertiary-container: #c2ece4;
--md-sys-color-on-tertiary-container: #021f1a;
--md-sys-color-surface: #f5fbf5;
--md-sys-color-surface-tint: rgba(57, 104, 76, 0.1);
--md-sys-color-surface-container-low: #fcfffb;
--md-sys-color-surface-container: #ebf4ea;
--md-sys-color-surface-container-high: #deecdd;
--md-sys-color-on-surface: #172119;
--md-sys-color-on-surface-variant: #415246;
--md-sys-color-outline: #708174;
--md-sys-color-outline-variant: #c1d3c2;
--elevation-card: 0 10px 30px rgba(24, 48, 32, 0.08);
}

View file

@ -1,56 +0,0 @@
(() => {
const STORAGE_KEY = "scholarr_theme";
const LEGACY_STORAGE_KEY = "scholar_tracker_theme";
const root = document.documentElement;
const themeControl = document.querySelector("[data-theme-control]");
if (!root) {
return;
}
const defaultTheme = root.dataset.defaultTheme || "terracotta";
const supportedThemes = themeControl
? Array.from(themeControl.options).map((option) => option.value)
: [];
const isSupported = (theme) =>
supportedThemes.length === 0 || supportedThemes.includes(theme);
const applyTheme = (theme) => {
if (!isSupported(theme)) {
return;
}
root.dataset.theme = theme;
};
let activeTheme = defaultTheme;
try {
const savedTheme = window.localStorage.getItem(STORAGE_KEY);
const legacyTheme = window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (savedTheme && isSupported(savedTheme)) {
activeTheme = savedTheme;
} else if (legacyTheme && isSupported(legacyTheme)) {
activeTheme = legacyTheme;
window.localStorage.setItem(STORAGE_KEY, legacyTheme);
}
} catch {
activeTheme = defaultTheme;
}
applyTheme(activeTheme);
if (!themeControl) {
return;
}
themeControl.value = activeTheme;
themeControl.addEventListener("change", (event) => {
const selectedTheme = event.target.value;
applyTheme(selectedTheme);
try {
window.localStorage.setItem(STORAGE_KEY, selectedTheme);
} catch {
// Ignore storage errors in locked-down browsers.
}
});
})();

View file

@ -1,23 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero" data-test="account-password-page">
<p class="eyebrow">Account</p>
<h1>Change Password</h1>
<p class="lede">Use a strong password. This updates only your own account.</p>
</section>
<section class="sandbox" data-test="account-password-form-card">
<h2>Password Update</h2>
<form method="post" action="/account/password" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="current_password">Current Password</label>
<input id="current_password" name="current_password" type="password" autocomplete="current-password" required>
<label for="new_password">New Password</label>
<input id="new_password" name="new_password" type="password" autocomplete="new-password" minlength="8" required>
<label for="confirm_password">Confirm New Password</label>
<input id="confirm_password" name="confirm_password" type="password" autocomplete="new-password" minlength="8" required>
<button type="submit" data-loading-text="Updating...">Update Password</button>
</form>
</section>
{% endblock %}

View file

@ -1,66 +0,0 @@
<!doctype html>
<html lang="en" data-theme="{{ theme_name | default('terracotta') }}" data-default-theme="{{ theme_name | default('terracotta') }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ page_title }} | scholarr</title>
<link rel="stylesheet" href="{{ url_for('static', path='theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', path='app.css') }}">
</head>
<body>
<div class="loading-indicator" data-loading-indicator aria-hidden="true"></div>
<div class="app-backdrop" aria-hidden="true"></div>
<header class="top-app-bar">
<div class="brand-wrap">
<a class="brand" href="/">scholarr</a>
{% if session_user %}
<p class="session-user" data-test="session-user">{{ session_user.email }}</p>
{% endif %}
</div>
<nav class="site-nav" aria-label="Primary">
{% if session_user %}
<a class="nav-link {% if active_nav == 'home' %}is-active{% endif %}" href="/">Dashboard</a>
<a class="nav-link {% if active_nav == 'scholars' %}is-active{% endif %}" href="/scholars">Scholars</a>
<a class="nav-link {% if active_nav == 'publications' %}is-active{% endif %}" href="/publications?mode=new">Publications</a>
<a class="nav-link {% if active_nav == 'runs' %}is-active{% endif %}" href="/runs">Runs</a>
<a class="nav-link {% if active_nav == 'settings' %}is-active{% endif %}" href="/settings">Settings</a>
<a class="nav-link {% if active_nav == 'account_password' %}is-active{% endif %}" href="/account/password">Password</a>
{% if session_user.is_admin %}
<a class="nav-link {% if active_nav == 'users' %}is-active{% endif %}" href="/users">Users</a>
{% endif %}
{% else %}
<a class="nav-link {% if active_nav == 'login' %}is-active{% endif %}" href="/login">Login</a>
{% endif %}
</nav>
<div class="header-actions">
<div class="theme-control-wrap">
<label for="theme-control">Theme</label>
<select id="theme-control" class="theme-control" data-theme-control aria-label="Color theme">
{% for theme in themes %}
<option value="{{ theme.slug }}" {% if theme.slug == theme_name %}selected{% endif %}>{{ theme.label }}</option>
{% endfor %}
</select>
</div>
{% if session_user %}
<form method="post" action="/logout" class="logout-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="button button-text" data-loading-text="Logging out...">Log out</button>
</form>
{% endif %}
</div>
</header>
<main class="page-shell">
{% if notice %}
<p class="flash flash-notice" data-test="flash-notice">{{ notice }}</p>
{% endif %}
{% if page_error %}
<p class="flash flash-error" data-test="flash-error">{{ page_error }}</p>
{% endif %}
{% block content %}{% endblock %}
</main>
<script defer src="{{ url_for('static', path='app.js') }}"></script>
<script defer src="{{ url_for('static', path='theme.js') }}"></script>
</body>
</html>

View file

@ -1,23 +0,0 @@
<section class="sandbox" data-test="dashboard-run-controls">
<div class="section-heading">
<h2>Run Tracking</h2>
<div class="inline-actions">
<a class="link-button" href="/publications?mode=new">New Publications</a>
<a class="link-button" href="/publications?mode=all">All Publications</a>
<a class="link-button" href="/runs?failed_only=1">Run Diagnostics</a>
<a class="link-button" href="/settings">Automation Settings</a>
<a class="link-button" href="/scholars">Manage Scholars</a>
</div>
</div>
<p class="lede">Manual runs use your request delay of {{ dashboard.run_controls.request_delay_seconds }} second(s).</p>
<p class="helper-text">
Queue state:
{{ dashboard.run_controls.queue_queued_count }} queued,
{{ dashboard.run_controls.queue_retrying_count }} retrying,
{{ dashboard.run_controls.queue_dropped_count }} dropped.
</p>
<form method="post" action="{{ dashboard.run_controls.run_manual_action }}" class="inline-actions">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" data-loading-text="Running...">Run Now</button>
</form>
</section>

View file

@ -1,38 +0,0 @@
<section class="sandbox" data-test="dashboard-run-history">
<div class="section-heading">
<h2>Run History</h2>
<div class="inline-actions">
<a class="link-button" href="/runs">View All Runs</a>
</div>
</div>
{% if dashboard.run_history %}
<table>
<thead>
<tr>
<th>Started</th>
<th>Status</th>
<th>Scholars</th>
<th>New Publications</th>
</tr>
</thead>
<tbody>
{% for run in dashboard.run_history %}
<tr>
<td>
{% if run.detail_url %}
<a href="{{ run.detail_url }}">{{ run.started_at_display }}</a>
{% else %}
{{ run.started_at_display }}
{% endif %}
</td>
<td><span class="badge {{ run.status_badge }}">{{ run.status_label }}</span></td>
<td>{{ run.scholar_count }}</td>
<td>{{ run.new_publication_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No runs yet.</p>
{% endif %}
</section>

View file

@ -1,47 +0,0 @@
<section class="sandbox" data-test="dashboard-unread-publications">
<div class="section-heading">
<h2>New Since Last Run</h2>
<div class="inline-actions">
<a class="link-button" href="/publications?mode=all">View All</a>
<form method="post" action="{{ dashboard.run_controls.mark_all_read_action }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="return_to" value="/">
<button type="submit" data-loading-text="Marking...">Mark All Read</button>
</form>
</div>
</div>
{% if dashboard.unread_publications %}
<table>
<thead>
<tr>
<th>Title</th>
<th>Scholar</th>
<th>Year</th>
<th>Citations</th>
</tr>
</thead>
<tbody>
{% for item in dashboard.unread_publications %}
<tr>
<td>
{% if item.pub_url %}
<a href="{{ item.pub_url }}" target="_blank" rel="noreferrer">{{ item.title }}</a>
{% else %}
{{ item.title }}
{% endif %}
{% if item.venue_text %}
<p class="helper-text">{{ item.venue_text }}</p>
{% endif %}
</td>
<td>{{ item.scholar_label }}</td>
<td>{{ item.year_display }}</td>
<td>{{ item.citation_count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No new publications in the latest run.</p>
{% endif %}
</section>

View file

@ -1,27 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero" data-test="home-hero">
<p class="eyebrow">Dashboard</p>
<h1>Keep up with your tracked scholars</h1>
<p class="lede">Run ingestion, review fresh publications, and inspect recent outcomes from one screen.</p>
<div class="stat-strip">
<article class="stat-item">
<p class="stat-label">New Since Last Run</p>
<p class="stat-value">{{ dashboard.unread_publications | length }}</p>
</article>
<article class="stat-item">
<p class="stat-label">Recent Runs</p>
<p class="stat-value">{{ dashboard.run_history | length }}</p>
</article>
<article class="stat-item">
<p class="stat-label">Automation Delay</p>
<p class="stat-value">{{ dashboard.run_controls.request_delay_seconds }}s</p>
</article>
</div>
</section>
{% for section_template in dashboard.section_templates %}
{% include section_template %}
{% endfor %}
{% endblock %}

View file

@ -1,23 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero auth-card" data-test="login-card">
<p class="eyebrow">Secure Access</p>
<h1>Sign in</h1>
<p class="lede">Use an admin-created account to access your tracked scholars and settings.</p>
{% if error_message %}
<p class="form-error" role="alert">{{ error_message }}</p>
{% endif %}
{% if retry_after_seconds %}
<p class="form-note">Retry after {{ retry_after_seconds }} seconds.</p>
{% endif %}
<form method="post" action="/login" class="auth-form" data-test="login-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="username" required autofocus>
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" required>
<button type="submit" data-loading-text="Signing in...">Sign in</button>
</form>
</section>
{% endblock %}

View file

@ -1,104 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero" data-test="publications-page">
<p class="eyebrow">Publications</p>
<h1>Publications</h1>
<p class="lede">
Browse publications discovered in the latest run or your complete tracked library.
{% if selected_scholar %}
Current scholar filter: <strong>{{ selected_scholar.display_name or selected_scholar.scholar_id }}</strong>.
{% endif %}
</p>
<div class="inline-actions">
<a class="link-button {% if mode == 'new' %}is-active{% endif %}" href="{{ mode_new_url }}">New Since Last Run ({{ new_count }})</a>
<a class="link-button {% if mode == 'all' %}is-active{% endif %}" href="{{ mode_all_url }}">All ({{ total_count }})</a>
<a class="link-button" href="/scholars">Manage Scholars</a>
<a class="link-button" href="/runs?failed_only=1">Run Diagnostics</a>
{% if mode == 'new' and new_count > 0 %}
<form method="post" action="/publications/mark-all-read">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="return_to" value="{{ current_publications_url }}">
<button type="submit" data-loading-text="Marking...">Mark All Read</button>
</form>
{% endif %}
</div>
</section>
<section class="sandbox" data-test="publications-table">
<div class="section-heading">
<h2>Filter</h2>
</div>
<form method="get" action="/publications" class="inline-actions">
<label for="mode">Mode</label>
<select id="mode" name="mode">
<option value="new" {% if mode == 'new' %}selected{% endif %}>New Since Last Run</option>
<option value="all" {% if mode == 'all' %}selected{% endif %}>All Publications</option>
</select>
<label for="scholar_profile_id">Scholar</label>
<select id="scholar_profile_id" name="scholar_profile_id">
<option value="">All Scholars</option>
{% for scholar in scholars %}
<option value="{{ scholar.id }}" {% if selected_scholar_id == scholar.id %}selected{% endif %}>
{{ scholar.display_name or scholar.scholar_id }}
</option>
{% endfor %}
</select>
<button type="submit" data-loading-text="Filtering...">Apply</button>
</form>
<div class="section-heading">
<h2>Results</h2>
</div>
{% if publications %}
<table>
<thead>
<tr>
<th>Title</th>
<th>Scholar</th>
<th>Year</th>
<th>Citations</th>
<th>New This Run</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for item in publications %}
<tr>
<td>
{% if item.pub_url %}
<a href="{{ item.pub_url }}" target="_blank" rel="noreferrer">{{ item.title }}</a>
{% else %}
{{ item.title }}
{% endif %}
{% if item.venue_text %}
<p class="helper-text">{{ item.venue_text }}</p>
{% endif %}
</td>
<td>{{ item.scholar_label }}</td>
<td>{{ item.year if item.year is not none else "-" }}</td>
<td>{{ item.citation_count }}</td>
<td>
{% if item.is_new_in_latest_run %}
<span class="badge warn">new</span>
{% else %}
<span class="badge">older</span>
{% endif %}
</td>
<td>
{% if item.is_read %}
<span class="badge ok">read</span>
{% else %}
<span class="badge warn">new</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No publications match this mode yet.</p>
{% endif %}
</section>
{% endblock %}

View file

@ -1,98 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero" data-test="run-detail-page">
<p class="eyebrow">Diagnostics</p>
<h1>Run #{{ run.id }}</h1>
<p class="lede">Detailed state and failure context for this execution.</p>
</section>
<section class="sandbox" data-test="run-detail-summary">
<h2>Summary</h2>
<table>
<tbody>
<tr><th>Started</th><td>{{ run.started_at }}</td></tr>
<tr><th>Finished</th><td>{{ run.finished_at }}</td></tr>
<tr><th>Status</th><td><span class="badge {{ run.status_badge }}">{{ run.status }}</span></td></tr>
<tr><th>Trigger</th><td>{{ run.trigger_type }}</td></tr>
<tr><th>Scholars</th><td>{{ run.scholar_count }}</td></tr>
<tr><th>New Publications</th><td>{{ run.new_publication_count }}</td></tr>
<tr><th>Succeeded</th><td>{{ run_summary.succeeded_count or 0 }}</td></tr>
<tr><th>Failed</th><td>{{ run_summary.failed_count or 0 }}</td></tr>
<tr><th>Partial</th><td>{{ run_summary.partial_count or 0 }}</td></tr>
</tbody>
</table>
{% if run_summary.failed_state_counts or run_summary.failed_reason_counts %}
<details>
<summary>Failure Breakdown</summary>
<pre>{{ {"failed_state_counts": run_summary.failed_state_counts, "failed_reason_counts": run_summary.failed_reason_counts} | tojson(indent=2) }}</pre>
</details>
{% endif %}
</section>
<section class="sandbox" data-test="run-detail-results">
<h2>Scholar Results</h2>
{% if scholar_results %}
<table>
<thead>
<tr>
<th>Scholar</th>
<th>Outcome</th>
<th>State</th>
<th>Reason</th>
<th>Publications</th>
<th>Pages</th>
<th>Attempts</th>
<th>Continuation</th>
<th>Warnings</th>
</tr>
</thead>
<tbody>
{% for item in scholar_results %}
<tr>
<td><code>{{ item.scholar_id }}</code></td>
<td>{{ item.outcome or "-" }}</td>
<td>{{ item.state }}</td>
<td>{{ item.state_reason or "-" }}</td>
<td>{{ item.publication_count }}</td>
<td>{{ item.pages_fetched or 0 }} / {{ item.pages_attempted or 0 }}</td>
<td>{{ item.attempt_count or 1 }}</td>
<td>
{% if item.continuation_enqueued %}
<span class="badge warn">queued</span>
<p class="helper-text">
reason: {{ item.continuation_reason or "-" }},
cstart: {{ item.continuation_cstart or "-" }}
</p>
{% elif item.continuation_cleared %}
<span class="badge ok">cleared</span>
{% else %}
-
{% endif %}
</td>
<td>
{% if item.warnings %}
{{ item.warnings | join(", ") }}
{% else %}
-
{% endif %}
</td>
</tr>
{% if item.debug %}
<tr>
<td colspan="9">
<details>
<summary>Debug Context</summary>
<pre>{{ item.debug | tojson(indent=2) }}</pre>
</details>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
{% else %}
<p>No scholar result details were captured.</p>
{% endif %}
</section>
{% endblock %}

View file

@ -1,129 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero" data-test="runs-page">
<p class="eyebrow">Runs</p>
<h1>Run History</h1>
<p class="lede">Review execution outcomes and drill into run diagnostics when needed.</p>
</section>
<section class="sandbox" data-test="runs-filter-card">
<h2>Filters</h2>
<form method="get" action="/runs" class="inline-actions">
<label class="checkbox-row" for="failed_only">
<input id="failed_only" type="checkbox" name="failed_only" value="1" {% if failed_only %}checked{% endif %}>
<span>Failed / partial only</span>
</label>
<button type="submit" data-loading-text="Filtering...">Apply</button>
{% if failed_only %}
<a class="link-button" href="/runs">Clear</a>
{% endif %}
</form>
</section>
<section class="sandbox" data-test="runs-table-card">
<h2>Recent Runs</h2>
{% if runs %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Started</th>
<th>Status</th>
<th>Trigger</th>
<th>Scholars</th>
<th>New Publications</th>
<th>Failures</th>
</tr>
</thead>
<tbody>
{% for run in runs %}
<tr>
<td><a href="/runs/{{ run.id }}">#{{ run.id }}</a></td>
<td>{{ run.started_at }}</td>
<td><span class="badge {{ run.status_badge }}">{{ run.status }}</span></td>
<td>{{ run.trigger_type }}</td>
<td>{{ run.scholar_count }}</td>
<td>{{ run.new_publication_count }}</td>
<td>{{ run.failed_count }} failed / {{ run.partial_count }} partial</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No runs match the current filter.</p>
{% endif %}
</section>
<section class="sandbox" data-test="runs-queue-card">
<div class="section-heading">
<h2>Continuation Queue</h2>
<a class="link-button" href="/runs?failed_only=1">Focus Failed Runs</a>
</div>
{% if queue_items %}
<table>
<thead>
<tr>
<th>Scholar</th>
<th>Status</th>
<th>Reason</th>
<th>Attempts</th>
<th>Next Attempt</th>
<th>Last Error</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for item in queue_items %}
<tr>
<td>
<a href="/publications?mode=new&scholar_profile_id={{ item.scholar_profile_id }}">{{ item.scholar_label }}</a>
<p class="helper-text">resume cstart: {{ item.resume_cstart }}</p>
</td>
<td><span class="badge {{ item.status_badge }}">{{ item.status }}</span></td>
<td>
{% if item.status == "dropped" and item.dropped_reason %}
<code>{{ item.dropped_reason }}</code>
{% else %}
<code>{{ item.reason }}</code>
{% endif %}
</td>
<td>{{ item.attempt_count }}</td>
<td>{{ item.next_attempt_at }}</td>
<td>
{% if item.last_error %}
<details>
<summary>show</summary>
<pre>{{ item.last_error }}</pre>
</details>
{% else %}
-
{% endif %}
</td>
<td>
<div class="inline-actions">
<form method="post" action="/runs/queue/{{ item.id }}/retry">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" data-loading-text="Retrying...">Retry Now</button>
</form>
{% if item.status != "dropped" %}
<form method="post" action="/runs/queue/{{ item.id }}/drop">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="danger-button" data-loading-text="Dropping...">Drop</button>
</form>
{% endif %}
<form method="post" action="/runs/queue/{{ item.id }}/clear">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="button-text" data-loading-text="Clearing...">Clear</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No queued continuation items.</p>
{% endif %}
</section>
{% endblock %}

View file

@ -1,99 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero" data-test="scholars-page">
<p class="eyebrow">Scholars</p>
<h1>Your Scholars</h1>
<p class="lede">Add, disable, or remove profiles scoped to your account.</p>
</section>
<section class="sandbox" data-test="scholars-create-card">
<h2>Add Scholar</h2>
<form method="post" action="/scholars" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="scholar_id">Scholar ID</label>
<input
id="scholar_id"
name="scholar_id"
type="text"
value="{{ form_scholar_id | default('') }}"
placeholder="abcDEF123456"
pattern="[a-zA-Z0-9_-]{12}"
required
>
<label for="display_name">Display Name (Optional)</label>
<input
id="display_name"
name="display_name"
type="text"
value="{{ form_display_name | default('') }}"
placeholder="Ada Lovelace"
>
<button type="submit" data-loading-text="Adding...">Add Scholar</button>
</form>
</section>
<section class="sandbox" data-test="scholars-list-card">
<h2>Tracked Scholars</h2>
{% if scholars %}
<table>
<thead>
<tr>
<th>Name</th>
<th>Scholar ID</th>
<th>Status</th>
<th>Last Run</th>
<th>Publications</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for scholar in scholars %}
<tr>
<td>{{ scholar.display_name }}</td>
<td><code>{{ scholar.scholar_id }}</code></td>
<td>
{% if scholar.is_enabled %}
<span class="badge ok">enabled</span>
{% else %}
<span class="badge warn">disabled</span>
{% endif %}
{% if scholar.baseline_completed %}
<p class="helper-text">baseline complete</p>
{% else %}
<p class="helper-text">awaiting first run</p>
{% endif %}
</td>
<td>
<span class="badge {% if scholar.last_run_status == 'success' %}ok{% elif scholar.last_run_status in ['failed', 'partial_failure'] %}danger{% else %}warn{% endif %}">
{{ scholar.last_run_status }}
</span>
<p class="helper-text">{{ scholar.last_run_dt }}</p>
</td>
<td>
<div class="inline-actions">
<a class="link-button" href="/publications?mode=new&scholar_profile_id={{ scholar.id }}">New</a>
<a class="link-button" href="/publications?mode=all&scholar_profile_id={{ scholar.id }}">All</a>
</div>
</td>
<td>
<div class="inline-actions">
<form method="post" action="/scholars/{{ scholar.id }}/toggle">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" data-loading-text="Saving...">{% if scholar.is_enabled %}Disable{% else %}Enable{% endif %}</button>
</form>
<form method="post" action="/scholars/{{ scholar.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="danger-button" data-loading-text="Deleting...">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p>No scholars tracked yet.</p>
{% endif %}
</section>
{% endblock %}

View file

@ -1,44 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero" data-test="settings-page">
<p class="eyebrow">Settings</p>
<h1>Your Settings</h1>
<p class="lede">Control automation cadence and request pacing for your own account.</p>
</section>
<section class="sandbox" data-test="settings-form-card">
<h2>Automation Settings</h2>
<form method="post" action="/settings" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label class="checkbox-row" for="auto_run_enabled">
<input id="auto_run_enabled" name="auto_run_enabled" type="checkbox" {% if user_settings.auto_run_enabled %}checked{% endif %}>
<span>Enable automatic runs</span>
</label>
<label for="run_interval_minutes">Run Interval (minutes)</label>
<input
id="run_interval_minutes"
name="run_interval_minutes"
type="number"
min="15"
step="1"
value="{{ user_settings.run_interval_minutes }}"
required
>
<label for="request_delay_seconds">Request Delay (seconds)</label>
<input
id="request_delay_seconds"
name="request_delay_seconds"
type="number"
min="1"
step="1"
value="{{ user_settings.request_delay_seconds }}"
required
>
<button type="submit" data-loading-text="Saving...">Save Settings</button>
</form>
</section>
{% endblock %}

View file

@ -1,75 +0,0 @@
{% extends "base.html" %}
{% block content %}
<section class="hero" data-test="users-page">
<p class="eyebrow">Admin</p>
<h1>User Management</h1>
<p class="lede">Admin-only controls for account creation, activation, and password resets.</p>
</section>
<section class="sandbox" data-test="users-create-card">
<h2>Create User</h2>
<form method="post" action="/users" class="stack-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="email">Email</label>
<input id="email" name="email" type="email" value="{{ form_email | default('') }}" required>
<label for="password">Temporary Password</label>
<input id="password" name="password" type="password" required>
<label class="checkbox-row" for="is_admin">
<input id="is_admin" name="is_admin" type="checkbox" {% if form_is_admin %}checked{% endif %}>
<span>Admin account</span>
</label>
<button type="submit" data-loading-text="Creating...">Create User</button>
</form>
</section>
<section class="sandbox" data-test="users-list-card">
<h2>Users</h2>
<table>
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.email }}</td>
<td>
{% if user.is_admin %}
<span class="badge ok">admin</span>
{% else %}
<span class="badge">user</span>
{% endif %}
</td>
<td>
{% if user.is_active %}
<span class="badge ok">active</span>
{% else %}
<span class="badge warn">inactive</span>
{% endif %}
</td>
<td>
<div class="inline-actions">
<form method="post" action="/users/{{ user.id }}/toggle-active">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" data-loading-text="Saving..." {% if user.id == current_user_id and user.is_active %}disabled{% endif %}>
{% if user.is_active %}Deactivate{% else %}Activate{% endif %}
</button>
</form>
<form method="post" action="/users/{{ user.id }}/reset-password" class="inline-reset-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input name="new_password" type="password" placeholder="New password" minlength="8" required>
<button type="submit" data-loading-text="Resetting...">Reset Password</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View file

@ -1,24 +0,0 @@
from dataclasses import dataclass
from typing import Final
@dataclass(frozen=True)
class ThemeDefinition:
slug: str
label: str
THEMES: Final[tuple[ThemeDefinition, ...]] = (
ThemeDefinition(slug="terracotta", label="Terracotta"),
ThemeDefinition(slug="fjord", label="Fjord"),
ThemeDefinition(slug="spruce", label="Spruce"),
)
_THEME_SLUGS: Final[frozenset[str]] = frozenset(theme.slug for theme in THEMES)
DEFAULT_THEME: Final[str] = THEMES[0].slug
def resolve_theme(candidate: str | None) -> str:
if candidate and candidate in _THEME_SLUGS:
return candidate
return DEFAULT_THEME

View file

@ -1,2 +0,0 @@
"""Web-layer routers, middleware, and shared helpers."""

View file

@ -1,140 +0,0 @@
from __future__ import annotations
import logging
from urllib.parse import urlencode
from fastapi import Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.session import (
SessionUser,
clear_session_user,
get_session_user,
set_session_user,
)
from app.db.models import User
from app.security.csrf import CSRF_SESSION_KEY, ensure_csrf_token
from app.services import users as user_service
from app.theme import THEMES
logger = logging.getLogger(__name__)
templates = Jinja2Templates(directory="app/templates")
def to_session_user(user: User | None) -> SessionUser | None:
if user is None:
return None
return SessionUser(id=user.id, email=user.email, is_admin=user.is_admin)
def build_template_context(
request: Request,
*,
page_title: str,
active_nav: str,
theme_name: str,
session_user: SessionUser | None,
notice: str | None = None,
page_error: str | None = None,
) -> dict[str, object]:
return {
"active_nav": active_nav,
"page_title": page_title,
"theme_name": theme_name,
"themes": THEMES,
"session_user": session_user,
"csrf_token": ensure_csrf_token(request),
"notice": notice,
"page_error": page_error,
}
def redirect_with_message(
path: str,
*,
notice: str | None = None,
error: str | None = None,
) -> RedirectResponse:
params: dict[str, str] = {}
if notice:
params["notice"] = notice
if error:
params["error"] = error
if params:
path = f"{path}?{urlencode(params)}"
return RedirectResponse(path, status_code=status.HTTP_303_SEE_OTHER)
def redirect_to_login() -> RedirectResponse:
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
def invalidate_session(request: Request) -> None:
clear_session_user(request)
request.session.pop(CSRF_SESSION_KEY, None)
async def get_authenticated_user(
request: Request,
db_session: AsyncSession,
) -> User | None:
session_user = get_session_user(request)
if session_user is None:
return None
user = await user_service.get_user_by_id(db_session, session_user.id)
if user is None or not user.is_active:
logger.info(
"auth.session_invalidated",
extra={
"event": "auth.session_invalidated",
"session_user_id": session_user.id,
},
)
invalidate_session(request)
return None
if user.email != session_user.email or user.is_admin != session_user.is_admin:
set_session_user(
request,
user_id=user.id,
email=user.email,
is_admin=user.is_admin,
)
return user
def login_rate_limit_key(request: Request, email: str) -> str:
client_host = request.client.host if request.client is not None else "unknown"
normalized_email = email.strip().lower()
return f"{client_host}:{normalized_email or '<empty>'}"
def render_login_page(
request: Request,
*,
theme_name: str,
error_message: str | None = None,
status_code: int = status.HTTP_200_OK,
retry_after_seconds: int | None = None,
) -> HTMLResponse:
context = build_template_context(
request,
page_title="Login",
active_nav="login",
theme_name=theme_name,
session_user=None,
)
context["error_message"] = error_message
context["retry_after_seconds"] = retry_after_seconds
return templates.TemplateResponse(
request=request,
name="login.html",
context=context,
status_code=status_code,
)

View file

@ -1,2 +0,0 @@
"""Application routers grouped by concern."""

View file

@ -1,209 +0,0 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import get_auth_service
from app.auth.service import AuthService
from app.db.session import get_db_session
from app.services import users as user_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
async def _render_users_page(
request: Request,
*,
db_session: AsyncSession,
current_user,
theme_name: str,
notice: str | None = None,
page_error: str | None = None,
status_code: int = status.HTTP_200_OK,
form_email: str = "",
form_is_admin: bool = False,
) -> HTMLResponse:
users = await user_service.list_users(db_session)
context = common.build_template_context(
request,
page_title="Users",
active_nav="users",
theme_name=theme_name,
session_user=common.to_session_user(current_user),
notice=notice,
page_error=page_error,
)
context["users"] = users
context["current_user_id"] = current_user.id
context["form_email"] = form_email
context["form_is_admin"] = form_is_admin
return common.templates.TemplateResponse(
request=request,
name="users.html",
context=context,
status_code=status_code,
)
@router.get("/users", response_class=HTMLResponse)
async def users_page(
request: Request,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required.")
return await _render_users_page(
request,
db_session=db_session,
current_user=current_user,
theme_name=resolve_theme(theme),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
@router.post("/users")
async def create_user(
request: Request,
email: Annotated[str, Form()],
password: Annotated[str, Form()],
is_admin: Annotated[str | None, Form()] = None,
db_session: AsyncSession = Depends(get_db_session),
auth_service: AuthService = Depends(get_auth_service),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required.")
active_theme = resolve_theme(None)
try:
validated_email = user_service.validate_email(email)
validated_password = user_service.validate_password(password)
created_user = await user_service.create_user(
db_session,
email=validated_email,
password_hash=auth_service.hash_password(validated_password),
is_admin=is_admin == "on",
)
except user_service.UserServiceError as exc:
logger.info(
"admin.user_create_failed",
extra={
"event": "admin.user_create_failed",
"admin_user_id": current_user.id,
"reason": "validation_or_conflict",
},
)
return await _render_users_page(
request,
db_session=db_session,
current_user=current_user,
theme_name=active_theme,
page_error=str(exc),
status_code=status.HTTP_400_BAD_REQUEST,
form_email=email,
form_is_admin=is_admin == "on",
)
logger.info(
"admin.user_created",
extra={
"event": "admin.user_created",
"admin_user_id": current_user.id,
"target_user_id": created_user.id,
"target_is_admin": created_user.is_admin,
},
)
return common.redirect_with_message(
"/users",
notice=f"User created: {created_user.email}",
)
@router.post("/users/{user_id}/toggle-active")
async def toggle_user_active(
request: Request,
user_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required.")
target_user = await user_service.get_user_by_id(db_session, user_id)
if target_user is None:
return common.redirect_with_message("/users", error="User not found.")
if target_user.id == current_user.id and target_user.is_active:
return common.redirect_with_message("/users", error="You cannot deactivate your own account.")
updated_user = await user_service.set_user_active(
db_session,
user=target_user,
is_active=not target_user.is_active,
)
status_label = "activated" if updated_user.is_active else "deactivated"
logger.info(
"admin.user_active_toggled",
extra={
"event": "admin.user_active_toggled",
"admin_user_id": current_user.id,
"target_user_id": updated_user.id,
"is_active": updated_user.is_active,
},
)
return common.redirect_with_message("/users", notice=f"User {status_label}: {updated_user.email}")
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
request: Request,
user_id: int,
new_password: Annotated[str, Form()],
db_session: AsyncSession = Depends(get_db_session),
auth_service: AuthService = Depends(get_auth_service),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required.")
target_user = await user_service.get_user_by_id(db_session, user_id)
if target_user is None:
return common.redirect_with_message("/users", error="User not found.")
try:
validated_password = user_service.validate_password(new_password)
except user_service.UserServiceError as exc:
return common.redirect_with_message("/users", error=str(exc))
await user_service.set_user_password_hash(
db_session,
user=target_user,
password_hash=auth_service.hash_password(validated_password),
)
logger.info(
"admin.user_password_reset",
extra={
"event": "admin.user_password_reset",
"admin_user_id": current_user.id,
"target_user_id": target_user.id,
},
)
return common.redirect_with_message("/users", notice=f"Password reset: {target_user.email}")

View file

@ -1,214 +0,0 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.deps import get_auth_service, get_login_rate_limiter
from app.auth.rate_limit import SlidingWindowRateLimiter
from app.auth.service import AuthService
from app.auth.session import get_session_user, set_session_user
from app.db.session import get_db_session
from app.security.csrf import ensure_csrf_token
from app.services import users as user_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, theme: str | None = None) -> HTMLResponse:
active_theme = resolve_theme(theme)
ensure_csrf_token(request)
if get_session_user(request) is not None:
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
return common.render_login_page(request, theme_name=active_theme)
@router.post("/login", response_class=HTMLResponse)
async def login(
request: Request,
email: Annotated[str, Form()],
password: Annotated[str, Form()],
db_session: AsyncSession = Depends(get_db_session),
auth_service: AuthService = Depends(get_auth_service),
rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter),
) -> HTMLResponse:
active_theme = resolve_theme(None)
limiter_key = common.login_rate_limit_key(request, email)
decision = rate_limiter.check(limiter_key)
normalized_email = email.strip().lower()
if not decision.allowed:
logger.warning(
"auth.login_rate_limited",
extra={
"event": "auth.login_rate_limited",
"email": normalized_email,
"retry_after_seconds": decision.retry_after_seconds,
},
)
response = common.render_login_page(
request,
theme_name=active_theme,
error_message="Too many login attempts. Please try again later.",
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
retry_after_seconds=decision.retry_after_seconds,
)
response.headers["Retry-After"] = str(decision.retry_after_seconds)
return response
user = await auth_service.authenticate_user(
db_session,
email=email,
password=password,
)
if user is None:
rate_limiter.record_failure(limiter_key)
logger.info(
"auth.login_failed",
extra={
"event": "auth.login_failed",
"email": normalized_email,
},
)
return common.render_login_page(
request,
theme_name=active_theme,
error_message="Invalid email or password.",
status_code=status.HTTP_401_UNAUTHORIZED,
)
rate_limiter.reset(limiter_key)
set_session_user(
request,
user_id=user.id,
email=user.email,
is_admin=user.is_admin,
)
ensure_csrf_token(request)
logger.info(
"auth.login_succeeded",
extra={
"event": "auth.login_succeeded",
"user_id": user.id,
"is_admin": user.is_admin,
},
)
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
@router.post("/logout")
async def logout(request: Request) -> RedirectResponse:
session_user = get_session_user(request)
common.invalidate_session(request)
logger.info(
"auth.logout",
extra={
"event": "auth.logout",
"user_id": session_user.id if session_user else None,
},
)
return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
@router.get("/account/password", response_class=HTMLResponse)
async def account_password_page(
request: Request,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
return common.templates.TemplateResponse(
request=request,
name="account_password.html",
context=common.build_template_context(
request,
page_title="Change Password",
active_nav="account_password",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
),
)
@router.post("/account/password")
async def update_account_password(
request: Request,
current_password: Annotated[str, Form()],
new_password: Annotated[str, Form()],
confirm_password: Annotated[str, Form()],
db_session: AsyncSession = Depends(get_db_session),
auth_service: AuthService = Depends(get_auth_service),
) -> RedirectResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
if not auth_service.verify_password(
password_hash=current_user.password_hash,
password=current_password,
):
logger.info(
"account.password_change_failed",
extra={
"event": "account.password_change_failed",
"user_id": current_user.id,
"reason": "invalid_current_password",
},
)
return common.redirect_with_message(
"/account/password",
error="Current password is incorrect.",
)
if new_password != confirm_password:
logger.info(
"account.password_change_failed",
extra={
"event": "account.password_change_failed",
"user_id": current_user.id,
"reason": "confirmation_mismatch",
},
)
return common.redirect_with_message(
"/account/password",
error="New password and confirmation do not match.",
)
try:
validated_password = user_service.validate_password(new_password)
except user_service.UserServiceError as exc:
logger.info(
"account.password_change_failed",
extra={
"event": "account.password_change_failed",
"user_id": current_user.id,
"reason": "validation_error",
},
)
return common.redirect_with_message("/account/password", error=str(exc))
await user_service.set_user_password_hash(
db_session,
user=current_user,
password_hash=auth_service.hash_password(validated_password),
)
logger.info(
"account.password_changed",
extra={
"event": "account.password_changed",
"user_id": current_user.id,
},
)
return common.redirect_with_message(
"/account/password",
notice="Password updated successfully.",
)

View file

@ -1,167 +0,0 @@
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import RunTriggerType
from app.db.session import get_db_session
from app.presentation import dashboard as dashboard_presenter
from app.services import ingestion as ingestion_service
from app.services import publications as publication_service
from app.services import runs as run_service
from app.services import user_settings as user_settings_service
from app.settings import settings
from app.theme import resolve_theme
from app.web import common
from app.web.deps import get_ingestion_service
logger = logging.getLogger(__name__)
router = APIRouter()
async def _render_dashboard_page(
request: Request,
*,
db_session: AsyncSession,
current_user,
theme_name: str,
notice: str | None = None,
page_error: str | None = None,
) -> HTMLResponse:
unread_publications = await publication_service.list_new_for_latest_run_for_user(
db_session,
user_id=current_user.id,
limit=50,
)
recent_runs = await run_service.list_recent_runs_for_user(
db_session,
user_id=current_user.id,
limit=20,
)
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
queue_counts = await run_service.queue_status_counts_for_user(
db_session,
user_id=current_user.id,
)
context = common.build_template_context(
request,
page_title="Dashboard",
active_nav="home",
theme_name=theme_name,
session_user=common.to_session_user(current_user),
notice=notice,
page_error=page_error,
)
context["dashboard"] = dashboard_presenter.build_dashboard_view_model(
unread_publications=unread_publications,
recent_runs=recent_runs,
request_delay_seconds=user_settings.request_delay_seconds,
queue_counts=queue_counts,
)
return common.templates.TemplateResponse(
request=request,
name="index.html",
context=context,
)
@router.get("/", response_class=HTMLResponse)
async def home(
request: Request,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
return await _render_dashboard_page(
request,
db_session=db_session,
current_user=current_user,
theme_name=resolve_theme(theme),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
@router.post("/runs/manual")
async def run_manual_ingestion(
request: Request,
db_session: AsyncSession = Depends(get_db_session),
ingest_service: ingestion_service.ScholarIngestionService = Depends(get_ingestion_service),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
logger.info(
"runs.manual_started",
extra={
"event": "runs.manual_started",
"user_id": current_user.id,
"request_delay_seconds": user_settings.request_delay_seconds,
"network_error_retries": settings.ingestion_network_error_retries,
"max_pages_per_scholar": settings.ingestion_max_pages_per_scholar,
"page_size": settings.ingestion_page_size,
},
)
try:
run_summary = await ingest_service.run_for_user(
db_session,
user_id=current_user.id,
trigger_type=RunTriggerType.MANUAL,
request_delay_seconds=user_settings.request_delay_seconds,
network_error_retries=settings.ingestion_network_error_retries,
retry_backoff_seconds=settings.ingestion_retry_backoff_seconds,
max_pages_per_scholar=settings.ingestion_max_pages_per_scholar,
page_size=settings.ingestion_page_size,
auto_queue_continuations=settings.ingestion_continuation_queue_enabled,
queue_delay_seconds=settings.ingestion_continuation_base_delay_seconds,
)
except ingestion_service.RunAlreadyInProgressError:
await db_session.rollback()
return common.redirect_with_message(
"/",
error="A run is already in progress for this account.",
)
except Exception:
await db_session.rollback()
logger.exception(
"runs.manual_failed",
extra={
"event": "runs.manual_failed",
"user_id": current_user.id,
},
)
return common.redirect_with_message("/", error="Manual run failed. Check logs for details.")
logger.info(
"runs.manual_completed",
extra={
"event": "runs.manual_completed",
"user_id": current_user.id,
"run_id": run_summary.crawl_run_id,
"status": run_summary.status.value,
"scholar_count": run_summary.scholar_count,
"new_publication_count": run_summary.new_publication_count,
},
)
return common.redirect_with_message(
"/",
notice=(
f"Run #{run_summary.crawl_run_id} complete ({run_summary.status.value}). "
f"Scholars: {run_summary.scholar_count}, "
f"new publications: {run_summary.new_publication_count}."
),
)

View file

@ -1,15 +0,0 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from app.db.session import check_database
router = APIRouter()
@router.get("/healthz")
async def healthz() -> dict[str, str]:
if await check_database():
return {"status": "ok"}
raise HTTPException(status_code=500, detail="database unavailable")

View file

@ -1,165 +0,0 @@
from __future__ import annotations
import logging
from urllib.parse import urlencode, urlparse
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import publications as publication_service
from app.services import scholars as scholar_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
MODE_NEW = "new"
MODE_ALL = "all"
def _resolve_mode(raw_mode: str | None) -> str:
return publication_service.resolve_mode(raw_mode)
def _parse_scholar_profile_id(value: str | None) -> int | None:
if not value:
return None
try:
parsed = int(value)
except ValueError:
return None
return parsed if parsed > 0 else None
def _build_publications_url(*, mode: str, scholar_profile_id: int | None) -> str:
params: dict[str, str] = {"mode": mode}
if scholar_profile_id is not None:
params["scholar_profile_id"] = str(scholar_profile_id)
return f"/publications?{urlencode(params)}"
def _is_safe_return_to(value: str | None) -> bool:
if not value:
return False
parsed = urlparse(value)
if parsed.scheme or parsed.netloc:
return False
if not value.startswith("/"):
return False
return value == "/" or value.startswith("/publications")
@router.get("/publications", response_class=HTMLResponse)
async def publications_page(
request: Request,
mode: str | None = None,
scholar_profile_id: str | None = None,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
resolved_mode = _resolve_mode(mode)
scholar_id_filter = _parse_scholar_profile_id(scholar_profile_id)
scholars = await scholar_service.list_scholars_for_user(
db_session,
user_id=current_user.id,
)
scholar_lookup = {scholar.id: scholar for scholar in scholars}
if scholar_id_filter not in scholar_lookup:
scholar_id_filter = None
publications = await publication_service.list_for_user(
db_session,
user_id=current_user.id,
mode=resolved_mode,
scholar_profile_id=scholar_id_filter,
limit=500,
)
new_count = await publication_service.count_for_user(
db_session,
user_id=current_user.id,
mode=MODE_NEW,
scholar_profile_id=scholar_id_filter,
)
total_count = await publication_service.count_for_user(
db_session,
user_id=current_user.id,
mode=MODE_ALL,
scholar_profile_id=scholar_id_filter,
)
mode_new_url = _build_publications_url(
mode=MODE_NEW,
scholar_profile_id=scholar_id_filter,
)
mode_all_url = _build_publications_url(
mode=MODE_ALL,
scholar_profile_id=scholar_id_filter,
)
selected_scholar = scholar_lookup.get(scholar_id_filter) if scholar_id_filter else None
current_publications_url = _build_publications_url(
mode=resolved_mode,
scholar_profile_id=scholar_id_filter,
)
context = common.build_template_context(
request,
page_title="Publications",
active_nav="publications",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
context["mode"] = resolved_mode
context["publications"] = publications
context["new_count"] = new_count
context["total_count"] = total_count
context["mode_new_url"] = mode_new_url
context["mode_all_url"] = mode_all_url
context["scholars"] = scholars
context["selected_scholar_id"] = scholar_id_filter
context["selected_scholar"] = selected_scholar
context["current_publications_url"] = current_publications_url
return common.templates.TemplateResponse(
request=request,
name="publications.html",
context=context,
)
@router.post("/publications/mark-all-read")
async def mark_all_publications_read(
request: Request,
return_to: str | None = Form(default=None),
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
updated_count = await publication_service.mark_all_unread_as_read_for_user(
db_session,
user_id=current_user.id,
)
logger.info(
"publications.mark_all_read",
extra={
"event": "publications.mark_all_read",
"user_id": current_user.id,
"updated_count": updated_count,
},
)
redirect_target = "/publications?mode=new"
if _is_safe_return_to(return_to):
redirect_target = return_to
return common.redirect_with_message(
redirect_target,
notice=f"Marked {updated_count} publication(s) as read.",
)

View file

@ -1,274 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import runs as run_service
from app.theme import resolve_theme
from app.web import common
router = APIRouter()
def _as_bool(value: str | None) -> bool:
if value is None:
return False
return value.strip().lower() in {"1", "true", "yes", "on"}
def _status_badge(status_value: str) -> str:
if status_value == "success":
return "ok"
if status_value == "failed":
return "danger"
return "warn"
def _queue_status_badge(status_value: str) -> str:
if status_value == "dropped":
return "danger"
if status_value == "retrying":
return "ok"
return "warn"
def _format_dt(value: datetime | None) -> str:
if value is None:
return "-"
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
def _summary_dict(error_log: object) -> dict[str, object]:
if not isinstance(error_log, dict):
return {}
summary = error_log.get("summary")
if not isinstance(summary, dict):
return {}
return summary
@router.get("/runs", response_class=HTMLResponse)
async def runs_page(
request: Request,
theme: str | None = None,
failed_only: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
failed_only_enabled = _as_bool(failed_only)
runs = await run_service.list_runs_for_user(
db_session,
user_id=current_user.id,
limit=200,
failed_only=failed_only_enabled,
)
run_items = []
for run in runs:
summary = _summary_dict(run.error_log)
failed_count = summary.get("failed_count", 0)
partial_count = summary.get("partial_count", 0)
run_items.append(
{
"id": run.id,
"started_at": _format_dt(run.start_dt),
"finished_at": _format_dt(run.end_dt),
"status": run.status.value,
"status_badge": _status_badge(run.status.value),
"trigger_type": run.trigger_type.value,
"scholar_count": run.scholar_count,
"new_publication_count": run.new_pub_count,
"failed_count": failed_count,
"partial_count": partial_count,
}
)
queue_entries = await run_service.list_queue_items_for_user(
db_session,
user_id=current_user.id,
limit=200,
)
queue_items = []
for item in queue_entries:
queue_items.append(
{
"id": item.id,
"scholar_profile_id": item.scholar_profile_id,
"scholar_label": item.scholar_label,
"status": item.status,
"status_badge": _queue_status_badge(item.status),
"reason": item.reason,
"dropped_reason": item.dropped_reason,
"attempt_count": item.attempt_count,
"resume_cstart": item.resume_cstart,
"next_attempt_at": _format_dt(item.next_attempt_dt),
"updated_at": _format_dt(item.updated_at),
"last_error": item.last_error,
"last_run_id": item.last_run_id,
}
)
context = common.build_template_context(
request,
page_title="Runs",
active_nav="runs",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
context["runs"] = run_items
context["failed_only"] = failed_only_enabled
context["queue_items"] = queue_items
return common.templates.TemplateResponse(
request=request,
name="runs.html",
context=context,
)
@router.get("/runs/{run_id}", response_class=HTMLResponse)
async def run_detail_page(
request: Request,
run_id: int,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
run = await run_service.get_run_for_user(
db_session,
user_id=current_user.id,
run_id=run_id,
)
if run is None:
raise HTTPException(status_code=404, detail="Run not found.")
error_log = run.error_log if isinstance(run.error_log, dict) else {}
scholar_results = error_log.get("scholar_results", [])
if not isinstance(scholar_results, list):
scholar_results = []
context = common.build_template_context(
request,
page_title=f"Run #{run.id}",
active_nav="runs",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
)
context["run"] = {
"id": run.id,
"started_at": _format_dt(run.start_dt),
"finished_at": _format_dt(run.end_dt),
"status": run.status.value,
"status_badge": _status_badge(run.status.value),
"trigger_type": run.trigger_type.value,
"scholar_count": run.scholar_count,
"new_publication_count": run.new_pub_count,
}
context["run_summary"] = _summary_dict(error_log)
context["scholar_results"] = scholar_results
return common.templates.TemplateResponse(
request=request,
name="run_detail.html",
context=context,
)
@router.post("/runs/queue/{queue_item_id}/drop")
async def drop_queue_item(
request: Request,
queue_item_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
try:
dropped = await run_service.drop_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if dropped is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=f"Queue item #{queue_item_id} marked as dropped.",
)
@router.post("/runs/queue/{queue_item_id}/clear")
async def clear_queue_item(
request: Request,
queue_item_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
try:
cleared = await run_service.clear_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if cleared is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=f"Queue item #{queue_item_id} cleared.",
)
@router.post("/runs/queue/{queue_item_id}/retry")
async def retry_queue_item(
request: Request,
queue_item_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
try:
queue_item = await run_service.retry_queue_item_for_user(
db_session,
user_id=current_user.id,
queue_item_id=queue_item_id,
)
except run_service.QueueTransitionError as exc:
return common.redirect_with_message(
"/runs",
error=f"Queue item #{queue_item_id}: {exc.message}",
)
if queue_item is None:
raise HTTPException(status_code=404, detail="Queue item not found.")
return common.redirect_with_message(
"/runs",
notice=(
f"Queue item #{queue_item_id} queued for retry "
f"(scholar: {queue_item.scholar_label})."
),
)

View file

@ -1,212 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import scholars as scholar_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
def _format_dt(value: datetime | None) -> str:
if value is None:
return "-"
return value.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
async def _render_scholars_page(
request: Request,
*,
db_session: AsyncSession,
current_user,
theme_name: str,
notice: str | None = None,
page_error: str | None = None,
status_code: int = status.HTTP_200_OK,
form_scholar_id: str = "",
form_display_name: str = "",
) -> HTMLResponse:
scholars = await scholar_service.list_scholars_for_user(
db_session,
user_id=current_user.id,
)
scholar_items = []
for scholar in scholars:
scholar_items.append(
{
"id": scholar.id,
"scholar_id": scholar.scholar_id,
"display_name": scholar.display_name or "Unnamed",
"is_enabled": scholar.is_enabled,
"baseline_completed": scholar.baseline_completed,
"last_run_status": (
scholar.last_run_status.value
if scholar.last_run_status is not None
else "never"
),
"last_run_dt": _format_dt(scholar.last_run_dt),
}
)
context = common.build_template_context(
request,
page_title="Scholars",
active_nav="scholars",
theme_name=theme_name,
session_user=common.to_session_user(current_user),
notice=notice,
page_error=page_error,
)
context["scholars"] = scholar_items
context["form_scholar_id"] = form_scholar_id
context["form_display_name"] = form_display_name
return common.templates.TemplateResponse(
request=request,
name="scholars.html",
context=context,
status_code=status_code,
)
@router.get("/scholars", response_class=HTMLResponse)
async def scholars_page(
request: Request,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
return await _render_scholars_page(
request,
db_session=db_session,
current_user=current_user,
theme_name=resolve_theme(theme),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
@router.post("/scholars")
async def create_scholar(
request: Request,
scholar_id: Annotated[str, Form()],
display_name: Annotated[str, Form()] = "",
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
active_theme = resolve_theme(None)
try:
created_profile = await scholar_service.create_scholar_for_user(
db_session,
user_id=current_user.id,
scholar_id=scholar_id,
display_name=display_name,
)
except scholar_service.ScholarServiceError as exc:
logger.info(
"scholars.create_failed",
extra={
"event": "scholars.create_failed",
"user_id": current_user.id,
"scholar_id": scholar_id.strip(),
},
)
return await _render_scholars_page(
request,
db_session=db_session,
current_user=current_user,
theme_name=active_theme,
page_error=str(exc),
status_code=status.HTTP_400_BAD_REQUEST,
form_scholar_id=scholar_id,
form_display_name=display_name,
)
label = created_profile.display_name or created_profile.scholar_id
logger.info(
"scholars.created",
extra={
"event": "scholars.created",
"user_id": current_user.id,
"scholar_profile_id": created_profile.id,
},
)
return common.redirect_with_message("/scholars", notice=f"Scholar added: {label}")
@router.post("/scholars/{scholar_profile_id}/toggle")
async def toggle_scholar(
request: Request,
scholar_profile_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
profile = await scholar_service.get_user_scholar_by_id(
db_session,
user_id=current_user.id,
scholar_profile_id=scholar_profile_id,
)
if profile is None:
raise HTTPException(status_code=404, detail="Scholar not found.")
updated_profile = await scholar_service.toggle_scholar_enabled(
db_session,
profile=profile,
)
status_label = "enabled" if updated_profile.is_enabled else "disabled"
logger.info(
"scholars.toggled",
extra={
"event": "scholars.toggled",
"user_id": current_user.id,
"scholar_profile_id": updated_profile.id,
"is_enabled": updated_profile.is_enabled,
},
)
return common.redirect_with_message(
"/scholars",
notice=f"Scholar {status_label}: {updated_profile.scholar_id}",
)
@router.post("/scholars/{scholar_profile_id}/delete")
async def delete_scholar(
request: Request,
scholar_profile_id: int,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
profile = await scholar_service.get_user_scholar_by_id(
db_session,
user_id=current_user.id,
scholar_profile_id=scholar_profile_id,
)
if profile is None:
raise HTTPException(status_code=404, detail="Scholar not found.")
deleted_label = profile.display_name or profile.scholar_id
await scholar_service.delete_scholar(db_session, profile=profile)
logger.info(
"scholars.deleted",
extra={
"event": "scholars.deleted",
"user_id": current_user.id,
"scholar_profile_id": scholar_profile_id,
},
)
return common.redirect_with_message("/scholars", notice=f"Scholar removed: {deleted_label}")

View file

@ -1,94 +0,0 @@
from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db_session
from app.services import user_settings as user_settings_service
from app.theme import resolve_theme
from app.web import common
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/settings", response_class=HTMLResponse)
async def settings_page(
request: Request,
theme: str | None = None,
db_session: AsyncSession = Depends(get_db_session),
) -> HTMLResponse:
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
context = common.build_template_context(
request,
page_title="Settings",
active_nav="settings",
theme_name=resolve_theme(theme),
session_user=common.to_session_user(current_user),
notice=request.query_params.get("notice"),
page_error=request.query_params.get("error"),
)
context["user_settings"] = user_settings
return common.templates.TemplateResponse(
request=request,
name="settings.html",
context=context,
)
@router.post("/settings")
async def update_settings(
request: Request,
run_interval_minutes: Annotated[str, Form()],
request_delay_seconds: Annotated[str, Form()],
auto_run_enabled: Annotated[str | None, Form()] = None,
db_session: AsyncSession = Depends(get_db_session),
):
current_user = await common.get_authenticated_user(request, db_session)
if current_user is None:
return common.redirect_to_login()
try:
parsed_interval = user_settings_service.parse_run_interval_minutes(
run_interval_minutes
)
parsed_delay = user_settings_service.parse_request_delay_seconds(
request_delay_seconds
)
except user_settings_service.UserSettingsServiceError as exc:
return common.redirect_with_message("/settings", error=str(exc))
user_settings = await user_settings_service.get_or_create_settings(
db_session,
user_id=current_user.id,
)
await user_settings_service.update_settings(
db_session,
settings=user_settings,
auto_run_enabled=auto_run_enabled == "on",
run_interval_minutes=parsed_interval,
request_delay_seconds=parsed_delay,
)
logger.info(
"settings.updated",
extra={
"event": "settings.updated",
"user_id": current_user.id,
"auto_run_enabled": auto_run_enabled == "on",
"run_interval_minutes": parsed_interval,
"request_delay_seconds": parsed_delay,
},
)
return common.redirect_with_message("/settings", notice="Settings updated.")