Files
Orchestrator/privat/andreknie-privat/OrgMyLife/app/main.py
T

1377 lines
50 KiB
Python

from fastapi import FastAPI, Depends, HTTPException, Request, Form, Response
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from pydantic import BaseModel
from typing import Optional, List
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session as DBSession
from sqlalchemy import or_
from app.db.session import get_db, SessionLocal, engine, Base
from app.models import Event, Task, UserSession, ProjectTask, CallItem
from app.services.nextcloud_sync import sync_calendar_events, sync_todo_tasks, update_remote_todo
from app.services.backlog_sync import sync_backlog
from app.services.email_sync import sync_emails, mark_email_done_and_archive, delete_email_from_server
from app.services.gmail_sync import sync_gmail, gmail_archive_email, gmail_delete_email, gmail_cleanup_dismissed
from app.services.prioritization import generate_suggestions
from app.services.digest import generate_daily_digest, generate_weekly_retro
from app.services.phone_extractor import extract_phone_number
from datetime import datetime, time, timedelta
from contextlib import asynccontextmanager
from zoneinfo import ZoneInfo
import asyncio
import os
import secrets
BERLIN_TZ = ZoneInfo("Europe/Berlin")
def _extract_sender(description: str | None, origin: str | None) -> str:
"""Extract sender from email task description (first line: 'From: ...')."""
if not description or not origin:
return ""
if origin in ("dhive_email", "gmail") and description.startswith("From: "):
first_line = description.split("\n")[0]
return first_line.replace("From: ", "").strip()
return ""
# --- Auto-sync background task ---
async def auto_sync_loop():
"""Background task that syncs Nextcloud and email every 5 minutes."""
while True:
await asyncio.sleep(300) # 5 minutes
try:
sync_calendar_events()
except Exception as e:
print(f"Auto-sync (calendar) error: {e}")
try:
sync_todo_tasks()
except Exception as e:
print(f"Auto-sync (todo) error: {e}")
try:
sync_emails()
except Exception as e:
print(f"Auto-sync (email) error: {e}")
try:
sync_gmail()
except Exception as e:
print(f"Auto-sync (gmail) error: {e}")
try:
gmail_cleanup_dismissed()
except Exception as e:
print(f"Auto-sync (gmail cleanup) error: {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""App lifespan: start background sync, create tables."""
# Create tables and add missing columns
Base.metadata.create_all(bind=engine)
# Ensure source_url column exists (for upgrades from older schema)
from sqlalchemy import inspect, text
with engine.connect() as conn:
inspector = inspect(engine)
task_columns = [c['name'] for c in inspector.get_columns('tasks')]
if 'source_url' not in task_columns:
conn.execute(text("ALTER TABLE tasks ADD COLUMN source_url VARCHAR"))
conn.commit()
if 'agent_ready' not in task_columns:
conn.execute(text("ALTER TABLE tasks ADD COLUMN agent_ready BOOLEAN DEFAULT FALSE"))
conn.commit()
if 'hidden_until' not in task_columns:
conn.execute(text("ALTER TABLE tasks ADD COLUMN hidden_until TIMESTAMP"))
conn.commit()
if 'labels' not in task_columns:
conn.execute(text("ALTER TABLE tasks ADD COLUMN labels VARCHAR"))
conn.commit()
if 'last_synced_at' not in task_columns:
conn.execute(text("ALTER TABLE tasks ADD COLUMN last_synced_at TIMESTAMP"))
conn.commit()
if 'has_conflict' not in task_columns:
conn.execute(text("ALTER TABLE tasks ADD COLUMN has_conflict BOOLEAN DEFAULT FALSE"))
conn.commit()
if 'conflict_data' not in task_columns:
conn.execute(text("ALTER TABLE tasks ADD COLUMN conflict_data TEXT"))
conn.commit()
# Ensure project_tasks table exists
if not inspector.has_table('project_tasks'):
Base.metadata.tables['project_tasks'].create(bind=engine)
# Ensure call_items table exists
if not inspector.has_table('call_items'):
Base.metadata.tables['call_items'].create(bind=engine)
# Restore dismissed tasks from backup file (survives VPS resets)
_restore_dismissed_from_backup()
task = asyncio.create_task(auto_sync_loop())
yield
task.cancel()
def _restore_dismissed_from_backup():
"""Load dismissed_tasks.json and mark those source_ids as dismissed in DB."""
import json
backup_path = os.path.join(os.path.dirname(__file__), '..', 'dismissed_tasks.json')
if not os.path.exists(backup_path):
return
try:
with open(backup_path, 'r') as f:
data = json.load(f)
dismissed_list = data.get("dismissed", [])
if not dismissed_list:
return
db = SessionLocal()
restored = 0
for item in dismissed_list:
source_id = item.get("source_id")
if not source_id:
continue
existing = db.query(Task).filter(Task.source_id == source_id).first()
if existing and existing.status == "open":
existing.status = "dismissed"
restored += 1
if restored:
db.commit()
print(f"Restored {restored} dismissed tasks from backup")
db.close()
except Exception as e:
print(f"Error restoring dismissed backup: {e}")
app = FastAPI(title="OrgMyLife API", description="Personal coordination system", lifespan=lifespan)
# Authentication config
API_SECRET = os.getenv("API_SECRET", "")
APP_USERNAME = os.getenv("APP_USERNAME", "admin")
APP_PASSWORD = os.getenv("APP_PASSWORD", "changeme")
SESSION_COOKIE = "orgmylife_session"
SESSION_MAX_AGE = 7 * 24 * 3600 # 7 days in seconds
# --- Session helpers (DB-persistent) ---
def verify_session(request: Request):
"""Check if user has a valid session cookie (DB-backed)."""
session_id = request.cookies.get(SESSION_COOKIE)
if session_id:
db = SessionLocal()
try:
sess = db.query(UserSession).filter(
UserSession.session_id == session_id,
UserSession.expires_at > datetime.utcnow()
).first()
if sess:
return sess.username
finally:
db.close()
# Also allow API_SECRET bearer token (for orchestrator)
auth_header = request.headers.get("Authorization", "")
if API_SECRET and auth_header == f"Bearer {API_SECRET}":
return "api"
return None
def verify_api_key(request: Request):
"""Verify API key for orchestrator endpoints."""
# Check X-API-Key header (avoids conflict with Caddy basic auth)
api_key_header = request.headers.get("X-API-Key", "")
if API_SECRET and api_key_header == API_SECRET:
return
# Also check Authorization Bearer (backward compat)
auth_header = request.headers.get("Authorization", "")
if API_SECRET and auth_header == f"Bearer {API_SECRET}":
return
if not API_SECRET:
return # No secret configured = allow all (dev mode only)
raise HTTPException(status_code=401, detail="Invalid API key")
# --- Login/Logout ---
@app.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
"""Serve the login page."""
# If already logged in, redirect to home
if verify_session(request):
return RedirectResponse(url="/", status_code=303)
error = request.query_params.get("error", "")
error_html = f'<p class="error">{error}</p>' if error else ""
return f"""<!DOCTYPE html>
<html>
<head>
<title>OrgMyLife - Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e; color: #e0e0e0; display: flex;
justify-content: center; align-items: center; min-height: 100vh; margin: 0; }}
.login-box {{ background: #16213e; padding: 2rem; border-radius: 12px;
width: 100%; max-width: 360px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); }}
h1 {{ color: #00d4aa; margin-bottom: 0.5rem; }}
p.subtitle {{ color: #888; margin-top: 0; }}
input {{ width: 100%; padding: 12px; margin: 8px 0; border: 1px solid #333;
border-radius: 6px; background: #0f3460; color: #e0e0e0;
font-size: 1rem; box-sizing: border-box; }}
input:focus {{ outline: none; border-color: #00d4aa; }}
button {{ width: 100%; padding: 12px; margin-top: 16px; border: none;
border-radius: 6px; background: #00d4aa; color: #1a1a2e;
font-size: 1rem; font-weight: bold; cursor: pointer; }}
button:hover {{ background: #00b894; }}
.error {{ color: #ff6b6b; font-size: 0.9rem; margin-top: 8px; }}
</style>
</head>
<body>
<div class="login-box">
<h1>OrgMyLife</h1>
<p class="subtitle">Your coordination system</p>
{error_html}
<form method="POST" action="/login">
<input type="text" name="username" placeholder="Username" required autofocus>
<input type="password" name="password" placeholder="Password" required>
<button type="submit">Log in</button>
</form>
</div>
</body>
</html>"""
@app.post("/login")
def login_submit(username: str = Form(...), password: str = Form(...)):
"""Handle login form submission."""
if username == APP_USERNAME and password == APP_PASSWORD:
session_id = secrets.token_hex(32)
expires_at = datetime.utcnow() + timedelta(seconds=SESSION_MAX_AGE)
# Store session in DB
db = SessionLocal()
try:
db.add(UserSession(session_id=session_id, username=username, expires_at=expires_at))
db.commit()
finally:
db.close()
response = RedirectResponse(url="/", status_code=303)
response.set_cookie(
SESSION_COOKIE, session_id,
httponly=True, secure=True, samesite="lax",
max_age=SESSION_MAX_AGE
)
return response
return RedirectResponse(url="/login?error=Invalid+credentials", status_code=303)
@app.get("/logout")
def logout(request: Request):
"""Log out and clear session."""
session_id = request.cookies.get(SESSION_COOKIE)
if session_id:
db = SessionLocal()
try:
db.query(UserSession).filter(UserSession.session_id == session_id).delete()
db.commit()
finally:
db.close()
response = RedirectResponse(url="/login", status_code=303)
response.delete_cookie(SESSION_COOKIE)
return response
# --- Pydantic models ---
class TaskCreate(BaseModel):
title: str
description: Optional[str] = ""
priority: Optional[int] = 4
agent_ready: Optional[bool] = False
due_date: Optional[str] = None
labels: Optional[str] = None
def model_post_init(self, __context):
if self.title:
self.title = self.title[:200]
if self.description:
self.description = self.description[:5000]
if self.priority is not None:
self.priority = max(1, min(4, self.priority))
if self.labels:
self.labels = self.labels[:200]
class TaskUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
priority: Optional[int] = None
status: Optional[str] = None
agent_ready: Optional[bool] = None
due_date: Optional[str] = None
labels: Optional[str] = None
def model_post_init(self, __context):
if self.title:
self.title = self.title[:200]
if self.description:
self.description = self.description[:5000]
if self.priority is not None:
self.priority = max(1, min(4, self.priority))
if self.status and self.status not in ("open", "in_progress", "review", "completed", "dismissed"):
self.status = None
if self.labels is not None:
self.labels = self.labels[:200]
class ConflictResolution(BaseModel):
resolution: str # "local" or "remote"
class ReorderItem(BaseModel):
id: int
position: int
class ReorderRequest(BaseModel):
order: List[ReorderItem]
# --- Call List Pydantic models ---
class CallItemCreate(BaseModel):
task_id: Optional[int] = None
person: Optional[str] = None
reason: Optional[str] = None
phone_number: Optional[str] = None
priority: int = 3
def model_post_init(self, __context):
if self.person:
self.person = self.person[:200]
if self.reason:
self.reason = self.reason[:500]
if self.phone_number:
self.phone_number = self.phone_number[:30]
if self.priority is not None:
self.priority = max(1, min(4, self.priority))
class CallItemUpdate(BaseModel):
person: Optional[str] = None
reason: Optional[str] = None
phone_number: Optional[str] = None
priority: Optional[int] = None
status: Optional[str] = None
resolve_task: bool = False
def model_post_init(self, __context):
if self.person:
self.person = self.person.strip()[:200]
if self.reason:
self.reason = self.reason.strip()[:500]
if self.phone_number:
self.phone_number = self.phone_number[:30]
if self.priority is not None:
self.priority = max(1, min(4, self.priority))
class CallItemResponse(BaseModel):
id: int
person: str
reason: str
phone_number: Optional[str] = None
original_task_id: Optional[int] = None
priority: int
status: str
created_at: str
# --- Call List API ---
@app.post("/api/calls")
def create_call_item(call: CallItemCreate, request: Request, db: DBSession = Depends(get_db)):
"""Create a new call item, either from an existing task or manually."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
if call.task_id:
# Create from existing task
task = db.query(Task).filter(Task.id == call.task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Extract sender name from description for email-origin tasks
person = "Unknown"
if task.origin in ("gmail", "dhive_email") and task.description:
sender = _extract_sender(task.description, task.origin)
if sender:
person = sender
# Extract phone number from task description
phone = None
if task.description:
phone = extract_phone_number(task.description)
new_call = CallItem(
person=person[:200],
reason=(task.title or "")[:500],
phone_number=phone[:30] if phone else None,
original_task_id=task.id,
priority=call.priority,
status="pending",
)
else:
# Manual creation — require person and reason
if not call.person or not call.reason:
raise HTTPException(status_code=422, detail="person and reason are required when no task_id is provided")
new_call = CallItem(
person=call.person[:200],
reason=call.reason[:500],
phone_number=call.phone_number[:30] if call.phone_number else None,
original_task_id=None,
priority=call.priority,
status="pending",
)
db.add(new_call)
db.commit()
db.refresh(new_call)
return CallItemResponse(
id=new_call.id,
person=new_call.person,
reason=new_call.reason,
phone_number=new_call.phone_number,
original_task_id=new_call.original_task_id,
priority=new_call.priority,
status=new_call.status,
created_at=new_call.created_at.strftime("%Y-%m-%d %H:%M") if new_call.created_at else "",
)
@app.get("/api/calls")
def get_call_items(request: Request, db: DBSession = Depends(get_db)):
"""Return all pending call items, ordered by priority ASC then created_at ASC."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
calls = db.query(CallItem).filter(
CallItem.status == "pending"
).order_by(CallItem.priority.asc(), CallItem.created_at.asc()).all()
return {
"calls": [
CallItemResponse(
id=c.id,
person=c.person,
reason=c.reason,
phone_number=c.phone_number,
original_task_id=c.original_task_id,
priority=c.priority,
status=c.status,
created_at=c.created_at.strftime("%Y-%m-%d %H:%M") if c.created_at else "",
)
for c in calls
]
}
@app.put("/api/calls/{id}")
def update_call_item(id: int, update: CallItemUpdate, request: Request, db: DBSession = Depends(get_db)):
"""Update a call item's fields. Optionally resolve the original task on completion."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
call = db.query(CallItem).filter(CallItem.id == id).first()
if not call:
raise HTTPException(status_code=404, detail="Call item not found")
# Validate status if provided
if update.status is not None:
if update.status not in ("pending", "done"):
raise HTTPException(status_code=400, detail="Status must be 'pending' or 'done'")
# Update only fields that are not None
if update.person is not None:
call.person = update.person
if update.reason is not None:
call.reason = update.reason
if update.phone_number is not None:
call.phone_number = update.phone_number
if update.priority is not None:
call.priority = update.priority
if update.status is not None:
call.status = update.status
db.commit()
# If marking as done and resolve_task=true, resolve the original task
if update.status == "done" and update.resolve_task and call.original_task_id:
original_task = db.query(Task).filter(Task.id == call.original_task_id).first()
if original_task:
original_task.status = "completed"
db.commit()
# Trigger existing side effects (email archiving, Nextcloud sync)
if original_task.origin == "dhive_email" and original_task.source_id:
try:
mark_email_done_and_archive(original_task.source_id)
except Exception as e:
print(f"Error archiving email for task {original_task.id}: {e}")
if original_task.origin == "gmail" and original_task.source_id:
try:
gmail_archive_email(original_task.source_id)
except Exception as e:
print(f"Error archiving gmail for task {original_task.id}: {e}")
if original_task.origin and original_task.origin.startswith("nextcloud_todo") and original_task.source_id:
try:
update_remote_todo(original_task.source_id, {"status": "completed"})
except Exception as e:
print(f"Error syncing nextcloud todo for task {original_task.id}: {e}")
else:
import logging
logging.warning(f"Original task {call.original_task_id} not found when resolving call item {call.id}")
db.refresh(call)
return CallItemResponse(
id=call.id,
person=call.person,
reason=call.reason,
phone_number=call.phone_number,
original_task_id=call.original_task_id,
priority=call.priority,
status=call.status,
created_at=call.created_at.strftime("%Y-%m-%d %H:%M") if call.created_at else "",
)
@app.delete("/api/calls/{id}")
def delete_call_item(id: int, request: Request, db: DBSession = Depends(get_db)):
"""Delete a call item from the database."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
call = db.query(CallItem).filter(CallItem.id == id).first()
if not call:
raise HTTPException(status_code=404, detail="Call item not found")
db.delete(call)
db.commit()
return {"detail": "Call item deleted"}
# Mount frontend static files
app.mount("/static", StaticFiles(directory="frontend"), name="static")
# --- Page routes ---
@app.get("/")
def read_root(request: Request):
user = verify_session(request)
if not user:
return RedirectResponse(url="/login", status_code=303)
return FileResponse("frontend/index.html")
@app.get("/api/health")
def health_check():
return {"status": "ok"}
@app.get("/api/debug/env")
def debug_env(request: Request):
"""Show which env vars are set (no values, just presence)."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
return {
"IMAP_SERVER": os.getenv("IMAP_SERVER", "NOT SET"),
"IMAP_PORT": os.getenv("IMAP_PORT", "NOT SET"),
"EMAIL_USERNAME": os.getenv("EMAIL_USERNAME", "NOT SET"),
"EMAIL_PASSWORD_SET": bool(os.getenv("EMAIL_PASSWORD")),
"GMAIL_USERNAME": os.getenv("GMAIL_USERNAME", "NOT SET"),
"GMAIL_PASSWORD_SET": bool(os.getenv("GMAIL_PASSWORD")),
"NEXTCLOUD_URL": os.getenv("NEXTCLOUD_URL", "NOT SET"),
}
@app.get("/api/debug/imap-folders")
def debug_imap_folders(request: Request):
"""List available IMAP folders."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
try:
from app.services.email_sync import _get_imap_connection
conn = _get_imap_connection()
status, folders = conn.list()
conn.logout()
if status == "OK":
return {"folders": [f.decode() for f in folders]}
return {"error": "Could not list folders"}
except Exception as e:
return {"error": str(e)}
@app.get("/api/tasks/labels")
def get_task_labels(request: Request, db: DBSession = Depends(get_db)):
"""Return all unique labels used across open tasks."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
tasks = db.query(Task.labels).filter(Task.status == "open", Task.labels != None, Task.labels != "").all()
all_labels = set()
for (labels_str,) in tasks:
for label in labels_str.split(","):
label = label.strip()
if label:
all_labels.add(label)
return {"labels": sorted(all_labels)}
@app.get("/api/tasks/conflicts")
def get_conflict_tasks(request: Request, db: DBSession = Depends(get_db)):
"""Return all tasks with sync conflicts."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
import json as json_mod
tasks = db.query(Task).filter(Task.has_conflict == True).all()
priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"}
return {
"conflicts": [
{
"id": t.id,
"title": t.title,
"description": t.description or "",
"status": t.status,
"priority": priority_labels.get(t.priority, "Low"),
"conflict_data": json_mod.loads(t.conflict_data) if t.conflict_data else {},
}
for t in tasks
],
"total": len(tasks),
}
@app.post("/api/tasks/{task_id}/resolve-conflict")
def resolve_conflict(task_id: int, body: ConflictResolution, request: Request, db: DBSession = Depends(get_db)):
"""Resolve a sync conflict by choosing local or remote version."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
import json as json_mod
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if not task.has_conflict:
raise HTTPException(status_code=400, detail="Task has no conflict")
now = datetime.now(tz=ZoneInfo("UTC"))
if body.resolution == "local":
# Keep local version, push to remote
if task.source_id and task.origin and task.origin.startswith("nextcloud_todo"):
push_updates = {
"title": task.title,
"description": task.description or "",
"status": task.status,
}
update_remote_todo(task.source_id, push_updates)
task.has_conflict = False
task.conflict_data = None
task.last_synced_at = now
elif body.resolution == "remote":
# Apply remote version from conflict_data
if task.conflict_data:
remote = json_mod.loads(task.conflict_data)
if "title" in remote:
task.title = remote["title"]
if "description" in remote:
task.description = remote["description"]
if "status" in remote:
task.status = remote["status"]
task.has_conflict = False
task.conflict_data = None
task.last_synced_at = now
else:
raise HTTPException(status_code=400, detail="resolution must be 'local' or 'remote'")
db.commit()
return {"status": "success", "task_id": task.id}
@app.get("/api/tasks/dismissed")
def get_dismissed_tasks(request: Request, db: DBSession = Depends(get_db)):
"""Export all dismissed/completed task source_ids for backup."""
verify_api_key(request)
tasks = db.query(Task).filter(
Task.status.in_(["dismissed", "completed"]),
Task.source_id != None
).all()
return {
"dismissed": [
{"source_id": t.source_id, "title": t.title, "origin": t.origin}
for t in tasks
]
}
# --- Sync endpoints ---
@app.post("/api/sync/nextcloud")
def trigger_nextcloud_sync(request: Request):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
success = sync_calendar_events()
if success:
return {"status": "success", "message": "Nextcloud synced successfully"}
return {"status": "error", "message": "Sync failed. Check server logs."}
@app.post("/api/sync/backlog")
def trigger_backlog_sync(request: Request):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
success = sync_backlog()
if success:
return {"status": "success", "message": "Backlog synced successfully"}
return {"status": "error", "message": "Backlog sync failed. Check server logs."}
@app.post("/api/sync/nextcloud-todo")
def trigger_nextcloud_todo_sync(request: Request):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
success = sync_todo_tasks()
if success:
return {"status": "success", "message": "Nextcloud ToDo synced successfully"}
return {"status": "error", "message": "Nextcloud ToDo sync failed. Check server logs."}
@app.post("/api/sync/email")
def trigger_email_sync(request: Request):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
try:
# Test connection first and return specific error
from app.services.email_sync import _get_imap_connection
try:
conn = _get_imap_connection()
conn.logout()
except ValueError as e:
return {"status": "error", "message": f"IMAP not configured: {e}"}
except Exception as e:
return {"status": "error", "message": f"IMAP connection failed: {e}"}
success = sync_emails()
if success:
return {"status": "success", "message": "Email sync completed successfully"}
return {"status": "error", "message": "Email sync failed after connecting. Check server logs."}
except Exception as e:
import traceback
return {"status": "error", "message": f"Email sync exception: {str(e)}", "trace": traceback.format_exc()}
@app.post("/api/sync/gmail")
def trigger_gmail_sync(request: Request):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
success = sync_gmail()
if success:
return {"status": "success", "message": "Gmail sync completed successfully"}
return {"status": "error", "message": "Gmail sync failed. Check server logs."}
# --- Task endpoints ---
@app.get("/api/tasks")
def get_all_tasks(
request: Request,
sort: Optional[str] = None,
filter_origin: Optional[str] = None,
filter_agent: Optional[str] = None,
filter_priority: Optional[str] = None,
filter_label: Optional[str] = None,
db: DBSession = Depends(get_db),
):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
query = db.query(Task).filter(Task.status == "open")
# Hide snoozed tasks (hidden_until in the future)
now = datetime.now(tz=BERLIN_TZ)
query = query.filter(or_(Task.hidden_until == None, Task.hidden_until <= now))
# Filters
if filter_origin:
if filter_origin == "nextcloud_todo":
query = query.filter(Task.origin.like("nextcloud_todo%"))
else:
query = query.filter(Task.origin == filter_origin)
if filter_agent == "true":
query = query.filter(Task.agent_ready == True)
elif filter_agent == "false":
query = query.filter((Task.agent_ready == False) | (Task.agent_ready == None))
if filter_priority:
try:
query = query.filter(Task.priority == int(filter_priority))
except ValueError:
pass
if filter_label:
query = query.filter(Task.labels.like(f"%{filter_label}%"))
# Sorting
if sort == "priority":
query = query.order_by(Task.priority, Task.position)
elif sort == "due_date":
query = query.order_by(Task.due_date.asc().nullslast(), Task.priority)
elif sort == "created":
query = query.order_by(Task.created_at.desc())
else:
query = query.order_by(Task.position)
real_tasks = query.all()
priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"}
tasks_payload = [
{
"id": t.id,
"title": t.title,
"description": t.description,
"sender": _extract_sender(t.description, t.origin),
"priority": priority_labels.get(t.priority, "Low"),
"priority_value": t.priority,
"position": t.position,
"status": t.status,
"origin": t.origin or "",
"source_url": t.source_url or "",
"agent_ready": t.agent_ready or False,
"labels": t.labels or "",
"has_conflict": t.has_conflict or False,
"due_date": t.due_date.strftime("%Y-%m-%d") if t.due_date else None,
"created_at": t.created_at.strftime("%Y-%m-%d %H:%M") if t.created_at else None,
}
for t in real_tasks
]
return {"tasks": tasks_payload, "total": len(tasks_payload)}
@app.post("/api/tasks")
def create_task(task: TaskCreate, request: Request, db: DBSession = Depends(get_db)):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
max_pos = db.query(Task).filter(Task.status == "open").count()
due = None
if task.due_date:
try:
due = datetime.strptime(task.due_date, "%Y-%m-%d")
except ValueError:
pass
new_task = Task(
title=task.title,
description=task.description,
priority=task.priority,
position=max_pos,
origin="manual",
status="open",
agent_ready=task.agent_ready,
due_date=due,
labels=task.labels,
)
db.add(new_task)
db.commit()
db.refresh(new_task)
return {"status": "success", "task_id": new_task.id}
@app.put("/api/tasks/reorder")
def reorder_tasks(req: ReorderRequest, request: Request, db: DBSession = Depends(get_db)):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
for item in req.order:
task = db.query(Task).filter(Task.id == item.id).first()
if task:
task.position = item.position
db.commit()
return {"status": "success"}
@app.put("/api/tasks/{task_id}/move")
def move_task(task_id: int, request: Request, db: DBSession = Depends(get_db)):
"""Move a task to top or bottom. Query param: direction=top|bottom"""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
direction = request.query_params.get("direction", "top")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if direction == "top":
# Set position to -1, then reindex all
min_pos = db.query(Task).filter(Task.status == "open").count()
task.position = -1
db.flush()
# Reindex: task at -1 becomes 0, others shift
all_tasks = db.query(Task).filter(Task.status == "open").order_by(Task.position).all()
for i, t in enumerate(all_tasks):
t.position = i
else:
# Move to bottom
max_pos = db.query(Task).filter(Task.status == "open").count()
task.position = max_pos + 1
db.flush()
all_tasks = db.query(Task).filter(Task.status == "open").order_by(Task.position).all()
for i, t in enumerate(all_tasks):
t.position = i
db.commit()
return {"status": "success"}
class SnoozeRequest(BaseModel):
until: str # ISO date string, e.g. "2026-05-13"
@app.post("/api/tasks/{task_id}/snooze")
def snooze_task(task_id: int, snooze: SnoozeRequest, request: Request, db: DBSession = Depends(get_db)):
"""Snooze a task until a given date (hide it until then)."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
try:
hidden_date = datetime.strptime(snooze.until, "%Y-%m-%d").replace(tzinfo=BERLIN_TZ)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
task.hidden_until = hidden_date
db.commit()
return {"status": "success", "hidden_until": snooze.until}
@app.put("/api/tasks/{task_id}")
def update_task(task_id: int, task_update: TaskUpdate, request: Request, db: DBSession = Depends(get_db)):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if task_update.title is not None:
task.title = task_update.title
if task_update.description is not None:
task.description = task_update.description
if task_update.priority is not None:
task.priority = task_update.priority
if task_update.status is not None:
task.status = task_update.status
if task_update.agent_ready is not None:
task.agent_ready = task_update.agent_ready
if task_update.labels is not None:
task.labels = task_update.labels if task_update.labels else None
if task_update.due_date is not None:
if task_update.due_date == "":
task.due_date = None
else:
try:
task.due_date = datetime.strptime(task_update.due_date, "%Y-%m-%d")
except ValueError:
pass
db.commit()
# Two-way sync: push changes back to external source
if task.origin == "nextcloud_todo" and task.source_id:
push_updates = {}
if task_update.title is not None:
push_updates["title"] = task.title
if task_update.description is not None:
push_updates["description"] = task.description
if task_update.status is not None:
push_updates["status"] = task.status
if push_updates:
update_remote_todo(task.source_id, push_updates)
if task.origin == "dhive_email" and task.source_id and task_update.status == "completed":
mark_email_done_and_archive(task.source_id)
if task.origin == "gmail" and task.source_id and task_update.status == "completed":
gmail_archive_email(task.source_id)
return {"status": "success"}
@app.delete("/api/tasks/{task_id}")
def delete_task(task_id: int, request: Request, db: DBSession = Depends(get_db)):
"""Won't do: archive email, dismiss from board. Does NOT delete from server."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Archive email on dismiss
if task.origin == "dhive_email" and task.source_id:
mark_email_done_and_archive(task.source_id)
if task.origin == "gmail" and task.source_id:
gmail_archive_email(task.source_id)
# Mark as dismissed locally (won't show up again)
task.status = "dismissed"
db.commit()
return {"status": "success"}
@app.post("/api/tasks/{task_id}/purge")
def purge_task(task_id: int, request: Request, db: DBSession = Depends(get_db)):
"""Delete permanently: delete email from server, mark nextcloud task done, remove from board."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Delete email from server (move to Trash)
if task.origin == "dhive_email" and task.source_id:
delete_email_from_server(task.source_id)
if task.origin == "gmail" and task.source_id:
gmail_delete_email(task.source_id)
# Mark nextcloud task as completed on server
if task.origin and task.origin.startswith("nextcloud_todo") and task.source_id:
update_remote_todo(task.source_id, {"status": "completed"})
# Remove from local DB entirely
db.delete(task)
db.commit()
return {"status": "success"}
# --- Dashboard ---
@app.get("/api/dashboard")
def get_dashboard_data(request: Request, db: DBSession = Depends(get_db)):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
now = datetime.now(BERLIN_TZ)
today_start = datetime.combine(now.date(), time.min).replace(tzinfo=BERLIN_TZ)
today_end = datetime.combine(now.date(), time.max).replace(tzinfo=BERLIN_TZ)
# Today's events
todays_events = db.query(Event).filter(
Event.start_time >= today_start,
Event.start_time <= today_end
).order_by(Event.start_time).all()
# Upcoming events
week_end = today_start + timedelta(days=7)
upcoming_events = db.query(Event).filter(
Event.start_time > today_end,
Event.start_time <= week_end
).order_by(Event.start_time).limit(5).all()
events_payload = [
{"id": e.id, "title": e.title, "time": f"{e.start_time.astimezone(BERLIN_TZ).strftime('%H:%M')} - {e.end_time.astimezone(BERLIN_TZ).strftime('%H:%M')}"}
for e in todays_events
]
if not events_payload:
events_payload = [{"id": 0, "title": "No events today", "time": ""}]
# Top priorities
real_tasks = db.query(Task).filter(Task.status == "open").order_by(Task.priority, Task.position).limit(5).all()
priority_labels = {1: "Very High", 2: "High", 3: "Medium", 4: "Low"}
tasks_payload = [
{"id": t.id, "title": t.title, "priority": priority_labels.get(t.priority, "Low")}
for t in real_tasks
]
if not tasks_payload:
tasks_payload = [{"id": 0, "title": "No open tasks", "priority": ""}]
# Overdue tasks
overdue = db.query(Task).filter(
Task.status == "open",
Task.due_date != None,
Task.due_date < today_start
).count()
# Tasks due this week
due_this_week = db.query(Task).filter(
Task.status == "open",
Task.due_date != None,
Task.due_date >= today_start,
Task.due_date <= week_end
).count()
total_open = db.query(Task).filter(Task.status == "open").count()
# Build digest
parts = [f"You have {total_open} open tasks."]
if overdue:
parts.append(f"⚠️ {overdue} overdue!")
if due_this_week:
parts.append(f"📅 {due_this_week} due this week.")
digest = " ".join(parts)
# Suggestions from prioritization engine
suggestions = generate_suggestions(db)
return {
"digest": digest,
"priorities": tasks_payload,
"events": events_payload,
"upcoming": [
{"id": e.id, "title": e.title, "time": f"{e.start_time.astimezone(BERLIN_TZ).strftime('%a %b %d, %H:%M')}"}
for e in upcoming_events
],
"suggestions": suggestions,
}
# --- Suggestions endpoint ---
@app.get("/api/suggestions")
def get_suggestions(request: Request, db: DBSession = Depends(get_db)):
"""Get prioritization suggestions based on current task state."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
return {"suggestions": generate_suggestions(db)}
# --- Project Tasks (Kanban Board) ---
class ProjectTaskSync(BaseModel):
repo: str
tasks: List[dict] # Each: {"title": str, "status": str (optional), "description": str (optional)}
@app.post("/api/projects/sync")
def sync_project_tasks(payload: ProjectTaskSync, request: Request, db: DBSession = Depends(get_db)):
"""Sync tasks from a repo's PLAN.md into the Projects board.
Creates new tasks, skips existing ones (by title+repo). Does not delete."""
verify_api_key(request)
repo = payload.repo[:100]
synced = 0
for item in payload.tasks:
title = item.get("title", "")[:200]
if not title:
continue
# Check if already exists
existing = db.query(ProjectTask).filter(
ProjectTask.repo == repo,
ProjectTask.title == title
).first()
if existing:
continue
status = item.get("status", "backlog")
if status not in ("backlog", "ready", "in_progress", "blocked", "review", "done"):
status = "backlog"
db.add(ProjectTask(
title=title,
description=item.get("description", "")[:2000],
repo=repo,
status=status,
))
synced += 1
db.commit()
return {"status": "success", "synced": synced, "repo": repo}
@app.get("/api/projects")
def get_project_tasks(request: Request, db: DBSession = Depends(get_db)):
"""Get all project tasks grouped by status."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
tasks = db.query(ProjectTask).order_by(ProjectTask.priority, ProjectTask.created_at).all()
return {
"tasks": [
{
"id": t.id,
"title": t.title,
"description": t.description,
"repo": t.repo,
"status": t.status,
"priority": t.priority,
"created_at": t.created_at.strftime("%Y-%m-%d") if t.created_at else None,
}
for t in tasks
]
}
@app.post("/api/projects")
def create_project_task(request: Request, db: DBSession = Depends(get_db)):
"""Create a new project task."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
import json
body = json.loads(request._receive.__self__._body.decode() if hasattr(request, '_receive') else '{}')
# Use a simpler approach
return {"status": "error", "message": "Use the JSON body endpoint"}
class ProjectTaskCreate(BaseModel):
title: str
repo: Optional[str] = ""
description: Optional[str] = ""
class ProjectTaskUpdate(BaseModel):
status: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
repo: Optional[str] = None
@app.post("/api/projects/create")
def create_project_task_json(task: ProjectTaskCreate, request: Request, db: DBSession = Depends(get_db)):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
new_task = ProjectTask(
title=task.title[:200],
repo=task.repo[:100] if task.repo else "",
description=task.description[:2000] if task.description else "",
status="backlog",
)
db.add(new_task)
db.commit()
db.refresh(new_task)
return {"status": "success", "id": new_task.id}
@app.put("/api/projects/{task_id}")
def update_project_task(task_id: int, update: ProjectTaskUpdate, request: Request, db: DBSession = Depends(get_db)):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
task = db.query(ProjectTask).filter(ProjectTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
valid_statuses = ["backlog", "ready", "in_progress", "blocked", "review", "done"]
if update.status and update.status in valid_statuses:
task.status = update.status
if update.title:
task.title = update.title[:200]
if update.description is not None:
task.description = update.description[:2000]
if update.repo is not None:
task.repo = update.repo[:100]
db.commit()
return {"status": "success"}
@app.delete("/api/projects/{task_id}")
def delete_project_task(task_id: int, request: Request, db: DBSession = Depends(get_db)):
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
task = db.query(ProjectTask).filter(ProjectTask.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
db.delete(task)
db.commit()
return {"status": "success"}
# --- Daily Digest & Weekly Retro ---
@app.get("/api/digest")
def get_daily_digest(request: Request, db: DBSession = Depends(get_db)):
"""Get structured daily digest."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
return generate_daily_digest(db)
@app.get("/api/retro")
def get_weekly_retro(request: Request, db: DBSession = Depends(get_db)):
"""Get weekly retrospective summary."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
return generate_weekly_retro(db)
# ---------------------------------------------------------------------------
# Orchestrator API — used by AI-Orchestrator to poll and update tasks
# ---------------------------------------------------------------------------
@app.get("/api/orchestrator/tasks")
def get_orchestrator_tasks(request: Request, db: DBSession = Depends(get_db)):
"""Fetch tasks eligible for agent dispatch."""
verify_api_key(request)
active_statuses = ["open", "in_progress"]
tasks = (
db.query(Task)
.filter(Task.agent_ready == True, Task.status.in_(active_statuses))
.order_by(Task.priority, Task.created_at)
.all()
)
return {
"tasks": [
{
"id": str(t.id),
"identifier": f"OML-{t.id}",
"title": t.title,
"description": t.description,
"priority": t.priority,
"state": t.status,
"labels": [l.strip() for l in (t.labels or "").split(",") if l.strip()],
"created_at": t.created_at.isoformat() if t.created_at else None,
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in tasks
]
}
@app.get("/api/orchestrator/tasks/by-states")
def get_orchestrator_tasks_by_states(states: str, request: Request, db: DBSession = Depends(get_db)):
"""Fetch tasks in specific states."""
verify_api_key(request)
state_list = [s.strip() for s in states.split(",") if s.strip()]
tasks = (
db.query(Task)
.filter(Task.agent_ready == True, Task.status.in_(state_list))
.all()
)
return {
"tasks": [
{"id": str(t.id), "identifier": f"OML-{t.id}", "title": t.title, "state": t.status}
for t in tasks
]
}
@app.post("/api/orchestrator/tasks/{task_id}/state")
def update_orchestrator_task_state(task_id: int, state: dict, request: Request, db: DBSession = Depends(get_db)):
"""Update task state from the orchestrator."""
verify_api_key(request)
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
new_status = state.get("status")
if new_status and new_status in ("open", "in_progress", "review", "completed"):
task.status = new_status
db.commit()
return {"status": "success", "task_id": task.id, "new_state": new_status}
raise HTTPException(status_code=400, detail="Invalid status")
# --- Send to NoteGraph ---
NOTEGRAPH_TOKEN = os.getenv("NOTEGRAPH_TOKEN", "")
NOTEGRAPH_API_URL = "https://notes.andreknie.de/browse/api/notes"
@app.post("/api/tasks/{task_id}/to-note")
def send_task_to_note(task_id: int, request: Request, db: DBSession = Depends(get_db)):
"""Send a task's content to NoteGraph as a new note."""
if not verify_session(request):
raise HTTPException(status_code=401, detail="Not authenticated")
if not NOTEGRAPH_TOKEN:
raise HTTPException(status_code=500, detail="NOTEGRAPH_TOKEN not configured")
task = db.query(Task).filter(Task.id == task_id).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
import httpx
note_payload = {
"title": task.title or "Untitled Task",
"content": task.description or "",
"tags": ["from-task"],
}
try:
resp = httpx.post(
f"{NOTEGRAPH_API_URL}?token={NOTEGRAPH_TOKEN}",
json=note_payload,
timeout=10.0,
)
if resp.status_code in (200, 201):
return {"status": "success", "message": "Note created in NoteGraph"}
else:
return {"status": "error", "message": f"NoteGraph returned {resp.status_code}: {resp.text[:200]}"}
except httpx.RequestError as e:
return {"status": "error", "message": f"Failed to reach NoteGraph: {str(e)}"}