Skip to content

Commit

Permalink
Merge pull request #964 from rommapp/feature/collections
Browse files Browse the repository at this point in the history
Collections
  • Loading branch information
zurdi15 committed Jul 5, 2024
2 parents 31fbb1b + ebaa5d6 commit 7c54a58
Show file tree
Hide file tree
Showing 114 changed files with 3,443 additions and 818 deletions.
125 changes: 125 additions & 0 deletions backend/alembic/versions/0022_collections_.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""empty message
Revision ID: 0022_collections
Revises: 0021_rom_user
Create Date: 2024-07-01 23:23:39.090219
"""

import json
import os
import shutil

import sqlalchemy as sa
from alembic import op
from config import RESOURCES_BASE_PATH
from sqlalchemy.dialects import mysql

# revision identifiers, used by Alembic.
revision = "0022_collections"
down_revision = "0021_rom_user"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"collections",
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("path_cover_l", sa.String(length=1000), nullable=True),
sa.Column("path_cover_s", sa.String(length=1000), nullable=True),
sa.Column("url_cover", sa.Text(), 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),
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.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
with op.batch_alter_table("rom_user", schema=None) as batch_op:
batch_op.alter_column(
"is_main_sibling",
existing_type=mysql.TINYINT(display_width=1),
nullable=True,
)

connection = op.get_bind()
roms = connection.execute(
sa.text(
"SELECT id, name, platform_id, path_cover_s, path_cover_l, path_screenshots FROM roms"
)
).fetchall()

# Define the path for the new folder
roms_folder_path = os.path.join(RESOURCES_BASE_PATH, "roms")

# Create the new folder if it doesn't exist
os.makedirs(roms_folder_path, exist_ok=True)

# List all items in the base directory
for folder in os.listdir(RESOURCES_BASE_PATH):
folder_path = os.path.join(RESOURCES_BASE_PATH, folder)

# Check if the item is a directory and not the new folder itself
if os.path.isdir(folder_path) and folder != "roms":
# Move the folder to the new folder
shutil.move(folder_path, roms_folder_path)

# Update paths for each rom
for rom in roms:
path_cover_s = rom.path_cover_s
path_cover_l = rom.path_cover_l
path_screenshots = rom.path_screenshots

# Add "roms/" prefix to path_cover_s and path_cover_l
if path_cover_s:
path_cover_s = f"roms/{path_cover_s}"
if path_cover_l:
path_cover_l = f"roms/{path_cover_l}"

# Add "roms/" prefix to each path in path_screenshots
if path_screenshots:
path_screenshots_list = json.loads(path_screenshots)
path_screenshots_list = [f"roms/{path}" for path in path_screenshots_list]
path_screenshots = json.dumps(path_screenshots_list)

# Update the database with the new paths
connection.execute(
sa.text(
"UPDATE roms SET path_cover_s = :path_cover_s, path_cover_l = :path_cover_l, path_screenshots = :path_screenshots WHERE id = :id"
),
{
"path_cover_s": path_cover_s,
"path_cover_l": path_cover_l,
"path_screenshots": path_screenshots,
"id": rom.id,
},
)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("rom_user", schema=None) as batch_op:
batch_op.alter_column(
"is_main_sibling",
existing_type=mysql.TINYINT(display_width=1),
nullable=False,
)

op.drop_table("collections")
# ### end Alembic commands ###
234 changes: 234 additions & 0 deletions backend/endpoints/collections.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import json
from shutil import rmtree

from config import RESOURCES_BASE_PATH
from decorators.auth import protected_route
from endpoints.responses import MessageResponse
from endpoints.responses.collection import CollectionSchema
from exceptions.endpoint_exceptions import (
CollectionAlreadyExistsException,
CollectionNotFoundInDatabaseException,
CollectionPermissionError,
)
from fastapi import APIRouter, Request, UploadFile
from handler.database import db_collection_handler
from handler.filesystem import fs_resource_handler
from logger.logger import log
from models.collection import Collection
from sqlalchemy.inspection import inspect

router = APIRouter()


@protected_route(router.post, "/collections", ["collections.write"])
async def add_collection(
request: Request,
artwork: UploadFile | None = None,
) -> CollectionSchema:
"""Create collection endpoint
Args:
request (Request): Fastapi Request object
Returns:
CollectionSchema: Just created collection
"""

data = await request.form()
cleaned_data = {
"name": data.get("name", ""),
"description": data.get("description", ""),
"url_cover": data.get("url_cover", ""),
"is_public": data.get("is_public", False),
"user_id": request.user.id,
}
db_collection = db_collection_handler.get_collection_by_name(
cleaned_data["name"], request.user.id
)

if db_collection:
raise CollectionAlreadyExistsException(cleaned_data["name"])

_added_collection = db_collection_handler.add_collection(Collection(**cleaned_data))

if artwork is not None:
file_ext = artwork.filename.split(".")[-1]
(
path_cover_l,
path_cover_s,
artwork_path,
) = fs_resource_handler.build_artwork_path(_added_collection, file_ext)

artwork_file = artwork.file.read()
file_location_s = f"{artwork_path}/small.{file_ext}"
with open(file_location_s, "wb+") as artwork_s:
artwork_s.write(artwork_file)
fs_resource_handler.resize_cover_to_small(file_location_s)

file_location_l = f"{artwork_path}/big.{file_ext}"
with open(file_location_l, "wb+") as artwork_l:
artwork_l.write(artwork_file)
else:
path_cover_s, path_cover_l = fs_resource_handler.get_cover(
overwrite=True,
entity=_added_collection,
url_cover=_added_collection.url_cover,
)

_added_collection.path_cover_s = path_cover_s
_added_collection.path_cover_l = path_cover_l
# Update the collection with the cover path and update database
return db_collection_handler.update_collection(
_added_collection.id,
{
c: getattr(_added_collection, c)
for c in inspect(_added_collection).mapper.column_attrs.keys()
},
)


@protected_route(router.get, "/collections", ["collections.read"])
def get_collections(request: Request) -> list[CollectionSchema]:
"""Get collections endpoint
Args:
request (Request): Fastapi Request object
id (int, optional): Collection id. Defaults to None.
Returns:
list[CollectionSchema]: List of collections
"""

return db_collection_handler.get_collections(user_id=request.user.id)


@protected_route(router.get, "/collections/{id}", ["collections.read"])
def get_collection(request: Request, id: int) -> CollectionSchema:
"""Get collections endpoint
Args:
request (Request): Fastapi Request object
id (int, optional): Collection id. Defaults to None.
Returns:
CollectionSchema: Collection
"""

collection = db_collection_handler.get_collection(id)

if not collection:
raise CollectionNotFoundInDatabaseException(id)

return collection


@protected_route(router.put, "/collections/{id}", ["collections.write"])
async def update_collection(
request: Request,
id: int,
remove_cover: bool = False,
artwork: UploadFile | None = None,
) -> CollectionSchema:
"""Update collection endpoint
Args:
request (Request): Fastapi Request object
Returns:
MessageResponse: Standard message response
"""

data = await request.form()
collection = db_collection_handler.get_collection(id)

if collection.user_id != request.user.id:
raise CollectionPermissionError(id)

if not collection:
raise CollectionNotFoundInDatabaseException(id)

try:
roms = json.loads(data["roms"])
except json.JSONDecodeError as e:
raise ValueError("Invalid list for roms field in update collection") from e
except KeyError:
roms = collection.roms

cleaned_data = {
"name": data.get("name", collection.name),
"description": data.get("description", collection.description),
"roms": list(set(roms)),
"url_cover": data.get("url_cover", collection.url_cover),
"is_public": data.get("is_public", collection.is_public),
"user_id": request.user.id,
}

if remove_cover:
cleaned_data.update(fs_resource_handler.remove_cover(collection))
cleaned_data.update({"url_cover": ""})
else:
if artwork is not None:
file_ext = artwork.filename.split(".")[-1]
(
path_cover_l,
path_cover_s,
artwork_path,
) = fs_resource_handler.build_artwork_path(collection, file_ext)

cleaned_data["path_cover_l"] = path_cover_l
cleaned_data["path_cover_s"] = path_cover_s

artwork_file = artwork.file.read()
file_location_s = f"{artwork_path}/small.{file_ext}"
with open(file_location_s, "wb+") as artwork_s:
artwork_s.write(artwork_file)
fs_resource_handler.resize_cover_to_small(file_location_s)

file_location_l = f"{artwork_path}/big.{file_ext}"
with open(file_location_l, "wb+") as artwork_l:
artwork_l.write(artwork_file)
else:
cleaned_data["url_cover"] = data.get("url_cover", collection.url_cover)
path_cover_s, path_cover_l = fs_resource_handler.get_cover(
overwrite=cleaned_data["url_cover"] != collection.url_cover,
entity=collection,
url_cover=cleaned_data.get("url_cover", ""),
)
cleaned_data.update(
{"path_cover_s": path_cover_s, "path_cover_l": path_cover_l}
)

return db_collection_handler.update_collection(id, cleaned_data)


@protected_route(router.delete, "/collections/{id}", ["collections.write"])
async def delete_collections(request: Request, id: int) -> MessageResponse:
"""Delete collections endpoint
Args:
request (Request): Fastapi Request object
{
"collections": List of rom's ids to delete
}
Raises:
HTTPException: Collection not found
Returns:
MessageResponse: Standard message response
"""

collection = db_collection_handler.get_collection(id)

if not collection:
raise CollectionNotFoundInDatabaseException(id)

log.info(f"Deleting {collection.name} from database")
db_collection_handler.delete_collection(id)

try:
rmtree(f"{RESOURCES_BASE_PATH}/{collection.fs_resources_path}")
except FileNotFoundError:
log.error(f"Couldn't find resources to delete for {collection.name}")

return {"msg": f"{collection.name} deleted successfully!"}
3 changes: 1 addition & 2 deletions backend/endpoints/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
24 changes: 24 additions & 0 deletions backend/endpoints/responses/collection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from datetime import datetime

from pydantic import BaseModel


class CollectionSchema(BaseModel):
id: int
name: str
description: str
path_cover_l: str | None
path_cover_s: str | None
has_cover: bool
url_cover: str
roms: set[int]
rom_count: int
user_id: int
user__username: str
is_public: bool

created_at: datetime
updated_at: datetime

class Config:
from_attributes = True
Loading

0 comments on commit 7c54a58

Please sign in to comment.