Download models.py from JuloEco/Opsiom: direct link, hf CLI and curl.
- Browser
- Download file 22.1 kB
-
https://huggingface.co/spaces/JuloEco/Opsiom/resolve/main/models.py
- Command line
-
hf download hf://spaces/JuloEco/Opsiom/models.py
-
curl -L -o models.py https://huggingface.co/spaces/JuloEco/Opsiom/resolve/main/models.py
22.1 kB
| """ | |
| Modèles de données — comptes Opsiom, forfaits et progression. | |
| Cette base locale à Opsiom ne stocke JAMAIS de mot de passe (voir auth.py, | |
| authentification déléguée à Octix). Elle sert à trois choses : | |
| 1. Le quota de tokens/jour, désormais dépendant du FORFAIT du compte | |
| (voir plans.py) et appliqué ici — Octix reste interrogé en best-effort | |
| pour rester synchronisé avec le CLI, mais l'application de la limite | |
| par forfait vit ici, car Octix ne connaît pas la notion de forfait. | |
| 2. Le forfait courant (free / plus / pro) et le statut Lab. | |
| 3. Le cache de progression (missions LearnCode/Classroom/Omnia Mind), | |
| recalculé toutes les PROGRESSION_CACHE_TTL (voir progression.py) pour | |
| éviter d'interroger trois bases externes à chaque page vue. | |
| 4. Les codes de promo (tokens en plus + accès temporaire à des modèles | |
| supplémentaires, créés depuis l'espace admin — voir promo.py) et leur | |
| utilisation par compte. | |
| Administrateur : un pseudo listé dans plans.ADMIN_USERNAMES (par défaut | |
| "Jules") n'a aucune limite de tokens ni de modèles. | |
| Quotas par personne et bêta-testeurs (voir QuotaOverride, BetaGrant et | |
| /admin/acces) : l'admin peut attribuer un quota à un pseudo ; les bêta-testeurs | |
| (tirés au sort chaque mois, ou permanents via la colonne `lab`) n'ont aucune | |
| limite et accèdent aux modèles en avant-première. | |
| """ | |
| import json | |
| from datetime import date, datetime | |
| from flask_sqlalchemy import SQLAlchemy | |
| from plans import ( | |
| ADMIN_USERNAMES, DEFAULT_PLAN, DEFAULT_PREVIEW_MODELS, MODEL_DISPLAY, UNLIMITED_REMAINING, plan_config, | |
| ) | |
| from dbprefix import t as T | |
| db = SQLAlchemy() | |
| class User(db.Model): | |
| """Ombre locale d'un compte Octix : pseudo + forfait + quota + progression. | |
| Aucun mot de passe n'est stocké ici : l'authentification est déléguée à | |
| Octix (voir auth.py). Cette table associe un pseudo Octix à son forfait | |
| Opsiom et à son compteur de tokens/jour. | |
| """ | |
| __tablename__ = T("users") | |
| id = db.Column(db.Integer, primary_key=True) | |
| username = db.Column(db.String(32), unique=True, nullable=False, index=True) | |
| email = db.Column(db.String(255), nullable=True) | |
| created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) | |
| # --- Forfait ----------------------------------------------------- | |
| plan = db.Column(db.String(16), default=DEFAULT_PLAN, nullable=False) | |
| plan_updated_at = db.Column(db.DateTime, nullable=True) | |
| # Statut Lab : séparé des forfaits classiques (voir plans.py). Pour | |
| # l'instant attribué uniquement à la main (pas de critère automatique). | |
| lab = db.Column(db.Boolean, default=False, nullable=False) | |
| # --- Quota de tokens, dépendant du forfait ------------------------ | |
| tokens_used_today = db.Column(db.Integer, default=0, nullable=False) | |
| quota_date = db.Column(db.Date, default=date.today, nullable=False) | |
| # --- Suivi "jours actifs" / "conversations" pour les missions Opsiom | |
| # (voir plans.py: opsiom.active_days / opsiom.conversations). | |
| # active_days_json = liste JSON des dates ISO (YYYY-MM-DD) où le compte | |
| # a envoyé au moins un message. Suffisant pour compter les jours | |
| # distincts sans repasser par une table séparée. | |
| active_days_json = db.Column(db.Text, default="[]", nullable=False) | |
| conversations_total = db.Column(db.Integer, default=0, nullable=False) | |
| # --- Cache de progression (missions LearnCode/Classroom/Omnia Mind) -- | |
| progression_json = db.Column(db.Text, nullable=True) | |
| progression_updated_at = db.Column(db.DateTime, nullable=True) | |
| # ------------------------------------------------------------------ | |
| # Quota | |
| # ------------------------------------------------------------------ | |
| def _reset_quota_if_needed(self): | |
| """Remet le compteur à zéro si on a changé de jour depuis le dernier appel.""" | |
| if self.quota_date != date.today(): | |
| self.quota_date = date.today() | |
| self.tokens_used_today = 0 | |
| # ------------------------------------------------------------------ | |
| # Administrateur & codes de promo | |
| # ------------------------------------------------------------------ | |
| def is_admin(self) -> bool: | |
| return self.username in ADMIN_USERNAMES | |
| def active_grants(self) -> list["PromoRedemption"]: | |
| """Codes de promo utilisés par ce compte et encore en cours de validité | |
| (les avantages sont TEMPORAIRES : ils disparaissent à `expires_at`).""" | |
| if self.id is None: | |
| return [] | |
| return ( | |
| PromoRedemption.query | |
| .filter(PromoRedemption.user_id == self.id, PromoRedemption.expires_at > datetime.utcnow()) | |
| .order_by(PromoRedemption.expires_at) | |
| .all() | |
| ) | |
| def bonus_tokens(self) -> int: | |
| """Tokens/jour supplémentaires apportés par les codes actifs.""" | |
| return sum(g.promo.bonus_tokens for g in self.active_grants()) | |
| def bonus_models(self) -> list[str]: | |
| extra: list[str] = [] | |
| for g in self.active_grants(): | |
| for model_id in g.promo.models: | |
| if model_id not in extra: | |
| extra.append(model_id) | |
| return extra | |
| # ------------------------------------------------------------------ | |
| # Quota attribué à la personne (admin) & statut bêta-testeur | |
| # ------------------------------------------------------------------ | |
| def override(self) -> "QuotaOverride | None": | |
| """Quota attribué à la main à CE compte (voir /admin/acces), s'il est actif.""" | |
| ov = QuotaOverride.query.filter_by(username=self.username).first() | |
| return ov if ov is not None and ov.is_active() else None | |
| def is_beta(self) -> bool: | |
| """Bêta-testeur : statut permanent (colonne `lab`, attribué à la main) OU | |
| tiré au sort / ajouté pour le mois en cours (table BetaGrant).""" | |
| if self.lab: | |
| return True | |
| return BetaGrant.query.filter_by(username=self.username, month=current_month()).first() is not None | |
| def unlimited_reason(self) -> str | None: | |
| """Pourquoi ce compte n'a aucune limite (None = il en a une).""" | |
| if self.is_admin: | |
| return "admin" | |
| if self.is_beta: | |
| return "beta" | |
| ov = self.override | |
| if ov is not None and ov.unlimited: | |
| return "custom" | |
| return None | |
| def is_unlimited(self) -> bool: | |
| return self.unlimited_reason is not None | |
| def base_daily_tokens(self) -> int: | |
| """Quota de base : celui attribué à la personne s'il existe, sinon celui du forfait.""" | |
| ov = self.override | |
| if ov is not None and ov.daily_tokens is not None: | |
| return ov.daily_tokens | |
| return plan_config(self.plan)["daily_tokens"] | |
| def daily_limit(self) -> int: | |
| return self.base_daily_tokens + self.bonus_tokens | |
| def allowed_models(self) -> list[str]: | |
| # Admin et bêta-testeurs : tous les modèles, y compris ceux en avant-première. | |
| if self.is_admin or self.is_beta: | |
| return list(MODEL_DISPLAY) | |
| preview = set(get_preview_models()) | |
| allowed = [m for m in plan_config(self.plan)["models"] if m not in preview] | |
| ov = self.override | |
| if ov is not None: | |
| for model_id in ov.models: | |
| if model_id not in allowed and model_id not in preview: | |
| allowed.append(model_id) | |
| # Un code de promo, créé par l'admin, peut en revanche ouvrir un modèle en avant-première. | |
| for model_id in self.bonus_models: | |
| if model_id not in allowed: | |
| allowed.append(model_id) | |
| return allowed | |
| def quota_status(self) -> dict: | |
| self._reset_quota_if_needed() | |
| reason = self.unlimited_reason | |
| if reason: | |
| return { | |
| "used": self.tokens_used_today, | |
| "limit": 0, | |
| "remaining": UNLIMITED_REMAINING, | |
| "plan": self.plan, | |
| "unlimited": True, | |
| "unlimited_reason": reason, | |
| "bonus": 0, | |
| } | |
| bonus = self.bonus_tokens | |
| limit = self.base_daily_tokens + bonus | |
| return { | |
| "used": self.tokens_used_today, | |
| "limit": limit, | |
| "remaining": max(0, limit - self.tokens_used_today), | |
| "plan": self.plan, | |
| "unlimited": False, | |
| "bonus": bonus, | |
| "custom": self.override is not None and self.override.daily_tokens is not None, | |
| } | |
| def can_send_message(self) -> bool: | |
| if self.is_unlimited: | |
| return True | |
| self._reset_quota_if_needed() | |
| return self.tokens_used_today < self.daily_limit | |
| def register_message_sent(self, tokens: int = 1): | |
| """À appeler une fois la réponse obtenue avec succès (pas avant), | |
| pour ne jamais décompter un message qui a échoué côté serveur. | |
| Met aussi à jour les compteurs utilisés par les missions Opsiom | |
| (jours actifs, nombre de conversations).""" | |
| self._reset_quota_if_needed() | |
| self.tokens_used_today += max(0, int(tokens)) | |
| today_iso = date.today().isoformat() | |
| try: | |
| days = set(json.loads(self.active_days_json or "[]")) | |
| except (ValueError, TypeError): | |
| days = set() | |
| days.add(today_iso) | |
| self.active_days_json = json.dumps(sorted(days)[-90:]) # 90 derniers jours suffisent | |
| self.conversations_total += 1 | |
| db.session.commit() | |
| # ------------------------------------------------------------------ | |
| # Stats Opsiom utilisées par progression.py pour les missions | |
| # ------------------------------------------------------------------ | |
| def opsiom_mission_stats(self) -> dict: | |
| try: | |
| days = json.loads(self.active_days_json or "[]") | |
| except (ValueError, TypeError): | |
| days = [] | |
| return { | |
| "active_days": len(days), | |
| "conversations": self.conversations_total, | |
| } | |
| # ------------------------------------------------------------------ | |
| # Progression (cache) | |
| # ------------------------------------------------------------------ | |
| def cached_progression(self): | |
| """Renvoie (raw_stats, is_fresh) à partir du cache local, ou | |
| (None, False) si rien n'est encore en cache.""" | |
| if not self.progression_json or not self.progression_updated_at: | |
| return None, False | |
| from progression import PROGRESSION_CACHE_TTL | |
| fresh = (datetime.utcnow() - self.progression_updated_at) < PROGRESSION_CACHE_TTL | |
| try: | |
| return json.loads(self.progression_json), fresh | |
| except (ValueError, TypeError): | |
| return None, False | |
| def store_progression(self, raw_stats: dict): | |
| self.progression_json = json.dumps(raw_stats) | |
| self.progression_updated_at = datetime.utcnow() | |
| db.session.commit() | |
| def __repr__(self): | |
| return f"<User {self.username} plan={self.plan}>" | |
| class PromoCode(db.Model): | |
| """Code de promo créé par un administrateur (voir promo.py). | |
| Un code donne, à qui l'utilise, des tokens/jour EN PLUS et/ou l'accès à des | |
| modèles supplémentaires, pendant `duration_hours` (24 h par défaut) à | |
| partir de l'utilisation. Rien n'est définitif : à l'expiration, le compte | |
| retrouve exactement les limites de son forfait. | |
| """ | |
| __tablename__ = T("promo_codes") | |
| id = db.Column(db.Integer, primary_key=True) | |
| code = db.Column(db.String(40), unique=True, nullable=False, index=True) | |
| note = db.Column(db.String(120), nullable=True) # mémo libre, visible seulement de l'admin | |
| bonus_tokens = db.Column(db.Integer, default=0, nullable=False) | |
| models_json = db.Column(db.Text, default="[]", nullable=False) # ex: ["large"] | |
| duration_hours = db.Column(db.Integer, default=24, nullable=False) | |
| max_uses = db.Column(db.Integer, nullable=True) # None = illimité | |
| valid_until = db.Column(db.DateTime, nullable=True) # date limite pour UTILISER le code | |
| active = db.Column(db.Boolean, default=True, nullable=False) | |
| created_by = db.Column(db.String(32), nullable=False) | |
| created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) | |
| redemptions = db.relationship( | |
| "PromoRedemption", backref="promo", cascade="all, delete-orphan", lazy="dynamic", | |
| ) | |
| def models(self) -> list[str]: | |
| try: | |
| value = json.loads(self.models_json or "[]") | |
| except (ValueError, TypeError): | |
| return [] | |
| return [m for m in value if m in MODEL_DISPLAY] if isinstance(value, list) else [] | |
| def uses_count(self) -> int: | |
| return self.redemptions.count() | |
| def redeemable_error(self) -> str | None: | |
| """None si le code peut encore être utilisé, sinon la raison (en français).""" | |
| if not self.active: | |
| return "Ce code n'est plus actif." | |
| if self.valid_until and datetime.utcnow() > self.valid_until: | |
| return "Ce code a expiré." | |
| if self.max_uses is not None and self.uses_count >= self.max_uses: | |
| return "Ce code a atteint son nombre maximal d'utilisations." | |
| return None | |
| class PromoRedemption(db.Model): | |
| """Utilisation d'un code de promo par un compte (une fois par compte et par code).""" | |
| __tablename__ = T("promo_redemptions") | |
| __table_args__ = (db.UniqueConstraint("user_id", "promo_id", name="uq_promo_user"),) | |
| id = db.Column(db.Integer, primary_key=True) | |
| user_id = db.Column(db.Integer, db.ForeignKey(T("users") + ".id"), nullable=False, index=True) | |
| promo_id = db.Column(db.Integer, db.ForeignKey(T("promo_codes") + ".id"), nullable=False, index=True) | |
| redeemed_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) | |
| expires_at = db.Column(db.DateTime, nullable=False) | |
| user = db.relationship("User") | |
| # --------------------------------------------------------------------------- | |
| # Quotas par personne, bêta-testeurs du mois, réglages admin. | |
| # Ces tables sont indexées par PSEUDO Octix (et non par User.id) : l'admin peut | |
| # ainsi attribuer un quota — ou le tirage peut désigner quelqu'un — qui ne s'est | |
| # pas encore connecté à Opsiom. Nouvelles tables : db.create_all() les crée sans | |
| # toucher aux bases existantes. | |
| # --------------------------------------------------------------------------- | |
| def current_month() -> str: | |
| """Mois en cours, 'AAAA-MM' (heure serveur, comme le reste des quotas).""" | |
| return date.today().strftime("%Y-%m") | |
| class QuotaOverride(db.Model): | |
| """Quota de tokens/jour attribué à une personne précise par l'admin. | |
| - daily_tokens : remplace le quota du forfait (None = on garde celui du forfait) ; | |
| - unlimited : aucune limite de tokens ; | |
| - models_json : modèles supplémentaires accessibles à cette personne ; | |
| - expires_at : date de fin (None = permanent) ; ensuite le forfait reprend. | |
| """ | |
| __tablename__ = T("quota_overrides") | |
| id = db.Column(db.Integer, primary_key=True) | |
| username = db.Column(db.String(32), unique=True, nullable=False, index=True) | |
| daily_tokens = db.Column(db.Integer, nullable=True) | |
| unlimited = db.Column(db.Boolean, default=False, nullable=False) | |
| models_json = db.Column(db.Text, default="[]", nullable=False) | |
| note = db.Column(db.String(120), nullable=True) | |
| expires_at = db.Column(db.DateTime, nullable=True) | |
| set_by = db.Column(db.String(32), nullable=False) | |
| updated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) | |
| def models(self) -> list[str]: | |
| try: | |
| value = json.loads(self.models_json or "[]") | |
| except (ValueError, TypeError): | |
| return [] | |
| return [m for m in value if m in MODEL_DISPLAY] if isinstance(value, list) else [] | |
| def is_active(self) -> bool: | |
| return self.expires_at is None or self.expires_at > datetime.utcnow() | |
| class BetaGrant(db.Model): | |
| """Un bêta-testeur pour un mois donné : modèles sans aucune limite et accès | |
| en avant-première. `source` = 'random' (tirage mensuel) ou 'manual' (admin).""" | |
| __tablename__ = T("beta_grants") | |
| __table_args__ = (db.UniqueConstraint("username", "month", name="uq_beta_user_month"),) | |
| id = db.Column(db.Integer, primary_key=True) | |
| username = db.Column(db.String(32), nullable=False, index=True) | |
| month = db.Column(db.String(7), nullable=False, index=True) # 'AAAA-MM' | |
| source = db.Column(db.String(12), default="random", nullable=False) | |
| created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) | |
| class AppSetting(db.Model): | |
| """Réglages modifiables depuis l'espace admin (clé -> valeur texte/JSON).""" | |
| __tablename__ = T("app_settings") | |
| key = db.Column(db.String(40), primary_key=True) | |
| value = db.Column(db.Text, nullable=False, default="") | |
| def get_setting(key: str, default=None): | |
| row = db.session.get(AppSetting, key) | |
| return row.value if row is not None else default | |
| def set_setting(key: str, value: str) -> None: | |
| row = db.session.get(AppSetting, key) | |
| if row is None: | |
| db.session.add(AppSetting(key=key, value=value)) | |
| else: | |
| row.value = value | |
| db.session.commit() | |
| def get_preview_models() -> list[str]: | |
| """Modèles en avant-première (réservés aux bêta-testeurs), choisis par l'admin.""" | |
| raw = get_setting("preview_models") | |
| if raw is None: | |
| return list(DEFAULT_PREVIEW_MODELS) | |
| try: | |
| value = json.loads(raw) | |
| except (ValueError, TypeError): | |
| return list(DEFAULT_PREVIEW_MODELS) | |
| return [m for m in value if m in MODEL_DISPLAY] if isinstance(value, list) else list(DEFAULT_PREVIEW_MODELS) | |
| class UsageEvent(db.Model): | |
| """Une tentative d'envoi de message (réussie, en erreur ou refusée par le quota), | |
| pour les statistiques de /admin/stats. Conservée OPSIOM_STATS_RETENTION_DAYS jours | |
| (30 par défaut) et bornée en nombre de lignes : voir stats.py.""" | |
| __tablename__ = T("usage_events") | |
| id = db.Column(db.Integer, primary_key=True) | |
| created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) | |
| username = db.Column(db.String(32), nullable=False, index=True) | |
| model_id = db.Column(db.String(32), nullable=True) | |
| status = db.Column(db.String(8), nullable=False, default="ok") # ok | error | quota | |
| tokens = db.Column(db.Integer, default=0, nullable=False) | |
| duration_ms = db.Column(db.Integer, nullable=True) # requête -> fin de la réponse | |
| ttft_ms = db.Column(db.Integer, nullable=True) # requête -> premier token (flux) | |
| used_after = db.Column(db.Integer, default=0, nullable=False) # tokens du jour après ce message | |
| quota_limit = db.Column(db.Integer, default=0, nullable=False) # limite du jour (0 si illimité) | |
| unlimited = db.Column(db.Boolean, default=False, nullable=False) | |
| beta = db.Column(db.Boolean, default=False, nullable=False) | |
| class BetaNotification(db.Model): | |
| """E-mail « tu es bêta-testeur » envoyé (ou non) à une personne pour un mois.""" | |
| __tablename__ = T("beta_notifications") | |
| __table_args__ = (db.UniqueConstraint("username", "month", name="uq_betanotif_user_month"),) | |
| id = db.Column(db.Integer, primary_key=True) | |
| username = db.Column(db.String(32), nullable=False, index=True) | |
| month = db.Column(db.String(7), nullable=False, index=True) # 'AAAA-MM' ou 'permanent' | |
| status = db.Column(db.String(12), nullable=False) # sent | failed | no_email | |
| detail = db.Column(db.String(200), nullable=True) | |
| created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) | |
| # Limites de l'historique (par compte) : évitent qu'une base ou un instantané | |
| # de sauvegarde grossisse sans fin. | |
| MAX_CONVERSATIONS_PER_USER = 50 | |
| MAX_MESSAGES_PER_CONVERSATION = 200 | |
| class Conversation(db.Model): | |
| """Une conversation sauvegardée dans l'historique d'un compte. | |
| Chaque compte ne voit que les siennes (toutes les routes filtrent sur | |
| user_id) et peut les supprimer à tout moment (voir app.py, /api/conversations). | |
| """ | |
| __tablename__ = T("conversations") | |
| id = db.Column(db.Integer, primary_key=True) | |
| user_id = db.Column(db.Integer, db.ForeignKey(T("users") + ".id"), nullable=False, index=True) | |
| title = db.Column(db.String(80), default="Nouvelle conversation", nullable=False) | |
| created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) | |
| updated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) | |
| messages = db.relationship( | |
| "ChatMessage", backref="conversation", cascade="all, delete-orphan", | |
| order_by="ChatMessage.id", lazy="dynamic", | |
| ) | |
| def summary(self) -> dict: | |
| return { | |
| "id": self.id, | |
| "title": self.title, | |
| "updated_at": self.updated_at.isoformat() + "Z", | |
| } | |
| class ChatMessage(db.Model): | |
| """Un message (utilisateur ou assistant) d'une conversation sauvegardée.""" | |
| __tablename__ = T("chat_messages") | |
| id = db.Column(db.Integer, primary_key=True) | |
| conversation_id = db.Column(db.Integer, db.ForeignKey(T("conversations") + ".id"), nullable=False, index=True) | |
| role = db.Column(db.String(12), nullable=False) # "user" | "assistant" | |
| content = db.Column(db.Text, nullable=False) | |
| model_id = db.Column(db.String(32), nullable=True) # modèle qui a répondu (assistant) | |
| created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) | |
| def get_or_create_user(username: str) -> User: | |
| user = User.query.filter_by(username=username).first() | |
| if user is None: | |
| user = User(username=username) | |
| db.session.add(user) | |
| db.session.commit() | |
| return user | |