Skip to content

Commit 948a79e

Browse files
feat: ajouter des statistiques de tableau de bord pour les commandes, groupes, tags et copies
1 parent 296b603 commit 948a79e

10 files changed

Lines changed: 394 additions & 90 deletions

File tree

backend/api/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,18 @@ class Command(Base):
118118
@property
119119
def tags(self) -> list[str]:
120120
return sorted({t.name for t in self.tag_entities})
121+
122+
123+
class CopyEvent(Base):
124+
__tablename__ = "copy_events"
125+
126+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
127+
command_id: Mapped[int] = mapped_column(
128+
ForeignKey("commands.id", ondelete="CASCADE"), index=True
129+
)
130+
delta: Mapped[int] = mapped_column(Integer, default=1)
131+
created_at: Mapped[dt.datetime] = mapped_column(
132+
DateTime(timezone=True), server_default=func.now(), index=True
133+
)
134+
135+
command: Mapped[Command] = relationship()

backend/api/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .imports import router as imports_router
55
from .search import router as search_router
66
from .tags import router as tags_router
7+
from .stats import router as stats_router
78

89
__all__ = [
910
"auth_router",
@@ -12,4 +13,5 @@
1213
"imports_router",
1314
"search_router",
1415
"tags_router",
16+
"stats_router",
1517
]

backend/api/routers/commands.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from ..database import get_db
88
from ..deps import require_ready_user
9-
from ..models import Command, Group, Tag
9+
from ..models import Command, CopyEvent, Group, Tag
1010
from ..schemas import (
1111
CommandCreate,
1212
CommandOut,
@@ -191,6 +191,8 @@ def update_command(
191191

192192
data = payload.model_dump(exclude_unset=True)
193193

194+
prev_copy_count = cmd.copy_count
195+
194196
if "group_id" in data and data["group_id"] is not None:
195197
group = db.get(Group, data["group_id"])
196198
if group is None:
@@ -208,6 +210,12 @@ def update_command(
208210
for key, value in data.items():
209211
setattr(cmd, key, value)
210212

213+
if "copy_count" in data and data.get("copy_count") is not None:
214+
next_copy_count = int(cmd.copy_count or 0)
215+
if next_copy_count > int(prev_copy_count or 0):
216+
delta = next_copy_count - int(prev_copy_count or 0)
217+
db.add(CopyEvent(command_id=cmd.id, delta=delta))
218+
211219
db.add(cmd)
212220
db.commit()
213221
db.refresh(cmd)

backend/api/routers/stats.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
import datetime as dt
4+
5+
from fastapi import APIRouter, Depends, Query
6+
from sqlalchemy import func, select
7+
from sqlalchemy.orm import Session
8+
9+
from ..database import get_db
10+
from ..deps import require_ready_user
11+
from ..models import Command, CopyEvent, Group, Tag
12+
from ..schemas import DashboardStatsResponse
13+
14+
router = APIRouter(
15+
prefix="/stats", tags=["stats"], dependencies=[Depends(require_ready_user)]
16+
)
17+
18+
19+
def _default_range() -> tuple[dt.date, dt.date]:
20+
today = dt.date.today()
21+
return (today - dt.timedelta(days=30), today)
22+
23+
24+
@router.get("/dashboard", response_model=DashboardStatsResponse)
25+
def dashboard_stats(
26+
from_date: dt.date | None = Query(
27+
default=None, description="Start date (YYYY-MM-DD)"
28+
),
29+
to_date: dt.date | None = Query(default=None, description="End date (YYYY-MM-DD)"),
30+
db: Session = Depends(get_db),
31+
) -> DashboardStatsResponse:
32+
if from_date is None or to_date is None:
33+
d0, d1 = _default_range()
34+
from_date = from_date or d0
35+
to_date = to_date or d1
36+
37+
if from_date > to_date:
38+
from_date, to_date = to_date, from_date
39+
40+
# Inclusive day range: [from 00:00, to+1day 00:00)
41+
start_dt = dt.datetime.combine(from_date, dt.time.min)
42+
end_dt = dt.datetime.combine(to_date + dt.timedelta(days=1), dt.time.min)
43+
44+
commands = int(db.scalar(select(func.count(Command.id))) or 0)
45+
groups = int(db.scalar(select(func.count(Group.id))) or 0)
46+
tags = int(db.scalar(select(func.count(Tag.id))) or 0)
47+
48+
copies_stmt = select(func.coalesce(func.sum(CopyEvent.delta), 0)).where(
49+
CopyEvent.created_at >= start_dt,
50+
CopyEvent.created_at < end_dt,
51+
)
52+
copies = int(db.scalar(copies_stmt) or 0)
53+
54+
return DashboardStatsResponse(
55+
commands=commands,
56+
groups=groups,
57+
tags=tags,
58+
copies=copies,
59+
from_date=from_date,
60+
to_date=to_date,
61+
)

backend/api/schemas.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,3 +108,12 @@ class CommandStatsResponse(BaseModel):
108108

109109
class SearchResponse(BaseModel):
110110
items: list[CommandOut]
111+
112+
113+
class DashboardStatsResponse(BaseModel):
114+
commands: int
115+
groups: int
116+
tags: int
117+
copies: int
118+
from_date: dt.date
119+
to_date: dt.date

backend/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
groups_router,
1111
imports_router,
1212
search_router,
13+
stats_router,
1314
tags_router,
1415
)
1516
from api.routers.auth import ensure_default_admin
@@ -48,6 +49,7 @@ async def lifespan(_: FastAPI):
4849
app.include_router(imports_router)
4950
app.include_router(search_router)
5051
app.include_router(tags_router)
52+
app.include_router(stats_router)
5153

5254

5355
@app.get("/health")
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import datetime as dt
2+
3+
4+
def _login(client, username: str, password: str) -> dict:
5+
res = client.post("/auth/login", json={"username": username, "password": password})
6+
assert res.status_code == 200
7+
return res.json()
8+
9+
10+
def _auth_headers(token: str) -> dict[str, str]:
11+
return {"Authorization": f"Bearer {token}"}
12+
13+
14+
def test_dashboard_stats_counts_and_copies_range(client):
15+
login = _login(client, "admin", "admin")
16+
token = login["access_token"]
17+
18+
# Need to be a "ready" user
19+
res = client.post(
20+
"/auth/change-password",
21+
json={"old_password": "admin", "new_password": "admin123"},
22+
headers=_auth_headers(token),
23+
)
24+
assert res.status_code == 200
25+
26+
login2 = _login(client, "admin", "admin123")
27+
token2 = login2["access_token"]
28+
29+
# Create group
30+
res = client.post(
31+
"/groups",
32+
json={"name": "Projet A"},
33+
headers=_auth_headers(token2),
34+
)
35+
assert res.status_code == 201
36+
group = res.json()
37+
38+
# Create command with 2 tags
39+
res = client.post(
40+
"/commands",
41+
json={
42+
"group_id": group["id"],
43+
"title": "Hello",
44+
"command": "echo hi",
45+
"tags": ["docker", "git"],
46+
},
47+
headers=_auth_headers(token2),
48+
)
49+
assert res.status_code == 201
50+
cmd = res.json()
51+
52+
# Simulate 3 copies (PATCH copy_count)
53+
res = client.patch(
54+
f"/commands/{cmd['id']}",
55+
json={"copy_count": 3},
56+
headers=_auth_headers(token2),
57+
)
58+
assert res.status_code == 200
59+
assert res.json()["copy_count"] == 3
60+
61+
today = dt.date.today().isoformat()
62+
63+
res = client.get(
64+
"/stats/dashboard",
65+
params={"from_date": today, "to_date": today},
66+
headers=_auth_headers(token2),
67+
)
68+
assert res.status_code == 200
69+
data = res.json()
70+
71+
assert data["commands"] == 1
72+
assert data["groups"] == 1
73+
assert data["tags"] == 2
74+
assert data["copies"] == 3
75+
assert data["from_date"] == today
76+
assert data["to_date"] == today

frontend/src/components/AppSidebar.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState, useRef } from "react";
22
import {
3-
Star, FolderOpen, Plus, Download, Upload, Sun, Moon, Menu, X, LogOut, Loader2,
3+
Star, FolderOpen, Plus, Download, Upload, Sun, Moon, Menu, X, LogOut, Loader2, BarChart3,
44
} from "lucide-react";
55
import { Group, CommandStats } from "@/hooks/useCommandVault";
66
import { Skeleton } from "@/components/ui/skeleton";
@@ -64,6 +64,16 @@ export default function AppSidebar({
6464

6565
{/* Nav */}
6666
<div className="flex-1 overflow-y-auto p-3 space-y-1">
67+
<button
68+
onClick={() => nav("dashboard")}
69+
disabled={loading}
70+
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors ${activeView === "dashboard" ? "bg-surface-active text-foreground font-medium" : "text-sidebar-fg hover:bg-surface-hover"
71+
}`}
72+
>
73+
<BarChart3 className="w-4 h-4" />
74+
<span>Dashboard</span>
75+
</button>
76+
6777
<button
6878
onClick={() => nav("all")}
6979
disabled={loading}

0 commit comments

Comments
 (0)