Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions backend/app/api/docs/collections/update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Update the editable fields of an existing collection.

You can update the collection's `name` and/or `description`. Both fields are optional in the request body — only the fields you include will be updated. Fields you omit (or send as `null`) are left unchanged.

**Behavior:**

- `name`: Must be unique within the project. If the new name is already in use by another active collection in the same project, the request fails with `409 Conflict`. Sending the same name the collection already has is a no-op (no conflict raised).
- `description`: Free-form text. Send an empty string `""` to clear it.

Other collection fields (such as `llm_service_id`, `provider`, documents, etc.) cannot be modified through this endpoint.
41 changes: 40 additions & 1 deletion backend/app/api/routes/collections.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from uuid import UUID
from typing import List

from fastapi import APIRouter, Query, Body, Depends
from fastapi import APIRouter, HTTPException, Query, Body, Depends
from fastapi import Path as FastPath

from app.api.deps import SessionDep, AuthContextDep
Expand All @@ -13,6 +13,7 @@
CollectionJobCrud,
DocumentCollectionCrud,
)
from app.crud.collection.collection import CollectionNameConflictError
from app.core.cloud import get_cloud_storage
from app.models import (
CollectionJobStatus,
Expand All @@ -27,6 +28,7 @@
CallbackRequest,
DeletionRequest,
CollectionPublic,
CollectionUpdate,
)
from app.utils import APIResponse, load_description, validate_callback_url
from app.services.collections.helpers import ensure_unique_name, to_collection_public
Expand Down Expand Up @@ -201,6 +203,43 @@ def delete_collection(
)


@router.patch(
"/{collection_id}",
description=load_description("collections/update.md"),
response_model=APIResponse[CollectionPublic],
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
)
def update_collection(
session: SessionDep,
current_user: AuthContextDep,
patch: CollectionUpdate,
collection_id: UUID = FastPath(description="Collection to update"),
) -> APIResponse[CollectionPublic]:
with log_context(
tag="collection",
system="collection",
lifecycle="api.collection.update",
action="update",
collection_id=collection_id,
project_id=current_user.project_.id,
organization_id=current_user.organization_.id,
):
collection_crud = CollectionCrud(session, current_user.project_.id)
try:
collection = collection_crud.update(collection_id, patch)
except CollectionNameConflictError as err:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Nitpick) If there is an error at the crud layer we can raise the httpexception at crud file only .. no need of raising httpexception here..

Copy link
Copy Markdown
Collaborator Author

@Ayush8923 Ayush8923 May 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check this comment #878 (comment). Earlier, I added the httpexception at crub file. But then I change the code according to comment.

raise HTTPException(
status_code=409,
detail=f"Collection '{err.name}' already exists. Choose a different name.",
)

logger.info(
f"[update_collection] Collection updated | {{'collection_id': '{collection_id}'}}"
)

return APIResponse.success_response(to_collection_public(collection))

Comment thread
coderabbitai[bot] marked this conversation as resolved.

@router.get(
"/{collection_id}",
description=load_description("collections/info.md"),
Expand Down
36 changes: 35 additions & 1 deletion backend/app/crud/collection/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@
from sqlmodel import Session, select, and_
from sqlalchemy.exc import IntegrityError

from app.models import Document, Collection, DocumentCollection
from app.models import Document, Collection, CollectionUpdate, DocumentCollection
from app.core.util import now
from app.crud.document_collection import DocumentCollectionCrud

logger = logging.getLogger(__name__)


class CollectionNameConflictError(Exception):
"""Raised when a collection name conflicts with an existing active collection."""

def __init__(self, name: str | None) -> None:
self.name = name
super().__init__(f"Collection name '{name}' already exists")


class CollectionCrud:
def __init__(self, session: Session, project_id: int):
self.session = session
Expand Down Expand Up @@ -93,6 +101,32 @@ def read_all(self):
collections = self.session.exec(statement).all()
return collections

def update(self, collection_id: UUID, patch: CollectionUpdate) -> Collection:
"""Update editable fields of a collection (name, description).

Raises:
CollectionNameConflictError: when the requested name is already taken
by another active collection in the same project (caught either by
the pre-check or by a unique-index IntegrityError on commit).
"""
collection = self.read_one(collection_id)

changes = patch.model_dump(exclude_unset=True, exclude_none=True)

if "name" in changes and changes["name"] != collection.name:
if self.exists_by_name(changes["name"]):
raise CollectionNameConflictError(changes["name"])

for field, value in changes.items():
setattr(collection, field, value)

collection.updated_at = now()
try:
return self._update(collection)
except IntegrityError:
self.session.rollback()
raise CollectionNameConflictError(changes.get("name"))

Comment thread
coderabbitai[bot] marked this conversation as resolved.
def exists_by_name(self, collection_name: str) -> bool:
statement = (
select(Collection.id)
Expand Down
1 change: 1 addition & 0 deletions backend/app/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
Collection,
CollectionIDPublic,
CollectionPublic,
CollectionUpdate,
CollectionWithDocsPublic,
CreationRequest,
DeletionRequest,
Expand Down
36 changes: 34 additions & 2 deletions backend/app/models/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,16 @@ class Collection(SQLModel, table=True):

# Request models
class CollectionOptions(SQLModel):
name: str | None = Field(default=None, description="Name of the collection")
name: str | None = Field(
default=None,
min_length=1,
max_length=255,
description="Name of the collection",
)
description: str | None = Field(
default=None, description="Description of the collection"
default=None,
max_length=2000,
description="Description of the collection",
)
documents: list[UUID] = Field(
description="List of document IDs",
Expand Down Expand Up @@ -192,6 +199,20 @@ class DeletionRequest(CallbackRequest):
collection_id: UUID = Field(description="Collection to delete")


class CollectionUpdate(SQLModel):
name: str | None = Field(
default=None,
min_length=1,
max_length=255,
description="New name for the collection",
)
description: str | None = Field(
default=None,
max_length=2000,
description="New description for the collection",
)


# Response models


Expand All @@ -201,6 +222,17 @@ class CollectionIDPublic(SQLModel):

class CollectionPublic(SQLModel):
id: UUID
name: str | None = Field(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while this is fine can we add some restriction like name should be there instead of name and minimum and maximum length

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update the code accordingly.

default=None,
min_length=1,
max_length=255,
description="Name of the collection",
)
description: str | None = Field(
default=None,
max_length=2000,
description="Description of the collection",
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add testcases for this as well

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, added the testcases.

llm_service_id: str | None = Field(
default=None,
description="LLM service ID (e.g., Assistant ID) when model and instructions were provided",
Expand Down
4 changes: 4 additions & 0 deletions backend/app/services/collections/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ def to_collection_public(collection: Collection) -> CollectionPublic:
if is_vector_store:
return CollectionPublic(
id=collection.id,
name=collection.name,
description=collection.description,
knowledge_base_id=collection.llm_service_id,
knowledge_base_provider=collection.llm_service_name,
project_id=collection.project_id,
Expand All @@ -181,6 +183,8 @@ def to_collection_public(collection: Collection) -> CollectionPublic:
else:
return CollectionPublic(
id=collection.id,
name=collection.name,
description=collection.description,
llm_service_id=collection.llm_service_id,
llm_service_name=collection.llm_service_name,
project_id=collection.project_id,
Expand Down
91 changes: 91 additions & 0 deletions backend/app/tests/api/routes/collections/test_collection_update.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from uuid import uuid4

from fastapi.testclient import TestClient
from sqlmodel import Session

from app.core.config import settings
from app.crud import CollectionCrud
from app.models import CollectionUpdate
from app.tests.utils.utils import get_project
from app.tests.utils.collection import get_assistant_collection


def test_update_collection_returns_updated_fields(
client: TestClient,
db: Session,
user_api_key_header: dict[str, str],
) -> None:
project = get_project(db, "Dalgo")
collection = get_assistant_collection(db, project)

response = client.patch(
f"{settings.API_V1_STR}/collections/{collection.id}",
headers=user_api_key_header,
json={"name": "edited", "description": "edited desc"},
)

assert response.status_code == 200
payload = response.json()
assert payload["success"] is True
data = payload["data"]
assert data["id"] == str(collection.id)
assert data["name"] == "edited"
assert data["description"] == "edited desc"


def test_update_collection_partial_update_preserves_other_fields(
client: TestClient,
db: Session,
user_api_key_header: dict[str, str],
) -> None:
project = get_project(db, "Dalgo")
collection = get_assistant_collection(db, project)
CollectionCrud(db, project.id).update(
collection.id, CollectionUpdate(name="original", description="original-desc")
)

response = client.patch(
f"{settings.API_V1_STR}/collections/{collection.id}",
headers=user_api_key_header,
json={"description": "patched-desc"},
)

assert response.status_code == 200
data = response.json()["data"]
assert data["name"] == "original"
assert data["description"] == "patched-desc"


def test_update_collection_rename_to_existing_name_returns_409(
client: TestClient,
db: Session,
user_api_key_header: dict[str, str],
) -> None:
project = get_project(db, "Dalgo")
crud = CollectionCrud(db, project.id)

first = get_assistant_collection(db, project)
crud.update(first.id, CollectionUpdate(name="duplicate"))

second = get_assistant_collection(db, project)

response = client.patch(
f"{settings.API_V1_STR}/collections/{second.id}",
headers=user_api_key_header,
json={"name": "duplicate"},
)

assert response.status_code == 409


def test_update_collection_not_found_returns_404(
client: TestClient,
user_api_key_header: dict[str, str],
) -> None:
response = client.patch(
f"{settings.API_V1_STR}/collections/{uuid4()}",
headers=user_api_key_header,
json={"name": "irrelevant"},
)

assert response.status_code == 404
Loading
Loading