diff --git a/app/modules/rPlace/__init__.py b/app/modules/rPlace/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/modules/rPlace/coredata_rplace.py b/app/modules/rPlace/coredata_rplace.py new file mode 100644 index 000000000..edd80b82c --- /dev/null +++ b/app/modules/rPlace/coredata_rplace.py @@ -0,0 +1,8 @@ +from app.types.core_data import BaseCoreData + + +class gridInformation(BaseCoreData): + nbLigne: int = 100 + nbColonne: int = 100 + pixelSize: float = 10 + cooldown: int = 10000000 diff --git a/app/modules/rPlace/cruds_rplace.py b/app/modules/rPlace/cruds_rplace.py new file mode 100644 index 000000000..be6007c7e --- /dev/null +++ b/app/modules/rPlace/cruds_rplace.py @@ -0,0 +1,100 @@ +from datetime import UTC, datetime + +from sqlalchemy import and_, func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.modules.rPlace import models_rplace + + +async def get_pixels(db: AsyncSession) -> list[models_rplace.Pixel]: + subquery = ( + select( + func.max(models_rplace.Pixel.date).label("max_date"), + models_rplace.Pixel.x, + models_rplace.Pixel.y, + ) + .group_by(models_rplace.Pixel.x, models_rplace.Pixel.y) + .alias("subquery") + ) + + result = await db.execute( + select(models_rplace.Pixel) + .join( + subquery, + and_( + models_rplace.Pixel.x == subquery.c.x, + models_rplace.Pixel.y == subquery.c.y, + models_rplace.Pixel.date == subquery.c.max_date, + ), + ) + .order_by(models_rplace.Pixel.date.desc()), + ) + + return list(result.scalars().all()) + + +async def create_pixel( + db: AsyncSession, + rplace_pixel: models_rplace.Pixel, +) -> models_rplace.Pixel: + """Add a pixel in database""" + db.add(rplace_pixel) + try: + await db.commit() + except IntegrityError as error: + await db.rollback() + raise ValueError(error) + return rplace_pixel + + +async def get_pixel_info( + db: AsyncSession, + x: int, + y: int, +) -> models_rplace.Pixel | None: + result = await db.execute( + select(models_rplace.Pixel) + .where(models_rplace.Pixel.x == x, models_rplace.Pixel.y == y) + .order_by(models_rplace.Pixel.date.desc()) + .options( + selectinload(models_rplace.Pixel.user), + ), + ) + + return result.scalars().first() + + +async def get_last_pixel_date( + db: AsyncSession, + user_id: str, +) -> datetime: + result = await db.execute( + select(models_rplace.Pixel.date) + .where( + models_rplace.Pixel.user_id == user_id, + ) + .order_by( + models_rplace.Pixel.date.desc(), + ) + .limit(1), + ) + return result.scalars().first() or datetime(2003, 12, 18, 7, 46, 0, 0, UTC) + # return {"last_pixel_date": date_result.isoformat()} + + +async def get_last_pixel( + db: AsyncSession, + user_id: str, +) -> models_rplace.Pixel | None: + result = await db.execute( + select(models_rplace.Pixel) + .where( + models_rplace.Pixel.user_id == user_id, + ) + .order_by( + models_rplace.Pixel.date.desc(), + ), + ) + return result.scalars().first() diff --git a/app/modules/rPlace/endpoints_rplace.py b/app/modules/rPlace/endpoints_rplace.py new file mode 100644 index 000000000..3e6e20331 --- /dev/null +++ b/app/modules/rPlace/endpoints_rplace.py @@ -0,0 +1,183 @@ +import logging +import uuid +from datetime import UTC, datetime, timedelta + +from fastapi import Depends, HTTPException, WebSocket +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.groups.groups_type import AccountType +from app.core.users import models_users +from app.core.utils.config import Settings +from app.dependencies import ( + get_db, + get_settings, + get_unsafe_db, + get_websocket_connection_manager, + is_user, + is_user_a_member, +) +from app.modules.rPlace import ( + coredata_rplace, + cruds_rplace, + models_rplace, + schemas_rplace, +) +from app.types.module import Module +from app.types.websocket import HyperionWebsocketsRoom, WebsocketConnectionManager +from app.utils.tools import get_core_data + +module = Module( + root="rplace", + tag="rplace", + default_allowed_account_types=[AccountType.student], + factory=None, +) + +hyperion_error_logger = logging.getLogger("hyperion_error_logger") + + +@module.router.get( + "/rplace/pixels", + response_model=list[schemas_rplace.Pixel], + status_code=200, +) +async def get_pixels( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_a_member), +): + return await cruds_rplace.get_pixels(db=db) + + +@module.router.post( + "/rplace/pixels", + response_model=schemas_rplace.Pixel, + status_code=201, +) +async def create_pixel( + pixel: schemas_rplace.Pixel, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_a_member), + ws_manager: WebsocketConnectionManager = Depends(get_websocket_connection_manager), +): + pixel_id = uuid.uuid4() + + db_item = models_rplace.Pixel( + id=pixel_id, + date=datetime.now(tz=UTC), + user_id=user.id, + x=pixel.x, + y=pixel.y, + color=pixel.color, + ) + + last_pixel_placed = await cruds_rplace.get_last_pixel( + db=db, + user_id=user.id, + ) + + grid_information = await get_core_data(coredata_rplace.gridInformation, db) + + if not last_pixel_placed or datetime.now(UTC) - last_pixel_placed.date.replace( + tzinfo=UTC, + ) >= timedelta( + microseconds=grid_information.cooldown, + ): + try: + res = await cruds_rplace.create_pixel( + rplace_pixel=db_item, + db=db, + ) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) + + try: + await ws_manager.send_message_to_room( + message=schemas_rplace.NewPixelWSMessageModel( + data=schemas_rplace.Pixel( + x=pixel.x, + y=pixel.y, + color=pixel.color, + ), + ), + room_id=HyperionWebsocketsRoom.RPLACE, + ) + except Exception: + hyperion_error_logger.exception( + f"Error while sending a message to the room {HyperionWebsocketsRoom.CDR}", + ) + return res + else: + raise HTTPException( + status_code=401, + detail="Vous devez attendre avant de placer un autre pixel", + ) + + +@module.router.get( + "/rplace/information", + response_model=coredata_rplace.gridInformation, + status_code=200, +) +async def get_grid_information( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +): + """ + Get grid information + """ + return await get_core_data(coredata_rplace.gridInformation, db) + + +@module.router.websocket("/rplace/ws") +async def websocket_endpoint( + websocket: WebSocket, + ws_manager: WebsocketConnectionManager = Depends(get_websocket_connection_manager), + db: AsyncSession = Depends(get_unsafe_db), + settings: Settings = Depends(get_settings), +): + await ws_manager.manage_websocket( + websocket=websocket, + settings=settings, + room=HyperionWebsocketsRoom.RPLACE, + db=db, + ) + + +@module.router.get( + "/rplace/pixel_info/{x}/{y}", + response_model=schemas_rplace.PixelComplete, + status_code=200, +) +async def get_pixel_info( + x: int, + y: int, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_a_member), +): + info = await cruds_rplace.get_pixel_info( + db=db, + x=x, + y=y, + ) + if info is None: + raise HTTPException( + status_code=404, + detail="pas de pixel place", + ) + return info + + +@module.router.get( + "/rplace/last_pixel_date", + response_model=schemas_rplace.UserInfo, + status_code=200, +) +async def get_last_pixel_date( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_a_member), +): + date = await cruds_rplace.get_last_pixel_date( + db=db, + user_id=user.id, + ) + return schemas_rplace.UserInfo(date=date) diff --git a/app/modules/rPlace/models_rplace.py b/app/modules/rPlace/models_rplace.py new file mode 100644 index 000000000..200846966 --- /dev/null +++ b/app/modules/rPlace/models_rplace.py @@ -0,0 +1,21 @@ +from datetime import datetime + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.users.models_users import CoreUser +from app.types.sqlalchemy import Base, PrimaryKey + + +class Pixel(Base): + __tablename__ = "pixels" + + id: Mapped[PrimaryKey] + user_id: Mapped[str] = mapped_column( + ForeignKey("core_user.id"), + ) + user: Mapped[CoreUser] = relationship("CoreUser", init=False) + date: Mapped[datetime] + x: Mapped[int] + y: Mapped[int] + color: Mapped[str] diff --git a/app/modules/rPlace/schemas_rplace.py b/app/modules/rPlace/schemas_rplace.py new file mode 100644 index 000000000..604d41ef0 --- /dev/null +++ b/app/modules/rPlace/schemas_rplace.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel + +from app.core.users.schemas_users import CoreUserSimple +from app.types.websocket import WSMessageModel + + +class Pixel(BaseModel): + x: int + y: int + color: str + + +class PixelComplete(BaseModel): + user: CoreUserSimple + date: datetime + + +class NewPixelWSMessageModel(WSMessageModel): + command: Literal["NEW_PIXEL"] = "NEW_PIXEL" + data: Pixel + + +class UserInfo(BaseModel): + date: datetime diff --git a/app/types/websocket.py b/app/types/websocket.py index dd6ba0e75..7440875e0 100644 --- a/app/types/websocket.py +++ b/app/types/websocket.py @@ -17,6 +17,7 @@ class HyperionWebsocketsRoom(str, Enum): CDR = "5a816d32-8b5d-4c44-8a8d-18fd830ec5a8" + RPLACE = "4c9e2273-3b4a-4f5d-a879-9c3b2d07376f" hyperion_error_logger = logging.getLogger("hyperion.error") diff --git a/migrations/versions/43-rPlace.py b/migrations/versions/43-rPlace.py new file mode 100644 index 000000000..906f0b15b --- /dev/null +++ b/migrations/versions/43-rPlace.py @@ -0,0 +1,57 @@ +"""rPlace + +Create Date: 2025-10-09 21:06:47.321120 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +from app.types.sqlalchemy import TZDateTime + +# revision identifiers, used by Alembic. +revision: str = "d5619766fb58" +down_revision: str | None = "c4812e1ab108" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "pixels", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("color", sa.String(), nullable=False), + sa.Column("date", TZDateTime(), nullable=False), + sa.Column("x", sa.Integer(), nullable=False), + sa.Column("y", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("pixels") + # ### end Alembic commands ### + + +def pre_test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass + + +def test_upgrade( + alembic_runner: "MigrationContext", + alembic_connection: sa.Connection, +) -> None: + pass