diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py index 5bfe0a955..f843029e2 100644 --- a/surfsense_backend/app/routes/search_source_connectors_routes.py +++ b/surfsense_backend/app/routes/search_source_connectors_routes.py @@ -15,7 +15,7 @@ from sqlalchemy.future import select from sqlalchemy.exc import IntegrityError from typing import List, Dict, Any from app.db import get_async_session, User, SearchSourceConnector, SearchSourceConnectorType, SearchSpace, async_session_maker -from app.schemas import SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead +from app.schemas import SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead, SearchSourceConnectorBase from app.users import current_active_user from app.utils.check_ownership import check_ownership from pydantic import ValidationError, BaseModel, Field @@ -159,54 +159,84 @@ async def update_search_source_connector( ): """ Update a search source connector. - - Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR). - The config must contain the appropriate keys for the connector type. + Handles partial updates, including merging changes into the 'config' field. """ - try: - db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user) + db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user) + + # Convert the sparse update data (only fields present in request) to a dict + update_data = connector_update.model_dump(exclude_unset=True) + + # Special handling for 'config' field + if "config" in update_data: + incoming_config = update_data["config"] # Config data from the request + existing_config = db_connector.config if db_connector.config else {} # Current config from DB - # If connector type is being changed, check if one of that type already exists - if connector_update.connector_type != db_connector.connector_type: + # Merge incoming config into existing config + # This preserves existing keys (like GITHUB_PAT) if they are not in the incoming data + merged_config = existing_config.copy() + merged_config.update(incoming_config) + + # -- Validation after merging -- + # Validate the *merged* config based on the connector type + # We need the connector type - use the one from the update if provided, else the existing one + current_connector_type = connector_update.connector_type if connector_update.connector_type is not None else db_connector.connector_type + + try: + # We can reuse the base validator by creating a temporary base model instance + # Note: This assumes 'name' and 'is_indexable' are not crucial for config validation itself + temp_data_for_validation = { + "name": db_connector.name, # Use existing name + "connector_type": current_connector_type, + "is_indexable": db_connector.is_indexable, # Use existing value + "last_indexed_at": db_connector.last_indexed_at, # Not used by validator + "config": merged_config + } + SearchSourceConnectorBase.model_validate(temp_data_for_validation) + except ValidationError as e: + # Raise specific validation error for the merged config + raise HTTPException( + status_code=422, + detail=f"Validation error for merged config: {str(e)}" + ) + + # If validation passes, update the main update_data dict with the merged config + update_data["config"] = merged_config + + # Apply all updates (including the potentially merged config) + 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( select(SearchSourceConnector) .filter( SearchSourceConnector.user_id == user.id, - SearchSourceConnector.connector_type == connector_update.connector_type, + SearchSourceConnector.connector_type == value, SearchSourceConnector.id != connector_id ) ) existing_connector = result.scalars().first() - if existing_connector: raise HTTPException( status_code=409, - detail=f"A connector with type {connector_update.connector_type} already exists. Each user can have only one connector of each type." + detail=f"A connector with type {value} already exists. Each user can have only one connector of each type." ) - update_data = connector_update.model_dump(exclude_unset=True) - for key, value in update_data.items(): - setattr(db_connector, key, value) + setattr(db_connector, key, value) + + try: await session.commit() await session.refresh(db_connector) return db_connector - except ValidationError as e: - await session.rollback() - raise HTTPException( - status_code=422, - detail=f"Validation error: {str(e)}" - ) except IntegrityError as e: await session.rollback() + # This might occur if connector_type constraint is violated somehow after the check raise HTTPException( status_code=409, - detail=f"Integrity error: A connector with this type already exists. {str(e)}" + detail=f"Database integrity error during update: {str(e)}" ) - except HTTPException: - await session.rollback() - raise except Exception as e: await session.rollback() + logger.error(f"Failed to update search source connector {connector_id}: {e}", exc_info=True) raise HTTPException( status_code=500, detail=f"Failed to update search source connector: {str(e)}" diff --git a/surfsense_backend/app/schemas/search_source_connector.py b/surfsense_backend/app/schemas/search_source_connector.py index 1005a63d8..d0c46313b 100644 --- a/surfsense_backend/app/schemas/search_source_connector.py +++ b/surfsense_backend/app/schemas/search_source_connector.py @@ -1,6 +1,6 @@ from datetime import datetime import uuid -from typing import Dict, Any +from typing import Dict, Any, Optional from pydantic import BaseModel, field_validator from .base import IDModel, TimestampModel from app.db import SearchSourceConnectorType @@ -9,7 +9,7 @@ class SearchSourceConnectorBase(BaseModel): name: str connector_type: SearchSourceConnectorType is_indexable: bool - last_indexed_at: datetime | None + last_indexed_at: Optional[datetime] = None config: Dict[str, Any] @field_validator('config') @@ -77,8 +77,12 @@ class SearchSourceConnectorBase(BaseModel): class SearchSourceConnectorCreate(SearchSourceConnectorBase): pass -class SearchSourceConnectorUpdate(SearchSourceConnectorBase): - pass +class SearchSourceConnectorUpdate(BaseModel): + name: Optional[str] = None + connector_type: Optional[SearchSourceConnectorType] = None + is_indexable: Optional[bool] = None + last_indexed_at: Optional[datetime] = None + config: Optional[Dict[str, Any]] = None class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel): user_id: uuid.UUID diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/(manage)/page.tsx index 817ca584d..bb890ebd5 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/(manage)/page.tsx @@ -205,7 +205,7 @@ export default function ConnectorsPage() { + + + + + Edit GitHub Connector + + Modify the connector name and repository selections. To change repository selections, you need to re-enter your PAT. + + + + {/* Use editForm for the main form structure */} +
+ + + {/* Connector Name Field */} + ( + + Connector Name + + + + + + )} + /> + +
+ + {/* Repository Selection Section */} +
+

Repository Selection

+ + {editMode === 'viewing' && ( +
+ Currently Indexed Repositories: + {currentSelectedRepos.length > 0 ? ( +
    + {currentSelectedRepos.map(repo =>
  • {repo}
  • )} +
+ ) : ( +

(No repositories currently selected for indexing)

+ )} + + Click "Change Selection" to re-enter your PAT and update the list. +
+ )} + + {editMode === 'editing_repos' && ( +
+ {/* PAT Input Section (No nested Form provider) */} + {/* We still use patForm fields but trigger validation manually */} +
+ ( + + Re-enter PAT to Fetch Repos + + + + + + )} + /> + +
+ + {/* Fetched Repository List (shown after fetch) */} + {isFetchingRepos && } + {!isFetchingRepos && fetchedRepos !== null && ( + fetchedRepos.length === 0 ? ( + + + No Repositories Found + Check the PAT and permissions. + + ) : ( +
+ Select Repositories to Index ({newSelectedRepos.length} selected): +
+ {fetchedRepos.map((repo) => ( +
+ handleRepoSelectionChange(repo.full_name, !!checked)} + /> + +
+ ))} +
+
+ ) + )} + +
+ )} +
+ +
+ + + + +
+ +
+
+ + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/page.tsx index e841639cd..ad6ceb7bf 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/page.tsx @@ -70,7 +70,7 @@ export default function EditConnectorPage() { const [connector, setConnector] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); - + console.log("connector", connector); // Initialize the form const form = useForm({ resolver: zodResolver(apiConnectorFormSchema),