forge-mvc-stats 1.0.0b7__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forge_mvc_stats-1.0.0b7/PKG-INFO +101 -0
- forge_mvc_stats-1.0.0b7/README.md +82 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats/__init__.py +51 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats/admin.py +139 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats/events.py +106 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats/schema.py +33 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats/tracking.py +73 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats.egg-info/PKG-INFO +101 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats.egg-info/SOURCES.txt +12 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats.egg-info/dependency_links.txt +1 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats.egg-info/requires.txt +1 -0
- forge_mvc_stats-1.0.0b7/forge_mvc_stats.egg-info/top_level.txt +1 -0
- forge_mvc_stats-1.0.0b7/pyproject.toml +34 -0
- forge_mvc_stats-1.0.0b7/setup.cfg +4 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mvc-stats
|
|
3
|
+
Version: 1.0.0b7
|
|
4
|
+
Summary: Forge stats — événements génériques, schéma SQL, tracking et consultation.
|
|
5
|
+
Author: Roger Cauchon
|
|
6
|
+
License-Expression: LicenseRef-Forge-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/caucrogeGit/Forge
|
|
8
|
+
Project-URL: Repository, https://github.com/caucrogeGit/Forge
|
|
9
|
+
Project-URL: Documentation, https://caucrogegit.github.io/Forge/
|
|
10
|
+
Keywords: python,mvc,forge,stats,statistiques
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: forge-mvc<2,>=1.0.0b5
|
|
19
|
+
|
|
20
|
+
# forge-mvc-stats
|
|
21
|
+
|
|
22
|
+
Module stats pour Forge — événements génériques, schéma SQL, tracking et consultation.
|
|
23
|
+
|
|
24
|
+
Extrait du core Forge depuis la version 2.8.0 (ADR-004).
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install forge-mvc-stats
|
|
30
|
+
# ou en mode developpement
|
|
31
|
+
pip install -e packages/forge-mvc-stats/
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from forge_mvc_stats import make_event, track_event
|
|
38
|
+
|
|
39
|
+
# Créer un événement
|
|
40
|
+
event = make_event(
|
|
41
|
+
"page_view",
|
|
42
|
+
label="Vue de page",
|
|
43
|
+
category="traffic",
|
|
44
|
+
metadata={"path": "/contact"},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Enregistrer dans la base (db.execute est fourni par l'application)
|
|
48
|
+
track_event(db.execute, event)
|
|
49
|
+
|
|
50
|
+
# Raccourci direct par nom
|
|
51
|
+
track_event(db.execute, "contact_click", label="Clic contact")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Consultation
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from forge_mvc_stats import list_stats_events
|
|
58
|
+
|
|
59
|
+
# Lister les 50 derniers événements (fetch_all est fourni par l'application)
|
|
60
|
+
events = list_stats_events(my_fetch_all)
|
|
61
|
+
|
|
62
|
+
# Filtrer par nom ou catégorie
|
|
63
|
+
page_views = list_stats_events(my_fetch_all, name="page_view")
|
|
64
|
+
traffic = list_stats_events(my_fetch_all, category="traffic", limit=100)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Schéma SQL
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from forge_mvc_stats import get_stats_events_schema_sql, STATS_EVENTS_TABLE
|
|
71
|
+
|
|
72
|
+
print(STATS_EVENTS_TABLE) # "forge_stats_events"
|
|
73
|
+
sql = get_stats_events_schema_sql() # CREATE TABLE IF NOT EXISTS forge_stats_events (...)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Cas d'usage
|
|
77
|
+
|
|
78
|
+
- Comptage de visites de pages
|
|
79
|
+
- Suivi de clics sur des liens ou boutons
|
|
80
|
+
- Mesure de soumissions de formulaires
|
|
81
|
+
- Traçage d'événements métier applicatifs
|
|
82
|
+
|
|
83
|
+
## API publique
|
|
84
|
+
|
|
85
|
+
- `StatsEvent` — dataclass d'événement (name, label, category, metadata)
|
|
86
|
+
- `StatsEventError` — exception de validation
|
|
87
|
+
- `make_event(name, label, category, metadata)` — crée et valide un événement
|
|
88
|
+
- `validate_event(event)` — valide un événement existant
|
|
89
|
+
- `normalize_event_name(value)` — normalise en snake_case
|
|
90
|
+
- `validate_event_name(value)` — valide un nom normalisé
|
|
91
|
+
- `STATS_EVENTS_TABLE` — nom de la table SQL (`forge_stats_events`)
|
|
92
|
+
- `STATS_EVENTS_COLUMNS` — tuple des colonnes
|
|
93
|
+
- `get_stats_events_schema_sql()` — SQL `CREATE TABLE IF NOT EXISTS`
|
|
94
|
+
- `track_event(execute, event_or_name, ...)` — enregistre un événement en base
|
|
95
|
+
- `get_track_event_sql()` — SQL `INSERT` paramétré
|
|
96
|
+
- `prepare_track_event_values(event)` — tuple de paramètres prêts pour `execute`
|
|
97
|
+
- `list_stats_events(fetch_all, name, category, limit)` — liste des événements normalisés
|
|
98
|
+
- `get_stats_events_admin_sql(name, category, limit)` — SQL `SELECT` filtré
|
|
99
|
+
- `prepare_stats_events_admin_params(name, category, limit)` — paramètres du SELECT
|
|
100
|
+
- `normalize_stats_event_row(row)` — normalise une ligne brute (metadata JSON → dict)
|
|
101
|
+
- `StatsAdminError` — exception de consultation
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# forge-mvc-stats
|
|
2
|
+
|
|
3
|
+
Module stats pour Forge — événements génériques, schéma SQL, tracking et consultation.
|
|
4
|
+
|
|
5
|
+
Extrait du core Forge depuis la version 2.8.0 (ADR-004).
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install forge-mvc-stats
|
|
11
|
+
# ou en mode developpement
|
|
12
|
+
pip install -e packages/forge-mvc-stats/
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from forge_mvc_stats import make_event, track_event
|
|
19
|
+
|
|
20
|
+
# Créer un événement
|
|
21
|
+
event = make_event(
|
|
22
|
+
"page_view",
|
|
23
|
+
label="Vue de page",
|
|
24
|
+
category="traffic",
|
|
25
|
+
metadata={"path": "/contact"},
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Enregistrer dans la base (db.execute est fourni par l'application)
|
|
29
|
+
track_event(db.execute, event)
|
|
30
|
+
|
|
31
|
+
# Raccourci direct par nom
|
|
32
|
+
track_event(db.execute, "contact_click", label="Clic contact")
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Consultation
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from forge_mvc_stats import list_stats_events
|
|
39
|
+
|
|
40
|
+
# Lister les 50 derniers événements (fetch_all est fourni par l'application)
|
|
41
|
+
events = list_stats_events(my_fetch_all)
|
|
42
|
+
|
|
43
|
+
# Filtrer par nom ou catégorie
|
|
44
|
+
page_views = list_stats_events(my_fetch_all, name="page_view")
|
|
45
|
+
traffic = list_stats_events(my_fetch_all, category="traffic", limit=100)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Schéma SQL
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from forge_mvc_stats import get_stats_events_schema_sql, STATS_EVENTS_TABLE
|
|
52
|
+
|
|
53
|
+
print(STATS_EVENTS_TABLE) # "forge_stats_events"
|
|
54
|
+
sql = get_stats_events_schema_sql() # CREATE TABLE IF NOT EXISTS forge_stats_events (...)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Cas d'usage
|
|
58
|
+
|
|
59
|
+
- Comptage de visites de pages
|
|
60
|
+
- Suivi de clics sur des liens ou boutons
|
|
61
|
+
- Mesure de soumissions de formulaires
|
|
62
|
+
- Traçage d'événements métier applicatifs
|
|
63
|
+
|
|
64
|
+
## API publique
|
|
65
|
+
|
|
66
|
+
- `StatsEvent` — dataclass d'événement (name, label, category, metadata)
|
|
67
|
+
- `StatsEventError` — exception de validation
|
|
68
|
+
- `make_event(name, label, category, metadata)` — crée et valide un événement
|
|
69
|
+
- `validate_event(event)` — valide un événement existant
|
|
70
|
+
- `normalize_event_name(value)` — normalise en snake_case
|
|
71
|
+
- `validate_event_name(value)` — valide un nom normalisé
|
|
72
|
+
- `STATS_EVENTS_TABLE` — nom de la table SQL (`forge_stats_events`)
|
|
73
|
+
- `STATS_EVENTS_COLUMNS` — tuple des colonnes
|
|
74
|
+
- `get_stats_events_schema_sql()` — SQL `CREATE TABLE IF NOT EXISTS`
|
|
75
|
+
- `track_event(execute, event_or_name, ...)` — enregistre un événement en base
|
|
76
|
+
- `get_track_event_sql()` — SQL `INSERT` paramétré
|
|
77
|
+
- `prepare_track_event_values(event)` — tuple de paramètres prêts pour `execute`
|
|
78
|
+
- `list_stats_events(fetch_all, name, category, limit)` — liste des événements normalisés
|
|
79
|
+
- `get_stats_events_admin_sql(name, category, limit)` — SQL `SELECT` filtré
|
|
80
|
+
- `prepare_stats_events_admin_params(name, category, limit)` — paramètres du SELECT
|
|
81
|
+
- `normalize_stats_event_row(row)` — normalise une ligne brute (metadata JSON → dict)
|
|
82
|
+
- `StatsAdminError` — exception de consultation
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Forge stats — événements génériques, schéma SQL, tracking et consultation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from forge_mvc_stats.admin import (
|
|
6
|
+
StatsAdminError,
|
|
7
|
+
get_stats_events_admin_sql,
|
|
8
|
+
list_stats_events,
|
|
9
|
+
normalize_stats_event_row,
|
|
10
|
+
prepare_stats_events_admin_params,
|
|
11
|
+
)
|
|
12
|
+
from forge_mvc_stats.events import (
|
|
13
|
+
StatsEvent,
|
|
14
|
+
StatsEventError,
|
|
15
|
+
make_event,
|
|
16
|
+
normalize_event_name,
|
|
17
|
+
validate_event,
|
|
18
|
+
validate_event_name,
|
|
19
|
+
)
|
|
20
|
+
from forge_mvc_stats.schema import (
|
|
21
|
+
STATS_EVENTS_COLUMNS,
|
|
22
|
+
STATS_EVENTS_TABLE,
|
|
23
|
+
get_stats_events_schema_sql,
|
|
24
|
+
)
|
|
25
|
+
from forge_mvc_stats.tracking import (
|
|
26
|
+
get_track_event_sql,
|
|
27
|
+
prepare_track_event_values,
|
|
28
|
+
track_event,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
__version__ = "1.0.0b7"
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"StatsEvent",
|
|
35
|
+
"StatsEventError",
|
|
36
|
+
"normalize_event_name",
|
|
37
|
+
"validate_event_name",
|
|
38
|
+
"make_event",
|
|
39
|
+
"validate_event",
|
|
40
|
+
"STATS_EVENTS_TABLE",
|
|
41
|
+
"STATS_EVENTS_COLUMNS",
|
|
42
|
+
"get_stats_events_schema_sql",
|
|
43
|
+
"get_track_event_sql",
|
|
44
|
+
"prepare_track_event_values",
|
|
45
|
+
"track_event",
|
|
46
|
+
"StatsAdminError",
|
|
47
|
+
"get_stats_events_admin_sql",
|
|
48
|
+
"prepare_stats_events_admin_params",
|
|
49
|
+
"normalize_stats_event_row",
|
|
50
|
+
"list_stats_events",
|
|
51
|
+
]
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""API de consultation admin simple des événements statistiques Forge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable, Iterable
|
|
7
|
+
|
|
8
|
+
from .events import StatsEventError, validate_event_name
|
|
9
|
+
from .schema import STATS_EVENTS_TABLE
|
|
10
|
+
|
|
11
|
+
_DEFAULT_LIMIT = 50
|
|
12
|
+
_MAX_LIMIT = 500
|
|
13
|
+
_REQUIRED_COLUMNS = ("id", "name", "label", "category", "metadata", "created_at")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StatsAdminError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate_limit(limit: int) -> int:
|
|
21
|
+
if not isinstance(limit, int) or isinstance(limit, bool):
|
|
22
|
+
raise StatsAdminError(
|
|
23
|
+
f"limit doit être un entier, reçu : {type(limit).__name__}."
|
|
24
|
+
)
|
|
25
|
+
if limit < 1:
|
|
26
|
+
raise StatsAdminError(f"limit doit être ≥ 1, reçu : {limit}.")
|
|
27
|
+
return min(limit, _MAX_LIMIT)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_stats_events_admin_sql(
|
|
31
|
+
name: str | None = None,
|
|
32
|
+
category: str | None = None,
|
|
33
|
+
limit: int = _DEFAULT_LIMIT,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Return the SELECT SQL for listing stats events with optional filters."""
|
|
36
|
+
parts = [
|
|
37
|
+
f"SELECT id, name, label, category, metadata, created_at"
|
|
38
|
+
f" FROM {STATS_EVENTS_TABLE}"
|
|
39
|
+
f" WHERE 1 = 1",
|
|
40
|
+
]
|
|
41
|
+
if name is not None:
|
|
42
|
+
parts.append(" AND name = ?")
|
|
43
|
+
if category is not None:
|
|
44
|
+
parts.append(" AND category = ?")
|
|
45
|
+
parts.append(" ORDER BY created_at DESC, id DESC")
|
|
46
|
+
parts.append(" LIMIT ?")
|
|
47
|
+
return "".join(parts)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def prepare_stats_events_admin_params(
|
|
51
|
+
name: str | None = None,
|
|
52
|
+
category: str | None = None,
|
|
53
|
+
limit: int = _DEFAULT_LIMIT,
|
|
54
|
+
) -> tuple:
|
|
55
|
+
"""Return the SQL parameter tuple for a stats events query."""
|
|
56
|
+
if name is not None:
|
|
57
|
+
try:
|
|
58
|
+
name = validate_event_name(name)
|
|
59
|
+
except StatsEventError as exc:
|
|
60
|
+
raise StatsAdminError(str(exc)) from exc
|
|
61
|
+
if category is not None:
|
|
62
|
+
if not isinstance(category, str) or not category.strip():
|
|
63
|
+
raise StatsAdminError(
|
|
64
|
+
"category doit être une chaîne non vide."
|
|
65
|
+
)
|
|
66
|
+
category = category.strip()
|
|
67
|
+
effective_limit = _validate_limit(limit)
|
|
68
|
+
params: list[Any] = []
|
|
69
|
+
if name is not None:
|
|
70
|
+
params.append(name)
|
|
71
|
+
if category is not None:
|
|
72
|
+
params.append(category)
|
|
73
|
+
params.append(effective_limit)
|
|
74
|
+
return tuple(params)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalize_stats_event_row(row: dict[str, Any]) -> dict[str, Any]:
|
|
78
|
+
"""Normalize a raw DB row into a clean stats event dict.
|
|
79
|
+
|
|
80
|
+
metadata is deserialized from JSON string to dict.
|
|
81
|
+
Missing required columns or invalid JSON raises StatsAdminError.
|
|
82
|
+
"""
|
|
83
|
+
if not isinstance(row, dict):
|
|
84
|
+
raise StatsAdminError(
|
|
85
|
+
f"Une ligne dict est attendue, reçu : {type(row).__name__}."
|
|
86
|
+
)
|
|
87
|
+
missing = [col for col in _REQUIRED_COLUMNS if col not in row]
|
|
88
|
+
if missing:
|
|
89
|
+
raise StatsAdminError(
|
|
90
|
+
f"Colonnes manquantes dans la ligne : {', '.join(missing)}."
|
|
91
|
+
)
|
|
92
|
+
raw_metadata = row["metadata"]
|
|
93
|
+
if raw_metadata is None or raw_metadata == "":
|
|
94
|
+
metadata: dict[str, Any] = {}
|
|
95
|
+
elif isinstance(raw_metadata, dict):
|
|
96
|
+
metadata = raw_metadata
|
|
97
|
+
elif isinstance(raw_metadata, str):
|
|
98
|
+
try:
|
|
99
|
+
parsed = json.loads(raw_metadata)
|
|
100
|
+
except json.JSONDecodeError as exc:
|
|
101
|
+
raise StatsAdminError(
|
|
102
|
+
f"metadata JSON invalide : {exc}"
|
|
103
|
+
) from exc
|
|
104
|
+
if not isinstance(parsed, dict):
|
|
105
|
+
raise StatsAdminError(
|
|
106
|
+
"metadata JSON doit représenter un objet, "
|
|
107
|
+
f"reçu : {type(parsed).__name__}."
|
|
108
|
+
)
|
|
109
|
+
metadata = parsed
|
|
110
|
+
else:
|
|
111
|
+
raise StatsAdminError(
|
|
112
|
+
f"metadata doit être une chaîne JSON ou None, "
|
|
113
|
+
f"reçu : {type(raw_metadata).__name__}."
|
|
114
|
+
)
|
|
115
|
+
return {
|
|
116
|
+
"id": row["id"],
|
|
117
|
+
"name": row["name"],
|
|
118
|
+
"label": row["label"],
|
|
119
|
+
"category": row["category"],
|
|
120
|
+
"metadata": metadata,
|
|
121
|
+
"created_at": row["created_at"],
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def list_stats_events(
|
|
126
|
+
fetch_all: Callable[[str, tuple], Iterable[dict]],
|
|
127
|
+
name: str | None = None,
|
|
128
|
+
category: str | None = None,
|
|
129
|
+
limit: int = _DEFAULT_LIMIT,
|
|
130
|
+
) -> list[dict[str, Any]]:
|
|
131
|
+
"""List stats events from the database using the provided fetch_all executor.
|
|
132
|
+
|
|
133
|
+
Returns a list of normalized dicts. The caller must supply fetch_all —
|
|
134
|
+
Forge never accesses the database automatically.
|
|
135
|
+
"""
|
|
136
|
+
sql = get_stats_events_admin_sql(name=name, category=category, limit=limit)
|
|
137
|
+
params = prepare_stats_events_admin_params(name=name, category=category, limit=limit)
|
|
138
|
+
rows = fetch_all(sql, params)
|
|
139
|
+
return [normalize_stats_event_row(row) for row in rows]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Événements statistiques génériques pour Forge.
|
|
2
|
+
|
|
3
|
+
Les noms d'événements sont de simples chaînes snake_case définies par l'application.
|
|
4
|
+
Forge ne préconise aucune liste de noms — ce principe est conforme à la Charte Forge
|
|
5
|
+
(Principe 1 : le framework n'est pas l'application).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
16
|
+
_SAFE_NORMALIZE_RE = re.compile(r"[\s\-]+")
|
|
17
|
+
_UNSAFE_CHARS_RE = re.compile(r"[^a-z0-9_\s\-]")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class StatsEventError(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class StatsEvent:
|
|
26
|
+
name: str
|
|
27
|
+
label: str = ""
|
|
28
|
+
category: str = "general"
|
|
29
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
validated_name = validate_event_name(self.name)
|
|
33
|
+
object.__setattr__(self, "name", validated_name)
|
|
34
|
+
if not self.label:
|
|
35
|
+
object.__setattr__(self, "label", validated_name)
|
|
36
|
+
if not self.category:
|
|
37
|
+
object.__setattr__(self, "category", "general")
|
|
38
|
+
if self.metadata is None:
|
|
39
|
+
object.__setattr__(self, "metadata", {})
|
|
40
|
+
if not isinstance(self.metadata, dict):
|
|
41
|
+
raise StatsEventError(
|
|
42
|
+
f"metadata doit être un dictionnaire, reçu : {type(self.metadata).__name__}."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize_event_name(value: str) -> str:
|
|
47
|
+
"""Convert a raw string to a valid snake_case event name.
|
|
48
|
+
|
|
49
|
+
Spaces and hyphens are converted to underscores.
|
|
50
|
+
Any other non-alphanumeric character raises StatsEventError.
|
|
51
|
+
"""
|
|
52
|
+
if not isinstance(value, str):
|
|
53
|
+
raise StatsEventError("Le nom d'événement doit être une chaîne.")
|
|
54
|
+
lowered = value.strip().lower()
|
|
55
|
+
if _UNSAFE_CHARS_RE.search(lowered):
|
|
56
|
+
raise StatsEventError(
|
|
57
|
+
f"Le nom d'événement '{value}' contient des caractères non autorisés. "
|
|
58
|
+
"Utilisez uniquement des lettres, des chiffres, des espaces et des tirets."
|
|
59
|
+
)
|
|
60
|
+
normalized = _SAFE_NORMALIZE_RE.sub("_", lowered)
|
|
61
|
+
normalized = re.sub(r"_+", "_", normalized).strip("_")
|
|
62
|
+
return normalized
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_event_name(value: str) -> str:
|
|
66
|
+
"""Validate and return a normalized event name, or raise StatsEventError."""
|
|
67
|
+
if not isinstance(value, str):
|
|
68
|
+
raise StatsEventError("Le nom d'événement doit être une chaîne.")
|
|
69
|
+
if not value or not value.strip():
|
|
70
|
+
raise StatsEventError("Le nom d'événement ne peut pas être vide.")
|
|
71
|
+
normalized = normalize_event_name(value)
|
|
72
|
+
if not normalized:
|
|
73
|
+
raise StatsEventError(
|
|
74
|
+
f"Le nom d'événement '{value}' est invalide après normalisation."
|
|
75
|
+
)
|
|
76
|
+
if not _VALID_NAME_RE.match(normalized):
|
|
77
|
+
raise StatsEventError(
|
|
78
|
+
f"Le nom d'événement '{value}' est invalide. "
|
|
79
|
+
"Format attendu : lettres minuscules, chiffres et tirets bas, "
|
|
80
|
+
"commençant par une lettre."
|
|
81
|
+
)
|
|
82
|
+
return normalized
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def make_event(
|
|
86
|
+
name: str,
|
|
87
|
+
label: str = "",
|
|
88
|
+
category: str = "general",
|
|
89
|
+
metadata: dict[str, Any] | None = None,
|
|
90
|
+
) -> StatsEvent:
|
|
91
|
+
"""Create a validated StatsEvent."""
|
|
92
|
+
return StatsEvent(
|
|
93
|
+
name=name,
|
|
94
|
+
label=label,
|
|
95
|
+
category=category,
|
|
96
|
+
metadata=metadata if metadata is not None else {},
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_event(event: StatsEvent) -> StatsEvent:
|
|
101
|
+
"""Validate an existing StatsEvent and return it, or raise StatsEventError."""
|
|
102
|
+
if not isinstance(event, StatsEvent):
|
|
103
|
+
raise StatsEventError(
|
|
104
|
+
f"Un StatsEvent est attendu, reçu : {type(event).__name__}."
|
|
105
|
+
)
|
|
106
|
+
return event
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Définition SQL générique de la table d'événements statistiques Forge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
STATS_EVENTS_TABLE = "forge_stats_events"
|
|
6
|
+
|
|
7
|
+
STATS_EVENTS_COLUMNS = (
|
|
8
|
+
"id",
|
|
9
|
+
"name",
|
|
10
|
+
"label",
|
|
11
|
+
"category",
|
|
12
|
+
"metadata",
|
|
13
|
+
"created_at",
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_SQL = """\
|
|
17
|
+
CREATE TABLE IF NOT EXISTS forge_stats_events (
|
|
18
|
+
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
19
|
+
name VARCHAR(100) NOT NULL,
|
|
20
|
+
label VARCHAR(150) NOT NULL,
|
|
21
|
+
category VARCHAR(100) NOT NULL DEFAULT 'general',
|
|
22
|
+
metadata JSON NULL,
|
|
23
|
+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
24
|
+
PRIMARY KEY (id),
|
|
25
|
+
INDEX idx_forge_stats_events_name (name),
|
|
26
|
+
INDEX idx_forge_stats_events_category (category),
|
|
27
|
+
INDEX idx_forge_stats_events_created_at (created_at)
|
|
28
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_stats_events_schema_sql() -> str:
|
|
32
|
+
"""Return the SQL CREATE TABLE statement for forge_stats_events."""
|
|
33
|
+
return _SQL
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Helper Python explicite de tracking statistique pour Forge."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .events import StatsEvent, StatsEventError, make_event, validate_event
|
|
9
|
+
from .schema import STATS_EVENTS_TABLE
|
|
10
|
+
|
|
11
|
+
_INSERT_SQL = (
|
|
12
|
+
f"INSERT INTO {STATS_EVENTS_TABLE}"
|
|
13
|
+
" (name, label, category, metadata)"
|
|
14
|
+
" VALUES (?, ?, ?, ?)"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_track_event_sql() -> str:
|
|
19
|
+
"""Return the INSERT SQL for recording a stats event."""
|
|
20
|
+
return _INSERT_SQL
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _serialize_metadata(metadata: dict[str, Any]) -> str:
|
|
24
|
+
try:
|
|
25
|
+
return json.dumps(metadata, ensure_ascii=False, sort_keys=True)
|
|
26
|
+
except (TypeError, ValueError) as exc:
|
|
27
|
+
raise StatsEventError(
|
|
28
|
+
f"metadata non sérialisable en JSON : {exc}"
|
|
29
|
+
) from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def prepare_track_event_values(event: StatsEvent) -> tuple[str, str, str, str]:
|
|
33
|
+
"""Return the SQL parameter tuple for a StatsEvent."""
|
|
34
|
+
if not isinstance(event, StatsEvent):
|
|
35
|
+
raise StatsEventError(
|
|
36
|
+
f"Un StatsEvent est attendu, reçu : {type(event).__name__}."
|
|
37
|
+
)
|
|
38
|
+
return (
|
|
39
|
+
event.name,
|
|
40
|
+
event.label,
|
|
41
|
+
event.category,
|
|
42
|
+
_serialize_metadata(event.metadata),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def track_event(
|
|
47
|
+
execute: Callable,
|
|
48
|
+
event_or_name: StatsEvent | str,
|
|
49
|
+
label: str = "",
|
|
50
|
+
category: str = "general",
|
|
51
|
+
metadata: dict[str, Any] | None = None,
|
|
52
|
+
) -> StatsEvent:
|
|
53
|
+
"""Record a stats event by calling the provided SQL executor.
|
|
54
|
+
|
|
55
|
+
The executor must be callable as execute(sql, params).
|
|
56
|
+
Forge never calls this automatically — the developer must call it explicitly.
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(event_or_name, StatsEvent):
|
|
59
|
+
event = validate_event(event_or_name)
|
|
60
|
+
elif isinstance(event_or_name, str):
|
|
61
|
+
event = make_event(
|
|
62
|
+
name=event_or_name,
|
|
63
|
+
label=label,
|
|
64
|
+
category=category,
|
|
65
|
+
metadata=metadata,
|
|
66
|
+
)
|
|
67
|
+
else:
|
|
68
|
+
raise StatsEventError(
|
|
69
|
+
"event_or_name doit être un StatsEvent ou un nom d'événement, "
|
|
70
|
+
f"reçu : {type(event_or_name).__name__}."
|
|
71
|
+
)
|
|
72
|
+
execute(get_track_event_sql(), prepare_track_event_values(event))
|
|
73
|
+
return event
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forge-mvc-stats
|
|
3
|
+
Version: 1.0.0b7
|
|
4
|
+
Summary: Forge stats — événements génériques, schéma SQL, tracking et consultation.
|
|
5
|
+
Author: Roger Cauchon
|
|
6
|
+
License-Expression: LicenseRef-Forge-Proprietary
|
|
7
|
+
Project-URL: Homepage, https://github.com/caucrogeGit/Forge
|
|
8
|
+
Project-URL: Repository, https://github.com/caucrogeGit/Forge
|
|
9
|
+
Project-URL: Documentation, https://caucrogegit.github.io/Forge/
|
|
10
|
+
Keywords: python,mvc,forge,stats,statistiques
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Requires-Dist: forge-mvc<2,>=1.0.0b5
|
|
19
|
+
|
|
20
|
+
# forge-mvc-stats
|
|
21
|
+
|
|
22
|
+
Module stats pour Forge — événements génériques, schéma SQL, tracking et consultation.
|
|
23
|
+
|
|
24
|
+
Extrait du core Forge depuis la version 2.8.0 (ADR-004).
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install forge-mvc-stats
|
|
30
|
+
# ou en mode developpement
|
|
31
|
+
pip install -e packages/forge-mvc-stats/
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from forge_mvc_stats import make_event, track_event
|
|
38
|
+
|
|
39
|
+
# Créer un événement
|
|
40
|
+
event = make_event(
|
|
41
|
+
"page_view",
|
|
42
|
+
label="Vue de page",
|
|
43
|
+
category="traffic",
|
|
44
|
+
metadata={"path": "/contact"},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Enregistrer dans la base (db.execute est fourni par l'application)
|
|
48
|
+
track_event(db.execute, event)
|
|
49
|
+
|
|
50
|
+
# Raccourci direct par nom
|
|
51
|
+
track_event(db.execute, "contact_click", label="Clic contact")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Consultation
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from forge_mvc_stats import list_stats_events
|
|
58
|
+
|
|
59
|
+
# Lister les 50 derniers événements (fetch_all est fourni par l'application)
|
|
60
|
+
events = list_stats_events(my_fetch_all)
|
|
61
|
+
|
|
62
|
+
# Filtrer par nom ou catégorie
|
|
63
|
+
page_views = list_stats_events(my_fetch_all, name="page_view")
|
|
64
|
+
traffic = list_stats_events(my_fetch_all, category="traffic", limit=100)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Schéma SQL
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
from forge_mvc_stats import get_stats_events_schema_sql, STATS_EVENTS_TABLE
|
|
71
|
+
|
|
72
|
+
print(STATS_EVENTS_TABLE) # "forge_stats_events"
|
|
73
|
+
sql = get_stats_events_schema_sql() # CREATE TABLE IF NOT EXISTS forge_stats_events (...)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Cas d'usage
|
|
77
|
+
|
|
78
|
+
- Comptage de visites de pages
|
|
79
|
+
- Suivi de clics sur des liens ou boutons
|
|
80
|
+
- Mesure de soumissions de formulaires
|
|
81
|
+
- Traçage d'événements métier applicatifs
|
|
82
|
+
|
|
83
|
+
## API publique
|
|
84
|
+
|
|
85
|
+
- `StatsEvent` — dataclass d'événement (name, label, category, metadata)
|
|
86
|
+
- `StatsEventError` — exception de validation
|
|
87
|
+
- `make_event(name, label, category, metadata)` — crée et valide un événement
|
|
88
|
+
- `validate_event(event)` — valide un événement existant
|
|
89
|
+
- `normalize_event_name(value)` — normalise en snake_case
|
|
90
|
+
- `validate_event_name(value)` — valide un nom normalisé
|
|
91
|
+
- `STATS_EVENTS_TABLE` — nom de la table SQL (`forge_stats_events`)
|
|
92
|
+
- `STATS_EVENTS_COLUMNS` — tuple des colonnes
|
|
93
|
+
- `get_stats_events_schema_sql()` — SQL `CREATE TABLE IF NOT EXISTS`
|
|
94
|
+
- `track_event(execute, event_or_name, ...)` — enregistre un événement en base
|
|
95
|
+
- `get_track_event_sql()` — SQL `INSERT` paramétré
|
|
96
|
+
- `prepare_track_event_values(event)` — tuple de paramètres prêts pour `execute`
|
|
97
|
+
- `list_stats_events(fetch_all, name, category, limit)` — liste des événements normalisés
|
|
98
|
+
- `get_stats_events_admin_sql(name, category, limit)` — SQL `SELECT` filtré
|
|
99
|
+
- `prepare_stats_events_admin_params(name, category, limit)` — paramètres du SELECT
|
|
100
|
+
- `normalize_stats_event_row(row)` — normalise une ligne brute (metadata JSON → dict)
|
|
101
|
+
- `StatsAdminError` — exception de consultation
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
forge_mvc_stats/__init__.py
|
|
4
|
+
forge_mvc_stats/admin.py
|
|
5
|
+
forge_mvc_stats/events.py
|
|
6
|
+
forge_mvc_stats/schema.py
|
|
7
|
+
forge_mvc_stats/tracking.py
|
|
8
|
+
forge_mvc_stats.egg-info/PKG-INFO
|
|
9
|
+
forge_mvc_stats.egg-info/SOURCES.txt
|
|
10
|
+
forge_mvc_stats.egg-info/dependency_links.txt
|
|
11
|
+
forge_mvc_stats.egg-info/requires.txt
|
|
12
|
+
forge_mvc_stats.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forge-mvc<2,>=1.0.0b5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forge_mvc_stats
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77.0.3"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "forge-mvc-stats"
|
|
7
|
+
version = "1.0.0b7"
|
|
8
|
+
description = "Forge stats — événements génériques, schéma SQL, tracking et consultation."
|
|
9
|
+
readme = { file = "README.md", content-type = "text/markdown" }
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Roger Cauchon" },
|
|
13
|
+
]
|
|
14
|
+
license = "LicenseRef-Forge-Proprietary"
|
|
15
|
+
keywords = ["python", "mvc", "forge", "stats", "statistiques"]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"forge-mvc>=1.0.0b5,<2",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.12",
|
|
23
|
+
"Programming Language :: Python :: 3.13",
|
|
24
|
+
"Programming Language :: Python :: 3.14",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/caucrogeGit/Forge"
|
|
29
|
+
Repository = "https://github.com/caucrogeGit/Forge"
|
|
30
|
+
Documentation = "https://caucrogegit.github.io/Forge/"
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["."]
|
|
34
|
+
include = ["forge_mvc_stats*"]
|