diff --git a/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py b/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py new file mode 100644 index 000000000..2b8db7cd4 --- /dev/null +++ b/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py @@ -0,0 +1,62 @@ +"""add quick_ask_actions table + +Revision ID: 109 +Revises: 108 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "109" +down_revision: str | None = "108" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute(""" + DO $$ BEGIN + CREATE TYPE quick_ask_action_mode AS ENUM ('transform', 'explore'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + + conn = op.get_bind() + result = conn.execute( + sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'quick_ask_actions'") + ) + if not result.fetchone(): + op.create_table( + "quick_ask_actions", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("search_space_id", sa.Integer(), nullable=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("prompt", sa.Text(), nullable=False), + sa.Column( + "mode", + sa.Enum("transform", "explore", name="quick_ask_action_mode", create_type=False), + nullable=False, + ), + sa.Column("icon", sa.String(50), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.func.now(), + ), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_quick_ask_actions_user_id", "quick_ask_actions", ["user_id"]) + op.create_index("ix_quick_ask_actions_search_space_id", "quick_ask_actions", ["search_space_id"]) + + +def downgrade() -> None: + op.drop_table("quick_ask_actions") + op.execute("DROP TYPE IF EXISTS quick_ask_action_mode") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 132bd8dae..eaa445223 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1722,6 +1722,35 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): ) +class QuickAskActionMode(StrEnum): + TRANSFORM = "transform" + EXPLORE = "explore" + + +class QuickAskAction(BaseModel, TimestampMixin): + __tablename__ = "quick_ask_actions" + + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + search_space_id = Column( + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + nullable=True, + index=True, + ) + name = Column(String(200), nullable=False) + prompt = Column(Text, nullable=False) + mode = Column(SQLAlchemyEnum(QuickAskActionMode), nullable=False) + icon = Column(String(50), nullable=True) + + user = relationship("User") + search_space = relationship("SearchSpace") + + if config.AUTH_TYPE == "GOOGLE": class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index f6975b69d..171ee5792 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -34,6 +34,7 @@ from .notifications_routes import router as notifications_router from .notion_add_connector_route import router as notion_add_connector_router from .podcasts_routes import router as podcasts_router from .public_chat_routes import router as public_chat_router +from .quick_ask_actions_routes import router as quick_ask_actions_router from .rbac_routes import router as rbac_router from .reports_routes import router as reports_router from .sandbox_routes import router as sandbox_router @@ -85,3 +86,4 @@ router.include_router(composio_router) # Composio OAuth and toolkit management router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages router.include_router(youtube_router) # YouTube playlist resolution +router.include_router(quick_ask_actions_router) diff --git a/surfsense_backend/app/routes/quick_ask_actions_routes.py b/surfsense_backend/app/routes/quick_ask_actions_routes.py new file mode 100644 index 000000000..6b9868a07 --- /dev/null +++ b/surfsense_backend/app/routes/quick_ask_actions_routes.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import QuickAskAction, User, get_async_session +from app.schemas.quick_ask_actions import ( + QuickAskActionCreate, + QuickAskActionRead, + QuickAskActionUpdate, +) +from app.users import current_active_user + +router = APIRouter(tags=["Quick Ask Actions"]) + + +@router.get("/quick-ask-actions", response_model=list[QuickAskActionRead]) +async def list_actions( + search_space_id: int | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + query = select(QuickAskAction).where(QuickAskAction.user_id == user.id) + if search_space_id is not None: + query = query.where(QuickAskAction.search_space_id == search_space_id) + query = query.order_by(QuickAskAction.created_at.desc()) + result = await session.execute(query) + return result.scalars().all() + + +@router.post("/quick-ask-actions", response_model=QuickAskActionRead) +async def create_action( + body: QuickAskActionCreate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + action = QuickAskAction( + user_id=user.id, + search_space_id=body.search_space_id, + name=body.name, + prompt=body.prompt, + mode=body.mode, + icon=body.icon, + ) + session.add(action) + await session.commit() + await session.refresh(action) + return action + + +@router.put("/quick-ask-actions/{action_id}", response_model=QuickAskActionRead) +async def update_action( + action_id: int, + body: QuickAskActionUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(QuickAskAction).where( + QuickAskAction.id == action_id, + QuickAskAction.user_id == user.id, + ) + ) + action = result.scalar_one_or_none() + if not action: + raise HTTPException(status_code=404, detail="Action not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(action, field, value) + + session.add(action) + await session.commit() + await session.refresh(action) + return action + + +@router.delete("/quick-ask-actions/{action_id}") +async def delete_action( + action_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(QuickAskAction).where( + QuickAskAction.id == action_id, + QuickAskAction.user_id == user.id, + ) + ) + action = result.scalar_one_or_none() + if not action: + raise HTTPException(status_code=404, detail="Action not found") + + await session.delete(action) + await session.commit() + return {"success": True} diff --git a/surfsense_backend/app/schemas/quick_ask_actions.py b/surfsense_backend/app/schemas/quick_ask_actions.py new file mode 100644 index 000000000..90fa716b9 --- /dev/null +++ b/surfsense_backend/app/schemas/quick_ask_actions.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +class QuickAskActionCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=200) + prompt: str = Field(..., min_length=1) + mode: str = Field(..., pattern="^(transform|explore)$") + icon: str | None = Field(None, max_length=50) + search_space_id: int | None = None + + +class QuickAskActionUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=200) + prompt: str | None = Field(None, min_length=1) + mode: str | None = Field(None, pattern="^(transform|explore)$") + icon: str | None = Field(None, max_length=50) + + +class QuickAskActionRead(BaseModel): + id: int + name: str + prompt: str + mode: str + icon: str | None + search_space_id: int | None + created_at: datetime + + class Config: + from_attributes = True