first commit

This commit is contained in:
Johan
2026-02-02 13:56:58 +01:00
commit 5156438be5
11 changed files with 388 additions and 0 deletions

47
README.md Executable file
View File

@@ -0,0 +1,47 @@
# Task Manager
## Installation & usage
### Backend
```bash
cd backend
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --host 127.0.0.1 --port 8000
```
### Frontend
```bash
cd frontend
python3 -m http.server 5173
```
## Quelques commandes `curl`
### Créer une tâche
```bash
curl -s -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Écrire tests API","description":"Ajouter des assertions sur /tasks"}' | jq
```
### Lister les tâches
```bash
curl -s http://127.0.0.1:8000/tasks | jq
```
### Mettre à jour une tâche
```bash
curl -s -X PUT http://127.0.0.1:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status":"DONE"}' | jq
```
### Supprimer une tâche
```bash
curl -i -X DELETE http://127.0.0.1:8000/tasks/1
```

9
backend/Dockerfile Executable file
View File

@@ -0,0 +1,9 @@
FROM python:3.12
WORKDIR /app
COPY . /app/
RUN pip install -r requirements.txt
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

24
backend/app/db.py Executable file
View File

@@ -0,0 +1,24 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
# SQLite local file. In CI, you can swap to Postgres later.
DATABASE_URL = "sqlite:///./taskmanager.db"
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False}, # needed for SQLite + threads
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

106
backend/app/main.py Executable file
View File

@@ -0,0 +1,106 @@
import os
import yaml
from fastapi import FastAPI, Body, Depends, Header, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from sqlalchemy import select, text
from datetime import datetime, timezone
from .db import Base, engine, get_db
from .models import Task
from .schemas import TaskCreate, TaskUpdate, TaskOut
API_KEY = "devsecops-demo-secret-<a_remplacer>"
app = FastAPI(title="Task Manager API", version="1.0.0")
# Allow local frontend (file:// or http://localhost) during training
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # tighten later for “good practices”
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Base.metadata.create_all(bind=engine)
@app.get("/debug")
def debug():
return {"env": dict(os.environ)}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/admin/stats")
def admin_stats(x_api_key: str | None = Header(default=None)):
if x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="Unauthorized")
return {"tasks": ""}
@app.post("/import")
def import_yaml(payload: str = Body(embed=True)):
data = yaml.full_load(payload)
return {"imported": True, "keys": list(data.keys()) if isinstance(data, dict) else "n/a"}
@app.get("/tasks", response_model=list[TaskOut])
def list_tasks(db: Session = Depends(get_db)):
tasks = db.execute(select(Task).order_by(Task.id.desc())).scalars().all()
return tasks
@app.post("/tasks", response_model=TaskOut, status_code=201)
def create_task(payload: TaskCreate, db: Session = Depends(get_db)):
task = Task(title=payload.title.strip(), description=payload.description, status="TODO")
db.add(task)
db.commit()
db.refresh(task)
return task
@app.get("/tasks/search", response_model=list[TaskOut])
def search_tasks(q: str = Query(""), db: Session = Depends(get_db)):
sql = text(f"SELECT * FROM tasks WHERE title LIKE '%{q}%' OR description LIKE '%{q}%'")
rows = db.execute(sql).mappings().all()
return [Task(**r) for r in rows]
@app.get("/tasks/{task_id}", response_model=TaskOut)
def get_task(task_id: int, db: Session = Depends(get_db)):
task = db.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@app.put("/tasks/{task_id}", response_model=TaskOut)
def update_task(task_id: int, payload: TaskUpdate, db: Session = Depends(get_db)):
task = db.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if payload.title is not None:
task.title = payload.title.strip()
if payload.description is not None:
task.description = payload.description
if payload.status is not None:
task.status = payload.status
task.updated_at = datetime.now(timezone.utc)
db.add(task)
db.commit()
db.refresh(task)
return task
@app.delete("/tasks/{task_id}", status_code=204)
def delete_task(task_id: int, db: Session = Depends(get_db)):
task = db.get(Task, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
db.delete(task)
db.commit()
return None

15
backend/app/models.py Executable file
View File

@@ -0,0 +1,15 @@
from sqlalchemy import String, Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime, timezone
from .db import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(String(1000), nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="TODO") # TODO/DOING/DONE
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))

25
backend/app/schemas.py Executable file
View File

@@ -0,0 +1,25 @@
from pydantic import BaseModel, Field
from datetime import datetime
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=1000)
class TaskUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
description: str | None = Field(default=None, max_length=1000)
status: str | None = Field(default=None, pattern="^(TODO|DOING|DONE)$")
class TaskOut(BaseModel):
id: int
title: str
description: str | None
status: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

6
backend/requirements.txt Executable file
View File

@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
pydantic==2.10.3
jinja2==2.10.1
PyYAML==5.3.1

BIN
backend/taskmanager.db Executable file

Binary file not shown.

93
frontend/app.js Executable file
View File

@@ -0,0 +1,93 @@
// Change this if your API runs elsewhere (CI, container, remote)
const API_URL = (localStorage.getItem("API_URL") || "http://127.0.0.1:8000");
document.getElementById("apiUrlLabel").textContent = API_URL;
async function api(path, options = {}) {
const res = await fetch(`${API_URL}${path}`, {
headers: { "Content-Type": "application/json" },
...options,
});
if (res.status === 204) return null;
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `HTTP ${res.status}`);
}
return res.json();
}
function taskCard(task) {
const div = document.createElement("div");
div.className = "task";
div.innerHTML = `
<div class="row">
<h3>${escapeHtml(task.title)}</h3>
<span class="badge">${task.status}</span>
</div>
<p>${task.description ? task.description : "<em>Pas de description</em>"}</p>
<small>id=${task.id} • créé=${new Date(task.created_at).toLocaleString()}</small>
<div class="actions">
<select data-role="status">
<option value="TODO" ${task.status === "TODO" ? "selected" : ""}>TODO</option>
<option value="DOING" ${task.status === "DOING" ? "selected" : ""}>DOING</option>
<option value="DONE" ${task.status === "DONE" ? "selected" : ""}>DONE</option>
</select>
<button class="secondary" data-role="save">Mettre à jour</button>
<button data-role="delete">Supprimer</button>
</div>
`;
div.querySelector('[data-role="save"]').addEventListener("click", async () => {
const status = div.querySelector('[data-role="status"]').value;
await api(`/tasks/${task.id}`, { method: "PUT", body: JSON.stringify({ status }) });
await refresh();
});
div.querySelector('[data-role="delete"]').addEventListener("click", async () => {
if (!confirm("Supprimer cette tâche ?")) return;
await api(`/tasks/${task.id}`, { method: "DELETE" });
await refresh();
});
return div;
}
function escapeHtml(s) {
return String(s)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function refresh() {
const container = document.getElementById("tasks");
container.innerHTML = "";
try {
const tasks = await api("/tasks");
if (tasks.length === 0) {
container.innerHTML = "<p><em>Aucune tâche pour linstant.</em></p>";
return;
}
tasks.forEach(t => container.appendChild(taskCard(t)));
} catch (e) {
container.innerHTML = `<p style="color:#b00020"><strong>Erreur:</strong> ${escapeHtml(e.message)}</p>
<p>Vérifie que lAPI tourne sur <code>${API_URL}</code>.</p>`;
}
}
document.getElementById("refreshBtn").addEventListener("click", refresh);
document.getElementById("createForm").addEventListener("submit", async (ev) => {
ev.preventDefault();
const title = document.getElementById("title").value.trim();
const description = document.getElementById("description").value.trim() || null;
await api("/tasks", { method: "POST", body: JSON.stringify({ title, description }) });
document.getElementById("title").value = "";
document.getElementById("description").value = "";
await refresh();
});
refresh();

46
frontend/index.html Executable file
View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Task Manager (Demo)</title>
<link rel="stylesheet" href="./styles.css" />
</head>
<body>
<main class="container">
<header class="header">
<h1>Task Manager</h1>
<p>Mini app fil rouge pour CI/CD + tests</p>
</header>
<section class="card">
<h2>Créer une tâche</h2>
<form id="createForm">
<label>
Titre
<input id="title" type="text" required maxlength="200" placeholder="Ex: Écrire les tests API" />
</label>
<label>
Description (optionnel)
<textarea id="description" maxlength="1000" placeholder="Détails..."></textarea>
</label>
<button type="submit">Créer</button>
</form>
</section>
<section class="card">
<div class="row">
<h2>Liste des tâches</h2>
<button id="refreshBtn" type="button">Rafraîchir</button>
</div>
<div id="tasks"></div>
</section>
<footer class="footer">
<small>API attendue sur <code id="apiUrlLabel"></code></small>
</footer>
</main>
<script src="./app.js"></script>
</body>
</html>

17
frontend/styles.css Executable file
View File

@@ -0,0 +1,17 @@
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; background: #f6f7fb; color: #111; }
.container { max-width: 900px; margin: 0 auto; padding: 24px; }
.header { margin-bottom: 18px; }
.card { background: white; border-radius: 12px; padding: 16px; margin-bottom: 16px; box-shadow: 0 2px 10px rgba(0,0,0,.06); }
label { display: block; margin-bottom: 10px; }
input, textarea, select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 10px; }
textarea { min-height: 80px; resize: vertical; }
button { padding: 10px 12px; border: 0; border-radius: 10px; cursor: pointer; background: #111; color: white; }
button.secondary { background: #e9e9ef; color: #111; }
.row { display:flex; align-items:center; justify-content:space-between; gap: 10px; }
.task { border: 1px solid #eee; border-radius: 10px; padding: 12px; margin-top: 10px; }
.task h3 { margin: 0 0 6px 0; }
.task small { color: #555; }
.task .actions { margin-top: 10px; display:flex; gap: 8px; flex-wrap: wrap; }
.badge { display:inline-block; padding: 2px 8px; border-radius: 999px; background:#f0f0f7; font-size: 12px; }
.footer { opacity: .8; }