Phase 1 MVP - Complete implementation
- Auth: register, login, JWT with refresh tokens, blocklist - Projects/Folders/Documents CRUD with soft deletes - Tags CRUD and assignment - FTS5 search with highlights and tag filtering - ADR-001, ADR-002, ADR-003 compliant - Security fixes applied (JWT_SECRET_KEY, exception handler, cookie secure) - 25 tests passing
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Claudia Docs Backend
|
||||
45
app/config.py
Normal file
45
app/config.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
def _resolve_db_url(url: str) -> str:
|
||||
"""Convert relative sqlite path to absolute path."""
|
||||
if url.startswith("sqlite+aiosqlite:///./"):
|
||||
# Convert relative path to absolute
|
||||
rel_path = url.replace("sqlite+aiosqlite:///./", "")
|
||||
abs_path = Path("/root/.openclaw/workspace-orchestrator/backend").resolve() / rel_path
|
||||
return f"sqlite+aiosqlite:///{abs_path}"
|
||||
return url
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./data/claudia_docs.db"
|
||||
JWT_SECRET_KEY: str
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:80"
|
||||
HOST: str = "0.0.0.0"
|
||||
PORT: int = 8000
|
||||
LOG_LEVEL: str = "INFO"
|
||||
INITIAL_ADMIN_USERNAME: str = "admin"
|
||||
INITIAL_ADMIN_PASSWORD: str = "admin123"
|
||||
|
||||
@property
|
||||
def resolved_database_url(self) -> str:
|
||||
return _resolve_db_url(self.DATABASE_URL)
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Validate required secrets at startup
|
||||
if not settings.JWT_SECRET_KEY or settings.JWT_SECRET_KEY == "change-me-super-secret-key-min32chars!!":
|
||||
raise ValueError("JWT_SECRET_KEY must be set in environment variables")
|
||||
247
app/database.py
Normal file
247
app/database.py
Normal file
@@ -0,0 +1,247 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
|
||||
# Async engine for aiosqlite
|
||||
DATABASE_URL = settings.resolved_database_url
|
||||
|
||||
# Sync engine for migrations and initial setup
|
||||
SYNC_DATABASE_URL = DATABASE_URL.replace("sqlite+aiosqlite:///", "sqlite:///")
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
# Async session factory
|
||||
async_engine = create_async_engine(DATABASE_URL, echo=False)
|
||||
AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
# Sync engine for migrations
|
||||
sync_engine = create_engine(SYNC_DATABASE_URL, echo=False)
|
||||
SyncSessionLocal = sessionmaker(sync_engine)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def get_db_simple():
|
||||
"""Bare async generator for FastAPI dependency injection."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Initialize database with all tables, views, FTS5, and triggers."""
|
||||
# Create data directory
|
||||
Path("./data").mkdir(exist_ok=True)
|
||||
|
||||
async with async_engine.begin() as conn:
|
||||
# Create all tables via SQL (not ORM) to handle SQLite-specific features
|
||||
await conn.run_sync(_create_schema)
|
||||
|
||||
|
||||
def _create_schema(sync_conn):
|
||||
"""Create all tables, views, FTS5 tables, and triggers synchronously."""
|
||||
|
||||
# Agents table
|
||||
sync_conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'agent' CHECK (role IN ('agent', 'admin')),
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TIMESTAMP NULL,
|
||||
deleted_by TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""))
|
||||
|
||||
# Projects table
|
||||
sync_conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id),
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TIMESTAMP NULL,
|
||||
deleted_by TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""))
|
||||
|
||||
# Folders table
|
||||
sync_conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
parent_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
path TEXT NOT NULL,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TIMESTAMP NULL,
|
||||
deleted_by TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""))
|
||||
|
||||
# Documents table
|
||||
sync_conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE SET NULL,
|
||||
path TEXT NOT NULL,
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TIMESTAMP NULL,
|
||||
deleted_by TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""))
|
||||
|
||||
# Tags table
|
||||
sync_conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#6366f1',
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
deleted_at TIMESTAMP NULL,
|
||||
deleted_by TEXT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
"""))
|
||||
|
||||
# Document tags junction
|
||||
sync_conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS document_tags (
|
||||
document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (document_id, tag_id)
|
||||
)
|
||||
"""))
|
||||
|
||||
# Refresh tokens table
|
||||
sync_conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES agents(id),
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_family_id TEXT NOT NULL,
|
||||
token_version INTEGER NOT NULL,
|
||||
user_agent TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
revoked_at TIMESTAMP NULL,
|
||||
is_global_logout INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""))
|
||||
|
||||
# JWT blocklist table
|
||||
sync_conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS jwt_blocklist (
|
||||
token_id TEXT PRIMARY KEY,
|
||||
revoked_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TIMESTAMP NOT NULL
|
||||
)
|
||||
"""))
|
||||
|
||||
# Indexes
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_projects_agent ON projects(agent_id)"))
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_folders_project ON folders(project_id)"))
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id)"))
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_documents_project ON documents(project_id)"))
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_documents_folder ON documents(folder_id)"))
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_document_tags_doc ON document_tags(document_id)"))
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_document_tags_tag ON document_tags(tag_id)"))
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash)"))
|
||||
sync_conn.execute(text("CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_family ON refresh_tokens(user_id, token_family_id)"))
|
||||
|
||||
# --- FTS5 virtual table ---
|
||||
sync_conn.execute(text("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
|
||||
document_id,
|
||||
title,
|
||||
content,
|
||||
path,
|
||||
tokenize='unicode61 remove_diacritics 1'
|
||||
)
|
||||
"""))
|
||||
|
||||
# --- Active views for soft deletes ---
|
||||
sync_conn.execute(text("""
|
||||
CREATE VIEW IF NOT EXISTS active_agents AS
|
||||
SELECT * FROM agents WHERE is_deleted = 0
|
||||
"""))
|
||||
sync_conn.execute(text("""
|
||||
CREATE VIEW IF NOT EXISTS active_projects AS
|
||||
SELECT * FROM projects WHERE is_deleted = 0
|
||||
"""))
|
||||
sync_conn.execute(text("""
|
||||
CREATE VIEW IF NOT EXISTS active_folders AS
|
||||
SELECT * FROM folders WHERE is_deleted = 0
|
||||
"""))
|
||||
sync_conn.execute(text("""
|
||||
CREATE VIEW IF NOT EXISTS active_documents AS
|
||||
SELECT * FROM documents WHERE is_deleted = 0
|
||||
"""))
|
||||
sync_conn.execute(text("""
|
||||
CREATE VIEW IF NOT EXISTS active_tags AS
|
||||
SELECT * FROM tags WHERE is_deleted = 0
|
||||
"""))
|
||||
|
||||
# --- FTS5 Sync Triggers ---
|
||||
# Insert trigger
|
||||
sync_conn.execute(text("""
|
||||
CREATE TRIGGER IF NOT EXISTS documents_fts_ai AFTER INSERT ON documents BEGIN
|
||||
INSERT INTO documents_fts(document_id, title, content, path)
|
||||
VALUES (new.id, new.title, new.content, new.path);
|
||||
END
|
||||
"""))
|
||||
|
||||
# Update trigger (delete old + insert new)
|
||||
sync_conn.execute(text("""
|
||||
CREATE TRIGGER IF NOT EXISTS documents_fts_au AFTER UPDATE ON documents BEGIN
|
||||
DELETE FROM documents_fts WHERE document_id = old.id;
|
||||
INSERT INTO documents_fts(document_id, title, content, path)
|
||||
VALUES (new.id, new.title, new.content, new.path);
|
||||
END
|
||||
"""))
|
||||
|
||||
# Soft-delete trigger (when is_deleted becomes TRUE)
|
||||
sync_conn.execute(text("""
|
||||
CREATE TRIGGER IF NOT EXISTS documents_fts_ad AFTER UPDATE ON documents
|
||||
WHEN new.is_deleted = 1 AND old.is_deleted = 0
|
||||
BEGIN
|
||||
DELETE FROM documents_fts WHERE document_id = old.id;
|
||||
END
|
||||
"""))
|
||||
|
||||
sync_conn.commit()
|
||||
63
app/main.py
Normal file
63
app/main.py
Normal file
@@ -0,0 +1,63 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.database import init_db, get_db, async_engine
|
||||
from app.routers import auth, projects, folders, documents, tags, search
|
||||
from app.services.auth import cleanup_expired_blocklist
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Startup: init database
|
||||
await init_db()
|
||||
yield
|
||||
# Shutdown
|
||||
await async_engine.dispose()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Claudia Docs API",
|
||||
description="Gestor documental para agentes de IA",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router)
|
||||
app.include_router(projects.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(documents.router)
|
||||
app.include_router(tags.router)
|
||||
app.include_router(search.router)
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health():
|
||||
return {"status": "ok", "timestamp": datetime.utcnow().isoformat()}
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
1
app/models/__init__.py
Normal file
1
app/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Models package
|
||||
25
app/models/agent.py
Normal file
25
app/models/agent.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Agent(Base):
|
||||
__tablename__ = "agents"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
||||
username: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False, default="agent")
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deleted_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
27
app/models/document.py
Normal file
27
app/models/document.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
||||
title: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
folder_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("folders.id", ondelete="SET NULL"), nullable=True)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deleted_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
26
app/models/folder.py
Normal file
26
app/models/folder.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Folder(Base):
|
||||
__tablename__ = "folders"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
project_id: Mapped[str] = mapped_column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
|
||||
parent_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("folders.id", ondelete="CASCADE"), nullable=True)
|
||||
path: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deleted_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
25
app/models/project.py
Normal file
25
app/models/project.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
agent_id: Mapped[str] = mapped_column(String(36), ForeignKey("agents.id"), nullable=False)
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deleted_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
35
app/models/refresh_token.py
Normal file
35
app/models/refresh_token.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class RefreshToken(Base):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
||||
user_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
token_family_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
token_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
is_global_logout: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
|
||||
class JwtBlocklist(Base):
|
||||
__tablename__ = "jwt_blocklist"
|
||||
|
||||
token_id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
||||
revoked_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
30
app/models/tag.py
Normal file
30
app/models/tag.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Table, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def generate_uuid() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#6366f1")
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
deleted_by: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
|
||||
class DocumentTag(Base):
|
||||
__tablename__ = "document_tags"
|
||||
|
||||
document_id: Mapped[str] = mapped_column(String(36), ForeignKey("documents.id", ondelete="CASCADE"), primary_key=True)
|
||||
tag_id: Mapped[str] = mapped_column(String(36), ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True)
|
||||
1
app/routers/__init__.py
Normal file
1
app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
167
app/routers/auth.py
Normal file
167
app/routers/auth.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.auth import AgentCreate, AgentLogin, AgentResponse, RefreshResponse, TokenResponse
|
||||
from app.services import auth as auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
COOKIE_NAME = "refresh_token"
|
||||
COOKIE_PATH = "/api/v1/auth/refresh"
|
||||
COOKIE_MAX_AGE = 60 * 60 * 24 * 7 # 7 days
|
||||
|
||||
|
||||
def _set_refresh_cookie(response: Response, token: str):
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
secure=True, # True in production with HTTPS
|
||||
samesite="lax",
|
||||
path=COOKIE_PATH,
|
||||
)
|
||||
|
||||
|
||||
def _clear_refresh_cookie(response: Response):
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value="",
|
||||
max_age=0,
|
||||
httponly=True,
|
||||
secure=True, # True in production with HTTPS
|
||||
samesite="lax",
|
||||
path=COOKIE_PATH,
|
||||
)
|
||||
|
||||
|
||||
async def get_current_agent(request: Request, db: AsyncSession) -> Agent:
|
||||
"""Get the current authenticated agent from request."""
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
token = auth_header[7:]
|
||||
payload = auth_service.decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti:
|
||||
is_blocked = await auth_service.is_token_blocklisted(db, jti)
|
||||
if is_blocked:
|
||||
raise HTTPException(status_code=401, detail="Token revoked")
|
||||
|
||||
agent_id = payload.get("sub")
|
||||
if not agent_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid token payload")
|
||||
|
||||
agent = await auth_service.get_agent_by_id(db, agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=401, detail="Agent not found")
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
@router.post("/register", response_model=AgentResponse, status_code=201)
|
||||
async def register(payload: AgentCreate, db: AsyncSession = Depends(get_db)):
|
||||
existing = await auth_service.get_agent_by_username(db, payload.username)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Username already exists")
|
||||
|
||||
agent = await auth_service.create_agent(db, payload.username, payload.password)
|
||||
return AgentResponse.model_validate(agent)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(
|
||||
payload: AgentLogin,
|
||||
request: Request,
|
||||
response: Response,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await auth_service.get_agent_by_username(db, payload.username)
|
||||
if not agent or not auth_service.verify_password(payload.password, agent.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
access_token, jti = auth_service.create_access_token(agent.id, agent.role)
|
||||
|
||||
refresh_token = auth_service.create_refresh_token()
|
||||
user_agent = request.headers.get("user-agent")
|
||||
client_ip = request.client.host if request.client else None
|
||||
await auth_service.save_refresh_token(db, agent.id, refresh_token, user_agent, client_ip)
|
||||
|
||||
_set_refresh_cookie(response, refresh_token)
|
||||
return TokenResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AgentResponse)
|
||||
async def get_me(request: Request, db: AsyncSession = Depends(get_db)):
|
||||
agent = await get_current_agent(request, db)
|
||||
return AgentResponse.model_validate(agent)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=RefreshResponse)
|
||||
async def refresh(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="No refresh token")
|
||||
|
||||
user_agent = request.headers.get("user-agent")
|
||||
client_ip = request.client.host if request.client else None
|
||||
|
||||
result = await auth_service.rotate_refresh_token(db, token, user_agent, client_ip)
|
||||
if not result:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
new_rt, new_token = result
|
||||
|
||||
agent = await auth_service.get_agent_by_id(db, new_rt.user_id)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=401, detail="Agent not found")
|
||||
|
||||
access_token, jti = auth_service.create_access_token(agent.id, agent.role)
|
||||
_set_refresh_cookie(response, new_token)
|
||||
return RefreshResponse(access_token=access_token)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=204)
|
||||
async def logout(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if token:
|
||||
await auth_service.revoke_refresh_token(db, token)
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
access_token = auth_header[7:]
|
||||
payload = auth_service.decode_token(access_token)
|
||||
if payload and "jti" in payload:
|
||||
exp = datetime.utcfromtimestamp(payload["exp"])
|
||||
await auth_service.add_to_blocklist(db, payload["jti"], exp)
|
||||
|
||||
_clear_refresh_cookie(response)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/logout-all", status_code=204)
|
||||
async def logout_all(request: Request, response: Response, db: AsyncSession = Depends(get_db)):
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
agent = None
|
||||
if auth_header.startswith("Bearer "):
|
||||
access_token = auth_header[7:]
|
||||
payload = auth_service.decode_token(access_token)
|
||||
if payload and "sub" in payload:
|
||||
agent = await auth_service.get_agent_by_id(db, payload["sub"])
|
||||
|
||||
if agent:
|
||||
await auth_service.revoke_all_user_tokens(db, agent.id)
|
||||
if payload and "jti" in payload:
|
||||
exp = datetime.utcfromtimestamp(payload["exp"])
|
||||
await auth_service.add_to_blocklist(db, payload["jti"], exp)
|
||||
|
||||
_clear_refresh_cookie(response)
|
||||
return Response(status_code=204)
|
||||
435
app/routers/documents.py
Normal file
435
app/routers/documents.py
Normal file
@@ -0,0 +1,435 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import delete, select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.document import Document
|
||||
from app.models.folder import Folder
|
||||
from app.models.project import Project
|
||||
from app.models.tag import DocumentTag, Tag
|
||||
from app.routers.auth import get_current_agent
|
||||
from app.schemas.document import (
|
||||
DocumentBriefResponse,
|
||||
DocumentContentUpdate,
|
||||
DocumentCreate,
|
||||
DocumentListResponse,
|
||||
DocumentResponse,
|
||||
DocumentUpdate,
|
||||
TagInfo,
|
||||
)
|
||||
from app.schemas.tag import DocumentTagsAssign
|
||||
|
||||
|
||||
router = APIRouter(tags=["documents"])
|
||||
|
||||
|
||||
def build_doc_path(project_id: str, doc_id: str, folder_id: str | None, folder_path: str | None) -> str:
|
||||
if folder_id and folder_path:
|
||||
return f"{folder_path}/{doc_id}"
|
||||
return f"/{project_id}/{doc_id}"
|
||||
|
||||
|
||||
async def get_document_tags(db: AsyncSession, doc_id: str) -> list[TagInfo]:
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT t.id, t.name, t.color
|
||||
FROM active_tags t
|
||||
JOIN document_tags dt ON t.id = dt.tag_id
|
||||
WHERE dt.document_id = :doc_id
|
||||
"""),
|
||||
{"doc_id": doc_id}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
return [TagInfo(id=r.id, name=r.name, color=r.color) for r in rows]
|
||||
|
||||
|
||||
async def document_to_response(db: AsyncSession, doc: Document) -> DocumentResponse:
|
||||
tags = await get_document_tags(db, doc.id)
|
||||
return DocumentResponse(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
content=doc.content,
|
||||
project_id=doc.project_id,
|
||||
folder_id=doc.folder_id,
|
||||
path=doc.path,
|
||||
tags=tags,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v1/projects/{project_id}/documents", response_model=DocumentListResponse)
|
||||
async def list_documents(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.project_id == project_id,
|
||||
Document.is_deleted == False,
|
||||
).order_by(Document.created_at.desc())
|
||||
)
|
||||
docs = result.scalars().all()
|
||||
|
||||
responses = []
|
||||
for doc in docs:
|
||||
tags = await get_document_tags(db, doc.id)
|
||||
responses.append(DocumentBriefResponse(
|
||||
id=doc.id,
|
||||
title=doc.title,
|
||||
project_id=doc.project_id,
|
||||
folder_id=doc.folder_id,
|
||||
path=doc.path,
|
||||
tags=tags,
|
||||
created_at=doc.created_at,
|
||||
updated_at=doc.updated_at,
|
||||
))
|
||||
|
||||
return DocumentListResponse(documents=responses)
|
||||
|
||||
|
||||
@router.post("/api/v1/projects/{project_id}/documents", response_model=DocumentResponse, status_code=201)
|
||||
async def create_document(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
payload: DocumentCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
folder_path = None
|
||||
if payload.folder_id:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.folder_id,
|
||||
Folder.project_id == project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=400, detail="Folder not found")
|
||||
folder_path = folder.path
|
||||
|
||||
doc_id = str(uuid.uuid4())
|
||||
path = build_doc_path(project_id, doc_id, payload.folder_id, folder_path)
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
title=payload.title,
|
||||
content=payload.content,
|
||||
project_id=project_id,
|
||||
folder_id=payload.folder_id,
|
||||
path=path,
|
||||
)
|
||||
db.add(doc)
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.get("/api/v1/documents/{document_id}", response_model=DocumentResponse)
|
||||
async def get_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.put("/api/v1/documents/{document_id}", response_model=DocumentResponse)
|
||||
async def update_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
if payload.title is not None:
|
||||
doc.title = payload.title
|
||||
if payload.folder_id is not None:
|
||||
if payload.folder_id:
|
||||
folder_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.folder_id,
|
||||
Folder.project_id == doc.project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = folder_result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=400, detail="Folder not found")
|
||||
doc.path = f"{folder.path}/{doc.id}"
|
||||
else:
|
||||
doc.path = f"/{doc.project_id}/{doc.id}"
|
||||
doc.folder_id = payload.folder_id
|
||||
|
||||
doc.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.delete("/api/v1/documents/{document_id}", status_code=204)
|
||||
async def delete_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.is_deleted = True
|
||||
doc.deleted_at = datetime.utcnow()
|
||||
doc.deleted_by = agent.id
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.put("/api/v1/documents/{document_id}/content", response_model=DocumentResponse)
|
||||
async def update_document_content(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentContentUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.content = payload.content
|
||||
doc.updated_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.post("/api/v1/documents/{document_id}/restore", response_model=DocumentResponse)
|
||||
async def restore_document(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == True,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
doc.is_deleted = False
|
||||
doc.deleted_at = None
|
||||
doc.deleted_by = None
|
||||
await db.flush()
|
||||
return await document_to_response(db, doc)
|
||||
|
||||
|
||||
@router.post("/api/v1/documents/{document_id}/tags", status_code=204)
|
||||
async def assign_tags(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
payload: DocumentTagsAssign,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
for tag_id in payload.tag_ids:
|
||||
tag_result = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.id == tag_id,
|
||||
Tag.is_deleted == False,
|
||||
)
|
||||
)
|
||||
tag = tag_result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=400, detail=f"Tag {tag_id} not found")
|
||||
|
||||
existing = await db.execute(
|
||||
select(DocumentTag).where(
|
||||
DocumentTag.document_id == document_id,
|
||||
DocumentTag.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
if not existing.scalar_one_or_none():
|
||||
dt = DocumentTag(document_id=document_id, tag_id=tag_id)
|
||||
db.add(dt)
|
||||
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.delete("/api/v1/documents/{document_id}/tags/{tag_id}", status_code=204)
|
||||
async def remove_tag(
|
||||
request: Request,
|
||||
document_id: str,
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Document).where(
|
||||
Document.id == document_id,
|
||||
Document.is_deleted == False,
|
||||
)
|
||||
)
|
||||
doc = result.scalar_one_or_none()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == doc.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
await db.execute(
|
||||
delete(DocumentTag).where(
|
||||
DocumentTag.document_id == document_id,
|
||||
DocumentTag.tag_id == tag_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return None
|
||||
251
app/routers/folders.py
Normal file
251
app/routers/folders.py
Normal file
@@ -0,0 +1,251 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.folder import Folder
|
||||
from app.models.project import Project
|
||||
from app.schemas.folder import FolderCreate, FolderListResponse, FolderResponse, FolderUpdate
|
||||
from app.routers.auth import get_current_agent
|
||||
|
||||
router = APIRouter(tags=["folders"])
|
||||
|
||||
|
||||
def build_folder_path(project_id: str, folder_id: str, parent_id: str | None, parent_path: str | None) -> str:
|
||||
if parent_id and parent_path:
|
||||
return f"{parent_path}/{folder_id}"
|
||||
return f"/{project_id}/{folder_id}"
|
||||
|
||||
|
||||
@router.get("/api/v1/projects/{project_id}/folders", response_model=FolderListResponse)
|
||||
async def list_folders(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
parent_id: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
query = select(Folder).where(
|
||||
Folder.project_id == project_id,
|
||||
Folder.parent_id == parent_id,
|
||||
Folder.is_deleted == False,
|
||||
).order_by(Folder.name)
|
||||
|
||||
result = await db.execute(query)
|
||||
folders = result.scalars().all()
|
||||
return FolderListResponse(folders=[FolderResponse.model_validate(f) for f in folders])
|
||||
|
||||
|
||||
@router.post("/api/v1/projects/{project_id}/folders", response_model=FolderResponse, status_code=201)
|
||||
async def create_folder(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
payload: FolderCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
parent_path = None
|
||||
if payload.parent_id:
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.parent_id,
|
||||
Folder.project_id == project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=400, detail="Parent folder not found")
|
||||
parent_path = parent.path
|
||||
|
||||
folder_id = str(uuid.uuid4())
|
||||
path = build_folder_path(project_id, folder_id, payload.parent_id, parent_path)
|
||||
|
||||
folder = Folder(
|
||||
id=folder_id,
|
||||
name=payload.name,
|
||||
project_id=project_id,
|
||||
parent_id=payload.parent_id,
|
||||
path=path,
|
||||
)
|
||||
db.add(folder)
|
||||
await db.flush()
|
||||
return FolderResponse.model_validate(folder)
|
||||
|
||||
|
||||
@router.get("/api/v1/folders/{folder_id}", response_model=FolderResponse)
|
||||
async def get_folder(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == folder.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
return FolderResponse.model_validate(folder)
|
||||
|
||||
|
||||
@router.put("/api/v1/folders/{folder_id}", response_model=FolderResponse)
|
||||
async def update_folder(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
payload: FolderUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == folder.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
if payload.name is not None:
|
||||
folder.name = payload.name
|
||||
if payload.parent_id is not None:
|
||||
if payload.parent_id == folder_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot set folder as its own parent")
|
||||
parent_result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == payload.parent_id,
|
||||
Folder.project_id == folder.project_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
parent = parent_result.scalar_one_or_none()
|
||||
if not parent:
|
||||
raise HTTPException(status_code=400, detail="Parent folder not found")
|
||||
folder.parent_id = payload.parent_id
|
||||
folder.path = f"{parent.path}/{folder.id}"
|
||||
folder.updated_at = datetime.utcnow()
|
||||
|
||||
await db.flush()
|
||||
return FolderResponse.model_validate(folder)
|
||||
|
||||
|
||||
@router.delete("/api/v1/folders/{folder_id}", status_code=204)
|
||||
async def delete_folder(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id,
|
||||
Folder.is_deleted == False,
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == folder.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
folder.is_deleted = True
|
||||
folder.deleted_at = datetime.utcnow()
|
||||
folder.deleted_by = agent.id
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/api/v1/folders/{folder_id}/restore", response_model=FolderResponse)
|
||||
async def restore_folder(
|
||||
request: Request,
|
||||
folder_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Folder).where(
|
||||
Folder.id == folder_id,
|
||||
Folder.is_deleted == True,
|
||||
)
|
||||
)
|
||||
folder = result.scalar_one_or_none()
|
||||
if not folder:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
proj_result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == folder.project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if not proj_result.scalar_one_or_none():
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
folder.is_deleted = False
|
||||
folder.deleted_at = None
|
||||
folder.deleted_by = None
|
||||
await db.flush()
|
||||
return FolderResponse.model_validate(folder)
|
||||
146
app/routers/projects.py
Normal file
146
app/routers/projects.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.project import Project
|
||||
from app.schemas.project import ProjectCreate, ProjectListResponse, ProjectResponse, ProjectUpdate
|
||||
from app.routers.auth import get_current_agent
|
||||
|
||||
router = APIRouter(prefix="/api/v1/projects", tags=["projects"])
|
||||
|
||||
|
||||
@router.get("", response_model=ProjectListResponse)
|
||||
async def list_projects(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
).order_by(Project.created_at.desc())
|
||||
)
|
||||
projects = result.scalars().all()
|
||||
return ProjectListResponse(projects=[ProjectResponse.model_validate(p) for p in projects])
|
||||
|
||||
|
||||
@router.post("", response_model=ProjectResponse, status_code=201)
|
||||
async def create_project(
|
||||
request: Request,
|
||||
payload: ProjectCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
agent_id=agent.id,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=ProjectResponse)
|
||||
async def update_project(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
payload: ProjectUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
if payload.name is not None:
|
||||
project.name = payload.name
|
||||
if payload.description is not None:
|
||||
project.description = payload.description
|
||||
project.updated_at = datetime.utcnow()
|
||||
|
||||
await db.flush()
|
||||
return ProjectResponse.model_validate(project)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", status_code=204)
|
||||
async def delete_project(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == False,
|
||||
)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project.is_deleted = True
|
||||
project.deleted_at = datetime.utcnow()
|
||||
project.deleted_by = agent.id
|
||||
await db.flush()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{project_id}/restore", response_model=ProjectResponse)
|
||||
async def restore_project(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.id == project_id,
|
||||
Project.agent_id == agent.id,
|
||||
Project.is_deleted == True,
|
||||
)
|
||||
)
|
||||
project = result.scalar_one_or_none()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
project.is_deleted = False
|
||||
project.deleted_at = None
|
||||
project.deleted_by = None
|
||||
await db.flush()
|
||||
return ProjectResponse.model_validate(project)
|
||||
36
app/routers/search.py
Normal file
36
app/routers/search.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.routers.auth import get_current_agent
|
||||
from app.schemas.search import SearchResponse
|
||||
from app.services.search import search_documents
|
||||
|
||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||
|
||||
|
||||
@router.get("", response_model=SearchResponse)
|
||||
async def search(
|
||||
request: Request,
|
||||
q: str = Query(..., min_length=1),
|
||||
project_id: str | None = Query(None),
|
||||
tags: str | None = Query(None),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
tag_list = None
|
||||
if tags:
|
||||
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
||||
|
||||
return await search_documents(
|
||||
db=db,
|
||||
query=q,
|
||||
agent_id=agent.id,
|
||||
project_id=project_id,
|
||||
tags=tag_list,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
79
app/routers/tags.py
Normal file
79
app/routers/tags.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.tag import Tag
|
||||
from app.routers.auth import get_current_agent
|
||||
from app.schemas.tag import TagCreate, TagListResponse, TagResponse
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=TagListResponse)
|
||||
async def list_tags(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
result = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.is_deleted == False,
|
||||
).order_by(Tag.name)
|
||||
)
|
||||
tags = result.scalars().all()
|
||||
return TagListResponse(tags=[TagResponse.model_validate(t) for t in tags])
|
||||
|
||||
|
||||
@router.post("", response_model=TagResponse, status_code=201)
|
||||
async def create_tag(
|
||||
request: Request,
|
||||
payload: TagCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
existing = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.name == payload.name,
|
||||
Tag.is_deleted == False,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Tag with this name already exists")
|
||||
|
||||
tag = Tag(
|
||||
id=str(uuid.uuid4()),
|
||||
name=payload.name,
|
||||
color=payload.color,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.flush()
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.post("/{tag_id}/restore", response_model=TagResponse)
|
||||
async def restore_tag(
|
||||
request: Request,
|
||||
tag_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
agent = await get_current_agent(request, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(Tag).where(
|
||||
Tag.id == tag_id,
|
||||
Tag.is_deleted == True,
|
||||
)
|
||||
)
|
||||
tag = result.scalar_one_or_none()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
tag.is_deleted = False
|
||||
tag.deleted_at = None
|
||||
tag.deleted_by = None
|
||||
await db.flush()
|
||||
return TagResponse.model_validate(tag)
|
||||
1
app/schemas/__init__.py
Normal file
1
app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Schemas package
|
||||
32
app/schemas/auth.py
Normal file
32
app/schemas/auth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AgentCreate(BaseModel):
|
||||
username: str = Field(..., min_length=3, max_length=50)
|
||||
password: str = Field(..., min_length=6)
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
id: str
|
||||
username: str
|
||||
role: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AgentLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
|
||||
class RefreshResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
58
app/schemas/document.py
Normal file
58
app/schemas/document.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class DocumentCreate(BaseModel):
|
||||
title: str
|
||||
content: str = ""
|
||||
folder_id: str | None = None
|
||||
|
||||
|
||||
class DocumentUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
folder_id: str | None = None
|
||||
|
||||
|
||||
class DocumentContentUpdate(BaseModel):
|
||||
content: str = Field(..., max_length=1_000_000) # 1MB limit
|
||||
|
||||
|
||||
class TagInfo(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
color: str
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DocumentResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
content: str
|
||||
project_id: str
|
||||
folder_id: str | None
|
||||
path: str
|
||||
tags: list[TagInfo] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DocumentListResponse(BaseModel):
|
||||
documents: list[DocumentResponse]
|
||||
|
||||
|
||||
class DocumentBriefResponse(BaseModel):
|
||||
"""Brief document for list views without content."""
|
||||
id: str
|
||||
title: str
|
||||
project_id: str
|
||||
folder_id: str | None
|
||||
path: str
|
||||
tags: list[TagInfo] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
29
app/schemas/folder.py
Normal file
29
app/schemas/folder.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class FolderUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
parent_id: str | None = None
|
||||
|
||||
|
||||
class FolderResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
project_id: str
|
||||
parent_id: str | None
|
||||
path: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class FolderListResponse(BaseModel):
|
||||
folders: list[FolderResponse]
|
||||
27
app/schemas/project.py
Normal file
27
app/schemas/project.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ProjectListResponse(BaseModel):
|
||||
projects: list[ProjectResponse]
|
||||
18
app/schemas/search.py
Normal file
18
app/schemas/search.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.schemas.document import TagInfo
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
excerpt: str
|
||||
project_id: str
|
||||
tags: list[TagInfo] = []
|
||||
score: float
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
results: list[SearchResult]
|
||||
25
app/schemas/tag.py
Normal file
25
app/schemas/tag.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str
|
||||
color: str = "#6366f1"
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
color: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TagListResponse(BaseModel):
|
||||
tags: list[TagResponse]
|
||||
|
||||
|
||||
class DocumentTagsAssign(BaseModel):
|
||||
tag_ids: list[str]
|
||||
1
app/services/__init__.py
Normal file
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
233
app/services/auth.py
Normal file
233
app/services/auth.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import bcrypt
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy import select, text, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.agent import Agent
|
||||
from app.models.refresh_token import JwtBlocklist, RefreshToken
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(agent_id: str, role: str) -> tuple[str, str]:
|
||||
"""Create JWT access token. Returns (token, jti)."""
|
||||
jti = str(uuid.uuid4())
|
||||
now = datetime.utcnow()
|
||||
expire = now + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
payload = {
|
||||
"sub": agent_id,
|
||||
"role": role,
|
||||
"jti": jti,
|
||||
"iat": now,
|
||||
"exp": expire,
|
||||
}
|
||||
token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
return token, jti
|
||||
|
||||
|
||||
def create_refresh_token() -> str:
|
||||
"""Create opaque refresh token."""
|
||||
return secrets.token_urlsafe(64)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""SHA-256 hash of a token."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict | None:
|
||||
"""Decode and validate JWT. Returns payload or None."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_agent_by_username(db: AsyncSession, username: str) -> Agent | None:
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.username == username, Agent.is_deleted == False)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_agent_by_id(db: AsyncSession, agent_id: str) -> Agent | None:
|
||||
result = await db.execute(
|
||||
select(Agent).where(Agent.id == agent_id, Agent.is_deleted == False)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_agent(db: AsyncSession, username: str, password: str, role: str = "agent") -> Agent:
|
||||
agent = Agent(
|
||||
id=str(uuid.uuid4()),
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
)
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
return agent
|
||||
|
||||
|
||||
async def save_refresh_token(
|
||||
db: AsyncSession,
|
||||
user_id: str,
|
||||
token: str,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> RefreshToken:
|
||||
"""Save a new refresh token with a new family."""
|
||||
token_hash = hash_token(token)
|
||||
family_id = str(uuid.uuid4())
|
||||
expires_at = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
rt = RefreshToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
token_hash=token_hash,
|
||||
token_family_id=family_id,
|
||||
token_version=1,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(rt)
|
||||
await db.flush()
|
||||
return rt
|
||||
|
||||
|
||||
async def rotate_refresh_token(
|
||||
db: AsyncSession,
|
||||
old_token: str,
|
||||
user_agent: str | None,
|
||||
ip_address: str | None,
|
||||
) -> tuple[RefreshToken, str] | None:
|
||||
"""
|
||||
Rotate a refresh token: revoke old, create new.
|
||||
Returns (new_rt, new_token) or None if invalid.
|
||||
"""
|
||||
token_hash = hash_token(old_token)
|
||||
|
||||
# Find existing token
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token_hash == token_hash,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
RefreshToken.is_global_logout == False,
|
||||
)
|
||||
)
|
||||
old_rt: RefreshToken | None = result.scalar_one_or_none()
|
||||
if not old_rt:
|
||||
return None
|
||||
|
||||
# Check expiry
|
||||
if old_rt.expires_at < datetime.utcnow():
|
||||
return None
|
||||
|
||||
# Reuse detection: check if a higher version exists
|
||||
reuse_check = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token_family_id == old_rt.token_family_id,
|
||||
RefreshToken.token_version > old_rt.token_version,
|
||||
RefreshToken.revoked_at.is_not(None),
|
||||
)
|
||||
)
|
||||
if reuse_check.scalar_one_or_none():
|
||||
# Possible theft detected: revoke entire family
|
||||
await db.execute(
|
||||
update(RefreshToken)
|
||||
.where(RefreshToken.token_family_id == old_rt.token_family_id)
|
||||
.values(is_global_logout=True, revoked_at=datetime.utcnow())
|
||||
)
|
||||
return None
|
||||
|
||||
# Revoke old token
|
||||
old_rt.revoked_at = datetime.utcnow()
|
||||
|
||||
# Create new token in same family
|
||||
new_token = create_refresh_token()
|
||||
new_hash = hash_token(new_token)
|
||||
expires_at = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
new_rt = RefreshToken(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=old_rt.user_id,
|
||||
token_hash=new_hash,
|
||||
token_family_id=old_rt.token_family_id,
|
||||
token_version=old_rt.token_version + 1,
|
||||
user_agent=user_agent,
|
||||
ip_address=ip_address,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(new_rt)
|
||||
await db.flush()
|
||||
return new_rt, new_token
|
||||
|
||||
|
||||
async def revoke_refresh_token(db: AsyncSession, token: str) -> bool:
|
||||
"""Revoke a single refresh token."""
|
||||
token_hash = hash_token(token)
|
||||
result = await db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.token_hash == token_hash,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
rt: RefreshToken | None = result.scalar_one_or_none()
|
||||
if not rt:
|
||||
return False
|
||||
rt.revoked_at = datetime.utcnow()
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def revoke_all_user_tokens(db: AsyncSession, user_id: str) -> bool:
|
||||
"""Revoke all refresh tokens for a user (logout-all)."""
|
||||
await db.execute(
|
||||
update(RefreshToken)
|
||||
.where(
|
||||
RefreshToken.user_id == user_id,
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
.values(is_global_logout=True, revoked_at=datetime.utcnow())
|
||||
)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def add_to_blocklist(db: AsyncSession, jti: str, expires_at: datetime) -> None:
|
||||
"""Add a token JTI to the blocklist."""
|
||||
entry = JwtBlocklist(token_id=jti, expires_at=expires_at)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def is_token_blocklisted(db: AsyncSession, jti: str) -> bool:
|
||||
"""Check if a token JTI is in the blocklist."""
|
||||
result = await db.execute(
|
||||
select(JwtBlocklist).where(JwtBlocklist.token_id == jti)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
|
||||
async def cleanup_expired_blocklist(db: AsyncSession) -> None:
|
||||
"""Remove expired entries from blocklist."""
|
||||
await db.execute(
|
||||
text("DELETE FROM jwt_blocklist WHERE expires_at < datetime('now')")
|
||||
)
|
||||
await db.flush()
|
||||
128
app/services/search.py
Normal file
128
app/services/search.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.search import SearchResult, SearchResponse
|
||||
from app.schemas.document import TagInfo
|
||||
|
||||
|
||||
async def search_documents(
|
||||
db: AsyncSession,
|
||||
query: str,
|
||||
agent_id: str | None = None,
|
||||
project_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> SearchResponse:
|
||||
"""
|
||||
Full-text search using FTS5.
|
||||
Returns snippets with highlight markup.
|
||||
"""
|
||||
if not query or len(query.strip()) == 0:
|
||||
return SearchResponse(results=[])
|
||||
|
||||
# Escape FTS5 special characters and prepare query
|
||||
safe_query = query.replace('"', '""')
|
||||
|
||||
# Build the FTS5 MATCH query
|
||||
fts_query = f'"{safe_query}"'
|
||||
|
||||
# Get document IDs from FTS5
|
||||
fts_sql = text("""
|
||||
SELECT document_id, title, content, path,
|
||||
bm25(documents_fts) as score
|
||||
FROM documents_fts
|
||||
WHERE documents_fts MATCH :q
|
||||
ORDER BY score
|
||||
LIMIT :limit OFFSET :offset
|
||||
""")
|
||||
|
||||
fts_result = await db.execute(
|
||||
fts_sql,
|
||||
{"q": fts_query, "limit": limit, "offset": offset}
|
||||
)
|
||||
fts_rows = fts_result.fetchall()
|
||||
|
||||
if not fts_rows:
|
||||
return SearchResponse(results=[])
|
||||
|
||||
results = []
|
||||
for row in fts_rows:
|
||||
doc_id = row.document_id
|
||||
|
||||
# Get document to verify access and get project_id
|
||||
doc_sql = text("""
|
||||
SELECT d.id, d.title, d.content, d.project_id, d.is_deleted,
|
||||
p.agent_id
|
||||
FROM active_documents d
|
||||
JOIN active_projects p ON d.project_id = p.id
|
||||
WHERE d.id = :doc_id AND p.agent_id = :agent_id
|
||||
""")
|
||||
doc_result = await db.execute(
|
||||
doc_sql,
|
||||
{"doc_id": doc_id, "agent_id": agent_id}
|
||||
)
|
||||
doc_row = doc_result.fetchone()
|
||||
if not doc_row:
|
||||
continue
|
||||
|
||||
# Filter by project_id if provided
|
||||
if project_id and doc_row.project_id != project_id:
|
||||
continue
|
||||
|
||||
# Get tags for this document
|
||||
tags_sql = text("""
|
||||
SELECT t.id, t.name, t.color
|
||||
FROM active_tags t
|
||||
JOIN document_tags dt ON t.id = dt.tag_id
|
||||
WHERE dt.document_id = :doc_id
|
||||
""")
|
||||
tags_result = await db.execute(tags_sql, {"doc_id": doc_id})
|
||||
tag_rows = tags_result.fetchall()
|
||||
doc_tags = [TagInfo(id=t.id, name=t.name, color=t.color) for t in tag_rows]
|
||||
|
||||
# Filter by tags if provided
|
||||
if tags:
|
||||
tag_names = {t.name for t in doc_tags}
|
||||
if not any(tn in tag_names for tn in tags):
|
||||
continue
|
||||
|
||||
# Build excerpt with snippet
|
||||
content = doc_row.content or ""
|
||||
excerpt = _build_snippet(content, query)
|
||||
|
||||
results.append(SearchResult(
|
||||
id=doc_row.id,
|
||||
title=doc_row.title,
|
||||
excerpt=excerpt,
|
||||
project_id=doc_row.project_id,
|
||||
tags=doc_tags,
|
||||
score=abs(row.score) if row.score else 0.0,
|
||||
))
|
||||
|
||||
return SearchResponse(results=results)
|
||||
|
||||
|
||||
def _build_snippet(content: str, query: str, context_chars: int = 150) -> str:
|
||||
"""Build a highlighted snippet from content."""
|
||||
query_lower = query.lower()
|
||||
content_lower = content.lower()
|
||||
|
||||
idx = content_lower.find(query_lower)
|
||||
if idx == -1:
|
||||
# No exact match, return beginning
|
||||
snippet = content[:context_chars * 2]
|
||||
else:
|
||||
start = max(0, idx - context_chars)
|
||||
end = min(len(content), idx + len(query) + context_chars)
|
||||
snippet = content[start:end]
|
||||
if start > 0:
|
||||
snippet = "..." + snippet
|
||||
if end < len(content):
|
||||
snippet = snippet + "..."
|
||||
|
||||
# Simple highlight: wrap matches in **
|
||||
import re
|
||||
pattern = re.compile(re.escape(query), re.IGNORECASE)
|
||||
snippet = pattern.sub(f"**{query}**", snippet)
|
||||
return snippet
|
||||
Reference in New Issue
Block a user