first commit
This commit is contained in:
47
README.md
Executable file
47
README.md
Executable 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
9
backend/Dockerfile
Executable 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
24
backend/app/db.py
Executable 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
106
backend/app/main.py
Executable 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
15
backend/app/models.py
Executable 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
25
backend/app/schemas.py
Executable 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
6
backend/requirements.txt
Executable 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
BIN
backend/taskmanager.db
Executable file
Binary file not shown.
93
frontend/app.js
Executable file
93
frontend/app.js
Executable 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("&", "&")
|
||||
.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 = "<p><em>Aucune tâche pour l’instant.</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 l’API 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
46
frontend/index.html
Executable 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
17
frontend/styles.css
Executable 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; }
|
||||
Reference in New Issue
Block a user