Initial commit: RSS platform phase 1 skeleton with code review fixes
Features: - FastAPI + SQLAlchemy 2.0 async + PostgreSQL/pgvector + Redis backend - Vue 3 + TypeScript + Element Plus frontend - JWT auth with access/refresh tokens and revocation - Admin/member RBAC - RSS feed CRUD and article listing - Settings management with Fernet encryption for sensitive values - Redis distributed lock service - Alembic initial migration - Docker Compose development environment Fixes from code review: - Fix DB session leak in dependency injection - Restrict registration to admin only - Add default admin password warning - Implement JWT refresh tokens and jti blacklist - Strengthen password policy - Use func.count for pagination totals - Replace NullPool with AsyncAdaptedQueuePool - Remove init_db from lifespan to enforce alembic migrations - Add request_id middleware and logging filter - Fix vite.config.ts env loading - Add frontend token refresh interceptor - Add Vue error handler Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
"""Models package."""
|
||||
from app.models.ai_config import AIProviderConfig, AITaskConfig
|
||||
from app.models.article import CleanedArticle, RawArticle
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.feed import Feed
|
||||
from app.models.lock import Lock
|
||||
from app.models.output import Output, OutputTask
|
||||
from app.models.reference import ArticleReference, DuplicateGroup
|
||||
from app.models.setting import AppSetting
|
||||
from app.models.skill import Skill
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"TimestampMixin",
|
||||
"UUIDMixin",
|
||||
"utc_now",
|
||||
"User",
|
||||
"Feed",
|
||||
"RawArticle",
|
||||
"CleanedArticle",
|
||||
"ArticleReference",
|
||||
"DuplicateGroup",
|
||||
"Skill",
|
||||
"AIProviderConfig",
|
||||
"AITaskConfig",
|
||||
"OutputTask",
|
||||
"Output",
|
||||
"ChatSession",
|
||||
"ChatMessage",
|
||||
"Lock",
|
||||
"AppSetting",
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
"""AI configuration models."""
|
||||
from sqlalchemy import Boolean, Float, ForeignKey, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class AIProviderConfig(Base, UUIDMixin, TimestampMixin):
|
||||
"""AI provider configuration (OpenAI, Anthropic, etc.)."""
|
||||
|
||||
__tablename__ = "ai_provider_configs"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
provider: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
base_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
api_key_encrypted: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
default_model: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
timeout: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
|
||||
max_retries: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
|
||||
rate_limit_rpm: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
||||
|
||||
class AITaskConfig(Base, UUIDMixin, TimestampMixin):
|
||||
"""AI task configuration (which model/skill for which task)."""
|
||||
|
||||
__tablename__ = "ai_task_configs"
|
||||
|
||||
task_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
provider_config_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("ai_provider_configs.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
model: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
skill_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("skills.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
temperature: Mapped[float] = mapped_column(Float, default=0.3, nullable=False)
|
||||
max_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
top_p: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
|
||||
system_prompt_override: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
fallback_config_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("ai_task_configs.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Article models: raw and cleaned."""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class RawArticle(Base, UUIDMixin, TimestampMixin):
|
||||
"""Raw article fetched from RSS feed."""
|
||||
|
||||
__tablename__ = "raw_articles"
|
||||
|
||||
feed_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("feeds.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
external_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
title: Mapped[str | None] = mapped_column(String(1024), default="", index=True)
|
||||
link: Mapped[str] = mapped_column(String(2048), nullable=False, index=True)
|
||||
author: Mapped[str | None] = mapped_column(String(256), default="")
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
fetched_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, index=True
|
||||
)
|
||||
content: Mapped[str | None] = mapped_column(Text, default="")
|
||||
summary: Mapped[str | None] = mapped_column(Text, default="")
|
||||
raw_html: Mapped[str | None] = mapped_column(Text, default="")
|
||||
content_hash: Mapped[str | None] = mapped_column(String(64), default="")
|
||||
language: Mapped[str | None] = mapped_column(String(16), default="")
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", nullable=False, index=True)
|
||||
|
||||
feed: Mapped["Feed"] = relationship("Feed", back_populates="raw_articles")
|
||||
cleaned_article: Mapped["CleanedArticle | None"] = relationship(
|
||||
"CleanedArticle", back_populates="raw_article", uselist=False
|
||||
)
|
||||
|
||||
|
||||
class CleanedArticle(Base, UUIDMixin, TimestampMixin):
|
||||
"""Cleaned and AI-enriched article."""
|
||||
|
||||
__tablename__ = "cleaned_articles"
|
||||
|
||||
raw_article_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("raw_articles.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
feed_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("feeds.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
|
||||
title: Mapped[str | None] = mapped_column(String(1024), default="", index=True)
|
||||
link: Mapped[str] = mapped_column(String(2048), default="", index=True)
|
||||
author: Mapped[str | None] = mapped_column(String(256), default="")
|
||||
feed_title: Mapped[str | None] = mapped_column(String(512), default="")
|
||||
feed_category: Mapped[str | None] = mapped_column(String(128), default="")
|
||||
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True)
|
||||
|
||||
content: Mapped[str | None] = mapped_column(Text, default="")
|
||||
content_length: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
original_summary: Mapped[str | None] = mapped_column(Text, default="")
|
||||
ai_summary: Mapped[str | None] = mapped_column(Text, default="")
|
||||
|
||||
category: Mapped[str | None] = mapped_column(String(128), default="", index=True)
|
||||
tags: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
|
||||
heat_score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
importance_score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
duplication_score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
composite_score: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
|
||||
|
||||
duplicate_group_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("duplicate_groups.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
is_representative: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
reference_links: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
processing_status: Mapped[str] = mapped_column(String(32), default="pending", nullable=False, index=True)
|
||||
|
||||
raw_article: Mapped["RawArticle | None"] = relationship("RawArticle", back_populates="cleaned_article")
|
||||
duplicate_group: Mapped["DuplicateGroup | None"] = relationship("DuplicateGroup", back_populates="articles")
|
||||
@@ -0,0 +1,45 @@
|
||||
"""SQLAlchemy 2.0 async base and session factory."""
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Adds created_at and updated_at columns."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class UUIDMixin:
|
||||
"""Adds UUID primary key."""
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid4,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
"""Return timezone-aware UTC now."""
|
||||
return datetime.now(timezone.utc)
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Chat models."""
|
||||
from sqlalchemy import ForeignKey, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class ChatSession(Base, UUIDMixin, TimestampMixin):
|
||||
"""Chat session."""
|
||||
|
||||
__tablename__ = "chat_sessions"
|
||||
|
||||
user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(String(256), default="")
|
||||
skill_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("skills.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
context_window: Mapped[int] = mapped_column(default=10, nullable=False)
|
||||
|
||||
messages: Mapped[list["ChatMessage"]] = relationship(
|
||||
"ChatMessage", back_populates="session", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ChatMessage(Base, UUIDMixin, TimestampMixin):
|
||||
"""Chat message."""
|
||||
|
||||
__tablename__ = "chat_messages"
|
||||
|
||||
session_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("chat_sessions.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(32), nullable=False, index=True) # user / assistant / tool
|
||||
content: Mapped[str | None] = mapped_column(Text, default="")
|
||||
tool_calls: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
tool_results: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
references: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
token_usage: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
session: Mapped["ChatSession"] = relationship("ChatSession", back_populates="messages")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Feed model."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Feed(Base, UUIDMixin, TimestampMixin):
|
||||
"""RSS feed source."""
|
||||
|
||||
__tablename__ = "feeds"
|
||||
|
||||
url: Mapped[str] = mapped_column(String(2048), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str | None] = mapped_column(String(512), default="")
|
||||
description: Mapped[str | None] = mapped_column(Text, default="")
|
||||
category: Mapped[str | None] = mapped_column(String(128), default="")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
fetch_interval_minutes: Mapped[int] = mapped_column(Integer, default=60, nullable=False)
|
||||
priority: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
parser_config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
proxy_policy: Mapped[str] = mapped_column(String(32), default="auto", nullable=False)
|
||||
|
||||
# Fetch statistics
|
||||
last_fetch_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_fetch_status: Mapped[str | None] = mapped_column(String(32), default="")
|
||||
last_error: Mapped[str | None] = mapped_column(Text, default="")
|
||||
error_type: Mapped[str | None] = mapped_column(String(64), default="")
|
||||
success_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
fail_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
article_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
raw_articles: Mapped[list["RawArticle"]] = relationship(
|
||||
"RawArticle", back_populates="feed", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def health_status(self, now: datetime | None = None) -> str:
|
||||
"""Compute feed health status."""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
total = self.success_count + self.fail_count
|
||||
if total == 0:
|
||||
return "unknown"
|
||||
|
||||
success_rate = self.success_count / total
|
||||
days_since = None
|
||||
if self.last_fetch_at:
|
||||
days_since = (now - self.last_fetch_at).days
|
||||
|
||||
if success_rate >= 0.9 and (days_since is None or days_since <= 7):
|
||||
return "healthy"
|
||||
if success_rate >= 0.5 and (days_since is None or days_since <= 7):
|
||||
return "warning"
|
||||
return "unhealthy"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Feed {self.title or self.url}>"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Lock model."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, UUIDMixin
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Lock(Base, UUIDMixin):
|
||||
"""Distributed lock record (fallback when Redis is unavailable)."""
|
||||
|
||||
__tablename__ = "locks"
|
||||
|
||||
lock_name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
owner_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
acquired_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=_utc_now
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Output task and output record models."""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class OutputTask(Base, UUIDMixin, TimestampMixin):
|
||||
"""Configurable output task (e.g. daily brief)."""
|
||||
|
||||
__tablename__ = "output_tasks"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
task_type: Mapped[str] = mapped_column(String(64), default="daily_brief", nullable=False, index=True)
|
||||
skill_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("skills.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
schedule: Mapped[str | None] = mapped_column(String(128), nullable=True) # cron expression
|
||||
filter_config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
output_config: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_output_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("outputs.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class Output(Base, UUIDMixin, TimestampMixin):
|
||||
"""Generated output record."""
|
||||
|
||||
__tablename__ = "outputs"
|
||||
|
||||
output_task_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("output_tasks.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
content: Mapped[str | None] = mapped_column(Text, default="")
|
||||
content_html: Mapped[str | None] = mapped_column(Text, default="")
|
||||
references: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
metadata: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Reference and duplicate group models."""
|
||||
from sqlalchemy import Float, ForeignKey, JSON, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class ArticleReference(Base, UUIDMixin, TimestampMixin):
|
||||
"""Reference from a cleaned article to another related article."""
|
||||
|
||||
__tablename__ = "article_references"
|
||||
|
||||
source_article_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("cleaned_articles.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
referenced_article_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("cleaned_articles.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
reference_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
reference_link: Mapped[str | None] = mapped_column(String(2048), default="")
|
||||
reference_title: Mapped[str | None] = mapped_column(String(1024), default="")
|
||||
similarity: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
|
||||
class DuplicateGroup(Base, UUIDMixin, TimestampMixin):
|
||||
"""Group of duplicate articles."""
|
||||
|
||||
__tablename__ = "duplicate_groups"
|
||||
|
||||
representative_article_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("cleaned_articles.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
member_article_ids: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
similarity_matrix: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||
brief_date: Mapped[str | None] = mapped_column(String(10), default="", index=True)
|
||||
|
||||
articles: Mapped[list["CleanedArticle"]] = relationship(
|
||||
"CleanedArticle", back_populates="duplicate_group"
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""App setting model."""
|
||||
from sqlalchemy import Boolean, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class AppSetting(Base, UUIDMixin, TimestampMixin):
|
||||
"""Runtime application setting."""
|
||||
|
||||
__tablename__ = "app_settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
value: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, default="")
|
||||
is_sensitive: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Skill model."""
|
||||
from sqlalchemy import Boolean, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin
|
||||
|
||||
|
||||
class Skill(Base, UUIDMixin, TimestampMixin):
|
||||
"""Reusable skill configuration for AI outputs."""
|
||||
|
||||
__tablename__ = "skills"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, default="")
|
||||
type: Mapped[str] = mapped_column(String(32), nullable=False, index=True) # output / tool / agent
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
system_prompt: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
output_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
tools: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
input_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
example_inputs: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""User model."""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin, UUIDMixin, utc_now
|
||||
|
||||
|
||||
class User(Base, UUIDMixin, TimestampMixin):
|
||||
"""Platform user."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(32), default="member", nullable=False, index=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User {self.username} ({self.role})>"
|
||||
Reference in New Issue
Block a user