initialer commit, Grundcode
This commit is contained in:
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
66
backend/auth.py
Normal file
66
backend/auth.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
|
||||
import models
|
||||
from database import get_db
|
||||
|
||||
SECRET_KEY = os.environ.get(
|
||||
"SECRET_KEY", "insecure-default-key-change-in-production-use-env-var"
|
||||
)
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7 # 7 days
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (
|
||||
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode["exp"] = expire
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
|
||||
) -> models.User:
|
||||
exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if not username:
|
||||
raise exc
|
||||
except JWTError:
|
||||
raise exc
|
||||
user = db.query(models.User).filter(models.User.username == username).first()
|
||||
if not user:
|
||||
raise exc
|
||||
return user
|
||||
|
||||
|
||||
def get_current_admin(
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
) -> models.User:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
267
backend/caldav_client.py
Normal file
267
backend/caldav_client.py
Normal file
@@ -0,0 +1,267 @@
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import caldav
|
||||
from icalendar import Calendar, Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
|
||||
|
||||
|
||||
def _client(url: str, username: str, password: str) -> caldav.DAVClient:
|
||||
return caldav.DAVClient(url=url, username=username, password=password)
|
||||
|
||||
|
||||
def fetch_calendars(url: str, username: str, password: str) -> List[Dict]:
|
||||
"""Return list of {url, name, color} dicts for all calendars in the account."""
|
||||
try:
|
||||
client = _client(url, username, password)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
except Exception as e:
|
||||
raise ValueError(f"Cannot connect to CalDAV server: {e}") from e
|
||||
|
||||
result = []
|
||||
for idx, cal in enumerate(calendars):
|
||||
try:
|
||||
try:
|
||||
name = cal.get_properties([caldav.elements.dav.DisplayName()])[
|
||||
"{DAV:}displayname"
|
||||
]
|
||||
except Exception:
|
||||
name = str(cal.url).rstrip("/").split("/")[-1] or "Calendar"
|
||||
|
||||
color = None
|
||||
try:
|
||||
props = cal.get_properties(
|
||||
[caldav.elements.cdav.CalendarColor()]
|
||||
)
|
||||
raw = props.get("{http://apple.com/ns/ical/}calendar-color", "")
|
||||
if raw and len(raw) >= 7:
|
||||
color = raw[:7]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result.append(
|
||||
{
|
||||
"url": str(cal.url),
|
||||
"name": name or "Calendar",
|
||||
"color": color or PALETTE[idx % len(PALETTE)],
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not inspect calendar %s: %s", cal.url, exc)
|
||||
result.append(
|
||||
{
|
||||
"url": str(cal.url),
|
||||
"name": "Calendar",
|
||||
"color": PALETTE[idx % len(PALETTE)],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_events(
|
||||
url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
calendar_url: str,
|
||||
start: datetime,
|
||||
end: datetime,
|
||||
) -> List[Dict]:
|
||||
"""Fetch events from a calendar within [start, end]."""
|
||||
try:
|
||||
client = _client(url, username, password)
|
||||
cal = client.calendar(url=calendar_url)
|
||||
resources = cal.date_search(start=start, end=end, expand=True)
|
||||
except Exception as exc:
|
||||
logger.error("Error fetching events from %s: %s", calendar_url, exc)
|
||||
return []
|
||||
|
||||
events = []
|
||||
for resource in resources:
|
||||
try:
|
||||
parsed = _parse_ics(resource.data, str(resource.url))
|
||||
if parsed:
|
||||
events.extend(parsed)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not parse event %s: %s", resource.url, exc)
|
||||
return events
|
||||
|
||||
|
||||
def _parse_ics(raw: str, event_url: str) -> List[Dict]:
|
||||
"""Parse raw ICS data and return a list of event dicts."""
|
||||
cal = Calendar.from_ical(raw)
|
||||
results = []
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
continue
|
||||
try:
|
||||
uid = str(component.get("UID", uuid.uuid4()))
|
||||
title = str(component.get("SUMMARY", "Untitled"))
|
||||
location = str(component.get("LOCATION", "") or "")
|
||||
description = str(component.get("DESCRIPTION", "") or "")
|
||||
color = str(component.get("X-CALENDARR-COLOR", "") or "")
|
||||
|
||||
dtstart_prop = component.get("DTSTART")
|
||||
dtend_prop = component.get("DTEND")
|
||||
duration_prop = component.get("DURATION")
|
||||
|
||||
if dtstart_prop is None:
|
||||
continue
|
||||
|
||||
dtstart = dtstart_prop.dt
|
||||
all_day = isinstance(dtstart, date) and not isinstance(dtstart, datetime)
|
||||
|
||||
if all_day:
|
||||
if dtend_prop:
|
||||
dtend = dtend_prop.dt
|
||||
elif duration_prop:
|
||||
dtend = dtstart + duration_prop.dt
|
||||
else:
|
||||
dtend = dtstart + timedelta(days=1)
|
||||
start_str = dtstart.isoformat()
|
||||
end_str = dtend.isoformat() if isinstance(dtend, date) else dtstart.isoformat()
|
||||
else:
|
||||
if dtstart.tzinfo is None:
|
||||
dtstart = dtstart.replace(tzinfo=timezone.utc)
|
||||
if dtend_prop:
|
||||
dtend = dtend_prop.dt
|
||||
if isinstance(dtend, date) and not isinstance(dtend, datetime):
|
||||
dtend = datetime.combine(dtend, datetime.min.time()).replace(
|
||||
tzinfo=timezone.utc
|
||||
)
|
||||
elif dtend.tzinfo is None:
|
||||
dtend = dtend.replace(tzinfo=timezone.utc)
|
||||
elif duration_prop:
|
||||
dtend = dtstart + duration_prop.dt
|
||||
else:
|
||||
dtend = dtstart + timedelta(hours=1)
|
||||
start_str = dtstart.isoformat()
|
||||
end_str = dtend.isoformat()
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": uid,
|
||||
"url": event_url,
|
||||
"title": title,
|
||||
"start": start_str,
|
||||
"end": end_str,
|
||||
"allDay": all_day,
|
||||
"location": location,
|
||||
"description": description,
|
||||
"color": color or None,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Skipping malformed VEVENT: %s", exc)
|
||||
return results
|
||||
|
||||
|
||||
def create_event(
|
||||
url: str,
|
||||
username: str,
|
||||
password: str,
|
||||
calendar_url: str,
|
||||
data: Dict,
|
||||
) -> str:
|
||||
client = _client(url, username, password)
|
||||
cal_obj = client.calendar(url=calendar_url)
|
||||
|
||||
cal = Calendar()
|
||||
cal.add("prodid", "-//Calendarr//EN")
|
||||
cal.add("version", "2.0")
|
||||
|
||||
event = Event()
|
||||
uid = str(uuid.uuid4())
|
||||
event.add("uid", uid)
|
||||
event.add("summary", data.get("title", "New Event"))
|
||||
event.add("dtstamp", datetime.now(timezone.utc))
|
||||
|
||||
if data.get("allDay"):
|
||||
start = date.fromisoformat(data["start"][:10])
|
||||
end_raw = data.get("end", data["start"])[:10]
|
||||
end = date.fromisoformat(end_raw)
|
||||
if end <= start:
|
||||
end = start + timedelta(days=1)
|
||||
event.add("dtstart", start)
|
||||
event.add("dtend", end)
|
||||
else:
|
||||
start = _parse_dt(data["start"])
|
||||
end = _parse_dt(data["end"])
|
||||
event.add("dtstart", start)
|
||||
event.add("dtend", end)
|
||||
|
||||
if data.get("location"):
|
||||
event.add("location", data["location"])
|
||||
if data.get("description"):
|
||||
event.add("description", data["description"])
|
||||
if data.get("color"):
|
||||
event.add("x-calendarr-color", data["color"])
|
||||
|
||||
cal.add_component(event)
|
||||
cal_obj.save_event(cal.to_ical().decode("utf-8"))
|
||||
return uid
|
||||
|
||||
|
||||
def update_event(
|
||||
url: str, username: str, password: str, event_url: str, data: Dict
|
||||
):
|
||||
client = _client(url, username, password)
|
||||
resource = client.event(url=event_url)
|
||||
raw = resource.data
|
||||
|
||||
cal = Calendar.from_ical(raw)
|
||||
new_cal = Calendar()
|
||||
for key, val in cal.items():
|
||||
new_cal.add(key, val)
|
||||
|
||||
for component in cal.walk():
|
||||
if component.name != "VEVENT":
|
||||
if component.name != "VCALENDAR":
|
||||
new_cal.add_component(component)
|
||||
continue
|
||||
|
||||
if "title" in data or "summary" in data:
|
||||
component["SUMMARY"] = data.get("title", data.get("summary", ""))
|
||||
|
||||
if "start" in data:
|
||||
if data.get("allDay"):
|
||||
component["DTSTART"] = date.fromisoformat(data["start"][:10])
|
||||
else:
|
||||
component["DTSTART"] = _parse_dt(data["start"])
|
||||
|
||||
if "end" in data:
|
||||
if data.get("allDay"):
|
||||
component["DTEND"] = date.fromisoformat(data["end"][:10])
|
||||
else:
|
||||
component["DTEND"] = _parse_dt(data["end"])
|
||||
|
||||
if "location" in data:
|
||||
component["LOCATION"] = data["location"]
|
||||
if "description" in data:
|
||||
component["DESCRIPTION"] = data["description"]
|
||||
if "color" in data:
|
||||
component["X-CALENDARR-COLOR"] = data["color"]
|
||||
|
||||
new_cal.add_component(component)
|
||||
|
||||
resource.data = new_cal.to_ical().decode("utf-8")
|
||||
resource.save()
|
||||
|
||||
|
||||
def delete_event(url: str, username: str, password: str, event_url: str):
|
||||
client = _client(url, username, password)
|
||||
resource = client.event(url=event_url)
|
||||
resource.delete()
|
||||
|
||||
|
||||
def _parse_dt(s: str) -> datetime:
|
||||
s = s.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(s)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
24
backend/database.py
Normal file
24
backend/database.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", Path(__file__).parent.parent / "data"))
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
DATABASE_URL = f"sqlite:///{DATA_DIR}/calendarr.db"
|
||||
|
||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
43
backend/main.py
Normal file
43
backend/main.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from database import Base, engine
|
||||
from routers import auth_router, caldav_router, settings_router, users_router
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Create DB tables on startup
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(title="Calendarr", docs_url=None, redoc_url=None)
|
||||
|
||||
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(users_router.router, prefix="/api/users", tags=["users"])
|
||||
app.include_router(caldav_router.router, prefix="/api/caldav", tags=["caldav"])
|
||||
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])
|
||||
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend"
|
||||
app.mount("/static", StaticFiles(directory=str(FRONTEND_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
async def spa_fallback(full_path: str):
|
||||
if full_path.startswith("api/"):
|
||||
raise HTTPException(status_code=404, detail="API endpoint not found")
|
||||
index = FRONTEND_DIR / "index.html"
|
||||
return FileResponse(str(index))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 8080))
|
||||
host = os.environ.get("HOST", "0.0.0.0")
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
65
backend/models.py
Normal file
65
backend/models.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=False)
|
||||
email = Column(String(100), unique=True, nullable=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
is_admin = Column(Boolean, default=False)
|
||||
|
||||
caldav_accounts = relationship(
|
||||
"CalDAVAccount", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
settings = relationship(
|
||||
"UserSettings", back_populates="user", uselist=False, cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class CalDAVAccount(Base):
|
||||
__tablename__ = "caldav_accounts"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
url = Column(String(500), nullable=False)
|
||||
username = Column(String(100), nullable=False)
|
||||
password = Column(String(255), nullable=False)
|
||||
color = Column(String(7), default="#4285f4")
|
||||
enabled = Column(Boolean, default=True)
|
||||
|
||||
user = relationship("User", back_populates="caldav_accounts")
|
||||
calendars = relationship(
|
||||
"Calendar", back_populates="account", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Calendar(Base):
|
||||
__tablename__ = "calendars"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
account_id = Column(Integer, ForeignKey("caldav_accounts.id"), nullable=False)
|
||||
cal_id = Column(String(500), nullable=False)
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(7), nullable=True)
|
||||
enabled = Column(Boolean, default=True)
|
||||
|
||||
account = relationship("CalDAVAccount", back_populates="calendars")
|
||||
|
||||
|
||||
class UserSettings(Base):
|
||||
__tablename__ = "user_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
|
||||
default_view = Column(String(20), default="month")
|
||||
primary_color = Column(String(7), default="#4285f4")
|
||||
accent_color = Column(String(7), default="#ea4335")
|
||||
today_color = Column(String(7), default="#4285f4")
|
||||
dim_past_events = Column(Boolean, default=False)
|
||||
|
||||
user = relationship("User", back_populates="settings")
|
||||
0
backend/routers/__init__.py
Normal file
0
backend/routers/__init__.py
Normal file
73
backend/routers/auth_router.py
Normal file
73
backend/routers/auth_router.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import create_access_token, get_current_user, get_password_hash, verify_password
|
||||
from database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SetupRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
def _user_dict(user: models.User) -> dict:
|
||||
return {"id": user.id, "username": user.username, "is_admin": user.is_admin}
|
||||
|
||||
|
||||
@router.get("/setup-required")
|
||||
def setup_required(db: Session = Depends(get_db)):
|
||||
return {"required": db.query(models.User).count() == 0}
|
||||
|
||||
|
||||
@router.post("/setup")
|
||||
def setup(req: SetupRequest, db: Session = Depends(get_db)):
|
||||
if db.query(models.User).count() > 0:
|
||||
raise HTTPException(400, "Setup already completed")
|
||||
user = models.User(
|
||||
username=req.username,
|
||||
email=req.email,
|
||||
password_hash=get_password_hash(req.password),
|
||||
is_admin=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(models.UserSettings(user_id=user.id))
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
token = create_access_token({"sub": user.username})
|
||||
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
|
||||
):
|
||||
user = (
|
||||
db.query(models.User).filter(models.User.username == form_data.username).first()
|
||||
)
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
token = create_access_token({"sub": user.username})
|
||||
return {"access_token": token, "token_type": "bearer", "user": _user_dict(user)}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(current_user: models.User = Depends(get_current_user)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"is_admin": current_user.is_admin,
|
||||
}
|
||||
375
backend/routers/caldav_router.py
Normal file
375
backend/routers/caldav_router.py
Normal file
@@ -0,0 +1,375 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import caldav_client
|
||||
import models
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PALETTE = ["#4285f4", "#ea4335", "#fbbc04", "#34a853", "#ff6d00", "#46bdc6", "#8e24aa"]
|
||||
|
||||
|
||||
class AccountCreate(BaseModel):
|
||||
name: str
|
||||
url: str
|
||||
username: str
|
||||
password: str
|
||||
color: str = "#4285f4"
|
||||
|
||||
|
||||
class CalendarUpdate(BaseModel):
|
||||
enabled: Optional[bool] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
calendar_id: int
|
||||
title: str
|
||||
start: str
|
||||
end: str
|
||||
allDay: bool = False
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
start: Optional[str] = None
|
||||
end: Optional[str] = None
|
||||
allDay: Optional[bool] = None
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
|
||||
|
||||
def _account_dict(a: models.CalDAVAccount) -> dict:
|
||||
return {
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"url": a.url,
|
||||
"username": a.username,
|
||||
"color": a.color,
|
||||
"enabled": a.enabled,
|
||||
"calendars": [
|
||||
{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"color": c.color or a.color,
|
||||
"enabled": c.enabled,
|
||||
"cal_id": c.cal_id,
|
||||
}
|
||||
for c in a.calendars
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _find_account_for_event_url(
|
||||
event_url: str, accounts: list[models.CalDAVAccount]
|
||||
) -> Optional[models.CalDAVAccount]:
|
||||
for acc in accounts:
|
||||
if event_url.startswith(acc.url):
|
||||
return acc
|
||||
# fallback: check calendar urls
|
||||
for acc in accounts:
|
||||
for cal in acc.calendars:
|
||||
if event_url.startswith(cal.cal_id):
|
||||
return acc
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
def list_accounts(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
accounts = (
|
||||
db.query(models.CalDAVAccount)
|
||||
.filter(models.CalDAVAccount.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
return [_account_dict(a) for a in accounts]
|
||||
|
||||
|
||||
@router.post("/accounts")
|
||||
def add_account(
|
||||
data: AccountCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
try:
|
||||
remote_cals = caldav_client.fetch_calendars(data.url, data.username, data.password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
account = models.CalDAVAccount(
|
||||
user_id=current_user.id,
|
||||
name=data.name,
|
||||
url=data.url,
|
||||
username=data.username,
|
||||
password=data.password,
|
||||
color=data.color,
|
||||
)
|
||||
db.add(account)
|
||||
db.flush()
|
||||
|
||||
for idx, cal in enumerate(remote_cals):
|
||||
db.add(
|
||||
models.Calendar(
|
||||
account_id=account.id,
|
||||
cal_id=cal["url"],
|
||||
name=cal["name"],
|
||||
color=cal.get("color") or PALETTE[idx % len(PALETTE)],
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return _account_dict(account)
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}")
|
||||
def delete_account(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
account = (
|
||||
db.query(models.CalDAVAccount)
|
||||
.filter(
|
||||
models.CalDAVAccount.id == account_id,
|
||||
models.CalDAVAccount.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not account:
|
||||
raise HTTPException(404, "Account not found")
|
||||
db.delete(account)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/sync")
|
||||
def sync_account(
|
||||
account_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
account = (
|
||||
db.query(models.CalDAVAccount)
|
||||
.filter(
|
||||
models.CalDAVAccount.id == account_id,
|
||||
models.CalDAVAccount.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not account:
|
||||
raise HTTPException(404, "Account not found")
|
||||
try:
|
||||
remote_cals = caldav_client.fetch_calendars(
|
||||
account.url, account.username, account.password
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
|
||||
existing = {c.cal_id: c for c in account.calendars}
|
||||
for idx, cal in enumerate(remote_cals):
|
||||
if cal["url"] not in existing:
|
||||
db.add(
|
||||
models.Calendar(
|
||||
account_id=account.id,
|
||||
cal_id=cal["url"],
|
||||
name=cal["name"],
|
||||
color=cal.get("color") or PALETTE[idx % len(PALETTE)],
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing[cal["url"]].name = cal["name"]
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return _account_dict(account)
|
||||
|
||||
|
||||
@router.put("/calendars/{calendar_id}")
|
||||
def update_calendar(
|
||||
calendar_id: int,
|
||||
data: CalendarUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
calendar = (
|
||||
db.query(models.Calendar)
|
||||
.join(models.CalDAVAccount)
|
||||
.filter(
|
||||
models.Calendar.id == calendar_id,
|
||||
models.CalDAVAccount.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not calendar:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
if data.enabled is not None:
|
||||
calendar.enabled = data.enabled
|
||||
if data.color is not None:
|
||||
calendar.color = data.color
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/events")
|
||||
def get_events(
|
||||
start: str = Query(...),
|
||||
end: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start.replace("Z", "+00:00"))
|
||||
end_dt = datetime.fromisoformat(end.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid date format — use ISO 8601")
|
||||
|
||||
# Make timezone-aware
|
||||
if start_dt.tzinfo is None:
|
||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
||||
if end_dt.tzinfo is None:
|
||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
all_events = []
|
||||
accounts = (
|
||||
db.query(models.CalDAVAccount)
|
||||
.filter(
|
||||
models.CalDAVAccount.user_id == current_user.id,
|
||||
models.CalDAVAccount.enabled == True,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
for account in accounts:
|
||||
for calendar in account.calendars:
|
||||
if not calendar.enabled:
|
||||
continue
|
||||
try:
|
||||
events = caldav_client.fetch_events(
|
||||
account.url,
|
||||
account.username,
|
||||
account.password,
|
||||
calendar.cal_id,
|
||||
start_dt,
|
||||
end_dt,
|
||||
)
|
||||
cal_color = calendar.color or account.color
|
||||
for ev in events:
|
||||
ev["calendar_id"] = calendar.id
|
||||
ev["calendar_name"] = calendar.name
|
||||
ev["calendarColor"] = cal_color
|
||||
all_events.append(ev)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"Error fetching calendar %s: %s", calendar.id, exc
|
||||
)
|
||||
|
||||
return all_events
|
||||
|
||||
|
||||
@router.post("/events")
|
||||
def create_event(
|
||||
data: EventCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
calendar = (
|
||||
db.query(models.Calendar)
|
||||
.join(models.CalDAVAccount)
|
||||
.filter(
|
||||
models.Calendar.id == data.calendar_id,
|
||||
models.CalDAVAccount.user_id == current_user.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not calendar:
|
||||
raise HTTPException(404, "Calendar not found")
|
||||
account = calendar.account
|
||||
try:
|
||||
uid = caldav_client.create_event(
|
||||
account.url,
|
||||
account.username,
|
||||
account.password,
|
||||
calendar.cal_id,
|
||||
{
|
||||
"title": data.title,
|
||||
"start": data.start,
|
||||
"end": data.end,
|
||||
"allDay": data.allDay,
|
||||
"location": data.location,
|
||||
"description": data.description,
|
||||
"color": data.color,
|
||||
},
|
||||
)
|
||||
return {"uid": uid, "calendar_id": data.calendar_id}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"Could not create event: {exc}")
|
||||
|
||||
|
||||
@router.put("/events/{event_id}")
|
||||
def update_event(
|
||||
event_id: str,
|
||||
event_url: str = Query(...),
|
||||
data: EventUpdate = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
accounts = (
|
||||
db.query(models.CalDAVAccount)
|
||||
.filter(models.CalDAVAccount.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
account = _find_account_for_event_url(event_url, accounts)
|
||||
if not account:
|
||||
raise HTTPException(404, "Event not found or not authorized")
|
||||
try:
|
||||
caldav_client.update_event(
|
||||
account.url,
|
||||
account.username,
|
||||
account.password,
|
||||
event_url,
|
||||
data.model_dump(exclude_none=True) if data else {},
|
||||
)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"Could not update event: {exc}")
|
||||
|
||||
|
||||
@router.delete("/events/{event_id}")
|
||||
def delete_event(
|
||||
event_id: str,
|
||||
event_url: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
accounts = (
|
||||
db.query(models.CalDAVAccount)
|
||||
.filter(models.CalDAVAccount.user_id == current_user.id)
|
||||
.all()
|
||||
)
|
||||
account = _find_account_for_event_url(event_url, accounts)
|
||||
if not account:
|
||||
raise HTTPException(404, "Event not found or not authorized")
|
||||
try:
|
||||
caldav_client.delete_event(
|
||||
account.url, account.username, account.password, event_url
|
||||
)
|
||||
return {"ok": True}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, f"Could not delete event: {exc}")
|
||||
69
backend/routers/settings_router.py
Normal file
69
backend/routers/settings_router.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_current_user
|
||||
from database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
default_view: Optional[str] = None
|
||||
primary_color: Optional[str] = None
|
||||
accent_color: Optional[str] = None
|
||||
today_color: Optional[str] = None
|
||||
dim_past_events: Optional[bool] = None
|
||||
|
||||
|
||||
def _settings_dict(s: models.UserSettings) -> dict:
|
||||
return {
|
||||
"default_view": s.default_view,
|
||||
"primary_color": s.primary_color,
|
||||
"accent_color": s.accent_color,
|
||||
"today_color": s.today_color,
|
||||
"dim_past_events": s.dim_past_events,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def get_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
settings = (
|
||||
db.query(models.UserSettings)
|
||||
.filter(models.UserSettings.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not settings:
|
||||
settings = models.UserSettings(user_id=current_user.id)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return _settings_dict(settings)
|
||||
|
||||
|
||||
@router.put("/")
|
||||
def update_settings(
|
||||
data: SettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
settings = (
|
||||
db.query(models.UserSettings)
|
||||
.filter(models.UserSettings.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if not settings:
|
||||
settings = models.UserSettings(user_id=current_user.id)
|
||||
db.add(settings)
|
||||
|
||||
for field, value in data.model_dump(exclude_none=True).items():
|
||||
setattr(settings, field, value)
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
89
backend/routers/users_router.py
Normal file
89
backend/routers/users_router.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import models
|
||||
from auth import get_current_admin, get_current_user, get_password_hash
|
||||
from database import get_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
email: Optional[str] = None
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
def _user_dict(u: models.User) -> dict:
|
||||
return {"id": u.id, "username": u.username, "email": u.email, "is_admin": u.is_admin}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
def list_users(
|
||||
db: Session = Depends(get_db),
|
||||
_: models.User = Depends(get_current_admin),
|
||||
):
|
||||
return [_user_dict(u) for u in db.query(models.User).all()]
|
||||
|
||||
|
||||
@router.post("/")
|
||||
def create_user(
|
||||
req: CreateUserRequest,
|
||||
db: Session = Depends(get_db),
|
||||
_: models.User = Depends(get_current_admin),
|
||||
):
|
||||
if db.query(models.User).filter(models.User.username == req.username).first():
|
||||
raise HTTPException(400, "Username already taken")
|
||||
user = models.User(
|
||||
username=req.username,
|
||||
email=req.email,
|
||||
password_hash=get_password_hash(req.password),
|
||||
is_admin=req.is_admin,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(models.UserSettings(user_id=user.id))
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return _user_dict(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_admin),
|
||||
):
|
||||
if user_id == current_user.id:
|
||||
raise HTTPException(400, "Cannot delete yourself")
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.put("/{user_id}/password")
|
||||
def change_password(
|
||||
user_id: int,
|
||||
req: ChangePasswordRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: models.User = Depends(get_current_user),
|
||||
):
|
||||
if not current_user.is_admin and current_user.id != user_id:
|
||||
raise HTTPException(403, "Not authorized")
|
||||
user = db.query(models.User).filter(models.User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(404, "User not found")
|
||||
user.password_hash = get_password_hash(req.password)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
Reference in New Issue
Block a user