feat: FlowDeck v0.2.0 — Kanban Gitea intégré
- Dashboard projets Gitea (user + orgs) - Board Kanban 5 colonnes avec drag & drop (SortableJS) - Sync bidirectionnelle colonnes ↔ labels Gitea - Filtres milestone/label/assignee - Notes Markdown par projet - API REST (/api/health, /api/move, /api/col-mapping…) - Colonnes custom, WIP limits - Thème clair/sombre - Cache Gitea TTL - Docker + docker-compose - Tests pytest - Docs: README, ARCHITECTURE, CHANGELOG, ROADMAP
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
/data/
|
||||||
|
.git/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
node_modules/
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ── Gitea ──
|
||||||
|
GITEA_URL=https://git.dracodev.net
|
||||||
|
GITEA_TOKEN=change-me
|
||||||
|
GITEA_OAUTH_CLIENT_ID=
|
||||||
|
GITEA_OAUTH_CLIENT_SECRET=
|
||||||
|
GITEA_WEBHOOK_SECRET=
|
||||||
|
|
||||||
|
# ── App ──
|
||||||
|
APP_SECRET_KEY=change-me-to-random
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8080
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
DEFAULT_LANG=fr
|
||||||
|
|
||||||
|
# ── Database ──
|
||||||
|
# SQLite (default): sqlite:////data/flowdeck.db
|
||||||
|
# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck
|
||||||
|
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||||
|
|
||||||
|
# ── Sync ──
|
||||||
|
SYNC_INTERVAL=60
|
||||||
|
GITEA_CACHE_TTL=30
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
/data/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Architecture FlowDeck
|
||||||
|
|
||||||
|
## Schéma
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ Browser │
|
||||||
|
│ HTMX + Alpine.js + SortableJS │
|
||||||
|
└──────────────┬───────────────────────────────┘
|
||||||
|
│ HTTP (Jinja2 HTML / JSON API)
|
||||||
|
┌──────────────▼───────────────────────────────┐
|
||||||
|
│ FastAPI (Python) │
|
||||||
|
│ ┌──────────┬──────────┬──────────┐ │
|
||||||
|
│ │ dashboard│ board │ notes │ api │
|
||||||
|
│ └────┬─────┴────┬─────┴──────────┴────┬─────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────▼──────────▼─────────────────────▼─────┐
|
||||||
|
│ │ GiteaClient (httpx async) │
|
||||||
|
│ │ repos, issues, labels, milestones │
|
||||||
|
│ └──────────────────┬────────────────────────┘
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────▼────────────────────────┐
|
||||||
|
│ │ SQLite (WAL mode) │
|
||||||
|
│ │ boards, cards, notes, col_mapping │
|
||||||
|
│ └───────────────────────────────────────────┘
|
||||||
|
└──────────────────────┬────────────────────────┘
|
||||||
|
│ HTTPS
|
||||||
|
┌──────────────────────▼────────────────────────┐
|
||||||
|
│ Gitea API REST v1 │
|
||||||
|
│ git.dracodev.net │
|
||||||
|
└───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flux de données
|
||||||
|
|
||||||
|
1. **Dashboard** : appelle `/api/v1/user/repos` + `/api/v1/user/orgs` Gitea → affiche liste projets
|
||||||
|
2. **Board** : appelle `/api/v1/repos/{owner}/{repo}/issues` → mappe sur colonnes via BDD locale
|
||||||
|
3. **Drag & drop** : SortableJS → `POST /api/move` → mise à jour SQLite + sync Gitea (labels/close)
|
||||||
|
4. **Notes** : stockées en SQLite local (non sync Gitea)
|
||||||
|
|
||||||
|
## Décisions techniques
|
||||||
|
|
||||||
|
- **SQLite (pas PostgreSQL)** : KISS. Un fichier, pas de serveur. Migration future triviale via Alembic si besoin.
|
||||||
|
- **HTMX + Alpine.js (pas React)** : Léger, progressif. Pas de build step.
|
||||||
|
- **Cache Gitea in-memory** : TTL configurable, pas de Redis. Suffisant pour <100 repos.
|
||||||
|
- **Pas d'auth OAuth en v0.2** : token global Gitea. OAuth par utilisateur prévu v0.3.
|
||||||
|
- **Pas de webhooks** : polling implicite au refresh. Webhooks prévus v0.4.
|
||||||
|
|
||||||
|
## Base de données
|
||||||
|
|
||||||
|
```
|
||||||
|
boards → config Kanban par projet (colonnes, WIP)
|
||||||
|
cards → position des issues dans les colonnes
|
||||||
|
notes → notes markdown par projet
|
||||||
|
col_mapping → mapping colonne→label Gitea (sync bidirectionnelle)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET / → Dashboard projets
|
||||||
|
GET /board/{owner}/{repo} → Board Kanban
|
||||||
|
GET /notes/{owner}/{repo} → Notes markdown
|
||||||
|
POST /notes/{owner}/{repo} → Sauvegarder notes
|
||||||
|
GET /api/health → Health check
|
||||||
|
GET /api/stats → Statistiques
|
||||||
|
GET /api/projects → Projets (JSON)
|
||||||
|
POST /api/move → Déplacer carte
|
||||||
|
POST /api/col-mapping → Config mapping colonne→label
|
||||||
|
GET /api/board-config/{o}/{r} → Config board
|
||||||
|
POST /api/board-config/{o}/{r} → MàJ config board
|
||||||
|
```
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.2.0] — 2026-07-08 — "FlowDeck"
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Renamed from Kanbante to FlowDeck
|
||||||
|
- Dashboard listant tous les projets Gitea (user + orgs)
|
||||||
|
- Board Kanban 5 colonnes : Backlog, À faire, En cours, Révision, Terminé
|
||||||
|
- Issues Gitea → cartes avec filtres (milestone, label, assignee)
|
||||||
|
- Drag & drop (SortableJS) avec persistance SQLite
|
||||||
|
- Sync bidirectionnelle : colonne → label Gitea, Terminé → close issue
|
||||||
|
- Colonnes personnalisables + mapping colonne→label
|
||||||
|
- WIP limits configurables
|
||||||
|
- Notes Markdown par projet
|
||||||
|
- API REST v1 : /api/health, /api/stats, /api/projects, /api/move, /api/col-mapping
|
||||||
|
- Thème clair/sombre avec persistance localStorage
|
||||||
|
- Cache Gitea in-memory avec TTL
|
||||||
|
- Docker + docker-compose
|
||||||
|
- Health check enrichi (DB + Gitea)
|
||||||
|
- Filtres intra-board (milestone, label, assignee)
|
||||||
|
- Pull requests affichées séparément
|
||||||
|
- Priorités (Low/Medium/High/Urgent) + dates d'échéance
|
||||||
|
- Architecture documentée (ARCHITECTURE.md)
|
||||||
|
- Roadmap (ROADMAP.md)
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
- FastAPI + Uvicorn + httpx + Jinja2 + HTMX + Alpine.js + SortableJS
|
||||||
|
- SQLite WAL mode
|
||||||
|
- Python 3.12-slim Docker
|
||||||
|
|
||||||
|
## [0.1.0] — Kanbante (non publié)
|
||||||
|
- MVP initial
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
@@ -1,3 +1,77 @@
|
|||||||
# flowdeck
|
# FlowDeck
|
||||||
|
|
||||||
FlowDeck — Gestion de projets Kanban léger intégré à Gitea
|
Gestion de projets Kanban légère, auto-hébergée, intégrée nativement à **Gitea**.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Éditer .env avec votre token Gitea
|
||||||
|
docker compose up -d
|
||||||
|
# → http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Dashboard** listant tous les projets Gitea (user + orgs)
|
||||||
|
- **Board Kanban** 5 colonnes : Backlog → À faire → En cours → Révision → Terminé
|
||||||
|
- **Issues Gitea → cartes** : sync automatique, filtres par milestone/label/assignee
|
||||||
|
- **Drag & drop** (SortableJS) avec persistance SQLite
|
||||||
|
- **Sync bidirectionnelle** : déplacer une carte → label Gitea mis à jour, colonne Terminé → ferme l'issue
|
||||||
|
- **Colonnes personnalisables** + configuration du mapping colonne→label Gitea
|
||||||
|
- **WIP limits** configurables par colonne
|
||||||
|
- **Notes Markdown** par projet (éditeur + sauvegarde)
|
||||||
|
- **API REST** : `/api/health`, `/api/stats`, `/api/projects`, `/api/move`, `/api/col-mapping`
|
||||||
|
- **Thème clair/sombre** commutable
|
||||||
|
- **Filtres** par milestone, label, assignee
|
||||||
|
- **PR affichées** séparément des issues
|
||||||
|
- **Dark/light theme** toggle avec persistance localStorage
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
| Couche | Techno |
|
||||||
|
|--------|--------|
|
||||||
|
| Frontend | HTML5 + Jinja2 + CSS custom, HTMX, Alpine.js, SortableJS |
|
||||||
|
| Backend | Python 3.12 + FastAPI + Uvicorn + httpx |
|
||||||
|
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
|
||||||
|
| Intégration | API REST Gitea v1 (token auth) |
|
||||||
|
| Déploiement | Docker (Python 3.12-slim), docker-compose |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Voir `.env.example`. Variables principales :
|
||||||
|
|
||||||
|
```env
|
||||||
|
GITEA_URL=https://git.dracodev.net
|
||||||
|
GITEA_TOKEN=votre_token
|
||||||
|
APP_PORT=8080
|
||||||
|
DATABASE_URL=sqlite:////data/flowdeck.db
|
||||||
|
SYNC_INTERVAL=60
|
||||||
|
GITEA_CACHE_TTL=30
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /api/health` | Health check (DB + Gitea) |
|
||||||
|
| `GET /api/stats` | Statistiques globales |
|
||||||
|
| `GET /api/projects` | Projets Gitea (JSON) |
|
||||||
|
| `POST /api/move` | Déplacer une carte |
|
||||||
|
| `POST /api/col-mapping` | Configurer mapping colonne→label |
|
||||||
|
| `GET /api/board-config/{owner}/{repo}` | Configuration board |
|
||||||
|
| `POST /api/board-config/{owner}/{repo}` | Mettre à jour colonnes/WIP |
|
||||||
|
|
||||||
|
## Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # éditer
|
||||||
|
uvicorn app.main:app --reload --port 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
# Roadmap FlowDeck
|
||||||
|
|
||||||
|
## v0.2.0 ✅ (actuel)
|
||||||
|
Dashboard + Board Kanban + Gitea sync + Notes + Docker
|
||||||
|
|
||||||
|
## v0.3.0 — Auth & Multi-user
|
||||||
|
- [ ] OAuth2 Gitea (login via Gitea)
|
||||||
|
- [ ] Token par utilisateur (plus sécurisé)
|
||||||
|
- [ ] Permissions basées sur droits Gitea
|
||||||
|
- [ ] CSRF protection
|
||||||
|
- [ ] Rate limiting API
|
||||||
|
|
||||||
|
## v0.4.0 — Webhooks & Temps réel
|
||||||
|
- [ ] Webhooks Gitea (issues, comments, PR)
|
||||||
|
- [ ] Sync temps réel sans polling
|
||||||
|
- [ ] Background tasks (sync périodique)
|
||||||
|
|
||||||
|
## v0.5.0 — Cartes enrichies
|
||||||
|
- [ ] Vue détail carte (modal) : description, labels, assignee, milestone, commentaires
|
||||||
|
- [ ] Checklists / sous-tâches
|
||||||
|
- [ ] Création d'issues depuis FlowDeck
|
||||||
|
- [ ] Édition inline (titre, description, labels, assignee)
|
||||||
|
- [ ] Commentaires Gitea dans la carte
|
||||||
|
|
||||||
|
## v0.6.0 — Vues additionnelles
|
||||||
|
- [ ] Vue Liste (table triable)
|
||||||
|
- [ ] Vue Calendrier (échéances, milestones)
|
||||||
|
- [ ] Vue Timeline / Roadmap
|
||||||
|
- [ ] Vue Sprint / Backlog
|
||||||
|
|
||||||
|
## v0.7.0 — Analytics
|
||||||
|
- [ ] Dashboard analytics : burndown chart, vélocité
|
||||||
|
- [ ] Cartes par statut, en retard
|
||||||
|
- [ ] Activité récente
|
||||||
|
|
||||||
|
## v0.8.0 — Multi-instance & Export
|
||||||
|
- [ ] Multi-instance Gitea
|
||||||
|
- [ ] Export board (JSON/CSV/Markdown)
|
||||||
|
- [ ] i18n (EN)
|
||||||
|
- [ ] Raccourcis clavier
|
||||||
|
|
||||||
|
## v0.9.0 — Infra & Qualité
|
||||||
|
- [ ] Migrations BDD (Alembic)
|
||||||
|
- [ ] Support PostgreSQL optionnel
|
||||||
|
- [ ] Logging structuré (loguru)
|
||||||
|
- [ ] Tests ≥ 70% couverture
|
||||||
|
- [ ] CI/CD Gitea Actions
|
||||||
|
|
||||||
|
## v1.0.0
|
||||||
|
- [ ] Production ready
|
||||||
|
- [ ] Tag git v1.0.0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""FlowDeck — Application package."""
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""FlowDeck — Configuration via pydantic-settings."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env", env_file_encoding="utf-8", extra="ignore"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Gitea
|
||||||
|
gitea_url: str = "https://git.dracodev.net"
|
||||||
|
gitea_token: str = "change-me"
|
||||||
|
gitea_oauth_client_id: str = ""
|
||||||
|
gitea_oauth_client_secret: str = ""
|
||||||
|
gitea_webhook_secret: str = ""
|
||||||
|
|
||||||
|
# App
|
||||||
|
app_secret_key: str = "change-me-to-random"
|
||||||
|
app_host: str = "0.0.0.0"
|
||||||
|
app_port: int = 8080
|
||||||
|
log_level: str = "INFO"
|
||||||
|
default_lang: str = "fr"
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = "sqlite:////data/flowdeck.db"
|
||||||
|
|
||||||
|
# Sync
|
||||||
|
sync_interval: int = 60
|
||||||
|
gitea_cache_ttl: int = 30
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db_path(self) -> Path:
|
||||||
|
if self.database_url.startswith("sqlite:///"):
|
||||||
|
return Path(self.database_url.replace("sqlite:///", "/"))
|
||||||
|
return Path("/data/flowdeck.db")
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""FlowDeck — Database layer (SQLite with WAL mode)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_path() -> Path:
|
||||||
|
from app.config import settings
|
||||||
|
return settings.db_path
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Create tables if they don't exist."""
|
||||||
|
db_path = get_db_path()
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_owner TEXT NOT NULL,
|
||||||
|
project_name TEXT NOT NULL,
|
||||||
|
columns_json TEXT NOT NULL DEFAULT '["Backlog","À faire","En cours","Révision","Terminé"]',
|
||||||
|
wip_limits_json TEXT DEFAULT '{}',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(project_owner, project_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
board_id INTEGER NOT NULL REFERENCES boards(id),
|
||||||
|
gitea_issue_id INTEGER,
|
||||||
|
column_name TEXT NOT NULL DEFAULT 'Backlog',
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
priority TEXT DEFAULT 'Medium',
|
||||||
|
due_date TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
project_owner TEXT NOT NULL,
|
||||||
|
project_name TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Notes',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(project_owner, project_name, title)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS col_mapping (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
board_id INTEGER NOT NULL REFERENCES boards(id),
|
||||||
|
column_name TEXT NOT NULL,
|
||||||
|
gitea_label TEXT NOT NULL,
|
||||||
|
close_issue BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(board_id, column_name)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_conn():
|
||||||
|
"""Yield a SQLite connection (WAL mode, foreign keys on)."""
|
||||||
|
db_path = get_db_path()
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"""FlowDeck — Kanban léger intégré à Gitea."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import init_db
|
||||||
|
from app.routers import dashboard, board, notes, api
|
||||||
|
|
||||||
|
# ── logging ──
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── app ──
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="FlowDeck",
|
||||||
|
version="0.2.0",
|
||||||
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
|
redoc_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ponytail: CORS for LAN access
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── routers ──
|
||||||
|
|
||||||
|
app.include_router(dashboard.router)
|
||||||
|
app.include_router(board.router)
|
||||||
|
app.include_router(notes.router)
|
||||||
|
app.include_router(api.router)
|
||||||
|
|
||||||
|
# ── static ──
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup():
|
||||||
|
init_db()
|
||||||
|
logger.info("FlowDeck started on port %d", settings.app_port)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""FlowDeck — Models."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""FlowDeck — Routers."""
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
"""FlowDeck — API REST v1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services.gitea_client import gitea
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["api"], prefix="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Health check: DB + Gitea connectivity."""
|
||||||
|
db_ok = False
|
||||||
|
gitea_ok = False
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("SELECT 1")
|
||||||
|
db_ok = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await gitea.get_user_repos(page=1, limit=1)
|
||||||
|
gitea_ok = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"db": db_ok,
|
||||||
|
"gitea": gitea_ok,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
async def stats():
|
||||||
|
"""Global stats for dashboard."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
|
||||||
|
card_count = conn.execute("SELECT COUNT(*) as c FROM cards").fetchone()["c"]
|
||||||
|
note_count = conn.execute("SELECT COUNT(*) as c FROM notes").fetchone()["c"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"boards": board_count,
|
||||||
|
"cards": card_count,
|
||||||
|
"notes": note_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects")
|
||||||
|
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
|
||||||
|
"""List Gitea projects (JSON)."""
|
||||||
|
try:
|
||||||
|
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||||
|
orgs = await gitea.get_user_orgs()
|
||||||
|
for org in orgs:
|
||||||
|
try:
|
||||||
|
org_repos = await gitea.get_org_repos(org["username"])
|
||||||
|
for r in org_repos:
|
||||||
|
r["_org"] = org["username"]
|
||||||
|
repos.extend(org_repos)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if search:
|
||||||
|
q = search.lower()
|
||||||
|
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
|
||||||
|
if not show_archived:
|
||||||
|
repos = [r for r in repos if not r.get("archived", False)]
|
||||||
|
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||||
|
except Exception:
|
||||||
|
repos = []
|
||||||
|
return {"projects": repos}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/move")
|
||||||
|
async def move_card(
|
||||||
|
owner: str = Query(...),
|
||||||
|
repo: str = Query(...),
|
||||||
|
issue_id: int = Query(...),
|
||||||
|
column: str = Query(...),
|
||||||
|
):
|
||||||
|
"""Move a card to a column. Updates DB position + Gitea labels if mapped."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
board = conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(status_code=404, detail="Board not found")
|
||||||
|
|
||||||
|
board_id = board["id"]
|
||||||
|
|
||||||
|
# Upsert card
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM cards WHERE board_id=? AND gitea_issue_id=?",
|
||||||
|
(board_id, issue_id),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE cards SET column_name=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(column, existing["id"]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO cards (board_id, gitea_issue_id, column_name, position) VALUES (?, ?, ?, 0)",
|
||||||
|
(board_id, issue_id, column),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check label mapping for sync to Gitea
|
||||||
|
mapping = conn.execute(
|
||||||
|
"SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?",
|
||||||
|
(board_id, column),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
# Sync to Gitea
|
||||||
|
if mapping:
|
||||||
|
try:
|
||||||
|
if mapping["close_issue"]:
|
||||||
|
await gitea.close_issue(owner, repo, issue_id)
|
||||||
|
else:
|
||||||
|
# Get current labels, add mapped label
|
||||||
|
issue = await gitea.get_issue(owner, repo, issue_id)
|
||||||
|
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||||
|
# Remove other status labels, add new one
|
||||||
|
status_labels = await _get_status_labels(owner, repo, board_id)
|
||||||
|
filtered = [l for l in current_labels if l not in status_labels]
|
||||||
|
filtered.append(mapping["gitea_label"])
|
||||||
|
await gitea.update_issue_labels(owner, repo, issue_id, filtered)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Gitea sync failed: %s", e)
|
||||||
|
|
||||||
|
return {"status": "ok", "issue_id": issue_id, "column": column}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
|
||||||
|
"""Get all status labels configured for this board."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT gitea_label FROM col_mapping WHERE board_id=?",
|
||||||
|
(board_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [r["gitea_label"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/col-mapping")
|
||||||
|
async def set_col_mapping(
|
||||||
|
owner: str = Query(...),
|
||||||
|
repo: str = Query(...),
|
||||||
|
column: str = Query(...),
|
||||||
|
gitea_label: str = Query(...),
|
||||||
|
close_issue: bool = Query(default=False),
|
||||||
|
):
|
||||||
|
"""Set column-to-label mapping."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
board = conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if not board:
|
||||||
|
raise HTTPException(status_code=404, detail="Board not found")
|
||||||
|
board_id = board["id"]
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO col_mapping (board_id, column_name, gitea_label, close_issue)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(board_id, column_name)
|
||||||
|
DO UPDATE SET gitea_label=excluded.gitea_label, close_issue=excluded.close_issue""",
|
||||||
|
(board_id, column, gitea_label, int(close_issue)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return {"status": "ok", "column": column, "label": gitea_label}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/board-config/{owner}/{repo}")
|
||||||
|
async def get_board_config(owner: str, repo: str):
|
||||||
|
"""Get board columns and WIP limits."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
board = conn.execute(
|
||||||
|
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if not board:
|
||||||
|
return {"columns": ["Backlog", "À faire", "En cours", "Révision", "Terminé"], "wip_limits": {}}
|
||||||
|
|
||||||
|
mappings = conn.execute(
|
||||||
|
"SELECT * FROM col_mapping WHERE board_id=?", (board["id"],)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"columns": json.loads(board["columns_json"]),
|
||||||
|
"wip_limits": json.loads(board["wip_limits_json"] or "{}"),
|
||||||
|
"col_mappings": [dict(m) for m in mappings],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/board-config/{owner}/{repo}")
|
||||||
|
async def update_board_config(
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
columns: str = Query(default=""),
|
||||||
|
wip_limits: str = Query(default=""),
|
||||||
|
):
|
||||||
|
"""Update board columns and WIP limits."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
board = conn.execute(
|
||||||
|
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if not board:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO boards (project_owner, project_name, columns_json, wip_limits_json) VALUES (?, ?, ?, ?)",
|
||||||
|
(owner, repo, columns, wip_limits),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE boards SET columns_json=?, wip_limits_json=? WHERE id=?",
|
||||||
|
(columns, wip_limits, board["id"]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"""FlowDeck — Board Kanban."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services.gitea_client import gitea
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["board"], prefix="/board")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_board(owner: str, repo: str) -> dict:
|
||||||
|
"""Get or create a board row, return columns config."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO boards (project_owner, project_name) VALUES (?, ?)",
|
||||||
|
(owner, repo),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"id": cur.lastrowid, "columns": ["Backlog", "À faire", "En cours", "Révision", "Terminé"]}
|
||||||
|
return {"id": row["id"], "columns": json.loads(row["columns_json"])}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cards(board_id: int) -> list[dict]:
|
||||||
|
"""Get all cards for a board, ordered by column + position."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM cards WHERE board_id=? ORDER BY column_name, position",
|
||||||
|
(board_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||||
|
async def board(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
milestone: str = "",
|
||||||
|
label: str = "",
|
||||||
|
assignee: str = "",
|
||||||
|
):
|
||||||
|
"""Kanban board for a Gitea project."""
|
||||||
|
try:
|
||||||
|
# Get issues
|
||||||
|
issues = await gitea.get_issues(owner, repo, state="all")
|
||||||
|
|
||||||
|
# Get labels
|
||||||
|
labels = await gitea.get_labels(owner, repo)
|
||||||
|
|
||||||
|
# Get milestones
|
||||||
|
milestones = await gitea.get_milestones(owner, repo)
|
||||||
|
|
||||||
|
# Filter by milestone
|
||||||
|
if milestone:
|
||||||
|
issues = [i for i in issues if i.get("milestone", {}).get("title") == milestone]
|
||||||
|
|
||||||
|
# Filter by label
|
||||||
|
if label:
|
||||||
|
issues = [i for i in issues if any(lbl["name"] == label for lbl in i.get("labels", []))]
|
||||||
|
|
||||||
|
# Filter by assignee
|
||||||
|
if assignee:
|
||||||
|
issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee]
|
||||||
|
|
||||||
|
# Separate PRs from issues (ponytail: PRs are also in /issues endpoint)
|
||||||
|
prs = [i for i in issues if "pull_request" in i]
|
||||||
|
issues_only = [i for i in issues if "pull_request" not in i]
|
||||||
|
|
||||||
|
# Get board config
|
||||||
|
board_info = _ensure_board(owner, repo)
|
||||||
|
columns = board_info["columns"]
|
||||||
|
cards = _get_cards(board_info["id"])
|
||||||
|
|
||||||
|
# Map issues to columns
|
||||||
|
column_map: dict[str, list[dict]] = {col: [] for col in columns}
|
||||||
|
card_by_issue = {c["gitea_issue_id"]: c for c in cards if c["gitea_issue_id"]}
|
||||||
|
|
||||||
|
for issue in issues_only:
|
||||||
|
# Determine column from issue state/labels first, then from DB position
|
||||||
|
col = _issue_column(issue, columns, board_info["id"])
|
||||||
|
issue["_col"] = col
|
||||||
|
issue["_card"] = card_by_issue.get(issue["number"])
|
||||||
|
column_map[col].append(issue)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Board error: %s", e)
|
||||||
|
return HTMLResponse(f"<p class='error'>Erreur: {e}</p>", status_code=500)
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from datetime import datetime
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
|
template = env.get_template("board.html")
|
||||||
|
return template.render(
|
||||||
|
request=request,
|
||||||
|
owner=owner,
|
||||||
|
repo=repo,
|
||||||
|
columns=columns,
|
||||||
|
column_map=column_map,
|
||||||
|
labels=labels,
|
||||||
|
milestones=milestones,
|
||||||
|
current_milestone=milestone,
|
||||||
|
current_label=label,
|
||||||
|
current_assignee=assignee,
|
||||||
|
prs=prs,
|
||||||
|
settings=settings,
|
||||||
|
now=datetime.now().isoformat()[:10],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||||
|
"""Determine which column an issue belongs to."""
|
||||||
|
# ponytail: closed = Terminé, else check label mapping
|
||||||
|
if issue.get("state") == "closed":
|
||||||
|
return "Terminé" if "Terminé" in columns else columns[-1]
|
||||||
|
|
||||||
|
# Check label-to-column mapping in DB
|
||||||
|
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])]
|
||||||
|
with get_conn() as conn:
|
||||||
|
for lbl in issue_labels:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
||||||
|
(board_id, lbl),
|
||||||
|
).fetchone()
|
||||||
|
if row and row["column_name"] in columns:
|
||||||
|
return row["column_name"]
|
||||||
|
|
||||||
|
# Default: Backlog
|
||||||
|
return columns[0] if columns else "Backlog"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""FlowDeck — Dashboard: liste des projets Gitea."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, HTTPException, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from app.services.gitea_client import gitea
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(
|
||||||
|
request: Request,
|
||||||
|
search: str = Query(default=""),
|
||||||
|
show_archived: bool = Query(default=False),
|
||||||
|
):
|
||||||
|
"""Dashboard: liste tous les projets Gitea (user + orgs)."""
|
||||||
|
try:
|
||||||
|
# User repos
|
||||||
|
repos = await gitea.get_user_repos(page=1, limit=50)
|
||||||
|
|
||||||
|
# Org repos
|
||||||
|
orgs = await gitea.get_user_orgs()
|
||||||
|
for org in orgs:
|
||||||
|
try:
|
||||||
|
org_repos = await gitea.get_org_repos(org["username"])
|
||||||
|
for r in org_repos:
|
||||||
|
r["_org"] = org["username"]
|
||||||
|
repos.extend(org_repos)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Skip org %s", org.get("username"))
|
||||||
|
|
||||||
|
# Filter
|
||||||
|
if search:
|
||||||
|
q = search.lower()
|
||||||
|
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
|
||||||
|
|
||||||
|
if not show_archived:
|
||||||
|
repos = [r for r in repos if not r.get("archived", False)]
|
||||||
|
|
||||||
|
# Sort by updated_at desc
|
||||||
|
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Dashboard error: %s", e)
|
||||||
|
repos = []
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
|
template = env.get_template("dashboard.html")
|
||||||
|
return template.render(
|
||||||
|
request=request,
|
||||||
|
repos=repos,
|
||||||
|
search=search,
|
||||||
|
show_archived=show_archived,
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""FlowDeck — Notes markdown par projet."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["notes"], prefix="/notes")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||||
|
async def get_notes(request: Request, owner: str, repo: str):
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
|
||||||
|
(owner, repo),
|
||||||
|
).fetchone()
|
||||||
|
content = row["content"] if row else ""
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
|
template = env.get_template("notes.html")
|
||||||
|
return template.render(request=request, owner=owner, repo=repo, content=content)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{owner}/{repo}", response_class=HTMLResponse)
|
||||||
|
async def save_notes(request: Request, owner: str, repo: str):
|
||||||
|
form = await request.form()
|
||||||
|
content = form.get("content", "")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO notes (project_owner, project_name, title, content, updated_at)
|
||||||
|
VALUES (?, ?, 'Notes', ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(project_owner, project_name, title)
|
||||||
|
DO UPDATE SET content=excluded.content, updated_at=CURRENT_TIMESTAMP""",
|
||||||
|
(owner, repo, content),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||||
|
template = env.get_template("notes.html")
|
||||||
|
return template.render(request=request, owner=owner, repo=repo, content=content)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""FlowDeck — Schemas."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""FlowDeck — Services."""
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""FlowDeck — Gitea API client with caching."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient:
|
||||||
|
"""Async Gitea API v1 client with simple TTL cache."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._base = f"{settings.gitea_url}/api/v1"
|
||||||
|
self._headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||||
|
self._cache: dict[str, tuple[datetime, Any]] = {}
|
||||||
|
self._ttl = timedelta(seconds=settings.gitea_cache_ttl)
|
||||||
|
|
||||||
|
# ── cache helper ──
|
||||||
|
|
||||||
|
def _cached(self, key: str) -> Any | None:
|
||||||
|
entry = self._cache.get(key)
|
||||||
|
if entry and entry[0] > datetime.now():
|
||||||
|
return entry[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _set_cache(self, key: str, value: Any) -> None:
|
||||||
|
self._cache[key] = (datetime.now() + self._ttl, value)
|
||||||
|
|
||||||
|
# ── repos ──
|
||||||
|
|
||||||
|
async def get_user_repos(self, page: int = 1, limit: int = 30) -> list[dict]:
|
||||||
|
"""List user repos (sorted by updated desc)."""
|
||||||
|
cache_key = f"repos:{page}:{limit}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/user/repos",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"page": page, "limit": limit, "sort": "updated", "order": "desc"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_org_repos(self, org: str, page: int = 1, limit: int = 20) -> list[dict]:
|
||||||
|
"""List repos for an org."""
|
||||||
|
cache_key = f"org_repos:{org}:{page}:{limit}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/orgs/{org}/repos",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_user_orgs(self) -> list[dict]:
|
||||||
|
"""List user orgs."""
|
||||||
|
cache_key = "user_orgs"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/user/orgs", headers=self._headers
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ── issues ──
|
||||||
|
|
||||||
|
async def get_issues(
|
||||||
|
self, owner: str, repo: str, state: str = "all", page: int = 1, limit: int = 50
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List issues for a repo."""
|
||||||
|
cache_key = f"issues:{owner}:{repo}:{state}:{page}:{limit}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"state": state, "page": page, "limit": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||||
|
"""Get a single issue."""
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def update_issue_labels(
|
||||||
|
self, owner: str, repo: str, issue_id: int, labels: list[str]
|
||||||
|
) -> dict:
|
||||||
|
"""Replace issue labels."""
|
||||||
|
self._invalidate_issue_cache(owner, repo)
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.put(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||||
|
headers=self._headers,
|
||||||
|
json={"labels": labels},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def close_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||||
|
"""Close an issue (move to Terminé)."""
|
||||||
|
self._invalidate_issue_cache(owner, repo)
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||||
|
headers=self._headers,
|
||||||
|
json={"state": "closed"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
async def reopen_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||||
|
"""Reopen an issue."""
|
||||||
|
self._invalidate_issue_cache(owner, repo)
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||||
|
headers=self._headers,
|
||||||
|
json={"state": "open"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def _invalidate_issue_cache(self, owner: str, repo: str) -> None:
|
||||||
|
prefix = f"issues:{owner}:{repo}:"
|
||||||
|
to_delete = [k for k in self._cache if k.startswith(prefix)]
|
||||||
|
for k in to_delete:
|
||||||
|
del self._cache[k]
|
||||||
|
|
||||||
|
# ── labels ──
|
||||||
|
|
||||||
|
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
||||||
|
"""List repo labels."""
|
||||||
|
cache_key = f"labels:{owner}:{repo}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/labels",
|
||||||
|
headers=self._headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ── milestones ──
|
||||||
|
|
||||||
|
async def get_milestones(
|
||||||
|
self, owner: str, repo: str, state: str = "open"
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List repo milestones."""
|
||||||
|
cache_key = f"milestones:{owner}:{repo}:{state}"
|
||||||
|
cached = self._cached(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base}/repos/{owner}/{repo}/milestones",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"state": state},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
self._set_cache(cache_key, data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ── singleton ──
|
||||||
|
|
||||||
|
gitea = GiteaClient()
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>FlowDeck</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/app.css">
|
||||||
|
<script src="https://unpkg.com/[email protected]" defer></script>
|
||||||
|
<script src="https://unpkg.com/[email protected]" defer></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<a href="/" class="logo">
|
||||||
|
<span class="logo-icon">▦</span>
|
||||||
|
<span class="logo-text">FlowDeck</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="main-content">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
<script src="/static/js/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="board"
|
||||||
|
x-data="{
|
||||||
|
owner: '{{ owner }}',
|
||||||
|
repo: '{{ repo }}',
|
||||||
|
milestone: '{{ current_milestone }}',
|
||||||
|
label: '{{ current_label }}',
|
||||||
|
assignee: '{{ current_assignee }}'
|
||||||
|
}">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="board-header">
|
||||||
|
<div class="board-breadcrumb">
|
||||||
|
<a href="/" class="back-link">← Projets</a>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<span class="repo-owner">{{ owner }}</span>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<strong>{{ repo }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="board-actions">
|
||||||
|
<a href="/notes/{{ owner }}/{{ repo }}" class="btn btn-sm">📝 Notes</a>
|
||||||
|
<a href="{{ settings.gitea_url }}/{{ owner }}/{{ repo }}/issues/new" target="_blank" class="btn btn-sm btn-primary">+ Issue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="board-filters">
|
||||||
|
<select x-model="milestone" @change="window.location='?milestone='+milestone+'&label='+label+'&assignee='+assignee" class="filter-select">
|
||||||
|
<option value="">Tous les milestones</option>
|
||||||
|
{% for m in milestones %}
|
||||||
|
<option value="{{ m.title }}" {% if m.title == current_milestone %}selected{% endif %}>{{ m.title }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<select x-model="label" @change="window.location='?milestone='+milestone+'&label='+label+'&assignee='+assignee" class="filter-select">
|
||||||
|
<option value="">Tous les labels</option>
|
||||||
|
{% for lbl in labels %}
|
||||||
|
<option value="{{ lbl.name }}" {% if lbl.name == current_label %}selected{% endif %}>🏷 {{ lbl.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Columns -->
|
||||||
|
<div class="board-columns"
|
||||||
|
hx-trigger="cardMoved from:body"
|
||||||
|
hx-get="/board/{{ owner }}/{{ repo }}?milestone={{ current_milestone }}&label={{ current_label }}&assignee={{ current_assignee }}"
|
||||||
|
hx-target=".board-columns"
|
||||||
|
hx-swap="outerHTML">
|
||||||
|
{% for col in columns %}
|
||||||
|
<div class="board-column"
|
||||||
|
data-column="{{ col }}"
|
||||||
|
id="col-{{ loop.index }}">
|
||||||
|
<div class="column-header">
|
||||||
|
<span class="column-title">{{ col }}</span>
|
||||||
|
<span class="column-count">{{ column_map[col]|length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="column-cards sortable"
|
||||||
|
data-column="{{ col }}"
|
||||||
|
data-owner="{{ owner }}"
|
||||||
|
data-repo="{{ repo }}">
|
||||||
|
{% for issue in column_map[col] %}
|
||||||
|
<div class="card"
|
||||||
|
data-issue-id="{{ issue.number }}"
|
||||||
|
data-column="{{ col }}"
|
||||||
|
draggable="true">
|
||||||
|
<div class="card-labels">
|
||||||
|
{% for lbl in issue.labels %}
|
||||||
|
<span class="card-label" style="background:#{{ lbl.color or '666' }}33; color:#{{ lbl.color or 'ccc' }}">{{ lbl.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="card-title">
|
||||||
|
<a href="{{ issue.html_url }}" target="_blank">#{{ issue.number }}</a>
|
||||||
|
{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-meta">
|
||||||
|
{% if issue.assignee %}
|
||||||
|
<span class="card-assignee" title="{{ issue.assignee.login }}">
|
||||||
|
<img src="{{ issue.assignee.avatar_url }}" alt="" width="16" height="16" class="avatar">
|
||||||
|
{{ issue.assignee.login }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if issue.milestone %}
|
||||||
|
<span class="card-milestone">📅 {{ issue.milestone.title }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if issue._card and issue._card.priority %}
|
||||||
|
<div class="card-priority priority-{{ issue._card.priority|lower }}">{{ issue._card.priority }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if issue._card and issue._card.due_date %}
|
||||||
|
<div class="card-due {% if issue._card.due_date < now %}overdue{% endif %}">
|
||||||
|
⏰ {{ issue._card.due_date[:10] }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ponytail: initialize SortableJS on each column
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.querySelectorAll('.sortable').forEach(el => {
|
||||||
|
new Sortable(el, {
|
||||||
|
group: 'kanban',
|
||||||
|
animation: 150,
|
||||||
|
ghostClass: 'card-ghost',
|
||||||
|
onEnd: async function(evt) {
|
||||||
|
const card = evt.item;
|
||||||
|
const issueId = card.dataset.issueId;
|
||||||
|
const newColumn = evt.to.dataset.column;
|
||||||
|
const owner = evt.to.dataset.owner;
|
||||||
|
const repo = evt.to.dataset.repo;
|
||||||
|
|
||||||
|
// Optimistic: move card in DOM is already done
|
||||||
|
|
||||||
|
// Persist to backend
|
||||||
|
try {
|
||||||
|
await fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update column counts
|
||||||
|
document.querySelectorAll('.board-column').forEach(col => {
|
||||||
|
const count = col.querySelectorAll('.card').length;
|
||||||
|
const badge = col.querySelector('.column-count');
|
||||||
|
if (badge) badge.textContent = count;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Move failed:', e);
|
||||||
|
// ponytail: reload on error
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="dashboard" x-data="{search: '{{ search }}', showArchived: {{ 'true' if show_archived else 'false' }}}">
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<h1>Projets</h1>
|
||||||
|
<div class="dashboard-controls">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
x-model="search"
|
||||||
|
placeholder="Rechercher un projet..."
|
||||||
|
class="search-input"
|
||||||
|
hx-get="/?show_archived=${showArchived}"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target=".project-list"
|
||||||
|
hx-include="[name='search'], [name='show_archived']"
|
||||||
|
name="search"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<label class="toggle-label">
|
||||||
|
<input type="checkbox" name="show_archived" x-model="showArchived"
|
||||||
|
hx-get="/"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-target=".project-list"
|
||||||
|
hx-include="[name='search'], [name='show_archived']">
|
||||||
|
<span>Archivés</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-bar">
|
||||||
|
<span>{{ repos|length }} projet(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="project-list">
|
||||||
|
{% if repos %}
|
||||||
|
{% for repo in repos %}
|
||||||
|
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}" class="project-card">
|
||||||
|
<div class="project-card-header">
|
||||||
|
<span class="project-org">{{ repo._org or repo.owner.login }}</span>
|
||||||
|
<span class="project-name">{{ repo.name }}</span>
|
||||||
|
{% if repo.archived %}<span class="badge archived">archivé</span>{% endif %}
|
||||||
|
{% if repo.private %}<span class="badge private">privé</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if repo.description %}
|
||||||
|
<p class="project-desc">{{ repo.description[:120] }}{% if repo.description|length > 120 %}...{% endif %}</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="project-meta">
|
||||||
|
{% if repo.language %}<span class="meta-lang">{{ repo.language }}</span>{% endif %}
|
||||||
|
<span class="meta-stars">⭐ {{ repo.stars_count or 0 }}</span>
|
||||||
|
<span class="meta-updated">Mis à jour {{ repo.updated_at[:10] if repo.updated_at else '' }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">Aucun projet trouvé.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="notes-page">
|
||||||
|
<div class="board-header">
|
||||||
|
<div class="board-breadcrumb">
|
||||||
|
<a href="/" class="back-link">← Projets</a>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<a href="/board/{{ owner }}/{{ repo }}" class="back-link">{{ owner }}/{{ repo }}</a>
|
||||||
|
<span class="separator">/</span>
|
||||||
|
<strong>Notes</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notes-editor">
|
||||||
|
<form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML">
|
||||||
|
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
|
||||||
|
<div class="notes-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if content %}
|
||||||
|
<div class="notes-preview">
|
||||||
|
<h3>Aperçu</h3>
|
||||||
|
<div class="markdown-body">{{ content }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
services:
|
||||||
|
flowdeck:
|
||||||
|
build: .
|
||||||
|
container_name: flowdeck
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
- flowdeck_data:/data
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
flowdeck_data:
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["."]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
fastapi==0.115.*
|
||||||
|
uvicorn[standard]==0.34.*
|
||||||
|
httpx==0.28.*
|
||||||
|
jinja2==3.1.*
|
||||||
|
python-multipart==0.0.*
|
||||||
|
pydantic==2.10.*
|
||||||
|
pydantic-settings==2.7.*
|
||||||
|
aiosqlite==0.20.*
|
||||||
|
loguru==0.7.*
|
||||||
|
python-dotenv==1.0.*
|
||||||
|
packaging>=24.0
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/* ── FlowDeck CSS ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--bg-card: #161b22;
|
||||||
|
--bg-hover: #21262d;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--text-dim: #8b949e;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--green: #3fb950;
|
||||||
|
--orange: #d2991d;
|
||||||
|
--red: #f85149;
|
||||||
|
--purple: #a371f7;
|
||||||
|
--col-backlog: #30363d;
|
||||||
|
--col-todo: #1f6feb;
|
||||||
|
--col-progress: #d2991d;
|
||||||
|
--col-review: #a371f7;
|
||||||
|
--col-done: #3fb950;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #ffffff;
|
||||||
|
--bg-card: #f6f8fa;
|
||||||
|
--bg-hover: #eaeef2;
|
||||||
|
--border: #d0d7de;
|
||||||
|
--text: #24292f;
|
||||||
|
--text-dim: #656d76;
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { min-height: 100vh; }
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.app-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0 16px; height: 48px; border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
.logo { text-decoration: none; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.logo-icon { font-size: 22px; color: var(--accent); }
|
||||||
|
.logo-text { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||||
|
.theme-toggle { background: none; border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 4px 8px; cursor: pointer; font-size: 16px; }
|
||||||
|
|
||||||
|
/* ── Dashboard ── */
|
||||||
|
.dashboard { max-width: 900px; margin: 0 auto; padding: 24px 16px; }
|
||||||
|
.dashboard-header { display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 16px; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.dashboard-header h1 { font-size: 24px; }
|
||||||
|
.dashboard-controls { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.search-input {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 6px 12px; color: var(--text); font-size: 14px; width: 240px;
|
||||||
|
}
|
||||||
|
.search-input:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.toggle-label { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.stats-bar { font-size: 13px; color: var(--text-dim); margin-bottom: 16px; }
|
||||||
|
.project-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
|
.project-card {
|
||||||
|
display: block; padding: 12px 16px; background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; text-decoration: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.project-card:hover { border-color: var(--accent); }
|
||||||
|
.project-card-header { display: flex; gap: 8px; align-items: center; margin-bottom: 4px; }
|
||||||
|
.project-org { font-size: 11px; color: var(--text-dim); text-transform: uppercase; }
|
||||||
|
.project-name { font-size: 15px; font-weight: 600; color: var(--accent); }
|
||||||
|
.project-desc { font-size: 13px; color: var(--text-dim); margin-bottom: 8px; }
|
||||||
|
.project-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-dim); }
|
||||||
|
.badge { font-size: 10px; padding: 1px 6px; border-radius: 10px; border: 1px solid var(--border); }
|
||||||
|
.badge.archived { color: var(--orange); }
|
||||||
|
.badge.private { color: var(--red); }
|
||||||
|
.empty { color: var(--text-dim); font-size: 14px; padding: 24px 0; }
|
||||||
|
|
||||||
|
/* ── Board ── */
|
||||||
|
.board { height: calc(100vh - 48px); display: flex; flex-direction: column; }
|
||||||
|
.board-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 8px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-card); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.board-breadcrumb { display: flex; align-items: center; gap: 6px; font-size: 14px; }
|
||||||
|
.back-link { color: var(--accent); text-decoration: none; }
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
.separator { color: var(--text-dim); }
|
||||||
|
.board-actions { display: flex; gap: 8px; }
|
||||||
|
.board-filters { display: flex; gap: 12px; padding: 8px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||||
|
.filter-select {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 4px 8px; color: var(--text); font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Columns ── */
|
||||||
|
.board-columns {
|
||||||
|
flex: 1; overflow-x: auto; display: flex; gap: 8px;
|
||||||
|
padding: 12px; min-height: 0;
|
||||||
|
}
|
||||||
|
.board-column {
|
||||||
|
min-width: 280px; max-width: 340px; flex-shrink: 0;
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.column-header {
|
||||||
|
padding: 10px 12px; display: flex; justify-content: space-between; align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.column-title { font-size: 13px; font-weight: 600; }
|
||||||
|
.column-count {
|
||||||
|
background: var(--border); border-radius: 10px; padding: 1px 8px;
|
||||||
|
font-size: 11px; color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.column-cards { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
|
||||||
|
/* ── Cards ── */
|
||||||
|
.card {
|
||||||
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 10px; cursor: grab; transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.3); }
|
||||||
|
.card:active { cursor: grabbing; }
|
||||||
|
.card-ghost { opacity: 0.4; }
|
||||||
|
.card-labels { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 6px; }
|
||||||
|
.card-label { font-size: 10px; padding: 1px 5px; border-radius: 8px; font-weight: 500; }
|
||||||
|
.card-title { font-size: 13px; margin-bottom: 6px; line-height: 1.4; }
|
||||||
|
.card-title a { color: var(--text-dim); text-decoration: none; font-size: 11px; margin-right: 4px; }
|
||||||
|
.card-meta { display: flex; gap: 8px; font-size: 11px; color: var(--text-dim); align-items: center; }
|
||||||
|
.avatar { border-radius: 50%; vertical-align: middle; }
|
||||||
|
.card-priority { font-size: 10px; margin-top: 6px; padding: 2px 6px; border-radius: 4px; display: inline-block; }
|
||||||
|
.priority-high { background: var(--red); color: white; }
|
||||||
|
.priority-urgent { background: var(--red); color: white; font-weight: bold; }
|
||||||
|
.priority-medium { background: var(--orange); color: black; }
|
||||||
|
.priority-low { background: var(--border); color: var(--text-dim); }
|
||||||
|
.card-due { font-size: 10px; color: var(--text-dim); margin-top: 4px; }
|
||||||
|
.card-due.overdue { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Notes ── */
|
||||||
|
.notes-page { max-width: 800px; margin: 0 auto; padding: 24px 16px; }
|
||||||
|
.notes-textarea {
|
||||||
|
width: 100%; min-height: 300px; background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; padding: 12px;
|
||||||
|
color: var(--text); font-family: monospace; font-size: 14px; resize: vertical;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.notes-textarea:focus { outline: none; border-color: var(--accent); }
|
||||||
|
.notes-preview { margin-top: 24px; padding: 16px; background: var(--bg-card); border-radius: 8px; border: 1px solid var(--border); }
|
||||||
|
.notes-preview h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* ── Buttons ── */
|
||||||
|
.btn { padding: 6px 14px; border-radius: 6px; font-size: 13px; font-weight: 500;
|
||||||
|
cursor: pointer; border: 1px solid var(--border); background: var(--bg-card);
|
||||||
|
color: var(--text); text-decoration: none; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.btn:hover { background: var(--bg-hover); }
|
||||||
|
.btn-primary { background: var(--accent); color: white; border-color: var(--accent); }
|
||||||
|
.btn-primary:hover { opacity: 0.9; }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ── Responsive ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.board-columns { flex-direction: column; }
|
||||||
|
.board-column { max-width: 100%; min-height: 200px; }
|
||||||
|
.dashboard-header { flex-direction: column; align-items: flex-start; }
|
||||||
|
.search-input { width: 100%; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// FlowDeck — Theme toggle
|
||||||
|
(function() {
|
||||||
|
const saved = localStorage.getItem('flowdeck-theme');
|
||||||
|
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const current = document.documentElement.getAttribute('data-theme');
|
||||||
|
const next = current === 'light' ? 'dark' : 'light';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('flowdeck-theme', next);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""FlowDeck — Basic tests."""
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
import os
|
||||||
|
os.environ["GITEA_URL"] = "https://git.dracodev.net"
|
||||||
|
os.environ["GITEA_TOKEN"] = "test"
|
||||||
|
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.db import init_db
|
||||||
|
init_db()
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_health(client):
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "status" in data
|
||||||
|
assert data["db"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard(client):
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "FlowDeck" in resp.text or "Projets" in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_stats(client):
|
||||||
|
resp = client.get("/api/stats")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "boards" in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_projects(client):
|
||||||
|
resp = client.get("/api/projects")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_board_404(client):
|
||||||
|
resp = client.get("/board/test/test")
|
||||||
|
# Should render or return error, not crash
|
||||||
|
assert resp.status_code in (200, 500)
|
||||||
Reference in New Issue
Block a user