feat(M2): configuration, gestion des secrets et logging
- config/settings.py : modèles Pydantic Settings (Pronote, CalDAV, XMPP, AI, Blog, App) avec SecretStr pour les mots de passe et clés API - config/env.py : fonction load_settings() pour le chargement du .env - utils/redaction.py : redact_url, redact_secrets, redact_exception (masquage des tokens icalsecurise, mots de passe et URLs sensibles) - utils/logging.py : setup_logging + RedactingFormatter (masquage automatique des secrets dans les logs) - utils/uid.py : normalize_pronote_uid (suppression suffixes temporels) et generate_deterministic_uid (hash SHA-1 usedforsecurity=False) - .env.example : aligné sur les modèles finaux (XMPP_TO, XMPP_HOST, XMPP_ENABLED, BLOG_ENABLED, etc.) - .pre-commit-config.yaml : ajout pydantic + pydantic-settings aux additional_dependencies du hook mypy - TODO.md : items M2 cochés Décisions d'architecture (@architect) : - XmppSettings : modèle complet §10.2.3, tous champs optionnels - sync_past_days/future_days déplacés vers AppSettings (sans préfixe) - AISettings.enabled = False par défaut - BlogSettings inclus dès M2 - redact_exception comme fonction module (pas méthode) - normalize_pronote_uid (nom du guide et des tests) Validations : - ruff check : PASS - ruff format --check : PASS - mypy strict : PASS (7 fichiers) - bandit : PASS (0 issue) - import settings : OK (toutes valeurs par défaut) - redact_secrets/icalsecurise : masqué en REDACTED - logging : secret masqué dans la sortie - uid normalize : idempotent, deterministic OK - SecretStr : pas de fuite dans repr - pytest : 0 test (infrastructure OK) Co-authored-by: OpenCode/orchestrator <opencode-orchestrator@agents.invalid>
This commit is contained in:
12
.env.example
12
.env.example
@@ -22,9 +22,15 @@ SYNC_FUTURE_DAYS=30
|
|||||||
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
|
THEORETICAL_AGENDA_PATH=./data/theoretical.ics
|
||||||
|
|
||||||
# --- XMPP ---
|
# --- XMPP ---
|
||||||
|
XMPP_ENABLED=false
|
||||||
XMPP_JID=user@example.com
|
XMPP_JID=user@example.com
|
||||||
XMPP_PASSWORD=your_xmpp_password
|
XMPP_PASSWORD=your_xmpp_password
|
||||||
XMPP_RECIPIENT=parent@example.com
|
XMPP_HOST=example.com
|
||||||
|
XMPP_PORT=5222
|
||||||
|
XMPP_TO=parent@example.com
|
||||||
|
XMPP_RESOURCE=pronote-sync
|
||||||
|
XMPP_USE_TLS=true
|
||||||
|
XMPP_TIMEOUT=30
|
||||||
|
|
||||||
# --- IA (optionnelle) ---
|
# --- IA (optionnelle) ---
|
||||||
AI_ENABLED=true
|
AI_ENABLED=true
|
||||||
@@ -32,6 +38,10 @@ AI_BASE_URL=https://api.openai.com/v1
|
|||||||
AI_API_KEY=your_ai_api_key
|
AI_API_KEY=your_ai_api_key
|
||||||
AI_MODEL=gpt-4o-mini
|
AI_MODEL=gpt-4o-mini
|
||||||
|
|
||||||
|
# --- Blog ---
|
||||||
|
BLOG_ENABLED=false
|
||||||
|
BLOG_RSS_URL=https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2
|
||||||
|
|
||||||
# --- Divers ---
|
# --- Divers ---
|
||||||
DRY_RUN=false
|
DRY_RUN=false
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ repos:
|
|||||||
name: mypy
|
name: mypy
|
||||||
entry: mypy
|
entry: mypy
|
||||||
language: python
|
language: python
|
||||||
additional_dependencies: ["mypy>=1.10.0"]
|
additional_dependencies: ["mypy>=1.10.0", "pydantic>=2.0.0", "pydantic-settings>=2.0.0"]
|
||||||
types: [python]
|
types: [python]
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|
||||||
|
|||||||
14
TODO.md
14
TODO.md
@@ -31,13 +31,13 @@ Mettre en place le dépôt, l'environnement, l'arborescence du package et la cha
|
|||||||
|
|
||||||
Implémenter la configuration Pydantic Settings, le masquage des secrets et la journalisation sûre.
|
Implémenter la configuration Pydantic Settings, le masquage des secrets et la journalisation sûre.
|
||||||
|
|
||||||
- [ ] Créer `config/settings.py` : `PronoteSettings`, `CalDAVSettings`, `XmppSettings`, `AISettings`, `AppSettings`, `Settings` (§3.2) avec `SecretStr` et prefixes d'env.
|
- [x] Créer `config/settings.py` : `PronoteSettings`, `CalDAVSettings`, `XmppSettings`, `AISettings`, `AppSettings`, `Settings` (§3.2) avec `SecretStr` et prefixes d'env.
|
||||||
- [ ] Créer `config/env.py` pour le chargement du `.env` (`SettingsConfigDict(env_file=".env")`).
|
- [x] Créer `config/env.py` pour le chargement du `.env` (`SettingsConfigDict(env_file=".env")`).
|
||||||
- [ ] Créer `.env.example` complet (toutes variables obligatoires §3.1.1 + optionnelles §3.1.2).
|
- [x] Créer `.env.example` complet (toutes variables obligatoires §3.1.1 + optionnelles §3.1.2).
|
||||||
- [ ] Créer `utils/redaction.py` : `redact_url`, `redact_secrets`, `redact_exception` (§4.2.1).
|
- [x] Créer `utils/redaction.py` : `redact_url`, `redact_secrets`, `redact_exception` (§4.2.1).
|
||||||
- [ ] Créer `utils/logging.py` : `setup_logging` + `RedactingFormatter` masquant les secrets dans messages et args (§4.2.2).
|
- [x] Créer `utils/logging.py` : `setup_logging` + `RedactingFormatter` masquant les secrets dans messages et args (§4.2.2).
|
||||||
- [ ] Créer `utils/uid.py` : `normalize_uid` (suppression des suffixes temporels des UID Pronote).
|
- [x] Créer `utils/uid.py` : `normalize_uid` (suppression des suffixes temporels des UID Pronote).
|
||||||
- [ ] Vérifier qu'aucun `SecretStr` n'est affiché en clair via `str()`/`print`.
|
- [x] Vérifier qu'aucun `SecretStr` n'est affiché en clair via `str()`/`print`.
|
||||||
|
|
||||||
### Critères d'acceptation
|
### Critères d'acceptation
|
||||||
- `from pronote_sync.config.settings import settings` fonctionne et charge `.env`.
|
- `from pronote_sync.config.settings import settings` fonctionne et charge `.env`.
|
||||||
|
|||||||
13
pronote_sync/config/env.py
Normal file
13
pronote_sync/config/env.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Chargement de la configuration depuis l'environnement."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pronote_sync.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings() -> Settings:
|
||||||
|
"""Charge et valide la configuration depuis le fichier ``.env`` et les variables d'environnement.
|
||||||
|
|
||||||
|
:return: Instance de configuration validée.
|
||||||
|
"""
|
||||||
|
return Settings()
|
||||||
132
pronote_sync/config/settings.py
Normal file
132
pronote_sync/config/settings.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""Configuration de l'application via ``pydantic-settings``.
|
||||||
|
|
||||||
|
Chaque sous-groupe de configuration est un modèle ``BaseSettings`` dédié, chargé
|
||||||
|
depuis les variables d'environnement (préfixées par groupe) et le fichier
|
||||||
|
``.env``. Tous les champs disposent de valeurs par défaut sûres afin que
|
||||||
|
``Settings()`` fonctionne même sans fichier de configuration présent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class PronoteSettings(BaseSettings):
|
||||||
|
"""Paramètres d'accès à Pronote (flux iCal et API ``pronotepy``).
|
||||||
|
|
||||||
|
Les variables d'environnement correspondantes sont préfixées par
|
||||||
|
``PRONOTE_``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="PRONOTE_")
|
||||||
|
|
||||||
|
ical_url: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
password: SecretStr | None = None
|
||||||
|
ent: str | None = None
|
||||||
|
agenda_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||||
|
homework_source: Literal["auto", "ical", "pronotepy"] = "auto"
|
||||||
|
messages_source: Literal["pronotepy"] = "pronotepy"
|
||||||
|
|
||||||
|
|
||||||
|
class CalDAVSettings(BaseSettings):
|
||||||
|
"""Paramètres d'accès au serveur CalDAV de destination.
|
||||||
|
|
||||||
|
Les variables d'environnement correspondantes sont préfixées par
|
||||||
|
``CALDAV_``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="CALDAV_")
|
||||||
|
|
||||||
|
url: str | None = None
|
||||||
|
username: str | None = None
|
||||||
|
password: SecretStr | None = None
|
||||||
|
calendar_path: str = "/pronote-sync/"
|
||||||
|
|
||||||
|
|
||||||
|
class XmppSettings(BaseSettings):
|
||||||
|
"""Paramètres du canal de notifications XMPP (désactivé par défaut).
|
||||||
|
|
||||||
|
Tous les champs ont des valeurs par défaut afin que le canal XMPP reste
|
||||||
|
inactif tant qu'il n'est pas explicitement activé. Les variables
|
||||||
|
d'environnement correspondantes sont préfixées par ``XMPP_``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="XMPP_")
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
jid: str | None = None
|
||||||
|
password: SecretStr | None = None
|
||||||
|
host: str = ""
|
||||||
|
port: int = 5222
|
||||||
|
to: str | None = None
|
||||||
|
resource: str = "pronote-sync"
|
||||||
|
use_tls: bool = True
|
||||||
|
timeout: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class AISettings(BaseSettings):
|
||||||
|
"""Paramètres de la synthèse par IA (désactivée par défaut).
|
||||||
|
|
||||||
|
Les variables d'environnement correspondantes sont préfixées par ``AI_``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="AI_")
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
provider: Literal["openai", "litellm"] = "openai"
|
||||||
|
base_url: str | None = None
|
||||||
|
api_key: SecretStr | None = None
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BlogSettings(BaseSettings):
|
||||||
|
"""Paramètres de la source RSS du blog du collège (désactivée par défaut).
|
||||||
|
|
||||||
|
Les variables d'environnement correspondantes sont préfixées par
|
||||||
|
``BLOG_``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="BLOG_")
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2"
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(BaseSettings):
|
||||||
|
"""Paramètres généraux de l'application, sans préfixe d'environnement.
|
||||||
|
|
||||||
|
Contient notamment la fenêtre de synchronisation en jours
|
||||||
|
(``SYNC_PAST_DAYS`` / ``SYNC_FUTURE_DAYS``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
dry_run: bool = False
|
||||||
|
log_level: str = "INFO"
|
||||||
|
theoretical_agenda_path: str | None = None
|
||||||
|
sync_past_days: int = 7
|
||||||
|
sync_future_days: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""Configuration racine du pipeline ``pronote-sync``.
|
||||||
|
|
||||||
|
Agrège les sous-groupes de configuration : Pronote, CalDAV, XMPP, IA,
|
||||||
|
blog et paramètres généraux de l'application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
pronote: PronoteSettings = PronoteSettings()
|
||||||
|
caldav: CalDAVSettings = CalDAVSettings()
|
||||||
|
xmpp: XmppSettings = XmppSettings()
|
||||||
|
ai: AISettings = AISettings()
|
||||||
|
blog: BlogSettings = BlogSettings()
|
||||||
|
app: AppSettings = AppSettings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
67
pronote_sync/utils/logging.py
Normal file
67
pronote_sync/utils/logging.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Configuration de la journalisation avec masquage automatique des secrets.
|
||||||
|
|
||||||
|
Ce module fournit un formateur de logs qui rédige les secrets (tokens, mots
|
||||||
|
de passe, URLs sensibles) ainsi que l'initialisation du logging global du
|
||||||
|
pipeline ``pronote-sync``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from pronote_sync.utils.redaction import redact_secrets
|
||||||
|
|
||||||
|
_LOG_FORMAT = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
|
||||||
|
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
|
||||||
|
class RedactingFormatter(logging.Formatter):
|
||||||
|
"""Formateur de logs qui masque les secrets des messages et des arguments."""
|
||||||
|
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
"""Formate un enregistrement de log en masquant les secrets.
|
||||||
|
|
||||||
|
Le message et chaque argument textuel de l'enregistrement sont rédigés
|
||||||
|
avant le formatage final effectué par :class:`logging.Formatter`.
|
||||||
|
|
||||||
|
:param record: Enregistrement de log à formater.
|
||||||
|
:return: Message formaté, avec les secrets remplacés par ``REDACTED``.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
record.msg = redact_secrets(str(record.msg))
|
||||||
|
args = record.args
|
||||||
|
if args:
|
||||||
|
if isinstance(args, tuple):
|
||||||
|
record.args = tuple(
|
||||||
|
redact_secrets(arg) if isinstance(arg, str) else arg for arg in args
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
record.args = {
|
||||||
|
key: redact_secrets(value) if isinstance(value, str) else value
|
||||||
|
for key, value in args.items()
|
||||||
|
}
|
||||||
|
return super().format(record)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: str = "INFO") -> None:
|
||||||
|
"""Configure la journalisation globale avec masquage des secrets.
|
||||||
|
|
||||||
|
Les gestionnaires existants du logger racine sont supprimés, puis un
|
||||||
|
gestionnaire unique écrivant sur ``sys.stdout`` est installé. Les loggers
|
||||||
|
tiers ``urllib3`` et ``slixmpp`` sont ramenés au niveau ``WARNING``.
|
||||||
|
|
||||||
|
:param level: Nom du niveau de log (ex: ``"DEBUG"``, ``"INFO"``) ;
|
||||||
|
les noms inconnus sont ignorés au profit de ``INFO``.
|
||||||
|
:rtype: None
|
||||||
|
"""
|
||||||
|
numeric_level: int = logging.getLevelNamesMapping().get(level.upper(), logging.INFO)
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(RedactingFormatter(fmt=_LOG_FORMAT, datefmt=_DATE_FORMAT))
|
||||||
|
root = logging.getLogger()
|
||||||
|
for existing in list(root.handlers):
|
||||||
|
root.removeHandler(existing)
|
||||||
|
root.setLevel(numeric_level)
|
||||||
|
root.addHandler(handler)
|
||||||
|
for noisy_name in ("urllib3", "slixmpp"):
|
||||||
|
logging.getLogger(noisy_name).setLevel(logging.WARNING)
|
||||||
65
pronote_sync/utils/redaction.py
Normal file
65
pronote_sync/utils/redaction.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Utilitaires de masquage des secrets dans les URLs, textes et exceptions.
|
||||||
|
|
||||||
|
Ce module centralise la rédaction des données sensibles (tokens, mots de
|
||||||
|
passe, clés d'accès) afin qu'aucun secret ne soit exposé dans les logs,
|
||||||
|
les messages d'erreur ou les traces du pipeline ``pronote-sync``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||||
|
|
||||||
|
_SENSITIVE_QUERY_KEYS = frozenset({"icalsecurise", "token", "key", "password", "secret"})
|
||||||
|
_URL_PATTERN = re.compile(r"https?://[^\s]+")
|
||||||
|
_ISOLATED_SECRET_PATTERN = re.compile(
|
||||||
|
r"\b(icalsecurise|token|password|secret|key)\s*=\s*[^\s&]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_REDACTED = "REDACTED"
|
||||||
|
_REDACTED_URL = "REDACTED_URL"
|
||||||
|
|
||||||
|
|
||||||
|
def redact_url(url: str) -> str:
|
||||||
|
"""Masque les paramètres sensibles dans une URL.
|
||||||
|
|
||||||
|
:param url: URL pouvant contenir des paramètres sensibles (ex: ``icalsecurise``).
|
||||||
|
:return: URL avec les paramètres sensibles remplacés par ``REDACTED``,
|
||||||
|
ou ``REDACTED_URL`` si le traitement échoue.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
query: list[tuple[str, str]] = parse_qsl(parts.query, keep_blank_values=True)
|
||||||
|
redacted_query = [
|
||||||
|
(key, _REDACTED if key.lower() in _SENSITIVE_QUERY_KEYS else value)
|
||||||
|
for key, value in query
|
||||||
|
]
|
||||||
|
return urlunsplit(parts._replace(query=urlencode(redacted_query, doseq=True)))
|
||||||
|
except Exception:
|
||||||
|
return _REDACTED_URL
|
||||||
|
|
||||||
|
|
||||||
|
def redact_secrets(text: str) -> str:
|
||||||
|
"""Masque les secrets présents dans un texte arbitraire.
|
||||||
|
|
||||||
|
Les URLs sont d'abord traitées par :func:`redact_url`, puis les affectations
|
||||||
|
isolées de type ``cle=valeur`` (ex: ``icalsecurise=XXX``) sont masquées,
|
||||||
|
sans distinction de casse.
|
||||||
|
|
||||||
|
:param text: Texte pouvant contenir des URLs ou des secrets en clair.
|
||||||
|
:return: Texte avec les secrets remplacés par ``REDACTED``.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
redacted = _URL_PATTERN.sub(lambda match: redact_url(match.group(0)), text)
|
||||||
|
return _ISOLATED_SECRET_PATTERN.sub(r"\1=REDACTED", redacted)
|
||||||
|
|
||||||
|
|
||||||
|
def redact_exception(exc: Exception) -> str:
|
||||||
|
"""Masque les secrets dans la représentation textuelle d'une exception.
|
||||||
|
|
||||||
|
:param exc: Exception dont le message doit être rédigé.
|
||||||
|
:return: Représentation textuelle de l'exception avec les secrets masqués.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
return redact_secrets(str(exc))
|
||||||
67
pronote_sync/utils/uid.py
Normal file
67
pronote_sync/utils/uid.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Utilitaires de gestion des identifiants uniques (UID) des événements.
|
||||||
|
|
||||||
|
Ce module fournit la normalisation des UIDs Pronote (suppression des
|
||||||
|
suffixes temporels) et la génération d'UIDs déterministes par hachage
|
||||||
|
des champs clés d'un événement, garantissant l'idempotence de la
|
||||||
|
synchronisation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
_TEMPORAL_SUFFIX_PATTERN = re.compile(r"-\d{8}T\d{6}Z-Index-Education$")
|
||||||
|
_EDUCATION_SUFFIX_PATTERN = re.compile(r"-Index-Education$")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_pronote_uid(uid: str) -> str:
|
||||||
|
"""Normalise un UID Pronote en supprimant ses suffixes temporels.
|
||||||
|
|
||||||
|
Les suffixes de type ``-AAAAMMJJTHHMMSSZ-Index-Education`` puis
|
||||||
|
``-Index-Education`` sont retirés. La fonction est idempotente :
|
||||||
|
appliquée à un UID déjà normalisé, elle retourne la même valeur.
|
||||||
|
|
||||||
|
:param uid: UID brut provenant de Pronote (ex: ``L-1234-20250901T080000Z-Index-Education``).
|
||||||
|
:return: UID normalisé, sans suffixe temporel ni marque ``Index-Education``.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
normalized = _TEMPORAL_SUFFIX_PATTERN.sub("", uid)
|
||||||
|
return _EDUCATION_SUFFIX_PATTERN.sub("", normalized)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_deterministic_uid(
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
subject: str,
|
||||||
|
teachers: list[str],
|
||||||
|
rooms: list[str],
|
||||||
|
group: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Génère un UID déterministe par hachage des champs clés d'un événement.
|
||||||
|
|
||||||
|
Utilisé lorsqu'aucun UID exploitable n'est disponible : deux appels avec
|
||||||
|
des champs identiques produisent le même identifiant, ce qui garantit
|
||||||
|
l'idempotence de la synchronisation.
|
||||||
|
|
||||||
|
:param start: Début de l'événement.
|
||||||
|
:param end: Fin de l'événement.
|
||||||
|
:param subject: Intitulé de la matière.
|
||||||
|
:param teachers: Liste des enseignants (triée avant hachage).
|
||||||
|
:param rooms: Liste des salles (triée avant hachage).
|
||||||
|
:param group: Groupe éventuel ; traité comme chaîne vide si absent.
|
||||||
|
:return: Identifiant déterministe : 12 premiers caractères hexadécimaux
|
||||||
|
du SHA-1 des champs clés joints par ``|``.
|
||||||
|
:rtype: str
|
||||||
|
"""
|
||||||
|
parts = [
|
||||||
|
start.isoformat(),
|
||||||
|
end.isoformat(),
|
||||||
|
subject,
|
||||||
|
",".join(sorted(teachers)),
|
||||||
|
",".join(sorted(rooms)),
|
||||||
|
group or "",
|
||||||
|
]
|
||||||
|
payload = "|".join(parts).encode("utf-8")
|
||||||
|
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
|
||||||
Reference in New Issue
Block a user