From 5156438be52e7b0e4da9a7e07149ca63c76adb1b Mon Sep 17 00:00:00 2001 From: Johan Date: Mon, 2 Feb 2026 13:56:58 +0100 Subject: [PATCH] first commit --- README.md | 47 +++++++++++++++++ backend/Dockerfile | 9 ++++ backend/app/db.py | 24 +++++++++ backend/app/main.py | 106 +++++++++++++++++++++++++++++++++++++++ backend/app/models.py | 15 ++++++ backend/app/schemas.py | 25 +++++++++ backend/requirements.txt | 6 +++ backend/taskmanager.db | Bin 0 -> 12288 bytes frontend/app.js | 93 ++++++++++++++++++++++++++++++++++ frontend/index.html | 46 +++++++++++++++++ frontend/styles.css | 17 +++++++ 11 files changed, 388 insertions(+) create mode 100755 README.md create mode 100755 backend/Dockerfile create mode 100755 backend/app/db.py create mode 100755 backend/app/main.py create mode 100755 backend/app/models.py create mode 100755 backend/app/schemas.py create mode 100755 backend/requirements.txt create mode 100755 backend/taskmanager.db create mode 100755 frontend/app.js create mode 100755 frontend/index.html create mode 100755 frontend/styles.css diff --git a/README.md b/README.md new file mode 100755 index 0000000..9deae29 --- /dev/null +++ b/README.md @@ -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 +``` \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100755 index 0000000..f1004ef --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100755 index 0000000..703ea42 --- /dev/null +++ b/backend/app/db.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100755 index 0000000..4380d01 --- /dev/null +++ b/backend/app/main.py @@ -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-" + +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 \ No newline at end of file diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100755 index 0000000..00c00e3 --- /dev/null +++ b/backend/app/models.py @@ -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)) \ No newline at end of file diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100755 index 0000000..7d6d0e3 --- /dev/null +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100755 index 0000000..4ec57ee --- /dev/null +++ b/backend/requirements.txt @@ -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 \ No newline at end of file diff --git a/backend/taskmanager.db b/backend/taskmanager.db new file mode 100755 index 0000000000000000000000000000000000000000..bff3170f9c782ffd188389cf28c131dd9228d39b GIT binary patch literal 12288 zcmeI#%SyvQ6b9f)DoTU5UA70@G$0b|3z)V;EvBi)D0Wq1XN1ApTc%Lli7)44ISHn; zf^OWEe_(Doa|X_r)!f~NN=tg4%wJPY7wni>7P};3jM=7FOfOr$-zhg{w$|3aEp2x8 zHajp;`|y(;?VAA&0uX=z1Rwwb2tWV=5P$##An*@?hP~^axK>|{gcaxG2v|< zQ`Ac+x(~xsa;jSCk)($pZe9g(t?v8YMp(#fI9F4xCgatibKjiGb2FXlMYj50|B*YK z%T!A-Fi-WCDJbc5`NrLgsrWN~6L-2n+^1{aFDiDt{B3R?{b&$?00bZa0SG_<0uX=z p1Rwwb2yC%{lh=m#{}%sYlm-C^KmY;|fB*y_009U<00IzT> ({})); + throw new Error(body.detail || `HTTP ${res.status}`); + } + return res.json(); +} + +function taskCard(task) { + const div = document.createElement("div"); + div.className = "task"; + div.innerHTML = ` +
+

${escapeHtml(task.title)}

+ ${task.status} +
+

${task.description ? task.description : "Pas de description"}

+ id=${task.id} • créé=${new Date(task.created_at).toLocaleString()} +
+ + + +
+ `; + + 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("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function refresh() { + const container = document.getElementById("tasks"); + container.innerHTML = ""; + try { + const tasks = await api("/tasks"); + if (tasks.length === 0) { + container.innerHTML = "

Aucune tâche pour l’instant.

"; + return; + } + tasks.forEach(t => container.appendChild(taskCard(t))); + } catch (e) { + container.innerHTML = `

Erreur: ${escapeHtml(e.message)}

+

Vérifie que l’API tourne sur ${API_URL}.

`; + } +} + +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(); \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100755 index 0000000..11394ec --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,46 @@ + + + + + + Task Manager (Demo) + + + +
+
+

Task Manager

+

Mini app fil rouge pour CI/CD + tests

+
+ +
+

Créer une tâche

+
+ + + +
+
+ +
+
+

Liste des tâches

+ +
+
+
+ +
+ API attendue sur +
+
+ + + + \ No newline at end of file diff --git a/frontend/styles.css b/frontend/styles.css new file mode 100755 index 0000000..f54f876 --- /dev/null +++ b/frontend/styles.css @@ -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; }