mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-02 19:55:18 +02:00
feat: Implement Role-Based Access Control (RBAC) for search space resources.
-Introduce granular permissions for documents, chats, podcasts, and logs. - Update routes to enforce permission checks for creating, reading, updating, and deleting resources. - Refactor user and search space interactions to align with RBAC model, removing ownership checks in favor of permission validation.
This commit is contained in:
parent
1ed0cb3dfe
commit
e9d32c3516
38 changed files with 5916 additions and 657 deletions
179
surfsense_backend/alembic/versions/39_add_rbac_tables.py
Normal file
179
surfsense_backend/alembic/versions/39_add_rbac_tables.py
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
"""Add RBAC tables for search space access control
|
||||||
|
|
||||||
|
Revision ID: 39
|
||||||
|
Revises: 38
|
||||||
|
Create Date: 2025-11-27 00:00:00.000000
|
||||||
|
|
||||||
|
This migration adds:
|
||||||
|
- Permission enum for granular access control
|
||||||
|
- search_space_roles table for custom roles per search space
|
||||||
|
- search_space_memberships table for user-searchspace-role relationships
|
||||||
|
- search_space_invites table for invite links
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "39"
|
||||||
|
down_revision: str | None = "38"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema - add RBAC tables for search space access control."""
|
||||||
|
|
||||||
|
# Create search_space_roles table
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS search_space_roles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description VARCHAR(500),
|
||||||
|
permissions TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
is_default BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_system_role BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT uq_searchspace_role_name UNIQUE (search_space_id, name)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create search_space_invites table (needs to be created before memberships due to FK)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS search_space_invites (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
invite_code VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE,
|
||||||
|
role_id INTEGER REFERENCES search_space_roles(id) ON DELETE SET NULL,
|
||||||
|
created_by_id UUID REFERENCES "user"(id) ON DELETE SET NULL,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
max_uses INTEGER,
|
||||||
|
uses_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
name VARCHAR(100)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create search_space_memberships table
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS search_space_memberships (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE,
|
||||||
|
role_id INTEGER REFERENCES search_space_roles(id) ON DELETE SET NULL,
|
||||||
|
is_owner BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
invited_by_invite_id INTEGER REFERENCES search_space_invites(id) ON DELETE SET NULL,
|
||||||
|
CONSTRAINT uq_user_searchspace_membership UNIQUE (user_id, search_space_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get connection and inspector for checking existing indexes
|
||||||
|
conn = op.get_bind()
|
||||||
|
inspector = inspect(conn)
|
||||||
|
|
||||||
|
# Create indexes for search_space_roles
|
||||||
|
existing_indexes = [
|
||||||
|
idx["name"] for idx in inspector.get_indexes("search_space_roles")
|
||||||
|
]
|
||||||
|
if "ix_search_space_roles_id" not in existing_indexes:
|
||||||
|
op.create_index("ix_search_space_roles_id", "search_space_roles", ["id"])
|
||||||
|
if "ix_search_space_roles_created_at" not in existing_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_search_space_roles_created_at", "search_space_roles", ["created_at"]
|
||||||
|
)
|
||||||
|
if "ix_search_space_roles_name" not in existing_indexes:
|
||||||
|
op.create_index("ix_search_space_roles_name", "search_space_roles", ["name"])
|
||||||
|
|
||||||
|
# Create indexes for search_space_memberships
|
||||||
|
existing_indexes = [
|
||||||
|
idx["name"] for idx in inspector.get_indexes("search_space_memberships")
|
||||||
|
]
|
||||||
|
if "ix_search_space_memberships_id" not in existing_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_search_space_memberships_id", "search_space_memberships", ["id"]
|
||||||
|
)
|
||||||
|
if "ix_search_space_memberships_created_at" not in existing_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_search_space_memberships_created_at",
|
||||||
|
"search_space_memberships",
|
||||||
|
["created_at"],
|
||||||
|
)
|
||||||
|
if "ix_search_space_memberships_user_id" not in existing_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_search_space_memberships_user_id",
|
||||||
|
"search_space_memberships",
|
||||||
|
["user_id"],
|
||||||
|
)
|
||||||
|
if "ix_search_space_memberships_search_space_id" not in existing_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_search_space_memberships_search_space_id",
|
||||||
|
"search_space_memberships",
|
||||||
|
["search_space_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for search_space_invites
|
||||||
|
existing_indexes = [
|
||||||
|
idx["name"] for idx in inspector.get_indexes("search_space_invites")
|
||||||
|
]
|
||||||
|
if "ix_search_space_invites_id" not in existing_indexes:
|
||||||
|
op.create_index("ix_search_space_invites_id", "search_space_invites", ["id"])
|
||||||
|
if "ix_search_space_invites_created_at" not in existing_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_search_space_invites_created_at", "search_space_invites", ["created_at"]
|
||||||
|
)
|
||||||
|
if "ix_search_space_invites_invite_code" not in existing_indexes:
|
||||||
|
op.create_index(
|
||||||
|
"ix_search_space_invites_invite_code",
|
||||||
|
"search_space_invites",
|
||||||
|
["invite_code"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema - remove RBAC tables."""
|
||||||
|
|
||||||
|
# Drop indexes for search_space_memberships
|
||||||
|
op.drop_index(
|
||||||
|
"ix_search_space_memberships_search_space_id",
|
||||||
|
table_name="search_space_memberships",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_search_space_memberships_user_id", table_name="search_space_memberships"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_search_space_memberships_created_at", table_name="search_space_memberships"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_search_space_memberships_id", table_name="search_space_memberships"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop indexes for search_space_invites
|
||||||
|
op.drop_index(
|
||||||
|
"ix_search_space_invites_invite_code", table_name="search_space_invites"
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"ix_search_space_invites_created_at", table_name="search_space_invites"
|
||||||
|
)
|
||||||
|
op.drop_index("ix_search_space_invites_id", table_name="search_space_invites")
|
||||||
|
|
||||||
|
# Drop indexes for search_space_roles
|
||||||
|
op.drop_index("ix_search_space_roles_name", table_name="search_space_roles")
|
||||||
|
op.drop_index("ix_search_space_roles_created_at", table_name="search_space_roles")
|
||||||
|
op.drop_index("ix_search_space_roles_id", table_name="search_space_roles")
|
||||||
|
|
||||||
|
# Drop tables in correct order (respecting foreign key constraints)
|
||||||
|
op.drop_table("search_space_memberships")
|
||||||
|
op.drop_table("search_space_invites")
|
||||||
|
op.drop_table("search_space_roles")
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
"""Move LLM preferences from user-level to search space level
|
||||||
|
|
||||||
|
Revision ID: 40
|
||||||
|
Revises: 39
|
||||||
|
Create Date: 2024-11-27
|
||||||
|
|
||||||
|
This migration moves LLM preferences (long_context_llm_id, fast_llm_id, strategic_llm_id)
|
||||||
|
from the user_search_space_preferences table to the searchspaces table itself.
|
||||||
|
|
||||||
|
This change supports the RBAC model where LLM preferences are shared by all members
|
||||||
|
of a search space, rather than being per-user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "40"
|
||||||
|
down_revision = "39"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add LLM preference columns to searchspaces table
|
||||||
|
op.add_column(
|
||||||
|
"searchspaces",
|
||||||
|
sa.Column("long_context_llm_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"searchspaces",
|
||||||
|
sa.Column("fast_llm_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"searchspaces",
|
||||||
|
sa.Column("strategic_llm_id", sa.Integer(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Migrate existing preferences from user_search_space_preferences to searchspaces
|
||||||
|
# We take the owner's preferences (the user who created the search space)
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
# Get all search spaces and their owner's preferences
|
||||||
|
connection.execute(
|
||||||
|
sa.text("""
|
||||||
|
UPDATE searchspaces ss
|
||||||
|
SET
|
||||||
|
long_context_llm_id = usp.long_context_llm_id,
|
||||||
|
fast_llm_id = usp.fast_llm_id,
|
||||||
|
strategic_llm_id = usp.strategic_llm_id
|
||||||
|
FROM user_search_space_preferences usp
|
||||||
|
WHERE ss.id = usp.search_space_id
|
||||||
|
AND ss.user_id = usp.user_id
|
||||||
|
""")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Remove LLM preference columns from searchspaces table
|
||||||
|
op.drop_column("searchspaces", "strategic_llm_id")
|
||||||
|
op.drop_column("searchspaces", "fast_llm_id")
|
||||||
|
op.drop_column("searchspaces", "long_context_llm_id")
|
||||||
|
|
@ -11,7 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
# Additional imports for document fetching
|
# Additional imports for document fetching
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.db import Document, SearchSpace
|
from app.db import Document
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
from app.services.query_service import QueryService
|
from app.services.query_service import QueryService
|
||||||
|
|
||||||
|
|
@ -92,19 +92,18 @@ def extract_sources_from_documents(
|
||||||
|
|
||||||
|
|
||||||
async def fetch_documents_by_ids(
|
async def fetch_documents_by_ids(
|
||||||
document_ids: list[int], user_id: str, db_session: AsyncSession
|
document_ids: list[int], search_space_id: int, db_session: AsyncSession
|
||||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
"""
|
"""
|
||||||
Fetch documents by their IDs with ownership check using DOCUMENTS mode approach.
|
Fetch documents by their IDs within a search space.
|
||||||
|
|
||||||
This function ensures that only documents belonging to the user are fetched,
|
This function ensures that only documents belonging to the search space are fetched.
|
||||||
providing security by checking ownership through SearchSpace association.
|
|
||||||
Similar to SearchMode.DOCUMENTS, it fetches full documents and concatenates their chunks.
|
Similar to SearchMode.DOCUMENTS, it fetches full documents and concatenates their chunks.
|
||||||
Also creates source objects for UI display, grouped by document type.
|
Also creates source objects for UI display, grouped by document type.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
document_ids: List of document IDs to fetch
|
document_ids: List of document IDs to fetch
|
||||||
user_id: The user ID to check ownership
|
search_space_id: The search space ID to filter by
|
||||||
db_session: The database session
|
db_session: The database session
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -114,11 +113,12 @@ async def fetch_documents_by_ids(
|
||||||
return [], []
|
return [], []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Query documents with ownership check
|
# Query documents filtered by search space
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
select(Document)
|
select(Document).filter(
|
||||||
.join(SearchSpace)
|
Document.id.in_(document_ids),
|
||||||
.filter(Document.id.in_(document_ids), SearchSpace.user_id == user_id)
|
Document.search_space_id == search_space_id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
documents = result.scalars().all()
|
documents = result.scalars().all()
|
||||||
|
|
||||||
|
|
@ -515,7 +515,6 @@ async def fetch_documents_by_ids(
|
||||||
|
|
||||||
async def fetch_relevant_documents(
|
async def fetch_relevant_documents(
|
||||||
research_questions: list[str],
|
research_questions: list[str],
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
connectors_to_search: list[str],
|
connectors_to_search: list[str],
|
||||||
|
|
@ -536,7 +535,6 @@ async def fetch_relevant_documents(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
research_questions: List of research questions to find documents for
|
research_questions: List of research questions to find documents for
|
||||||
user_id: The user ID
|
|
||||||
search_space_id: The search space ID
|
search_space_id: The search space ID
|
||||||
db_session: The database session
|
db_session: The database session
|
||||||
connectors_to_search: List of connectors to search
|
connectors_to_search: List of connectors to search
|
||||||
|
|
@ -619,7 +617,6 @@ async def fetch_relevant_documents(
|
||||||
youtube_chunks,
|
youtube_chunks,
|
||||||
) = await connector_service.search_youtube(
|
) = await connector_service.search_youtube(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -646,7 +643,6 @@ async def fetch_relevant_documents(
|
||||||
extension_chunks,
|
extension_chunks,
|
||||||
) = await connector_service.search_extension(
|
) = await connector_service.search_extension(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -673,7 +669,6 @@ async def fetch_relevant_documents(
|
||||||
crawled_urls_chunks,
|
crawled_urls_chunks,
|
||||||
) = await connector_service.search_crawled_urls(
|
) = await connector_service.search_crawled_urls(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -697,7 +692,6 @@ async def fetch_relevant_documents(
|
||||||
elif connector == "FILE":
|
elif connector == "FILE":
|
||||||
source_object, files_chunks = await connector_service.search_files(
|
source_object, files_chunks = await connector_service.search_files(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -721,7 +715,6 @@ async def fetch_relevant_documents(
|
||||||
elif connector == "SLACK_CONNECTOR":
|
elif connector == "SLACK_CONNECTOR":
|
||||||
source_object, slack_chunks = await connector_service.search_slack(
|
source_object, slack_chunks = await connector_service.search_slack(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -748,7 +741,6 @@ async def fetch_relevant_documents(
|
||||||
notion_chunks,
|
notion_chunks,
|
||||||
) = await connector_service.search_notion(
|
) = await connector_service.search_notion(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -775,7 +767,6 @@ async def fetch_relevant_documents(
|
||||||
github_chunks,
|
github_chunks,
|
||||||
) = await connector_service.search_github(
|
) = await connector_service.search_github(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -802,7 +793,6 @@ async def fetch_relevant_documents(
|
||||||
linear_chunks,
|
linear_chunks,
|
||||||
) = await connector_service.search_linear(
|
) = await connector_service.search_linear(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -829,7 +819,6 @@ async def fetch_relevant_documents(
|
||||||
tavily_chunks,
|
tavily_chunks,
|
||||||
) = await connector_service.search_tavily(
|
) = await connector_service.search_tavily(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
)
|
)
|
||||||
|
|
@ -855,7 +844,6 @@ async def fetch_relevant_documents(
|
||||||
searx_chunks,
|
searx_chunks,
|
||||||
) = await connector_service.search_searxng(
|
) = await connector_service.search_searxng(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
)
|
)
|
||||||
|
|
@ -881,7 +869,6 @@ async def fetch_relevant_documents(
|
||||||
linkup_chunks,
|
linkup_chunks,
|
||||||
) = await connector_service.search_linkup(
|
) = await connector_service.search_linkup(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
mode=linkup_mode,
|
mode=linkup_mode,
|
||||||
)
|
)
|
||||||
|
|
@ -907,7 +894,6 @@ async def fetch_relevant_documents(
|
||||||
baidu_chunks,
|
baidu_chunks,
|
||||||
) = await connector_service.search_baidu(
|
) = await connector_service.search_baidu(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
)
|
)
|
||||||
|
|
@ -933,7 +919,6 @@ async def fetch_relevant_documents(
|
||||||
discord_chunks,
|
discord_chunks,
|
||||||
) = await connector_service.search_discord(
|
) = await connector_service.search_discord(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -955,7 +940,6 @@ async def fetch_relevant_documents(
|
||||||
elif connector == "JIRA_CONNECTOR":
|
elif connector == "JIRA_CONNECTOR":
|
||||||
source_object, jira_chunks = await connector_service.search_jira(
|
source_object, jira_chunks = await connector_service.search_jira(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -981,7 +965,6 @@ async def fetch_relevant_documents(
|
||||||
calendar_chunks,
|
calendar_chunks,
|
||||||
) = await connector_service.search_google_calendar(
|
) = await connector_service.search_google_calendar(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -1007,7 +990,6 @@ async def fetch_relevant_documents(
|
||||||
airtable_chunks,
|
airtable_chunks,
|
||||||
) = await connector_service.search_airtable(
|
) = await connector_service.search_airtable(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -1033,7 +1015,6 @@ async def fetch_relevant_documents(
|
||||||
gmail_chunks,
|
gmail_chunks,
|
||||||
) = await connector_service.search_google_gmail(
|
) = await connector_service.search_google_gmail(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -1059,7 +1040,6 @@ async def fetch_relevant_documents(
|
||||||
confluence_chunks,
|
confluence_chunks,
|
||||||
) = await connector_service.search_confluence(
|
) = await connector_service.search_confluence(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -1085,7 +1065,6 @@ async def fetch_relevant_documents(
|
||||||
clickup_chunks,
|
clickup_chunks,
|
||||||
) = await connector_service.search_clickup(
|
) = await connector_service.search_clickup(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -1112,7 +1091,6 @@ async def fetch_relevant_documents(
|
||||||
luma_chunks,
|
luma_chunks,
|
||||||
) = await connector_service.search_luma(
|
) = await connector_service.search_luma(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -1139,7 +1117,6 @@ async def fetch_relevant_documents(
|
||||||
elasticsearch_chunks,
|
elasticsearch_chunks,
|
||||||
) = await connector_service.search_elasticsearch(
|
) = await connector_service.search_elasticsearch(
|
||||||
user_query=reformulated_query,
|
user_query=reformulated_query,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
search_mode=search_mode,
|
search_mode=search_mode,
|
||||||
|
|
@ -1315,7 +1292,6 @@ async def reformulate_user_query(
|
||||||
reformulated_query = await QueryService.reformulate_query_with_chat_history(
|
reformulated_query = await QueryService.reformulate_query_with_chat_history(
|
||||||
user_query=user_query,
|
user_query=user_query,
|
||||||
session=state.db_session,
|
session=state.db_session,
|
||||||
user_id=configuration.user_id,
|
|
||||||
search_space_id=configuration.search_space_id,
|
search_space_id=configuration.search_space_id,
|
||||||
chat_history_str=chat_history_str,
|
chat_history_str=chat_history_str,
|
||||||
)
|
)
|
||||||
|
|
@ -1389,7 +1365,7 @@ async def handle_qna_workflow(
|
||||||
user_selected_documents,
|
user_selected_documents,
|
||||||
) = await fetch_documents_by_ids(
|
) = await fetch_documents_by_ids(
|
||||||
document_ids=configuration.document_ids_to_add_in_context,
|
document_ids=configuration.document_ids_to_add_in_context,
|
||||||
user_id=configuration.user_id,
|
search_space_id=configuration.search_space_id,
|
||||||
db_session=state.db_session,
|
db_session=state.db_session,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1404,7 +1380,7 @@ async def handle_qna_workflow(
|
||||||
|
|
||||||
# Create connector service using state db_session
|
# Create connector service using state db_session
|
||||||
connector_service = ConnectorService(
|
connector_service = ConnectorService(
|
||||||
state.db_session, user_id=configuration.user_id
|
state.db_session, search_space_id=configuration.search_space_id
|
||||||
)
|
)
|
||||||
await connector_service.initialize_counter()
|
await connector_service.initialize_counter()
|
||||||
|
|
||||||
|
|
@ -1413,7 +1389,6 @@ async def handle_qna_workflow(
|
||||||
|
|
||||||
relevant_documents = await fetch_relevant_documents(
|
relevant_documents = await fetch_relevant_documents(
|
||||||
research_questions=research_questions,
|
research_questions=research_questions,
|
||||||
user_id=configuration.user_id,
|
|
||||||
search_space_id=configuration.search_space_id,
|
search_space_id=configuration.search_space_id,
|
||||||
db_session=state.db_session,
|
db_session=state.db_session,
|
||||||
connectors_to_search=configuration.connectors_to_search,
|
connectors_to_search=configuration.connectors_to_search,
|
||||||
|
|
@ -1459,7 +1434,6 @@ async def handle_qna_workflow(
|
||||||
"user_query": user_query, # Use the reformulated query
|
"user_query": user_query, # Use the reformulated query
|
||||||
"reformulated_query": reformulated_query,
|
"reformulated_query": reformulated_query,
|
||||||
"relevant_documents": all_documents, # Use combined documents
|
"relevant_documents": all_documents, # Use combined documents
|
||||||
"user_id": configuration.user_id,
|
|
||||||
"search_space_id": configuration.search_space_id,
|
"search_space_id": configuration.search_space_id,
|
||||||
"language": configuration.language,
|
"language": configuration.language,
|
||||||
}
|
}
|
||||||
|
|
@ -1551,12 +1525,11 @@ async def generate_further_questions(
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing the further questions in the "further_questions" key for state update.
|
Dict containing the further questions in the "further_questions" key for state update.
|
||||||
"""
|
"""
|
||||||
from app.services.llm_service import get_user_fast_llm
|
from app.services.llm_service import get_fast_llm
|
||||||
|
|
||||||
# Get configuration and state data
|
# Get configuration and state data
|
||||||
configuration = Configuration.from_runnable_config(config)
|
configuration = Configuration.from_runnable_config(config)
|
||||||
chat_history = state.chat_history
|
chat_history = state.chat_history
|
||||||
user_id = configuration.user_id
|
|
||||||
search_space_id = configuration.search_space_id
|
search_space_id = configuration.search_space_id
|
||||||
streaming_service = state.streaming_service
|
streaming_service = state.streaming_service
|
||||||
|
|
||||||
|
|
@ -1571,10 +1544,10 @@ async def generate_further_questions(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user's fast LLM
|
# Get search space's fast LLM
|
||||||
llm = await get_user_fast_llm(state.db_session, user_id, search_space_id)
|
llm = await get_fast_llm(state.db_session, search_space_id)
|
||||||
if not llm:
|
if not llm:
|
||||||
error_message = f"No fast LLM configured for user {user_id} in search space {search_space_id}"
|
error_message = f"No fast LLM configured for search space {search_space_id}"
|
||||||
print(error_message)
|
print(error_message)
|
||||||
writer({"yield_value": streaming_service.format_error(error_message)})
|
writer({"yield_value": streaming_service.format_error(error_message)})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ class Configuration:
|
||||||
relevant_documents: list[
|
relevant_documents: list[
|
||||||
Any
|
Any
|
||||||
] # Documents provided directly to the agent for answering
|
] # Documents provided directly to the agent for answering
|
||||||
user_id: str # User identifier
|
|
||||||
search_space_id: int # Search space identifier
|
search_space_id: int # Search space identifier
|
||||||
language: str | None = None # Language for responses
|
language: str | None = None # Language for responses
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,12 @@ async def answer_question(state: State, config: RunnableConfig) -> dict[str, Any
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing the final answer in the "final_answer" key.
|
Dict containing the final answer in the "final_answer" key.
|
||||||
"""
|
"""
|
||||||
from app.services.llm_service import get_user_fast_llm
|
from app.services.llm_service import get_fast_llm
|
||||||
|
|
||||||
# Get configuration and relevant documents from configuration
|
# Get configuration and relevant documents from configuration
|
||||||
configuration = Configuration.from_runnable_config(config)
|
configuration = Configuration.from_runnable_config(config)
|
||||||
documents = state.reranked_documents
|
documents = state.reranked_documents
|
||||||
user_query = configuration.user_query
|
user_query = configuration.user_query
|
||||||
user_id = configuration.user_id
|
|
||||||
search_space_id = configuration.search_space_id
|
search_space_id = configuration.search_space_id
|
||||||
language = configuration.language
|
language = configuration.language
|
||||||
|
|
||||||
|
|
@ -178,10 +177,10 @@ async def answer_question(state: State, config: RunnableConfig) -> dict[str, Any
|
||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get user's fast LLM
|
# Get search space's fast LLM
|
||||||
llm = await get_user_fast_llm(state.db_session, user_id, search_space_id)
|
llm = await get_fast_llm(state.db_session, search_space_id)
|
||||||
if not llm:
|
if not llm:
|
||||||
error_message = f"No fast LLM configured for user {user_id} in search space {search_space_id}"
|
error_message = f"No fast LLM configured for search space {search_space_id}"
|
||||||
print(error_message)
|
print(error_message)
|
||||||
raise RuntimeError(error_message)
|
raise RuntimeError(error_message)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,169 @@ class LogStatus(str, Enum):
|
||||||
FAILED = "FAILED"
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
class Permission(str, Enum):
|
||||||
|
"""
|
||||||
|
Granular permissions for search space resources.
|
||||||
|
Use '*' (FULL_ACCESS) to grant all permissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Documents
|
||||||
|
DOCUMENTS_CREATE = "documents:create"
|
||||||
|
DOCUMENTS_READ = "documents:read"
|
||||||
|
DOCUMENTS_UPDATE = "documents:update"
|
||||||
|
DOCUMENTS_DELETE = "documents:delete"
|
||||||
|
|
||||||
|
# Chats
|
||||||
|
CHATS_CREATE = "chats:create"
|
||||||
|
CHATS_READ = "chats:read"
|
||||||
|
CHATS_UPDATE = "chats:update"
|
||||||
|
CHATS_DELETE = "chats:delete"
|
||||||
|
|
||||||
|
# LLM Configs
|
||||||
|
LLM_CONFIGS_CREATE = "llm_configs:create"
|
||||||
|
LLM_CONFIGS_READ = "llm_configs:read"
|
||||||
|
LLM_CONFIGS_UPDATE = "llm_configs:update"
|
||||||
|
LLM_CONFIGS_DELETE = "llm_configs:delete"
|
||||||
|
|
||||||
|
# Podcasts
|
||||||
|
PODCASTS_CREATE = "podcasts:create"
|
||||||
|
PODCASTS_READ = "podcasts:read"
|
||||||
|
PODCASTS_UPDATE = "podcasts:update"
|
||||||
|
PODCASTS_DELETE = "podcasts:delete"
|
||||||
|
|
||||||
|
# Connectors
|
||||||
|
CONNECTORS_CREATE = "connectors:create"
|
||||||
|
CONNECTORS_READ = "connectors:read"
|
||||||
|
CONNECTORS_UPDATE = "connectors:update"
|
||||||
|
CONNECTORS_DELETE = "connectors:delete"
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
LOGS_READ = "logs:read"
|
||||||
|
LOGS_DELETE = "logs:delete"
|
||||||
|
|
||||||
|
# Members
|
||||||
|
MEMBERS_INVITE = "members:invite"
|
||||||
|
MEMBERS_VIEW = "members:view"
|
||||||
|
MEMBERS_REMOVE = "members:remove"
|
||||||
|
MEMBERS_MANAGE_ROLES = "members:manage_roles"
|
||||||
|
|
||||||
|
# Roles
|
||||||
|
ROLES_CREATE = "roles:create"
|
||||||
|
ROLES_READ = "roles:read"
|
||||||
|
ROLES_UPDATE = "roles:update"
|
||||||
|
ROLES_DELETE = "roles:delete"
|
||||||
|
|
||||||
|
# Search Space Settings
|
||||||
|
SETTINGS_VIEW = "settings:view"
|
||||||
|
SETTINGS_UPDATE = "settings:update"
|
||||||
|
SETTINGS_DELETE = "settings:delete" # Delete the entire search space
|
||||||
|
|
||||||
|
# Full access wildcard
|
||||||
|
FULL_ACCESS = "*"
|
||||||
|
|
||||||
|
|
||||||
|
# Predefined role permission sets for convenience
|
||||||
|
DEFAULT_ROLE_PERMISSIONS = {
|
||||||
|
"Owner": [Permission.FULL_ACCESS.value],
|
||||||
|
"Admin": [
|
||||||
|
# Documents
|
||||||
|
Permission.DOCUMENTS_CREATE.value,
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
Permission.DOCUMENTS_UPDATE.value,
|
||||||
|
Permission.DOCUMENTS_DELETE.value,
|
||||||
|
# Chats
|
||||||
|
Permission.CHATS_CREATE.value,
|
||||||
|
Permission.CHATS_READ.value,
|
||||||
|
Permission.CHATS_UPDATE.value,
|
||||||
|
Permission.CHATS_DELETE.value,
|
||||||
|
# LLM Configs
|
||||||
|
Permission.LLM_CONFIGS_CREATE.value,
|
||||||
|
Permission.LLM_CONFIGS_READ.value,
|
||||||
|
Permission.LLM_CONFIGS_UPDATE.value,
|
||||||
|
Permission.LLM_CONFIGS_DELETE.value,
|
||||||
|
# Podcasts
|
||||||
|
Permission.PODCASTS_CREATE.value,
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
Permission.PODCASTS_UPDATE.value,
|
||||||
|
Permission.PODCASTS_DELETE.value,
|
||||||
|
# Connectors
|
||||||
|
Permission.CONNECTORS_CREATE.value,
|
||||||
|
Permission.CONNECTORS_READ.value,
|
||||||
|
Permission.CONNECTORS_UPDATE.value,
|
||||||
|
Permission.CONNECTORS_DELETE.value,
|
||||||
|
# Logs
|
||||||
|
Permission.LOGS_READ.value,
|
||||||
|
Permission.LOGS_DELETE.value,
|
||||||
|
# Members
|
||||||
|
Permission.MEMBERS_INVITE.value,
|
||||||
|
Permission.MEMBERS_VIEW.value,
|
||||||
|
Permission.MEMBERS_REMOVE.value,
|
||||||
|
Permission.MEMBERS_MANAGE_ROLES.value,
|
||||||
|
# Roles
|
||||||
|
Permission.ROLES_CREATE.value,
|
||||||
|
Permission.ROLES_READ.value,
|
||||||
|
Permission.ROLES_UPDATE.value,
|
||||||
|
Permission.ROLES_DELETE.value,
|
||||||
|
# Settings (no delete)
|
||||||
|
Permission.SETTINGS_VIEW.value,
|
||||||
|
Permission.SETTINGS_UPDATE.value,
|
||||||
|
],
|
||||||
|
"Editor": [
|
||||||
|
# Documents
|
||||||
|
Permission.DOCUMENTS_CREATE.value,
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
Permission.DOCUMENTS_UPDATE.value,
|
||||||
|
Permission.DOCUMENTS_DELETE.value,
|
||||||
|
# Chats
|
||||||
|
Permission.CHATS_CREATE.value,
|
||||||
|
Permission.CHATS_READ.value,
|
||||||
|
Permission.CHATS_UPDATE.value,
|
||||||
|
Permission.CHATS_DELETE.value,
|
||||||
|
# LLM Configs (read only)
|
||||||
|
Permission.LLM_CONFIGS_READ.value,
|
||||||
|
Permission.LLM_CONFIGS_CREATE.value,
|
||||||
|
Permission.LLM_CONFIGS_UPDATE.value,
|
||||||
|
# Podcasts
|
||||||
|
Permission.PODCASTS_CREATE.value,
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
Permission.PODCASTS_UPDATE.value,
|
||||||
|
Permission.PODCASTS_DELETE.value,
|
||||||
|
# Connectors (full access for editors)
|
||||||
|
Permission.CONNECTORS_CREATE.value,
|
||||||
|
Permission.CONNECTORS_READ.value,
|
||||||
|
Permission.CONNECTORS_UPDATE.value,
|
||||||
|
# Logs
|
||||||
|
Permission.LOGS_READ.value,
|
||||||
|
# Members (view only)
|
||||||
|
Permission.MEMBERS_VIEW.value,
|
||||||
|
# Roles (read only)
|
||||||
|
Permission.ROLES_READ.value,
|
||||||
|
# Settings (view only)
|
||||||
|
Permission.SETTINGS_VIEW.value,
|
||||||
|
],
|
||||||
|
"Viewer": [
|
||||||
|
# Documents (read only)
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
# Chats (read only)
|
||||||
|
Permission.CHATS_READ.value,
|
||||||
|
# LLM Configs (read only)
|
||||||
|
Permission.LLM_CONFIGS_READ.value,
|
||||||
|
# Podcasts (read only)
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
# Connectors (read only)
|
||||||
|
Permission.CONNECTORS_READ.value,
|
||||||
|
# Logs (read only)
|
||||||
|
Permission.LOGS_READ.value,
|
||||||
|
# Members (view only)
|
||||||
|
Permission.MEMBERS_VIEW.value,
|
||||||
|
# Roles (read only)
|
||||||
|
Permission.ROLES_READ.value,
|
||||||
|
# Settings (view only)
|
||||||
|
Permission.SETTINGS_VIEW.value,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -230,6 +393,13 @@ class SearchSpace(BaseModel, TimestampMixin):
|
||||||
qna_custom_instructions = Column(
|
qna_custom_instructions = Column(
|
||||||
Text, nullable=True, default=""
|
Text, nullable=True, default=""
|
||||||
) # User's custom instructions
|
) # User's custom instructions
|
||||||
|
|
||||||
|
# Search space-level LLM preferences (shared by all members)
|
||||||
|
# Note: These can be negative IDs for global configs (from YAML) or positive IDs for custom configs (from DB)
|
||||||
|
long_context_llm_id = Column(Integer, nullable=True)
|
||||||
|
fast_llm_id = Column(Integer, nullable=True)
|
||||||
|
strategic_llm_id = Column(Integer, nullable=True)
|
||||||
|
|
||||||
user_id = Column(
|
user_id = Column(
|
||||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
|
|
@ -277,6 +447,26 @@ class SearchSpace(BaseModel, TimestampMixin):
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RBAC relationships
|
||||||
|
roles = relationship(
|
||||||
|
"SearchSpaceRole",
|
||||||
|
back_populates="search_space",
|
||||||
|
order_by="SearchSpaceRole.id",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
memberships = relationship(
|
||||||
|
"SearchSpaceMembership",
|
||||||
|
back_populates="search_space",
|
||||||
|
order_by="SearchSpaceMembership.id",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
invites = relationship(
|
||||||
|
"SearchSpaceInvite",
|
||||||
|
back_populates="search_space",
|
||||||
|
order_by="SearchSpaceInvite.id",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SearchSourceConnector(BaseModel, TimestampMixin):
|
class SearchSourceConnector(BaseModel, TimestampMixin):
|
||||||
__tablename__ = "search_source_connectors"
|
__tablename__ = "search_source_connectors"
|
||||||
|
|
@ -368,13 +558,6 @@ class UserSearchSpacePreference(BaseModel, TimestampMixin):
|
||||||
user = relationship("User", back_populates="search_space_preferences")
|
user = relationship("User", back_populates="search_space_preferences")
|
||||||
search_space = relationship("SearchSpace", back_populates="user_preferences")
|
search_space = relationship("SearchSpace", back_populates="user_preferences")
|
||||||
|
|
||||||
# Note: Relationships removed because foreign keys no longer exist
|
|
||||||
# Global configs (negative IDs) don't exist in llm_configs table
|
|
||||||
# Application code manually fetches configs when needed
|
|
||||||
# long_context_llm = relationship("LLMConfig", foreign_keys=[long_context_llm_id], post_update=True)
|
|
||||||
# fast_llm = relationship("LLMConfig", foreign_keys=[fast_llm_id], post_update=True)
|
|
||||||
# strategic_llm = relationship("LLMConfig", foreign_keys=[strategic_llm_id], post_update=True)
|
|
||||||
|
|
||||||
|
|
||||||
class Log(BaseModel, TimestampMixin):
|
class Log(BaseModel, TimestampMixin):
|
||||||
__tablename__ = "logs"
|
__tablename__ = "logs"
|
||||||
|
|
@ -393,6 +576,140 @@ class Log(BaseModel, TimestampMixin):
|
||||||
search_space = relationship("SearchSpace", back_populates="logs")
|
search_space = relationship("SearchSpace", back_populates="logs")
|
||||||
|
|
||||||
|
|
||||||
|
class SearchSpaceRole(BaseModel, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Custom roles that can be defined per search space.
|
||||||
|
Each search space can have multiple roles with different permission sets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "search_space_roles"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"search_space_id",
|
||||||
|
"name",
|
||||||
|
name="uq_searchspace_role_name",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
name = Column(String(100), nullable=False, index=True)
|
||||||
|
description = Column(String(500), nullable=True)
|
||||||
|
# List of Permission enum values (e.g., ["documents:read", "chats:create"])
|
||||||
|
permissions = Column(ARRAY(String), nullable=False, default=[])
|
||||||
|
# Whether this role is assigned to new members by default when they join via invite
|
||||||
|
is_default = Column(Boolean, nullable=False, default=False)
|
||||||
|
# System roles (Owner, Admin, Editor, Viewer) cannot be deleted
|
||||||
|
is_system_role = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
search_space_id = Column(
|
||||||
|
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
search_space = relationship("SearchSpace", back_populates="roles")
|
||||||
|
|
||||||
|
memberships = relationship(
|
||||||
|
"SearchSpaceMembership", back_populates="role", passive_deletes=True
|
||||||
|
)
|
||||||
|
invites = relationship(
|
||||||
|
"SearchSpaceInvite", back_populates="role", passive_deletes=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchSpaceMembership(BaseModel, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Tracks user membership in search spaces with their assigned role.
|
||||||
|
Each user can be a member of multiple search spaces with different roles.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "search_space_memberships"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_id",
|
||||||
|
"search_space_id",
|
||||||
|
name="uq_user_searchspace_membership",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
search_space_id = Column(
|
||||||
|
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
role_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("search_space_roles.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
# Indicates if this user is the original creator/owner of the search space
|
||||||
|
is_owner = Column(Boolean, nullable=False, default=False)
|
||||||
|
# Timestamp when the user joined (via invite or as creator)
|
||||||
|
joined_at = Column(
|
||||||
|
TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
# Reference to the invite used to join (null if owner/creator)
|
||||||
|
invited_by_invite_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("search_space_invites.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="search_space_memberships")
|
||||||
|
search_space = relationship("SearchSpace", back_populates="memberships")
|
||||||
|
role = relationship("SearchSpaceRole", back_populates="memberships")
|
||||||
|
invited_by_invite = relationship(
|
||||||
|
"SearchSpaceInvite", back_populates="used_by_memberships"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchSpaceInvite(BaseModel, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Invite links for search spaces.
|
||||||
|
Users can create invite links with specific roles that others can use to join.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "search_space_invites"
|
||||||
|
|
||||||
|
# Unique invite code (used in invite URLs)
|
||||||
|
invite_code = Column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
search_space_id = Column(
|
||||||
|
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
# Role to assign when invite is used (null means use default role)
|
||||||
|
role_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("search_space_roles.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
# User who created this invite
|
||||||
|
created_by_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expiration timestamp (null means never expires)
|
||||||
|
expires_at = Column(TIMESTAMP(timezone=True), nullable=True)
|
||||||
|
# Maximum number of times this invite can be used (null means unlimited)
|
||||||
|
max_uses = Column(Integer, nullable=True)
|
||||||
|
# Number of times this invite has been used
|
||||||
|
uses_count = Column(Integer, nullable=False, default=0)
|
||||||
|
# Whether this invite is currently active
|
||||||
|
is_active = Column(Boolean, nullable=False, default=True)
|
||||||
|
# Optional custom name/label for the invite
|
||||||
|
name = Column(String(100), nullable=True)
|
||||||
|
|
||||||
|
search_space = relationship("SearchSpace", back_populates="invites")
|
||||||
|
role = relationship("SearchSpaceRole", back_populates="invites")
|
||||||
|
created_by = relationship("User", back_populates="created_invites")
|
||||||
|
used_by_memberships = relationship(
|
||||||
|
"SearchSpaceMembership",
|
||||||
|
back_populates="invited_by_invite",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if config.AUTH_TYPE == "GOOGLE":
|
if config.AUTH_TYPE == "GOOGLE":
|
||||||
|
|
||||||
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
|
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
|
||||||
|
|
@ -409,6 +726,18 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RBAC relationships
|
||||||
|
search_space_memberships = relationship(
|
||||||
|
"SearchSpaceMembership",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
created_invites = relationship(
|
||||||
|
"SearchSpaceInvite",
|
||||||
|
back_populates="created_by",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(Integer, nullable=False, default=500, server_default="500")
|
pages_limit = Column(Integer, nullable=False, default=500, server_default="500")
|
||||||
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
|
@ -423,6 +752,18 @@ else:
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RBAC relationships
|
||||||
|
search_space_memberships = relationship(
|
||||||
|
"SearchSpaceMembership",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
created_invites = relationship(
|
||||||
|
"SearchSpaceInvite",
|
||||||
|
back_populates="created_by",
|
||||||
|
passive_deletes=True,
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(Integer, nullable=False, default=500, server_default="500")
|
pages_limit = Column(Integer, nullable=False, default=500, server_default="500")
|
||||||
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
||||||
|
|
@ -492,3 +833,109 @@ async def get_documents_hybrid_search_retriever(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
return DocumentHybridSearchRetriever(session)
|
return DocumentHybridSearchRetriever(session)
|
||||||
|
|
||||||
|
|
||||||
|
def has_permission(user_permissions: list[str], required_permission: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the user has the required permission.
|
||||||
|
Supports wildcard (*) for full access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_permissions: List of permission strings the user has
|
||||||
|
required_permission: The permission string to check for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has the permission, False otherwise
|
||||||
|
"""
|
||||||
|
if not user_permissions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Full access wildcard grants all permissions
|
||||||
|
if Permission.FULL_ACCESS.value in user_permissions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return required_permission in user_permissions
|
||||||
|
|
||||||
|
|
||||||
|
def has_any_permission(
|
||||||
|
user_permissions: list[str], required_permissions: list[str]
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the user has any of the required permissions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_permissions: List of permission strings the user has
|
||||||
|
required_permissions: List of permission strings to check for (any match)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has at least one of the permissions, False otherwise
|
||||||
|
"""
|
||||||
|
if not user_permissions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if Permission.FULL_ACCESS.value in user_permissions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return any(perm in user_permissions for perm in required_permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def has_all_permissions(
|
||||||
|
user_permissions: list[str], required_permissions: list[str]
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the user has all of the required permissions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_permissions: List of permission strings the user has
|
||||||
|
required_permissions: List of permission strings to check for (all must match)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has all of the permissions, False otherwise
|
||||||
|
"""
|
||||||
|
if not user_permissions:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if Permission.FULL_ACCESS.value in user_permissions:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return all(perm in user_permissions for perm in required_permissions)
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_roles_config() -> list[dict]:
|
||||||
|
"""
|
||||||
|
Get the configuration for default system roles.
|
||||||
|
These roles are created automatically when a search space is created.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of role configurations with name, description, permissions, and flags
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "Owner",
|
||||||
|
"description": "Full access to all search space resources and settings",
|
||||||
|
"permissions": DEFAULT_ROLE_PERMISSIONS["Owner"],
|
||||||
|
"is_default": False,
|
||||||
|
"is_system_role": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Admin",
|
||||||
|
"description": "Can manage most resources except deleting the search space",
|
||||||
|
"permissions": DEFAULT_ROLE_PERMISSIONS["Admin"],
|
||||||
|
"is_default": False,
|
||||||
|
"is_system_role": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Editor",
|
||||||
|
"description": "Can create and edit documents, chats, and podcasts",
|
||||||
|
"permissions": DEFAULT_ROLE_PERMISSIONS["Editor"],
|
||||||
|
"is_default": True, # Default role for new members via invite
|
||||||
|
"is_system_role": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Viewer",
|
||||||
|
"description": "Read-only access to search space resources",
|
||||||
|
"permissions": DEFAULT_ROLE_PERMISSIONS["Viewer"],
|
||||||
|
"is_default": False,
|
||||||
|
"is_system_role": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ class ChucksHybridSearchRetriever:
|
||||||
self,
|
self,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
top_k: int,
|
top_k: int,
|
||||||
user_id: str,
|
search_space_id: int,
|
||||||
search_space_id: int | None = None,
|
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Perform vector similarity search on chunks.
|
Perform vector similarity search on chunks.
|
||||||
|
|
@ -21,8 +20,7 @@ class ChucksHybridSearchRetriever:
|
||||||
Args:
|
Args:
|
||||||
query_text: The search query text
|
query_text: The search query text
|
||||||
top_k: Number of results to return
|
top_k: Number of results to return
|
||||||
user_id: The ID of the user performing the search
|
search_space_id: The search space ID to search within
|
||||||
search_space_id: Optional search space ID to filter results
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of chunks sorted by vector similarity
|
List of chunks sorted by vector similarity
|
||||||
|
|
@ -31,25 +29,20 @@ class ChucksHybridSearchRetriever:
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import Chunk, Document, SearchSpace
|
from app.db import Chunk, Document
|
||||||
|
|
||||||
# Get embedding for the query
|
# Get embedding for the query
|
||||||
embedding_model = config.embedding_model_instance
|
embedding_model = config.embedding_model_instance
|
||||||
query_embedding = embedding_model.embed(query_text)
|
query_embedding = embedding_model.embed(query_text)
|
||||||
|
|
||||||
# Build the base query with user ownership check
|
# Build the query filtered by search space
|
||||||
query = (
|
query = (
|
||||||
select(Chunk)
|
select(Chunk)
|
||||||
.options(joinedload(Chunk.document).joinedload(Document.search_space))
|
.options(joinedload(Chunk.document).joinedload(Document.search_space))
|
||||||
.join(Document, Chunk.document_id == Document.id)
|
.join(Document, Chunk.document_id == Document.id)
|
||||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
.where(Document.search_space_id == search_space_id)
|
||||||
.where(SearchSpace.user_id == user_id)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add search space filter if provided
|
|
||||||
if search_space_id is not None:
|
|
||||||
query = query.where(Document.search_space_id == search_space_id)
|
|
||||||
|
|
||||||
# Add vector similarity ordering
|
# Add vector similarity ordering
|
||||||
query = query.order_by(Chunk.embedding.op("<=>")(query_embedding)).limit(top_k)
|
query = query.order_by(Chunk.embedding.op("<=>")(query_embedding)).limit(top_k)
|
||||||
|
|
||||||
|
|
@ -63,8 +56,7 @@ class ChucksHybridSearchRetriever:
|
||||||
self,
|
self,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
top_k: int,
|
top_k: int,
|
||||||
user_id: str,
|
search_space_id: int,
|
||||||
search_space_id: int | None = None,
|
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Perform full-text keyword search on chunks.
|
Perform full-text keyword search on chunks.
|
||||||
|
|
@ -72,8 +64,7 @@ class ChucksHybridSearchRetriever:
|
||||||
Args:
|
Args:
|
||||||
query_text: The search query text
|
query_text: The search query text
|
||||||
top_k: Number of results to return
|
top_k: Number of results to return
|
||||||
user_id: The ID of the user performing the search
|
search_space_id: The search space ID to search within
|
||||||
search_space_id: Optional search space ID to filter results
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of chunks sorted by text relevance
|
List of chunks sorted by text relevance
|
||||||
|
|
@ -81,28 +72,23 @@ class ChucksHybridSearchRetriever:
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.db import Chunk, Document, SearchSpace
|
from app.db import Chunk, Document
|
||||||
|
|
||||||
# Create tsvector and tsquery for PostgreSQL full-text search
|
# Create tsvector and tsquery for PostgreSQL full-text search
|
||||||
tsvector = func.to_tsvector("english", Chunk.content)
|
tsvector = func.to_tsvector("english", Chunk.content)
|
||||||
tsquery = func.plainto_tsquery("english", query_text)
|
tsquery = func.plainto_tsquery("english", query_text)
|
||||||
|
|
||||||
# Build the base query with user ownership check
|
# Build the query filtered by search space
|
||||||
query = (
|
query = (
|
||||||
select(Chunk)
|
select(Chunk)
|
||||||
.options(joinedload(Chunk.document).joinedload(Document.search_space))
|
.options(joinedload(Chunk.document).joinedload(Document.search_space))
|
||||||
.join(Document, Chunk.document_id == Document.id)
|
.join(Document, Chunk.document_id == Document.id)
|
||||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
.where(Document.search_space_id == search_space_id)
|
||||||
.where(SearchSpace.user_id == user_id)
|
|
||||||
.where(
|
.where(
|
||||||
tsvector.op("@@")(tsquery)
|
tsvector.op("@@")(tsquery)
|
||||||
) # Only include results that match the query
|
) # Only include results that match the query
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add search space filter if provided
|
|
||||||
if search_space_id is not None:
|
|
||||||
query = query.where(Document.search_space_id == search_space_id)
|
|
||||||
|
|
||||||
# Add text search ranking
|
# Add text search ranking
|
||||||
query = query.order_by(func.ts_rank_cd(tsvector, tsquery).desc()).limit(top_k)
|
query = query.order_by(func.ts_rank_cd(tsvector, tsquery).desc()).limit(top_k)
|
||||||
|
|
||||||
|
|
@ -116,8 +102,7 @@ class ChucksHybridSearchRetriever:
|
||||||
self,
|
self,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
top_k: int,
|
top_k: int,
|
||||||
user_id: str,
|
search_space_id: int,
|
||||||
search_space_id: int | None = None,
|
|
||||||
document_type: str | None = None,
|
document_type: str | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
|
|
@ -126,8 +111,7 @@ class ChucksHybridSearchRetriever:
|
||||||
Args:
|
Args:
|
||||||
query_text: The search query text
|
query_text: The search query text
|
||||||
top_k: Number of results to return
|
top_k: Number of results to return
|
||||||
user_id: The ID of the user performing the search
|
search_space_id: The search space ID to search within
|
||||||
search_space_id: Optional search space ID to filter results
|
|
||||||
document_type: Optional document type to filter results (e.g., "FILE", "CRAWLED_URL")
|
document_type: Optional document type to filter results (e.g., "FILE", "CRAWLED_URL")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -137,7 +121,7 @@ class ChucksHybridSearchRetriever:
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import Chunk, Document, DocumentType, SearchSpace
|
from app.db import Chunk, Document, DocumentType
|
||||||
|
|
||||||
# Get embedding for the query
|
# Get embedding for the query
|
||||||
embedding_model = config.embedding_model_instance
|
embedding_model = config.embedding_model_instance
|
||||||
|
|
@ -151,12 +135,8 @@ class ChucksHybridSearchRetriever:
|
||||||
tsvector = func.to_tsvector("english", Chunk.content)
|
tsvector = func.to_tsvector("english", Chunk.content)
|
||||||
tsquery = func.plainto_tsquery("english", query_text)
|
tsquery = func.plainto_tsquery("english", query_text)
|
||||||
|
|
||||||
# Base conditions for document filtering
|
# Base conditions for chunk filtering - search space is required
|
||||||
base_conditions = [SearchSpace.user_id == user_id]
|
base_conditions = [Document.search_space_id == search_space_id]
|
||||||
|
|
||||||
# Add search space filter if provided
|
|
||||||
if search_space_id is not None:
|
|
||||||
base_conditions.append(Document.search_space_id == search_space_id)
|
|
||||||
|
|
||||||
# Add document type filter if provided
|
# Add document type filter if provided
|
||||||
if document_type is not None:
|
if document_type is not None:
|
||||||
|
|
@ -171,7 +151,7 @@ class ChucksHybridSearchRetriever:
|
||||||
else:
|
else:
|
||||||
base_conditions.append(Document.document_type == document_type)
|
base_conditions.append(Document.document_type == document_type)
|
||||||
|
|
||||||
# CTE for semantic search with user ownership check
|
# CTE for semantic search filtered by search space
|
||||||
semantic_search_cte = (
|
semantic_search_cte = (
|
||||||
select(
|
select(
|
||||||
Chunk.id,
|
Chunk.id,
|
||||||
|
|
@ -180,7 +160,6 @@ class ChucksHybridSearchRetriever:
|
||||||
.label("rank"),
|
.label("rank"),
|
||||||
)
|
)
|
||||||
.join(Document, Chunk.document_id == Document.id)
|
.join(Document, Chunk.document_id == Document.id)
|
||||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
|
||||||
.where(*base_conditions)
|
.where(*base_conditions)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -190,7 +169,7 @@ class ChucksHybridSearchRetriever:
|
||||||
.cte("semantic_search")
|
.cte("semantic_search")
|
||||||
)
|
)
|
||||||
|
|
||||||
# CTE for keyword search with user ownership check
|
# CTE for keyword search filtered by search space
|
||||||
keyword_search_cte = (
|
keyword_search_cte = (
|
||||||
select(
|
select(
|
||||||
Chunk.id,
|
Chunk.id,
|
||||||
|
|
@ -199,7 +178,6 @@ class ChucksHybridSearchRetriever:
|
||||||
.label("rank"),
|
.label("rank"),
|
||||||
)
|
)
|
||||||
.join(Document, Chunk.document_id == Document.id)
|
.join(Document, Chunk.document_id == Document.id)
|
||||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
|
||||||
.where(*base_conditions)
|
.where(*base_conditions)
|
||||||
.where(tsvector.op("@@")(tsquery))
|
.where(tsvector.op("@@")(tsquery))
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,7 @@ class DocumentHybridSearchRetriever:
|
||||||
self,
|
self,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
top_k: int,
|
top_k: int,
|
||||||
user_id: str,
|
search_space_id: int,
|
||||||
search_space_id: int | None = None,
|
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Perform vector similarity search on documents.
|
Perform vector similarity search on documents.
|
||||||
|
|
@ -21,8 +20,7 @@ class DocumentHybridSearchRetriever:
|
||||||
Args:
|
Args:
|
||||||
query_text: The search query text
|
query_text: The search query text
|
||||||
top_k: Number of results to return
|
top_k: Number of results to return
|
||||||
user_id: The ID of the user performing the search
|
search_space_id: The search space ID to search within
|
||||||
search_space_id: Optional search space ID to filter results
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of documents sorted by vector similarity
|
List of documents sorted by vector similarity
|
||||||
|
|
@ -31,24 +29,19 @@ class DocumentHybridSearchRetriever:
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import Document, SearchSpace
|
from app.db import Document
|
||||||
|
|
||||||
# Get embedding for the query
|
# Get embedding for the query
|
||||||
embedding_model = config.embedding_model_instance
|
embedding_model = config.embedding_model_instance
|
||||||
query_embedding = embedding_model.embed(query_text)
|
query_embedding = embedding_model.embed(query_text)
|
||||||
|
|
||||||
# Build the base query with user ownership check
|
# Build the query filtered by search space
|
||||||
query = (
|
query = (
|
||||||
select(Document)
|
select(Document)
|
||||||
.options(joinedload(Document.search_space))
|
.options(joinedload(Document.search_space))
|
||||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
.where(Document.search_space_id == search_space_id)
|
||||||
.where(SearchSpace.user_id == user_id)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add search space filter if provided
|
|
||||||
if search_space_id is not None:
|
|
||||||
query = query.where(Document.search_space_id == search_space_id)
|
|
||||||
|
|
||||||
# Add vector similarity ordering
|
# Add vector similarity ordering
|
||||||
query = query.order_by(Document.embedding.op("<=>")(query_embedding)).limit(
|
query = query.order_by(Document.embedding.op("<=>")(query_embedding)).limit(
|
||||||
top_k
|
top_k
|
||||||
|
|
@ -64,8 +57,7 @@ class DocumentHybridSearchRetriever:
|
||||||
self,
|
self,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
top_k: int,
|
top_k: int,
|
||||||
user_id: str,
|
search_space_id: int,
|
||||||
search_space_id: int | None = None,
|
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Perform full-text keyword search on documents.
|
Perform full-text keyword search on documents.
|
||||||
|
|
@ -73,8 +65,7 @@ class DocumentHybridSearchRetriever:
|
||||||
Args:
|
Args:
|
||||||
query_text: The search query text
|
query_text: The search query text
|
||||||
top_k: Number of results to return
|
top_k: Number of results to return
|
||||||
user_id: The ID of the user performing the search
|
search_space_id: The search space ID to search within
|
||||||
search_space_id: Optional search space ID to filter results
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of documents sorted by text relevance
|
List of documents sorted by text relevance
|
||||||
|
|
@ -82,27 +73,22 @@ class DocumentHybridSearchRetriever:
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.db import Document, SearchSpace
|
from app.db import Document
|
||||||
|
|
||||||
# Create tsvector and tsquery for PostgreSQL full-text search
|
# Create tsvector and tsquery for PostgreSQL full-text search
|
||||||
tsvector = func.to_tsvector("english", Document.content)
|
tsvector = func.to_tsvector("english", Document.content)
|
||||||
tsquery = func.plainto_tsquery("english", query_text)
|
tsquery = func.plainto_tsquery("english", query_text)
|
||||||
|
|
||||||
# Build the base query with user ownership check
|
# Build the query filtered by search space
|
||||||
query = (
|
query = (
|
||||||
select(Document)
|
select(Document)
|
||||||
.options(joinedload(Document.search_space))
|
.options(joinedload(Document.search_space))
|
||||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
.where(Document.search_space_id == search_space_id)
|
||||||
.where(SearchSpace.user_id == user_id)
|
|
||||||
.where(
|
.where(
|
||||||
tsvector.op("@@")(tsquery)
|
tsvector.op("@@")(tsquery)
|
||||||
) # Only include results that match the query
|
) # Only include results that match the query
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add search space filter if provided
|
|
||||||
if search_space_id is not None:
|
|
||||||
query = query.where(Document.search_space_id == search_space_id)
|
|
||||||
|
|
||||||
# Add text search ranking
|
# Add text search ranking
|
||||||
query = query.order_by(func.ts_rank_cd(tsvector, tsquery).desc()).limit(top_k)
|
query = query.order_by(func.ts_rank_cd(tsvector, tsquery).desc()).limit(top_k)
|
||||||
|
|
||||||
|
|
@ -116,8 +102,7 @@ class DocumentHybridSearchRetriever:
|
||||||
self,
|
self,
|
||||||
query_text: str,
|
query_text: str,
|
||||||
top_k: int,
|
top_k: int,
|
||||||
user_id: str,
|
search_space_id: int,
|
||||||
search_space_id: int | None = None,
|
|
||||||
document_type: str | None = None,
|
document_type: str | None = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
|
|
@ -126,8 +111,7 @@ class DocumentHybridSearchRetriever:
|
||||||
Args:
|
Args:
|
||||||
query_text: The search query text
|
query_text: The search query text
|
||||||
top_k: Number of results to return
|
top_k: Number of results to return
|
||||||
user_id: The ID of the user performing the search
|
search_space_id: The search space ID to search within
|
||||||
search_space_id: Optional search space ID to filter results
|
|
||||||
document_type: Optional document type to filter results (e.g., "FILE", "CRAWLED_URL")
|
document_type: Optional document type to filter results (e.g., "FILE", "CRAWLED_URL")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
@ -135,7 +119,7 @@ class DocumentHybridSearchRetriever:
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import Document, DocumentType, SearchSpace
|
from app.db import Document, DocumentType
|
||||||
|
|
||||||
# Get embedding for the query
|
# Get embedding for the query
|
||||||
embedding_model = config.embedding_model_instance
|
embedding_model = config.embedding_model_instance
|
||||||
|
|
@ -149,12 +133,8 @@ class DocumentHybridSearchRetriever:
|
||||||
tsvector = func.to_tsvector("english", Document.content)
|
tsvector = func.to_tsvector("english", Document.content)
|
||||||
tsquery = func.plainto_tsquery("english", query_text)
|
tsquery = func.plainto_tsquery("english", query_text)
|
||||||
|
|
||||||
# Base conditions for document filtering
|
# Base conditions for document filtering - search space is required
|
||||||
base_conditions = [SearchSpace.user_id == user_id]
|
base_conditions = [Document.search_space_id == search_space_id]
|
||||||
|
|
||||||
# Add search space filter if provided
|
|
||||||
if search_space_id is not None:
|
|
||||||
base_conditions.append(Document.search_space_id == search_space_id)
|
|
||||||
|
|
||||||
# Add document type filter if provided
|
# Add document type filter if provided
|
||||||
if document_type is not None:
|
if document_type is not None:
|
||||||
|
|
@ -169,17 +149,13 @@ class DocumentHybridSearchRetriever:
|
||||||
else:
|
else:
|
||||||
base_conditions.append(Document.document_type == document_type)
|
base_conditions.append(Document.document_type == document_type)
|
||||||
|
|
||||||
# CTE for semantic search with user ownership check
|
# CTE for semantic search filtered by search space
|
||||||
semantic_search_cte = (
|
semantic_search_cte = select(
|
||||||
select(
|
Document.id,
|
||||||
Document.id,
|
func.rank()
|
||||||
func.rank()
|
.over(order_by=Document.embedding.op("<=>")(query_embedding))
|
||||||
.over(order_by=Document.embedding.op("<=>")(query_embedding))
|
.label("rank"),
|
||||||
.label("rank"),
|
).where(*base_conditions)
|
||||||
)
|
|
||||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
|
||||||
.where(*base_conditions)
|
|
||||||
)
|
|
||||||
|
|
||||||
semantic_search_cte = (
|
semantic_search_cte = (
|
||||||
semantic_search_cte.order_by(Document.embedding.op("<=>")(query_embedding))
|
semantic_search_cte.order_by(Document.embedding.op("<=>")(query_embedding))
|
||||||
|
|
@ -187,7 +163,7 @@ class DocumentHybridSearchRetriever:
|
||||||
.cte("semantic_search")
|
.cte("semantic_search")
|
||||||
)
|
)
|
||||||
|
|
||||||
# CTE for keyword search with user ownership check
|
# CTE for keyword search filtered by search space
|
||||||
keyword_search_cte = (
|
keyword_search_cte = (
|
||||||
select(
|
select(
|
||||||
Document.id,
|
Document.id,
|
||||||
|
|
@ -195,7 +171,6 @@ class DocumentHybridSearchRetriever:
|
||||||
.over(order_by=func.ts_rank_cd(tsvector, tsquery).desc())
|
.over(order_by=func.ts_rank_cd(tsvector, tsquery).desc())
|
||||||
.label("rank"),
|
.label("rank"),
|
||||||
)
|
)
|
||||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
|
||||||
.where(*base_conditions)
|
.where(*base_conditions)
|
||||||
.where(tsvector.op("@@")(tsquery))
|
.where(tsvector.op("@@")(tsquery))
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,14 @@ from .llm_config_routes import router as llm_config_router
|
||||||
from .logs_routes import router as logs_router
|
from .logs_routes import router as logs_router
|
||||||
from .luma_add_connector_route import router as luma_add_connector_router
|
from .luma_add_connector_route import router as luma_add_connector_router
|
||||||
from .podcasts_routes import router as podcasts_router
|
from .podcasts_routes import router as podcasts_router
|
||||||
|
from .rbac_routes import router as rbac_router
|
||||||
from .search_source_connectors_routes import router as search_source_connectors_router
|
from .search_source_connectors_routes import router as search_source_connectors_router
|
||||||
from .search_spaces_routes import router as search_spaces_router
|
from .search_spaces_routes import router as search_spaces_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
router.include_router(search_spaces_router)
|
router.include_router(search_spaces_router)
|
||||||
|
router.include_router(rbac_router) # RBAC routes for roles, members, invites
|
||||||
router.include_router(documents_router)
|
router.include_router(documents_router)
|
||||||
router.include_router(podcasts_router)
|
router.include_router(podcasts_router)
|
||||||
router.include_router(chats_router)
|
router.include_router(chats_router)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from app.db import Chat, SearchSpace, User, UserSearchSpacePreference, get_async_session
|
from app.db import (
|
||||||
|
Chat,
|
||||||
|
Permission,
|
||||||
|
SearchSpace,
|
||||||
|
SearchSpaceMembership,
|
||||||
|
User,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
AISDKChatRequest,
|
AISDKChatRequest,
|
||||||
ChatCreate,
|
ChatCreate,
|
||||||
|
|
@ -16,7 +23,7 @@ from app.schemas import (
|
||||||
)
|
)
|
||||||
from app.tasks.stream_connector_search_results import stream_connector_search_results
|
from app.tasks.stream_connector_search_results import stream_connector_search_results
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
from app.utils.rbac import check_permission
|
||||||
from app.utils.validators import (
|
from app.utils.validators import (
|
||||||
validate_connectors,
|
validate_connectors,
|
||||||
validate_document_ids,
|
validate_document_ids,
|
||||||
|
|
@ -59,45 +66,38 @@ async def handle_chat_data(
|
||||||
# print("RESQUEST DATA:", request_data)
|
# print("RESQUEST DATA:", request_data)
|
||||||
# print("SELECTED CONNECTORS:", selected_connectors)
|
# print("SELECTED CONNECTORS:", selected_connectors)
|
||||||
|
|
||||||
# Check if the search space belongs to the current user
|
# Check if the user has chat access to the search space
|
||||||
try:
|
try:
|
||||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
await check_permission(
|
||||||
language_result = await session.execute(
|
session,
|
||||||
select(UserSearchSpacePreference)
|
user,
|
||||||
.options(
|
search_space_id,
|
||||||
selectinload(UserSearchSpacePreference.search_space).selectinload(
|
Permission.CHATS_CREATE.value,
|
||||||
SearchSpace.llm_configs
|
"You don't have permission to use chat in this search space",
|
||||||
),
|
|
||||||
# Note: Removed selectinload for LLM relationships as they no longer exist
|
|
||||||
# Global configs (negative IDs) don't have foreign keys
|
|
||||||
# LLM configs are now fetched manually when needed
|
|
||||||
)
|
|
||||||
.filter(
|
|
||||||
UserSearchSpacePreference.search_space_id == search_space_id,
|
|
||||||
UserSearchSpacePreference.user_id == user.id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
user_preference = language_result.scalars().first()
|
|
||||||
# print("UserSearchSpacePreference:", user_preference)
|
# Get search space with LLM configs (preferences are now stored at search space level)
|
||||||
|
search_space_result = await session.execute(
|
||||||
|
select(SearchSpace)
|
||||||
|
.options(selectinload(SearchSpace.llm_configs))
|
||||||
|
.filter(SearchSpace.id == search_space_id)
|
||||||
|
)
|
||||||
|
search_space = search_space_result.scalars().first()
|
||||||
|
|
||||||
language = None
|
language = None
|
||||||
llm_configs = [] # Initialize to empty list
|
llm_configs = [] # Initialize to empty list
|
||||||
|
|
||||||
if (
|
if search_space and search_space.llm_configs:
|
||||||
user_preference
|
llm_configs = search_space.llm_configs
|
||||||
and user_preference.search_space
|
|
||||||
and user_preference.search_space.llm_configs
|
|
||||||
):
|
|
||||||
llm_configs = user_preference.search_space.llm_configs
|
|
||||||
|
|
||||||
# Manually fetch LLM configs since relationships no longer exist
|
# Get language from configured LLM preferences
|
||||||
# Check fast_llm, long_context_llm, and strategic_llm IDs
|
# LLM preferences are now stored on the SearchSpace model
|
||||||
from app.config import config as app_config
|
from app.config import config as app_config
|
||||||
|
|
||||||
for llm_id in [
|
for llm_id in [
|
||||||
user_preference.fast_llm_id,
|
search_space.fast_llm_id,
|
||||||
user_preference.long_context_llm_id,
|
search_space.long_context_llm_id,
|
||||||
user_preference.strategic_llm_id,
|
search_space.strategic_llm_id,
|
||||||
]:
|
]:
|
||||||
if llm_id is not None:
|
if llm_id is not None:
|
||||||
# Check if it's a global config (negative ID)
|
# Check if it's a global config (negative ID)
|
||||||
|
|
@ -161,8 +161,18 @@ async def create_chat(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Create a new chat.
|
||||||
|
Requires CHATS_CREATE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
await check_ownership(session, SearchSpace, chat.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
chat.search_space_id,
|
||||||
|
Permission.CHATS_CREATE.value,
|
||||||
|
"You don't have permission to create chats in this search space",
|
||||||
|
)
|
||||||
db_chat = Chat(**chat.model_dump())
|
db_chat = Chat(**chat.model_dump())
|
||||||
session.add(db_chat)
|
session.add(db_chat)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -197,6 +207,10 @@ async def read_chats(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
List chats the user has access to.
|
||||||
|
Requires CHATS_READ permission for the search space(s).
|
||||||
|
"""
|
||||||
# Validate pagination parameters
|
# Validate pagination parameters
|
||||||
if skip < 0:
|
if skip < 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -212,9 +226,17 @@ async def read_chats(
|
||||||
status_code=400, detail="search_space_id must be a positive integer"
|
status_code=400, detail="search_space_id must be a positive integer"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Select specific fields excluding messages
|
if search_space_id is not None:
|
||||||
query = (
|
# Check permission for specific search space
|
||||||
select(
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.CHATS_READ.value,
|
||||||
|
"You don't have permission to read chats in this search space",
|
||||||
|
)
|
||||||
|
# Select specific fields excluding messages
|
||||||
|
query = select(
|
||||||
Chat.id,
|
Chat.id,
|
||||||
Chat.type,
|
Chat.type,
|
||||||
Chat.title,
|
Chat.title,
|
||||||
|
|
@ -222,17 +244,28 @@ async def read_chats(
|
||||||
Chat.search_space_id,
|
Chat.search_space_id,
|
||||||
Chat.created_at,
|
Chat.created_at,
|
||||||
Chat.state_version,
|
Chat.state_version,
|
||||||
|
).filter(Chat.search_space_id == search_space_id)
|
||||||
|
else:
|
||||||
|
# Get chats from all search spaces user has membership in
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
Chat.id,
|
||||||
|
Chat.type,
|
||||||
|
Chat.title,
|
||||||
|
Chat.initial_connectors,
|
||||||
|
Chat.search_space_id,
|
||||||
|
Chat.created_at,
|
||||||
|
Chat.state_version,
|
||||||
|
)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
)
|
)
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter by search_space_id if provided
|
|
||||||
if search_space_id is not None:
|
|
||||||
query = query.filter(Chat.search_space_id == search_space_id)
|
|
||||||
|
|
||||||
result = await session.execute(query.offset(skip).limit(limit))
|
result = await session.execute(query.offset(skip).limit(limit))
|
||||||
return result.all()
|
return result.all()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503, detail="Database operation failed. Please try again later."
|
status_code=503, detail="Database operation failed. Please try again later."
|
||||||
|
|
@ -249,19 +282,32 @@ async def read_chat(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Get a specific chat by ID.
|
||||||
|
Requires CHATS_READ permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||||
select(Chat)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Chat.id == chat_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
chat = result.scalars().first()
|
chat = result.scalars().first()
|
||||||
|
|
||||||
if not chat:
|
if not chat:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="Chat not found or you don't have permission to access it",
|
detail="Chat not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
chat.search_space_id,
|
||||||
|
Permission.CHATS_READ.value,
|
||||||
|
"You don't have permission to read chats in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
return chat
|
return chat
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=503, detail="Database operation failed. Please try again later."
|
status_code=503, detail="Database operation failed. Please try again later."
|
||||||
|
|
@ -280,8 +326,26 @@ async def update_chat(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Update a chat.
|
||||||
|
Requires CHATS_UPDATE permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
db_chat = await read_chat(chat_id, session, user)
|
result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||||
|
db_chat = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_chat:
|
||||||
|
raise HTTPException(status_code=404, detail="Chat not found")
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_chat.search_space_id,
|
||||||
|
Permission.CHATS_UPDATE.value,
|
||||||
|
"You don't have permission to update chats in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
update_data = chat_update.model_dump(exclude_unset=True)
|
update_data = chat_update.model_dump(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
if key == "messages":
|
if key == "messages":
|
||||||
|
|
@ -318,8 +382,26 @@ async def delete_chat(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Delete a chat.
|
||||||
|
Requires CHATS_DELETE permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
db_chat = await read_chat(chat_id, session, user)
|
result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||||
|
db_chat = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_chat:
|
||||||
|
raise HTTPException(status_code=404, detail="Chat not found")
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_chat.search_space_id,
|
||||||
|
Permission.CHATS_DELETE.value,
|
||||||
|
"You don't have permission to delete chats in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(db_chat)
|
await session.delete(db_chat)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"message": "Chat deleted successfully"}
|
return {"message": "Chat deleted successfully"}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@ from app.db import (
|
||||||
Chunk,
|
Chunk,
|
||||||
Document,
|
Document,
|
||||||
DocumentType,
|
DocumentType,
|
||||||
|
Permission,
|
||||||
SearchSpace,
|
SearchSpace,
|
||||||
|
SearchSpaceMembership,
|
||||||
User,
|
User,
|
||||||
get_async_session,
|
get_async_session,
|
||||||
)
|
)
|
||||||
|
|
@ -22,7 +24,7 @@ from app.schemas import (
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
try:
|
try:
|
||||||
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
||||||
|
|
@ -44,9 +46,19 @@ async def create_documents(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Create new documents.
|
||||||
|
Requires DOCUMENTS_CREATE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if the user owns the search space
|
# Check permission
|
||||||
await check_ownership(session, SearchSpace, request.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
request.search_space_id,
|
||||||
|
Permission.DOCUMENTS_CREATE.value,
|
||||||
|
"You don't have permission to create documents in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
if request.document_type == DocumentType.EXTENSION:
|
if request.document_type == DocumentType.EXTENSION:
|
||||||
from app.tasks.celery_tasks.document_tasks import (
|
from app.tasks.celery_tasks.document_tasks import (
|
||||||
|
|
@ -93,8 +105,19 @@ async def create_documents_file_upload(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Upload files as documents.
|
||||||
|
Requires DOCUMENTS_CREATE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
# Check permission
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.DOCUMENTS_CREATE.value,
|
||||||
|
"You don't have permission to create documents in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
if not files:
|
if not files:
|
||||||
raise HTTPException(status_code=400, detail="No files provided")
|
raise HTTPException(status_code=400, detail="No files provided")
|
||||||
|
|
@ -151,7 +174,8 @@ async def read_documents(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
List documents owned by the current user, with optional filtering and pagination.
|
List documents the user has access to, with optional filtering and pagination.
|
||||||
|
Requires DOCUMENTS_READ permission for the search space(s).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'.
|
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'.
|
||||||
|
|
@ -167,40 +191,49 @@ async def read_documents(
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- If both 'skip' and 'page' are provided, 'skip' is used.
|
- If both 'skip' and 'page' are provided, 'skip' is used.
|
||||||
- Results are scoped to documents owned by the current user.
|
- Results are scoped to documents in search spaces the user has membership in.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
query = (
|
# If specific search_space_id, check permission
|
||||||
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter by search_space_id if provided
|
|
||||||
if search_space_id is not None:
|
if search_space_id is not None:
|
||||||
query = query.filter(Document.search_space_id == search_space_id)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
"You don't have permission to read documents in this search space",
|
||||||
|
)
|
||||||
|
query = select(Document).filter(Document.search_space_id == search_space_id)
|
||||||
|
count_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Document)
|
||||||
|
.filter(Document.search_space_id == search_space_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Get documents from all search spaces user has membership in
|
||||||
|
query = (
|
||||||
|
select(Document)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
)
|
||||||
|
count_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Document)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
# Filter by document_types if provided
|
# Filter by document_types if provided
|
||||||
if document_types is not None and document_types.strip():
|
if document_types is not None and document_types.strip():
|
||||||
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
||||||
if type_list:
|
if type_list:
|
||||||
query = query.filter(Document.document_type.in_(type_list))
|
query = query.filter(Document.document_type.in_(type_list))
|
||||||
|
|
||||||
# Get total count
|
|
||||||
count_query = (
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Document)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
if search_space_id is not None:
|
|
||||||
count_query = count_query.filter(
|
|
||||||
Document.search_space_id == search_space_id
|
|
||||||
)
|
|
||||||
if document_types is not None and document_types.strip():
|
|
||||||
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
|
||||||
if type_list:
|
|
||||||
count_query = count_query.filter(Document.document_type.in_(type_list))
|
count_query = count_query.filter(Document.document_type.in_(type_list))
|
||||||
|
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
|
@ -235,6 +268,8 @@ async def read_documents(
|
||||||
)
|
)
|
||||||
|
|
||||||
return PaginatedResponse(items=api_documents, total=total)
|
return PaginatedResponse(items=api_documents, total=total)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to fetch documents: {e!s}"
|
status_code=500, detail=f"Failed to fetch documents: {e!s}"
|
||||||
|
|
@ -254,6 +289,7 @@ async def search_documents(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Search documents by title substring, optionally filtered by search_space_id and document_types.
|
Search documents by title substring, optionally filtered by search_space_id and document_types.
|
||||||
|
Requires DOCUMENTS_READ permission for the search space(s).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
title: Case-insensitive substring to match against document titles. Required.
|
title: Case-insensitive substring to match against document titles. Required.
|
||||||
|
|
@ -275,37 +311,48 @@ async def search_documents(
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
query = (
|
# If specific search_space_id, check permission
|
||||||
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
if search_space_id is not None:
|
if search_space_id is not None:
|
||||||
query = query.filter(Document.search_space_id == search_space_id)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
"You don't have permission to read documents in this search space",
|
||||||
|
)
|
||||||
|
query = select(Document).filter(Document.search_space_id == search_space_id)
|
||||||
|
count_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Document)
|
||||||
|
.filter(Document.search_space_id == search_space_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Get documents from all search spaces user has membership in
|
||||||
|
query = (
|
||||||
|
select(Document)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
)
|
||||||
|
count_query = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(Document)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
)
|
||||||
|
|
||||||
# Only search by title (case-insensitive)
|
# Only search by title (case-insensitive)
|
||||||
query = query.filter(Document.title.ilike(f"%{title}%"))
|
query = query.filter(Document.title.ilike(f"%{title}%"))
|
||||||
|
count_query = count_query.filter(Document.title.ilike(f"%{title}%"))
|
||||||
|
|
||||||
# Filter by document_types if provided
|
# Filter by document_types if provided
|
||||||
if document_types is not None and document_types.strip():
|
if document_types is not None and document_types.strip():
|
||||||
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
||||||
if type_list:
|
if type_list:
|
||||||
query = query.filter(Document.document_type.in_(type_list))
|
query = query.filter(Document.document_type.in_(type_list))
|
||||||
|
|
||||||
# Get total count
|
|
||||||
count_query = (
|
|
||||||
select(func.count())
|
|
||||||
.select_from(Document)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
if search_space_id is not None:
|
|
||||||
count_query = count_query.filter(
|
|
||||||
Document.search_space_id == search_space_id
|
|
||||||
)
|
|
||||||
count_query = count_query.filter(Document.title.ilike(f"%{title}%"))
|
|
||||||
if document_types is not None and document_types.strip():
|
|
||||||
type_list = [t.strip() for t in document_types.split(",") if t.strip()]
|
|
||||||
if type_list:
|
|
||||||
count_query = count_query.filter(Document.document_type.in_(type_list))
|
count_query = count_query.filter(Document.document_type.in_(type_list))
|
||||||
|
|
||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
|
@ -340,6 +387,8 @@ async def search_documents(
|
||||||
)
|
)
|
||||||
|
|
||||||
return PaginatedResponse(items=api_documents, total=total)
|
return PaginatedResponse(items=api_documents, total=total)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to search documents: {e!s}"
|
status_code=500, detail=f"Failed to search documents: {e!s}"
|
||||||
|
|
@ -353,7 +402,8 @@ async def get_document_type_counts(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get counts of documents by type for the current user.
|
Get counts of documents by type for search spaces the user has access to.
|
||||||
|
Requires DOCUMENTS_READ permission for the search space(s).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
search_space_id: If provided, restrict counts to a specific search space.
|
search_space_id: If provided, restrict counts to a specific search space.
|
||||||
|
|
@ -366,20 +416,36 @@ async def get_document_type_counts(
|
||||||
try:
|
try:
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
query = (
|
|
||||||
select(Document.document_type, func.count(Document.id))
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(SearchSpace.user_id == user.id)
|
|
||||||
.group_by(Document.document_type)
|
|
||||||
)
|
|
||||||
|
|
||||||
if search_space_id is not None:
|
if search_space_id is not None:
|
||||||
query = query.filter(Document.search_space_id == search_space_id)
|
# Check permission for specific search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
"You don't have permission to read documents in this search space",
|
||||||
|
)
|
||||||
|
query = (
|
||||||
|
select(Document.document_type, func.count(Document.id))
|
||||||
|
.filter(Document.search_space_id == search_space_id)
|
||||||
|
.group_by(Document.document_type)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Get counts from all search spaces user has membership in
|
||||||
|
query = (
|
||||||
|
select(Document.document_type, func.count(Document.id))
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
.group_by(Document.document_type)
|
||||||
|
)
|
||||||
|
|
||||||
result = await session.execute(query)
|
result = await session.execute(query)
|
||||||
type_counts = dict(result.all())
|
type_counts = dict(result.all())
|
||||||
|
|
||||||
return type_counts
|
return type_counts
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to fetch document type counts: {e!s}"
|
status_code=500, detail=f"Failed to fetch document type counts: {e!s}"
|
||||||
|
|
@ -394,6 +460,7 @@ async def get_document_by_chunk_id(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Retrieves a document based on a chunk ID, including all its chunks ordered by creation time.
|
Retrieves a document based on a chunk ID, including all its chunks ordered by creation time.
|
||||||
|
Requires DOCUMENTS_READ permission for the search space.
|
||||||
The document's embedding and chunk embeddings are excluded from the response.
|
The document's embedding and chunk embeddings are excluded from the response.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -406,21 +473,29 @@ async def get_document_by_chunk_id(
|
||||||
status_code=404, detail=f"Chunk with id {chunk_id} not found"
|
status_code=404, detail=f"Chunk with id {chunk_id} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the associated document and verify ownership
|
# Get the associated document
|
||||||
document_result = await session.execute(
|
document_result = await session.execute(
|
||||||
select(Document)
|
select(Document)
|
||||||
.options(selectinload(Document.chunks))
|
.options(selectinload(Document.chunks))
|
||||||
.join(SearchSpace)
|
.filter(Document.id == chunk.document_id)
|
||||||
.filter(Document.id == chunk.document_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
)
|
||||||
document = document_result.scalars().first()
|
document = document_result.scalars().first()
|
||||||
|
|
||||||
if not document:
|
if not document:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="Document not found or you don't have access to it",
|
detail="Document not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
document.search_space_id,
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
"You don't have permission to read documents in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
# Sort chunks by creation time
|
# Sort chunks by creation time
|
||||||
sorted_chunks = sorted(document.chunks, key=lambda x: x.created_at)
|
sorted_chunks = sorted(document.chunks, key=lambda x: x.created_at)
|
||||||
|
|
||||||
|
|
@ -449,11 +524,13 @@ async def read_document(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Get a specific document by ID.
|
||||||
|
Requires DOCUMENTS_READ permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Document)
|
select(Document).filter(Document.id == document_id)
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
)
|
||||||
document = result.scalars().first()
|
document = result.scalars().first()
|
||||||
|
|
||||||
|
|
@ -462,6 +539,15 @@ async def read_document(
|
||||||
status_code=404, detail=f"Document with id {document_id} not found"
|
status_code=404, detail=f"Document with id {document_id} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
document.search_space_id,
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
"You don't have permission to read documents in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
# Convert database object to API-friendly format
|
# Convert database object to API-friendly format
|
||||||
return DocumentRead(
|
return DocumentRead(
|
||||||
id=document.id,
|
id=document.id,
|
||||||
|
|
@ -472,6 +558,8 @@ async def read_document(
|
||||||
created_at=document.created_at,
|
created_at=document.created_at,
|
||||||
search_space_id=document.search_space_id,
|
search_space_id=document.search_space_id,
|
||||||
)
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to fetch document: {e!s}"
|
status_code=500, detail=f"Failed to fetch document: {e!s}"
|
||||||
|
|
@ -485,12 +573,13 @@ async def update_document(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Update a document.
|
||||||
|
Requires DOCUMENTS_UPDATE permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Query the document directly instead of using read_document function
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Document)
|
select(Document).filter(Document.id == document_id)
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
)
|
||||||
db_document = result.scalars().first()
|
db_document = result.scalars().first()
|
||||||
|
|
||||||
|
|
@ -499,6 +588,15 @@ async def update_document(
|
||||||
status_code=404, detail=f"Document with id {document_id} not found"
|
status_code=404, detail=f"Document with id {document_id} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_document.search_space_id,
|
||||||
|
Permission.DOCUMENTS_UPDATE.value,
|
||||||
|
"You don't have permission to update documents in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
update_data = document_update.model_dump(exclude_unset=True)
|
update_data = document_update.model_dump(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(db_document, key, value)
|
setattr(db_document, key, value)
|
||||||
|
|
@ -530,12 +628,13 @@ async def delete_document(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Delete a document.
|
||||||
|
Requires DOCUMENTS_DELETE permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Query the document directly instead of using read_document function
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Document)
|
select(Document).filter(Document.id == document_id)
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
)
|
||||||
document = result.scalars().first()
|
document = result.scalars().first()
|
||||||
|
|
||||||
|
|
@ -544,6 +643,15 @@ async def delete_document(
|
||||||
status_code=404, detail=f"Document with id {document_id} not found"
|
status_code=404, detail=f"Document with id {document_id} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
document.search_space_id,
|
||||||
|
Permission.DOCUMENTS_DELETE.value,
|
||||||
|
"You don't have permission to delete documents in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(document)
|
await session.delete(document)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"message": "Document deleted successfully"}
|
return {"message": "Document deleted successfully"}
|
||||||
|
|
|
||||||
|
|
@ -8,67 +8,22 @@ from sqlalchemy.future import select
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import (
|
from app.db import (
|
||||||
LLMConfig,
|
LLMConfig,
|
||||||
|
Permission,
|
||||||
SearchSpace,
|
SearchSpace,
|
||||||
User,
|
User,
|
||||||
UserSearchSpacePreference,
|
|
||||||
get_async_session,
|
get_async_session,
|
||||||
)
|
)
|
||||||
from app.schemas import LLMConfigCreate, LLMConfigRead, LLMConfigUpdate
|
from app.schemas import LLMConfigCreate, LLMConfigRead, LLMConfigUpdate
|
||||||
from app.services.llm_service import validate_llm_config
|
from app.services.llm_service import validate_llm_config
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Helper function to check search space access
|
|
||||||
async def check_search_space_access(
|
|
||||||
session: AsyncSession, search_space_id: int, user: User
|
|
||||||
) -> SearchSpace:
|
|
||||||
"""Verify that the user has access to the search space"""
|
|
||||||
result = await session.execute(
|
|
||||||
select(SearchSpace).filter(
|
|
||||||
SearchSpace.id == search_space_id, SearchSpace.user_id == user.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
search_space = result.scalars().first()
|
|
||||||
if not search_space:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Search space not found or you don't have permission to access it",
|
|
||||||
)
|
|
||||||
return search_space
|
|
||||||
|
|
||||||
|
|
||||||
# Helper function to get or create user search space preference
|
|
||||||
async def get_or_create_user_preference(
|
|
||||||
session: AsyncSession, user_id, search_space_id: int
|
|
||||||
) -> UserSearchSpacePreference:
|
|
||||||
"""Get or create user preference for a search space"""
|
|
||||||
result = await session.execute(
|
|
||||||
select(UserSearchSpacePreference).filter(
|
|
||||||
UserSearchSpacePreference.user_id == user_id,
|
|
||||||
UserSearchSpacePreference.search_space_id == search_space_id,
|
|
||||||
)
|
|
||||||
# Removed selectinload options since relationships no longer exist
|
|
||||||
)
|
|
||||||
preference = result.scalars().first()
|
|
||||||
|
|
||||||
if not preference:
|
|
||||||
# Create new preference entry
|
|
||||||
preference = UserSearchSpacePreference(
|
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
)
|
|
||||||
session.add(preference)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(preference)
|
|
||||||
|
|
||||||
return preference
|
|
||||||
|
|
||||||
|
|
||||||
class LLMPreferencesUpdate(BaseModel):
|
class LLMPreferencesUpdate(BaseModel):
|
||||||
"""Schema for updating user LLM preferences"""
|
"""Schema for updating search space LLM preferences"""
|
||||||
|
|
||||||
long_context_llm_id: int | None = None
|
long_context_llm_id: int | None = None
|
||||||
fast_llm_id: int | None = None
|
fast_llm_id: int | None = None
|
||||||
|
|
@ -76,7 +31,7 @@ class LLMPreferencesUpdate(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class LLMPreferencesRead(BaseModel):
|
class LLMPreferencesRead(BaseModel):
|
||||||
"""Schema for reading user LLM preferences"""
|
"""Schema for reading search space LLM preferences"""
|
||||||
|
|
||||||
long_context_llm_id: int | None = None
|
long_context_llm_id: int | None = None
|
||||||
fast_llm_id: int | None = None
|
fast_llm_id: int | None = None
|
||||||
|
|
@ -144,10 +99,19 @@ async def create_llm_config(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Create a new LLM configuration for a search space"""
|
"""
|
||||||
|
Create a new LLM configuration for a search space.
|
||||||
|
Requires LLM_CONFIGS_CREATE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Verify user has access to the search space
|
# Verify user has permission to create LLM configs
|
||||||
await check_search_space_access(session, llm_config.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
llm_config.search_space_id,
|
||||||
|
Permission.LLM_CONFIGS_CREATE.value,
|
||||||
|
"You don't have permission to create LLM configurations in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
# Validate the LLM configuration by making a test API call
|
# Validate the LLM configuration by making a test API call
|
||||||
is_valid, error_message = await validate_llm_config(
|
is_valid, error_message = await validate_llm_config(
|
||||||
|
|
@ -187,10 +151,19 @@ async def read_llm_configs(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Get all LLM configurations for a search space"""
|
"""
|
||||||
|
Get all LLM configurations for a search space.
|
||||||
|
Requires LLM_CONFIGS_READ permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Verify user has access to the search space
|
# Verify user has permission to read LLM configs
|
||||||
await check_search_space_access(session, search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.LLM_CONFIGS_READ.value,
|
||||||
|
"You don't have permission to view LLM configurations in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(LLMConfig)
|
select(LLMConfig)
|
||||||
|
|
@ -213,7 +186,10 @@ async def read_llm_config(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Get a specific LLM configuration by ID"""
|
"""
|
||||||
|
Get a specific LLM configuration by ID.
|
||||||
|
Requires LLM_CONFIGS_READ permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the LLM config
|
# Get the LLM config
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|
@ -224,8 +200,14 @@ async def read_llm_config(
|
||||||
if not llm_config:
|
if not llm_config:
|
||||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||||
|
|
||||||
# Verify user has access to the search space
|
# Verify user has permission to read LLM configs
|
||||||
await check_search_space_access(session, llm_config.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
llm_config.search_space_id,
|
||||||
|
Permission.LLM_CONFIGS_READ.value,
|
||||||
|
"You don't have permission to view LLM configurations in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
return llm_config
|
return llm_config
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -243,7 +225,10 @@ async def update_llm_config(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Update an existing LLM configuration"""
|
"""
|
||||||
|
Update an existing LLM configuration.
|
||||||
|
Requires LLM_CONFIGS_UPDATE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the LLM config
|
# Get the LLM config
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|
@ -254,8 +239,14 @@ async def update_llm_config(
|
||||||
if not db_llm_config:
|
if not db_llm_config:
|
||||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||||
|
|
||||||
# Verify user has access to the search space
|
# Verify user has permission to update LLM configs
|
||||||
await check_search_space_access(session, db_llm_config.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_llm_config.search_space_id,
|
||||||
|
Permission.LLM_CONFIGS_UPDATE.value,
|
||||||
|
"You don't have permission to update LLM configurations in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
update_data = llm_config_update.model_dump(exclude_unset=True)
|
update_data = llm_config_update.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
|
@ -311,7 +302,10 @@ async def delete_llm_config(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Delete an LLM configuration"""
|
"""
|
||||||
|
Delete an LLM configuration.
|
||||||
|
Requires LLM_CONFIGS_DELETE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the LLM config
|
# Get the LLM config
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
|
|
@ -322,8 +316,14 @@ async def delete_llm_config(
|
||||||
if not db_llm_config:
|
if not db_llm_config:
|
||||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||||
|
|
||||||
# Verify user has access to the search space
|
# Verify user has permission to delete LLM configs
|
||||||
await check_search_space_access(session, db_llm_config.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_llm_config.search_space_id,
|
||||||
|
Permission.LLM_CONFIGS_DELETE.value,
|
||||||
|
"You don't have permission to delete LLM configurations in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(db_llm_config)
|
await session.delete(db_llm_config)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -337,28 +337,42 @@ async def delete_llm_config(
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
# User LLM Preferences endpoints
|
# Search Space LLM Preferences endpoints
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/search-spaces/{search_space_id}/llm-preferences",
|
"/search-spaces/{search_space_id}/llm-preferences",
|
||||||
response_model=LLMPreferencesRead,
|
response_model=LLMPreferencesRead,
|
||||||
)
|
)
|
||||||
async def get_user_llm_preferences(
|
async def get_llm_preferences(
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Get the current user's LLM preferences for a specific search space"""
|
"""
|
||||||
|
Get the LLM preferences for a specific search space.
|
||||||
|
LLM preferences are shared by all members of the search space.
|
||||||
|
Requires LLM_CONFIGS_READ permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Verify user has access to the search space
|
# Verify user has permission to read LLM configs
|
||||||
await check_search_space_access(session, search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
# Get or create user preference for this search space
|
user,
|
||||||
preference = await get_or_create_user_preference(
|
search_space_id,
|
||||||
session, user.id, search_space_id
|
Permission.LLM_CONFIGS_READ.value,
|
||||||
|
"You don't have permission to view LLM preferences in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get the search space
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||||
|
)
|
||||||
|
search_space = result.scalars().first()
|
||||||
|
|
||||||
|
if not search_space:
|
||||||
|
raise HTTPException(status_code=404, detail="Search space not found")
|
||||||
|
|
||||||
# Helper function to get config (global or custom)
|
# Helper function to get config (global or custom)
|
||||||
async def get_config_for_id(config_id):
|
async def get_config_for_id(config_id):
|
||||||
if config_id is None:
|
if config_id is None:
|
||||||
|
|
@ -391,14 +405,14 @@ async def get_user_llm_preferences(
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
# Get the configs (from DB for custom, or constructed for global)
|
# Get the configs (from DB for custom, or constructed for global)
|
||||||
long_context_llm = await get_config_for_id(preference.long_context_llm_id)
|
long_context_llm = await get_config_for_id(search_space.long_context_llm_id)
|
||||||
fast_llm = await get_config_for_id(preference.fast_llm_id)
|
fast_llm = await get_config_for_id(search_space.fast_llm_id)
|
||||||
strategic_llm = await get_config_for_id(preference.strategic_llm_id)
|
strategic_llm = await get_config_for_id(search_space.strategic_llm_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"long_context_llm_id": preference.long_context_llm_id,
|
"long_context_llm_id": search_space.long_context_llm_id,
|
||||||
"fast_llm_id": preference.fast_llm_id,
|
"fast_llm_id": search_space.fast_llm_id,
|
||||||
"strategic_llm_id": preference.strategic_llm_id,
|
"strategic_llm_id": search_space.strategic_llm_id,
|
||||||
"long_context_llm": long_context_llm,
|
"long_context_llm": long_context_llm,
|
||||||
"fast_llm": fast_llm,
|
"fast_llm": fast_llm,
|
||||||
"strategic_llm": strategic_llm,
|
"strategic_llm": strategic_llm,
|
||||||
|
|
@ -415,22 +429,37 @@ async def get_user_llm_preferences(
|
||||||
"/search-spaces/{search_space_id}/llm-preferences",
|
"/search-spaces/{search_space_id}/llm-preferences",
|
||||||
response_model=LLMPreferencesRead,
|
response_model=LLMPreferencesRead,
|
||||||
)
|
)
|
||||||
async def update_user_llm_preferences(
|
async def update_llm_preferences(
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
preferences: LLMPreferencesUpdate,
|
preferences: LLMPreferencesUpdate,
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Update the current user's LLM preferences for a specific search space"""
|
"""
|
||||||
|
Update the LLM preferences for a specific search space.
|
||||||
|
LLM preferences are shared by all members of the search space.
|
||||||
|
Requires SETTINGS_UPDATE permission (only users with settings access can change).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Verify user has access to the search space
|
# Verify user has permission to update settings (not just LLM configs)
|
||||||
await check_search_space_access(session, search_space_id, user)
|
# This ensures only users with settings access can change shared LLM preferences
|
||||||
|
await check_permission(
|
||||||
# Get or create user preference for this search space
|
session,
|
||||||
preference = await get_or_create_user_preference(
|
user,
|
||||||
session, user.id, search_space_id
|
search_space_id,
|
||||||
|
Permission.SETTINGS_UPDATE.value,
|
||||||
|
"You don't have permission to update LLM preferences in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get the search space
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||||
|
)
|
||||||
|
search_space = result.scalars().first()
|
||||||
|
|
||||||
|
if not search_space:
|
||||||
|
raise HTTPException(status_code=404, detail="Search space not found")
|
||||||
|
|
||||||
# Validate that all provided LLM config IDs belong to the search space
|
# Validate that all provided LLM config IDs belong to the search space
|
||||||
update_data = preferences.model_dump(exclude_unset=True)
|
update_data = preferences.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
|
@ -485,18 +514,13 @@ async def update_user_llm_preferences(
|
||||||
f"Multiple languages detected in LLM selection for search_space {search_space_id}: {languages}. "
|
f"Multiple languages detected in LLM selection for search_space {search_space_id}: {languages}. "
|
||||||
"This may affect response quality."
|
"This may affect response quality."
|
||||||
)
|
)
|
||||||
# Don't raise an exception - allow users to proceed
|
|
||||||
# raise HTTPException(
|
|
||||||
# status_code=400,
|
|
||||||
# detail="All selected LLM configurations must have the same language setting",
|
|
||||||
# )
|
|
||||||
|
|
||||||
# Update user preferences
|
# Update search space LLM preferences
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(preference, key, value)
|
setattr(search_space, key, value)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(preference)
|
await session.refresh(search_space)
|
||||||
|
|
||||||
# Helper function to get config (global or custom)
|
# Helper function to get config (global or custom)
|
||||||
async def get_config_for_id(config_id):
|
async def get_config_for_id(config_id):
|
||||||
|
|
@ -530,15 +554,15 @@ async def update_user_llm_preferences(
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
# Get the configs (from DB for custom, or constructed for global)
|
# Get the configs (from DB for custom, or constructed for global)
|
||||||
long_context_llm = await get_config_for_id(preference.long_context_llm_id)
|
long_context_llm = await get_config_for_id(search_space.long_context_llm_id)
|
||||||
fast_llm = await get_config_for_id(preference.fast_llm_id)
|
fast_llm = await get_config_for_id(search_space.fast_llm_id)
|
||||||
strategic_llm = await get_config_for_id(preference.strategic_llm_id)
|
strategic_llm = await get_config_for_id(search_space.strategic_llm_id)
|
||||||
|
|
||||||
# Return updated preferences
|
# Return updated preferences
|
||||||
return {
|
return {
|
||||||
"long_context_llm_id": preference.long_context_llm_id,
|
"long_context_llm_id": search_space.long_context_llm_id,
|
||||||
"fast_llm_id": preference.fast_llm_id,
|
"fast_llm_id": search_space.fast_llm_id,
|
||||||
"strategic_llm_id": preference.strategic_llm_id,
|
"strategic_llm_id": search_space.strategic_llm_id,
|
||||||
"long_context_llm": long_context_llm,
|
"long_context_llm": long_context_llm,
|
||||||
"fast_llm": fast_llm,
|
"fast_llm": fast_llm,
|
||||||
"strategic_llm": strategic_llm,
|
"strategic_llm": strategic_llm,
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,19 @@ from sqlalchemy import and_, desc
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.db import Log, LogLevel, LogStatus, SearchSpace, User, get_async_session
|
from app.db import (
|
||||||
|
Log,
|
||||||
|
LogLevel,
|
||||||
|
LogStatus,
|
||||||
|
Permission,
|
||||||
|
SearchSpace,
|
||||||
|
SearchSpaceMembership,
|
||||||
|
User,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
from app.schemas import LogCreate, LogRead, LogUpdate
|
from app.schemas import LogCreate, LogRead, LogUpdate
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -19,10 +28,19 @@ async def create_log(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Create a new log entry."""
|
"""
|
||||||
|
Create a new log entry.
|
||||||
|
Note: This is typically called internally. Requires LOGS_READ permission (since logs are usually system-generated).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if the user owns the search space
|
# Check if the user has access to the search space
|
||||||
await check_ownership(session, SearchSpace, log.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
log.search_space_id,
|
||||||
|
Permission.LOGS_READ.value,
|
||||||
|
"You don't have permission to access logs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
db_log = Log(**log.model_dump())
|
db_log = Log(**log.model_dump())
|
||||||
session.add(db_log)
|
session.add(db_log)
|
||||||
|
|
@ -51,22 +69,38 @@ async def read_logs(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Get logs with optional filtering."""
|
"""
|
||||||
|
Get logs with optional filtering.
|
||||||
|
Requires LOGS_READ permission for the search space(s).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Build base query - only logs from user's search spaces
|
|
||||||
query = (
|
|
||||||
select(Log)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(SearchSpace.user_id == user.id)
|
|
||||||
.order_by(desc(Log.created_at)) # Most recent first
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply filters
|
# Apply filters
|
||||||
filters = []
|
filters = []
|
||||||
|
|
||||||
if search_space_id is not None:
|
if search_space_id is not None:
|
||||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
# Check permission for specific search space
|
||||||
filters.append(Log.search_space_id == search_space_id)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.LOGS_READ.value,
|
||||||
|
"You don't have permission to read logs in this search space",
|
||||||
|
)
|
||||||
|
# Build query for specific search space
|
||||||
|
query = (
|
||||||
|
select(Log)
|
||||||
|
.filter(Log.search_space_id == search_space_id)
|
||||||
|
.order_by(desc(Log.created_at))
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Build base query - logs from search spaces user has membership in
|
||||||
|
query = (
|
||||||
|
select(Log)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
.order_by(desc(Log.created_at))
|
||||||
|
)
|
||||||
|
|
||||||
if level is not None:
|
if level is not None:
|
||||||
filters.append(Log.level == level)
|
filters.append(Log.level == level)
|
||||||
|
|
@ -104,19 +138,26 @@ async def read_log(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Get a specific log by ID."""
|
"""
|
||||||
|
Get a specific log by ID.
|
||||||
|
Requires LOGS_READ permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get log and verify user owns the search space
|
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||||
result = await session.execute(
|
|
||||||
select(Log)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
log = result.scalars().first()
|
log = result.scalars().first()
|
||||||
|
|
||||||
if not log:
|
if not log:
|
||||||
raise HTTPException(status_code=404, detail="Log not found")
|
raise HTTPException(status_code=404, detail="Log not found")
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
log.search_space_id,
|
||||||
|
Permission.LOGS_READ.value,
|
||||||
|
"You don't have permission to read logs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
return log
|
return log
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -133,19 +174,26 @@ async def update_log(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Update a log entry."""
|
"""
|
||||||
|
Update a log entry.
|
||||||
|
Requires LOGS_READ permission (logs are typically updated by system).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get log and verify user owns the search space
|
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||||
result = await session.execute(
|
|
||||||
select(Log)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
db_log = result.scalars().first()
|
db_log = result.scalars().first()
|
||||||
|
|
||||||
if not db_log:
|
if not db_log:
|
||||||
raise HTTPException(status_code=404, detail="Log not found")
|
raise HTTPException(status_code=404, detail="Log not found")
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_log.search_space_id,
|
||||||
|
Permission.LOGS_READ.value,
|
||||||
|
"You don't have permission to access logs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
# Update only provided fields
|
# Update only provided fields
|
||||||
update_data = log_update.model_dump(exclude_unset=True)
|
update_data = log_update.model_dump(exclude_unset=True)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
|
|
@ -169,19 +217,26 @@ async def delete_log(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Delete a log entry."""
|
"""
|
||||||
|
Delete a log entry.
|
||||||
|
Requires LOGS_DELETE permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get log and verify user owns the search space
|
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||||
result = await session.execute(
|
|
||||||
select(Log)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
db_log = result.scalars().first()
|
db_log = result.scalars().first()
|
||||||
|
|
||||||
if not db_log:
|
if not db_log:
|
||||||
raise HTTPException(status_code=404, detail="Log not found")
|
raise HTTPException(status_code=404, detail="Log not found")
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_log.search_space_id,
|
||||||
|
Permission.LOGS_DELETE.value,
|
||||||
|
"You don't have permission to delete logs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(db_log)
|
await session.delete(db_log)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"message": "Log deleted successfully"}
|
return {"message": "Log deleted successfully"}
|
||||||
|
|
@ -201,10 +256,19 @@ async def get_logs_summary(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Get a summary of logs for a search space in the last X hours."""
|
"""
|
||||||
|
Get a summary of logs for a search space in the last X hours.
|
||||||
|
Requires LOGS_READ permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check ownership
|
# Check permission
|
||||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.LOGS_READ.value,
|
||||||
|
"You don't have permission to read logs in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
# Calculate time window
|
# Calculate time window
|
||||||
since = datetime.utcnow().replace(microsecond=0) - timedelta(hours=hours)
|
since = datetime.utcnow().replace(microsecond=0) - timedelta(hours=hours)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,15 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.db import Chat, Podcast, SearchSpace, User, get_async_session
|
from app.db import (
|
||||||
|
Chat,
|
||||||
|
Permission,
|
||||||
|
Podcast,
|
||||||
|
SearchSpace,
|
||||||
|
SearchSpaceMembership,
|
||||||
|
User,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
PodcastCreate,
|
PodcastCreate,
|
||||||
PodcastGenerateRequest,
|
PodcastGenerateRequest,
|
||||||
|
|
@ -16,7 +24,7 @@ from app.schemas import (
|
||||||
)
|
)
|
||||||
from app.tasks.podcast_tasks import generate_chat_podcast
|
from app.tasks.podcast_tasks import generate_chat_podcast
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -27,8 +35,18 @@ async def create_podcast(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Create a new podcast.
|
||||||
|
Requires PODCASTS_CREATE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
await check_ownership(session, SearchSpace, podcast.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
podcast.search_space_id,
|
||||||
|
Permission.PODCASTS_CREATE.value,
|
||||||
|
"You don't have permission to create podcasts in this search space",
|
||||||
|
)
|
||||||
db_podcast = Podcast(**podcast.model_dump())
|
db_podcast = Podcast(**podcast.model_dump())
|
||||||
session.add(db_podcast)
|
session.add(db_podcast)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -58,20 +76,45 @@ async def create_podcast(
|
||||||
async def read_podcasts(
|
async def read_podcasts(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
search_space_id: int | None = None,
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
List podcasts the user has access to.
|
||||||
|
Requires PODCASTS_READ permission for the search space(s).
|
||||||
|
"""
|
||||||
if skip < 0 or limit < 1:
|
if skip < 0 or limit < 1:
|
||||||
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
|
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
if search_space_id is not None:
|
||||||
select(Podcast)
|
# Check permission for specific search space
|
||||||
.join(SearchSpace)
|
await check_permission(
|
||||||
.filter(SearchSpace.user_id == user.id)
|
session,
|
||||||
.offset(skip)
|
user,
|
||||||
.limit(limit)
|
search_space_id,
|
||||||
)
|
Permission.PODCASTS_READ.value,
|
||||||
|
"You don't have permission to read podcasts in this search space",
|
||||||
|
)
|
||||||
|
result = await session.execute(
|
||||||
|
select(Podcast)
|
||||||
|
.filter(Podcast.search_space_id == search_space_id)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Get podcasts from all search spaces user has membership in
|
||||||
|
result = await session.execute(
|
||||||
|
select(Podcast)
|
||||||
|
.join(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except SQLAlchemyError:
|
except SQLAlchemyError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail="Database error occurred while fetching podcasts"
|
status_code=500, detail="Database error occurred while fetching podcasts"
|
||||||
|
|
@ -84,18 +127,29 @@ async def read_podcast(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Get a specific podcast by ID.
|
||||||
|
Requires PODCASTS_READ permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||||
select(Podcast)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
podcast = result.scalars().first()
|
podcast = result.scalars().first()
|
||||||
|
|
||||||
if not podcast:
|
if not podcast:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="Podcast not found or you don't have permission to access it",
|
detail="Podcast not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
podcast.search_space_id,
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
"You don't have permission to read podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
return podcast
|
return podcast
|
||||||
except HTTPException as he:
|
except HTTPException as he:
|
||||||
raise he
|
raise he
|
||||||
|
|
@ -112,8 +166,26 @@ async def update_podcast(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Update a podcast.
|
||||||
|
Requires PODCASTS_UPDATE permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
db_podcast = await read_podcast(podcast_id, session, user)
|
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||||
|
db_podcast = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_podcast:
|
||||||
|
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_podcast.search_space_id,
|
||||||
|
Permission.PODCASTS_UPDATE.value,
|
||||||
|
"You don't have permission to update podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
update_data = podcast_update.model_dump(exclude_unset=True)
|
update_data = podcast_update.model_dump(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(db_podcast, key, value)
|
setattr(db_podcast, key, value)
|
||||||
|
|
@ -140,8 +212,26 @@ async def delete_podcast(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Delete a podcast.
|
||||||
|
Requires PODCASTS_DELETE permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
db_podcast = await read_podcast(podcast_id, session, user)
|
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||||
|
db_podcast = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_podcast:
|
||||||
|
raise HTTPException(status_code=404, detail="Podcast not found")
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_podcast.search_space_id,
|
||||||
|
Permission.PODCASTS_DELETE.value,
|
||||||
|
"You don't have permission to delete podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(db_podcast)
|
await session.delete(db_podcast)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"message": "Podcast deleted successfully"}
|
return {"message": "Podcast deleted successfully"}
|
||||||
|
|
@ -181,9 +271,19 @@ async def generate_podcast(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Generate a podcast from a chat or document.
|
||||||
|
Requires PODCASTS_CREATE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if the user owns the search space
|
# Check if the user has permission to create podcasts
|
||||||
await check_ownership(session, SearchSpace, request.search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
request.search_space_id,
|
||||||
|
Permission.PODCASTS_CREATE.value,
|
||||||
|
"You don't have permission to create podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
if request.type == "CHAT":
|
if request.type == "CHAT":
|
||||||
# Verify that all chat IDs belong to this user and search space
|
# Verify that all chat IDs belong to this user and search space
|
||||||
|
|
@ -251,22 +351,29 @@ async def stream_podcast(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Stream a podcast audio file."""
|
"""
|
||||||
|
Stream a podcast audio file.
|
||||||
|
Requires PODCASTS_READ permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the podcast and check if user has access
|
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||||
result = await session.execute(
|
|
||||||
select(Podcast)
|
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
|
||||||
podcast = result.scalars().first()
|
podcast = result.scalars().first()
|
||||||
|
|
||||||
if not podcast:
|
if not podcast:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
detail="Podcast not found or you don't have permission to access it",
|
detail="Podcast not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
podcast.search_space_id,
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
"You don't have permission to access podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
# Get the file path
|
# Get the file path
|
||||||
file_path = podcast.file_location
|
file_path = podcast.file_location
|
||||||
|
|
||||||
|
|
@ -303,12 +410,30 @@ async def get_podcast_by_chat_id(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Get a podcast by its associated chat ID.
|
||||||
|
Requires PODCASTS_READ permission for the search space.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the podcast and check if user has access
|
# First get the chat to find its search space
|
||||||
|
chat_result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||||
|
chat = chat_result.scalars().first()
|
||||||
|
|
||||||
|
if not chat:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check permission for the search space
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
chat.search_space_id,
|
||||||
|
Permission.PODCASTS_READ.value,
|
||||||
|
"You don't have permission to read podcasts in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get the podcast
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Podcast)
|
select(Podcast).filter(Podcast.chat_id == chat_id)
|
||||||
.join(SearchSpace)
|
|
||||||
.filter(Podcast.chat_id == chat_id, SearchSpace.user_id == user.id)
|
|
||||||
)
|
)
|
||||||
podcast = result.scalars().first()
|
podcast = result.scalars().first()
|
||||||
|
|
||||||
|
|
|
||||||
1084
surfsense_backend/app/routes/rbac_routes.py
Normal file
1084
surfsense_backend/app/routes/rbac_routes.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,9 +22,9 @@ from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.connectors.github_connector import GitHubConnector
|
from app.connectors.github_connector import GitHubConnector
|
||||||
from app.db import (
|
from app.db import (
|
||||||
|
Permission,
|
||||||
SearchSourceConnector,
|
SearchSourceConnector,
|
||||||
SearchSourceConnectorType,
|
SearchSourceConnectorType,
|
||||||
SearchSpace,
|
|
||||||
User,
|
User,
|
||||||
async_session_maker,
|
async_session_maker,
|
||||||
get_async_session,
|
get_async_session,
|
||||||
|
|
@ -52,12 +52,12 @@ from app.tasks.connector_indexers import (
|
||||||
index_slack_messages,
|
index_slack_messages,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
|
||||||
from app.utils.periodic_scheduler import (
|
from app.utils.periodic_scheduler import (
|
||||||
create_periodic_schedule,
|
create_periodic_schedule,
|
||||||
delete_periodic_schedule,
|
delete_periodic_schedule,
|
||||||
update_periodic_schedule,
|
update_periodic_schedule,
|
||||||
)
|
)
|
||||||
|
from app.utils.rbac import check_permission
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -108,19 +108,25 @@ async def create_search_source_connector(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a new search source connector.
|
Create a new search source connector.
|
||||||
|
Requires CONNECTORS_CREATE permission.
|
||||||
|
|
||||||
Each search space can have only one connector of each type per user (based on search_space_id, user_id, and connector_type).
|
Each search space can have only one connector of each type (based on search_space_id and connector_type).
|
||||||
The config must contain the appropriate keys for the connector type.
|
The config must contain the appropriate keys for the connector type.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if the search space belongs to the user
|
# Check if user has permission to create connectors
|
||||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.CONNECTORS_CREATE.value,
|
||||||
|
"You don't have permission to create connectors in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
# Check if a connector with the same type already exists for this search space and user
|
# Check if a connector with the same type already exists for this search space
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SearchSourceConnector).filter(
|
select(SearchSourceConnector).filter(
|
||||||
SearchSourceConnector.search_space_id == search_space_id,
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
SearchSourceConnector.user_id == user.id,
|
|
||||||
SearchSourceConnector.connector_type == connector.connector_type,
|
SearchSourceConnector.connector_type == connector.connector_type,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -128,7 +134,7 @@ async def create_search_source_connector(
|
||||||
if existing_connector:
|
if existing_connector:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"A connector with type {connector.connector_type} already exists in this search space. Each search space can have only one connector of each type per user.",
|
detail=f"A connector with type {connector.connector_type} already exists in this search space.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prepare connector data
|
# Prepare connector data
|
||||||
|
|
@ -198,22 +204,34 @@ async def read_search_source_connectors(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""List all search source connectors for the current user, optionally filtered by search space."""
|
"""
|
||||||
|
List all search source connectors for a search space.
|
||||||
|
Requires CONNECTORS_READ permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
query = select(SearchSourceConnector).filter(
|
if search_space_id is None:
|
||||||
SearchSourceConnector.user_id == user.id
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="search_space_id is required",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if user has permission to read connectors
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.CONNECTORS_READ.value,
|
||||||
|
"You don't have permission to view connectors in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Filter by search_space_id if provided
|
query = select(SearchSourceConnector).filter(
|
||||||
if search_space_id is not None:
|
SearchSourceConnector.search_space_id == search_space_id
|
||||||
# Verify the search space belongs to the user
|
)
|
||||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
|
||||||
query = query.filter(
|
|
||||||
SearchSourceConnector.search_space_id == search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await session.execute(query.offset(skip).limit(limit))
|
result = await session.execute(query.offset(skip).limit(limit))
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
|
|
@ -229,9 +247,32 @@ async def read_search_source_connector(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Get a specific search source connector by ID."""
|
"""
|
||||||
|
Get a specific search source connector by ID.
|
||||||
|
Requires CONNECTORS_READ permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
return await check_ownership(session, SearchSourceConnector, connector_id, user)
|
# Get the connector first
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.id == connector_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not connector:
|
||||||
|
raise HTTPException(status_code=404, detail="Connector not found")
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
connector.search_space_id,
|
||||||
|
Permission.CONNECTORS_READ.value,
|
||||||
|
"You don't have permission to view this connector",
|
||||||
|
)
|
||||||
|
|
||||||
|
return connector
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -251,10 +292,25 @@ async def update_search_source_connector(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Update a search source connector.
|
Update a search source connector.
|
||||||
|
Requires CONNECTORS_UPDATE permission.
|
||||||
Handles partial updates, including merging changes into the 'config' field.
|
Handles partial updates, including merging changes into the 'config' field.
|
||||||
"""
|
"""
|
||||||
db_connector = await check_ownership(
|
# Get the connector first
|
||||||
session, SearchSourceConnector, connector_id, user
|
result = await session.execute(
|
||||||
|
select(SearchSourceConnector).filter(SearchSourceConnector.id == connector_id)
|
||||||
|
)
|
||||||
|
db_connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_connector:
|
||||||
|
raise HTTPException(status_code=404, detail="Connector not found")
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_connector.search_space_id,
|
||||||
|
Permission.CONNECTORS_UPDATE.value,
|
||||||
|
"You don't have permission to update this connector",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Convert the sparse update data (only fields present in request) to a dict
|
# Convert the sparse update data (only fields present in request) to a dict
|
||||||
|
|
@ -349,20 +405,19 @@ async def update_search_source_connector(
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
# Prevent changing connector_type if it causes a duplicate (check moved here)
|
# Prevent changing connector_type if it causes a duplicate (check moved here)
|
||||||
if key == "connector_type" and value != db_connector.connector_type:
|
if key == "connector_type" and value != db_connector.connector_type:
|
||||||
result = await session.execute(
|
check_result = await session.execute(
|
||||||
select(SearchSourceConnector).filter(
|
select(SearchSourceConnector).filter(
|
||||||
SearchSourceConnector.search_space_id
|
SearchSourceConnector.search_space_id
|
||||||
== db_connector.search_space_id,
|
== db_connector.search_space_id,
|
||||||
SearchSourceConnector.user_id == user.id,
|
|
||||||
SearchSourceConnector.connector_type == value,
|
SearchSourceConnector.connector_type == value,
|
||||||
SearchSourceConnector.id != connector_id,
|
SearchSourceConnector.id != connector_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_connector = result.scalars().first()
|
existing_connector = check_result.scalars().first()
|
||||||
if existing_connector:
|
if existing_connector:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=409,
|
status_code=409,
|
||||||
detail=f"A connector with type {value} already exists in this search space. Each search space can have only one connector of each type per user.",
|
detail=f"A connector with type {value} already exists in this search space.",
|
||||||
)
|
)
|
||||||
|
|
||||||
setattr(db_connector, key, value)
|
setattr(db_connector, key, value)
|
||||||
|
|
@ -425,10 +480,29 @@ async def delete_search_source_connector(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
"""Delete a search source connector."""
|
"""
|
||||||
|
Delete a search source connector.
|
||||||
|
Requires CONNECTORS_DELETE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
db_connector = await check_ownership(
|
# Get the connector first
|
||||||
session, SearchSourceConnector, connector_id, user
|
result = await session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.id == connector_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db_connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_connector:
|
||||||
|
raise HTTPException(status_code=404, detail="Connector not found")
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
db_connector.search_space_id,
|
||||||
|
Permission.CONNECTORS_DELETE.value,
|
||||||
|
"You don't have permission to delete this connector",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete any periodic schedule associated with this connector
|
# Delete any periodic schedule associated with this connector
|
||||||
|
|
@ -473,6 +547,7 @@ async def index_connector_content(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Index content from a connector to a search space.
|
Index content from a connector to a search space.
|
||||||
|
Requires CONNECTORS_UPDATE permission (to trigger indexing).
|
||||||
|
|
||||||
Currently supports:
|
Currently supports:
|
||||||
- SLACK_CONNECTOR: Indexes messages from all accessible Slack channels
|
- SLACK_CONNECTOR: Indexes messages from all accessible Slack channels
|
||||||
|
|
@ -488,20 +563,29 @@ async def index_connector_content(
|
||||||
Args:
|
Args:
|
||||||
connector_id: ID of the connector to use
|
connector_id: ID of the connector to use
|
||||||
search_space_id: ID of the search space to store indexed content
|
search_space_id: ID of the search space to store indexed content
|
||||||
background_tasks: FastAPI background tasks
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dictionary with indexing status
|
Dictionary with indexing status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check if the connector belongs to the user
|
# Get the connector first
|
||||||
connector = await check_ownership(
|
result = await session.execute(
|
||||||
session, SearchSourceConnector, connector_id, user
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.id == connector_id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
connector = result.scalars().first()
|
||||||
|
|
||||||
# Check if the search space belongs to the user
|
if not connector:
|
||||||
_search_space = await check_ownership(
|
raise HTTPException(status_code=404, detail="Connector not found")
|
||||||
session, SearchSpace, search_space_id, user
|
|
||||||
|
# Check if user has permission to update connectors (indexing is an update operation)
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.CONNECTORS_UPDATE.value,
|
||||||
|
"You don't have permission to index content in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Handle different connector types
|
# Handle different connector types
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,77 @@
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.db import SearchSpace, User, get_async_session
|
from app.db import (
|
||||||
from app.schemas import SearchSpaceCreate, SearchSpaceRead, SearchSpaceUpdate
|
Permission,
|
||||||
|
SearchSpace,
|
||||||
|
SearchSpaceMembership,
|
||||||
|
SearchSpaceRole,
|
||||||
|
User,
|
||||||
|
get_async_session,
|
||||||
|
get_default_roles_config,
|
||||||
|
)
|
||||||
|
from app.schemas import (
|
||||||
|
SearchSpaceCreate,
|
||||||
|
SearchSpaceRead,
|
||||||
|
SearchSpaceUpdate,
|
||||||
|
SearchSpaceWithStats,
|
||||||
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.check_ownership import check_ownership
|
from app.utils.rbac import check_permission, check_search_space_access
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def create_default_roles_and_membership(
|
||||||
|
session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
owner_user_id,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Create default system roles for a search space and add the owner as a member.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
search_space_id: The ID of the newly created search space
|
||||||
|
owner_user_id: The UUID of the user who created the search space
|
||||||
|
"""
|
||||||
|
# Create default roles
|
||||||
|
default_roles = get_default_roles_config()
|
||||||
|
owner_role_id = None
|
||||||
|
|
||||||
|
for role_config in default_roles:
|
||||||
|
db_role = SearchSpaceRole(
|
||||||
|
name=role_config["name"],
|
||||||
|
description=role_config["description"],
|
||||||
|
permissions=role_config["permissions"],
|
||||||
|
is_default=role_config["is_default"],
|
||||||
|
is_system_role=role_config["is_system_role"],
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
)
|
||||||
|
session.add(db_role)
|
||||||
|
await session.flush() # Get the ID
|
||||||
|
|
||||||
|
if role_config["name"] == "Owner":
|
||||||
|
owner_role_id = db_role.id
|
||||||
|
|
||||||
|
# Create owner membership
|
||||||
|
owner_membership = SearchSpaceMembership(
|
||||||
|
user_id=owner_user_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
role_id=owner_role_id,
|
||||||
|
is_owner=True,
|
||||||
|
)
|
||||||
|
session.add(owner_membership)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/searchspaces", response_model=SearchSpaceRead)
|
@router.post("/searchspaces", response_model=SearchSpaceRead)
|
||||||
async def create_search_space(
|
async def create_search_space(
|
||||||
search_space: SearchSpaceCreate,
|
search_space: SearchSpaceCreate,
|
||||||
|
|
@ -27,6 +86,11 @@ async def create_search_space(
|
||||||
|
|
||||||
db_search_space = SearchSpace(**search_space_data, user_id=user.id)
|
db_search_space = SearchSpace(**search_space_data, user_id=user.id)
|
||||||
session.add(db_search_space)
|
session.add(db_search_space)
|
||||||
|
await session.flush() # Get the search space ID
|
||||||
|
|
||||||
|
# Create default roles and owner membership
|
||||||
|
await create_default_roles_and_membership(session, db_search_space.id, user.id)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(db_search_space)
|
await session.refresh(db_search_space)
|
||||||
return db_search_space
|
return db_search_space
|
||||||
|
|
@ -34,26 +98,86 @@ async def create_search_space(
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
logger.error(f"Failed to create search space: {e!s}", exc_info=True)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to create search space: {e!s}"
|
status_code=500, detail=f"Failed to create search space: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/searchspaces", response_model=list[SearchSpaceRead])
|
@router.get("/searchspaces", response_model=list[SearchSpaceWithStats])
|
||||||
async def read_search_spaces(
|
async def read_search_spaces(
|
||||||
skip: int = 0,
|
skip: int = 0,
|
||||||
limit: int = 200,
|
limit: int = 200,
|
||||||
|
owned_only: bool = False,
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Get all search spaces the user has access to, with member count and ownership info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
skip: Number of items to skip
|
||||||
|
limit: Maximum number of items to return
|
||||||
|
owned_only: If True, only return search spaces owned by the user.
|
||||||
|
If False (default), return all search spaces the user has access to.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
result = await session.execute(
|
if owned_only:
|
||||||
select(SearchSpace)
|
# Return only search spaces where user is the original creator (user_id)
|
||||||
.filter(SearchSpace.user_id == user.id)
|
result = await session.execute(
|
||||||
.offset(skip)
|
select(SearchSpace)
|
||||||
.limit(limit)
|
.filter(SearchSpace.user_id == user.id)
|
||||||
)
|
.offset(skip)
|
||||||
return result.scalars().all()
|
.limit(limit)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Return all search spaces the user has membership in
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace)
|
||||||
|
.join(SearchSpaceMembership)
|
||||||
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
.offset(skip)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
search_spaces = result.scalars().all()
|
||||||
|
|
||||||
|
# Get member counts and ownership info for each search space
|
||||||
|
search_spaces_with_stats = []
|
||||||
|
for space in search_spaces:
|
||||||
|
# Get member count
|
||||||
|
count_result = await session.execute(
|
||||||
|
select(func.count(SearchSpaceMembership.id)).filter(
|
||||||
|
SearchSpaceMembership.search_space_id == space.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
member_count = count_result.scalar() or 1
|
||||||
|
|
||||||
|
# Check if current user is owner
|
||||||
|
ownership_result = await session.execute(
|
||||||
|
select(SearchSpaceMembership).filter(
|
||||||
|
SearchSpaceMembership.search_space_id == space.id,
|
||||||
|
SearchSpaceMembership.user_id == user.id,
|
||||||
|
SearchSpaceMembership.is_owner == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is_owner = ownership_result.scalars().first() is not None
|
||||||
|
|
||||||
|
search_spaces_with_stats.append(
|
||||||
|
SearchSpaceWithStats(
|
||||||
|
id=space.id,
|
||||||
|
name=space.name,
|
||||||
|
description=space.description,
|
||||||
|
created_at=space.created_at,
|
||||||
|
user_id=space.user_id,
|
||||||
|
citations_enabled=space.citations_enabled,
|
||||||
|
qna_custom_instructions=space.qna_custom_instructions,
|
||||||
|
member_count=member_count,
|
||||||
|
is_owner=is_owner,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return search_spaces_with_stats
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to fetch search spaces: {e!s}"
|
status_code=500, detail=f"Failed to fetch search spaces: {e!s}"
|
||||||
|
|
@ -97,10 +221,22 @@ async def read_search_space(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Get a specific search space by ID.
|
||||||
|
Requires SETTINGS_VIEW permission or membership.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
search_space = await check_ownership(
|
# Check if user has access (is a member)
|
||||||
session, SearchSpace, search_space_id, user
|
await check_search_space_access(session, user, search_space_id)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||||
)
|
)
|
||||||
|
search_space = result.scalars().first()
|
||||||
|
|
||||||
|
if not search_space:
|
||||||
|
raise HTTPException(status_code=404, detail="Search space not found")
|
||||||
|
|
||||||
return search_space
|
return search_space
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -118,10 +254,28 @@ async def update_search_space(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Update a search space.
|
||||||
|
Requires SETTINGS_UPDATE permission.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
db_search_space = await check_ownership(
|
# Check permission
|
||||||
session, SearchSpace, search_space_id, user
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.SETTINGS_UPDATE.value,
|
||||||
|
"You don't have permission to update this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||||
|
)
|
||||||
|
db_search_space = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_search_space:
|
||||||
|
raise HTTPException(status_code=404, detail="Search space not found")
|
||||||
|
|
||||||
update_data = search_space_update.model_dump(exclude_unset=True)
|
update_data = search_space_update.model_dump(exclude_unset=True)
|
||||||
for key, value in update_data.items():
|
for key, value in update_data.items():
|
||||||
setattr(db_search_space, key, value)
|
setattr(db_search_space, key, value)
|
||||||
|
|
@ -143,10 +297,28 @@ async def delete_search_space(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Delete a search space.
|
||||||
|
Requires SETTINGS_DELETE permission (only owners have this by default).
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
db_search_space = await check_ownership(
|
# Check permission - only those with SETTINGS_DELETE can delete
|
||||||
session, SearchSpace, search_space_id, user
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.SETTINGS_DELETE.value,
|
||||||
|
"You don't have permission to delete this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||||
|
)
|
||||||
|
db_search_space = result.scalars().first()
|
||||||
|
|
||||||
|
if not db_search_space:
|
||||||
|
raise HTTPException(status_code=404, detail="Search space not found")
|
||||||
|
|
||||||
await session.delete(db_search_space)
|
await session.delete(db_search_space)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"message": "Search space deleted successfully"}
|
return {"message": "Search space deleted successfully"}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,23 @@ from .podcasts import (
|
||||||
PodcastRead,
|
PodcastRead,
|
||||||
PodcastUpdate,
|
PodcastUpdate,
|
||||||
)
|
)
|
||||||
|
from .rbac_schemas import (
|
||||||
|
InviteAcceptRequest,
|
||||||
|
InviteAcceptResponse,
|
||||||
|
InviteCreate,
|
||||||
|
InviteInfoResponse,
|
||||||
|
InviteRead,
|
||||||
|
InviteUpdate,
|
||||||
|
MembershipRead,
|
||||||
|
MembershipReadWithUser,
|
||||||
|
MembershipUpdate,
|
||||||
|
PermissionInfo,
|
||||||
|
PermissionsListResponse,
|
||||||
|
RoleCreate,
|
||||||
|
RoleRead,
|
||||||
|
RoleUpdate,
|
||||||
|
UserSearchSpaceAccess,
|
||||||
|
)
|
||||||
from .search_source_connector import (
|
from .search_source_connector import (
|
||||||
SearchSourceConnectorBase,
|
SearchSourceConnectorBase,
|
||||||
SearchSourceConnectorCreate,
|
SearchSourceConnectorCreate,
|
||||||
|
|
@ -38,6 +55,7 @@ from .search_space import (
|
||||||
SearchSpaceCreate,
|
SearchSpaceCreate,
|
||||||
SearchSpaceRead,
|
SearchSpaceRead,
|
||||||
SearchSpaceUpdate,
|
SearchSpaceUpdate,
|
||||||
|
SearchSpaceWithStats,
|
||||||
)
|
)
|
||||||
from .users import UserCreate, UserRead, UserUpdate
|
from .users import UserCreate, UserRead, UserUpdate
|
||||||
|
|
||||||
|
|
@ -60,6 +78,13 @@ __all__ = [
|
||||||
"ExtensionDocumentContent",
|
"ExtensionDocumentContent",
|
||||||
"ExtensionDocumentMetadata",
|
"ExtensionDocumentMetadata",
|
||||||
"IDModel",
|
"IDModel",
|
||||||
|
# RBAC schemas
|
||||||
|
"InviteAcceptRequest",
|
||||||
|
"InviteAcceptResponse",
|
||||||
|
"InviteCreate",
|
||||||
|
"InviteInfoResponse",
|
||||||
|
"InviteRead",
|
||||||
|
"InviteUpdate",
|
||||||
"LLMConfigBase",
|
"LLMConfigBase",
|
||||||
"LLMConfigCreate",
|
"LLMConfigCreate",
|
||||||
"LLMConfigRead",
|
"LLMConfigRead",
|
||||||
|
|
@ -69,12 +94,20 @@ __all__ = [
|
||||||
"LogFilter",
|
"LogFilter",
|
||||||
"LogRead",
|
"LogRead",
|
||||||
"LogUpdate",
|
"LogUpdate",
|
||||||
|
"MembershipRead",
|
||||||
|
"MembershipReadWithUser",
|
||||||
|
"MembershipUpdate",
|
||||||
"PaginatedResponse",
|
"PaginatedResponse",
|
||||||
|
"PermissionInfo",
|
||||||
|
"PermissionsListResponse",
|
||||||
"PodcastBase",
|
"PodcastBase",
|
||||||
"PodcastCreate",
|
"PodcastCreate",
|
||||||
"PodcastGenerateRequest",
|
"PodcastGenerateRequest",
|
||||||
"PodcastRead",
|
"PodcastRead",
|
||||||
"PodcastUpdate",
|
"PodcastUpdate",
|
||||||
|
"RoleCreate",
|
||||||
|
"RoleRead",
|
||||||
|
"RoleUpdate",
|
||||||
"SearchSourceConnectorBase",
|
"SearchSourceConnectorBase",
|
||||||
"SearchSourceConnectorCreate",
|
"SearchSourceConnectorCreate",
|
||||||
"SearchSourceConnectorRead",
|
"SearchSourceConnectorRead",
|
||||||
|
|
@ -83,8 +116,10 @@ __all__ = [
|
||||||
"SearchSpaceCreate",
|
"SearchSpaceCreate",
|
||||||
"SearchSpaceRead",
|
"SearchSpaceRead",
|
||||||
"SearchSpaceUpdate",
|
"SearchSpaceUpdate",
|
||||||
|
"SearchSpaceWithStats",
|
||||||
"TimestampModel",
|
"TimestampModel",
|
||||||
"UserCreate",
|
"UserCreate",
|
||||||
"UserRead",
|
"UserRead",
|
||||||
|
"UserSearchSpaceAccess",
|
||||||
"UserUpdate",
|
"UserUpdate",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
186
surfsense_backend/app/schemas/rbac_schemas.py
Normal file
186
surfsense_backend/app/schemas/rbac_schemas.py
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
"""
|
||||||
|
Pydantic schemas for RBAC (Role-Based Access Control) endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
# ============ Role Schemas ============
|
||||||
|
|
||||||
|
|
||||||
|
class RoleBase(BaseModel):
|
||||||
|
"""Base schema for roles."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
permissions: list[str] = Field(default_factory=list)
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class RoleCreate(RoleBase):
|
||||||
|
"""Schema for creating a new role."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RoleUpdate(BaseModel):
|
||||||
|
"""Schema for updating a role (partial update)."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
description: str | None = Field(None, max_length=500)
|
||||||
|
permissions: list[str] | None = None
|
||||||
|
is_default: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRead(RoleBase):
|
||||||
|
"""Schema for reading a role."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
search_space_id: int
|
||||||
|
is_system_role: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Membership Schemas ============
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipBase(BaseModel):
|
||||||
|
"""Base schema for memberships."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipUpdate(BaseModel):
|
||||||
|
"""Schema for updating a membership (change role)."""
|
||||||
|
|
||||||
|
role_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipRead(BaseModel):
|
||||||
|
"""Schema for reading a membership."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
user_id: UUID
|
||||||
|
search_space_id: int
|
||||||
|
role_id: int | None
|
||||||
|
is_owner: bool
|
||||||
|
joined_at: datetime
|
||||||
|
created_at: datetime
|
||||||
|
# Nested role info
|
||||||
|
role: RoleRead | None = None
|
||||||
|
# User email (populated separately)
|
||||||
|
user_email: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipReadWithUser(MembershipRead):
|
||||||
|
"""Schema for reading a membership with user details."""
|
||||||
|
|
||||||
|
user_email: str | None = None
|
||||||
|
user_is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Invite Schemas ============
|
||||||
|
|
||||||
|
|
||||||
|
class InviteBase(BaseModel):
|
||||||
|
"""Base schema for invites."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, max_length=100)
|
||||||
|
role_id: int | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
max_uses: int | None = Field(None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class InviteCreate(InviteBase):
|
||||||
|
"""Schema for creating a new invite."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class InviteUpdate(BaseModel):
|
||||||
|
"""Schema for updating an invite (partial update)."""
|
||||||
|
|
||||||
|
name: str | None = Field(None, max_length=100)
|
||||||
|
role_id: int | None = None
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
max_uses: int | None = Field(None, ge=1)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRead(InviteBase):
|
||||||
|
"""Schema for reading an invite."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
invite_code: str
|
||||||
|
search_space_id: int
|
||||||
|
created_by_id: UUID | None
|
||||||
|
uses_count: int
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
# Nested role info
|
||||||
|
role: RoleRead | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class InviteAcceptRequest(BaseModel):
|
||||||
|
"""Schema for accepting an invite."""
|
||||||
|
|
||||||
|
invite_code: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class InviteAcceptResponse(BaseModel):
|
||||||
|
"""Response schema for accepting an invite."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
search_space_id: int
|
||||||
|
search_space_name: str
|
||||||
|
role_name: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class InviteInfoResponse(BaseModel):
|
||||||
|
"""Response schema for getting invite info (public endpoint)."""
|
||||||
|
|
||||||
|
search_space_name: str
|
||||||
|
role_name: str | None
|
||||||
|
is_valid: bool
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Permission Schemas ============
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionInfo(BaseModel):
|
||||||
|
"""Schema for permission information."""
|
||||||
|
|
||||||
|
value: str
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionsListResponse(BaseModel):
|
||||||
|
"""Response schema for listing all available permissions."""
|
||||||
|
|
||||||
|
permissions: list[PermissionInfo]
|
||||||
|
|
||||||
|
|
||||||
|
# ============ User Access Info ============
|
||||||
|
|
||||||
|
|
||||||
|
class UserSearchSpaceAccess(BaseModel):
|
||||||
|
"""Schema for user's access info in a search space."""
|
||||||
|
|
||||||
|
search_space_id: int
|
||||||
|
search_space_name: str
|
||||||
|
is_owner: bool
|
||||||
|
role_name: str | None
|
||||||
|
permissions: list[str]
|
||||||
|
|
@ -34,3 +34,10 @@ class SearchSpaceRead(SearchSpaceBase, IDModel, TimestampModel):
|
||||||
qna_custom_instructions: str | None = None
|
qna_custom_instructions: str | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchSpaceWithStats(SearchSpaceRead):
|
||||||
|
"""Extended search space info with member count and ownership status."""
|
||||||
|
|
||||||
|
member_count: int = 1
|
||||||
|
is_owner: bool = False
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,17 @@ from app.db import (
|
||||||
Document,
|
Document,
|
||||||
SearchSourceConnector,
|
SearchSourceConnector,
|
||||||
SearchSourceConnectorType,
|
SearchSourceConnectorType,
|
||||||
SearchSpace,
|
|
||||||
)
|
)
|
||||||
from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
|
from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
|
||||||
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
|
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
|
||||||
|
|
||||||
|
|
||||||
class ConnectorService:
|
class ConnectorService:
|
||||||
def __init__(self, session: AsyncSession, user_id: str | None = None):
|
def __init__(self, session: AsyncSession, search_space_id: int | None = None):
|
||||||
self.session = session
|
self.session = session
|
||||||
self.chunk_retriever = ChucksHybridSearchRetriever(session)
|
self.chunk_retriever = ChucksHybridSearchRetriever(session)
|
||||||
self.document_retriever = DocumentHybridSearchRetriever(session)
|
self.document_retriever = DocumentHybridSearchRetriever(session)
|
||||||
self.user_id = user_id
|
self.search_space_id = search_space_id
|
||||||
self.source_id_counter = (
|
self.source_id_counter = (
|
||||||
100000 # High starting value to avoid collisions with existing IDs
|
100000 # High starting value to avoid collisions with existing IDs
|
||||||
)
|
)
|
||||||
|
|
@ -36,23 +35,22 @@ class ConnectorService:
|
||||||
|
|
||||||
async def initialize_counter(self):
|
async def initialize_counter(self):
|
||||||
"""
|
"""
|
||||||
Initialize the source_id_counter based on the total number of chunks for the user.
|
Initialize the source_id_counter based on the total number of chunks for the search space.
|
||||||
This ensures unique IDs across different sessions.
|
This ensures unique IDs across different sessions.
|
||||||
"""
|
"""
|
||||||
if self.user_id:
|
if self.search_space_id:
|
||||||
try:
|
try:
|
||||||
# Count total chunks for documents belonging to this user
|
# Count total chunks for documents belonging to this search space
|
||||||
|
|
||||||
result = await self.session.execute(
|
result = await self.session.execute(
|
||||||
select(func.count(Chunk.id))
|
select(func.count(Chunk.id))
|
||||||
.join(Document)
|
.join(Document)
|
||||||
.join(SearchSpace)
|
.filter(Document.search_space_id == self.search_space_id)
|
||||||
.filter(SearchSpace.user_id == self.user_id)
|
|
||||||
)
|
)
|
||||||
chunk_count = result.scalar() or 0
|
chunk_count = result.scalar() or 0
|
||||||
self.source_id_counter = chunk_count + 1
|
self.source_id_counter = chunk_count + 1
|
||||||
print(
|
print(
|
||||||
f"Initialized source_id_counter to {self.source_id_counter} for user {self.user_id}"
|
f"Initialized source_id_counter to {self.source_id_counter} for search space {self.search_space_id}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error initializing source_id_counter: {e!s}")
|
print(f"Error initializing source_id_counter: {e!s}")
|
||||||
|
|
@ -62,7 +60,6 @@ class ConnectorService:
|
||||||
async def search_crawled_urls(
|
async def search_crawled_urls(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -72,7 +69,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -84,7 +80,6 @@ class ConnectorService:
|
||||||
crawled_urls_chunks = await self.chunk_retriever.hybrid_search(
|
crawled_urls_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="CRAWLED_URL",
|
document_type="CRAWLED_URL",
|
||||||
)
|
)
|
||||||
|
|
@ -92,7 +87,6 @@ class ConnectorService:
|
||||||
crawled_urls_chunks = await self.document_retriever.hybrid_search(
|
crawled_urls_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="CRAWLED_URL",
|
document_type="CRAWLED_URL",
|
||||||
)
|
)
|
||||||
|
|
@ -171,7 +165,6 @@ class ConnectorService:
|
||||||
async def search_files(
|
async def search_files(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -186,7 +179,6 @@ class ConnectorService:
|
||||||
files_chunks = await self.chunk_retriever.hybrid_search(
|
files_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="FILE",
|
document_type="FILE",
|
||||||
)
|
)
|
||||||
|
|
@ -194,7 +186,6 @@ class ConnectorService:
|
||||||
files_chunks = await self.document_retriever.hybrid_search(
|
files_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="FILE",
|
document_type="FILE",
|
||||||
)
|
)
|
||||||
|
|
@ -274,43 +265,35 @@ class ConnectorService:
|
||||||
|
|
||||||
async def get_connector_by_type(
|
async def get_connector_by_type(
|
||||||
self,
|
self,
|
||||||
user_id: str,
|
|
||||||
connector_type: SearchSourceConnectorType,
|
connector_type: SearchSourceConnectorType,
|
||||||
search_space_id: int | None = None,
|
search_space_id: int,
|
||||||
) -> SearchSourceConnector | None:
|
) -> SearchSourceConnector | None:
|
||||||
"""
|
"""
|
||||||
Get a connector by type for a specific user and optionally a search space
|
Get a connector by type for a specific search space
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: The user's ID
|
|
||||||
connector_type: The connector type to retrieve
|
connector_type: The connector type to retrieve
|
||||||
search_space_id: Optional search space ID to filter by
|
search_space_id: The search space ID to filter by
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Optional[SearchSourceConnector]: The connector if found, None otherwise
|
Optional[SearchSourceConnector]: The connector if found, None otherwise
|
||||||
"""
|
"""
|
||||||
query = select(SearchSourceConnector).filter(
|
query = select(SearchSourceConnector).filter(
|
||||||
SearchSourceConnector.user_id == user_id,
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
SearchSourceConnector.connector_type == connector_type,
|
SearchSourceConnector.connector_type == connector_type,
|
||||||
)
|
)
|
||||||
|
|
||||||
if search_space_id is not None:
|
|
||||||
query = query.filter(
|
|
||||||
SearchSourceConnector.search_space_id == search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await self.session.execute(query)
|
result = await self.session.execute(query)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
async def search_tavily(
|
async def search_tavily(
|
||||||
self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20
|
self, user_query: str, search_space_id: int, top_k: int = 20
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""
|
"""
|
||||||
Search using Tavily API and return both the source information and documents
|
Search using Tavily API and return both the source information and documents
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID
|
search_space_id: The search space ID
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
|
|
||||||
|
|
@ -319,7 +302,7 @@ class ConnectorService:
|
||||||
"""
|
"""
|
||||||
# Get Tavily connector configuration
|
# Get Tavily connector configuration
|
||||||
tavily_connector = await self.get_connector_by_type(
|
tavily_connector = await self.get_connector_by_type(
|
||||||
user_id, SearchSourceConnectorType.TAVILY_API, search_space_id
|
SearchSourceConnectorType.TAVILY_API, search_space_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not tavily_connector:
|
if not tavily_connector:
|
||||||
|
|
@ -412,7 +395,6 @@ class ConnectorService:
|
||||||
async def search_searxng(
|
async def search_searxng(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
|
|
@ -420,7 +402,7 @@ class ConnectorService:
|
||||||
Search using a configured SearxNG instance and return both sources and documents.
|
Search using a configured SearxNG instance and return both sources and documents.
|
||||||
"""
|
"""
|
||||||
searx_connector = await self.get_connector_by_type(
|
searx_connector = await self.get_connector_by_type(
|
||||||
user_id, SearchSourceConnectorType.SEARXNG_API, search_space_id
|
SearchSourceConnectorType.SEARXNG_API, search_space_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not searx_connector:
|
if not searx_connector:
|
||||||
|
|
@ -598,7 +580,6 @@ class ConnectorService:
|
||||||
async def search_baidu(
|
async def search_baidu(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
|
|
@ -610,7 +591,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: User's search query
|
user_query: User's search query
|
||||||
user_id: User ID
|
|
||||||
search_space_id: Search space ID
|
search_space_id: Search space ID
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
|
|
||||||
|
|
@ -619,7 +599,7 @@ class ConnectorService:
|
||||||
"""
|
"""
|
||||||
# Get Baidu connector configuration
|
# Get Baidu connector configuration
|
||||||
baidu_connector = await self.get_connector_by_type(
|
baidu_connector = await self.get_connector_by_type(
|
||||||
user_id, SearchSourceConnectorType.BAIDU_SEARCH_API, search_space_id
|
SearchSourceConnectorType.BAIDU_SEARCH_API, search_space_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not baidu_connector:
|
if not baidu_connector:
|
||||||
|
|
@ -824,7 +804,6 @@ class ConnectorService:
|
||||||
async def search_slack(
|
async def search_slack(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -839,7 +818,6 @@ class ConnectorService:
|
||||||
slack_chunks = await self.chunk_retriever.hybrid_search(
|
slack_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="SLACK_CONNECTOR",
|
document_type="SLACK_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -847,7 +825,6 @@ class ConnectorService:
|
||||||
slack_chunks = await self.document_retriever.hybrid_search(
|
slack_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="SLACK_CONNECTOR",
|
document_type="SLACK_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -912,7 +889,6 @@ class ConnectorService:
|
||||||
async def search_notion(
|
async def search_notion(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -922,7 +898,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
|
|
||||||
|
|
@ -933,7 +908,6 @@ class ConnectorService:
|
||||||
notion_chunks = await self.chunk_retriever.hybrid_search(
|
notion_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="NOTION_CONNECTOR",
|
document_type="NOTION_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -941,7 +915,6 @@ class ConnectorService:
|
||||||
notion_chunks = await self.document_retriever.hybrid_search(
|
notion_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="NOTION_CONNECTOR",
|
document_type="NOTION_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1009,7 +982,6 @@ class ConnectorService:
|
||||||
async def search_extension(
|
async def search_extension(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1019,7 +991,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
|
|
||||||
|
|
@ -1030,7 +1001,6 @@ class ConnectorService:
|
||||||
extension_chunks = await self.chunk_retriever.hybrid_search(
|
extension_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="EXTENSION",
|
document_type="EXTENSION",
|
||||||
)
|
)
|
||||||
|
|
@ -1038,7 +1008,6 @@ class ConnectorService:
|
||||||
extension_chunks = await self.document_retriever.hybrid_search(
|
extension_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="EXTENSION",
|
document_type="EXTENSION",
|
||||||
)
|
)
|
||||||
|
|
@ -1130,7 +1099,6 @@ class ConnectorService:
|
||||||
async def search_youtube(
|
async def search_youtube(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1140,7 +1108,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
|
|
||||||
|
|
@ -1151,7 +1118,6 @@ class ConnectorService:
|
||||||
youtube_chunks = await self.chunk_retriever.hybrid_search(
|
youtube_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="YOUTUBE_VIDEO",
|
document_type="YOUTUBE_VIDEO",
|
||||||
)
|
)
|
||||||
|
|
@ -1159,7 +1125,6 @@ class ConnectorService:
|
||||||
youtube_chunks = await self.document_retriever.hybrid_search(
|
youtube_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="YOUTUBE_VIDEO",
|
document_type="YOUTUBE_VIDEO",
|
||||||
)
|
)
|
||||||
|
|
@ -1227,7 +1192,6 @@ class ConnectorService:
|
||||||
async def search_github(
|
async def search_github(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: int,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1242,7 +1206,6 @@ class ConnectorService:
|
||||||
github_chunks = await self.chunk_retriever.hybrid_search(
|
github_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="GITHUB_CONNECTOR",
|
document_type="GITHUB_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1250,7 +1213,6 @@ class ConnectorService:
|
||||||
github_chunks = await self.document_retriever.hybrid_search(
|
github_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="GITHUB_CONNECTOR",
|
document_type="GITHUB_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1302,7 +1264,6 @@ class ConnectorService:
|
||||||
async def search_linear(
|
async def search_linear(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1312,7 +1273,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
|
|
||||||
|
|
@ -1323,7 +1283,6 @@ class ConnectorService:
|
||||||
linear_chunks = await self.chunk_retriever.hybrid_search(
|
linear_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="LINEAR_CONNECTOR",
|
document_type="LINEAR_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1331,7 +1290,6 @@ class ConnectorService:
|
||||||
linear_chunks = await self.document_retriever.hybrid_search(
|
linear_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="LINEAR_CONNECTOR",
|
document_type="LINEAR_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1411,7 +1369,6 @@ class ConnectorService:
|
||||||
async def search_jira(
|
async def search_jira(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1421,7 +1378,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -1433,7 +1389,6 @@ class ConnectorService:
|
||||||
jira_chunks = await self.chunk_retriever.hybrid_search(
|
jira_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="JIRA_CONNECTOR",
|
document_type="JIRA_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1441,7 +1396,6 @@ class ConnectorService:
|
||||||
jira_chunks = await self.document_retriever.hybrid_search(
|
jira_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="JIRA_CONNECTOR",
|
document_type="JIRA_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1532,7 +1486,6 @@ class ConnectorService:
|
||||||
async def search_google_calendar(
|
async def search_google_calendar(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1542,7 +1495,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -1554,7 +1506,6 @@ class ConnectorService:
|
||||||
calendar_chunks = await self.chunk_retriever.hybrid_search(
|
calendar_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="GOOGLE_CALENDAR_CONNECTOR",
|
document_type="GOOGLE_CALENDAR_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1562,7 +1513,6 @@ class ConnectorService:
|
||||||
calendar_chunks = await self.document_retriever.hybrid_search(
|
calendar_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="GOOGLE_CALENDAR_CONNECTOR",
|
document_type="GOOGLE_CALENDAR_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1665,7 +1615,6 @@ class ConnectorService:
|
||||||
async def search_airtable(
|
async def search_airtable(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1675,7 +1624,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -1687,7 +1635,6 @@ class ConnectorService:
|
||||||
airtable_chunks = await self.chunk_retriever.hybrid_search(
|
airtable_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="AIRTABLE_CONNECTOR",
|
document_type="AIRTABLE_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1695,7 +1642,6 @@ class ConnectorService:
|
||||||
airtable_chunks = await self.document_retriever.hybrid_search(
|
airtable_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="AIRTABLE_CONNECTOR",
|
document_type="AIRTABLE_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1753,7 +1699,6 @@ class ConnectorService:
|
||||||
async def search_google_gmail(
|
async def search_google_gmail(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1763,7 +1708,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -1775,7 +1719,6 @@ class ConnectorService:
|
||||||
gmail_chunks = await self.chunk_retriever.hybrid_search(
|
gmail_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="GOOGLE_GMAIL_CONNECTOR",
|
document_type="GOOGLE_GMAIL_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1783,7 +1726,6 @@ class ConnectorService:
|
||||||
gmail_chunks = await self.document_retriever.hybrid_search(
|
gmail_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="GOOGLE_GMAIL_CONNECTOR",
|
document_type="GOOGLE_GMAIL_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1877,7 +1819,6 @@ class ConnectorService:
|
||||||
async def search_confluence(
|
async def search_confluence(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1887,7 +1828,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -1899,7 +1839,6 @@ class ConnectorService:
|
||||||
confluence_chunks = await self.chunk_retriever.hybrid_search(
|
confluence_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="CONFLUENCE_CONNECTOR",
|
document_type="CONFLUENCE_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1907,7 +1846,6 @@ class ConnectorService:
|
||||||
confluence_chunks = await self.document_retriever.hybrid_search(
|
confluence_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="CONFLUENCE_CONNECTOR",
|
document_type="CONFLUENCE_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -1972,7 +1910,6 @@ class ConnectorService:
|
||||||
async def search_clickup(
|
async def search_clickup(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -1982,7 +1919,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -1994,7 +1930,6 @@ class ConnectorService:
|
||||||
clickup_chunks = await self.chunk_retriever.hybrid_search(
|
clickup_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="CLICKUP_CONNECTOR",
|
document_type="CLICKUP_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -2002,7 +1937,6 @@ class ConnectorService:
|
||||||
clickup_chunks = await self.document_retriever.hybrid_search(
|
clickup_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="CLICKUP_CONNECTOR",
|
document_type="CLICKUP_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -2088,7 +2022,6 @@ class ConnectorService:
|
||||||
async def search_linkup(
|
async def search_linkup(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
mode: str = "standard",
|
mode: str = "standard",
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
|
|
@ -2097,7 +2030,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID
|
search_space_id: The search space ID
|
||||||
mode: Search depth mode, can be "standard" or "deep"
|
mode: Search depth mode, can be "standard" or "deep"
|
||||||
|
|
||||||
|
|
@ -2106,7 +2038,7 @@ class ConnectorService:
|
||||||
"""
|
"""
|
||||||
# Get Linkup connector configuration
|
# Get Linkup connector configuration
|
||||||
linkup_connector = await self.get_connector_by_type(
|
linkup_connector = await self.get_connector_by_type(
|
||||||
user_id, SearchSourceConnectorType.LINKUP_API, search_space_id
|
SearchSourceConnectorType.LINKUP_API, search_space_id
|
||||||
)
|
)
|
||||||
|
|
||||||
if not linkup_connector:
|
if not linkup_connector:
|
||||||
|
|
@ -2211,7 +2143,6 @@ class ConnectorService:
|
||||||
async def search_discord(
|
async def search_discord(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -2221,7 +2152,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
|
|
||||||
|
|
@ -2232,7 +2162,6 @@ class ConnectorService:
|
||||||
discord_chunks = await self.chunk_retriever.hybrid_search(
|
discord_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="DISCORD_CONNECTOR",
|
document_type="DISCORD_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -2240,7 +2169,6 @@ class ConnectorService:
|
||||||
discord_chunks = await self.document_retriever.hybrid_search(
|
discord_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="DISCORD_CONNECTOR",
|
document_type="DISCORD_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -2308,7 +2236,6 @@ class ConnectorService:
|
||||||
async def search_luma(
|
async def search_luma(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -2318,7 +2245,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -2330,7 +2256,6 @@ class ConnectorService:
|
||||||
luma_chunks = await self.chunk_retriever.hybrid_search(
|
luma_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="LUMA_CONNECTOR",
|
document_type="LUMA_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -2338,7 +2263,6 @@ class ConnectorService:
|
||||||
luma_chunks = await self.document_retriever.hybrid_search(
|
luma_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="LUMA_CONNECTOR",
|
document_type="LUMA_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -2466,7 +2390,6 @@ class ConnectorService:
|
||||||
async def search_elasticsearch(
|
async def search_elasticsearch(
|
||||||
self,
|
self,
|
||||||
user_query: str,
|
user_query: str,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
top_k: int = 20,
|
top_k: int = 20,
|
||||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||||
|
|
@ -2476,7 +2399,6 @@ class ConnectorService:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The user's query
|
user_query: The user's query
|
||||||
user_id: The user's ID
|
|
||||||
search_space_id: The search space ID to search in
|
search_space_id: The search space ID to search in
|
||||||
top_k: Maximum number of results to return
|
top_k: Maximum number of results to return
|
||||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||||
|
|
@ -2488,7 +2410,6 @@ class ConnectorService:
|
||||||
elasticsearch_chunks = await self.chunk_retriever.hybrid_search(
|
elasticsearch_chunks = await self.chunk_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="ELASTICSEARCH_CONNECTOR",
|
document_type="ELASTICSEARCH_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
@ -2496,7 +2417,6 @@ class ConnectorService:
|
||||||
elasticsearch_chunks = await self.document_retriever.hybrid_search(
|
elasticsearch_chunks = await self.document_retriever.hybrid_search(
|
||||||
query_text=user_query,
|
query_text=user_query,
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
user_id=user_id,
|
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
document_type="ELASTICSEARCH_CONNECTOR",
|
document_type="ELASTICSEARCH_CONNECTOR",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import LLMConfig, UserSearchSpacePreference
|
from app.db import LLMConfig, SearchSpace
|
||||||
|
|
||||||
# Configure litellm to automatically drop unsupported parameters
|
# Configure litellm to automatically drop unsupported parameters
|
||||||
litellm.drop_params = True
|
litellm.drop_params = True
|
||||||
|
|
@ -144,15 +144,16 @@ async def validate_llm_config(
|
||||||
return False, error_msg
|
return False, error_msg
|
||||||
|
|
||||||
|
|
||||||
async def get_user_llm_instance(
|
async def get_search_space_llm_instance(
|
||||||
session: AsyncSession, user_id: str, search_space_id: int, role: str
|
session: AsyncSession, search_space_id: int, role: str
|
||||||
) -> ChatLiteLLM | None:
|
) -> ChatLiteLLM | None:
|
||||||
"""
|
"""
|
||||||
Get a ChatLiteLLM instance for a specific user, search space, and role.
|
Get a ChatLiteLLM instance for a specific search space and role.
|
||||||
|
|
||||||
|
LLM preferences are stored at the search space level and shared by all members.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
user_id: User ID
|
|
||||||
search_space_id: Search Space ID
|
search_space_id: Search Space ID
|
||||||
role: LLM role ('long_context', 'fast', or 'strategic')
|
role: LLM role ('long_context', 'fast', or 'strategic')
|
||||||
|
|
||||||
|
|
@ -160,37 +161,30 @@ async def get_user_llm_instance(
|
||||||
ChatLiteLLM instance or None if not found
|
ChatLiteLLM instance or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get user's LLM preferences for this search space
|
# Get the search space with its LLM preferences
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(UserSearchSpacePreference).where(
|
select(SearchSpace).where(SearchSpace.id == search_space_id)
|
||||||
UserSearchSpacePreference.user_id == user_id,
|
|
||||||
UserSearchSpacePreference.search_space_id == search_space_id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
preference = result.scalars().first()
|
search_space = result.scalars().first()
|
||||||
|
|
||||||
if not preference:
|
if not search_space:
|
||||||
logger.error(
|
logger.error(f"Search space {search_space_id} not found")
|
||||||
f"No LLM preferences found for user {user_id} in search space {search_space_id}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Get the appropriate LLM config ID based on role
|
# Get the appropriate LLM config ID based on role
|
||||||
llm_config_id = None
|
llm_config_id = None
|
||||||
if role == LLMRole.LONG_CONTEXT:
|
if role == LLMRole.LONG_CONTEXT:
|
||||||
llm_config_id = preference.long_context_llm_id
|
llm_config_id = search_space.long_context_llm_id
|
||||||
elif role == LLMRole.FAST:
|
elif role == LLMRole.FAST:
|
||||||
llm_config_id = preference.fast_llm_id
|
llm_config_id = search_space.fast_llm_id
|
||||||
elif role == LLMRole.STRATEGIC:
|
elif role == LLMRole.STRATEGIC:
|
||||||
llm_config_id = preference.strategic_llm_id
|
llm_config_id = search_space.strategic_llm_id
|
||||||
else:
|
else:
|
||||||
logger.error(f"Invalid LLM role: {role}")
|
logger.error(f"Invalid LLM role: {role}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not llm_config_id:
|
if not llm_config_id:
|
||||||
logger.error(
|
logger.error(f"No {role} LLM configured for search space {search_space_id}")
|
||||||
f"No {role} LLM configured for user {user_id} in search space {search_space_id}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Check if this is a global config (negative ID)
|
# Check if this is a global config (negative ID)
|
||||||
|
|
@ -331,31 +325,63 @@ async def get_user_llm_instance(
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error getting LLM instance for user {user_id}, role {role}: {e!s}"
|
f"Error getting LLM instance for search space {search_space_id}, role {role}: {e!s}"
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_long_context_llm(
|
||||||
|
session: AsyncSession, search_space_id: int
|
||||||
|
) -> ChatLiteLLM | None:
|
||||||
|
"""Get the search space's long context LLM instance."""
|
||||||
|
return await get_search_space_llm_instance(
|
||||||
|
session, search_space_id, LLMRole.LONG_CONTEXT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_fast_llm(
|
||||||
|
session: AsyncSession, search_space_id: int
|
||||||
|
) -> ChatLiteLLM | None:
|
||||||
|
"""Get the search space's fast LLM instance."""
|
||||||
|
return await get_search_space_llm_instance(session, search_space_id, LLMRole.FAST)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_strategic_llm(
|
||||||
|
session: AsyncSession, search_space_id: int
|
||||||
|
) -> ChatLiteLLM | None:
|
||||||
|
"""Get the search space's strategic LLM instance."""
|
||||||
|
return await get_search_space_llm_instance(
|
||||||
|
session, search_space_id, LLMRole.STRATEGIC
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Backward-compatible aliases (deprecated - will be removed in future versions)
|
||||||
|
async def get_user_llm_instance(
|
||||||
|
session: AsyncSession, user_id: str, search_space_id: int, role: str
|
||||||
|
) -> ChatLiteLLM | None:
|
||||||
|
"""
|
||||||
|
Deprecated: Use get_search_space_llm_instance instead.
|
||||||
|
LLM preferences are now stored at the search space level, not per-user.
|
||||||
|
"""
|
||||||
|
return await get_search_space_llm_instance(session, search_space_id, role)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_long_context_llm(
|
async def get_user_long_context_llm(
|
||||||
session: AsyncSession, user_id: str, search_space_id: int
|
session: AsyncSession, user_id: str, search_space_id: int
|
||||||
) -> ChatLiteLLM | None:
|
) -> ChatLiteLLM | None:
|
||||||
"""Get user's long context LLM instance for a specific search space."""
|
"""Deprecated: Use get_long_context_llm instead."""
|
||||||
return await get_user_llm_instance(
|
return await get_long_context_llm(session, search_space_id)
|
||||||
session, user_id, search_space_id, LLMRole.LONG_CONTEXT
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_fast_llm(
|
async def get_user_fast_llm(
|
||||||
session: AsyncSession, user_id: str, search_space_id: int
|
session: AsyncSession, user_id: str, search_space_id: int
|
||||||
) -> ChatLiteLLM | None:
|
) -> ChatLiteLLM | None:
|
||||||
"""Get user's fast LLM instance for a specific search space."""
|
"""Deprecated: Use get_fast_llm instead."""
|
||||||
return await get_user_llm_instance(session, user_id, search_space_id, LLMRole.FAST)
|
return await get_fast_llm(session, search_space_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_strategic_llm(
|
async def get_user_strategic_llm(
|
||||||
session: AsyncSession, user_id: str, search_space_id: int
|
session: AsyncSession, user_id: str, search_space_id: int
|
||||||
) -> ChatLiteLLM | None:
|
) -> ChatLiteLLM | None:
|
||||||
"""Get user's strategic LLM instance for a specific search space."""
|
"""Deprecated: Use get_strategic_llm instead."""
|
||||||
return await get_user_llm_instance(
|
return await get_strategic_llm(session, search_space_id)
|
||||||
session, user_id, search_space_id, LLMRole.STRATEGIC
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from typing import Any
|
||||||
from langchain.schema import AIMessage, HumanMessage, SystemMessage
|
from langchain.schema import AIMessage, HumanMessage, SystemMessage
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.services.llm_service import get_user_strategic_llm
|
from app.services.llm_service import get_strategic_llm
|
||||||
|
|
||||||
|
|
||||||
class QueryService:
|
class QueryService:
|
||||||
|
|
@ -16,19 +16,17 @@ class QueryService:
|
||||||
async def reformulate_query_with_chat_history(
|
async def reformulate_query_with_chat_history(
|
||||||
user_query: str,
|
user_query: str,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: str,
|
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
chat_history_str: str | None = None,
|
chat_history_str: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Reformulate the user query using the user's strategic LLM to make it more
|
Reformulate the user query using the search space's strategic LLM to make it more
|
||||||
effective for information retrieval and research purposes.
|
effective for information retrieval and research purposes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_query: The original user query
|
user_query: The original user query
|
||||||
session: Database session for accessing user LLM configs
|
session: Database session for accessing LLM configs
|
||||||
user_id: User ID to get their specific LLM configuration
|
search_space_id: Search Space ID to get LLM preferences
|
||||||
search_space_id: Search Space ID to get user's LLM preferences
|
|
||||||
chat_history_str: Optional chat history string
|
chat_history_str: Optional chat history string
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -38,11 +36,11 @@ class QueryService:
|
||||||
return user_query
|
return user_query
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get the user's strategic LLM instance
|
# Get the search space's strategic LLM instance
|
||||||
llm = await get_user_strategic_llm(session, user_id, search_space_id)
|
llm = await get_strategic_llm(session, search_space_id)
|
||||||
if not llm:
|
if not llm:
|
||||||
print(
|
print(
|
||||||
f"Warning: No strategic LLM configured for user {user_id} in search space {search_space_id}. Using original query."
|
f"Warning: No strategic LLM configured for search space {search_space_id}. Using original query."
|
||||||
)
|
)
|
||||||
return user_query
|
return user_query
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.future import select
|
|
||||||
|
|
||||||
from app.db import User
|
|
||||||
|
|
||||||
|
|
||||||
# Helper function to check user ownership
|
|
||||||
async def check_ownership(session: AsyncSession, model, item_id: int, user: User):
|
|
||||||
item = await session.execute(
|
|
||||||
select(model).filter(model.id == item_id, model.user_id == user.id)
|
|
||||||
)
|
|
||||||
item = item.scalars().first()
|
|
||||||
if not item:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail="Item not found or you don't have permission to access it",
|
|
||||||
)
|
|
||||||
return item
|
|
||||||
274
surfsense_backend/app/utils/rbac.py
Normal file
274
surfsense_backend/app/utils/rbac.py
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
"""
|
||||||
|
RBAC (Role-Based Access Control) utility functions.
|
||||||
|
Provides helpers for checking user permissions in search spaces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.db import (
|
||||||
|
Permission,
|
||||||
|
SearchSpace,
|
||||||
|
SearchSpaceMembership,
|
||||||
|
SearchSpaceRole,
|
||||||
|
User,
|
||||||
|
has_permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_membership(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: UUID,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> SearchSpaceMembership | None:
|
||||||
|
"""
|
||||||
|
Get the user's membership in a search space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user_id: User UUID
|
||||||
|
search_space_id: Search space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchSpaceMembership if found, None otherwise
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpaceMembership)
|
||||||
|
.options(selectinload(SearchSpaceMembership.role))
|
||||||
|
.filter(
|
||||||
|
SearchSpaceMembership.user_id == user_id,
|
||||||
|
SearchSpaceMembership.search_space_id == search_space_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_permissions(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: UUID,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get the user's permissions in a search space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user_id: User UUID
|
||||||
|
search_space_id: Search space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of permission strings
|
||||||
|
"""
|
||||||
|
membership = await get_user_membership(session, user_id, search_space_id)
|
||||||
|
|
||||||
|
if not membership:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Owners always have full access
|
||||||
|
if membership.is_owner:
|
||||||
|
return [Permission.FULL_ACCESS.value]
|
||||||
|
|
||||||
|
# Get permissions from role
|
||||||
|
if membership.role:
|
||||||
|
return membership.role.permissions or []
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def check_permission(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
search_space_id: int,
|
||||||
|
required_permission: str,
|
||||||
|
error_message: str = "You don't have permission to perform this action",
|
||||||
|
) -> SearchSpaceMembership:
|
||||||
|
"""
|
||||||
|
Check if a user has a specific permission in a search space.
|
||||||
|
Raises HTTPException if permission is denied.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user: User object
|
||||||
|
search_space_id: Search space ID
|
||||||
|
required_permission: Permission string to check
|
||||||
|
error_message: Custom error message for permission denied
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchSpaceMembership if permission granted
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If user doesn't have access or permission
|
||||||
|
"""
|
||||||
|
membership = await get_user_membership(session, user.id, search_space_id)
|
||||||
|
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="You don't have access to this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user's permissions
|
||||||
|
if membership.is_owner:
|
||||||
|
permissions = [Permission.FULL_ACCESS.value]
|
||||||
|
elif membership.role:
|
||||||
|
permissions = membership.role.permissions or []
|
||||||
|
else:
|
||||||
|
permissions = []
|
||||||
|
|
||||||
|
if not has_permission(permissions, required_permission):
|
||||||
|
raise HTTPException(status_code=403, detail=error_message)
|
||||||
|
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
async def check_search_space_access(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> SearchSpaceMembership:
|
||||||
|
"""
|
||||||
|
Check if a user has any access to a search space.
|
||||||
|
This is used for basic access control (user is a member).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user: User object
|
||||||
|
search_space_id: Search space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SearchSpaceMembership if user has access
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If user doesn't have access
|
||||||
|
"""
|
||||||
|
membership = await get_user_membership(session, user.id, search_space_id)
|
||||||
|
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="You don't have access to this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
return membership
|
||||||
|
|
||||||
|
|
||||||
|
async def is_search_space_owner(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: UUID,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Check if a user is the owner of a search space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user_id: User UUID
|
||||||
|
search_space_id: Search space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user is the owner, False otherwise
|
||||||
|
"""
|
||||||
|
membership = await get_user_membership(session, user_id, search_space_id)
|
||||||
|
return membership is not None and membership.is_owner
|
||||||
|
|
||||||
|
|
||||||
|
async def get_search_space_with_access_check(
|
||||||
|
session: AsyncSession,
|
||||||
|
user: User,
|
||||||
|
search_space_id: int,
|
||||||
|
required_permission: str | None = None,
|
||||||
|
) -> tuple[SearchSpace, SearchSpaceMembership]:
|
||||||
|
"""
|
||||||
|
Get a search space with access and optional permission check.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
user: User object
|
||||||
|
search_space_id: Search space ID
|
||||||
|
required_permission: Optional permission to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (SearchSpace, SearchSpaceMembership)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If search space not found or user lacks access/permission
|
||||||
|
"""
|
||||||
|
# Get the search space
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpace).filter(SearchSpace.id == search_space_id)
|
||||||
|
)
|
||||||
|
search_space = result.scalars().first()
|
||||||
|
|
||||||
|
if not search_space:
|
||||||
|
raise HTTPException(status_code=404, detail="Search space not found")
|
||||||
|
|
||||||
|
# Check access
|
||||||
|
if required_permission:
|
||||||
|
membership = await check_permission(
|
||||||
|
session, user, search_space_id, required_permission
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
membership = await check_search_space_access(session, user, search_space_id)
|
||||||
|
|
||||||
|
return search_space, membership
|
||||||
|
|
||||||
|
|
||||||
|
def generate_invite_code() -> str:
|
||||||
|
"""
|
||||||
|
Generate a unique invite code for search space invites.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A 32-character URL-safe invite code
|
||||||
|
"""
|
||||||
|
return secrets.token_urlsafe(24)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_default_role(
|
||||||
|
session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> SearchSpaceRole | None:
|
||||||
|
"""
|
||||||
|
Get the default role for a search space (used when accepting invites without a specific role).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
search_space_id: Search space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Default SearchSpaceRole or None
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpaceRole).filter(
|
||||||
|
SearchSpaceRole.search_space_id == search_space_id,
|
||||||
|
SearchSpaceRole.is_default == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_owner_role(
|
||||||
|
session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> SearchSpaceRole | None:
|
||||||
|
"""
|
||||||
|
Get the Owner role for a search space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
search_space_id: Search space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Owner SearchSpaceRole or None
|
||||||
|
"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSpaceRole).filter(
|
||||||
|
SearchSpaceRole.search_space_id == search_space_id,
|
||||||
|
SearchSpaceRole.name == "Owner",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
@ -18,6 +18,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||||
|
import { useUserAccess } from "@/hooks/use-rbac";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
|
|
@ -60,11 +61,15 @@ export function DashboardClientLayout({
|
||||||
}, [activeChatId, isChatPannelOpen]);
|
}, [activeChatId, isChatPannelOpen]);
|
||||||
|
|
||||||
const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum);
|
const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum);
|
||||||
|
const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum);
|
||||||
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
||||||
|
|
||||||
// Skip onboarding check if we're already on the onboarding page
|
// Skip onboarding check if we're already on the onboarding page
|
||||||
const isOnboardingPage = pathname?.includes("/onboard");
|
const isOnboardingPage = pathname?.includes("/onboard");
|
||||||
|
|
||||||
|
// Only owners should see onboarding - invited members use existing config
|
||||||
|
const isOwner = access?.is_owner ?? false;
|
||||||
|
|
||||||
// Translate navigation items
|
// Translate navigation items
|
||||||
const tNavMenu = useTranslations("nav_menu");
|
const tNavMenu = useTranslations("nav_menu");
|
||||||
const translatedNavMain = useMemo(() => {
|
const translatedNavMain = useMemo(() => {
|
||||||
|
|
@ -102,11 +107,13 @@ export function DashboardClientLayout({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only check once after preferences have loaded
|
// Wait for both preferences and access data to load
|
||||||
if (!loading && !hasCheckedOnboarding) {
|
if (!loading && !accessLoading && !hasCheckedOnboarding) {
|
||||||
const onboardingComplete = isOnboardingComplete();
|
const onboardingComplete = isOnboardingComplete();
|
||||||
|
|
||||||
if (!onboardingComplete) {
|
// Only redirect to onboarding if user is the owner and onboarding is not complete
|
||||||
|
// Invited members (non-owners) should skip onboarding and use existing config
|
||||||
|
if (!onboardingComplete && isOwner) {
|
||||||
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,8 +121,10 @@ export function DashboardClientLayout({
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
loading,
|
loading,
|
||||||
|
accessLoading,
|
||||||
isOnboardingComplete,
|
isOnboardingComplete,
|
||||||
isOnboardingPage,
|
isOnboardingPage,
|
||||||
|
isOwner,
|
||||||
router,
|
router,
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
hasCheckedOnboarding,
|
hasCheckedOnboarding,
|
||||||
|
|
@ -145,7 +154,7 @@ export function DashboardClientLayout({
|
||||||
}, [chat_id, search_space_id]);
|
}, [chat_id, search_space_id]);
|
||||||
|
|
||||||
// Show loading screen while checking onboarding status (only on first load)
|
// Show loading screen while checking onboarding status (only on first load)
|
||||||
if (!hasCheckedOnboarding && loading && !isOnboardingPage) {
|
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,12 @@ export default function DashboardLayout({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Team",
|
||||||
|
url: `/dashboard/${search_space_id}/team`,
|
||||||
|
icon: "Users",
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Settings",
|
title: "Settings",
|
||||||
url: `/dashboard/${search_space_id}/settings`,
|
url: `/dashboard/${search_space_id}/settings`,
|
||||||
|
|
|
||||||
|
|
@ -1126,7 +1126,7 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
|
||||||
setIsDeleting(true);
|
setIsDeleting(true);
|
||||||
try {
|
try {
|
||||||
await deleteLog(log.id);
|
await deleteLog(log.id);
|
||||||
toast.success(t("log_deleted_success"));
|
// toast.success(t("log_deleted_success"));
|
||||||
await refreshLogs();
|
await refreshLogs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting log:", error);
|
console.error("Error deleting log:", error);
|
||||||
|
|
|
||||||
1325
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
1325
surfsense_web/app/dashboard/[search_space_id]/team/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
import { AlertCircle, Loader2, Plus, Search, Trash2, UserCheck, Users } from "lucide-react";
|
||||||
import { motion, type Variants } from "motion/react";
|
import { motion, type Variants } from "motion/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -308,16 +309,30 @@ const DashboardPage = () => {
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 flex-col justify-between p-1">
|
<div className="flex flex-1 flex-col justify-between p-1">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-lg">{space.name}</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-lg">{space.name}</h3>
|
||||||
|
{!space.is_owner && (
|
||||||
|
<Badge variant="secondary" className="text-xs font-normal">
|
||||||
|
{t("shared")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{space.description}
|
{space.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-xs text-muted-foreground">
|
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||||
{/* <span>{space.title}</span> */}
|
|
||||||
<span>
|
<span>
|
||||||
{t("created")} {formatDate(space.created_at)}
|
{t("created")} {formatDate(space.created_at)}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{space.is_owner ? (
|
||||||
|
<UserCheck className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{space.member_count}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
336
surfsense_web/app/invite/[invite_code]/page.tsx
Normal file
336
surfsense_web/app/invite/[invite_code]/page.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Loader2,
|
||||||
|
LogIn,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
XCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { use, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useInviteInfo } from "@/hooks/use-rbac";
|
||||||
|
|
||||||
|
export default function InviteAcceptPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const inviteCode = params.invite_code as string;
|
||||||
|
|
||||||
|
const { inviteInfo, loading, acceptInvite } = useInviteInfo(inviteCode);
|
||||||
|
const [accepting, setAccepting] = useState(false);
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
|
const [acceptedData, setAcceptedData] = useState<{
|
||||||
|
search_space_id: number;
|
||||||
|
search_space_name: string;
|
||||||
|
role_name: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
// Check if user is logged in
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const token = localStorage.getItem("surfsense_bearer_token");
|
||||||
|
setIsLoggedIn(!!token);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAccept = async () => {
|
||||||
|
setAccepting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await acceptInvite();
|
||||||
|
if (result) {
|
||||||
|
setAccepted(true);
|
||||||
|
setAcceptedData(result);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to accept invite");
|
||||||
|
} finally {
|
||||||
|
setAccepting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginRedirect = () => {
|
||||||
|
// Store the invite code to redirect back after login
|
||||||
|
localStorage.setItem("pending_invite_code", inviteCode);
|
||||||
|
router.push("/auth");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for pending invite after login
|
||||||
|
useEffect(() => {
|
||||||
|
if (isLoggedIn && typeof window !== "undefined") {
|
||||||
|
const pendingInvite = localStorage.getItem("pending_invite_code");
|
||||||
|
if (pendingInvite === inviteCode) {
|
||||||
|
localStorage.removeItem("pending_invite_code");
|
||||||
|
// Auto-accept the invite after redirect
|
||||||
|
handleAccept();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, inviteCode]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-primary/5">
|
||||||
|
{/* Background decoration */}
|
||||||
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div className="absolute -top-1/2 -right-1/2 w-full h-full bg-gradient-to-bl from-primary/10 via-transparent to-transparent rounded-full blur-3xl" />
|
||||||
|
<div className="absolute -bottom-1/2 -left-1/2 w-full h-full bg-gradient-to-tr from-violet-500/10 via-transparent to-transparent rounded-full blur-3xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||||
|
className="w-full max-w-md relative z-10"
|
||||||
|
>
|
||||||
|
<Card className="border-none shadow-2xl bg-card/80 backdrop-blur-xl">
|
||||||
|
{loading || isLoggedIn === null ? (
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-16">
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-12 w-12 text-primary" />
|
||||||
|
</motion.div>
|
||||||
|
<p className="mt-4 text-muted-foreground">Loading invite details...</p>
|
||||||
|
</CardContent>
|
||||||
|
) : accepted && acceptedData ? (
|
||||||
|
<>
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||||
|
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-emerald-500/20 to-emerald-500/5 flex items-center justify-center ring-4 ring-emerald-500/20"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="h-10 w-10 text-emerald-500" />
|
||||||
|
</motion.div>
|
||||||
|
<CardTitle className="text-2xl">Welcome to the team!</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
You've successfully joined {acceptedData.search_space_name}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Users className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{acceptedData.search_space_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||||
|
<Shield className="h-5 w-5 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{acceptedData.role_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Your Role</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={() => router.push(`/dashboard/${acceptedData.search_space_id}`)}
|
||||||
|
>
|
||||||
|
Go to Search Space
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</>
|
||||||
|
) : !inviteInfo?.is_valid ? (
|
||||||
|
<>
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||||
|
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-destructive/20 to-destructive/5 flex items-center justify-center ring-4 ring-destructive/20"
|
||||||
|
>
|
||||||
|
<XCircle className="h-10 w-10 text-destructive" />
|
||||||
|
</motion.div>
|
||||||
|
<CardTitle className="text-2xl">Invalid Invite</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{inviteInfo?.message || "This invite link is no longer valid"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The invite may have expired, reached its maximum uses, or been revoked by the
|
||||||
|
owner.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => router.push("/dashboard")}
|
||||||
|
>
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</>
|
||||||
|
) : !isLoggedIn ? (
|
||||||
|
<>
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||||
|
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-4 ring-primary/20"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-10 w-10 text-primary" />
|
||||||
|
</motion.div>
|
||||||
|
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Sign in to join {inviteInfo?.search_space_name || "this search space"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Users className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{inviteInfo?.search_space_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{inviteInfo?.role_name && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||||
|
<Shield className="h-5 w-5 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{inviteInfo.role_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Role you'll receive</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button className="w-full gap-2" onClick={handleLoginRedirect}>
|
||||||
|
<LogIn className="h-4 w-4" />
|
||||||
|
Sign in to Accept
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CardHeader className="text-center pb-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 15 }}
|
||||||
|
className="mx-auto mb-4 h-20 w-20 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-4 ring-primary/20"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-10 w-10 text-primary" />
|
||||||
|
</motion.div>
|
||||||
|
<CardTitle className="text-2xl">You're Invited!</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Accept this invite to join {inviteInfo?.search_space_name || "this search space"}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="bg-muted/50 rounded-lg p-4 space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||||
|
<Users className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{inviteInfo?.search_space_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Search Space</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{inviteInfo?.role_name && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-lg bg-violet-500/10 flex items-center justify-center">
|
||||||
|
<Shield className="h-5 w-5 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{inviteInfo.role_name}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Role you'll receive</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 p-3 bg-destructive/10 text-destructive rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => router.push("/dashboard")}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
|
||||||
|
{accepting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Accepting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Accept Invite
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Branding */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="mt-6 text-center"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Image src="/icon-128.png" alt="SurfSense" width={24} height={24} className="rounded" />
|
||||||
|
<span className="text-sm font-medium">SurfSense</span>
|
||||||
|
</Link>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
Trash2,
|
Trash2,
|
||||||
Undo2,
|
Undo2,
|
||||||
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
@ -54,6 +55,7 @@ export const iconMap: Record<string, LucideIcon> = {
|
||||||
Trash2,
|
Trash2,
|
||||||
Podcast,
|
Podcast,
|
||||||
FileText,
|
FileText,
|
||||||
|
Users,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export function NavMain({ items }: { items: NavItem[] }) {
|
||||||
Podcasts: "podcasts",
|
Podcasts: "podcasts",
|
||||||
Logs: "logs",
|
Logs: "logs",
|
||||||
Platform: "platform",
|
Platform: "platform",
|
||||||
|
Team: "team",
|
||||||
};
|
};
|
||||||
|
|
||||||
const key = titleMap[title];
|
const key = titleMap[title];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from "./use-document-by-chunk";
|
export * from "./use-document-by-chunk";
|
||||||
export * from "./use-logs";
|
export * from "./use-logs";
|
||||||
|
export * from "./use-rbac";
|
||||||
export * from "./use-search-source-connectors";
|
export * from "./use-search-source-connectors";
|
||||||
export * from "./use-search-space";
|
export * from "./use-search-space";
|
||||||
export * from "./use-user";
|
export * from "./use-user";
|
||||||
|
|
|
||||||
773
surfsense_web/hooks/use-rbac.ts
Normal file
773
surfsense_web/hooks/use-rbac.ts
Normal file
|
|
@ -0,0 +1,773 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
// ============ Types ============
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
is_default: boolean;
|
||||||
|
is_system_role: boolean;
|
||||||
|
search_space_id: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Member {
|
||||||
|
id: number;
|
||||||
|
user_id: string;
|
||||||
|
search_space_id: number;
|
||||||
|
role_id: number | null;
|
||||||
|
is_owner: boolean;
|
||||||
|
joined_at: string;
|
||||||
|
created_at: string;
|
||||||
|
role: Role | null;
|
||||||
|
user_email: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invite {
|
||||||
|
id: number;
|
||||||
|
invite_code: string;
|
||||||
|
search_space_id: number;
|
||||||
|
role_id: number | null;
|
||||||
|
created_by_id: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
max_uses: number | null;
|
||||||
|
uses_count: number;
|
||||||
|
is_active: boolean;
|
||||||
|
name: string | null;
|
||||||
|
created_at: string;
|
||||||
|
role: Role | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteCreate {
|
||||||
|
name?: string;
|
||||||
|
role_id?: number;
|
||||||
|
expires_at?: string;
|
||||||
|
max_uses?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteUpdate {
|
||||||
|
name?: string;
|
||||||
|
role_id?: number;
|
||||||
|
expires_at?: string;
|
||||||
|
max_uses?: number;
|
||||||
|
is_active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleCreate {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
permissions: string[];
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleUpdate {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
permissions?: string[];
|
||||||
|
is_default?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionInfo {
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAccess {
|
||||||
|
search_space_id: number;
|
||||||
|
search_space_name: string;
|
||||||
|
is_owner: boolean;
|
||||||
|
role_name: string | null;
|
||||||
|
permissions: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InviteInfo {
|
||||||
|
search_space_name: string;
|
||||||
|
role_name: string | null;
|
||||||
|
is_valid: boolean;
|
||||||
|
message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Members Hook ============
|
||||||
|
|
||||||
|
export function useMembers(searchSpaceId: number) {
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchMembers = useCallback(async () => {
|
||||||
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
|
window.location.href = "/";
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch members");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setMembers(data);
|
||||||
|
setError(null);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to fetch members");
|
||||||
|
console.error("Error fetching members:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMembers();
|
||||||
|
}, [fetchMembers]);
|
||||||
|
|
||||||
|
const updateMemberRole = useCallback(
|
||||||
|
async (membershipId: number, roleId: number | null) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/${membershipId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ role_id: roleId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to update member role");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedMember = await response.json();
|
||||||
|
setMembers((prev) => prev.map((m) => (m.id === membershipId ? updatedMember : m)));
|
||||||
|
toast.success("Member role updated successfully");
|
||||||
|
return updatedMember;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to update member role");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeMember = useCallback(
|
||||||
|
async (membershipId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/${membershipId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "DELETE",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to remove member");
|
||||||
|
}
|
||||||
|
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== membershipId));
|
||||||
|
toast.success("Member removed successfully");
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to remove member");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const leaveSearchSpace = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/members/me`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "DELETE",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to leave search space");
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success("Successfully left the search space");
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to leave search space");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
members,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchMembers,
|
||||||
|
updateMemberRole,
|
||||||
|
removeMember,
|
||||||
|
leaveSearchSpace,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Roles Hook ============
|
||||||
|
|
||||||
|
export function useRoles(searchSpaceId: number) {
|
||||||
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchRoles = useCallback(async () => {
|
||||||
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
|
window.location.href = "/";
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch roles");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setRoles(data);
|
||||||
|
setError(null);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to fetch roles");
|
||||||
|
console.error("Error fetching roles:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRoles();
|
||||||
|
}, [fetchRoles]);
|
||||||
|
|
||||||
|
const createRole = useCallback(
|
||||||
|
async (roleData: RoleCreate) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(roleData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to create role");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRole = await response.json();
|
||||||
|
setRoles((prev) => [...prev, newRole]);
|
||||||
|
toast.success("Role created successfully");
|
||||||
|
return newRole;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to create role");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateRole = useCallback(
|
||||||
|
async (roleId: number, roleData: RoleUpdate) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(roleData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to update role");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRole = await response.json();
|
||||||
|
setRoles((prev) => prev.map((r) => (r.id === roleId ? updatedRole : r)));
|
||||||
|
toast.success("Role updated successfully");
|
||||||
|
return updatedRole;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to update role");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteRole = useCallback(
|
||||||
|
async (roleId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/roles/${roleId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "DELETE",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to delete role");
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoles((prev) => prev.filter((r) => r.id !== roleId));
|
||||||
|
toast.success("Role deleted successfully");
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to delete role");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchRoles,
|
||||||
|
createRole,
|
||||||
|
updateRole,
|
||||||
|
deleteRole,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Invites Hook ============
|
||||||
|
|
||||||
|
export function useInvites(searchSpaceId: number) {
|
||||||
|
const [invites, setInvites] = useState<Invite[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchInvites = useCallback(async () => {
|
||||||
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
|
window.location.href = "/";
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch invites");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setInvites(data);
|
||||||
|
setError(null);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to fetch invites");
|
||||||
|
console.error("Error fetching invites:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInvites();
|
||||||
|
}, [fetchInvites]);
|
||||||
|
|
||||||
|
const createInvite = useCallback(
|
||||||
|
async (inviteData: InviteCreate) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(inviteData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to create invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newInvite = await response.json();
|
||||||
|
setInvites((prev) => [...prev, newInvite]);
|
||||||
|
toast.success("Invite created successfully");
|
||||||
|
return newInvite;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to create invite");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateInvite = useCallback(
|
||||||
|
async (inviteId: number, inviteData: InviteUpdate) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites/${inviteId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(inviteData),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to update invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedInvite = await response.json();
|
||||||
|
setInvites((prev) => prev.map((i) => (i.id === inviteId ? updatedInvite : i)));
|
||||||
|
toast.success("Invite updated successfully");
|
||||||
|
return updatedInvite;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to update invite");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const revokeInvite = useCallback(
|
||||||
|
async (inviteId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/invites/${inviteId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "DELETE",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to revoke invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
setInvites((prev) => prev.filter((i) => i.id !== inviteId));
|
||||||
|
toast.success("Invite revoked successfully");
|
||||||
|
return true;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to revoke invite");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
invites,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchInvites,
|
||||||
|
createInvite,
|
||||||
|
updateInvite,
|
||||||
|
revokeInvite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Permissions Hook ============
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/permissions`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch permissions");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setPermissions(data.permissions);
|
||||||
|
setError(null);
|
||||||
|
return data.permissions;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to fetch permissions");
|
||||||
|
console.error("Error fetching permissions:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPermissions();
|
||||||
|
}, [fetchPermissions]);
|
||||||
|
|
||||||
|
// Group permissions by category
|
||||||
|
const groupedPermissions = useMemo(() => {
|
||||||
|
const groups: Record<string, PermissionInfo[]> = {};
|
||||||
|
for (const perm of permissions) {
|
||||||
|
if (!groups[perm.category]) {
|
||||||
|
groups[perm.category] = [];
|
||||||
|
}
|
||||||
|
groups[perm.category].push(perm);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [permissions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
groupedPermissions,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchPermissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ User Access Hook ============
|
||||||
|
|
||||||
|
export function useUserAccess(searchSpaceId: number) {
|
||||||
|
const [access, setAccess] = useState<UserAccess | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchAccess = useCallback(async () => {
|
||||||
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}/my-access`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
localStorage.removeItem("surfsense_bearer_token");
|
||||||
|
window.location.href = "/";
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch access info");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setAccess(data);
|
||||||
|
setError(null);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to fetch access info");
|
||||||
|
console.error("Error fetching access:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAccess();
|
||||||
|
}, [fetchAccess]);
|
||||||
|
|
||||||
|
// Helper function to check if user has a specific permission
|
||||||
|
const hasPermission = useCallback(
|
||||||
|
(permission: string) => {
|
||||||
|
if (!access) return false;
|
||||||
|
// Owner/full access check
|
||||||
|
if (access.permissions.includes("*")) return true;
|
||||||
|
return access.permissions.includes(permission);
|
||||||
|
},
|
||||||
|
[access]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper function to check if user has any of the given permissions
|
||||||
|
const hasAnyPermission = useCallback(
|
||||||
|
(permissions: string[]) => {
|
||||||
|
if (!access) return false;
|
||||||
|
if (access.permissions.includes("*")) return true;
|
||||||
|
return permissions.some((p) => access.permissions.includes(p));
|
||||||
|
},
|
||||||
|
[access]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
access,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchAccess,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Invite Info Hook (Public) ============
|
||||||
|
|
||||||
|
export function useInviteInfo(inviteCode: string | null) {
|
||||||
|
const [inviteInfo, setInviteInfo] = useState<InviteInfo | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchInviteInfo = useCallback(async () => {
|
||||||
|
if (!inviteCode) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/invites/${inviteCode}/info`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to fetch invite info");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setInviteInfo(data);
|
||||||
|
setError(null);
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "Failed to fetch invite info");
|
||||||
|
console.error("Error fetching invite info:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [inviteCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchInviteInfo();
|
||||||
|
}, [fetchInviteInfo]);
|
||||||
|
|
||||||
|
const acceptInvite = useCallback(async () => {
|
||||||
|
if (!inviteCode) {
|
||||||
|
toast.error("No invite code provided");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/invites/accept`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ invite_code: inviteCode }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || "Failed to accept invite");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
toast.success(data.message || "Successfully joined the search space");
|
||||||
|
return data;
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || "Failed to accept invite");
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, [inviteCode]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
inviteInfo,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchInviteInfo,
|
||||||
|
acceptInvite,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,8 @@ interface SearchSpace {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
citations_enabled: boolean;
|
citations_enabled: boolean;
|
||||||
qna_custom_instructions: string | null;
|
qna_custom_instructions: string | null;
|
||||||
|
member_count: number;
|
||||||
|
is_owner: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSearchSpaces() {
|
export function useSearchSpaces() {
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@
|
||||||
"surfsense_dashboard": "SurfSense Dashboard",
|
"surfsense_dashboard": "SurfSense Dashboard",
|
||||||
"welcome_message": "Welcome to your SurfSense dashboard.",
|
"welcome_message": "Welcome to your SurfSense dashboard.",
|
||||||
"your_search_spaces": "Your Search Spaces",
|
"your_search_spaces": "Your Search Spaces",
|
||||||
|
"shared": "Shared",
|
||||||
"create_search_space": "Create Search Space",
|
"create_search_space": "Create Search Space",
|
||||||
"add_new_search_space": "Add New Search Space",
|
"add_new_search_space": "Add New Search Space",
|
||||||
"loading": "Loading",
|
"loading": "Loading",
|
||||||
|
|
@ -149,7 +150,8 @@
|
||||||
"podcasts": "Podcasts",
|
"podcasts": "Podcasts",
|
||||||
"logs": "Logs",
|
"logs": "Logs",
|
||||||
"all_search_spaces": "All Search Spaces",
|
"all_search_spaces": "All Search Spaces",
|
||||||
"chat": "Chat"
|
"chat": "Chat",
|
||||||
|
"team": "Team"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"title": "SurfSense Pricing",
|
"title": "SurfSense Pricing",
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@
|
||||||
"surfsense_dashboard": "SurfSense 仪表盘",
|
"surfsense_dashboard": "SurfSense 仪表盘",
|
||||||
"welcome_message": "欢迎来到您的 SurfSense 仪表盘。",
|
"welcome_message": "欢迎来到您的 SurfSense 仪表盘。",
|
||||||
"your_search_spaces": "您的搜索空间",
|
"your_search_spaces": "您的搜索空间",
|
||||||
|
"shared": "共享",
|
||||||
"create_search_space": "创建搜索空间",
|
"create_search_space": "创建搜索空间",
|
||||||
"add_new_search_space": "添加新的搜索空间",
|
"add_new_search_space": "添加新的搜索空间",
|
||||||
"loading": "加载中",
|
"loading": "加载中",
|
||||||
|
|
@ -149,7 +150,8 @@
|
||||||
"podcasts": "播客",
|
"podcasts": "播客",
|
||||||
"logs": "日志",
|
"logs": "日志",
|
||||||
"all_search_spaces": "所有搜索空间",
|
"all_search_spaces": "所有搜索空间",
|
||||||
"chat": "聊天"
|
"chat": "聊天",
|
||||||
|
"team": "团队"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
"title": "SurfSense 定价",
|
"title": "SurfSense 定价",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue