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
175 lines
6.3 KiB
Python
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
|