404 lines
14 KiB
Python
404 lines
14 KiB
Python
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
|
|
from pydantic import BaseModel, Field
|
|
from fastapi.staticfiles import StaticFiles
|
|
|
|
from .admin_ui import router as admin_router
|
|
from .auth import create_session_token, verify_credentials, verify_session_token
|
|
from .config import get_settings
|
|
from .db import init_db
|
|
from .ingestion import run_ingestion
|
|
from .policy import evaluate_source_policy, is_source_allowed
|
|
from .repositories import (
|
|
ArticleUpsert,
|
|
FeedCreate,
|
|
RunCreate,
|
|
SourceCreate,
|
|
create_feed as repo_create_feed,
|
|
create_run,
|
|
create_source as repo_create_source,
|
|
finish_run,
|
|
get_article_by_id,
|
|
get_feed_by_id,
|
|
get_run_by_id,
|
|
get_source_by_id,
|
|
list_articles as repo_list_articles,
|
|
list_feeds as repo_list_feeds,
|
|
list_runs,
|
|
list_sources as repo_list_sources,
|
|
update_article_status,
|
|
upsert_article as repo_upsert_article,
|
|
)
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
@asynccontextmanager
|
|
async def app_lifespan(_: FastAPI):
|
|
init_db()
|
|
yield
|
|
|
|
|
|
app = FastAPI(title=settings.app_name, lifespan=app_lifespan)
|
|
app.include_router(admin_router)
|
|
app.mount(
|
|
"/admin/static",
|
|
StaticFiles(directory=str(Path(__file__).resolve().parent.parent / "static")),
|
|
name="admin-static",
|
|
)
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
class SourceCreateRequest(BaseModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
base_url: str | None = None
|
|
terms_url: str | None = None
|
|
license_name: str | None = None
|
|
risk_level: str = Field(default="yellow", pattern="^(green|yellow|red)$")
|
|
is_enabled: bool = False
|
|
notes: str | None = None
|
|
last_reviewed_at: str | None = None
|
|
|
|
|
|
class FeedCreateRequest(BaseModel):
|
|
name: str = Field(min_length=1, max_length=200)
|
|
url: str = Field(min_length=5, max_length=1000)
|
|
source_id: int | None = None
|
|
is_enabled: bool = True
|
|
|
|
|
|
class RunCreateRequest(BaseModel):
|
|
run_type: str = Field(min_length=2, max_length=100)
|
|
status: str = Field(default="queued", pattern="^(queued|running|success|failed)$")
|
|
details: str | None = None
|
|
|
|
|
|
class RunFinishRequest(BaseModel):
|
|
status: str = Field(pattern="^(success|failed)$")
|
|
details: str | None = None
|
|
|
|
|
|
class ArticleUpsertRequest(BaseModel):
|
|
feed_id: int | None = None
|
|
source_article_id: str | None = None
|
|
source_hash: str | None = None
|
|
title: str = Field(min_length=1, max_length=500)
|
|
source_url: str = Field(min_length=5, max_length=2000)
|
|
canonical_url: str | None = None
|
|
published_at: str | None = None
|
|
author: str | None = None
|
|
summary: str | None = None
|
|
content_raw: str | None = None
|
|
content_rewritten: str | None = None
|
|
word_count: int = 0
|
|
status: str = Field(default="new", pattern="^(new|rewrite|review|approved|published|error)$")
|
|
meta_json: str | None = None
|
|
|
|
|
|
class IngestionRunRequest(BaseModel):
|
|
feed_id: int | None = None
|
|
|
|
|
|
class ArticleTransitionRequest(BaseModel):
|
|
target_status: str = Field(pattern="^(new|rewrite|review|approved|published|error)$")
|
|
note: str | None = None
|
|
|
|
|
|
class ArticleReviewRequest(BaseModel):
|
|
decision: str = Field(pattern="^(approve|reject)$")
|
|
note: str | None = None
|
|
|
|
|
|
ALLOWED_ARTICLE_TRANSITIONS: dict[str, set[str]] = {
|
|
"new": {"review", "rewrite", "error"},
|
|
"rewrite": {"review", "error"},
|
|
"review": {"approved", "rewrite", "error"},
|
|
"approved": {"published", "error"},
|
|
"published": {"error"},
|
|
"error": {"review", "rewrite"},
|
|
}
|
|
|
|
|
|
def require_auth(request: Request) -> str:
|
|
token = request.cookies.get(settings.session_cookie_name)
|
|
if not token:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet")
|
|
|
|
username = verify_session_token(token)
|
|
if not username:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session ungueltig oder abgelaufen")
|
|
|
|
return username
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
return {"status": "ok", "service": settings.app_name, "db_path": settings.app_db_path}
|
|
|
|
|
|
@app.post("/auth/login")
|
|
def login(payload: LoginRequest, response: Response) -> dict:
|
|
if not verify_credentials(payload.username, payload.password):
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Ungueltige Zugangsdaten")
|
|
|
|
token = create_session_token(payload.username)
|
|
response.set_cookie(
|
|
key=settings.session_cookie_name,
|
|
value=token,
|
|
max_age=settings.session_max_age_seconds,
|
|
httponly=True,
|
|
secure=False,
|
|
samesite="lax",
|
|
)
|
|
return {"ok": True, "username": payload.username}
|
|
|
|
|
|
@app.post("/auth/logout")
|
|
def logout(response: Response) -> dict:
|
|
response.delete_cookie(settings.session_cookie_name)
|
|
return {"ok": True}
|
|
|
|
|
|
@app.get("/auth/me")
|
|
def me(username: str = Depends(require_auth)) -> dict:
|
|
return {"authenticated": True, "username": username}
|
|
|
|
|
|
@app.get("/api/protected")
|
|
def protected(username: str = Depends(require_auth)) -> dict:
|
|
return {"ok": True, "message": "Protected endpoint", "username": username}
|
|
|
|
|
|
@app.get("/api/pipeline/status")
|
|
def pipeline_status(username: str = Depends(require_auth)) -> dict:
|
|
feeds_total = len(repo_list_feeds())
|
|
sources_total = len(repo_list_sources())
|
|
articles_total = len(repo_list_articles(limit=500))
|
|
return {
|
|
"ok": True,
|
|
"stage": "skeleton+db",
|
|
"requested_by": username,
|
|
"counts": {
|
|
"sources": sources_total,
|
|
"feeds": feeds_total,
|
|
"articles": articles_total,
|
|
},
|
|
}
|
|
|
|
|
|
@app.get("/api/sources")
|
|
def list_sources(username: str = Depends(require_auth)) -> dict:
|
|
return {"ok": True, "items": repo_list_sources(), "requested_by": username}
|
|
|
|
|
|
@app.get("/api/sources/{source_id}/policy-check")
|
|
def source_policy_check(source_id: int, username: str = Depends(require_auth)) -> dict:
|
|
source = get_source_by_id(source_id)
|
|
if not source:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Quelle nicht gefunden")
|
|
issues = evaluate_source_policy(source)
|
|
return {
|
|
"ok": True,
|
|
"source_id": source_id,
|
|
"allowed": is_source_allowed(source),
|
|
"issues": issues,
|
|
"requested_by": username,
|
|
}
|
|
|
|
|
|
@app.post("/api/sources")
|
|
def create_source(payload: SourceCreateRequest, username: str = Depends(require_auth)) -> dict:
|
|
source_id = repo_create_source(
|
|
SourceCreate(
|
|
name=payload.name,
|
|
base_url=payload.base_url,
|
|
terms_url=payload.terms_url,
|
|
license_name=payload.license_name,
|
|
risk_level=payload.risk_level,
|
|
is_enabled=payload.is_enabled,
|
|
notes=payload.notes,
|
|
last_reviewed_at=payload.last_reviewed_at,
|
|
)
|
|
)
|
|
return {"ok": True, "id": source_id, "requested_by": username}
|
|
|
|
|
|
@app.get("/api/feeds")
|
|
def list_feeds(username: str = Depends(require_auth)) -> dict:
|
|
return {"ok": True, "items": repo_list_feeds(), "requested_by": username}
|
|
|
|
|
|
@app.get("/api/feeds/{feed_id}/policy-check")
|
|
def feed_policy_check(feed_id: int, username: str = Depends(require_auth)) -> dict:
|
|
feed = get_feed_by_id(feed_id)
|
|
if not feed:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feed nicht gefunden")
|
|
|
|
source_snapshot = {
|
|
"id": feed.get("source_id"),
|
|
"name": feed.get("source_name"),
|
|
"base_url": feed.get("source_base_url"),
|
|
"terms_url": feed.get("source_terms_url"),
|
|
"license_name": feed.get("source_license_name"),
|
|
"risk_level": feed.get("source_risk_level"),
|
|
"last_reviewed_at": feed.get("source_last_reviewed_at"),
|
|
"is_enabled": feed.get("source_is_enabled"),
|
|
}
|
|
issues = evaluate_source_policy(source_snapshot)
|
|
return {
|
|
"ok": True,
|
|
"feed_id": feed_id,
|
|
"allowed": len(issues) == 0,
|
|
"issues": issues,
|
|
"requested_by": username,
|
|
}
|
|
|
|
|
|
@app.post("/api/feeds")
|
|
def create_feed(payload: FeedCreateRequest, username: str = Depends(require_auth)) -> dict:
|
|
try:
|
|
feed_id = repo_create_feed(
|
|
FeedCreate(
|
|
name=payload.name,
|
|
url=payload.url,
|
|
source_id=payload.source_id,
|
|
is_enabled=payload.is_enabled,
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Feed konnte nicht angelegt werden: {exc}") from exc
|
|
|
|
return {"ok": True, "id": feed_id, "requested_by": username}
|
|
|
|
|
|
@app.get("/api/runs")
|
|
def api_list_runs(limit: int = 50, username: str = Depends(require_auth)) -> dict:
|
|
return {"ok": True, "items": list_runs(limit=limit), "requested_by": username}
|
|
|
|
|
|
@app.get("/api/runs/{run_id}")
|
|
def api_get_run(run_id: int, username: str = Depends(require_auth)) -> dict:
|
|
run = get_run_by_id(run_id)
|
|
if not run:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run nicht gefunden")
|
|
return {"ok": True, "item": run, "requested_by": username}
|
|
|
|
|
|
@app.post("/api/runs")
|
|
def api_create_run(payload: RunCreateRequest, username: str = Depends(require_auth)) -> dict:
|
|
run_id = create_run(RunCreate(run_type=payload.run_type, status=payload.status, details=payload.details))
|
|
return {"ok": True, "id": run_id, "requested_by": username}
|
|
|
|
|
|
@app.post("/api/runs/{run_id}/finish")
|
|
def api_finish_run(run_id: int, payload: RunFinishRequest, username: str = Depends(require_auth)) -> dict:
|
|
finish_run(run_id=run_id, status=payload.status, details=payload.details)
|
|
return {"ok": True, "id": run_id, "requested_by": username}
|
|
|
|
|
|
@app.get("/api/articles")
|
|
def api_list_articles(limit: int = 100, status_filter: str | None = None, username: str = Depends(require_auth)) -> dict:
|
|
return {"ok": True, "items": repo_list_articles(limit=limit, status_filter=status_filter), "requested_by": username}
|
|
|
|
|
|
@app.get("/api/articles/{article_id}")
|
|
def api_get_article(article_id: int, username: str = Depends(require_auth)) -> dict:
|
|
article = get_article_by_id(article_id)
|
|
if not article:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
|
|
return {"ok": True, "item": article, "requested_by": username}
|
|
|
|
|
|
@app.post("/api/articles/upsert")
|
|
def api_upsert_article(payload: ArticleUpsertRequest, username: str = Depends(require_auth)) -> dict:
|
|
article_id = repo_upsert_article(
|
|
ArticleUpsert(
|
|
feed_id=payload.feed_id,
|
|
source_article_id=payload.source_article_id,
|
|
source_hash=payload.source_hash,
|
|
title=payload.title,
|
|
source_url=payload.source_url,
|
|
canonical_url=payload.canonical_url,
|
|
published_at=payload.published_at,
|
|
author=payload.author,
|
|
summary=payload.summary,
|
|
content_raw=payload.content_raw,
|
|
content_rewritten=payload.content_rewritten,
|
|
word_count=payload.word_count,
|
|
status=payload.status,
|
|
meta_json=payload.meta_json,
|
|
)
|
|
)
|
|
return {"ok": True, "id": article_id, "requested_by": username}
|
|
|
|
|
|
@app.post("/api/articles/{article_id}/transition")
|
|
def api_article_transition(article_id: int, payload: ArticleTransitionRequest, username: str = Depends(require_auth)) -> dict:
|
|
article = get_article_by_id(article_id)
|
|
if not article:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
|
|
|
|
current_status = article.get("status")
|
|
allowed_targets = ALLOWED_ARTICLE_TRANSITIONS.get(current_status, set())
|
|
if payload.target_status not in allowed_targets:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Ungueltiger Statuswechsel: {current_status} -> {payload.target_status}",
|
|
)
|
|
|
|
updated = update_article_status(article_id, payload.target_status, actor=username, note=payload.note)
|
|
if not updated:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
|
|
return {"ok": True, "id": article_id, "from_status": current_status, "to_status": payload.target_status}
|
|
|
|
|
|
@app.post("/api/articles/{article_id}/review")
|
|
def api_article_review(article_id: int, payload: ArticleReviewRequest, username: str = Depends(require_auth)) -> dict:
|
|
article = get_article_by_id(article_id)
|
|
if not article:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
|
|
if article.get("status") != "review":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Review nur fuer Status 'review' erlaubt (aktuell: {article.get('status')})",
|
|
)
|
|
|
|
target_status = "approved" if payload.decision == "approve" else "rewrite"
|
|
updated = update_article_status(
|
|
article_id,
|
|
target_status,
|
|
actor=username,
|
|
note=payload.note,
|
|
decision=payload.decision,
|
|
)
|
|
if not updated:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Artikel nicht gefunden")
|
|
return {
|
|
"ok": True,
|
|
"id": article_id,
|
|
"decision": payload.decision,
|
|
"to_status": target_status,
|
|
}
|
|
|
|
|
|
@app.post("/api/ingestion/run")
|
|
def api_run_ingestion(payload: IngestionRunRequest, username: str = Depends(require_auth)) -> dict:
|
|
stats = run_ingestion(feed_id=payload.feed_id)
|
|
return {
|
|
"ok": stats.status == "success",
|
|
"run_id": stats.run_id,
|
|
"status": stats.status,
|
|
"message": stats.message,
|
|
"stats": {
|
|
"feeds_processed": stats.feeds_processed,
|
|
"entries_seen": stats.entries_seen,
|
|
"articles_upserted": stats.articles_upserted,
|
|
},
|
|
"requested_by": username,
|
|
}
|