mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-06 06:12:40 +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
|
||||
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.query_service import QueryService
|
||||
|
||||
|
|
@ -92,19 +92,18 @@ def extract_sources_from_documents(
|
|||
|
||||
|
||||
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]]]:
|
||||
"""
|
||||
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,
|
||||
providing security by checking ownership through SearchSpace association.
|
||||
This function ensures that only documents belonging to the search space are fetched.
|
||||
Similar to SearchMode.DOCUMENTS, it fetches full documents and concatenates their chunks.
|
||||
Also creates source objects for UI display, grouped by document type.
|
||||
|
||||
Args:
|
||||
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
|
||||
|
||||
Returns:
|
||||
|
|
@ -114,11 +113,12 @@ async def fetch_documents_by_ids(
|
|||
return [], []
|
||||
|
||||
try:
|
||||
# Query documents with ownership check
|
||||
# Query documents filtered by search space
|
||||
result = await db_session.execute(
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id.in_(document_ids), SearchSpace.user_id == user_id)
|
||||
select(Document).filter(
|
||||
Document.id.in_(document_ids),
|
||||
Document.search_space_id == search_space_id,
|
||||
)
|
||||
)
|
||||
documents = result.scalars().all()
|
||||
|
||||
|
|
@ -515,7 +515,6 @@ async def fetch_documents_by_ids(
|
|||
|
||||
async def fetch_relevant_documents(
|
||||
research_questions: list[str],
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
db_session: AsyncSession,
|
||||
connectors_to_search: list[str],
|
||||
|
|
@ -536,7 +535,6 @@ async def fetch_relevant_documents(
|
|||
|
||||
Args:
|
||||
research_questions: List of research questions to find documents for
|
||||
user_id: The user ID
|
||||
search_space_id: The search space ID
|
||||
db_session: The database session
|
||||
connectors_to_search: List of connectors to search
|
||||
|
|
@ -619,7 +617,6 @@ async def fetch_relevant_documents(
|
|||
youtube_chunks,
|
||||
) = await connector_service.search_youtube(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -646,7 +643,6 @@ async def fetch_relevant_documents(
|
|||
extension_chunks,
|
||||
) = await connector_service.search_extension(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -673,7 +669,6 @@ async def fetch_relevant_documents(
|
|||
crawled_urls_chunks,
|
||||
) = await connector_service.search_crawled_urls(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -697,7 +692,6 @@ async def fetch_relevant_documents(
|
|||
elif connector == "FILE":
|
||||
source_object, files_chunks = await connector_service.search_files(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -721,7 +715,6 @@ async def fetch_relevant_documents(
|
|||
elif connector == "SLACK_CONNECTOR":
|
||||
source_object, slack_chunks = await connector_service.search_slack(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -748,7 +741,6 @@ async def fetch_relevant_documents(
|
|||
notion_chunks,
|
||||
) = await connector_service.search_notion(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -775,7 +767,6 @@ async def fetch_relevant_documents(
|
|||
github_chunks,
|
||||
) = await connector_service.search_github(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -802,7 +793,6 @@ async def fetch_relevant_documents(
|
|||
linear_chunks,
|
||||
) = await connector_service.search_linear(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -829,7 +819,6 @@ async def fetch_relevant_documents(
|
|||
tavily_chunks,
|
||||
) = await connector_service.search_tavily(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
)
|
||||
|
|
@ -855,7 +844,6 @@ async def fetch_relevant_documents(
|
|||
searx_chunks,
|
||||
) = await connector_service.search_searxng(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
)
|
||||
|
|
@ -881,7 +869,6 @@ async def fetch_relevant_documents(
|
|||
linkup_chunks,
|
||||
) = await connector_service.search_linkup(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
mode=linkup_mode,
|
||||
)
|
||||
|
|
@ -907,7 +894,6 @@ async def fetch_relevant_documents(
|
|||
baidu_chunks,
|
||||
) = await connector_service.search_baidu(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
)
|
||||
|
|
@ -933,7 +919,6 @@ async def fetch_relevant_documents(
|
|||
discord_chunks,
|
||||
) = await connector_service.search_discord(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -955,7 +940,6 @@ async def fetch_relevant_documents(
|
|||
elif connector == "JIRA_CONNECTOR":
|
||||
source_object, jira_chunks = await connector_service.search_jira(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -981,7 +965,6 @@ async def fetch_relevant_documents(
|
|||
calendar_chunks,
|
||||
) = await connector_service.search_google_calendar(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -1007,7 +990,6 @@ async def fetch_relevant_documents(
|
|||
airtable_chunks,
|
||||
) = await connector_service.search_airtable(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -1033,7 +1015,6 @@ async def fetch_relevant_documents(
|
|||
gmail_chunks,
|
||||
) = await connector_service.search_google_gmail(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -1059,7 +1040,6 @@ async def fetch_relevant_documents(
|
|||
confluence_chunks,
|
||||
) = await connector_service.search_confluence(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -1085,7 +1065,6 @@ async def fetch_relevant_documents(
|
|||
clickup_chunks,
|
||||
) = await connector_service.search_clickup(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -1112,7 +1091,6 @@ async def fetch_relevant_documents(
|
|||
luma_chunks,
|
||||
) = await connector_service.search_luma(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -1139,7 +1117,6 @@ async def fetch_relevant_documents(
|
|||
elasticsearch_chunks,
|
||||
) = await connector_service.search_elasticsearch(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
|
|
@ -1315,7 +1292,6 @@ async def reformulate_user_query(
|
|||
reformulated_query = await QueryService.reformulate_query_with_chat_history(
|
||||
user_query=user_query,
|
||||
session=state.db_session,
|
||||
user_id=configuration.user_id,
|
||||
search_space_id=configuration.search_space_id,
|
||||
chat_history_str=chat_history_str,
|
||||
)
|
||||
|
|
@ -1389,7 +1365,7 @@ async def handle_qna_workflow(
|
|||
user_selected_documents,
|
||||
) = await fetch_documents_by_ids(
|
||||
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,
|
||||
)
|
||||
|
||||
|
|
@ -1404,7 +1380,7 @@ async def handle_qna_workflow(
|
|||
|
||||
# Create connector service using state db_session
|
||||
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()
|
||||
|
||||
|
|
@ -1413,7 +1389,6 @@ async def handle_qna_workflow(
|
|||
|
||||
relevant_documents = await fetch_relevant_documents(
|
||||
research_questions=research_questions,
|
||||
user_id=configuration.user_id,
|
||||
search_space_id=configuration.search_space_id,
|
||||
db_session=state.db_session,
|
||||
connectors_to_search=configuration.connectors_to_search,
|
||||
|
|
@ -1459,7 +1434,6 @@ async def handle_qna_workflow(
|
|||
"user_query": user_query, # Use the reformulated query
|
||||
"reformulated_query": reformulated_query,
|
||||
"relevant_documents": all_documents, # Use combined documents
|
||||
"user_id": configuration.user_id,
|
||||
"search_space_id": configuration.search_space_id,
|
||||
"language": configuration.language,
|
||||
}
|
||||
|
|
@ -1551,12 +1525,11 @@ async def generate_further_questions(
|
|||
Returns:
|
||||
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
|
||||
configuration = Configuration.from_runnable_config(config)
|
||||
chat_history = state.chat_history
|
||||
user_id = configuration.user_id
|
||||
search_space_id = configuration.search_space_id
|
||||
streaming_service = state.streaming_service
|
||||
|
||||
|
|
@ -1571,10 +1544,10 @@ async def generate_further_questions(
|
|||
}
|
||||
)
|
||||
|
||||
# Get user's fast LLM
|
||||
llm = await get_user_fast_llm(state.db_session, user_id, search_space_id)
|
||||
# Get search space's fast LLM
|
||||
llm = await get_fast_llm(state.db_session, search_space_id)
|
||||
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)
|
||||
writer({"yield_value": streaming_service.format_error(error_message)})
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ class Configuration:
|
|||
relevant_documents: list[
|
||||
Any
|
||||
] # Documents provided directly to the agent for answering
|
||||
user_id: str # User identifier
|
||||
search_space_id: int # Search space identifier
|
||||
language: str | None = None # Language for responses
|
||||
|
||||
|
|
|
|||
|
|
@ -142,13 +142,12 @@ async def answer_question(state: State, config: RunnableConfig) -> dict[str, Any
|
|||
Returns:
|
||||
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
|
||||
configuration = Configuration.from_runnable_config(config)
|
||||
documents = state.reranked_documents
|
||||
user_query = configuration.user_query
|
||||
user_id = configuration.user_id
|
||||
search_space_id = configuration.search_space_id
|
||||
language = configuration.language
|
||||
|
||||
|
|
@ -178,10 +177,10 @@ async def answer_question(state: State, config: RunnableConfig) -> dict[str, Any
|
|||
else ""
|
||||
)
|
||||
|
||||
# Get user's fast LLM
|
||||
llm = await get_user_fast_llm(state.db_session, user_id, search_space_id)
|
||||
# Get search space's fast LLM
|
||||
llm = await get_fast_llm(state.db_session, search_space_id)
|
||||
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)
|
||||
raise RuntimeError(error_message)
|
||||
|
||||
|
|
|
|||
|
|
@ -131,6 +131,169 @@ class LogStatus(str, Enum):
|
|||
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):
|
||||
pass
|
||||
|
||||
|
|
@ -230,6 +393,13 @@ class SearchSpace(BaseModel, TimestampMixin):
|
|||
qna_custom_instructions = Column(
|
||||
Text, nullable=True, default=""
|
||||
) # 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(
|
||||
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
|
|
@ -277,6 +447,26 @@ class SearchSpace(BaseModel, TimestampMixin):
|
|||
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):
|
||||
__tablename__ = "search_source_connectors"
|
||||
|
|
@ -368,13 +558,6 @@ class UserSearchSpacePreference(BaseModel, TimestampMixin):
|
|||
user = relationship("User", back_populates="search_space_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):
|
||||
__tablename__ = "logs"
|
||||
|
|
@ -393,6 +576,140 @@ class Log(BaseModel, TimestampMixin):
|
|||
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":
|
||||
|
||||
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
|
||||
|
|
@ -409,6 +726,18 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
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
|
||||
pages_limit = Column(Integer, nullable=False, default=500, server_default="500")
|
||||
pages_used = Column(Integer, nullable=False, default=0, server_default="0")
|
||||
|
|
@ -423,6 +752,18 @@ else:
|
|||
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
|
||||
pages_limit = Column(Integer, nullable=False, default=500, server_default="500")
|
||||
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),
|
||||
):
|
||||
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,
|
||||
query_text: str,
|
||||
top_k: int,
|
||||
user_id: str,
|
||||
search_space_id: int | None = None,
|
||||
search_space_id: int,
|
||||
) -> list:
|
||||
"""
|
||||
Perform vector similarity search on chunks.
|
||||
|
|
@ -21,8 +20,7 @@ class ChucksHybridSearchRetriever:
|
|||
Args:
|
||||
query_text: The search query text
|
||||
top_k: Number of results to return
|
||||
user_id: The ID of the user performing the search
|
||||
search_space_id: Optional search space ID to filter results
|
||||
search_space_id: The search space ID to search within
|
||||
|
||||
Returns:
|
||||
List of chunks sorted by vector similarity
|
||||
|
|
@ -31,25 +29,20 @@ class ChucksHybridSearchRetriever:
|
|||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.config import config
|
||||
from app.db import Chunk, Document, SearchSpace
|
||||
from app.db import Chunk, Document
|
||||
|
||||
# Get embedding for the query
|
||||
embedding_model = config.embedding_model_instance
|
||||
query_embedding = embedding_model.embed(query_text)
|
||||
|
||||
# Build the base query with user ownership check
|
||||
# Build the query filtered by search space
|
||||
query = (
|
||||
select(Chunk)
|
||||
.options(joinedload(Chunk.document).joinedload(Document.search_space))
|
||||
.join(Document, Chunk.document_id == Document.id)
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(SearchSpace.user_id == user_id)
|
||||
.where(Document.search_space_id == search_space_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
|
||||
query = query.order_by(Chunk.embedding.op("<=>")(query_embedding)).limit(top_k)
|
||||
|
||||
|
|
@ -63,8 +56,7 @@ class ChucksHybridSearchRetriever:
|
|||
self,
|
||||
query_text: str,
|
||||
top_k: int,
|
||||
user_id: str,
|
||||
search_space_id: int | None = None,
|
||||
search_space_id: int,
|
||||
) -> list:
|
||||
"""
|
||||
Perform full-text keyword search on chunks.
|
||||
|
|
@ -72,8 +64,7 @@ class ChucksHybridSearchRetriever:
|
|||
Args:
|
||||
query_text: The search query text
|
||||
top_k: Number of results to return
|
||||
user_id: The ID of the user performing the search
|
||||
search_space_id: Optional search space ID to filter results
|
||||
search_space_id: The search space ID to search within
|
||||
|
||||
Returns:
|
||||
List of chunks sorted by text relevance
|
||||
|
|
@ -81,28 +72,23 @@ class ChucksHybridSearchRetriever:
|
|||
from sqlalchemy import func, select
|
||||
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
|
||||
tsvector = func.to_tsvector("english", Chunk.content)
|
||||
tsquery = func.plainto_tsquery("english", query_text)
|
||||
|
||||
# Build the base query with user ownership check
|
||||
# Build the query filtered by search space
|
||||
query = (
|
||||
select(Chunk)
|
||||
.options(joinedload(Chunk.document).joinedload(Document.search_space))
|
||||
.join(Document, Chunk.document_id == Document.id)
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(SearchSpace.user_id == user_id)
|
||||
.where(Document.search_space_id == search_space_id)
|
||||
.where(
|
||||
tsvector.op("@@")(tsquery)
|
||||
) # 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
|
||||
query = query.order_by(func.ts_rank_cd(tsvector, tsquery).desc()).limit(top_k)
|
||||
|
||||
|
|
@ -116,8 +102,7 @@ class ChucksHybridSearchRetriever:
|
|||
self,
|
||||
query_text: str,
|
||||
top_k: int,
|
||||
user_id: str,
|
||||
search_space_id: int | None = None,
|
||||
search_space_id: int,
|
||||
document_type: str | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
|
|
@ -126,8 +111,7 @@ class ChucksHybridSearchRetriever:
|
|||
Args:
|
||||
query_text: The search query text
|
||||
top_k: Number of results to return
|
||||
user_id: The ID of the user performing the search
|
||||
search_space_id: Optional search space ID to filter results
|
||||
search_space_id: The search space ID to search within
|
||||
document_type: Optional document type to filter results (e.g., "FILE", "CRAWLED_URL")
|
||||
|
||||
Returns:
|
||||
|
|
@ -137,7 +121,7 @@ class ChucksHybridSearchRetriever:
|
|||
from sqlalchemy.orm import joinedload
|
||||
|
||||
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
|
||||
embedding_model = config.embedding_model_instance
|
||||
|
|
@ -151,12 +135,8 @@ class ChucksHybridSearchRetriever:
|
|||
tsvector = func.to_tsvector("english", Chunk.content)
|
||||
tsquery = func.plainto_tsquery("english", query_text)
|
||||
|
||||
# Base conditions for document filtering
|
||||
base_conditions = [SearchSpace.user_id == user_id]
|
||||
|
||||
# Add search space filter if provided
|
||||
if search_space_id is not None:
|
||||
base_conditions.append(Document.search_space_id == search_space_id)
|
||||
# Base conditions for chunk filtering - search space is required
|
||||
base_conditions = [Document.search_space_id == search_space_id]
|
||||
|
||||
# Add document type filter if provided
|
||||
if document_type is not None:
|
||||
|
|
@ -171,7 +151,7 @@ class ChucksHybridSearchRetriever:
|
|||
else:
|
||||
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 = (
|
||||
select(
|
||||
Chunk.id,
|
||||
|
|
@ -180,7 +160,6 @@ class ChucksHybridSearchRetriever:
|
|||
.label("rank"),
|
||||
)
|
||||
.join(Document, Chunk.document_id == Document.id)
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(*base_conditions)
|
||||
)
|
||||
|
||||
|
|
@ -190,7 +169,7 @@ class ChucksHybridSearchRetriever:
|
|||
.cte("semantic_search")
|
||||
)
|
||||
|
||||
# CTE for keyword search with user ownership check
|
||||
# CTE for keyword search filtered by search space
|
||||
keyword_search_cte = (
|
||||
select(
|
||||
Chunk.id,
|
||||
|
|
@ -199,7 +178,6 @@ class ChucksHybridSearchRetriever:
|
|||
.label("rank"),
|
||||
)
|
||||
.join(Document, Chunk.document_id == Document.id)
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(*base_conditions)
|
||||
.where(tsvector.op("@@")(tsquery))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,8 +12,7 @@ class DocumentHybridSearchRetriever:
|
|||
self,
|
||||
query_text: str,
|
||||
top_k: int,
|
||||
user_id: str,
|
||||
search_space_id: int | None = None,
|
||||
search_space_id: int,
|
||||
) -> list:
|
||||
"""
|
||||
Perform vector similarity search on documents.
|
||||
|
|
@ -21,8 +20,7 @@ class DocumentHybridSearchRetriever:
|
|||
Args:
|
||||
query_text: The search query text
|
||||
top_k: Number of results to return
|
||||
user_id: The ID of the user performing the search
|
||||
search_space_id: Optional search space ID to filter results
|
||||
search_space_id: The search space ID to search within
|
||||
|
||||
Returns:
|
||||
List of documents sorted by vector similarity
|
||||
|
|
@ -31,24 +29,19 @@ class DocumentHybridSearchRetriever:
|
|||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.config import config
|
||||
from app.db import Document, SearchSpace
|
||||
from app.db import Document
|
||||
|
||||
# Get embedding for the query
|
||||
embedding_model = config.embedding_model_instance
|
||||
query_embedding = embedding_model.embed(query_text)
|
||||
|
||||
# Build the base query with user ownership check
|
||||
# Build the query filtered by search space
|
||||
query = (
|
||||
select(Document)
|
||||
.options(joinedload(Document.search_space))
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(SearchSpace.user_id == user_id)
|
||||
.where(Document.search_space_id == search_space_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
|
||||
query = query.order_by(Document.embedding.op("<=>")(query_embedding)).limit(
|
||||
top_k
|
||||
|
|
@ -64,8 +57,7 @@ class DocumentHybridSearchRetriever:
|
|||
self,
|
||||
query_text: str,
|
||||
top_k: int,
|
||||
user_id: str,
|
||||
search_space_id: int | None = None,
|
||||
search_space_id: int,
|
||||
) -> list:
|
||||
"""
|
||||
Perform full-text keyword search on documents.
|
||||
|
|
@ -73,8 +65,7 @@ class DocumentHybridSearchRetriever:
|
|||
Args:
|
||||
query_text: The search query text
|
||||
top_k: Number of results to return
|
||||
user_id: The ID of the user performing the search
|
||||
search_space_id: Optional search space ID to filter results
|
||||
search_space_id: The search space ID to search within
|
||||
|
||||
Returns:
|
||||
List of documents sorted by text relevance
|
||||
|
|
@ -82,27 +73,22 @@ class DocumentHybridSearchRetriever:
|
|||
from sqlalchemy import func, select
|
||||
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
|
||||
tsvector = func.to_tsvector("english", Document.content)
|
||||
tsquery = func.plainto_tsquery("english", query_text)
|
||||
|
||||
# Build the base query with user ownership check
|
||||
# Build the query filtered by search space
|
||||
query = (
|
||||
select(Document)
|
||||
.options(joinedload(Document.search_space))
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(SearchSpace.user_id == user_id)
|
||||
.where(Document.search_space_id == search_space_id)
|
||||
.where(
|
||||
tsvector.op("@@")(tsquery)
|
||||
) # 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
|
||||
query = query.order_by(func.ts_rank_cd(tsvector, tsquery).desc()).limit(top_k)
|
||||
|
||||
|
|
@ -116,8 +102,7 @@ class DocumentHybridSearchRetriever:
|
|||
self,
|
||||
query_text: str,
|
||||
top_k: int,
|
||||
user_id: str,
|
||||
search_space_id: int | None = None,
|
||||
search_space_id: int,
|
||||
document_type: str | None = None,
|
||||
) -> list:
|
||||
"""
|
||||
|
|
@ -126,8 +111,7 @@ class DocumentHybridSearchRetriever:
|
|||
Args:
|
||||
query_text: The search query text
|
||||
top_k: Number of results to return
|
||||
user_id: The ID of the user performing the search
|
||||
search_space_id: Optional search space ID to filter results
|
||||
search_space_id: The search space ID to search within
|
||||
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 app.config import config
|
||||
from app.db import Document, DocumentType, SearchSpace
|
||||
from app.db import Document, DocumentType
|
||||
|
||||
# Get embedding for the query
|
||||
embedding_model = config.embedding_model_instance
|
||||
|
|
@ -149,12 +133,8 @@ class DocumentHybridSearchRetriever:
|
|||
tsvector = func.to_tsvector("english", Document.content)
|
||||
tsquery = func.plainto_tsquery("english", query_text)
|
||||
|
||||
# Base conditions for document filtering
|
||||
base_conditions = [SearchSpace.user_id == user_id]
|
||||
|
||||
# Add search space filter if provided
|
||||
if search_space_id is not None:
|
||||
base_conditions.append(Document.search_space_id == search_space_id)
|
||||
# Base conditions for document filtering - search space is required
|
||||
base_conditions = [Document.search_space_id == search_space_id]
|
||||
|
||||
# Add document type filter if provided
|
||||
if document_type is not None:
|
||||
|
|
@ -169,17 +149,13 @@ class DocumentHybridSearchRetriever:
|
|||
else:
|
||||
base_conditions.append(Document.document_type == document_type)
|
||||
|
||||
# CTE for semantic search with user ownership check
|
||||
semantic_search_cte = (
|
||||
select(
|
||||
Document.id,
|
||||
func.rank()
|
||||
.over(order_by=Document.embedding.op("<=>")(query_embedding))
|
||||
.label("rank"),
|
||||
)
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(*base_conditions)
|
||||
)
|
||||
# CTE for semantic search filtered by search space
|
||||
semantic_search_cte = select(
|
||||
Document.id,
|
||||
func.rank()
|
||||
.over(order_by=Document.embedding.op("<=>")(query_embedding))
|
||||
.label("rank"),
|
||||
).where(*base_conditions)
|
||||
|
||||
semantic_search_cte = (
|
||||
semantic_search_cte.order_by(Document.embedding.op("<=>")(query_embedding))
|
||||
|
|
@ -187,7 +163,7 @@ class DocumentHybridSearchRetriever:
|
|||
.cte("semantic_search")
|
||||
)
|
||||
|
||||
# CTE for keyword search with user ownership check
|
||||
# CTE for keyword search filtered by search space
|
||||
keyword_search_cte = (
|
||||
select(
|
||||
Document.id,
|
||||
|
|
@ -195,7 +171,6 @@ class DocumentHybridSearchRetriever:
|
|||
.over(order_by=func.ts_rank_cd(tsvector, tsquery).desc())
|
||||
.label("rank"),
|
||||
)
|
||||
.join(SearchSpace, Document.search_space_id == SearchSpace.id)
|
||||
.where(*base_conditions)
|
||||
.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 .luma_add_connector_route import router as luma_add_connector_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_spaces_routes import router as search_spaces_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
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(podcasts_router)
|
||||
router.include_router(chats_router)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.future import select
|
||||
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 (
|
||||
AISDKChatRequest,
|
||||
ChatCreate,
|
||||
|
|
@ -16,7 +23,7 @@ from app.schemas import (
|
|||
)
|
||||
from app.tasks.stream_connector_search_results import stream_connector_search_results
|
||||
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 (
|
||||
validate_connectors,
|
||||
validate_document_ids,
|
||||
|
|
@ -59,45 +66,38 @@ async def handle_chat_data(
|
|||
# print("RESQUEST DATA:", request_data)
|
||||
# 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:
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
language_result = await session.execute(
|
||||
select(UserSearchSpacePreference)
|
||||
.options(
|
||||
selectinload(UserSearchSpacePreference.search_space).selectinload(
|
||||
SearchSpace.llm_configs
|
||||
),
|
||||
# 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,
|
||||
)
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.CHATS_CREATE.value,
|
||||
"You don't have permission to use chat in this search space",
|
||||
)
|
||||
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
|
||||
llm_configs = [] # Initialize to empty list
|
||||
|
||||
if (
|
||||
user_preference
|
||||
and user_preference.search_space
|
||||
and user_preference.search_space.llm_configs
|
||||
):
|
||||
llm_configs = user_preference.search_space.llm_configs
|
||||
if search_space and search_space.llm_configs:
|
||||
llm_configs = search_space.llm_configs
|
||||
|
||||
# Manually fetch LLM configs since relationships no longer exist
|
||||
# Check fast_llm, long_context_llm, and strategic_llm IDs
|
||||
# Get language from configured LLM preferences
|
||||
# LLM preferences are now stored on the SearchSpace model
|
||||
from app.config import config as app_config
|
||||
|
||||
for llm_id in [
|
||||
user_preference.fast_llm_id,
|
||||
user_preference.long_context_llm_id,
|
||||
user_preference.strategic_llm_id,
|
||||
search_space.fast_llm_id,
|
||||
search_space.long_context_llm_id,
|
||||
search_space.strategic_llm_id,
|
||||
]:
|
||||
if llm_id is not None:
|
||||
# Check if it's a global config (negative ID)
|
||||
|
|
@ -161,8 +161,18 @@ async def create_chat(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Create a new chat.
|
||||
Requires CHATS_CREATE permission.
|
||||
"""
|
||||
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())
|
||||
session.add(db_chat)
|
||||
await session.commit()
|
||||
|
|
@ -197,6 +207,10 @@ async def read_chats(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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
|
||||
if skip < 0:
|
||||
raise HTTPException(
|
||||
|
|
@ -212,9 +226,17 @@ async def read_chats(
|
|||
status_code=400, detail="search_space_id must be a positive integer"
|
||||
)
|
||||
try:
|
||||
# Select specific fields excluding messages
|
||||
query = (
|
||||
select(
|
||||
if search_space_id is not None:
|
||||
# Check permission for specific search space
|
||||
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.type,
|
||||
Chat.title,
|
||||
|
|
@ -222,17 +244,28 @@ async def read_chats(
|
|||
Chat.search_space_id,
|
||||
Chat.created_at,
|
||||
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))
|
||||
return result.all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except OperationalError:
|
||||
raise HTTPException(
|
||||
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),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific chat by ID.
|
||||
Requires CHATS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Chat)
|
||||
.join(SearchSpace)
|
||||
.filter(Chat.id == chat_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Chat).filter(Chat.id == chat_id))
|
||||
chat = result.scalars().first()
|
||||
|
||||
if not chat:
|
||||
raise HTTPException(
|
||||
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
|
||||
except HTTPException:
|
||||
raise
|
||||
except OperationalError:
|
||||
raise HTTPException(
|
||||
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),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a chat.
|
||||
Requires CHATS_UPDATE permission for the search space.
|
||||
"""
|
||||
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)
|
||||
for key, value in update_data.items():
|
||||
if key == "messages":
|
||||
|
|
@ -318,8 +382,26 @@ async def delete_chat(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a chat.
|
||||
Requires CHATS_DELETE permission for the search space.
|
||||
"""
|
||||
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.commit()
|
||||
return {"message": "Chat deleted successfully"}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ from app.db import (
|
|||
Chunk,
|
||||
Document,
|
||||
DocumentType,
|
||||
Permission,
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
|
|
@ -22,7 +24,7 @@ from app.schemas import (
|
|||
PaginatedResponse,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
try:
|
||||
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
||||
|
|
@ -44,9 +46,19 @@ async def create_documents(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Create new documents.
|
||||
Requires DOCUMENTS_CREATE permission.
|
||||
"""
|
||||
try:
|
||||
# Check if the user owns the search space
|
||||
await check_ownership(session, SearchSpace, request.search_space_id, user)
|
||||
# Check permission
|
||||
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:
|
||||
from app.tasks.celery_tasks.document_tasks import (
|
||||
|
|
@ -93,8 +105,19 @@ async def create_documents_file_upload(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Upload files as documents.
|
||||
Requires DOCUMENTS_CREATE permission.
|
||||
"""
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="No files provided")
|
||||
|
|
@ -151,7 +174,8 @@ async def read_documents(
|
|||
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:
|
||||
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:
|
||||
- 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:
|
||||
from sqlalchemy import func
|
||||
|
||||
query = (
|
||||
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
|
||||
)
|
||||
|
||||
# Filter by search_space_id if provided
|
||||
# If specific search_space_id, check permission
|
||||
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
|
||||
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:
|
||||
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))
|
||||
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
|
|
@ -235,6 +268,8 @@ async def read_documents(
|
|||
)
|
||||
|
||||
return PaginatedResponse(items=api_documents, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
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.
|
||||
Requires DOCUMENTS_READ permission for the search space(s).
|
||||
|
||||
Args:
|
||||
title: Case-insensitive substring to match against document titles. Required.
|
||||
|
|
@ -275,37 +311,48 @@ async def search_documents(
|
|||
try:
|
||||
from sqlalchemy import func
|
||||
|
||||
query = (
|
||||
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
|
||||
)
|
||||
# If specific search_space_id, check permission
|
||||
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)
|
||||
query = query.filter(Document.title.ilike(f"%{title}%"))
|
||||
count_query = count_query.filter(Document.title.ilike(f"%{title}%"))
|
||||
|
||||
# Filter by document_types if provided
|
||||
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:
|
||||
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))
|
||||
|
||||
total_result = await session.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
|
|
@ -340,6 +387,8 @@ async def search_documents(
|
|||
)
|
||||
|
||||
return PaginatedResponse(items=api_documents, total=total)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
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),
|
||||
):
|
||||
"""
|
||||
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:
|
||||
search_space_id: If provided, restrict counts to a specific search space.
|
||||
|
|
@ -366,20 +416,36 @@ async def get_document_type_counts(
|
|||
try:
|
||||
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:
|
||||
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)
|
||||
type_counts = dict(result.all())
|
||||
|
||||
return type_counts
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
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.
|
||||
Requires DOCUMENTS_READ permission for the search space.
|
||||
The document's embedding and chunk embeddings are excluded from the response.
|
||||
"""
|
||||
try:
|
||||
|
|
@ -406,21 +473,29 @@ async def get_document_by_chunk_id(
|
|||
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(
|
||||
select(Document)
|
||||
.options(selectinload(Document.chunks))
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id == chunk.document_id, SearchSpace.user_id == user.id)
|
||||
.filter(Document.id == chunk.document_id)
|
||||
)
|
||||
document = document_result.scalars().first()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
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
|
||||
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),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific document by ID.
|
||||
Requires DOCUMENTS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
||||
select(Document).filter(Document.id == document_id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -462,6 +539,15 @@ async def read_document(
|
|||
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
|
||||
return DocumentRead(
|
||||
id=document.id,
|
||||
|
|
@ -472,6 +558,8 @@ async def read_document(
|
|||
created_at=document.created_at,
|
||||
search_space_id=document.search_space_id,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
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),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a document.
|
||||
Requires DOCUMENTS_UPDATE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Query the document directly instead of using read_document function
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
||||
select(Document).filter(Document.id == document_id)
|
||||
)
|
||||
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"
|
||||
)
|
||||
|
||||
# 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)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_document, key, value)
|
||||
|
|
@ -530,12 +628,13 @@ async def delete_document(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a document.
|
||||
Requires DOCUMENTS_DELETE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Query the document directly instead of using read_document function
|
||||
result = await session.execute(
|
||||
select(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(Document.id == document_id, SearchSpace.user_id == user.id)
|
||||
select(Document).filter(Document.id == document_id)
|
||||
)
|
||||
document = result.scalars().first()
|
||||
|
||||
|
|
@ -544,6 +643,15 @@ async def delete_document(
|
|||
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.commit()
|
||||
return {"message": "Document deleted successfully"}
|
||||
|
|
|
|||
|
|
@ -8,67 +8,22 @@ from sqlalchemy.future import select
|
|||
from app.config import config
|
||||
from app.db import (
|
||||
LLMConfig,
|
||||
Permission,
|
||||
SearchSpace,
|
||||
User,
|
||||
UserSearchSpacePreference,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas import LLMConfigCreate, LLMConfigRead, LLMConfigUpdate
|
||||
from app.services.llm_service import validate_llm_config
|
||||
from app.users import current_active_user
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
router = APIRouter()
|
||||
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):
|
||||
"""Schema for updating user LLM preferences"""
|
||||
"""Schema for updating search space LLM preferences"""
|
||||
|
||||
long_context_llm_id: int | None = None
|
||||
fast_llm_id: int | None = None
|
||||
|
|
@ -76,7 +31,7 @@ class LLMPreferencesUpdate(BaseModel):
|
|||
|
||||
|
||||
class LLMPreferencesRead(BaseModel):
|
||||
"""Schema for reading user LLM preferences"""
|
||||
"""Schema for reading search space LLM preferences"""
|
||||
|
||||
long_context_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),
|
||||
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:
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, llm_config.search_space_id, user)
|
||||
# Verify user has permission to create LLM configs
|
||||
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
|
||||
is_valid, error_message = await validate_llm_config(
|
||||
|
|
@ -187,10 +151,19 @@ async def read_llm_configs(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, search_space_id, user)
|
||||
# Verify user has permission to read LLM configs
|
||||
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(
|
||||
select(LLMConfig)
|
||||
|
|
@ -213,7 +186,10 @@ async def read_llm_config(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# Get the LLM config
|
||||
result = await session.execute(
|
||||
|
|
@ -224,8 +200,14 @@ async def read_llm_config(
|
|||
if not llm_config:
|
||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, llm_config.search_space_id, user)
|
||||
# Verify user has permission to read LLM configs
|
||||
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
|
||||
except HTTPException:
|
||||
|
|
@ -243,7 +225,10 @@ async def update_llm_config(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Update an existing LLM configuration"""
|
||||
"""
|
||||
Update an existing LLM configuration.
|
||||
Requires LLM_CONFIGS_UPDATE permission.
|
||||
"""
|
||||
try:
|
||||
# Get the LLM config
|
||||
result = await session.execute(
|
||||
|
|
@ -254,8 +239,14 @@ async def update_llm_config(
|
|||
if not db_llm_config:
|
||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, db_llm_config.search_space_id, user)
|
||||
# Verify user has permission to update LLM configs
|
||||
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)
|
||||
|
||||
|
|
@ -311,7 +302,10 @@ async def delete_llm_config(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Delete an LLM configuration"""
|
||||
"""
|
||||
Delete an LLM configuration.
|
||||
Requires LLM_CONFIGS_DELETE permission.
|
||||
"""
|
||||
try:
|
||||
# Get the LLM config
|
||||
result = await session.execute(
|
||||
|
|
@ -322,8 +316,14 @@ async def delete_llm_config(
|
|||
if not db_llm_config:
|
||||
raise HTTPException(status_code=404, detail="LLM configuration not found")
|
||||
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, db_llm_config.search_space_id, user)
|
||||
# Verify user has permission to delete LLM configs
|
||||
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.commit()
|
||||
|
|
@ -337,28 +337,42 @@ async def delete_llm_config(
|
|||
) from e
|
||||
|
||||
|
||||
# User LLM Preferences endpoints
|
||||
# Search Space LLM Preferences endpoints
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search-spaces/{search_space_id}/llm-preferences",
|
||||
response_model=LLMPreferencesRead,
|
||||
)
|
||||
async def get_user_llm_preferences(
|
||||
async def get_llm_preferences(
|
||||
search_space_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, search_space_id, user)
|
||||
|
||||
# Get or create user preference for this search space
|
||||
preference = await get_or_create_user_preference(
|
||||
session, user.id, search_space_id
|
||||
# Verify user has permission to read LLM configs
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
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)
|
||||
async def get_config_for_id(config_id):
|
||||
if config_id is None:
|
||||
|
|
@ -391,14 +405,14 @@ async def get_user_llm_preferences(
|
|||
return result.scalars().first()
|
||||
|
||||
# Get the configs (from DB for custom, or constructed for global)
|
||||
long_context_llm = await get_config_for_id(preference.long_context_llm_id)
|
||||
fast_llm = await get_config_for_id(preference.fast_llm_id)
|
||||
strategic_llm = await get_config_for_id(preference.strategic_llm_id)
|
||||
long_context_llm = await get_config_for_id(search_space.long_context_llm_id)
|
||||
fast_llm = await get_config_for_id(search_space.fast_llm_id)
|
||||
strategic_llm = await get_config_for_id(search_space.strategic_llm_id)
|
||||
|
||||
return {
|
||||
"long_context_llm_id": preference.long_context_llm_id,
|
||||
"fast_llm_id": preference.fast_llm_id,
|
||||
"strategic_llm_id": preference.strategic_llm_id,
|
||||
"long_context_llm_id": search_space.long_context_llm_id,
|
||||
"fast_llm_id": search_space.fast_llm_id,
|
||||
"strategic_llm_id": search_space.strategic_llm_id,
|
||||
"long_context_llm": long_context_llm,
|
||||
"fast_llm": fast_llm,
|
||||
"strategic_llm": strategic_llm,
|
||||
|
|
@ -415,22 +429,37 @@ async def get_user_llm_preferences(
|
|||
"/search-spaces/{search_space_id}/llm-preferences",
|
||||
response_model=LLMPreferencesRead,
|
||||
)
|
||||
async def update_user_llm_preferences(
|
||||
async def update_llm_preferences(
|
||||
search_space_id: int,
|
||||
preferences: LLMPreferencesUpdate,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# Verify user has access to the search space
|
||||
await check_search_space_access(session, search_space_id, user)
|
||||
|
||||
# Get or create user preference for this search space
|
||||
preference = await get_or_create_user_preference(
|
||||
session, user.id, search_space_id
|
||||
# Verify user has permission to update settings (not just LLM configs)
|
||||
# This ensures only users with settings access can change shared LLM preferences
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
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
|
||||
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}. "
|
||||
"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():
|
||||
setattr(preference, key, value)
|
||||
setattr(search_space, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(preference)
|
||||
await session.refresh(search_space)
|
||||
|
||||
# Helper function to get config (global or custom)
|
||||
async def get_config_for_id(config_id):
|
||||
|
|
@ -530,15 +554,15 @@ async def update_user_llm_preferences(
|
|||
return result.scalars().first()
|
||||
|
||||
# Get the configs (from DB for custom, or constructed for global)
|
||||
long_context_llm = await get_config_for_id(preference.long_context_llm_id)
|
||||
fast_llm = await get_config_for_id(preference.fast_llm_id)
|
||||
strategic_llm = await get_config_for_id(preference.strategic_llm_id)
|
||||
long_context_llm = await get_config_for_id(search_space.long_context_llm_id)
|
||||
fast_llm = await get_config_for_id(search_space.fast_llm_id)
|
||||
strategic_llm = await get_config_for_id(search_space.strategic_llm_id)
|
||||
|
||||
# Return updated preferences
|
||||
return {
|
||||
"long_context_llm_id": preference.long_context_llm_id,
|
||||
"fast_llm_id": preference.fast_llm_id,
|
||||
"strategic_llm_id": preference.strategic_llm_id,
|
||||
"long_context_llm_id": search_space.long_context_llm_id,
|
||||
"fast_llm_id": search_space.fast_llm_id,
|
||||
"strategic_llm_id": search_space.strategic_llm_id,
|
||||
"long_context_llm": long_context_llm,
|
||||
"fast_llm": fast_llm,
|
||||
"strategic_llm": strategic_llm,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,19 @@ from sqlalchemy import and_, desc
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
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.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -19,10 +28,19 @@ async def create_log(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# Check if the user owns the search space
|
||||
await check_ownership(session, SearchSpace, log.search_space_id, user)
|
||||
# Check if the user has access to the search space
|
||||
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())
|
||||
session.add(db_log)
|
||||
|
|
@ -51,22 +69,38 @@ async def read_logs(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# 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
|
||||
filters = []
|
||||
|
||||
if search_space_id is not None:
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
filters.append(Log.search_space_id == search_space_id)
|
||||
# Check permission for specific search space
|
||||
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:
|
||||
filters.append(Log.level == level)
|
||||
|
|
@ -104,19 +138,26 @@ async def read_log(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# Get log and verify user owns the search space
|
||||
result = await session.execute(
|
||||
select(Log)
|
||||
.join(SearchSpace)
|
||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||
log = result.scalars().first()
|
||||
|
||||
if not log:
|
||||
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
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -133,19 +174,26 @@ async def update_log(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# Get log and verify user owns the search space
|
||||
result = await session.execute(
|
||||
select(Log)
|
||||
.join(SearchSpace)
|
||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||
db_log = result.scalars().first()
|
||||
|
||||
if not db_log:
|
||||
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_data = log_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
|
|
@ -169,19 +217,26 @@ async def delete_log(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Delete a log entry."""
|
||||
"""
|
||||
Delete a log entry.
|
||||
Requires LOGS_DELETE permission for the search space.
|
||||
"""
|
||||
try:
|
||||
# Get log and verify user owns the search space
|
||||
result = await session.execute(
|
||||
select(Log)
|
||||
.join(SearchSpace)
|
||||
.filter(Log.id == log_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Log).filter(Log.id == log_id))
|
||||
db_log = result.scalars().first()
|
||||
|
||||
if not db_log:
|
||||
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.commit()
|
||||
return {"message": "Log deleted successfully"}
|
||||
|
|
@ -201,10 +256,19 @@ async def get_logs_summary(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
# Check ownership
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
# Check permission
|
||||
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
|
||||
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.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 (
|
||||
PodcastCreate,
|
||||
PodcastGenerateRequest,
|
||||
|
|
@ -16,7 +24,7 @@ from app.schemas import (
|
|||
)
|
||||
from app.tasks.podcast_tasks import generate_chat_podcast
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -27,8 +35,18 @@ async def create_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Create a new podcast.
|
||||
Requires PODCASTS_CREATE permission.
|
||||
"""
|
||||
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())
|
||||
session.add(db_podcast)
|
||||
await session.commit()
|
||||
|
|
@ -58,20 +76,45 @@ async def create_podcast(
|
|||
async def read_podcasts(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
search_space_id: int | None = None,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Invalid pagination parameters")
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
if search_space_id is not None:
|
||||
# Check permission for specific search space
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
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()
|
||||
except HTTPException:
|
||||
raise
|
||||
except SQLAlchemyError:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Database error occurred while fetching podcasts"
|
||||
|
|
@ -84,18 +127,29 @@ async def read_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific podcast by ID.
|
||||
Requires PODCASTS_READ permission for the search space.
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||
podcast = result.scalars().first()
|
||||
|
||||
if not podcast:
|
||||
raise HTTPException(
|
||||
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
|
||||
except HTTPException as he:
|
||||
raise he
|
||||
|
|
@ -112,8 +166,26 @@ async def update_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a podcast.
|
||||
Requires PODCASTS_UPDATE permission for the search space.
|
||||
"""
|
||||
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)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_podcast, key, value)
|
||||
|
|
@ -140,8 +212,26 @@ async def delete_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a podcast.
|
||||
Requires PODCASTS_DELETE permission for the search space.
|
||||
"""
|
||||
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.commit()
|
||||
return {"message": "Podcast deleted successfully"}
|
||||
|
|
@ -181,9 +271,19 @@ async def generate_podcast(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Generate a podcast from a chat or document.
|
||||
Requires PODCASTS_CREATE permission.
|
||||
"""
|
||||
try:
|
||||
# Check if the user owns the search space
|
||||
await check_ownership(session, SearchSpace, request.search_space_id, user)
|
||||
# Check if the user has permission to create podcasts
|
||||
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":
|
||||
# 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),
|
||||
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:
|
||||
# Get the podcast and check if user has access
|
||||
result = await session.execute(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.filter(Podcast.id == podcast_id, SearchSpace.user_id == user.id)
|
||||
)
|
||||
result = await session.execute(select(Podcast).filter(Podcast.id == podcast_id))
|
||||
podcast = result.scalars().first()
|
||||
|
||||
if not podcast:
|
||||
raise HTTPException(
|
||||
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
|
||||
file_path = podcast.file_location
|
||||
|
||||
|
|
@ -303,12 +410,30 @@ async def get_podcast_by_chat_id(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a podcast by its associated chat ID.
|
||||
Requires PODCASTS_READ permission for the search space.
|
||||
"""
|
||||
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(
|
||||
select(Podcast)
|
||||
.join(SearchSpace)
|
||||
.filter(Podcast.chat_id == chat_id, SearchSpace.user_id == user.id)
|
||||
select(Podcast).filter(Podcast.chat_id == chat_id)
|
||||
)
|
||||
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.db import (
|
||||
Permission,
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
SearchSpace,
|
||||
User,
|
||||
async_session_maker,
|
||||
get_async_session,
|
||||
|
|
@ -52,12 +52,12 @@ from app.tasks.connector_indexers import (
|
|||
index_slack_messages,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
from app.utils.check_ownership import check_ownership
|
||||
from app.utils.periodic_scheduler import (
|
||||
create_periodic_schedule,
|
||||
delete_periodic_schedule,
|
||||
update_periodic_schedule,
|
||||
)
|
||||
from app.utils.rbac import check_permission
|
||||
|
||||
# Set up logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -108,19 +108,25 @@ async def create_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.
|
||||
"""
|
||||
try:
|
||||
# Check if the search space belongs to the user
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
# Check if user has permission to create connectors
|
||||
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(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.search_space_id == search_space_id,
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type == connector.connector_type,
|
||||
)
|
||||
)
|
||||
|
|
@ -128,7 +134,7 @@ async def create_search_source_connector(
|
|||
if existing_connector:
|
||||
raise HTTPException(
|
||||
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
|
||||
|
|
@ -198,22 +204,34 @@ async def read_search_source_connectors(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
query = select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.user_id == user.id
|
||||
if search_space_id is None:
|
||||
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
|
||||
if search_space_id is not None:
|
||||
# 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
|
||||
)
|
||||
query = select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.search_space_id == search_space_id
|
||||
)
|
||||
|
||||
result = await session.execute(query.offset(skip).limit(limit))
|
||||
return result.scalars().all()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
|
|
@ -229,9 +247,32 @@ async def read_search_source_connector(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
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:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -251,10 +292,25 @@ async def update_search_source_connector(
|
|||
):
|
||||
"""
|
||||
Update a search source connector.
|
||||
Requires CONNECTORS_UPDATE permission.
|
||||
Handles partial updates, including merging changes into the 'config' field.
|
||||
"""
|
||||
db_connector = await check_ownership(
|
||||
session, SearchSourceConnector, connector_id, user
|
||||
# Get the connector first
|
||||
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
|
||||
|
|
@ -349,20 +405,19 @@ async def update_search_source_connector(
|
|||
for key, value in update_data.items():
|
||||
# Prevent changing connector_type if it causes a duplicate (check moved here)
|
||||
if key == "connector_type" and value != db_connector.connector_type:
|
||||
result = await session.execute(
|
||||
check_result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.search_space_id
|
||||
== db_connector.search_space_id,
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type == value,
|
||||
SearchSourceConnector.id != connector_id,
|
||||
)
|
||||
)
|
||||
existing_connector = result.scalars().first()
|
||||
existing_connector = check_result.scalars().first()
|
||||
if existing_connector:
|
||||
raise HTTPException(
|
||||
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)
|
||||
|
|
@ -425,10 +480,29 @@ async def delete_search_source_connector(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Delete a search source connector."""
|
||||
"""
|
||||
Delete a search source connector.
|
||||
Requires CONNECTORS_DELETE permission.
|
||||
"""
|
||||
try:
|
||||
db_connector = await check_ownership(
|
||||
session, SearchSourceConnector, connector_id, user
|
||||
# Get the connector first
|
||||
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
|
||||
|
|
@ -473,6 +547,7 @@ async def index_connector_content(
|
|||
):
|
||||
"""
|
||||
Index content from a connector to a search space.
|
||||
Requires CONNECTORS_UPDATE permission (to trigger indexing).
|
||||
|
||||
Currently supports:
|
||||
- SLACK_CONNECTOR: Indexes messages from all accessible Slack channels
|
||||
|
|
@ -488,20 +563,29 @@ async def index_connector_content(
|
|||
Args:
|
||||
connector_id: ID of the connector to use
|
||||
search_space_id: ID of the search space to store indexed content
|
||||
background_tasks: FastAPI background tasks
|
||||
|
||||
Returns:
|
||||
Dictionary with indexing status
|
||||
"""
|
||||
try:
|
||||
# Check if the connector belongs to the user
|
||||
connector = 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()
|
||||
|
||||
# Check if the search space belongs to the user
|
||||
_search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
if not connector:
|
||||
raise HTTPException(status_code=404, detail="Connector not found")
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -1,18 +1,77 @@
|
|||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.db import SearchSpace, User, get_async_session
|
||||
from app.schemas import SearchSpaceCreate, SearchSpaceRead, SearchSpaceUpdate
|
||||
from app.db import (
|
||||
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.utils.check_ownership import check_ownership
|
||||
from app.utils.rbac import check_permission, check_search_space_access
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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)
|
||||
async def create_search_space(
|
||||
search_space: SearchSpaceCreate,
|
||||
|
|
@ -27,6 +86,11 @@ async def create_search_space(
|
|||
|
||||
db_search_space = SearchSpace(**search_space_data, user_id=user.id)
|
||||
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.refresh(db_search_space)
|
||||
return db_search_space
|
||||
|
|
@ -34,26 +98,86 @@ async def create_search_space(
|
|||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Failed to create search space: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to create search space: {e!s}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/searchspaces", response_model=list[SearchSpaceRead])
|
||||
@router.get("/searchspaces", response_model=list[SearchSpaceWithStats])
|
||||
async def read_search_spaces(
|
||||
skip: int = 0,
|
||||
limit: int = 200,
|
||||
owned_only: bool = False,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
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:
|
||||
result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return result.scalars().all()
|
||||
if owned_only:
|
||||
# Return only search spaces where user is the original creator (user_id)
|
||||
result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.offset(skip)
|
||||
.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:
|
||||
raise HTTPException(
|
||||
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),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Get a specific search space by ID.
|
||||
Requires SETTINGS_VIEW permission or membership.
|
||||
"""
|
||||
try:
|
||||
search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
# Check if user has access (is a member)
|
||||
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
|
||||
|
||||
except HTTPException:
|
||||
|
|
@ -118,10 +254,28 @@ async def update_search_space(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Update a search space.
|
||||
Requires SETTINGS_UPDATE permission.
|
||||
"""
|
||||
try:
|
||||
db_search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
# Check permission
|
||||
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)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_search_space, key, value)
|
||||
|
|
@ -143,10 +297,28 @@ async def delete_search_space(
|
|||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Delete a search space.
|
||||
Requires SETTINGS_DELETE permission (only owners have this by default).
|
||||
"""
|
||||
try:
|
||||
db_search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
# Check permission - only those with SETTINGS_DELETE can delete
|
||||
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.commit()
|
||||
return {"message": "Search space deleted successfully"}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,23 @@ from .podcasts import (
|
|||
PodcastRead,
|
||||
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 (
|
||||
SearchSourceConnectorBase,
|
||||
SearchSourceConnectorCreate,
|
||||
|
|
@ -38,6 +55,7 @@ from .search_space import (
|
|||
SearchSpaceCreate,
|
||||
SearchSpaceRead,
|
||||
SearchSpaceUpdate,
|
||||
SearchSpaceWithStats,
|
||||
)
|
||||
from .users import UserCreate, UserRead, UserUpdate
|
||||
|
||||
|
|
@ -60,6 +78,13 @@ __all__ = [
|
|||
"ExtensionDocumentContent",
|
||||
"ExtensionDocumentMetadata",
|
||||
"IDModel",
|
||||
# RBAC schemas
|
||||
"InviteAcceptRequest",
|
||||
"InviteAcceptResponse",
|
||||
"InviteCreate",
|
||||
"InviteInfoResponse",
|
||||
"InviteRead",
|
||||
"InviteUpdate",
|
||||
"LLMConfigBase",
|
||||
"LLMConfigCreate",
|
||||
"LLMConfigRead",
|
||||
|
|
@ -69,12 +94,20 @@ __all__ = [
|
|||
"LogFilter",
|
||||
"LogRead",
|
||||
"LogUpdate",
|
||||
"MembershipRead",
|
||||
"MembershipReadWithUser",
|
||||
"MembershipUpdate",
|
||||
"PaginatedResponse",
|
||||
"PermissionInfo",
|
||||
"PermissionsListResponse",
|
||||
"PodcastBase",
|
||||
"PodcastCreate",
|
||||
"PodcastGenerateRequest",
|
||||
"PodcastRead",
|
||||
"PodcastUpdate",
|
||||
"RoleCreate",
|
||||
"RoleRead",
|
||||
"RoleUpdate",
|
||||
"SearchSourceConnectorBase",
|
||||
"SearchSourceConnectorCreate",
|
||||
"SearchSourceConnectorRead",
|
||||
|
|
@ -83,8 +116,10 @@ __all__ = [
|
|||
"SearchSpaceCreate",
|
||||
"SearchSpaceRead",
|
||||
"SearchSpaceUpdate",
|
||||
"SearchSpaceWithStats",
|
||||
"TimestampModel",
|
||||
"UserCreate",
|
||||
"UserRead",
|
||||
"UserSearchSpaceAccess",
|
||||
"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
|
||||
|
||||
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,
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
SearchSpace,
|
||||
)
|
||||
from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
|
||||
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
|
||||
|
||||
|
||||
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.chunk_retriever = ChucksHybridSearchRetriever(session)
|
||||
self.document_retriever = DocumentHybridSearchRetriever(session)
|
||||
self.user_id = user_id
|
||||
self.search_space_id = search_space_id
|
||||
self.source_id_counter = (
|
||||
100000 # High starting value to avoid collisions with existing IDs
|
||||
)
|
||||
|
|
@ -36,23 +35,22 @@ class ConnectorService:
|
|||
|
||||
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.
|
||||
"""
|
||||
if self.user_id:
|
||||
if self.search_space_id:
|
||||
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(
|
||||
select(func.count(Chunk.id))
|
||||
.join(Document)
|
||||
.join(SearchSpace)
|
||||
.filter(SearchSpace.user_id == self.user_id)
|
||||
.filter(Document.search_space_id == self.search_space_id)
|
||||
)
|
||||
chunk_count = result.scalar() or 0
|
||||
self.source_id_counter = chunk_count + 1
|
||||
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:
|
||||
print(f"Error initializing source_id_counter: {e!s}")
|
||||
|
|
@ -62,7 +60,6 @@ class ConnectorService:
|
|||
async def search_crawled_urls(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -72,7 +69,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -84,7 +80,6 @@ class ConnectorService:
|
|||
crawled_urls_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="CRAWLED_URL",
|
||||
)
|
||||
|
|
@ -92,7 +87,6 @@ class ConnectorService:
|
|||
crawled_urls_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="CRAWLED_URL",
|
||||
)
|
||||
|
|
@ -171,7 +165,6 @@ class ConnectorService:
|
|||
async def search_files(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -186,7 +179,6 @@ class ConnectorService:
|
|||
files_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="FILE",
|
||||
)
|
||||
|
|
@ -194,7 +186,6 @@ class ConnectorService:
|
|||
files_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="FILE",
|
||||
)
|
||||
|
|
@ -274,43 +265,35 @@ class ConnectorService:
|
|||
|
||||
async def get_connector_by_type(
|
||||
self,
|
||||
user_id: str,
|
||||
connector_type: SearchSourceConnectorType,
|
||||
search_space_id: int | None = None,
|
||||
search_space_id: int,
|
||||
) -> 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:
|
||||
user_id: The user's ID
|
||||
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:
|
||||
Optional[SearchSourceConnector]: The connector if found, None otherwise
|
||||
"""
|
||||
query = select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.user_id == user_id,
|
||||
SearchSourceConnector.search_space_id == search_space_id,
|
||||
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)
|
||||
return result.scalars().first()
|
||||
|
||||
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:
|
||||
"""
|
||||
Search using Tavily API and return both the source information and documents
|
||||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID
|
||||
top_k: Maximum number of results to return
|
||||
|
||||
|
|
@ -319,7 +302,7 @@ class ConnectorService:
|
|||
"""
|
||||
# Get Tavily connector configuration
|
||||
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:
|
||||
|
|
@ -412,7 +395,6 @@ class ConnectorService:
|
|||
async def search_searxng(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
) -> tuple:
|
||||
|
|
@ -420,7 +402,7 @@ class ConnectorService:
|
|||
Search using a configured SearxNG instance and return both sources and documents.
|
||||
"""
|
||||
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:
|
||||
|
|
@ -598,7 +580,6 @@ class ConnectorService:
|
|||
async def search_baidu(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
) -> tuple:
|
||||
|
|
@ -610,7 +591,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: User's search query
|
||||
user_id: User ID
|
||||
search_space_id: Search space ID
|
||||
top_k: Maximum number of results to return
|
||||
|
||||
|
|
@ -619,7 +599,7 @@ class ConnectorService:
|
|||
"""
|
||||
# Get Baidu connector configuration
|
||||
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:
|
||||
|
|
@ -824,7 +804,6 @@ class ConnectorService:
|
|||
async def search_slack(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -839,7 +818,6 @@ class ConnectorService:
|
|||
slack_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="SLACK_CONNECTOR",
|
||||
)
|
||||
|
|
@ -847,7 +825,6 @@ class ConnectorService:
|
|||
slack_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="SLACK_CONNECTOR",
|
||||
)
|
||||
|
|
@ -912,7 +889,6 @@ class ConnectorService:
|
|||
async def search_notion(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -922,7 +898,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
|
||||
|
|
@ -933,7 +908,6 @@ class ConnectorService:
|
|||
notion_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="NOTION_CONNECTOR",
|
||||
)
|
||||
|
|
@ -941,7 +915,6 @@ class ConnectorService:
|
|||
notion_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="NOTION_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1009,7 +982,6 @@ class ConnectorService:
|
|||
async def search_extension(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1019,7 +991,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
|
||||
|
|
@ -1030,7 +1001,6 @@ class ConnectorService:
|
|||
extension_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="EXTENSION",
|
||||
)
|
||||
|
|
@ -1038,7 +1008,6 @@ class ConnectorService:
|
|||
extension_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="EXTENSION",
|
||||
)
|
||||
|
|
@ -1130,7 +1099,6 @@ class ConnectorService:
|
|||
async def search_youtube(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1140,7 +1108,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
|
||||
|
|
@ -1151,7 +1118,6 @@ class ConnectorService:
|
|||
youtube_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="YOUTUBE_VIDEO",
|
||||
)
|
||||
|
|
@ -1159,7 +1125,6 @@ class ConnectorService:
|
|||
youtube_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="YOUTUBE_VIDEO",
|
||||
)
|
||||
|
|
@ -1227,7 +1192,6 @@ class ConnectorService:
|
|||
async def search_github(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: int,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1242,7 +1206,6 @@ class ConnectorService:
|
|||
github_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="GITHUB_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1250,7 +1213,6 @@ class ConnectorService:
|
|||
github_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="GITHUB_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1302,7 +1264,6 @@ class ConnectorService:
|
|||
async def search_linear(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1312,7 +1273,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
|
||||
|
|
@ -1323,7 +1283,6 @@ class ConnectorService:
|
|||
linear_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="LINEAR_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1331,7 +1290,6 @@ class ConnectorService:
|
|||
linear_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="LINEAR_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1411,7 +1369,6 @@ class ConnectorService:
|
|||
async def search_jira(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1421,7 +1378,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -1433,7 +1389,6 @@ class ConnectorService:
|
|||
jira_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="JIRA_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1441,7 +1396,6 @@ class ConnectorService:
|
|||
jira_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="JIRA_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1532,7 +1486,6 @@ class ConnectorService:
|
|||
async def search_google_calendar(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1542,7 +1495,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -1554,7 +1506,6 @@ class ConnectorService:
|
|||
calendar_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="GOOGLE_CALENDAR_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1562,7 +1513,6 @@ class ConnectorService:
|
|||
calendar_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="GOOGLE_CALENDAR_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1665,7 +1615,6 @@ class ConnectorService:
|
|||
async def search_airtable(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1675,7 +1624,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -1687,7 +1635,6 @@ class ConnectorService:
|
|||
airtable_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="AIRTABLE_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1695,7 +1642,6 @@ class ConnectorService:
|
|||
airtable_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="AIRTABLE_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1753,7 +1699,6 @@ class ConnectorService:
|
|||
async def search_google_gmail(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1763,7 +1708,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -1775,7 +1719,6 @@ class ConnectorService:
|
|||
gmail_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="GOOGLE_GMAIL_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1783,7 +1726,6 @@ class ConnectorService:
|
|||
gmail_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="GOOGLE_GMAIL_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1877,7 +1819,6 @@ class ConnectorService:
|
|||
async def search_confluence(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1887,7 +1828,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -1899,7 +1839,6 @@ class ConnectorService:
|
|||
confluence_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="CONFLUENCE_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1907,7 +1846,6 @@ class ConnectorService:
|
|||
confluence_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="CONFLUENCE_CONNECTOR",
|
||||
)
|
||||
|
|
@ -1972,7 +1910,6 @@ class ConnectorService:
|
|||
async def search_clickup(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -1982,7 +1919,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -1994,7 +1930,6 @@ class ConnectorService:
|
|||
clickup_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="CLICKUP_CONNECTOR",
|
||||
)
|
||||
|
|
@ -2002,7 +1937,6 @@ class ConnectorService:
|
|||
clickup_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="CLICKUP_CONNECTOR",
|
||||
)
|
||||
|
|
@ -2088,7 +2022,6 @@ class ConnectorService:
|
|||
async def search_linkup(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
mode: str = "standard",
|
||||
) -> tuple:
|
||||
|
|
@ -2097,7 +2030,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID
|
||||
mode: Search depth mode, can be "standard" or "deep"
|
||||
|
||||
|
|
@ -2106,7 +2038,7 @@ class ConnectorService:
|
|||
"""
|
||||
# Get Linkup connector configuration
|
||||
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:
|
||||
|
|
@ -2211,7 +2143,6 @@ class ConnectorService:
|
|||
async def search_discord(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -2221,7 +2152,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
|
||||
|
|
@ -2232,7 +2162,6 @@ class ConnectorService:
|
|||
discord_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="DISCORD_CONNECTOR",
|
||||
)
|
||||
|
|
@ -2240,7 +2169,6 @@ class ConnectorService:
|
|||
discord_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="DISCORD_CONNECTOR",
|
||||
)
|
||||
|
|
@ -2308,7 +2236,6 @@ class ConnectorService:
|
|||
async def search_luma(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -2318,7 +2245,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -2330,7 +2256,6 @@ class ConnectorService:
|
|||
luma_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="LUMA_CONNECTOR",
|
||||
)
|
||||
|
|
@ -2338,7 +2263,6 @@ class ConnectorService:
|
|||
luma_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="LUMA_CONNECTOR",
|
||||
)
|
||||
|
|
@ -2466,7 +2390,6 @@ class ConnectorService:
|
|||
async def search_elasticsearch(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
|
|
@ -2476,7 +2399,6 @@ class ConnectorService:
|
|||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
|
@ -2488,7 +2410,6 @@ class ConnectorService:
|
|||
elasticsearch_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="ELASTICSEARCH_CONNECTOR",
|
||||
)
|
||||
|
|
@ -2496,7 +2417,6 @@ class ConnectorService:
|
|||
elasticsearch_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="ELASTICSEARCH_CONNECTOR",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.future import select
|
||||
|
||||
from app.config import config
|
||||
from app.db import LLMConfig, UserSearchSpacePreference
|
||||
from app.db import LLMConfig, SearchSpace
|
||||
|
||||
# Configure litellm to automatically drop unsupported parameters
|
||||
litellm.drop_params = True
|
||||
|
|
@ -144,15 +144,16 @@ async def validate_llm_config(
|
|||
return False, error_msg
|
||||
|
||||
|
||||
async def get_user_llm_instance(
|
||||
session: AsyncSession, user_id: str, search_space_id: int, role: str
|
||||
async def get_search_space_llm_instance(
|
||||
session: AsyncSession, search_space_id: int, role: str
|
||||
) -> 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:
|
||||
session: Database session
|
||||
user_id: User ID
|
||||
search_space_id: Search Space ID
|
||||
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
|
||||
"""
|
||||
try:
|
||||
# Get user's LLM preferences for this search space
|
||||
# Get the search space with its LLM preferences
|
||||
result = await session.execute(
|
||||
select(UserSearchSpacePreference).where(
|
||||
UserSearchSpacePreference.user_id == user_id,
|
||||
UserSearchSpacePreference.search_space_id == search_space_id,
|
||||
)
|
||||
select(SearchSpace).where(SearchSpace.id == search_space_id)
|
||||
)
|
||||
preference = result.scalars().first()
|
||||
search_space = result.scalars().first()
|
||||
|
||||
if not preference:
|
||||
logger.error(
|
||||
f"No LLM preferences found for user {user_id} in search space {search_space_id}"
|
||||
)
|
||||
if not search_space:
|
||||
logger.error(f"Search space {search_space_id} not found")
|
||||
return None
|
||||
|
||||
# Get the appropriate LLM config ID based on role
|
||||
llm_config_id = None
|
||||
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:
|
||||
llm_config_id = preference.fast_llm_id
|
||||
llm_config_id = search_space.fast_llm_id
|
||||
elif role == LLMRole.STRATEGIC:
|
||||
llm_config_id = preference.strategic_llm_id
|
||||
llm_config_id = search_space.strategic_llm_id
|
||||
else:
|
||||
logger.error(f"Invalid LLM role: {role}")
|
||||
return None
|
||||
|
||||
if not llm_config_id:
|
||||
logger.error(
|
||||
f"No {role} LLM configured for user {user_id} in search space {search_space_id}"
|
||||
)
|
||||
logger.error(f"No {role} LLM configured for search space {search_space_id}")
|
||||
return None
|
||||
|
||||
# Check if this is a global config (negative ID)
|
||||
|
|
@ -331,31 +325,63 @@ async def get_user_llm_instance(
|
|||
|
||||
except Exception as e:
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
session: AsyncSession, user_id: str, search_space_id: int
|
||||
) -> ChatLiteLLM | None:
|
||||
"""Get user's long context LLM instance for a specific search space."""
|
||||
return await get_user_llm_instance(
|
||||
session, user_id, search_space_id, LLMRole.LONG_CONTEXT
|
||||
)
|
||||
"""Deprecated: Use get_long_context_llm instead."""
|
||||
return await get_long_context_llm(session, search_space_id)
|
||||
|
||||
|
||||
async def get_user_fast_llm(
|
||||
session: AsyncSession, user_id: str, search_space_id: int
|
||||
) -> ChatLiteLLM | None:
|
||||
"""Get user's fast LLM instance for a specific search space."""
|
||||
return await get_user_llm_instance(session, user_id, search_space_id, LLMRole.FAST)
|
||||
"""Deprecated: Use get_fast_llm instead."""
|
||||
return await get_fast_llm(session, search_space_id)
|
||||
|
||||
|
||||
async def get_user_strategic_llm(
|
||||
session: AsyncSession, user_id: str, search_space_id: int
|
||||
) -> ChatLiteLLM | None:
|
||||
"""Get user's strategic LLM instance for a specific search space."""
|
||||
return await get_user_llm_instance(
|
||||
session, user_id, search_space_id, LLMRole.STRATEGIC
|
||||
)
|
||||
"""Deprecated: Use get_strategic_llm instead."""
|
||||
return await get_strategic_llm(session, search_space_id)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from typing import Any
|
|||
from langchain.schema import AIMessage, HumanMessage, SystemMessage
|
||||
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:
|
||||
|
|
@ -16,19 +16,17 @@ class QueryService:
|
|||
async def reformulate_query_with_chat_history(
|
||||
user_query: str,
|
||||
session: AsyncSession,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
chat_history_str: str | None = None,
|
||||
) -> 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.
|
||||
|
||||
Args:
|
||||
user_query: The original user query
|
||||
session: Database session for accessing user LLM configs
|
||||
user_id: User ID to get their specific LLM configuration
|
||||
search_space_id: Search Space ID to get user's LLM preferences
|
||||
session: Database session for accessing LLM configs
|
||||
search_space_id: Search Space ID to get LLM preferences
|
||||
chat_history_str: Optional chat history string
|
||||
|
||||
Returns:
|
||||
|
|
@ -38,11 +36,11 @@ class QueryService:
|
|||
return user_query
|
||||
|
||||
try:
|
||||
# Get the user's strategic LLM instance
|
||||
llm = await get_user_strategic_llm(session, user_id, search_space_id)
|
||||
# Get the search space's strategic LLM instance
|
||||
llm = await get_strategic_llm(session, search_space_id)
|
||||
if not llm:
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
import { useUserAccess } from "@/hooks/use-rbac";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
|
|
@ -60,11 +61,15 @@ export function DashboardClientLayout({
|
|||
}, [activeChatId, isChatPannelOpen]);
|
||||
|
||||
const { loading, error, isOnboardingComplete } = useLLMPreferences(searchSpaceIdNum);
|
||||
const { access, loading: accessLoading } = useUserAccess(searchSpaceIdNum);
|
||||
const [hasCheckedOnboarding, setHasCheckedOnboarding] = useState(false);
|
||||
|
||||
// Skip onboarding check if we're already on the onboarding page
|
||||
const isOnboardingPage = pathname?.includes("/onboard");
|
||||
|
||||
// Only owners should see onboarding - invited members use existing config
|
||||
const isOwner = access?.is_owner ?? false;
|
||||
|
||||
// Translate navigation items
|
||||
const tNavMenu = useTranslations("nav_menu");
|
||||
const translatedNavMain = useMemo(() => {
|
||||
|
|
@ -102,11 +107,13 @@ export function DashboardClientLayout({
|
|||
return;
|
||||
}
|
||||
|
||||
// Only check once after preferences have loaded
|
||||
if (!loading && !hasCheckedOnboarding) {
|
||||
// Wait for both preferences and access data to load
|
||||
if (!loading && !accessLoading && !hasCheckedOnboarding) {
|
||||
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`);
|
||||
}
|
||||
|
||||
|
|
@ -114,8 +121,10 @@ export function DashboardClientLayout({
|
|||
}
|
||||
}, [
|
||||
loading,
|
||||
accessLoading,
|
||||
isOnboardingComplete,
|
||||
isOnboardingPage,
|
||||
isOwner,
|
||||
router,
|
||||
searchSpaceId,
|
||||
hasCheckedOnboarding,
|
||||
|
|
@ -145,7 +154,7 @@ export function DashboardClientLayout({
|
|||
}, [chat_id, search_space_id]);
|
||||
|
||||
// Show loading screen while checking onboarding status (only on first load)
|
||||
if (!hasCheckedOnboarding && loading && !isOnboardingPage) {
|
||||
if (!hasCheckedOnboarding && (loading || accessLoading) && !isOnboardingPage) {
|
||||
return (
|
||||
<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">
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ export default function DashboardLayout({
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Team",
|
||||
url: `/dashboard/${search_space_id}/team`,
|
||||
icon: "Users",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
url: `/dashboard/${search_space_id}/settings`,
|
||||
|
|
|
|||
|
|
@ -1126,7 +1126,7 @@ function LogRowActions({ row, t }: { row: Row<Log>; t: (key: string) => string }
|
|||
setIsDeleting(true);
|
||||
try {
|
||||
await deleteLog(log.id);
|
||||
toast.success(t("log_deleted_success"));
|
||||
// toast.success(t("log_deleted_success"));
|
||||
await refreshLogs();
|
||||
} catch (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";
|
||||
|
||||
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 Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
|
@ -22,6 +22,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -308,16 +309,30 @@ const DashboardPage = () => {
|
|||
>
|
||||
<div className="flex flex-1 flex-col justify-between p-1">
|
||||
<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">
|
||||
{space.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 text-xs text-muted-foreground">
|
||||
{/* <span>{space.title}</span> */}
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("created")} {formatDate(space.created_at)}
|
||||
</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>
|
||||
</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,
|
||||
Trash2,
|
||||
Undo2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
|
@ -54,6 +55,7 @@ export const iconMap: Record<string, LucideIcon> = {
|
|||
Trash2,
|
||||
Podcast,
|
||||
FileText,
|
||||
Users,
|
||||
};
|
||||
|
||||
const defaultData = {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export function NavMain({ items }: { items: NavItem[] }) {
|
|||
Podcasts: "podcasts",
|
||||
Logs: "logs",
|
||||
Platform: "platform",
|
||||
Team: "team",
|
||||
};
|
||||
|
||||
const key = titleMap[title];
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from "./use-document-by-chunk";
|
||||
export * from "./use-logs";
|
||||
export * from "./use-rbac";
|
||||
export * from "./use-search-source-connectors";
|
||||
export * from "./use-search-space";
|
||||
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;
|
||||
citations_enabled: boolean;
|
||||
qna_custom_instructions: string | null;
|
||||
member_count: number;
|
||||
is_owner: boolean;
|
||||
}
|
||||
|
||||
export function useSearchSpaces() {
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@
|
|||
"surfsense_dashboard": "SurfSense Dashboard",
|
||||
"welcome_message": "Welcome to your SurfSense dashboard.",
|
||||
"your_search_spaces": "Your Search Spaces",
|
||||
"shared": "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"
|
||||
"chat": "Chat",
|
||||
"team": "Team"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "SurfSense Pricing",
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@
|
|||
"surfsense_dashboard": "SurfSense 仪表盘",
|
||||
"welcome_message": "欢迎来到您的 SurfSense 仪表盘。",
|
||||
"your_search_spaces": "您的搜索空间",
|
||||
"shared": "共享",
|
||||
"create_search_space": "创建搜索空间",
|
||||
"add_new_search_space": "添加新的搜索空间",
|
||||
"loading": "加载中",
|
||||
|
|
@ -149,7 +150,8 @@
|
|||
"podcasts": "播客",
|
||||
"logs": "日志",
|
||||
"all_search_spaces": "所有搜索空间",
|
||||
"chat": "聊天"
|
||||
"chat": "聊天",
|
||||
"team": "团队"
|
||||
},
|
||||
"pricing": {
|
||||
"title": "SurfSense 定价",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue