diff --git a/backend/src/cms_backend/api/main.py b/backend/src/cms_backend/api/main.py index b9954eb..6ee91c1 100644 --- a/backend/src/cms_backend/api/main.py +++ b/backend/src/cms_backend/api/main.py @@ -19,6 +19,7 @@ from cms_backend.api.routes.http_errors import BadRequestError from cms_backend.api.routes.staging import router as staging_router from cms_backend.api.routes.titles import router as titles_router +from cms_backend.api.routes.warehouse import router as warehouse_router from cms_backend.api.routes.zimfarm_notifications import ( router as zimfarm_notification_router, ) @@ -74,6 +75,7 @@ def create_app(*, debug: bool = True): main_router.include_router(router=auth_router) main_router.include_router(router=account_router) main_router.include_router(router=staging_router) + main_router.include_router(router=warehouse_router) app.include_router(router=main_router) diff --git a/backend/src/cms_backend/api/routes/collection.py b/backend/src/cms_backend/api/routes/collection.py index 25b3882..0d434ee 100644 --- a/backend/src/cms_backend/api/routes/collection.py +++ b/backend/src/cms_backend/api/routes/collection.py @@ -5,21 +5,31 @@ import xxhash from fastapi import APIRouter, Depends, Path, Query from fastapi.responses import Response +from pydantic import AnyUrl, Field from sqlalchemy.orm import Session as OrmSession -from cms_backend.api.routes.fields import LimitFieldMax200, SkipField +from cms_backend.api.routes.dependencies import require_permission +from cms_backend.api.routes.fields import LimitFieldMax200, NotEmptyString, SkipField from cms_backend.api.routes.models import ListResponse, calculate_pagination_metadata from cms_backend.api.routes.utils import build_library_xml from cms_backend.db import gen_dbsession +from cms_backend.db.collection import create_collection as db_create_collection from cms_backend.db.collection import ( - get_collection, + create_collection_full_schema, get_collection_by_name_or_none, get_latest_books_for_collection, ) +from cms_backend.db.collection import get_collection as db_get_collection +from cms_backend.db.collection import ( + get_collection_by_name as db_get_collection_by_name, +) from cms_backend.db.collection import get_collections as db_get_collections +from cms_backend.db.collection import update_collection as db_update_collection from cms_backend.db.exceptions import RecordDoesNotExistError from cms_backend.schemas import BaseModel -from cms_backend.schemas.orms import CollectionLightSchema +from cms_backend.schemas.models import CollectionUpdateSchema +from cms_backend.schemas.orms import CollectionFullSchema, CollectionLightSchema +from cms_backend.utils import is_valid_uuid router = APIRouter(prefix="/collections", tags=["collections"]) @@ -27,16 +37,19 @@ class CollectionsGetSchema(BaseModel): skip: SkipField = 0 limit: LimitFieldMax200 = 20 + name: NotEmptyString | None = None @router.get("") -async def get_collections( +def get_collections( params: Annotated[CollectionsGetSchema, Query()], session: Annotated[OrmSession, Depends(gen_dbsession)], ) -> ListResponse[CollectionLightSchema]: """Get a list of collections""" - results = db_get_collections(session, skip=params.skip, limit=params.limit) + results = db_get_collections( + session, skip=params.skip, limit=params.limit, name=params.name + ) return ListResponse[CollectionLightSchema]( meta=calculate_pagination_metadata( @@ -49,6 +62,71 @@ async def get_collections( ) +class CollectionCreateSchema(BaseModel): + name: NotEmptyString = Field(min_length=3) + warehouse_name: NotEmptyString = Field(min_length=3) + download_base_url: AnyUrl | None = None + view_base_url: AnyUrl | None = None + + +@router.post( + "", + dependencies=[Depends(require_permission(namespace="collection", name="create"))], +) +def create_collection( + session: Annotated[OrmSession, Depends(gen_dbsession)], + request: CollectionCreateSchema, +): + """Create a collection""" + return create_collection_full_schema( + db_create_collection( + session, + name=request.name, + warehouse_name=request.warehouse_name, + download_base_url=( + str(request.download_base_url) + if request.download_base_url is not None + else None + ), + view_base_url=( + str(request.view_base_url) + if request.view_base_url is not None + else None + ), + ) + ) + + +@router.get("/{collection_id_or_name}") +def get_collection( + collection_id_or_name: Annotated[str, Path()], + session: Annotated[OrmSession, Depends(gen_dbsession)], +): + """Get collection by collection ID (UUID) or name.""" + if is_valid_uuid(collection_id_or_name): + collection = db_get_collection(session, UUID(collection_id_or_name)) + else: + collection = db_get_collection_by_name(session, collection_id_or_name) + return create_collection_full_schema(collection) + + +@router.patch( + "/{collection_id_or_name}", + dependencies=[Depends(require_permission(namespace="collection", name="update"))], +) +def update_collection( + collection_id_or_name: Annotated[str, Path()], + collection_data: CollectionUpdateSchema, + session: OrmSession = Depends(gen_dbsession), +) -> CollectionFullSchema: + """Update a collectio's data""" + return create_collection_full_schema( + db_update_collection( + session, collection_id=collection_id_or_name, request=collection_data + ) + ) + + def _get_catalog_xml_content( collection_id_or_name: str, session: OrmSession ) -> tuple[str, int]: @@ -57,7 +135,7 @@ def _get_catalog_xml_content( try: collection_id = UUID(collection_id_or_name) try: - collection = get_collection(session, collection_id) + collection = db_get_collection(session, collection_id) except RecordDoesNotExistError: pass except ValueError: @@ -78,7 +156,7 @@ def _get_catalog_xml_content( @router.get("/{collection_id_or_name}/catalog.xml") -async def get_library_catalog_xml( +def get_library_catalog_xml( collection_id_or_name: Annotated[str, Path()], session: Annotated[OrmSession, Depends(gen_dbsession)], ): @@ -95,7 +173,7 @@ async def get_library_catalog_xml( @router.head("/{collection_id_or_name}/catalog.xml") -async def head_library_catalog_xml( +def head_library_catalog_xml( collection_id_or_name: Annotated[str, Path()], session: Annotated[OrmSession, Depends(gen_dbsession)], ): diff --git a/backend/src/cms_backend/api/routes/fields.py b/backend/src/cms_backend/api/routes/fields.py index 2eb1297..b412b80 100644 --- a/backend/src/cms_backend/api/routes/fields.py +++ b/backend/src/cms_backend/api/routes/fields.py @@ -1,6 +1,5 @@ from typing import Annotated, Any -import regex from pydantic import ( AfterValidator, Field, @@ -10,16 +9,6 @@ ) -class GraphemeStr(str): - def __len__(self) -> int: - # Count the number of grapheme clusters - return len(regex.findall(r"\X", self)) - - -def to_grapheme_str(value: str): - return GraphemeStr(value) - - def no_null_char(value: str) -> str: """Validate that string value does not contains Unicode null character""" if "\u0000" in value: @@ -46,9 +35,7 @@ def not_empty(value: str) -> str: return value.strip() -NoNullCharString = Annotated[ - str, AfterValidator(no_null_char), AfterValidator(to_grapheme_str) -] +NoNullCharString = Annotated[str, AfterValidator(no_null_char)] NotEmptyString = Annotated[NoNullCharString, AfterValidator(not_empty)] diff --git a/backend/src/cms_backend/api/routes/titles.py b/backend/src/cms_backend/api/routes/titles.py index 839f8c2..9ba7f31 100644 --- a/backend/src/cms_backend/api/routes/titles.py +++ b/backend/src/cms_backend/api/routes/titles.py @@ -30,6 +30,7 @@ class TitlesGetSchema(BaseModel): skip: SkipField = 0 limit: LimitFieldMax200 = 20 name: NotEmptyString | None = None + collection_name: NotEmptyString | None = None class BaseTitleCreateUpdateSchema(BaseModel): @@ -70,6 +71,7 @@ def get_titles( skip=params.skip, limit=params.limit, name=params.name, + collection_name=params.collection_name, ) return ListResponse[TitleLightSchema]( meta=calculate_pagination_metadata( diff --git a/backend/src/cms_backend/api/routes/warehouse.py b/backend/src/cms_backend/api/routes/warehouse.py new file mode 100644 index 0000000..fd2d0cf --- /dev/null +++ b/backend/src/cms_backend/api/routes/warehouse.py @@ -0,0 +1,30 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session as OrmSession + +from cms_backend.api.routes.dependencies import gen_dbsession +from cms_backend.api.routes.fields import LimitFieldMax200, SkipField +from cms_backend.api.routes.models import ListResponse, calculate_pagination_metadata +from cms_backend.db.warehouse import get_warehouses as db_get_warehouses + +router = APIRouter(prefix="/warehouses", tags=["warehouses"]) + + +@router.get("") +def get_warehouses( + session: OrmSession = Depends(gen_dbsession), + skip: Annotated[SkipField, Query()] = 0, + limit: Annotated[LimitFieldMax200, Query()] = 200, +): + """Get a list of warehouses""" + result = db_get_warehouses(session, skip=skip, limit=limit) + return ListResponse( + items=result.records, + meta=calculate_pagination_metadata( + nb_records=result.nb_records, + skip=skip, + limit=limit, + page_size=len(result.records), + ), + ) diff --git a/backend/src/cms_backend/db/collection.py b/backend/src/cms_backend/db/collection.py index eb029f4..297f8b8 100644 --- a/backend/src/cms_backend/db/collection.py +++ b/backend/src/cms_backend/db/collection.py @@ -2,7 +2,7 @@ from typing import NamedTuple, cast from uuid import UUID -from sqlalchemy import and_, func, select +from sqlalchemy import and_, func, select, update from sqlalchemy.orm import Session as OrmSession from cms_backend.db import count_from_stmt @@ -14,7 +14,14 @@ CollectionTitle, Title, ) -from cms_backend.schemas.orms import CollectionLightSchema, ListResult +from cms_backend.db.warehouse import get_warehouse +from cms_backend.schemas.models import CollectionUpdateSchema +from cms_backend.schemas.orms import ( + CollectionFullSchema, + CollectionLightSchema, + ListResult, +) +from cms_backend.utils import is_valid_uuid def get_collection_or_none(session: OrmSession, library_id: UUID) -> Collection | None: @@ -125,7 +132,7 @@ def get_latest_books_for_collection( def get_collections( - session: OrmSession, *, skip: int, limit: int + session: OrmSession, *, name: str | None = None, skip: int, limit: int ) -> ListResult[CollectionLightSchema]: """Get the list of collections.""" stmt = ( @@ -139,8 +146,19 @@ def get_collections( [], ).label("paths"), ) + .where( + # If a client provides an argument i.e it is not None, + # we compare the corresponding model field against the argument, + # otherwise, we compare the argument to its default which translates + # to a SQL true i.e we don't filter based on this argument (a no-op). + ( + Collection.name.ilike(f"%{name if name is not None else ''}%") + | (name is None) + ), + ) .outerjoin(CollectionTitle) .group_by(Collection.id) + .order_by(Collection.name.desc()) ) return ListResult[CollectionLightSchema]( @@ -156,3 +174,52 @@ def get_collections( ).all() ], ) + + +def create_collection_full_schema(collection: Collection) -> CollectionFullSchema: + return CollectionFullSchema( + id=collection.id, + warehouse=collection.warehouse.name, + name=collection.name, + download_base_url=collection.download_base_url, + view_base_url=collection.view_base_url, + ) + + +def create_collection( + session: OrmSession, + *, + name: str, + warehouse_name: str, + download_base_url: str | None = None, + view_base_url: str | None, +) -> Collection: + warehouse = get_warehouse(session, warehouse_name) + collection = Collection( + name=name, + download_base_url=download_base_url, + view_base_url=view_base_url, + warehouse_id=warehouse.id, + ) + session.add(collection) + session.flush() + return collection + + +def update_collection( + session: OrmSession, *, collection_id: str, request: CollectionUpdateSchema +) -> Collection: + """Update a collection""" + if is_valid_uuid(collection_id): + collection = get_collection(session, UUID(collection_id)) + else: + collection = get_collection_by_name(session, collection_id) + values = request.model_dump(exclude_unset=True) + if not values: + return collection + + session.execute( + update(Collection).values(**values).where(Collection.id == collection.id) + ) + session.refresh(collection) + return collection diff --git a/backend/src/cms_backend/db/models.py b/backend/src/cms_backend/db/models.py index e237b1a..54c69f7 100644 --- a/backend/src/cms_backend/db/models.py +++ b/backend/src/cms_backend/db/models.py @@ -253,7 +253,7 @@ class Warehouse(Base): id: Mapped[UUID] = mapped_column( init=False, primary_key=True, server_default=text("uuid_generate_v4()") ) - name: Mapped[str] + name: Mapped[str] = mapped_column(unique=True, index=True) collections: Mapped[list["Collection"]] = relationship( back_populates="warehouse", diff --git a/backend/src/cms_backend/db/title.py b/backend/src/cms_backend/db/title.py index 28776cc..0d13fc9 100644 --- a/backend/src/cms_backend/db/title.py +++ b/backend/src/cms_backend/db/title.py @@ -19,6 +19,7 @@ from cms_backend.db.exceptions import RecordAlreadyExistsError, RecordDoesNotExistError from cms_backend.db.models import ( Book, + Collection, CollectionTitle, Title, ) @@ -121,6 +122,7 @@ def get_titles( limit: int, name: str | None = None, omit_names: list[str] | None = None, + collection_name: str | None = None, ) -> ListResult[TitleLightSchema]: """Get a list of titles""" @@ -130,6 +132,8 @@ def get_titles( Title.name.label("title_name"), Title.maturity.label("title_maturity"), ) + .join(CollectionTitle, CollectionTitle.title_id == Title.id, isouter=True) + .join(Collection, CollectionTitle.collection_id == Collection.id, isouter=True) .order_by(Title.name) .where( # If a client provides an argument i.e it is not None, @@ -141,6 +145,12 @@ def get_titles( | (name is None) ), (Title.name.not_in(omit_names or []) | (omit_names is None)), + ( + Collection.name.ilike( + f"%{collection_name if collection_name is not None else ''}%" + ) + | (collection_name is None) + ), ) ) diff --git a/backend/src/cms_backend/db/warehouse.py b/backend/src/cms_backend/db/warehouse.py new file mode 100644 index 0000000..30205ba --- /dev/null +++ b/backend/src/cms_backend/db/warehouse.py @@ -0,0 +1,52 @@ +from sqlalchemy import func, select +from sqlalchemy.orm import Session as OrmSession + +from cms_backend.db.exceptions import RecordDoesNotExistError +from cms_backend.db.models import Warehouse +from cms_backend.schemas.orms import ListResult + + +def get_warehouse_or_none(session: OrmSession, warehouse_name: str) -> Warehouse | None: + """Get a warehouse by name or None.""" + return session.scalars( + select(Warehouse).where(Warehouse.name == warehouse_name) + ).one_or_none() + + +def get_warehouse(session: OrmSession, warehouse_name: str) -> Warehouse: + """Get a warehouse by name if possible else raise an exception""" + if ( + warehouse := get_warehouse_or_none(session, warehouse_name=warehouse_name) + ) is None: + raise RecordDoesNotExistError( + f"Warehouse with name {warehouse_name} does not exist" + ) + return warehouse + + +def get_warehouses( + session: OrmSession, *, name: str | None = None, skip: int, limit: int +) -> ListResult[str]: + """Get the list of warehouses.""" + stmt = ( + select(func.count().over().label("nb_records"), Warehouse.name) + .where( + # If a client provides an argument i.e it is not None, + # we compare the corresponding model field against the argument, + # otherwise, we compare the argument to its default which translates + # to a SQL true i.e we don't filter based on this argument (a no-op). + ( + Warehouse.name.ilike(f"%{name if name is not None else ''}%") + | (name is None) + ), + ) + .offset(skip) + .limit(limit) + .order_by(Warehouse.name.desc()) + ) + results = ListResult[str](nb_records=0, records=[]) + + for nb_records, warehouse_name in session.execute(stmt).all(): + results.nb_records = nb_records + results.records.append(warehouse_name) + return results diff --git a/backend/src/cms_backend/migrations/versions/1625ae981cfb_make_warehouse_name_unique.py b/backend/src/cms_backend/migrations/versions/1625ae981cfb_make_warehouse_name_unique.py new file mode 100644 index 0000000..f40de74 --- /dev/null +++ b/backend/src/cms_backend/migrations/versions/1625ae981cfb_make_warehouse_name_unique.py @@ -0,0 +1,27 @@ +"""make warehouse name unique + +Revision ID: 1625ae981cfb +Revises: 7c36d46dd0dd +Create Date: 2026-05-05 14:26:31.772973 + +""" + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "1625ae981cfb" +down_revision = "7c36d46dd0dd" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_index(op.f("ix_warehouse_name"), "warehouse", ["name"], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_warehouse_name"), table_name="warehouse") + # ### end Alembic commands ### diff --git a/backend/src/cms_backend/roles.py b/backend/src/cms_backend/roles.py index 854f329..75d1aef 100644 --- a/backend/src/cms_backend/roles.py +++ b/backend/src/cms_backend/roles.py @@ -31,6 +31,7 @@ class RoleEnum(StrEnum): "title": ResourcePermissions.get_all(), "zimfarm_notification": ResourcePermissions.get_all(), "account": ResourcePermissions.get_all(), + "collection": ResourcePermissions.get_all(), }, RoleEnum.ZIMFARM: { "zimfarm_notification": ResourcePermissions.get(read=True, create=True), diff --git a/backend/src/cms_backend/schemas/models.py b/backend/src/cms_backend/schemas/models.py index f977de1..2f26053 100644 --- a/backend/src/cms_backend/schemas/models.py +++ b/backend/src/cms_backend/schemas/models.py @@ -42,3 +42,9 @@ class AccountUpdateSchema(BaseModel): username: NotEmptyString | None = None idp_sub: NotEmptyString | None = None display_name: NotEmptyString | None = None + + +class CollectionUpdateSchema(BaseModel): + name: NotEmptyString | None = None + download_base_url: AnyUrl | None = None + view_base_url: AnyUrl | None = None diff --git a/backend/src/cms_backend/schemas/orms.py b/backend/src/cms_backend/schemas/orms.py index 8b8be2c..59627d3 100644 --- a/backend/src/cms_backend/schemas/orms.py +++ b/backend/src/cms_backend/schemas/orms.py @@ -44,13 +44,23 @@ class TitleFullSchema(TitleLightSchema): class CollectionLightSchema(BaseModel): - """Collection for reading a collection with all the paths in it.""" + """Schema for reading a collection with all the paths in it.""" id: UUID name: str paths: list[Path] +class CollectionFullSchema(BaseModel): + """Schema for reading a collection with all the fileds inlcuding warehouse.""" + + id: UUID + name: str + warehouse: str + download_base_url: str | None = None + view_base_url: str | None = None + + class ZimfarmNotificationLightSchema(BaseModel): """ Schema for reading a zimfarm notification model with some fields diff --git a/backend/tests/api/routes/test_collections.py b/backend/tests/api/routes/test_collections.py index 0183f2c..f12e27f 100644 --- a/backend/tests/api/routes/test_collections.py +++ b/backend/tests/api/routes/test_collections.py @@ -5,7 +5,10 @@ from fastapi.testclient import TestClient from sqlalchemy.orm import Session as OrmSession -from cms_backend.db.models import Collection, Title +from cms_backend.api.token import generate_access_token +from cms_backend.db.models import Account, Collection, Title, Warehouse +from cms_backend.roles import RoleEnum +from cms_backend.utils.datetime import getnow def test_get_collections_empty(client: TestClient): @@ -81,3 +84,82 @@ def test_get_collections_pagination( assert response_doc["meta"]["limit"] <= limit assert response_doc["meta"]["page_size"] == expected_count assert "items" in response_doc + + +@pytest.mark.parametrize( + "permission,expected_status_code", + [ + pytest.param(RoleEnum.EDITOR, HTTPStatus.OK, id="editor"), + pytest.param(RoleEnum.VIEWER, HTTPStatus.UNAUTHORIZED, id="viewer"), + ], +) +def test_create_collection_required_permissions( + client: TestClient, + create_account: Callable[..., Account], + warehouse: Warehouse, + permission: RoleEnum, + expected_status_code: HTTPStatus, +): + """Test creating a collection with different roles""" + collection_data = { + "name": "wikipedia_en_test", + "warehouse_name": warehouse.name, + } + + account = create_account(permission=permission) + access_token = generate_access_token( + account_id=str(account.id), issue_time=getnow() + ) + response = client.post( + "/v1/collections", + json=collection_data, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == expected_status_code + + +@pytest.mark.parametrize( + "permission,expected_status_code", + [ + pytest.param(RoleEnum.EDITOR, HTTPStatus.OK, id="editor"), + pytest.param(RoleEnum.VIEWER, HTTPStatus.UNAUTHORIZED, id="viewer"), + ], +) +def test_updating_collection_required_permissions( + client: TestClient, + create_account: Callable[..., Account], + collection: Collection, + warehouse: Warehouse, + permission: RoleEnum, + expected_status_code: HTTPStatus, +): + """Test updating a collection with different roles""" + collection_data = { + "name": collection.name + "update", + "warehouse_name": warehouse.name, + } + + account = create_account(permission=permission) + access_token = generate_access_token( + account_id=str(account.id), issue_time=getnow() + ) + response = client.patch( + f"/v1/collections/{collection.name}", + json=collection_data, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == expected_status_code + + +def test_get_collection( + client: TestClient, + collection: Collection, +): + """Test retrieving a collection""" + response = client.get(f"/v1/collections/{collection.name}") + assert response.status_code == HTTPStatus.OK + data = response.json() + assert data["name"] == collection.name + assert data["warehouse"] == collection.warehouse.name + assert data["download_base_url"] == collection.download_base_url + assert data["view_base_url"] == collection.view_base_url diff --git a/backend/tests/api/routes/test_warehouses.py b/backend/tests/api/routes/test_warehouses.py new file mode 100644 index 0000000..e59c4b1 --- /dev/null +++ b/backend/tests/api/routes/test_warehouses.py @@ -0,0 +1,42 @@ +from collections.abc import Callable + +import pytest +from sqlalchemy.orm import Session as OrmSession + +from cms_backend.db.models import Warehouse +from cms_backend.db.warehouse import get_warehouses + + +def test_get_warehouses_empty(dbsession: OrmSession): + """Test that get_warehouses returns empty list when no recipes exist""" + result = get_warehouses(dbsession, skip=0, limit=10) + assert result.nb_records == 0 + assert len(result.records) == 0 + + +@pytest.mark.parametrize( + "skip,limit,expected_count,nb_records", + [ + pytest.param(0, 3, 3, 8, id="first-page"), + pytest.param(3, 3, 3, 8, id="second-page"), + pytest.param(6, 3, 2, 8, id="third-page-partial"), + pytest.param(8, 3, 0, 0, id="page-num-too-high-no-results"), + pytest.param(0, 1, 1, 8, id="first-page-with-low-limit"), + pytest.param(0, 20, 8, 8, id="first-page-with-high-limit"), + ], +) +def test_get_tags( + dbsession: OrmSession, + create_warehouse: Callable[..., Warehouse], + skip: int, + limit: int, + expected_count: int, + nb_records: int, +): + for _ in range(8): + create_warehouse() + + result = get_warehouses(dbsession, skip=skip, limit=limit) + assert result.nb_records == nb_records + assert len(result.records) <= limit + assert len(result.records) == expected_count diff --git a/backend/tests/db/test_collections.py b/backend/tests/db/test_collections.py index d39d4f4..d58543e 100644 --- a/backend/tests/db/test_collections.py +++ b/backend/tests/db/test_collections.py @@ -4,12 +4,13 @@ from sqlalchemy.orm import Session as OrmSession from cms_backend.db.collection import ( + create_collection, get_collection_by_name, get_collection_by_name_or_none, get_collections, ) from cms_backend.db.exceptions import RecordDoesNotExistError -from cms_backend.db.models import Collection, Title +from cms_backend.db.models import Collection, Title, Warehouse def test_get_collection_by_name_or_none_not_found( @@ -91,6 +92,20 @@ def test_get_collections_pagination( skip=skip, limit=limit, ) - assert results.nb_records == 8 assert len(results.records) <= limit assert len(results.records) == expected_count + + +def test_create_collection(dbsession: OrmSession, warehouse: Warehouse): + """Test creating a collection.""" + collection = create_collection( + dbsession, + name="testcollection", + download_base_url="https://www.example.com", + view_base_url="https://www.example.com", + warehouse_name=warehouse.name, + ) + assert collection.name == "testcollection" + assert collection.download_base_url == "https://www.example.com" + assert collection.view_base_url == "https://www.example.com" + assert collection.warehouse.name == warehouse.name diff --git a/backend/tests/db/test_warehouse.py b/backend/tests/db/test_warehouse.py new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4d72b1d..cd27894 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -25,6 +25,7 @@ onMounted(async () => { const canReadUsers = computed(() => { return authStore.hasPermission('account', 'read') }) + const navigationItems = computed(() => [ { name: 'inbox', @@ -42,6 +43,14 @@ const navigationItems = computed(() => [ disabled: false, show: true, }, + { + name: 'collections', + label: 'Collections', + route: 'collections', + icon: 'mdi-folder-multiple', + disabled: false, + show: true, + }, { name: 'users-list', label: 'Users', diff --git a/frontend/src/components/CollectionFormDialog.vue b/frontend/src/components/CollectionFormDialog.vue new file mode 100644 index 0000000..2c5999a --- /dev/null +++ b/frontend/src/components/CollectionFormDialog.vue @@ -0,0 +1,257 @@ + + + diff --git a/frontend/src/components/CollectionsTable.vue b/frontend/src/components/CollectionsTable.vue new file mode 100644 index 0000000..2bf0e46 --- /dev/null +++ b/frontend/src/components/CollectionsTable.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/frontend/src/components/EditCollectionDialog.vue b/frontend/src/components/EditCollectionDialog.vue new file mode 100644 index 0000000..a7d3774 --- /dev/null +++ b/frontend/src/components/EditCollectionDialog.vue @@ -0,0 +1,30 @@ + + + diff --git a/frontend/src/components/EventsList.vue b/frontend/src/components/EventsList.vue index 19d6772..b20cd8e 100644 --- a/frontend/src/components/EventsList.vue +++ b/frontend/src/components/EventsList.vue @@ -8,15 +8,10 @@ - + {{ event.timestamp }}: {{ event.message }} @@ -89,3 +84,9 @@ const copyToClipboard = async (text: string) => { } } + + diff --git a/frontend/src/components/TitlesFilters.vue b/frontend/src/components/TitlesFilters.vue index ccb0321..a5afebe 100644 --- a/frontend/src/components/TitlesFilters.vue +++ b/frontend/src/components/TitlesFilters.vue @@ -13,6 +13,17 @@ @change="emitFilters" /> + + + @@ -25,6 +36,7 @@ import { ref, watch } from 'vue' interface Props { filters: { name: string + collection_name: string } } @@ -35,6 +47,7 @@ const emit = defineEmits<{ filtersChanged: [ filters: { name: string + collection_name: string }, ] }>() @@ -42,6 +55,7 @@ const emit = defineEmits<{ // Local filters state const localFilters = ref({ name: props.filters.name, + collection_name: props.filters.collection_name, }) // Watch for prop changes and update local state @@ -50,6 +64,7 @@ watch( (newFilters) => { localFilters.value = { name: newFilters.name, + collection_name: newFilters.collection_name, } }, { deep: true }, @@ -59,6 +74,7 @@ watch( function emitFilters() { emit('filtersChanged', { name: localFilters.value.name, + collection_name: localFilters.value.collection_name, }) } diff --git a/frontend/src/components/TitlesTable.vue b/frontend/src/components/TitlesTable.vue index 7ab59bd..9d836f3 100644 --- a/frontend/src/components/TitlesTable.vue +++ b/frontend/src/components/TitlesTable.vue @@ -36,12 +36,14 @@ :items-per-page="selectedLimit" :items-length="paginator.count" :items-per-page-options="limits" - class="elevation-1" + class="elevation-1 cursor-pointer-table" item-value="name" :show-select="showSelection" :model-value="selectedTitles" + hover @update:model-value="handleSelectionChange" @update:options="onUpdateOptions" + @click:row="onRowClick" :hide-default-footer="props.paginator.count === 0" :hide-default-header="props.paginator.count === 0" > @@ -53,11 +55,9 @@