diff --git a/backend/alembic/versions/0021_rom_user.py b/backend/alembic/versions/0021_rom_user.py new file mode 100644 index 000000000..6b3994623 --- /dev/null +++ b/backend/alembic/versions/0021_rom_user.py @@ -0,0 +1,97 @@ +"""empty message + +Revision ID: 0021_rom_user +Revises: 0020_created_and_updated +Create Date: 2024-06-29 00:11:51.800988 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0021_rom_user" +down_revision = "0020_created_and_updated" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "rom_user", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("note_raw_markdown", sa.Text(), nullable=False), + sa.Column("note_is_public", sa.Boolean(), nullable=True), + sa.Column("is_main_sibling", sa.Boolean(), nullable=False), + sa.Column("rom_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["rom_id"], ["roms.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("rom_id", "user_id", name="unique_rom_user_props"), + ) + + op.execute( + """ + INSERT INTO rom_user (id, updated_at, note_raw_markdown, note_is_public, is_main_sibling, rom_id, user_id) + SELECT id, updated_at, raw_markdown, is_public, FALSE, rom_id, user_id + FROM rom_notes + """ + ) + + op.drop_table("rom_notes") + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "rom_notes", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("raw_markdown", sa.Text(), nullable=False), + sa.Column("is_public", sa.Boolean(), nullable=True), + sa.Column("rom_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["rom_id"], ["roms.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("rom_id", "user_id", name="unique_rom_user_note"), + ) + + # Copy the data back from the new table to the old table + op.execute( + """ + INSERT INTO rom_notes (id, updated_at, raw_markdown, is_public, rom_id, user_id) + SELECT id, updated_at, note_raw_markdown, note_is_public, rom_id, user_id + FROM rom_user + """ + ) + + # Drop the new table + op.drop_table("rom_user") + # ### end Alembic commands ### diff --git a/backend/endpoints/feeds.py b/backend/endpoints/feeds.py index b6e410488..be5d7dc48 100644 --- a/backend/endpoints/feeds.py +++ b/backend/endpoints/feeds.py @@ -30,40 +30,37 @@ def platforms_webrcade_feed(request: Request) -> WebrcadeFeedSchema: platforms = db_platform_handler.get_platforms() - with db_platform_handler.session.begin() as session: - return { - "title": "RomM Feed", - "longTitle": "Custom RomM Feed", - "description": "Custom feed from your RomM library", - "thumbnail": "https://raw.githubusercontent.com/rommapp/romm/f2dd425d87ad8e21bf47f8258ae5dcf90f56fbc2/frontend/assets/isotipo.svg", - "background": "https://raw.githubusercontent.com/rommapp/romm/release/.github/screenshots/gallery.png", - "categories": [ - { - "title": p.name, - "longTitle": f"{p.name} Games", - "background": f"{ROMM_HOST}/assets/webrcade/feed/{p.slug.lower()}-background.png", - "thumbnail": f"{ROMM_HOST}/assets/webrcade/feed/{p.slug.lower()}-thumb.png", - "description": "", - "items": [ - { - "title": rom.name, - "description": rom.summary, - "type": WEBRCADE_SLUG_TO_TYPE_MAP.get(p.slug, p.slug), - "thumbnail": f"{ROMM_HOST}/assets/romm/resources/{rom.path_cover_s}", - "background": f"{ROMM_HOST}/assets/romm/resources/{rom.path_cover_l}", - "props": { - "rom": f"{ROMM_HOST}/api/roms/{rom.id}/content/{rom.file_name}" - }, - } - for rom in session.scalars( - db_rom_handler.get_roms(platform_id=p.id) - ).all() - ], - } - for p in platforms - if p.slug in WEBRCADE_SUPPORTED_PLATFORM_SLUGS - ], - } + return { + "title": "RomM Feed", + "longTitle": "Custom RomM Feed", + "description": "Custom feed from your RomM library", + "thumbnail": "https://raw.githubusercontent.com/rommapp/romm/f2dd425d87ad8e21bf47f8258ae5dcf90f56fbc2/frontend/assets/isotipo.svg", + "background": "https://raw.githubusercontent.com/rommapp/romm/release/.github/screenshots/gallery.png", + "categories": [ + { + "title": p.name, + "longTitle": f"{p.name} Games", + "background": f"{ROMM_HOST}/assets/webrcade/feed/{p.slug.lower()}-background.png", + "thumbnail": f"{ROMM_HOST}/assets/webrcade/feed/{p.slug.lower()}-thumb.png", + "description": "", + "items": [ + { + "title": rom.name, + "description": rom.summary, + "type": WEBRCADE_SLUG_TO_TYPE_MAP.get(p.slug, p.slug), + "thumbnail": f"{ROMM_HOST}/assets/romm/resources/{rom.path_cover_s}", + "background": f"{ROMM_HOST}/assets/romm/resources/{rom.path_cover_l}", + "props": { + "rom": f"{ROMM_HOST}/api/roms/{rom.id}/content/{rom.file_name}" + }, + } + for rom in db_rom_handler.get_roms(platform_id=p.id) + ], + } + for p in platforms + if p.slug in WEBRCADE_SUPPORTED_PLATFORM_SLUGS + ], + } @protected_route(router.get, "/tinfoil/feed", ["roms.read"]) @@ -79,19 +76,16 @@ def tinfoil_index_feed(request: Request, slug: str = "switch") -> TinfoilFeedSch TinfoilFeedSchema: Tinfoil feed object schema """ switch = db_platform_handler.get_platform_by_fs_slug(slug) - with db_rom_handler.session.begin() as session: - files: list[Rom] = session.scalars( - db_rom_handler.get_roms(platform_id=switch.id) - ).all() + files: list[Rom] = db_rom_handler.get_roms(platform_id=switch.id) - return { - "files": [ - { - "url": f"{ROMM_HOST}/api/roms/{file.id}/content/{file.file_name}", - "size": file.file_size_bytes, - } - for file in files - ], - "directories": [], - "success": "RomM Switch Library", - } + return { + "files": [ + { + "url": f"{ROMM_HOST}/api/roms/{file.id}/content/{file.file_name}", + "size": file.file_size_bytes, + } + for file in files + ], + "directories": [], + "success": "RomM Switch Library", + } diff --git a/backend/endpoints/platform.py b/backend/endpoints/platform.py index 695052cd6..85b018451 100644 --- a/backend/endpoints/platform.py +++ b/backend/endpoints/platform.py @@ -3,8 +3,9 @@ from datetime import datetime from decorators.auth import protected_route from endpoints.responses import MessageResponse from endpoints.responses.platform import PlatformSchema +from exceptions.endpoint_exceptions import PlatformNotFoundInDatabaseException from exceptions.fs_exceptions import PlatformAlreadyExistsException -from fastapi import APIRouter, HTTPException, Request, status +from fastapi import APIRouter, Request from handler.database import db_platform_handler from handler.filesystem import fs_platform_handler from handler.metadata.igdb_handler import IGDB_PLATFORM_LIST @@ -100,7 +101,12 @@ def get_platform(request: Request, id: int) -> PlatformSchema: PlatformSchema: Platform """ - return db_platform_handler.get_platform(id) + platform = db_platform_handler.get_platform(id) + + if not platform: + raise PlatformNotFoundInDatabaseException(id) + + return platform @protected_route(router.put, "/platforms/{id}", ["platforms.write"]) @@ -135,10 +141,9 @@ async def delete_platforms(request: Request, id: int) -> MessageResponse: """ platform = db_platform_handler.get_platform(id) + if not platform: - error = f"Platform id {id} not found" - log.error(error) - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) + raise PlatformNotFoundInDatabaseException(id) log.info(f"Deleting {platform.name} [{platform.fs_slug}] from database") db_platform_handler.delete_platform(id) diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 984ef42ea..b0122473a 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -28,25 +28,39 @@ RomMobyMetadata = TypedDict( # type: ignore[misc] ) -class RomNoteSchema(BaseModel): +class RomUserSchema(BaseModel): id: int user_id: int rom_id: int + created_at: datetime updated_at: datetime - raw_markdown: str - is_public: bool + note_raw_markdown: str + note_is_public: bool + is_main_sibling: bool user__username: str class Config: from_attributes = True @classmethod - def for_user(cls, db_rom: Rom, user_id: int) -> list[RomNoteSchema]: + def for_user(cls, db_rom: Rom, user_id: int) -> RomUserSchema | None: + for n in db_rom.rom_users: + if n.user_id == user_id: + return cls.model_validate(n) + + return None + + @classmethod + def notes_for_user(cls, db_rom: Rom, user_id: int) -> list[UserNotesSchema]: return [ - cls.model_validate(n) - for n in db_rom.notes + { + "user_id": n.user_id, + "username": n.user__username, + "note_raw_markdown": n.note_raw_markdown, + } + for n in db_rom.rom_users # This is what filters out private notes - if n.user_id == user_id or n.is_public + if n.user_id == user_id or n.note_is_public ] @@ -95,13 +109,29 @@ class RomSchema(BaseModel): multi: bool files: list[str] full_path: str - created_at: datetime updated_at: datetime + rom_user: RomUserSchema | None = Field(default=None) + class Config: from_attributes = True + @classmethod + def from_orm_with_request(cls, db_rom: Rom, request: Request) -> RomSchema: + rom = cls.model_validate(db_rom) + user_id = request.user.id + + rom.rom_user = RomUserSchema.for_user(db_rom, user_id) + + return rom + + @classmethod + def from_orm_with_request_list( + cls, db_roms: list[Rom], request: Request + ) -> list[RomSchema]: + return [cls.from_orm_with_request(rom, request) for rom in db_roms] + @computed_field # type: ignore @property def sort_comparator(self) -> str: @@ -117,17 +147,20 @@ class RomSchema(BaseModel): class DetailedRomSchema(RomSchema): merged_screenshots: list[str] + rom_user: RomUserSchema | None = Field(default=None) sibling_roms: list[RomSchema] = Field(default_factory=list) user_saves: list[SaveSchema] = Field(default_factory=list) user_states: list[StateSchema] = Field(default_factory=list) user_screenshots: list[ScreenshotSchema] = Field(default_factory=list) - user_notes: list[RomNoteSchema] = Field(default_factory=list) + user_notes: list[UserNotesSchema] = Field(default_factory=list) @classmethod def from_orm_with_request(cls, db_rom: Rom, request: Request) -> DetailedRomSchema: rom = cls.model_validate(db_rom) user_id = request.user.id + rom.rom_user = RomUserSchema.for_user(db_rom, user_id) + rom.user_notes = RomUserSchema.notes_for_user(db_rom, user_id) rom.sibling_roms = [ RomSchema.model_validate(r) for r in db_rom.get_sibling_roms() ] @@ -142,11 +175,16 @@ class DetailedRomSchema(RomSchema): for s in db_rom.screenshots if s.user_id == user_id ] - rom.user_notes = RomNoteSchema.for_user(db_rom, user_id) return rom +class UserNotesSchema(TypedDict): + user_id: int + username: str + note_raw_markdown: str + + class AddRomsResponse(TypedDict): uploaded_roms: list[str] skipped_roms: list[str] diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index e26913f49..907afad42 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -16,9 +16,10 @@ from endpoints.responses.rom import ( AddRomsResponse, CustomStreamingResponse, DetailedRomSchema, - RomNoteSchema, RomSchema, + RomUserSchema, ) +from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException from exceptions.fs_exceptions import RomAlreadyExistsException from fastapi import APIRouter, File, HTTPException, Query, Request, UploadFile, status from fastapi.responses import FileResponse @@ -107,16 +108,15 @@ def get_roms( Returns: list[RomSchema]: List of roms stored in the database """ + db_roms = db_rom_handler.get_roms( + platform_id=platform_id, + search_term=search_term.lower(), + order_by=order_by.lower(), + order_dir=order_dir.lower(), + limit=limit, + ) - with db_rom_handler.session.begin() as session: - return session.scalars( - db_rom_handler.get_roms( - platform_id=platform_id, - search_term=search_term.lower(), - order_by=order_by.lower(), - order_dir=order_dir.lower(), - ).limit(limit) - ).all() + return RomSchema.from_orm_with_request_list(db_roms, request) @protected_route( @@ -134,7 +134,13 @@ def get_rom(request: Request, id: int) -> DetailedRomSchema: Returns: DetailedRomSchema: Rom stored in the database """ - return DetailedRomSchema.from_orm_with_request(db_rom_handler.get_rom(id), request) + + rom = db_rom_handler.get_rom(id) + + if not rom: + raise RomNotFoundInDatabaseException(id) + + return DetailedRomSchema.from_orm_with_request(rom, request) @protected_route( @@ -155,6 +161,10 @@ def head_rom_content(request: Request, id: int, file_name: str): """ rom = db_rom_handler.get_rom(id) + + if not rom: + raise RomNotFoundInDatabaseException(id) + rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}" return FileResponse( @@ -190,6 +200,10 @@ def get_rom_content( """ rom = db_rom_handler.get_rom(id) + + if not rom: + raise RomNotFoundInDatabaseException(id) + rom_path = f"{LIBRARY_BASE_PATH}/{rom.full_path}" files_to_download = files or rom.files @@ -273,7 +287,10 @@ async def update_rom( data = await request.form() - db_rom = db_rom_handler.get_rom(id) + rom = db_rom_handler.get_rom(id) + + if not rom: + raise RomNotFoundInDatabaseException(id) cleaned_data = {} cleaned_data["igdb_id"] = data.get("igdb_id", None) @@ -291,25 +308,23 @@ async def update_rom( else: cleaned_data.update({"igdb_metadata": {}}) - cleaned_data["name"] = data.get("name", db_rom.name) - cleaned_data["summary"] = data.get("summary", db_rom.summary) + cleaned_data["name"] = data.get("name", rom.name) + cleaned_data["summary"] = data.get("summary", rom.summary) - fs_safe_file_name = ( - data.get("file_name", db_rom.file_name).strip().replace("/", "-") - ) + fs_safe_file_name = data.get("file_name", rom.file_name).strip().replace("/", "-") fs_safe_name = cleaned_data["name"].strip().replace("/", "-") if rename_as_source: - fs_safe_file_name = db_rom.file_name.replace( - db_rom.file_name_no_tags or db_rom.file_name_no_ext, fs_safe_name + fs_safe_file_name = rom.file_name.replace( + rom.file_name_no_tags or rom.file_name_no_ext, fs_safe_name ) try: - if db_rom.file_name != fs_safe_file_name: + if rom.file_name != fs_safe_file_name: fs_rom_handler.rename_file( - old_name=db_rom.file_name, + old_name=rom.file_name, new_name=fs_safe_file_name, - file_path=db_rom.file_path, + file_path=rom.file_path, ) except RomAlreadyExistsException as exc: log.error(str(exc)) @@ -326,7 +341,7 @@ async def update_rom( ) if remove_cover: - cleaned_data.update(fs_resource_handler.remove_cover(rom=db_rom)) + cleaned_data.update(fs_resource_handler.remove_cover(rom=rom)) cleaned_data.update({"url_cover": ""}) else: if artwork is not None: @@ -335,7 +350,7 @@ async def update_rom( path_cover_l, path_cover_s, artwork_path, - ) = fs_resource_handler.build_artwork_path(db_rom, file_ext) + ) = fs_resource_handler.build_artwork_path(rom, file_ext) cleaned_data["path_cover_l"] = path_cover_l cleaned_data["path_cover_s"] = path_cover_s @@ -350,22 +365,19 @@ async def update_rom( with open(file_location_l, "wb+") as artwork_l: artwork_l.write(artwork_file) else: - cleaned_data["url_cover"] = data.get("url_cover", db_rom.url_cover) + cleaned_data["url_cover"] = data.get("url_cover", rom.url_cover) path_cover_s, path_cover_l = fs_resource_handler.get_rom_cover( overwrite=True, - rom=db_rom, + rom=rom, url_cover=cleaned_data.get("url_cover", ""), ) cleaned_data.update( {"path_cover_s": path_cover_s, "path_cover_l": path_cover_l} ) - if ( - cleaned_data["igdb_id"] != db_rom.igdb_id - or cleaned_data["moby_id"] != db_rom.moby_id - ): + if cleaned_data["igdb_id"] != rom.igdb_id or cleaned_data["moby_id"] != rom.moby_id: path_screenshots = fs_resource_handler.get_rom_screenshots( - rom=db_rom, + rom=rom, url_screenshots=cleaned_data.get("url_screenshots", []), ) cleaned_data.update({"path_screenshots": path_screenshots}) @@ -398,10 +410,9 @@ async def delete_roms( for id in roms_ids: rom = db_rom_handler.get_rom(id) + if not rom: - error = f"Rom with id {id} not found" - log.error(error) - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=error) + raise RomNotFoundInDatabaseException(id) log.info(f"Deleting {rom.file_name} from database") db_rom_handler.delete_rom(id) @@ -427,21 +438,28 @@ async def delete_roms( return {"msg": f"{len(roms_ids)} roms deleted successfully!"} -@protected_route(router.put, "/roms/{id}/note", ["notes.write"]) -async def update_rom_note(request: Request, id: int) -> RomNoteSchema: - db_note = db_rom_handler.get_rom_note(id, request.user.id) - if not db_note: - db_note = db_rom_handler.add_rom_note(id, request.user.id) - +@protected_route(router.put, "/roms/{id}/props", ["roms.user.write"]) +async def update_rom_user(request: Request, id: int) -> RomUserSchema: data = await request.json() - db_rom_handler.update_rom_note( - db_note.id, - { - "updated_at": datetime.now(), - "raw_markdown": data.get("raw_markdown", db_note.raw_markdown), - "is_public": data.get("is_public", db_note.is_public), - }, + + rom = db_rom_handler.get_rom(id) + + if not rom: + raise RomNotFoundInDatabaseException(id) + + db_rom_user = db_rom_handler.get_rom_user( + id, request.user.id + ) or db_rom_handler.add_rom_user(id, request.user.id) + + cleaned_data = {} + cleaned_data["note_raw_markdown"] = data.get( + "note_raw_markdown", db_rom_user.note_raw_markdown + ) + cleaned_data["note_is_public"] = data.get( + "note_is_public", db_rom_user.note_is_public + ) + cleaned_data["is_main_sibling"] = data.get( + "is_main_sibling", db_rom_user.is_main_sibling ) - db_note = db_rom_handler.get_rom_note(id, request.user.id) - return db_note + return db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data) diff --git a/backend/endpoints/sockets/scan.py b/backend/endpoints/sockets/scan.py index 5eb98477b..b485c63d4 100644 --- a/backend/endpoints/sockets/scan.py +++ b/backend/endpoints/sockets/scan.py @@ -29,7 +29,7 @@ from rq import Worker from rq.job import Job from sqlalchemy.inspection import inspect -EXCLUDED_FROM_DUMP: Final = {"created_at", "updated_at"} +EXCLUDED_FROM_DUMP: Final = {"created_at", "updated_at", "rom_user"} class ScanStats: diff --git a/backend/exceptions/endpoint_exceptions.py b/backend/exceptions/endpoint_exceptions.py new file mode 100644 index 000000000..0de3d09ba --- /dev/null +++ b/backend/exceptions/endpoint_exceptions.py @@ -0,0 +1,24 @@ +from fastapi import HTTPException, status +from logger.logger import log + + +class PlatformNotFoundInDatabaseException(Exception): + def __init__(self, id): + self.message = f"Platform with id '{id}' not found" + super().__init__(self.message) + log.critical(self.message) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=self.message) + + def __repr__(self) -> str: + return self.message + + +class RomNotFoundInDatabaseException(Exception): + def __init__(self, id): + self.message = f"Rom with id '{id}' not found" + super().__init__(self.message) + log.critical(self.message) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=self.message) + + def __repr__(self) -> str: + return self.message diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index b78c4ce34..3127d0cf0 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -22,8 +22,8 @@ DEFAULT_SCOPES_MAP: Final = { "assets.read": "View assets", "assets.write": "Modify assets", "firmware.read": "View firmware", - "notes.read": "View notes", - "notes.write": "Modify notes", + "roms.user.read": "View user-rom properties", + "roms.user.write": "Modify user-rom properties", } WRITE_SCOPES_MAP: Final = { diff --git a/backend/handler/database/roms_handler.py b/backend/handler/database/roms_handler.py index 06aa4cde4..998fa714d 100644 --- a/backend/handler/database/roms_handler.py +++ b/backend/handler/database/roms_handler.py @@ -1,35 +1,52 @@ import functools from decorators.database import begin_session -from models.rom import Rom, RomNote -from sqlalchemy import Select, and_, delete, func, or_, select, update +from models.rom import Rom, RomUser +from sqlalchemy import and_, delete, func, or_, select, update from sqlalchemy.orm import Query, Session, selectinload from .base_handler import DBBaseHandler -def with_assets(func): +def with_details(func): @functools.wraps(func) def wrapper(*args, **kwargs): session = kwargs.get("session") if session is None: - raise ValueError("session is required") + raise TypeError( + f"{func} is missing required kwarg 'session' with type 'Session'" + ) kwargs["query"] = select(Rom).options( selectinload(Rom.saves), selectinload(Rom.states), selectinload(Rom.screenshots), - selectinload(Rom.notes), + selectinload(Rom.rom_users), ) return func(*args, **kwargs) return wrapper +def with_simple(func): + @functools.wraps(func) + def wrapper(*args, **kwargs): + session = kwargs.get("session") + if session is None: + raise TypeError( + f"{func} is missing required kwarg 'session' with type 'Session'" + ) + + kwargs["query"] = select(Rom).options(selectinload(Rom.rom_users)) + return func(*args, **kwargs) + + return wrapper + + class DBRomsHandler(DBBaseHandler): def _filter(self, data, platform_id: int | None, search_term: str): if platform_id: - data = data.filter_by(platform_id=platform_id) + data = data.filter(Rom.platform_id == platform_id) if search_term: data = data.filter( @@ -53,7 +70,7 @@ class DBRomsHandler(DBBaseHandler): return data.order_by(_column.asc()) @begin_session - @with_assets + @with_details def add_rom(self, rom: Rom, query: Query = None, session: Session = None) -> Rom: rom = session.merge(rom) session.flush() @@ -61,12 +78,14 @@ class DBRomsHandler(DBBaseHandler): return session.scalar(query.filter_by(id=rom.id).limit(1)) @begin_session - @with_assets + @with_details def get_rom( self, id: int, *, query: Query = None, session: Session = None ) -> Rom | None: return session.scalar(query.filter_by(id=id).limit(1)) + @begin_session + @with_simple def get_roms( self, *, @@ -74,15 +93,24 @@ class DBRomsHandler(DBBaseHandler): search_term: str = "", order_by: str = "name", order_dir: str = "asc", - ) -> Select[tuple[Rom]]: - return self._order( - self._filter(select(Rom), platform_id, search_term), - order_by, - order_dir, + limit: int | None = None, + 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() ) @begin_session - @with_assets + @with_details def get_rom_by_filename( self, platform_id: int, @@ -95,7 +123,7 @@ class DBRomsHandler(DBBaseHandler): ) @begin_session - @with_assets + @with_details def get_rom_by_filename_no_tags( self, file_name_no_tags: str, query: Query = None, session: Session = None ) -> Rom | None: @@ -104,7 +132,7 @@ class DBRomsHandler(DBBaseHandler): ) @begin_session - @with_assets + @with_details def get_rom_by_filename_no_ext( self, file_name_no_ext: str, query: Query = None, session: Session = None ) -> Rom | None: @@ -112,6 +140,32 @@ class DBRomsHandler(DBBaseHandler): query.filter_by(file_name_no_ext=file_name_no_ext).limit(1) ) + @begin_session + @with_simple + def get_sibling_roms( + self, rom: Rom, query: Query = None, session: Session = None + ) -> list[Rom]: + return session.scalars( + query.where( + and_( + Rom.platform_id == rom.platform_id, + Rom.id != rom.id, + or_( + and_( + Rom.igdb_id == rom.igdb_id, + Rom.igdb_id.isnot(None), + Rom.igdb_id != "", + ), + and_( + Rom.moby_id == rom.moby_id, + Rom.moby_id.isnot(None), + Rom.moby_id != "", + ), + ), + ) + ) + ).all() + @begin_session def update_rom(self, id: int, data: dict, session: Session = None) -> Rom: return session.execute( @@ -140,24 +194,46 @@ class DBRomsHandler(DBBaseHandler): ) @begin_session - def get_rom_note( + def add_rom_user( self, rom_id: int, user_id: int, session: Session = None - ) -> RomNote | None: + ) -> RomUser: + return session.merge(RomUser(rom_id=rom_id, user_id=user_id)) + + @begin_session + def get_rom_user( + self, rom_id: int, user_id: int, session: Session = None + ) -> RomUser | None: return session.scalar( - select(RomNote).filter_by(rom_id=rom_id, user_id=user_id).limit(1) + select(RomUser).filter_by(rom_id=rom_id, user_id=user_id).limit(1) ) @begin_session - def add_rom_note( - self, rom_id: int, user_id: int, session: Session = None - ) -> RomNote: - return session.merge(RomNote(rom_id=rom_id, user_id=user_id)) + def get_rom_user_by_id(self, id: int, session: Session = None) -> RomUser | None: + return session.scalar(select(RomUser).filter_by(id=id).limit(1)) @begin_session - def update_rom_note(self, id: int, data: dict, session: Session = None) -> RomNote: - return session.execute( - update(RomNote) - .where(RomNote.id == id) + def update_rom_user(self, id: int, data: dict, session: Session = None) -> RomUser: + session.execute( + update(RomUser) + .where(RomUser.id == id) .values(**data) .execution_options(synchronize_session="evaluate") ) + + rom_user = self.get_rom_user_by_id(id) + + if data["is_main_sibling"]: + session.execute( + update(RomUser) + .where( + and_( + RomUser.rom_id.in_( + [rom.id for rom in rom_user.rom.get_sibling_roms()] + ), + RomUser.user_id == rom_user.user_id, + ) + ) + .values(is_main_sibling=False) + ) + + return self.get_rom_user_by_id(id) diff --git a/backend/handler/database/users_handler.py b/backend/handler/database/users_handler.py index 76dcd98d7..f0a6eafa4 100644 --- a/backend/handler/database/users_handler.py +++ b/backend/handler/database/users_handler.py @@ -29,7 +29,7 @@ class DBUsersHandler(DBBaseHandler): ) @begin_session - def get_users(self, session: Session = None): + def get_users(self, session: Session = None) -> list[User]: return session.scalars(select(User)).all() @begin_session @@ -41,5 +41,5 @@ class DBUsersHandler(DBBaseHandler): ) @begin_session - def get_admin_users(self, session: Session = None): + def get_admin_users(self, session: Session = None) -> list[User]: return session.scalars(select(User).filter_by(role=Role.ADMIN)).all() diff --git a/backend/handler/tests/test_db_handler.py b/backend/handler/tests/test_db_handler.py index 26521b54f..6e0f589d0 100644 --- a/backend/handler/tests/test_db_handler.py +++ b/backend/handler/tests/test_db_handler.py @@ -46,9 +46,8 @@ def test_roms(rom: Rom, platform: Platform): ) ) - with db_rom_handler.session.begin() as session: - roms = session.scalars(db_rom_handler.get_roms(platform_id=platform.id)).all() - assert len(roms) == 2 + roms = db_rom_handler.get_roms(platform_id=platform.id) + assert len(roms) == 2 rom = db_rom_handler.get_rom(roms[0].id) assert rom is not None @@ -61,26 +60,23 @@ def test_roms(rom: Rom, platform: Platform): db_rom_handler.delete_rom(rom.id) - with db_rom_handler.session.begin() as session: - roms = session.scalars(db_rom_handler.get_roms(platform_id=platform.id)).all() - assert len(roms) == 1 + roms = db_rom_handler.get_roms(platform_id=platform.id) + assert len(roms) == 1 db_rom_handler.purge_roms(rom_2.platform_id, [rom_2.id]) - with db_rom_handler.session.begin() as session: - roms = session.scalars(db_rom_handler.get_roms(platform_id=platform.id)).all() - assert len(roms) == 0 + roms = db_rom_handler.get_roms(platform_id=platform.id) + assert len(roms) == 0 def test_utils(rom: Rom, platform: Platform): - with db_rom_handler.session.begin() as session: - roms = session.scalars(db_rom_handler.get_roms(platform_id=platform.id)).all() - assert ( - db_rom_handler.get_rom_by_filename( - platform_id=platform.id, file_name=rom.file_name - ).id - == roms[0].id - ) + roms = db_rom_handler.get_roms(platform_id=platform.id) + assert ( + db_rom_handler.get_rom_by_filename( + platform_id=platform.id, file_name=rom.file_name + ).id + == roms[0].id + ) def test_users(admin_user): diff --git a/backend/models/rom.py b/backend/models/rom.py index 61ab55f1d..5500af70a 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -1,24 +1,11 @@ from __future__ import annotations -from datetime import datetime from functools import cached_property from typing import TYPE_CHECKING, Any from config import FRONTEND_RESOURCES_PATH from models.base import BaseModel -from sqlalchemy import ( - JSON, - BigInteger, - DateTime, - ForeignKey, - String, - Text, - UniqueConstraint, - and_, - func, - or_, - select, -) +from sqlalchemy import JSON, BigInteger, ForeignKey, String, Text, UniqueConstraint from sqlalchemy.dialects.mysql.json import JSON as MySQLJSON from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -82,7 +69,7 @@ class Rom(BaseModel): saves: Mapped[list[Save]] = relationship(back_populates="rom") states: Mapped[list[State]] = relationship(back_populates="rom") screenshots: Mapped[list[Screenshot]] = relationship(back_populates="rom") - notes: Mapped[list[RomNote]] = relationship(back_populates="rom") + rom_users: Mapped[list[RomUser]] = relationship(back_populates="rom") @property def platform_slug(self) -> str: @@ -114,27 +101,7 @@ class Rom(BaseModel): def get_sibling_roms(self) -> list[Rom]: from handler.database import db_rom_handler - with db_rom_handler.session.begin() as session: - return session.scalars( - select(Rom).where( - and_( - Rom.platform_id == self.platform_id, - Rom.id != self.id, - or_( - and_( - Rom.igdb_id == self.igdb_id, - Rom.igdb_id is not None, - Rom.igdb_id != "", - ), - and_( - Rom.moby_id == self.moby_id, - Rom.moby_id is not None, - Rom.moby_id != "", - ), - ), - ) - ) - ).all() + return db_rom_handler.get_sibling_roms(self) # Metadata fields @property @@ -181,24 +148,24 @@ class Rom(BaseModel): return self.file_name -class RomNote(BaseModel): - __tablename__ = "rom_notes" +class RomUser(BaseModel): + __tablename__ = "rom_user" __table_args__ = ( - UniqueConstraint("rom_id", "user_id", name="unique_rom_user_note"), + UniqueConstraint("rom_id", "user_id", name="unique_rom_user_props"), ) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - raw_markdown: Mapped[str] = mapped_column(Text, default="") - is_public: Mapped[bool | None] = mapped_column(default=False) + + note_raw_markdown: Mapped[str] = mapped_column(Text, default="") + note_is_public: Mapped[bool | None] = mapped_column(default=False) + + is_main_sibling: Mapped[bool | None] = mapped_column(default=False) rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) - rom: Mapped[Rom] = relationship(lazy="joined", back_populates="notes") - user: Mapped[User] = relationship(lazy="joined", back_populates="notes") + rom: Mapped[Rom] = relationship(lazy="joined", back_populates="rom_users") + user: Mapped[User] = relationship(lazy="joined", back_populates="rom_users") @property def user__username(self) -> str: diff --git a/backend/models/user.py b/backend/models/user.py index fbce35319..c86c2d80d 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -11,7 +11,7 @@ from starlette.authentication import SimpleUser if TYPE_CHECKING: from models.assets import Save, Screenshot, State - from models.rom import RomNote + from models.rom import RomUser class Role(enum.Enum): @@ -39,7 +39,7 @@ class User(BaseModel, SimpleUser): saves: Mapped[list[Save]] = relationship(back_populates="user") states: Mapped[list[State]] = relationship(back_populates="user") screenshots: Mapped[list[Screenshot]] = relationship(back_populates="user") - notes: Mapped[list[RomNote]] = relationship(back_populates="user") + rom_users: Mapped[list[RomUser]] = relationship(back_populates="user") @property def oauth_scopes(self): diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index b4c5aafa9..37f066c7f 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -27,8 +27,8 @@ export type { PlatformSchema } from "./models/PlatformSchema"; export type { Role } from "./models/Role"; export type { RomIGDBMetadata } from "./models/RomIGDBMetadata"; export type { RomMobyMetadata } from "./models/RomMobyMetadata"; -export type { RomNoteSchema } from "./models/RomNoteSchema"; export type { RomSchema } from "./models/RomSchema"; +export type { RomUserSchema } from "./models/RomUserSchema"; export type { SaveSchema } from "./models/SaveSchema"; export type { SchedulerDict } from "./models/SchedulerDict"; export type { ScreenshotSchema } from "./models/ScreenshotSchema"; @@ -42,6 +42,7 @@ export type { TokenResponse } from "./models/TokenResponse"; export type { UploadedSavesResponse } from "./models/UploadedSavesResponse"; export type { UploadedScreenshotsResponse } from "./models/UploadedScreenshotsResponse"; export type { UploadedStatesResponse } from "./models/UploadedStatesResponse"; +export type { UserNotesSchema } from "./models/UserNotesSchema"; export type { UserSchema } from "./models/UserSchema"; export type { ValidationError } from "./models/ValidationError"; export type { WatcherDict } from "./models/WatcherDict"; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index 10ad1628c..4df6c7079 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -5,11 +5,12 @@ import type { RomIGDBMetadata } from "./RomIGDBMetadata"; import type { RomMobyMetadata } from "./RomMobyMetadata"; -import type { RomNoteSchema } from "./RomNoteSchema"; import type { RomSchema } from "./RomSchema"; +import type { RomUserSchema } from "./RomUserSchema"; import type { SaveSchema } from "./SaveSchema"; import type { ScreenshotSchema } from "./ScreenshotSchema"; import type { StateSchema } from "./StateSchema"; +import type { UserNotesSchema } from "./UserNotesSchema"; export type DetailedRomSchema = { id: number; @@ -50,11 +51,12 @@ export type DetailedRomSchema = { full_path: string; created_at: string; updated_at: string; + rom_user?: RomUserSchema | null; merged_screenshots: Array; sibling_roms?: Array; user_saves?: Array; user_states?: Array; user_screenshots?: Array; - user_notes?: Array; + user_notes?: Array; readonly sort_comparator: string; }; diff --git a/frontend/src/__generated__/models/RomSchema.ts b/frontend/src/__generated__/models/RomSchema.ts index 80f8b6d3a..9c6687f71 100644 --- a/frontend/src/__generated__/models/RomSchema.ts +++ b/frontend/src/__generated__/models/RomSchema.ts @@ -5,6 +5,7 @@ import type { RomIGDBMetadata } from "./RomIGDBMetadata"; import type { RomMobyMetadata } from "./RomMobyMetadata"; +import type { RomUserSchema } from "./RomUserSchema"; export type RomSchema = { id: number; @@ -45,5 +46,6 @@ export type RomSchema = { full_path: string; created_at: string; updated_at: string; + rom_user?: RomUserSchema | null; readonly sort_comparator: string; }; diff --git a/frontend/src/__generated__/models/RomNoteSchema.ts b/frontend/src/__generated__/models/RomUserSchema.ts similarity index 63% rename from frontend/src/__generated__/models/RomNoteSchema.ts rename to frontend/src/__generated__/models/RomUserSchema.ts index 2c5b4d12e..03948b7ad 100644 --- a/frontend/src/__generated__/models/RomNoteSchema.ts +++ b/frontend/src/__generated__/models/RomUserSchema.ts @@ -3,12 +3,14 @@ /* tslint:disable */ /* eslint-disable */ -export type RomNoteSchema = { +export type RomUserSchema = { id: number; user_id: number; rom_id: number; + created_at: string; updated_at: string; - raw_markdown: string; - is_public: boolean; + note_raw_markdown: string; + note_is_public: boolean; + is_main_sibling: boolean; user__username: string; }; diff --git a/frontend/src/__generated__/models/UserNotesSchema.ts b/frontend/src/__generated__/models/UserNotesSchema.ts new file mode 100644 index 000000000..b9a84681a --- /dev/null +++ b/frontend/src/__generated__/models/UserNotesSchema.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type UserNotesSchema = { + user_id: number; + username: string; + note_raw_markdown: string; +}; diff --git a/frontend/src/components/Details/Info/FileInfo.vue b/frontend/src/components/Details/Info/FileInfo.vue index 1ab1d7aca..ac34f3c49 100644 --- a/frontend/src/components/Details/Info/FileInfo.vue +++ b/frontend/src/components/Details/Info/FileInfo.vue @@ -1,77 +1,155 @@ diff --git a/frontend/src/components/Details/Info/GameInfo.vue b/frontend/src/components/Details/Info/GameInfo.vue index b66121cf9..de5bc6029 100644 --- a/frontend/src/components/Details/Info/GameInfo.vue +++ b/frontend/src/components/Details/Info/GameInfo.vue @@ -10,108 +10,116 @@ const galleryFilter = storeGalleryFilter(); const show = ref(false); + + diff --git a/frontend/src/components/Details/Notes.vue b/frontend/src/components/Details/Notes.vue index 70000b703..2969d2559 100644 --- a/frontend/src/components/Details/Notes.vue +++ b/frontend/src/components/Details/Notes.vue @@ -4,45 +4,65 @@ import storeAuth from "@/stores/auth"; import type { DetailedRom } from "@/stores/roms"; import { MdEditor, MdPreview } from "md-editor-v3"; import "md-editor-v3/lib/style.css"; -import { ref } from "vue"; +import { ref, watch } from "vue"; import { useTheme } from "vuetify"; +// Props const props = defineProps<{ rom: DetailedRom }>(); const auth = storeAuth(); const theme = useTheme(); const editingNote = ref(false); -const ownNote = ref( - props.rom.user_notes?.find((note) => note.user_id === auth.user?.id) ?? { +const romUser = ref( + props.rom.rom_user ?? { id: null, user_id: auth.user?.id, rom_id: props.rom.id, updated_at: new Date(), - raw_markdown: "", - is_public: false, + note_raw_markdown: "", + note_is_public: false, + is_main_sibling: false, } ); const publicNotes = props.rom.user_notes?.filter((note) => note.user_id !== auth.user?.id) ?? []; +// Functions function togglePublic() { - ownNote.value.is_public = !ownNote.value.is_public; - romApi.updateRomNote({ + romUser.value.note_is_public = !romUser.value.note_is_public; + romApi.updateUserRomProps({ romId: props.rom.id, - rawMarkdown: ownNote.value.raw_markdown, - isPublic: ownNote.value.is_public, + noteRawMarkdown: romUser.value.note_raw_markdown, + noteIsPublic: romUser.value.note_is_public, + isMainSibling: romUser.value.is_main_sibling, }); } -function onEditNote() { +function editNote() { if (editingNote.value) { - romApi.updateRomNote({ + romApi.updateUserRomProps({ romId: props.rom.id, - rawMarkdown: ownNote.value.raw_markdown, - isPublic: ownNote.value.is_public, + noteRawMarkdown: romUser.value.note_raw_markdown, + noteIsPublic: romUser.value.note_is_public, + isMainSibling: romUser.value.is_main_sibling, }); } editingNote.value = !editingNote.value; } + +watch( + () => props.rom, + async () => { + romUser.value = props.rom.rom_user ?? { + id: null, + user_id: auth.user?.id, + rom_id: props.rom.id, + updated_at: new Date(), + note_raw_markdown: "", + note_is_public: false, + is_main_sibling: false, + }; + } +);