diff --git a/backend/alembic/versions/0022_collections_.py b/backend/alembic/versions/0022_collections_.py index 351780eed..b67a3a0c8 100644 --- a/backend/alembic/versions/0022_collections_.py +++ b/backend/alembic/versions/0022_collections_.py @@ -24,7 +24,8 @@ def upgrade() -> None: sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), sa.Column("name", sa.String(length=400), nullable=False), sa.Column("description", sa.Text(), nullable=True), - sa.Column("logo_path", sa.String(length=1000), nullable=True), + sa.Column("path_cover_l", sa.String(length=1000), nullable=True), + sa.Column("path_cover_s", sa.String(length=1000), nullable=True), sa.Column("roms", sa.JSON(), nullable=False), sa.Column("user_id", sa.Integer(), nullable=False), sa.Column("is_public", sa.Boolean(), nullable=False), diff --git a/backend/endpoints/collections.py b/backend/endpoints/collections.py index 1f0622a63..f6b39531c 100644 --- a/backend/endpoints/collections.py +++ b/backend/endpoints/collections.py @@ -1,11 +1,14 @@ from decorators.auth import protected_route from endpoints.responses import MessageResponse from endpoints.responses.collection import CollectionSchema -from exceptions.endpoint_exceptions import CollectionNotFoundInDatabaseException +from exceptions.endpoint_exceptions import ( + CollectionAlreadyExistsException, + CollectionNotFoundInDatabaseException, +) from fastapi import APIRouter, Request from handler.database import db_collection_handler -from handler.filesystem import fs_collection_handler from logger.logger import log +from models.collection import Collection router = APIRouter() @@ -22,11 +25,18 @@ async def add_collection(request: Request) -> CollectionSchema: """ data = await request.json() - cleaned_data = {} - cleaned_data["name"] = data["name"] - cleaned_data["description"] = data["description"] - cleaned_data["user_id"] = request.user.id - return fs_collection_handler.add_collection(cleaned_data) + cleaned_data = { + "name": data["name"], + "description": data["description"], + "user_id": request.user.id, + } + collection_db = db_collection_handler.get_collection_by_name( + cleaned_data["name"], request.user.id + ) + if collection_db: + raise CollectionAlreadyExistsException(cleaned_data["name"]) + collection = Collection(**cleaned_data) + return db_collection_handler.add_collection(collection) @protected_route(router.get, "/collections", ["collections.read"]) @@ -41,7 +51,7 @@ def get_collections(request: Request) -> list[CollectionSchema]: list[CollectionSchema]: List of collections """ - return db_collection_handler.get_collections() + return db_collection_handler.get_collections(user_id=request.user.id) @protected_route(router.get, "/collections/{id}", ["collections.read"]) diff --git a/backend/endpoints/platform.py b/backend/endpoints/platform.py index 85b018451..b701e66b1 100644 --- a/backend/endpoints/platform.py +++ b/backend/endpoints/platform.py @@ -34,8 +34,7 @@ async def add_platforms(request: Request) -> PlatformSchema: except PlatformAlreadyExistsException: log.info(f"Detected platform: {fs_slug}") scanned_platform = scan_platform(fs_slug, [fs_slug]) - platform = db_platform_handler.add_platform(scanned_platform) - return platform + return db_platform_handler.add_platform(scanned_platform) @protected_route(router.get, "/platforms", ["platforms.read"]) diff --git a/backend/endpoints/responses/collection.py b/backend/endpoints/responses/collection.py index c6d5d8383..d6e8d15cc 100644 --- a/backend/endpoints/responses/collection.py +++ b/backend/endpoints/responses/collection.py @@ -7,8 +7,9 @@ class CollectionSchema(BaseModel): id: int name: str description: str - logo_path: str = "" - roms: set[int] = {} + path_cover_l: str | None + path_cover_s: str | None + roms: set[int] rom_count: int user_id: int user__username: str diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 907afad42..f02e26c9e 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -94,6 +94,7 @@ def add_roms( def get_roms( request: Request, platform_id: int | None = None, + collection_id: int | None = None, search_term: str = "", limit: int | None = None, order_by: str = "name", @@ -108,8 +109,10 @@ def get_roms( Returns: list[RomSchema]: List of roms stored in the database """ + db_roms = db_rom_handler.get_roms( platform_id=platform_id, + collection_id=collection_id, search_term=search_term.lower(), order_by=order_by.lower(), order_dir=order_dir.lower(), diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index 3127d0cf0..c2350aab1 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -24,6 +24,8 @@ "firmware.read": "View firmware", "roms.user.read": "View user-rom properties", "roms.user.write": "Modify user-rom properties", + "collections.read": "View collections", + "collections.write": "Modify collections", } WRITE_SCOPES_MAP: Final = { diff --git a/backend/handler/database/__init__.py b/backend/handler/database/__init__.py index e09b022bc..65816f359 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -1,3 +1,4 @@ +from .collections_handler import DBCollectionsHandler from .firmware_handler import DBFirmwareHandler from .platforms_handler import DBPlatformsHandler from .roms_handler import DBRomsHandler @@ -15,3 +16,4 @@ db_state_handler = DBStatesHandler() db_stats_handler = DBStatsHandler() db_user_handler = DBUsersHandler() +db_collection_handler = DBCollectionsHandler() diff --git a/backend/handler/database/collections_handler.py b/backend/handler/database/collections_handler.py index a8812b05e..1a439aa01 100644 --- a/backend/handler/database/collections_handler.py +++ b/backend/handler/database/collections_handler.py @@ -1,8 +1,7 @@ from decorators.database import begin_session from models.collection import Collection -from models.rom import Rom -from sqlalchemy import Select, delete, or_, select -from sqlalchemy.orm import Query, Session +from sqlalchemy import Select, and_, delete, or_, select +from sqlalchemy.orm import Session from .base_handler import DBBaseHandler @@ -10,23 +9,34 @@ class DBCollectionsHandler(DBBaseHandler): @begin_session def add_collection( - self, data: dict, query: Query = None, session: Session = None + self, collection: Collection, session: Session = None ) -> Collection | None: - collection = session.merge(**data) + collection = session.merge(collection) session.flush() + return session.scalar(select(Collection).filter_by(id=collection.id).limit(1)) - return session.scalar(query.filter_by(id=collection.id).limit(1)) + @begin_session + def get_collection(self, id: int, session: Session = None) -> Collection | None: + return session.scalar(select(Collection).filter_by(id=id).limit(1)) @begin_session - def get_collection( - self, id: int, *, query: Query = None, session: Session = None + def get_collection_by_name( + self, name: str, user_id: int, session: Session = None ) -> Collection | None: - return session.scalar(query.filter_by(id=id).limit(1)) + return session.scalar( + select(Collection).filter_by(name=name, user_id=user_id).limit(1) + ) @begin_session - def get_collections(self, *, session: Session = None) -> Select[tuple[Collection]]: + def get_collections( + self, user_id: int, session: Session = None + ) -> Select[tuple[Collection]]: return ( - session.scalars(select(Collection).order_by(Collection.name.asc())) # type: ignore[attr-defined] + session.scalars( + select(Collection) + .filter_by(user_id=user_id) + .order_by(Collection.name.asc()) + ) # type: ignore[attr-defined] .unique() .all() ) diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 998fa714d..5fa17e399 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -1,6 +1,7 @@ import functools from decorators.database import begin_session +from models.collection import Collection from models.rom import Rom, RomUser from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.orm import Query, Session, selectinload @@ -44,10 +45,26 @@ def wrapper(*args, **kwargs): class DBRomsHandler(DBBaseHandler): - def _filter(self, data, platform_id: int | None, search_term: str): + def _filter( + self, + data, + platform_id: int | None, + collection_id: int | None, + search_term: str, + session: Session, + ): if platform_id: data = data.filter(Rom.platform_id == platform_id) + elif collection_id: + collection = ( + session.query(Collection) + .filter(Collection.id == collection_id) + .one_or_none() + ) + if collection: + data = data.filter(Rom.id.in_(collection.roms)) + if search_term: data = data.filter( or_( @@ -90,6 +107,7 @@ def get_roms( self, *, platform_id: int | None = None, + collection_id: int | None = None, search_term: str = "", order_by: str = "name", order_dir: str = "asc", @@ -97,17 +115,12 @@ def get_roms( query: Query = None, session: Session = None, ) -> list[Rom]: - return ( - session.scalars( - self._order( - self._filter(query, platform_id, search_term), - order_by, - order_dir, - ).limit(limit) - ) - .unique() - .all() + filtered_query = self._filter( + query, platform_id, collection_id, search_term, session ) + ordered_query = self._order(filtered_query, order_by, order_dir) + limited_query = ordered_query.limit(limit) + return session.scalars(limited_query).unique().all() @begin_session @with_details diff --git a/backend/main.py b/backend/main.py index 65f89020d..5f006f900 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,6 +8,7 @@ from config import DEV_HOST, DEV_PORT, DISABLE_CSRF_PROTECTION, ROMM_AUTH_SECRET_KEY from endpoints import ( auth, + collections, config, feeds, firmware, @@ -93,6 +94,7 @@ async def lifespan(app: FastAPI): app.include_router(raw.router) app.include_router(screenshots.router) app.include_router(firmware.router) +app.include_router(collections.router) app.mount("/ws", socket_handler.socket_app) diff --git a/backend/models/collection.py b/backend/models/collection.py index bbac97d26..4d46cd3ea 100644 --- a/backend/models/collection.py +++ b/backend/models/collection.py @@ -13,7 +13,9 @@ class Collection(BaseModel): name: Mapped[str] = mapped_column(String(length=400)) description: Mapped[str | None] = mapped_column(Text) - logo_path: Mapped[str | None] = mapped_column(String(length=1000), default="") + + path_cover_l: Mapped[str | None] = mapped_column(Text, default="") + path_cover_s: Mapped[str | None] = mapped_column(Text, default="") roms: Mapped[set[int]] = mapped_column( JSON, default=[], doc="Rom id's that belong to this collection" diff --git a/backend/models/user.py b/backend/models/user.py index c86c2d80d..c1d80f34f 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -11,6 +11,7 @@ if TYPE_CHECKING: from models.assets import Save, Screenshot, State + from models.collection import Collection from models.rom import RomUser @@ -40,6 +41,7 @@ class User(BaseModel, SimpleUser): states: Mapped[list[State]] = relationship(back_populates="user") screenshots: Mapped[list[Screenshot]] = relationship(back_populates="user") rom_users: Mapped[list[RomUser]] = relationship(back_populates="user") + collections: Mapped[list[Collection]] = relationship(back_populates="user") @property def oauth_scopes(self): diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 37f066c7f..5ea59a9cd 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -13,6 +13,7 @@ export type { Body_add_states_states_post } from "./models/Body_add_states_state export type { Body_token_token_post } from "./models/Body_token_token_post"; export type { Body_update_rom_roms__id__put } from "./models/Body_update_rom_roms__id__put"; export type { Body_update_user_users__id__put } from "./models/Body_update_user_users__id__put"; +export type { CollectionSchema } from "./models/CollectionSchema"; export type { ConfigResponse } from "./models/ConfigResponse"; export type { DetailedRomSchema } from "./models/DetailedRomSchema"; export type { FirmwareSchema } from "./models/FirmwareSchema"; diff --git a/frontend/src/__generated__/models/CollectionSchema.ts b/frontend/src/__generated__/models/CollectionSchema.ts new file mode 100644 index 000000000..481255ea9 --- /dev/null +++ b/frontend/src/__generated__/models/CollectionSchema.ts @@ -0,0 +1,19 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type CollectionSchema = { + id: number; + name: string; + description: string; + path_cover_l: string | null; + path_cover_s: string | null; + roms: Array; + rom_count: number; + user_id: number; + user__username: string; + is_public: boolean; + created_at: string; + updated_at: string; +}; diff --git a/frontend/src/components/common/Collection/Card.vue b/frontend/src/components/common/Collection/Card.vue new file mode 100644 index 000000000..23be015c9 --- /dev/null +++ b/frontend/src/components/common/Collection/Card.vue @@ -0,0 +1,40 @@ + + + diff --git a/frontend/src/components/common/Collection/Cover.vue b/frontend/src/components/common/Collection/Cover.vue new file mode 100644 index 000000000..00b946c99 --- /dev/null +++ b/frontend/src/components/common/Collection/Cover.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/common/Collection/Dialog/CreateCollection.vue b/frontend/src/components/common/Collection/Dialog/CreateCollection.vue new file mode 100644 index 000000000..fe8bb2a55 --- /dev/null +++ b/frontend/src/components/common/Collection/Dialog/CreateCollection.vue @@ -0,0 +1,188 @@ + + + diff --git a/frontend/src/components/common/Collection/Dialog/DeleteCollection.vue b/frontend/src/components/common/Collection/Dialog/DeleteCollection.vue new file mode 100644 index 000000000..0e60798b9 --- /dev/null +++ b/frontend/src/components/common/Collection/Dialog/DeleteCollection.vue @@ -0,0 +1,84 @@ + + diff --git a/frontend/src/components/common/Collection/ListItem.vue b/frontend/src/components/common/Collection/ListItem.vue new file mode 100644 index 000000000..384159ce2 --- /dev/null +++ b/frontend/src/components/common/Collection/ListItem.vue @@ -0,0 +1,61 @@ + + + + + diff --git a/frontend/src/components/common/EmptyCollection.vue b/frontend/src/components/common/EmptyCollection.vue new file mode 100644 index 000000000..5185b2bb6 --- /dev/null +++ b/frontend/src/components/common/EmptyCollection.vue @@ -0,0 +1,8 @@ + diff --git a/frontend/src/components/common/Navigation/CollectionsDrawer.vue b/frontend/src/components/common/Navigation/CollectionsDrawer.vue index bf3baed36..cd5ed5bef 100644 --- a/frontend/src/components/common/Navigation/CollectionsDrawer.vue +++ b/frontend/src/components/common/Navigation/CollectionsDrawer.vue @@ -1,7 +1,11 @@