Files
Enclume/enclume/db.py
T
hcornet b063d59525
build / Garde-fou (pull_request) Failing after 7s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (pull_request) Skipped
build / Images Harbor (web, Dockerfile) (pull_request) Skipped
build / Garde-fou (push) Failing after 8s
build / Images Harbor (catalog-sync, Dockerfile.catalog-sync) (push) Skipped
build / Images Harbor (web, Dockerfile) (push) Skipped
Partage par lien, comptes Authentik, soumission et moderation de packs, prerequis des sondes ; corrige le lien casse vers le script de l'editeur
2026-09-10 16:28:26 +02:00

175 lines
6.3 KiB
Python

"""Persistance : comptes, projets enregistres, partages, soumissions de packs.
Toutes les tables sont facultatives : sans ENCLUME_DATABASE_URL, rien de tout
cela n'est instancie et l'application fonctionne en mode sans etat.
Regle de migration : on ajoute, on ne retire jamais dans la meme version que le
code qui cesse d'utiliser une colonne. La suppression arrive une version plus
tard, quand le retour arriere n'est plus envisage.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
def maintenant() -> datetime:
return datetime.now(timezone.utc)
def _identifiant_court() -> str:
"""12 caracteres d'alphabet URL, soit 72 bits : non enumerable."""
return secrets.token_urlsafe(9)
class Utilisateur(db.Model):
__tablename__ = "utilisateurs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Identifiant stable du fournisseur OIDC (revendication "sub").
sujet: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(320), default="")
nom: Mapped[str] = mapped_column(String(255), default="")
administrateur: Mapped[bool] = mapped_column(Boolean, default=False)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
vu_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
projets: Mapped[list["Projet"]] = relationship(back_populates="proprietaire", cascade="all, delete-orphan")
@property
def affichage(self) -> str:
return self.nom or self.email or f"compte {self.id}"
class Projet(db.Model):
__tablename__ = "projets"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
utilisateur_id: Mapped[int] = mapped_column(ForeignKey("utilisateurs.id"), nullable=False)
nom: Mapped[str] = mapped_column(String(255), default="Projet sans nom")
contenu: Mapped[str] = mapped_column(Text, nullable=False)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
maj_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant, onupdate=maintenant)
proprietaire: Mapped[Utilisateur] = relationship(back_populates="projets")
__table_args__ = (Index("ix_projets_utilisateur", "utilisateur_id"),)
def resume(self) -> dict:
return {
"id": self.id,
"nom": self.nom,
"maj_le": self.maj_le.isoformat() if self.maj_le else None,
}
class Partage(db.Model):
__tablename__ = "partages"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_identifiant_court)
contenu: Mapped[str] = mapped_column(Text, nullable=False)
nom: Mapped[str] = mapped_column(String(255), default="")
utilisateur_id: Mapped[int | None] = mapped_column(ForeignKey("utilisateurs.id"), nullable=True)
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
expire_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
vues: Mapped[int] = mapped_column(Integer, default=0)
@classmethod
def creer(cls, contenu: str, nom: str, jours: int, utilisateur_id: int | None = None) -> "Partage":
return cls(
contenu=contenu,
nom=nom[:255],
utilisateur_id=utilisateur_id,
expire_le=maintenant() + timedelta(days=jours),
)
@property
def expire(self) -> bool:
limite = self.expire_le
if limite.tzinfo is None: # SQLite rend des datetimes naifs
limite = limite.replace(tzinfo=timezone.utc)
return limite < maintenant()
class Soumission(db.Model):
__tablename__ = "soumissions"
EN_ATTENTE = "en_attente"
PUBLIEE = "publiee"
REFUSEE = "refusee"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
utilisateur_id: Mapped[int] = mapped_column(ForeignKey("utilisateurs.id"), nullable=False)
pack_id: Mapped[str] = mapped_column(String(64), nullable=False)
categorie: Mapped[str] = mapped_column(String(32), nullable=False)
nom: Mapped[str] = mapped_column(String(255), default="")
contenu: Mapped[str] = mapped_column(Text, nullable=False) # le YAML soumis
message: Mapped[str] = mapped_column(Text, default="") # mot de l'auteur
statut: Mapped[str] = mapped_column(String(16), default=EN_ATTENTE)
reponse: Mapped[str] = mapped_column(Text, default="") # motif du refus
url_pr: Mapped[str] = mapped_column(String(512), default="")
cree_le: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=maintenant)
traite_le: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
auteur: Mapped[Utilisateur] = relationship()
__table_args__ = (Index("ix_soumissions_statut", "statut"),)
def resume(self, avec_auteur: bool = False) -> dict:
donnees = {
"id": self.id,
"pack_id": self.pack_id,
"categorie": self.categorie,
"nom": self.nom,
"statut": self.statut,
"message": self.message,
"reponse": self.reponse,
"url_pr": self.url_pr,
"cree_le": self.cree_le.isoformat() if self.cree_le else None,
}
if avec_auteur and self.auteur is not None:
donnees["auteur"] = self.auteur.affichage
return donnees
def compter_soumissions_du_jour(utilisateur_id: int) -> int:
depuis = maintenant() - timedelta(days=1)
return (
db.session.query(func.count(Soumission.id))
.filter(Soumission.utilisateur_id == utilisateur_id, Soumission.cree_le >= depuis)
.scalar()
or 0
)
def purger_partages_expires() -> int:
"""Menage opportuniste : appele a la creation d'un partage."""
supprimes = (
db.session.query(Partage)
.filter(Partage.expire_le < maintenant())
.delete(synchronize_session=False)
)
db.session.commit()
return supprimes or 0