From 5f27c1cf3930a233d0db681ac2c3754292768bf7 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Thu, 29 Aug 2024 15:56:01 -0400 Subject: [PATCH 01/17] Create new fields on rom_user --- backend/alembic/env.py | 12 ++- .../versions/0026_romuser_status_fields.py | 84 +++++++++++++++++++ backend/endpoints/saves.py | 26 ++++++ backend/endpoints/states.py | 22 +++++ backend/models/rom.py | 23 +++++ 5 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 backend/alembic/versions/0026_romuser_status_fields.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 2bf6bfbc6..8cd4677fe 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -8,7 +8,7 @@ from models.base import BaseModel from models.firmware import Firmware # noqa from models.platform import Platform # noqa -from models.rom import Rom # noqa +from models.rom import Rom, SiblingRom # noqa from models.user import User # noqa from sqlalchemy import create_engine @@ -33,6 +33,14 @@ # ... etc. +# Ignore specific models when running migrations +def include_object(object, name, type_, reflected, compare_to): + if type_ == "table" and name in [SiblingRom.__tablename__]: # Virtual table + return False + + return True + + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. @@ -53,6 +61,7 @@ def run_migrations_offline() -> None: literal_binds=True, dialect_opts={"paramstyle": "named"}, compare_type=True, + include_object=include_object, ) with context.begin_transaction(): @@ -75,6 +84,7 @@ def run_migrations_online() -> None: target_metadata=target_metadata, render_as_batch=True, compare_type=True, + include_object=include_object, ) with context.begin_transaction(): diff --git a/backend/alembic/versions/0026_romuser_status_fields.py b/backend/alembic/versions/0026_romuser_status_fields.py new file mode 100644 index 000000000..42c6ed481 --- /dev/null +++ b/backend/alembic/versions/0026_romuser_status_fields.py @@ -0,0 +1,84 @@ +"""empty message + +Revision ID: 0026_romuser_status_fields +Revises: 0025_roms_hashes +Create Date: 2024-08-29 15:52:56.031850 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision = "0026_romuser_status_fields" +down_revision = "0025_roms_hashes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("collections", schema=None) as batch_op: + batch_op.alter_column( + "path_cover_l", + existing_type=mysql.VARCHAR(length=1000), + type_=sa.Text(), + existing_nullable=True, + ) + batch_op.alter_column( + "path_cover_s", + existing_type=mysql.VARCHAR(length=1000), + type_=sa.Text(), + existing_nullable=True, + ) + + with op.batch_alter_table("rom_user", schema=None) as batch_op: + batch_op.add_column( + sa.Column("last_played", sa.DateTime(timezone=True), nullable=True) + ) + batch_op.add_column(sa.Column("backlogged", sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column("now_playing", sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column("hidden", sa.Boolean(), nullable=False)) + batch_op.add_column(sa.Column("rating", sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column("difficulty", sa.Integer(), nullable=False)) + batch_op.add_column(sa.Column("completion", sa.Integer(), nullable=False)) + batch_op.add_column( + sa.Column( + "status", + sa.Enum( + "INCOMPLETE", + "FINISHED", + "COMPLETED_100", + "RETIRED", + "NEVER_PLAYING", + name="romuserstatus", + ), + nullable=True, + ) + ) + + +def downgrade() -> None: + with op.batch_alter_table("rom_user", schema=None) as batch_op: + batch_op.drop_column("status") + batch_op.drop_column("completion") + batch_op.drop_column("difficulty") + batch_op.drop_column("rating") + batch_op.drop_column("hidden") + batch_op.drop_column("now_playing") + batch_op.drop_column("backlogged") + batch_op.drop_column("last_played") + + with op.batch_alter_table("collections", schema=None) as batch_op: + batch_op.alter_column( + "path_cover_s", + existing_type=sa.Text(), + type_=mysql.VARCHAR(length=1000), + existing_nullable=True, + ) + batch_op.alter_column( + "path_cover_l", + existing_type=sa.Text(), + type_=mysql.VARCHAR(length=1000), + existing_nullable=True, + ) diff --git a/backend/endpoints/saves.py b/backend/endpoints/saves.py index 271a26146..5a588bb17 100644 --- a/backend/endpoints/saves.py +++ b/backend/endpoints/saves.py @@ -1,6 +1,9 @@ +from datetime import datetime + from decorators.auth import protected_route from endpoints.responses import MessageResponse from endpoints.responses.assets import SaveSchema, UploadedSavesResponse +from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException from fastapi import File, HTTPException, Request, UploadFile, status from handler.database import db_rom_handler, db_save_handler, db_screenshot_handler from handler.filesystem import fs_asset_handler @@ -19,6 +22,9 @@ def add_saves( emulator: str | None = None, ) -> UploadedSavesResponse: rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise RomNotFoundInDatabaseException(rom_id) + current_user = request.user log.info(f"Uploading saves to {rom.name}") @@ -57,7 +63,18 @@ def add_saves( scanned_save.emulator = emulator db_save_handler.add_save(scanned_save) + # Set the last played time for the current user + rom_user = db_rom_handler.get_rom_user(rom.id, current_user.id) + if not rom_user: + rom_user = db_rom_handler.add_rom_user(rom.id, current_user.id) + rom_user.last_played = datetime.now() + db_rom_handler.update_rom_user(rom_user.id, {"last_played": datetime.now()}) + + # Refetch the rom to get updated saves rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise RomNotFoundInDatabaseException(rom_id) + return { "uploaded": len(saves), "saves": [s for s in rom.saves if s.user_id == current_user.id], @@ -94,6 +111,15 @@ async def update_save(request: Request, id: int) -> SaveSchema: fs_asset_handler.write_file(file=file, path=db_save.file_path) db_save_handler.update_save(db_save.id, {"file_size_bytes": file.size}) + # Set the last played time for the current user + current_user = request.user + rom_user = db_rom_handler.get_rom_user(db_save.rom_id, current_user.id) + if not rom_user: + rom_user = db_rom_handler.add_rom_user(db_save.rom_id, current_user.id) + rom_user.last_played = datetime.now() + db_rom_handler.update_rom_user(rom_user.id, {"last_played": datetime.now()}) + + # Refetch the save to get updated fields db_save = db_save_handler.get_save(id) return db_save diff --git a/backend/endpoints/states.py b/backend/endpoints/states.py index b4199ae22..256277ea8 100644 --- a/backend/endpoints/states.py +++ b/backend/endpoints/states.py @@ -1,3 +1,5 @@ +from datetime import datetime + from decorators.auth import protected_route from endpoints.responses import MessageResponse from endpoints.responses.assets import StateSchema, UploadedStatesResponse @@ -8,6 +10,8 @@ from logger.logger import log from utils.router import APIRouter +from backend.exceptions.endpoint_exceptions import RomNotFoundInDatabaseException + router = APIRouter() @@ -19,6 +23,9 @@ def add_states( emulator: str | None = None, ) -> UploadedStatesResponse: rom = db_rom_handler.get_rom(rom_id) + if not rom: + raise RomNotFoundInDatabaseException(rom_id) + current_user = request.user log.info(f"Uploading states to {rom.name}") @@ -57,6 +64,13 @@ def add_states( scanned_state.emulator = emulator db_state_handler.add_state(scanned_state) + # Set the last played time for the current user + rom_user = db_rom_handler.get_rom_user(rom.id, current_user.id) + if not rom_user: + rom_user = db_rom_handler.add_rom_user(rom.id, current_user.id) + rom_user.last_played = datetime.now() + db_rom_handler.update_rom_user(rom_user.id, {"last_played": datetime.now()}) + rom = db_rom_handler.get_rom(rom_id) return { "uploaded": len(states), @@ -94,6 +108,14 @@ async def update_state(request: Request, id: int) -> StateSchema: fs_asset_handler.write_file(file=file, path=db_state.file_path) db_state_handler.update_state(db_state.id, {"file_size_bytes": file.size}) + # Set the last played time for the current user + current_user = request.user + rom_user = db_rom_handler.get_rom_user(db_state.rom_id, current_user.id) + if not rom_user: + rom_user = db_rom_handler.add_rom_user(db_state.rom_id, current_user.id) + rom_user.last_played = datetime.now() + db_rom_handler.update_rom_user(rom_user.id, {"last_played": datetime.now()}) + db_state = db_state_handler.get_state(id) return db_state diff --git a/backend/models/rom.py b/backend/models/rom.py index 0f196a251..570b2a01b 100644 --- a/backend/models/rom.py +++ b/backend/models/rom.py @@ -1,5 +1,7 @@ from __future__ import annotations +import enum +from datetime import datetime from functools import cached_property from typing import TYPE_CHECKING, Any, TypedDict @@ -8,6 +10,8 @@ from sqlalchemy import ( JSON, BigInteger, + DateTime, + Enum, ForeignKey, Integer, String, @@ -170,6 +174,14 @@ def __repr__(self) -> str: return self.file_name +class RomUserStatus(enum.Enum): + INCOMPLETE = "incomplete" # Started but not finished + FINISHED = "finished" # Reached the end of the game + COMPLETED_100 = "completed_100" # Completed 100% + RETIRED = "retired" # Won't play again + NEVER_PLAYING = "never_playing" # Will never play + + class RomUser(BaseModel): __tablename__ = "rom_user" __table_args__ = ( @@ -182,6 +194,17 @@ class RomUser(BaseModel): note_is_public: Mapped[bool] = mapped_column(default=False) is_main_sibling: Mapped[bool] = mapped_column(default=False) + last_played: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + backlogged: Mapped[bool] = mapped_column(default=False) + now_playing: Mapped[bool] = mapped_column(default=False) + hidden: Mapped[bool] = mapped_column(default=False) + rating: Mapped[int] = mapped_column(default=0) + difficulty: Mapped[int] = mapped_column(default=0) + completion: Mapped[int] = mapped_column(default=0) + status: Mapped[RomUserStatus | None] = mapped_column( + Enum(RomUserStatus), default=None + ) rom_id: Mapped[int] = mapped_column(ForeignKey("roms.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) From 48ec9f39a1b1031bf6ac599c018d7adef0108171 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Thu, 29 Aug 2024 17:43:07 -0400 Subject: [PATCH 02/17] create ui components and page --- backend/endpoints/responses/rom.py | 39 ++- backend/endpoints/rom.py | 38 ++- backend/endpoints/states.py | 3 +- frontend/src/__generated__/index.ts | 1 + .../__generated__/models/DetailedRomSchema.ts | 2 +- .../src/__generated__/models/RomUserSchema.ts | 9 + .../src/__generated__/models/RomUserStatus.ts | 6 + .../__generated__/models/SimpleRomSchema.ts | 2 +- .../src/components/Details/Info/FileInfo.vue | 55 ++-- frontend/src/components/Details/Notes.vue | 182 ----------- frontend/src/components/Details/Personal.vue | 297 ++++++++++++++++++ frontend/src/services/api/rom.ts | 20 +- frontend/src/views/GameDetails.vue | 12 +- 13 files changed, 419 insertions(+), 247 deletions(-) create mode 100644 frontend/src/__generated__/models/RomUserStatus.ts delete mode 100644 frontend/src/components/Details/Notes.vue create mode 100644 frontend/src/components/Details/Personal.vue diff --git a/backend/endpoints/responses/rom.py b/backend/endpoints/responses/rom.py index 200a9fef8..8c3b952ac 100644 --- a/backend/endpoints/responses/rom.py +++ b/backend/endpoints/responses/rom.py @@ -9,7 +9,7 @@ from fastapi import Request from handler.metadata.igdb_handler import IGDBMetadata from handler.metadata.moby_handler import MobyMetadata -from models.rom import Rom, RomFile +from models.rom import Rom, RomFile, RomUserStatus from pydantic import BaseModel, Field, computed_field SORT_COMPARE_REGEX = re.compile(r"^([Tt]he|[Aa]|[Aa]nd)\s") @@ -26,6 +26,27 @@ ) +def rom_user_schema_factory() -> RomUserSchema: + return RomUserSchema( + id=-1, + user_id=-1, + rom_id=-1, + created_at=datetime.now(), + updated_at=datetime.now(), + note_raw_markdown="", + note_is_public=False, + is_main_sibling=False, + backlogged=False, + now_playing=False, + hidden=False, + rating=0, + difficulty=0, + completion=0, + status=None, + user__username="", + ) + + class RomUserSchema(BaseModel): id: int user_id: int @@ -35,18 +56,26 @@ class RomUserSchema(BaseModel): note_raw_markdown: str note_is_public: bool is_main_sibling: bool + backlogged: bool + now_playing: bool + hidden: bool + rating: int + difficulty: int + completion: int + status: RomUserStatus | None user__username: str class Config: from_attributes = True @classmethod - def for_user(cls, user_id: int, db_rom: Rom) -> RomUserSchema | None: + def for_user(cls, user_id: int, db_rom: Rom) -> RomUserSchema: for n in db_rom.rom_users: if n.user_id == user_id: return cls.model_validate(n) - return None + # Return a dummy RomUserSchema if the user + rom combination doesn't exist + return rom_user_schema_factory() @classmethod def notes_for_user(cls, user_id: int, db_rom: Rom) -> list[UserNotesSchema]: @@ -131,7 +160,7 @@ def sort_comparator(self) -> str: class SimpleRomSchema(RomSchema): sibling_roms: list[RomSchema] = Field(default_factory=list) - rom_user: RomUserSchema | None = Field(default=None) + rom_user: RomUserSchema = Field(default_factory=rom_user_schema_factory) @classmethod def from_orm_with_request(cls, db_rom: Rom, request: Request) -> SimpleRomSchema: @@ -146,7 +175,7 @@ def from_orm_with_request(cls, db_rom: Rom, request: Request) -> SimpleRomSchema class DetailedRomSchema(RomSchema): merged_screenshots: list[str] sibling_roms: list[RomSchema] = Field(default_factory=list) - rom_user: RomUserSchema | None = Field(default=None) + rom_user: RomUserSchema = Field(default_factory=rom_user_schema_factory) user_saves: list[SaveSchema] = Field(default_factory=list) user_states: list[StateSchema] = Field(default_factory=list) user_screenshots: list[ScreenshotSchema] = Field(default_factory=list) diff --git a/backend/endpoints/rom.py b/backend/endpoints/rom.py index 86455e681..8790dc719 100644 --- a/backend/endpoints/rom.py +++ b/backend/endpoints/rom.py @@ -497,12 +497,36 @@ async def update_rom_user(request: Request, id: int) -> RomUserSchema: id, request.user.id ) or db_rom_handler.add_rom_user(id, request.user.id) - cleaned_data = { - "note_raw_markdown": data.get( - "note_raw_markdown", db_rom_user.note_raw_markdown - ), - "note_is_public": data.get("note_is_public", db_rom_user.note_is_public), - "is_main_sibling": data.get("is_main_sibling", db_rom_user.is_main_sibling), - } + cleaned_data = {} + + if data.get("note_raw_markdown", None): + cleaned_data.update({"note_raw_markdown": data.get("note_raw_markdown")}) + + if data.get("note_is_public", None): + cleaned_data.update({"note_is_public": data.get("note_is_public")}) + + if data.get("is_main_sibling", None): + cleaned_data.update({"is_main_sibling": data.get("is_main_sibling")}) + + if data.get("backlogged", None): + cleaned_data.update({"backlogged": data.get("backlogged")}) + + if data.get("now_playing", None): + cleaned_data.update({"now_playing": data.get("now_playing")}) + + if data.get("hidden", None): + cleaned_data.update({"hidden": data.get("hidden")}) + + if data.get("rating", None): + cleaned_data.update({"rating": data.get("rating")}) + + if data.get("difficulty", None): + cleaned_data.update({"difficulty": data.get("difficulty")}) + + if data.get("completion", None): + cleaned_data.update({"completion": data.get("completion")}) + + if data.get("status", None): + cleaned_data.update({"status": data.get("status")}) return db_rom_handler.update_rom_user(db_rom_user.id, cleaned_data) diff --git a/backend/endpoints/states.py b/backend/endpoints/states.py index 256277ea8..2da83b318 100644 --- a/backend/endpoints/states.py +++ b/backend/endpoints/states.py @@ -3,6 +3,7 @@ from decorators.auth import protected_route from endpoints.responses import MessageResponse from endpoints.responses.assets import StateSchema, UploadedStatesResponse +from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException from fastapi import File, HTTPException, Request, UploadFile, status from handler.database import db_rom_handler, db_screenshot_handler, db_state_handler from handler.filesystem import fs_asset_handler @@ -10,8 +11,6 @@ from logger.logger import log from utils.router import APIRouter -from backend.exceptions.endpoint_exceptions import RomNotFoundInDatabaseException - router = APIRouter() diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index dcb788b2d..c6ae557a6 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -32,6 +32,7 @@ export type { RomIGDBMetadata } from './models/RomIGDBMetadata'; export type { RomMobyMetadata } from './models/RomMobyMetadata'; export type { RomSchema } from './models/RomSchema'; export type { RomUserSchema } from './models/RomUserSchema'; +export type { RomUserStatus } from './models/RomUserStatus'; export type { SaveSchema } from './models/SaveSchema'; export type { SchedulerDict } from './models/SchedulerDict'; export type { ScreenshotSchema } from './models/ScreenshotSchema'; diff --git a/frontend/src/__generated__/models/DetailedRomSchema.ts b/frontend/src/__generated__/models/DetailedRomSchema.ts index 1888f2db9..a0fdbefa8 100644 --- a/frontend/src/__generated__/models/DetailedRomSchema.ts +++ b/frontend/src/__generated__/models/DetailedRomSchema.ts @@ -58,7 +58,7 @@ export type DetailedRomSchema = { updated_at: string; merged_screenshots: Array; sibling_roms?: Array; - rom_user?: (RomUserSchema | null); + rom_user: RomUserSchema; user_saves?: Array; user_states?: Array; user_screenshots?: Array; diff --git a/frontend/src/__generated__/models/RomUserSchema.ts b/frontend/src/__generated__/models/RomUserSchema.ts index db0740842..bc226a6a2 100644 --- a/frontend/src/__generated__/models/RomUserSchema.ts +++ b/frontend/src/__generated__/models/RomUserSchema.ts @@ -3,6 +3,8 @@ /* tslint:disable */ /* eslint-disable */ +import type { RomUserStatus } from './RomUserStatus'; + export type RomUserSchema = { id: number; user_id: number; @@ -12,6 +14,13 @@ export type RomUserSchema = { note_raw_markdown: string; note_is_public: boolean; is_main_sibling: boolean; + backlogged: boolean; + now_playing: boolean; + hidden: boolean; + rating: number; + difficulty: number; + completion: number; + status: (RomUserStatus | null); user__username: string; }; diff --git a/frontend/src/__generated__/models/RomUserStatus.ts b/frontend/src/__generated__/models/RomUserStatus.ts new file mode 100644 index 000000000..d6f3a090b --- /dev/null +++ b/frontend/src/__generated__/models/RomUserStatus.ts @@ -0,0 +1,6 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +export type RomUserStatus = 'incomplete' | 'finished' | 'completed_100' | 'retired' | 'never_playing'; diff --git a/frontend/src/__generated__/models/SimpleRomSchema.ts b/frontend/src/__generated__/models/SimpleRomSchema.ts index a06ea7335..c808556fd 100644 --- a/frontend/src/__generated__/models/SimpleRomSchema.ts +++ b/frontend/src/__generated__/models/SimpleRomSchema.ts @@ -52,7 +52,7 @@ export type SimpleRomSchema = { created_at: string; updated_at: string; sibling_roms?: Array; - rom_user?: (RomUserSchema | null); + rom_user: RomUserSchema; readonly sort_comparator: string; }; diff --git a/frontend/src/components/Details/Info/FileInfo.vue b/frontend/src/components/Details/Info/FileInfo.vue index a121249ac..551154f5f 100644 --- a/frontend/src/components/Details/Info/FileInfo.vue +++ b/frontend/src/components/Details/Info/FileInfo.vue @@ -14,17 +14,7 @@ import { ref, watch } from "vue"; const props = defineProps<{ rom: DetailedRom; platform: Platform }>(); const downloadStore = storeDownload(); const auth = storeAuth(); -const romUser = ref( - 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, - } -); +const romUser = ref(props.rom.rom_user); // Functions function collectionsWithoutFavourites(collections: Collection[]) { @@ -35,25 +25,13 @@ async function toggleMainSibling() { romUser.value.is_main_sibling = !romUser.value.is_main_sibling; romApi.updateUserRomProps({ romId: props.rom.id, - noteRawMarkdown: romUser.value.note_raw_markdown, - noteIsPublic: romUser.value.note_is_public, - isMainSibling: romUser.value.is_main_sibling, + data: romUser.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, - }; - } + async () => (romUser.value = props.rom.rom_user), );