Initial commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./data/sleep.db"
|
||||
CORS_ORIGINS: str = "http://localhost:5173"
|
||||
LOG_LEVEL: str = "info"
|
||||
|
||||
model_config = {"env_file": ".env", "extra": "ignore"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,113 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Union
|
||||
from sqlmodel import select, func
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from app.models import SleepSession
|
||||
|
||||
def ensure_aware(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
async def get_sessions(
|
||||
session: AsyncSession,
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
limit: int = 200,
|
||||
) -> tuple[List[SleepSession], int]:
|
||||
if to_date and to_date.hour == 0 and to_date.minute == 0 and to_date.second == 0 and to_date.microsecond == 0:
|
||||
to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999)
|
||||
|
||||
# If we have an aware to_date, but the DB is naive,
|
||||
# we should make it naive for comparison to avoid errors in some drivers.
|
||||
query_to_date = to_date.replace(tzinfo=None) if to_date and to_date.tzinfo else to_date
|
||||
query_from_date = from_date.replace(tzinfo=None) if from_date and from_date.tzinfo else from_date
|
||||
|
||||
count_statement = select(func.count()).select_from(SleepSession)
|
||||
if query_from_date:
|
||||
count_statement = count_statement.where(SleepSession.start_time >= query_from_date)
|
||||
if query_to_date:
|
||||
count_statement = count_statement.where(SleepSession.start_time <= query_to_date)
|
||||
count_result = await session.exec(count_statement)
|
||||
total = count_result.one() or 0
|
||||
|
||||
statement = select(SleepSession).order_by(SleepSession.start_time.desc())
|
||||
if query_from_date:
|
||||
statement = statement.where(SleepSession.start_time >= query_from_date)
|
||||
if query_to_date:
|
||||
statement = statement.where(SleepSession.start_time <= query_to_date)
|
||||
statement = statement.limit(limit)
|
||||
|
||||
result = await session.exec(statement)
|
||||
sessions = result.all()
|
||||
return list(sessions), total
|
||||
|
||||
async def get_active_session(session: AsyncSession) -> Optional[SleepSession]:
|
||||
statement = select(SleepSession).where(SleepSession.end_time == None)
|
||||
result = await session.exec(statement)
|
||||
return result.first()
|
||||
|
||||
async def create_sleep_session(session: AsyncSession, start_time: datetime) -> SleepSession:
|
||||
new_session = SleepSession(start_time=start_time)
|
||||
session.add(new_session)
|
||||
await session.commit()
|
||||
await session.refresh(new_session)
|
||||
return new_session
|
||||
|
||||
async def end_sleep_session(
|
||||
session: AsyncSession, session_id: int, end_time: datetime
|
||||
) -> Union[SleepSession, bool, str, None]:
|
||||
statement = select(SleepSession).where(SleepSession.id == session_id)
|
||||
result = await session.exec(statement)
|
||||
sleep_session = result.first()
|
||||
|
||||
if not sleep_session:
|
||||
return None # Not found
|
||||
|
||||
if sleep_session.end_time is not None:
|
||||
return False # Already ended
|
||||
|
||||
# Normalize to aware for comparison
|
||||
end_time_aware = end_time.replace(tzinfo=timezone.utc) if end_time.tzinfo is None else end_time
|
||||
start_time_aware = sleep_session.start_time.replace(tzinfo=timezone.utc) if sleep_session.start_time.tzinfo is None else sleep_session.start_time
|
||||
|
||||
if end_time_aware < start_time_aware:
|
||||
return "invalid" # End time before start time
|
||||
|
||||
# To store back to naive DB, we strip tzinfo if it was aware
|
||||
sleep_session.end_time = end_time.replace(tzinfo=None) if end_time.tzinfo else end_time
|
||||
session.add(sleep_session)
|
||||
await session.commit()
|
||||
await session.refresh(sleep_session)
|
||||
return sleep_session
|
||||
|
||||
async def delete_session(session: AsyncSession, session_id: int) -> bool:
|
||||
statement = select(SleepSession).where(SleepSession.id == session_id)
|
||||
result = await session.exec(statement)
|
||||
sleep_session = result.first()
|
||||
|
||||
if not sleep_session:
|
||||
return False
|
||||
|
||||
await session.delete(sleep_session)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def update_session(
|
||||
session: AsyncSession, session_id: int, update_data: dict
|
||||
) -> Optional[SleepSession]:
|
||||
statement = select(SleepSession).where(SleepSession.id == session_id)
|
||||
result = await session.exec(statement)
|
||||
sleep_session = result.first()
|
||||
|
||||
if not sleep_session:
|
||||
return None
|
||||
|
||||
for key, value in update_data.items():
|
||||
if hasattr(sleep_session, key):
|
||||
setattr(sleep_session, key, value)
|
||||
|
||||
session.add(sleep_session)
|
||||
await session.commit()
|
||||
await session.refresh(sleep_session)
|
||||
return sleep_session
|
||||
@@ -0,0 +1,23 @@
|
||||
from sqlmodel import SQLModel, Field, create_engine, Session
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime
|
||||
import datetime as dt
|
||||
|
||||
from app.config import settings
|
||||
|
||||
DATABASE_URL = settings.DATABASE_URL
|
||||
|
||||
engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
|
||||
|
||||
async def init_db():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(SQLModel.metadata.create_all)
|
||||
|
||||
|
||||
async def get_session() -> AsyncSession:
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
@@ -0,0 +1,28 @@
|
||||
from fastapi import FastAPI
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.database import engine, init_db
|
||||
from app.routers import sessions, stats
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await init_db()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS.split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include routers
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(stats.router)
|
||||
@@ -0,0 +1,14 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class SleepSession(SQLModel, table=True):
|
||||
__tablename__ = "sleep_session"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
start_time: datetime = Field(nullable=False)
|
||||
end_time: Optional[datetime] = Field(default=None, nullable=True)
|
||||
notes: Optional[str] = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -0,0 +1,103 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from app.database import get_session
|
||||
from app.crud import get_sessions, end_sleep_session
|
||||
from app.schemas import (
|
||||
SessionListResponse,
|
||||
SleepSessionUpdate,
|
||||
SleepSessionRead,
|
||||
SleepSessionCreate,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
|
||||
|
||||
|
||||
@router.post("", response_model=SleepSessionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_session(
|
||||
payload: SleepSessionCreate, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
from app.crud import get_active_session, create_sleep_session
|
||||
|
||||
active = await get_active_session(session)
|
||||
if active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="An active sleep session is already open.",
|
||||
)
|
||||
|
||||
return await create_sleep_session(session, payload.start_time)
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
async def read_active_session(session: AsyncSession = Depends(get_session)):
|
||||
from app.crud import get_active_session
|
||||
|
||||
active = await get_active_session(session)
|
||||
return {"session": active}
|
||||
|
||||
|
||||
@router.get("", response_model=SessionListResponse)
|
||||
async def list_sessions(
|
||||
from_date: Optional[datetime] = None,
|
||||
to_date: Optional[datetime] = None,
|
||||
limit: int = 200,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
sessions, total = await get_sessions(session, from_date, to_date, limit)
|
||||
return {"sessions": sessions, "total": total}
|
||||
|
||||
|
||||
@router.patch("/{session_id}/end", response_model=None)
|
||||
async def end_session_endpoint(
|
||||
session_id: int,
|
||||
payload: SleepSessionUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
if not payload.end_time:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="end_time is required to end a session."
|
||||
)
|
||||
|
||||
result = await end_sleep_session(session, session_id, payload.end_time)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found.")
|
||||
if result is False:
|
||||
raise HTTPException(status_code=409, detail="Session has already ended.")
|
||||
if result == "invalid":
|
||||
raise HTTPException(
|
||||
status_code=400, detail="end_time cannot be before start_time."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_session_endpoint(
|
||||
session_id: int, session: AsyncSession = Depends(get_session)
|
||||
):
|
||||
from app.crud import delete_session
|
||||
|
||||
success = await delete_session(session, session_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Session not found.")
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/{session_id}", response_model=None)
|
||||
async def update_session_endpoint(
|
||||
session_id: int,
|
||||
payload: SleepSessionUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
from app.crud import update_session
|
||||
|
||||
updated = await update_session(
|
||||
session, session_id, payload.model_dump(exclude_unset=True)
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail="Session not found.")
|
||||
return updated
|
||||
@@ -0,0 +1,159 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from app.database import get_session
|
||||
from app.crud import get_sessions
|
||||
from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse
|
||||
from app.utils.time_utils import get_day_night_splits
|
||||
|
||||
router = APIRouter(prefix="/api/stats", tags=["stats"])
|
||||
|
||||
|
||||
@router.get("/daily-summary", response_model=DailySummaryResponse)
|
||||
async def get_daily_summary(
|
||||
from_date: datetime,
|
||||
to_date: datetime,
|
||||
day_start: str = Query("07:00"),
|
||||
day_end: str = Query("20:00"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
day_start_time = time.fromisoformat(day_start)
|
||||
day_end_time = time.fromisoformat(day_end)
|
||||
except ValueError:
|
||||
return {"days": []}
|
||||
|
||||
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
|
||||
summary_map = {}
|
||||
|
||||
for s in sessions:
|
||||
if not s.end_time:
|
||||
continue
|
||||
segments = get_day_night_splits(
|
||||
s.start_time, s.end_time, day_start_time, day_end_time
|
||||
)
|
||||
for seg in segments:
|
||||
date_str = seg["start"].strftime("%Y-%m-%d")
|
||||
if date_str not in summary_map:
|
||||
summary_map[date_str] = {"day": 0.0, "night": 0.0}
|
||||
duration = (seg["end"] - seg["start"]).total_seconds() / 60.0
|
||||
if seg["type"] == "day":
|
||||
summary_map[date_str]["day"] += duration
|
||||
else:
|
||||
summary_map[date_str]["night"] += duration
|
||||
|
||||
days = []
|
||||
for date_str in sorted(summary_map.keys()):
|
||||
data = summary_map[date_str]
|
||||
days.append(
|
||||
{
|
||||
"date": date_str,
|
||||
"total_minutes": int(round(data["day"] + data["night"])),
|
||||
"day_minutes": int(round(data["day"])),
|
||||
"night_minutes": int(round(data["night"])),
|
||||
}
|
||||
)
|
||||
return {"days": days}
|
||||
|
||||
|
||||
@router.get("/timetable", response_model=TimetableResponse)
|
||||
async def get_timetable(
|
||||
from_date: datetime,
|
||||
to_date: datetime,
|
||||
day_start: str = Query("07:00"),
|
||||
day_end: str = Query("20:00"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
|
||||
slots = []
|
||||
current_date = from_date.date()
|
||||
while current_date <= to_date.date():
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
for i in range(96):
|
||||
slot_start_time = datetime.combine(current_date, time(0, 0)) + timedelta(
|
||||
minutes=i * 15
|
||||
)
|
||||
slot_end_time = slot_start_time + timedelta(minutes=15)
|
||||
slot_start_str = slot_start_time.strftime("%H:%M")
|
||||
slot_end_str = slot_end_time.strftime("%H:%M")
|
||||
is_sleeping = False
|
||||
for s in sessions:
|
||||
if s.start_time <= slot_end_time and (
|
||||
s.end_time is None or s.end_time >= slot_start_time
|
||||
):
|
||||
is_sleeping = True
|
||||
break
|
||||
slots.append(
|
||||
{
|
||||
"date": date_str,
|
||||
"slot_start": slot_start_str,
|
||||
"slot_end": slot_end_str,
|
||||
"sleeping": is_sleeping,
|
||||
}
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
return {"slots": slots}
|
||||
|
||||
|
||||
@router.get("/wake-sleep-times", response_model=WakeupBedtimeResponse)
|
||||
async def get_wake_sleep_times(
|
||||
from_date: datetime,
|
||||
to_date: datetime,
|
||||
wakeup_ref: str = Query("04:00"),
|
||||
bedtime_ref: str = Query("19:00"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
w_ref_time = time.fromisoformat(wakeup_ref)
|
||||
b_ref_time = time.fromisoformat(bedtime_ref)
|
||||
except ValueError:
|
||||
return {"days": []}
|
||||
|
||||
sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date)
|
||||
days_data = []
|
||||
current_date = from_date.date()
|
||||
while current_date <= to_date.date():
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
day_sessions = [
|
||||
s
|
||||
for s in sessions
|
||||
if s.start_time.date() == current_date
|
||||
or (s.end_time and s.end_time.date() == current_date)
|
||||
]
|
||||
|
||||
wakeup_min = None
|
||||
bedtime_min = None
|
||||
|
||||
if day_sessions:
|
||||
# Find first wakeup after wakeup_ref
|
||||
wakeups = []
|
||||
for s in day_sessions:
|
||||
if s.end_time and s.end_time.time() >= w_ref_time:
|
||||
diff = (
|
||||
s.end_time - datetime.combine(current_date, w_ref_time)
|
||||
).total_seconds() / 60.0
|
||||
wakeups.append(int(diff))
|
||||
if wakeups:
|
||||
wakeup_min = min(wakeups)
|
||||
|
||||
# Find last bedtime before bedtime_ref
|
||||
bedtimes = []
|
||||
for s in day_sessions:
|
||||
if s.start_time.time() <= b_ref_time:
|
||||
diff = (
|
||||
s.start_time - datetime.combine(current_date, b_ref_time)
|
||||
).total_seconds() / 60.0
|
||||
bedtimes.append(int(diff))
|
||||
if bedtimes:
|
||||
bedtime_min = max(bedtimes)
|
||||
|
||||
days_data.append(
|
||||
{
|
||||
"date": date_str,
|
||||
"wakeup_minutes": wakeup_min,
|
||||
"bedtime_minutes": bedtime_min,
|
||||
}
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
return {"days": days_data}
|
||||
@@ -0,0 +1,59 @@
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SleepSessionCreate(BaseModel):
|
||||
start_time: datetime
|
||||
|
||||
|
||||
class SleepSessionUpdate(BaseModel):
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class SleepSessionRead(BaseModel):
|
||||
id: int
|
||||
start_time: datetime
|
||||
end_time: Optional[datetime]
|
||||
notes: Optional[str]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SessionListResponse(BaseModel):
|
||||
sessions: List[SleepSessionRead]
|
||||
total: int
|
||||
|
||||
|
||||
class DailySummaryDay(BaseModel):
|
||||
date: str
|
||||
total_minutes: int
|
||||
day_minutes: int
|
||||
night_minutes: int
|
||||
|
||||
|
||||
class DailySummaryResponse(BaseModel):
|
||||
days: List[DailySummaryDay]
|
||||
|
||||
|
||||
class TimetableSlot(BaseModel):
|
||||
date: str
|
||||
slot_start: str
|
||||
slot_end: str
|
||||
sleeping: bool
|
||||
|
||||
|
||||
class TimetableResponse(BaseModel):
|
||||
slots: List[TimetableSlot]
|
||||
|
||||
|
||||
class WakeupBedtimeDay(BaseModel):
|
||||
date: str
|
||||
wakeup_minutes: Optional[int]
|
||||
bedtime_minutes: Optional[int]
|
||||
|
||||
|
||||
class WakeupBedtimeResponse(BaseModel):
|
||||
days: List[WakeupBedtimeDay]
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime, time, timedelta
|
||||
from typing import List
|
||||
|
||||
|
||||
def get_day_night_splits(
|
||||
session_start: datetime,
|
||||
session_end: datetime,
|
||||
day_start_boundary: time,
|
||||
day_end_boundary: time,
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Splits a sleep session into day and night segments based on boundaries.
|
||||
A segment is defined by [segment_start, segment_end] and its type (day/night).
|
||||
"""
|
||||
segments = []
|
||||
points = [session_start, session_end]
|
||||
|
||||
for offset in [0, 1]:
|
||||
base_date = session_start.date() + timedelta(days=offset)
|
||||
try:
|
||||
b1 = datetime.combine(base_date, day_start_boundary).replace(
|
||||
tzinfo=session_start.tzinfo
|
||||
)
|
||||
b2 = datetime.combine(base_date, day_end_boundary).replace(
|
||||
tzinfo=session_start.tzinfo
|
||||
)
|
||||
if session_start < b1 < session_end:
|
||||
points.append(b1)
|
||||
if session_start < b2 < session_end:
|
||||
points.append(b2)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
sorted_points = sorted(list(set(points)))
|
||||
|
||||
for i in range(len(sorted_points) - 1):
|
||||
seg_start = sorted_points[i]
|
||||
seg_end = sorted_points[i + 1]
|
||||
midpoint = seg_start + (seg_end - seg_start) / 2
|
||||
is_day = day_start_pattern(
|
||||
midpoint.time(), day_start_boundary, day_end_boundary
|
||||
)
|
||||
segments.append(
|
||||
{"start": seg_start, "end": seg_end, "type": "day" if is_day else "night"}
|
||||
)
|
||||
|
||||
return segments
|
||||
|
||||
|
||||
def day_start_pattern(current_time: time, start: time, end: time) -> bool:
|
||||
if start <= end:
|
||||
return start <= current_time < end
|
||||
else: # Spans midnight
|
||||
return current_time >= start or current_time < end
|
||||
|
||||
|
||||
from datetime import datetime, time, timedelta
|
||||
|
||||
|
||||
def minutes_after(dt: datetime, reference: time) -> float:
|
||||
"""Returns minutes after a reference time."""
|
||||
ref_delta = timedelta(hours=reference.hour, minutes=reference.minute)
|
||||
dt_delta = timedelta(hours=dt.hour, minutes=dt.minute)
|
||||
return (dt_delta - ref_delta).total_seconds() / 60.0
|
||||
Reference in New Issue
Block a user