feat: initial commit for TodoFlow - Conteneurised API and Glassmorphic Desktop Widget

This commit is contained in:
Michael committed 2026-05-26 17:42:14 +02:00
commit a996f0694b
23 files changed
+1897

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
from typing import List
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DATABASE_URL: str = "sqlite:///./todo.db"
# Clés API séparées par des virgules dans l'environnement, stockées comme liste
API_KEYS_RAW: str = "widget-token-secure-789,ai-agent-token-secure-101"
@property
def api_keys(self) -> List[str]:
return [key.strip() for key in self.API_KEYS_RAW.split(",") if key.strip()]
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
settings = Settings()
+18
View File
@@ -0,0 +1,18 @@
from sqlmodel import SQLModel, create_engine, Session
from app.config import settings
# Configuration de SQLite pour supporter le multi-threading de FastAPI
connect_args = {}
if settings.DATABASE_URL.startswith("sqlite"):
connect_args = {"check_same_thread": False}
engine = create_engine(settings.DATABASE_URL, echo=False, connect_args=connect_args)
def create_db_and_tables():
# Crée les tables si elles n'existent pas
SQLModel.metadata.create_all(engine)
def get_session():
# Fournit une session de base de données à chaque requête API
with Session(engine) as session:
yield session
+203
View File
@@ -0,0 +1,203 @@
from contextlib import asynccontextmanager
from datetime import datetime
from uuid import UUID
from typing import List, Optional
from fastapi import FastAPI, HTTPException, Depends, Security, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security.api_key import APIKeyHeader
from sqlmodel import Session, select
from app.config import settings
from app.database import create_db_and_tables, get_session
from app.models import Task, TaskCreate, TaskUpdate, TaskResponse, TaskStatus
# Définition du header attendu pour la clé API
API_KEY_NAME = "X-API-Key"
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
# Dépendance pour vérifier la clé API
def verify_api_key(api_key: str = Depends(api_key_header)):
# L'en-tête est obligatoire pour sécuriser l'API des accès non autorisés
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="La clé API est manquante dans l'en-tête X-API-Key."
)
if api_key not in settings.api_keys:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Clé API invalide ou non autorisée."
)
return api_key
# Cycle de vie de l'application (Lifespan modern FastAPI)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Création des tables à l'initialisation du serveur
create_db_and_tables()
yield
app = FastAPI(
title="TodoFlow API",
description="API centralisée pour la Todo list, connectée au widget de bureau et pilotable par Agent IA.",
version="1.0.0",
lifespan=lifespan
)
# Configuration CORS pour permettre au widget (Tauri) de requêter le serveur librement
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Tauri utilise des protocoles personnalisés ou localhost
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- ENDPOINTS API ---
@app.get(
"/api/v1/tasks",
response_model=List[TaskResponse],
dependencies=[Depends(verify_api_key)],
summary="Récupérer toutes les tâches"
)
def read_tasks(
status: Optional[TaskStatus] = None,
db: Session = Depends(get_session)
):
"""
Récupère la liste de toutes les tâches stockées.
Permet de filtrer optionnellement par statut (A faire, En cours, Termine).
"""
try:
statement = select(Task)
if status:
statement = statement.where(Task.status == status)
# Tri automatique : d'abord les tâches "En cours", puis "À faire", et enfin les "Terminé".
# En second critère, on trie par date de création décroissante.
tasks = db.exec(statement).all()
# Tri personnalisé côté serveur pour optimiser l'affichage du widget
status_priority = {TaskStatus.IN_PROGRESS: 0, TaskStatus.TODO: 1, TaskStatus.DONE: 2}
tasks.sort(key=lambda t: (status_priority.get(t.status, 9), t.created_at), reverse=False)
return tasks
except Exception as e:
# Journaliser et renvoyer une erreur interne propre
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la récupération des tâches : {str(e)}"
)
@app.get(
"/api/v1/tasks/{task_id}",
response_model=TaskResponse,
dependencies=[Depends(verify_api_key)],
summary="Récupérer les détails d'une tâche"
)
def read_task(task_id: UUID, db: Session = Depends(get_session)):
"""
Récupère une tâche spécifique par son UUID.
Renvoie une erreur 404 si la tâche n'existe pas.
"""
task = db.get(Task, task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tâche introuvable."
)
return task
@app.post(
"/api/v1/tasks",
response_model=TaskResponse,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(verify_api_key)],
summary="Créer une nouvelle tâche"
)
def create_task(task_create: TaskCreate, db: Session = Depends(get_session)):
"""
Crée une nouvelle tâche avec une date de création automatique et un UUID unique.
"""
try:
new_task = Task.model_validate(task_create)
db.add(new_task)
db.commit()
db.refresh(new_task)
return new_task
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Impossible de créer la tâche : {str(e)}"
)
@app.put(
"/api/v1/tasks/{task_id}",
response_model=TaskResponse,
dependencies=[Depends(verify_api_key)],
summary="Mettre à jour une tâche existante"
)
def update_task(
task_id: UUID,
task_update: TaskUpdate,
db: Session = Depends(get_session)
):
"""
Met à jour partiellement ou totalement une tâche.
Met automatiquement à jour le champ updated_at.
"""
task = db.get(Task, task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tâche introuvable pour mise à jour."
)
try:
# Extraction des champs soumis pour une mise à jour partielle
update_data = task_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(task, key, value)
# Mise à jour systématique de l'horodatage de modification
task.updated_at = datetime.utcnow()
db.add(task)
db.commit()
db.refresh(task)
return task
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Erreur lors de la mise à jour de la tâche : {str(e)}"
)
@app.delete(
"/api/v1/tasks/{task_id}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(verify_api_key)],
summary="Supprimer une tâche"
)
def delete_task(task_id: UUID, db: Session = Depends(get_session)):
"""
Supprime définitivement une tâche par son UUID.
"""
task = db.get(Task, task_id)
if not task:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tâche introuvable pour suppression."
)
try:
db.delete(task)
db.commit()
return None
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la suppression de la tâche : {str(e)}"
)
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from uuid import UUID, uuid4
from sqlmodel import SQLModel, Field
class TaskStatus(str, Enum):
TODO = "A faire"
IN_PROGRESS = "En cours"
DONE = "Termine"
class TaskBase(SQLModel):
title: str = Field(index=True, min_length=1, max_length=100)
description: Optional[str] = Field(default=None)
status: TaskStatus = Field(default=TaskStatus.TODO)
class TaskCreate(TaskBase):
pass
class TaskUpdate(SQLModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=100)
description: Optional[str] = None
status: Optional[TaskStatus] = None
class TaskResponse(TaskBase):
id: UUID
created_at: datetime
updated_at: datetime
class Task(TaskBase, table=True):
__tablename__ = "tasks"
id: UUID = Field(default_factory=uuid4, primary_key=True, index=True)
created_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)
updated_at: datetime = Field(default_factory=datetime.utcnow, nullable=False)