calink/backend/main.py
2026-02-20 15:50:32 +02:00

537 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Calink API - Generate reusable calendar invitation links (.ics)
Requirements:
pip install fastapi uvicorn pydantic aiosqlite
Example usage:
# Run the server
python main.py
"""
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional, List
import os
import json
import uuid
import sqlite3
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
from pydantic import BaseModel, Field, field_validator
from zoneinfo import ZoneInfo
# Configuration
DATABASE_PATH = os.getenv("DATABASE_PATH", "./data/app.db")
# Database initialization
def init_db():
"""Initialize SQLite database with required tables."""
os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else "./data", exist_ok=True)
conn = sqlite3.connect(DATABASE_PATH)
cursor = conn.cursor()
# Events history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS events_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_dt TEXT NOT NULL,
end_dt TEXT,
timezone TEXT NOT NULL,
reminders_json TEXT,
last_downloaded_at TEXT
)
""")
# Reminder templates table
cursor.execute("""
CREATE TABLE IF NOT EXISTS reminder_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
reminders_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
conn.commit()
conn.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
init_db()
yield
# FastAPI app
app = FastAPI(
title="Calink API",
description="Calink API Generate reusable calendar invitation links (.ics)",
version="1.0.0",
lifespan=lifespan
)
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Models
class ReminderUnit(str, Enum):
"""Supported reminder time units."""
minutes = "minutes"
hours = "hours"
days = "days"
weeks = "weeks"
class Reminder(BaseModel):
"""Reminder configuration for an event."""
amount: int = Field(..., gt=0, description="Amount of time before event")
unit: ReminderUnit = Field(..., description="Time unit for reminder")
class EventRequest(BaseModel):
"""Request model for creating an event."""
title: str = Field(..., min_length=1, description="Event title/summary")
description: Optional[str] = Field(None, description="Event description")
location: Optional[str] = Field(None, description="Event location")
start_dt: str = Field(..., description="Event start datetime (ISO 8601 string)")
end_dt: Optional[str] = Field(None, description="Event end datetime (ISO 8601 string)")
timezone: str = Field("UTC", description="Timezone for naive datetimes")
reminders: Optional[List[Reminder]] = Field(None, max_length=10, description="Event reminders (max 10)")
@field_validator('reminders')
@classmethod
def validate_reminders(cls, v):
"""Validate reminders list."""
if v is not None and len(v) > 10:
raise ValueError("Maximum 10 reminders allowed")
return v
class ReminderTemplate(BaseModel):
"""Reminder template model."""
name: str = Field(..., min_length=1, max_length=100)
reminders: List[Reminder] = Field(..., max_length=10)
class HistoryEvent(BaseModel):
"""History event response model."""
id: int
created_at: str
title: str
description: Optional[str]
location: Optional[str]
start_dt: str
end_dt: Optional[str]
timezone: str
reminders: Optional[List[Reminder]]
last_downloaded_at: Optional[str]
class TemplateResponse(BaseModel):
"""Template response model."""
id: int
name: str
reminders: List[Reminder]
created_at: str
# Helper functions
def get_db():
"""Get database connection."""
conn = sqlite3.connect(DATABASE_PATH)
conn.row_factory = sqlite3.Row
return conn
def parse_datetime(dt_str: str, tz: str = "UTC") -> datetime:
"""Parse ISO datetime string and apply timezone if naive."""
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo(tz))
return dt
except Exception as e:
raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO 8601 format.")
def format_datetime_for_ics(dt: datetime) -> str:
"""Format datetime for iCalendar format (UTC with Z suffix)."""
utc_dt = dt.astimezone(ZoneInfo("UTC"))
return utc_dt.strftime("%Y%m%dT%H%M%SZ")
def reminder_to_trigger(reminder: Reminder) -> str:
"""Convert reminder to iCalendar TRIGGER format."""
amount = reminder.amount
unit = reminder.unit
if unit == ReminderUnit.minutes:
return f"-PT{amount}M"
elif unit == ReminderUnit.hours:
return f"-PT{amount}H"
elif unit == ReminderUnit.days:
return f"-P{amount}D"
elif unit == ReminderUnit.weeks:
return f"-P{amount}W"
return "-PT15M"
def generate_ics(event: EventRequest) -> str:
"""Generate iCalendar .ics file content from event parameters."""
# Parse datetimes
start_dt = parse_datetime(event.start_dt, event.timezone)
# Calculate end_dt if not provided (default 60 minutes)
if event.end_dt:
end_dt = parse_datetime(event.end_dt, event.timezone)
else:
end_dt = start_dt + timedelta(minutes=60)
# Validate end_dt is after start_dt
if end_dt <= start_dt:
raise ValueError("end_dt must be after start_dt")
# Generate unique ID and timestamp
uid = str(uuid.uuid4())
dtstamp = datetime.now(ZoneInfo("UTC"))
# Start building ICS content
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Calink//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{format_datetime_for_ics(dtstamp)}",
f"DTSTART:{format_datetime_for_ics(start_dt)}",
f"DTEND:{format_datetime_for_ics(end_dt)}",
f"SUMMARY:{event.title}",
]
# Add optional DESCRIPTION
if event.description:
desc = event.description.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;").replace("\n", "\\n")
lines.append(f"DESCRIPTION:{desc}")
# Add optional LOCATION
if event.location:
loc = event.location.replace("\\", "\\\\").replace(",", "\\,").replace(";", "\\;")
lines.append(f"LOCATION:{loc}")
# Add VALARM blocks for reminders
if event.reminders:
for reminder in event.reminders:
trigger = reminder_to_trigger(reminder)
lines.extend([
"BEGIN:VALARM",
"ACTION:DISPLAY",
"DESCRIPTION:Reminder",
f"TRIGGER:{trigger}",
"END:VALARM"
])
# Close VEVENT and VCALENDAR
lines.extend([
"END:VEVENT",
"END:VCALENDAR"
])
# Join with CRLF and ensure trailing CRLF
ics_content = "\r\n".join(lines) + "\r\n"
return ics_content
def save_to_history(event: EventRequest, is_download: bool = False):
"""Save event to history."""
conn = get_db()
cursor = conn.cursor()
reminders_json = json.dumps([r.dict() for r in event.reminders]) if event.reminders else None
now = datetime.now(ZoneInfo("UTC")).isoformat()
cursor.execute("""
INSERT INTO events_history
(created_at, title, description, location, start_dt, end_dt, timezone, reminders_json, last_downloaded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
now,
event.title,
event.description,
event.location,
event.start_dt,
event.end_dt,
event.timezone,
reminders_json,
now if is_download else None
))
conn.commit()
conn.close()
# API Endpoints
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"ok": True}
@app.get("/")
async def root():
"""Root endpoint with API information."""
return {
"app": "Calink",
"description": "Generate reusable calendar invitation links (.ics)",
"docs": "/docs",
"health": "/health"
}
@app.post("/api/ics")
async def create_ics(event: EventRequest):
"""Generate and download an iCalendar .ics file."""
try:
ics_content = generate_ics(event)
save_to_history(event, is_download=True)
return Response(
content=ics_content,
media_type="text/calendar; charset=utf-8",
headers={
"Content-Disposition": 'attachment; filename="event.ics"'
}
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
@app.post("/api/preview")
async def preview_ics(event: EventRequest):
"""Generate and return ICS content as plain text for preview."""
try:
ics_content = generate_ics(event)
save_to_history(event, is_download=False)
return Response(
content=ics_content,
media_type="text/plain; charset=utf-8"
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error generating ICS file: {str(e)}")
@app.get("/api/history")
async def get_history(limit: int = Query(20, ge=1, le=100)):
"""Get events history."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("""
SELECT * FROM events_history
ORDER BY created_at DESC
LIMIT ?
""", (limit,))
rows = cursor.fetchall()
conn.close()
events = []
for row in rows:
reminders = None
if row['reminders_json']:
reminders_data = json.loads(row['reminders_json'])
reminders = [Reminder(**r) for r in reminders_data]
events.append(HistoryEvent(
id=row['id'],
created_at=row['created_at'],
title=row['title'],
description=row['description'],
location=row['location'],
start_dt=row['start_dt'],
end_dt=row['end_dt'],
timezone=row['timezone'],
reminders=reminders,
last_downloaded_at=row['last_downloaded_at']
))
return events
@app.get("/api/history/{event_id}")
async def get_history_event(event_id: int):
"""Get a specific event from history."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT * FROM events_history WHERE id = ?", (event_id,))
row = cursor.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Event not found")
reminders = None
if row['reminders_json']:
reminders_data = json.loads(row['reminders_json'])
reminders = [Reminder(**r) for r in reminders_data]
return HistoryEvent(
id=row['id'],
created_at=row['created_at'],
title=row['title'],
description=row['description'],
location=row['location'],
start_dt=row['start_dt'],
end_dt=row['end_dt'],
timezone=row['timezone'],
reminders=reminders,
last_downloaded_at=row['last_downloaded_at']
)
@app.delete("/api/history/{event_id}")
async def delete_history_event(event_id: int):
"""Delete an event from history."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM events_history WHERE id = ?", (event_id,))
if cursor.rowcount == 0:
conn.close()
raise HTTPException(status_code=404, detail="Event not found")
conn.commit()
conn.close()
return {"message": "Event deleted successfully"}
@app.post("/api/templates")
async def create_template(template: ReminderTemplate):
"""Create a new reminder template."""
conn = get_db()
cursor = conn.cursor()
reminders_json = json.dumps([r.dict() for r in template.reminders])
now = datetime.now(ZoneInfo("UTC")).isoformat()
try:
cursor.execute("""
INSERT INTO reminder_templates (name, reminders_json, created_at)
VALUES (?, ?, ?)
""", (template.name, reminders_json, now))
template_id = cursor.lastrowid
conn.commit()
conn.close()
return {
"id": template_id,
"name": template.name,
"reminders": template.reminders,
"created_at": now
}
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(status_code=400, detail="Template with this name already exists")
@app.get("/api/templates")
async def get_templates():
"""Get all reminder templates."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT * FROM reminder_templates ORDER BY created_at DESC")
rows = cursor.fetchall()
conn.close()
templates = []
for row in rows:
reminders_data = json.loads(row['reminders_json'])
reminders = [Reminder(**r) for r in reminders_data]
templates.append(TemplateResponse(
id=row['id'],
name=row['name'],
reminders=reminders,
created_at=row['created_at']
))
return templates
@app.get("/api/templates/{template_id}")
async def get_template(template_id: int):
"""Get a specific template."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("SELECT * FROM reminder_templates WHERE id = ?", (template_id,))
row = cursor.fetchone()
conn.close()
if not row:
raise HTTPException(status_code=404, detail="Template not found")
reminders_data = json.loads(row['reminders_json'])
reminders = [Reminder(**r) for r in reminders_data]
return TemplateResponse(
id=row['id'],
name=row['name'],
reminders=reminders,
created_at=row['created_at']
)
@app.delete("/api/templates/{template_id}")
async def delete_template(template_id: int):
"""Delete a template."""
conn = get_db()
cursor = conn.cursor()
cursor.execute("DELETE FROM reminder_templates WHERE id = ?", (template_id,))
if cursor.rowcount == 0:
conn.close()
raise HTTPException(status_code=404, detail="Template not found")
conn.commit()
conn.close()
return {"message": "Template deleted successfully"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)