diff --git a/app/core/groups/groups_type.py b/app/core/groups/groups_type.py index 60faf5bf07..8348e488c7 100644 --- a/app/core/groups/groups_type.py +++ b/app/core/groups/groups_type.py @@ -24,6 +24,7 @@ class GroupType(str, Enum): ph = "4ec5ae77-f955-4309-96a5-19cc3c8be71c" admin_cdr = "c1275229-46b2-4e53-a7c4-305513bb1a2a" eclair = "1f841bd9-00be-41a7-96e1-860a18a46105" + meme = "3e5b04e5-5b19-4950-8e6a-143bdf559290" # Auth related groups diff --git a/app/modules/meme/__init__.py b/app/modules/meme/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/modules/meme/cruds_meme.py b/app/modules/meme/cruds_meme.py new file mode 100644 index 0000000000..25c1f1a538 --- /dev/null +++ b/app/modules/meme/cruds_meme.py @@ -0,0 +1,363 @@ +import uuid +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core import models_core +from app.modules.meme import models_meme, types_meme + +n_weeks = 7 + + +async def get_memes_by_date( + db: AsyncSession, + descending: bool, + user_id: str, +) -> Sequence[models_meme.Meme]: + result = await db.execute( + select(models_meme.Meme) + .options( + selectinload( + models_meme.Meme.votes.and_(models_meme.Vote.user_id == user_id), + ).load_only(models_meme.Vote.positive), + selectinload(models_meme.Meme.user), + ) + .execution_options(populate_existing=True) + .where(models_meme.Meme.status == types_meme.MemeStatus.neutral) + .order_by( + models_meme.Meme.creation_time.desc() + if descending + else models_meme.Meme.creation_time, + ), + ) + meme_page = result.scalars().all() + return meme_page + + +async def get_my_memes( + db: AsyncSession, + user_id: str, +) -> Sequence[models_meme.Meme]: + result = await db.execute( + select(models_meme.Meme) + .options( + selectinload( + models_meme.Meme.votes.and_(models_meme.Vote.user_id == user_id), + ).load_only(models_meme.Vote.positive), + selectinload(models_meme.Meme.user), + ) + .execution_options(populate_existing=True) + .where(models_meme.Meme.user_id == user_id) + .order_by(models_meme.Meme.creation_time.desc()) + ) + meme_page = result.scalars().all() + return meme_page + + +async def get_memes_by_votes( + db: AsyncSession, + descending: bool, + user_id: str, +) -> Sequence[models_meme.Meme]: + result = await db.execute( + select(models_meme.Meme) + .where(models_meme.Meme.status == types_meme.MemeStatus.neutral) + .options( + selectinload( + models_meme.Meme.votes.and_(models_meme.Vote.user_id == user_id), + ).load_only(models_meme.Vote.positive), + selectinload(models_meme.Meme.user), + ) + .execution_options(populate_existing=True) + .order_by( + models_meme.Meme.vote_score.desc() + if descending + else models_meme.Meme.vote_score, + ), + ) + meme_page = result.scalars().all() + return meme_page + + +async def get_trending_memes( + db: AsyncSession, + user_id: str, +) -> Sequence[models_meme.Meme]: + result = await db.execute( + select(models_meme.Meme) + .order_by(models_meme.Meme.vote_score) + .options( + selectinload( + models_meme.Meme.votes.and_(models_meme.Vote.user_id == user_id), + ).load_only(models_meme.Vote.positive), + selectinload(models_meme.Meme.user), + ) + .execution_options(populate_existing=True) + .where( + (models_meme.Meme.creation_time - datetime.now(tz=UTC)) + < timedelta(days=n_weeks), + models_meme.Meme.status == types_meme.MemeStatus.neutral, + ), + ) + meme_page = result.scalars().all() + return meme_page + + +async def get_memes_from_user( + db: AsyncSession, + user_id: str, +) -> Sequence[models_meme.Meme]: + result = await db.execute( + select(models_meme.Meme) + .options( + selectinload( + models_meme.Meme.votes.and_(models_meme.Vote.user_id == user_id), + ).load_only(models_meme.Vote.positive), + selectinload(models_meme.Meme.user), + ) + .execution_options(populate_existing=True) + .where( + models_meme.Meme.user_id == user_id, + models_meme.Meme.status == types_meme.MemeStatus.neutral, + ) + .order_by(models_meme.Meme.creation_time), + ) + meme_page = result.scalars().all() + return meme_page + + +async def update_ban_status_of_memes_from_user( + db: AsyncSession, + user_id: str, + new_ban_status: types_meme.MemeStatus, +): + await db.execute( + update(models_meme.Meme) + .where(models_meme.Meme.user_id == user_id) + .values({models_meme.Meme.status: new_ban_status}), + ) + + +async def get_meme_by_id( + db: AsyncSession, + meme_id: uuid.UUID, + user_id: str, +) -> models_meme.Meme | None: + result = await db.execute( + select(models_meme.Meme) + .options( + selectinload( + models_meme.Meme.votes.and_( + models_meme.Vote.user_id == user_id, + ), + ).load_only(models_meme.Vote.positive), + selectinload(models_meme.Meme.user), + ) + .execution_options(populate_existing=True) + .where(models_meme.Meme.id == meme_id), + ) + return result.unique().scalars().first() + + +def add_meme(db: AsyncSession, meme: models_meme.Meme): + db.add(meme) + + +async def update_meme_ban_status( + db: AsyncSession, + ban_status: types_meme.MemeStatus, + meme_id: UUID, +): + await db.execute( + update(models_meme.Meme) + .where(models_meme.Meme.id == meme_id) + .values({models_meme.Meme.status: ban_status}), + ) + + +async def update_meme_vote_score( + db: AsyncSession, + old_positive: bool | None, + new_positive: bool | None, + meme_id: UUID, +): + if old_positive == new_positive: + score_diff = 0 + elif old_positive is None: + score_diff = 1 if new_positive else -1 + elif not old_positive: + score_diff = 1 if new_positive is None else 2 + else: + # old_positve == True + score_diff = -1 if new_positive is None else -2 + + await db.execute( + update(models_meme.Meme) + .where(models_meme.Meme.id == meme_id) + .values( + {models_meme.Meme.vote_score: models_meme.Meme.vote_score + score_diff}, + ), + ) + + +async def delete_meme_by_id(db: AsyncSession, meme_id: UUID): + await db.execute( + delete(models_meme.Meme).where(models_meme.Meme.id == meme_id), + ) + + +async def get_vote( + db: AsyncSession, + meme_id: UUID, + user_id: str, +) -> models_meme.Vote | None: + result = await db.execute( + select(models_meme.Vote).where( + models_meme.Vote.meme_id == meme_id, + models_meme.Vote.user_id == user_id, + ), + ) + return result.unique().scalars().first() + + +async def get_vote_by_id( + db: AsyncSession, + vote_id: UUID, +) -> models_meme.Vote | None: + result = await db.execute( + select(models_meme.Vote).where(models_meme.Vote.id == vote_id), + ) + return result.unique().scalars().first() + + +def add_vote(db: AsyncSession, vote: models_meme.Vote): + db.add(vote) + + +async def update_vote(db: AsyncSession, vote_id: UUID, new_positive: bool): + await db.execute( + update(models_meme.Vote) + .where(models_meme.Vote.id == vote_id) + .values({models_meme.Vote.positive: new_positive}), + ) + + +async def delete_vote(db: AsyncSession, vote_id: UUID): + await db.execute( + delete(models_meme.Vote).where(models_meme.Vote.id == vote_id), + ) + + +async def get_ban_by_id( + db: AsyncSession, + ban_id: UUID, +) -> models_meme.Ban | None: + result = await db.execute( + select(models_meme.Ban).where(models_meme.Ban.id == ban_id), + ) + return result.unique().scalars().first() + + +async def get_user_current_ban( + db: AsyncSession, + user_id: str, +) -> models_meme.Ban | None: + result = await db.execute( + select(models_meme.Ban).where( + models_meme.Ban.user_id == user_id, + models_meme.Ban.end_time.is_(None), + ), + ) + return result.unique().scalars().first() + + +async def get_user_ban_history( + db: AsyncSession, + user_id: str, +) -> Sequence[models_meme.Ban]: + result = await db.execute( + select(models_meme.Ban) + .where(models_meme.Ban.user_id == user_id) + .order_by(models_meme.Ban.creation_time), + ) + return result.scalars().all() + + +def add_user_ban(db: AsyncSession, ban: models_meme.Ban): + db.add(ban) + + +async def update_end_of_ban(db: AsyncSession, end_time: datetime, ban_id: UUID): + await db.execute( + update(models_meme.Ban) + .where(models_meme.Ban.id == ban_id) + .values({models_meme.Ban.end_time: end_time}), + ) + + +async def delete_ban(db: AsyncSession, ban_id: UUID): + await db.execute( + delete(models_meme.Ban).where(models_meme.Ban.id == ban_id), + ) + + +async def get_banned_users(db: AsyncSession) -> Sequence[models_core.CoreUser]: + result = await db.execute( + select(models_core.CoreUser) + .join(models_meme.Ban, models_core.CoreUser.id == models_meme.Ban.user_id) + .where(models_meme.Ban.end_time.is_(None)) + .order_by(models_meme.Ban.creation_time), + ) + return result.scalars().all() + + +async def get_hidden_memes( + db: AsyncSession, + descending: bool, + user_id: str, +) -> Sequence[models_meme.Meme]: + result = await db.execute( + select(models_meme.Meme) + .options( + selectinload( + models_meme.Meme.votes.and_(models_meme.Vote.user_id == user_id), + ).load_only(models_meme.Vote.positive), + selectinload(models_meme.Meme.user), + ) + .execution_options(populate_existing=True) + .where(models_meme.Meme.status == types_meme.MemeStatus.hidden) + .order_by( + models_meme.Meme.creation_time.desc() + if descending + else models_meme.Meme.creation_time, + ), + ) + return result.scalars().all() + + +async def get_all_memes(db: AsyncSession, n_jours) -> Sequence[models_meme.Meme]: + if n_jours == -1: + result = await db.execute( + select(models_meme.Meme) + .where( + models_meme.Meme.status == types_meme.MemeStatus.neutral, + ) + .options(selectinload(models_meme.Meme.user)), + ) + + else: + threshold_date = datetime.now(tz=UTC) - timedelta(days=n_jours) + + result = await db.execute( + select(models_meme.Meme) + .where( + models_meme.Meme.creation_time >= threshold_date, + models_meme.Meme.status == types_meme.MemeStatus.neutral, + ) + .options(selectinload(models_meme.Meme.user)), + ) + return result.scalars().all() diff --git a/app/modules/meme/endpoints_meme.py b/app/modules/meme/endpoints_meme.py new file mode 100644 index 0000000000..f840fc4e10 --- /dev/null +++ b/app/modules/meme/endpoints_meme.py @@ -0,0 +1,744 @@ +import uuid +from datetime import UTC, datetime +from typing import TYPE_CHECKING + +from fastapi import Depends, File, HTTPException +from fastapi.datastructures import UploadFile +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core import models_core, schemas_core +from app.core.groups.groups_type import GroupType +from app.dependencies import ( + get_db, + get_request_id, + is_user_a_member, + is_user_in, +) +from app.modules.meme import cruds_meme, models_meme, schemas_meme, types_meme +from app.types.content_type import ContentType +from app.types.module import Module +from app.utils.tools import ( + delete_file_from_data, + get_file_from_data, + save_file_as_data, +) + +if TYPE_CHECKING: + from app.types.floors_type import FloorsType + +module = Module( + root="meme", + tag="Centrale Mega Meme", +) + + +async def is_allowed_meme_user( + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_a_member), +) -> models_core.CoreUser: + """ + Overloads the is_user() dependency injection to verify if the user is in the banned table + """ + user_current_ban = await cruds_meme.get_user_current_ban(db=db, user_id=user.id) + if user_current_ban is not None: + raise HTTPException(status_code=403, detail="You are currently banned") + return user + + +@module.router.get( + "/meme/memes/", + response_model=list[schemas_meme.ShownMeme], + status_code=200, +) +async def get_memes( + sort_by: str = "best", + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_a_member), +): + """ + Get memes according to the asked sort + """ + + match sort_by: + case types_meme.MemeSort.best: + meme_page = await cruds_meme.get_memes_by_votes( + db=db, + descending=True, + user_id=user.id, + ) + case types_meme.MemeSort.worst: + meme_page = await cruds_meme.get_memes_by_votes( + db=db, + descending=False, + user_id=user.id, + ) + case types_meme.MemeSort.trending: + meme_page = await cruds_meme.get_trending_memes( + db=db, + user_id=user.id, + ) + case types_meme.MemeSort.newest: + meme_page = await cruds_meme.get_memes_by_date( + db=db, + descending=True, + user_id=user.id, + ) + case types_meme.MemeSort.oldest: + meme_page = await cruds_meme.get_memes_by_date( + db=db, + descending=False, + user_id=user.id, + ) + case _: + raise HTTPException(status_code=404, detail="Invalid sort method") + + return [ + schemas_meme.ShownMeme( + id=str(meme.id), + user=meme.user, + creation_time=meme.creation_time, + vote_score=meme.vote_score, + status=meme.status, + my_vote=meme.votes[0].positive if meme.votes else None, + ) + for meme in meme_page + ] + + +@module.router.get( + "/meme/memes/me", + response_model=list[schemas_meme.ShownMeme], + status_code=200, +) +async def get_my_memes( + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_a_member), +): + meme_page = await cruds_meme.get_my_memes( + db=db, + user_id=user.id, + ) + + return [ + schemas_meme.ShownMeme( + id=str(meme.id), + user=meme.user, + creation_time=meme.creation_time, + vote_score=meme.vote_score, + status=meme.status, + my_vote=meme.votes[0].positive if meme.votes else None, + ) + for meme in meme_page + ] + + +@module.router.get( + "/meme/memes/{meme_id}/img/", + status_code=200, + response_class=FileResponse, +) +async def get_meme_image_by_id( + meme_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_allowed_meme_user), +): + """ + Get a meme image using its id + """ + meme = await cruds_meme.get_meme_by_id(db=db, meme_id=meme_id, user_id=user.id) + if meme is None: + raise HTTPException(status_code=404, detail="The meme does not exist") + + return get_file_from_data( + default_asset="assets/images/default_meme.png", + directory="memes", + filename=str(meme_id), + ) + + +@module.router.post( + "/meme/memes/{meme_id}/hide/", + status_code=201, +) +async def hide_meme_by_id( + meme_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_in(GroupType.meme)), +): + """ + Hide a meme from db + Must be admin + """ + meme = await cruds_meme.get_meme_by_id(db=db, meme_id=meme_id, user_id=user.id) + if meme is None: + raise HTTPException(status_code=404, detail="The meme does not exist") + + try: + await cruds_meme.update_meme_ban_status( + db=db, + ban_status=types_meme.MemeStatus.hidden, + meme_id=meme_id, + ) + await db.commit() + except Exception: + await db.rollback() + raise + + +@module.router.post( + "/meme/memes/{meme_id}/show/", + status_code=201, +) +async def show_meme_by_id( + meme_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_in(GroupType.meme)), +): + """ + Show a meme from db + Must be admin + """ + meme = await cruds_meme.get_meme_by_id(db=db, meme_id=meme_id, user_id=user.id) + if meme is None: + raise HTTPException(status_code=404, detail="The meme does not exist") + + try: + await cruds_meme.update_meme_ban_status( + db=db, + ban_status=types_meme.MemeStatus.neutral, + meme_id=meme_id, + ) + await db.commit() + except Exception: + await db.rollback() + raise + + +@module.router.delete( + "/meme/memes/{meme_id}/", + status_code=204, +) +async def delete_meme_by_id( + meme_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_a_member), +): + """ + Remove a meme from db + Must be author of meme if meme is not banned + """ + meme = await cruds_meme.get_meme_by_id(db=db, meme_id=meme_id, user_id=user.id) + if not meme: + raise HTTPException( + status_code=404, + detail="Invalid meme_id", + ) + if meme.status == types_meme.MemeStatus.hidden: + raise HTTPException( + status_code=403, + detail="You can't delete a banned meme", + ) + if meme.user_id != user.id: + raise HTTPException( + status_code=403, + detail="You are not the author of this meme", + ) + try: + await cruds_meme.delete_meme_by_id(db=db, meme_id=meme_id) + delete_file_from_data(directory="meme", filename=str(meme_id)) + await db.commit() + except Exception: + await db.rollback() + raise + + +@module.router.post( + "/meme/memes/", + response_model=schemas_meme.Meme, + status_code=201, +) +async def add_meme( + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_allowed_meme_user), + image: UploadFile = File(...), + request_id: str = Depends(get_request_id), +): + """ + Add a new meme + """ + try: + meme_id = uuid.uuid4() + meme = models_meme.Meme( + id=meme_id, + user_id=user.id, + creation_time=datetime.now(UTC), + vote_score=0, + votes=[], + status=types_meme.MemeStatus.neutral, + ) + cruds_meme.add_meme(db=db, meme=meme) + await db.commit() + await save_file_as_data( + upload_file=image, + directory="memes", + filename=str(meme_id), + request_id=request_id, + max_file_size=4 * 1024 * 1024, + accepted_content_types=[ + ContentType.jpg, + ContentType.png, + ContentType.webp, + ], + ) + + except Exception: + await db.rollback() + raise + else: + return meme + + +@module.router.get( + "/meme/memes/{meme_id}/vote/", + status_code=200, + response_model=schemas_meme.Vote, +) +async def get_vote( + meme_id: uuid.UUID, + user_id: str, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_allowed_meme_user), +): + """ + Get a meme caracteristics using its id + """ + vote = await cruds_meme.get_vote(db=db, meme_id=meme_id, user_id=user_id) + if vote is None: + raise HTTPException( + status_code=404, + detail="The meme has no vote from this user", + ) + + return vote + + +@module.router.get( + "/meme/memes/votes/{vote_id}/", + status_code=200, + response_model=schemas_meme.Vote, +) +async def get_vote_by_id( + vote_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_allowed_meme_user), +): + """ + Get a meme caracteristics using its id + """ + vote = await cruds_meme.get_vote_by_id(db=db, vote_id=vote_id) + if vote is None: + raise HTTPException(status_code=404, detail="The vote does not exist") + + return vote + + +@module.router.post( + "/meme/memes/{meme_id}/vote/", + response_model=schemas_meme.Vote, + status_code=201, +) +async def add_vote( + meme_id: uuid.UUID, + positive: bool, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_allowed_meme_user), +): + """ + Add a new vote for the user to a meme from its id + """ + meme = await cruds_meme.get_meme_by_id(db=db, meme_id=meme_id, user_id=user.id) + if meme is None: + raise HTTPException(status_code=404, detail="The meme does not exist") + vote = await cruds_meme.get_vote(db=db, meme_id=meme_id, user_id=user.id) + if vote is not None: + raise HTTPException(status_code=404, detail="Vote already created") + + try: + vote_id = uuid.uuid4() + vote = models_meme.Vote( + id=vote_id, + meme_id=meme_id, + user_id=user.id, + positive=positive, + ) + await cruds_meme.update_meme_vote_score( + db=db, + meme_id=meme_id, + old_positive=None, + new_positive=positive, + ) + cruds_meme.add_vote(db=db, vote=vote) + await db.commit() + except Exception: + await db.rollback() + raise + else: + return schemas_meme.Vote( + meme_id=str(vote.meme_id), + positive=vote.positive, + user=vote.user, + ) + + +@module.router.delete( + "/meme/memes/{meme_id}/vote/", + status_code=204, +) +async def delete_vote( + meme_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_allowed_meme_user), +): + """ + Remove the vote from the user if it exists + """ + meme = await cruds_meme.get_meme_by_id(db=db, meme_id=meme_id, user_id=user.id) + if meme is None: + raise HTTPException(status_code=404, detail="The meme does not exist") + vote = await cruds_meme.get_vote(db=db, meme_id=meme_id, user_id=user.id) + if vote is None: + raise HTTPException(status_code=404, detail="The vote does not exist") + try: + await cruds_meme.update_meme_vote_score( + db=db, + meme_id=meme_id, + old_positive=meme.votes[0].positive if meme.votes else None, + new_positive=None, + ) + await cruds_meme.delete_vote(db=db, vote_id=vote.id) + await db.commit() + except Exception: + await db.rollback() + raise + + +@module.router.patch( + "/meme/memes/{meme_id}/vote/", + status_code=204, +) +async def update_vote( + meme_id: uuid.UUID, + positive: bool, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_allowed_meme_user), +): + """ + Update a vote from the user if it exists even if vote is already at the right positivity + """ + meme = await cruds_meme.get_meme_by_id(db=db, meme_id=meme_id, user_id=user.id) + if meme is None: + raise HTTPException(status_code=404, detail="The meme does not exist") + vote = await cruds_meme.get_vote(db=db, meme_id=meme_id, user_id=user.id) + if vote is None: + raise HTTPException(status_code=404, detail="The vote does not exist") + try: + await cruds_meme.update_meme_vote_score( + db=db, + meme_id=meme_id, + old_positive=meme.votes[0].positive, # should exist + new_positive=positive, + ) + await cruds_meme.update_vote(db=db, vote_id=vote.id, new_positive=positive) + await db.commit() + except Exception: + await db.rollback() + raise + else: + return schemas_meme.Vote( + meme_id=str(vote.meme_id), + positive=positive, + user=user, + ) + + +@module.router.post( + "/meme/users/{user_id}/ban/", + status_code=201, +) +async def ban_user( + user_id: str, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_in(GroupType.meme)), +): + """ + Ban a user and hide all of his memes + Must be admin + """ + + current_ban = await cruds_meme.get_user_current_ban(db=db, user_id=user_id) + if current_ban is not None: + raise HTTPException(status_code=404, detail="User is already banned") + + ban = models_meme.Ban( + id=uuid.uuid4(), + user_id=user_id, + admin_id=user.id, + end_time=None, + creation_time=datetime.now(UTC), + ) + try: + cruds_meme.add_user_ban(db=db, ban=ban) + await cruds_meme.update_ban_status_of_memes_from_user( + db=db, + user_id=user_id, + new_ban_status=types_meme.MemeStatus.hidden, + ) + await db.commit() + except Exception: + await db.rollback() + raise + + +@module.router.post( + "/meme/users/{user_id}/unban/", + status_code=201, +) +async def unban_user( + user_id: str, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_in(GroupType.meme)), +): + """ + Unban a user and unhide all of his memes + Must be admin + """ + + current_ban = await cruds_meme.get_user_current_ban(db=db, user_id=user_id) + if current_ban is None: + raise HTTPException(status_code=404, detail="User is not already banned") + + try: + await cruds_meme.update_end_of_ban( + db=db, + ban_id=current_ban.id, + end_time=datetime.now(UTC), + ) + await cruds_meme.update_ban_status_of_memes_from_user( + db=db, + user_id=user_id, + new_ban_status=types_meme.MemeStatus.neutral, + ) + await db.commit() + except Exception: + await db.rollback() + raise + + +@module.router.get( + "/meme/users/{user_id}/ban_history/", + status_code=200, + response_model=list[schemas_meme.Ban], +) +async def get_user_ban_history( + user_id: str, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_in(GroupType.meme)), +): + """ + Get the ban history of an user + """ + ban_history = await cruds_meme.get_user_ban_history(db=db, user_id=user_id) + return ban_history + + +@module.router.get( + "/meme/users/banned/", + status_code=200, + response_model=list[schemas_core.CoreUserSimple], +) +async def get_banned_users( + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_in(GroupType.meme)), +): + banned_users = await cruds_meme.get_banned_users(db=db) + return banned_users + + +@module.router.get( + "/meme/memes/hidden/", + status_code=200, + response_model=list[schemas_meme.ShownMeme], +) +async def get_hidden_memes( + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_in(GroupType.meme)), +): + hidden_memes = await cruds_meme.get_hidden_memes( + db=db, + descending=True, + user_id=user.id, + ) + return [ + schemas_meme.ShownMeme( + id=str(meme.id), + user=meme.user, + creation_time=meme.creation_time, + vote_score=meme.vote_score, + status=meme.status, + my_vote=meme.votes[0].positive if meme.votes else None, + ) + for meme in hidden_memes + ] + + +@module.router.get( + "/meme/leaderboard/", + status_code=200, + response_model=list[schemas_meme.UserScore] + | list[schemas_meme.FloorScore] + | list[schemas_meme.PromoScore], +) +async def get_user_leaderbord( + period: types_meme.PeriodLeaderboard, + entity: types_meme.EntityLeaderboard, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_a_member), +): + match period: + case types_meme.PeriodLeaderboard.week: + n_jours = 7 + case types_meme.PeriodLeaderboard.month: + n_jours = 30 + case types_meme.PeriodLeaderboard.year: + n_jours = 365 + case types_meme.PeriodLeaderboard.always: + n_jours = -1 + case _: + raise HTTPException(status_code=404, detail="Invalid period") + + memes = await cruds_meme.get_all_memes(db=db, n_jours=n_jours) + + match entity: + case types_meme.EntityLeaderboard.promo: + promo_scores: dict[int, int] = {} + + for meme in memes: + meme_author = meme.user + meme_author_promo = meme_author.promo + if meme_author_promo: + if meme_author_promo not in promo_scores: + promo_scores[meme_author_promo] = 0 + + promo_scores[meme_author_promo] += meme.vote_score + + sorted_promo_scores = sorted( + promo_scores.items(), + key=lambda item: item[1], + reverse=True, + ) + + return [ + { + "promo": promo, + "score": total_score, + "position": i + 1, + } + for i, (promo, total_score) in enumerate(sorted_promo_scores) + ] + + case types_meme.EntityLeaderboard.floor: + floor_scores: dict[FloorsType, int] = {} + + for meme in memes: + meme_author = meme.user + meme_author_floor = meme_author.floor + if meme_author_floor: + if meme_author_floor not in floor_scores: + floor_scores[meme_author_floor] = 0 + + floor_scores[meme_author_floor] += meme.vote_score + + sorted_floor_scores = sorted( + floor_scores.items(), + key=lambda item: item[1], + reverse=True, + ) + + return [ + { + "floor": floor, + "score": total_score, + "position": i + 1, + } + for i, (floor, total_score) in enumerate(sorted_floor_scores) + ] + + case types_meme.EntityLeaderboard.user: + user_scores: dict[str, int] = {} + users: dict[str, models_core.CoreUser] = {} + + for meme in memes: + meme_author = meme.user + meme_author_id = meme_author.id + + user_scores[meme_author_id] = ( + user_scores.get(meme_author_id, 0) + meme.vote_score + ) + users[meme_author_id] = meme_author + + sorted_user_scores = sorted( + user_scores.items(), + key=lambda item: item[1], + reverse=True, + ) + + return [ + {"user": users[user_id], "score": score, "position": i + 1} + for i, (user_id, score) in enumerate(sorted_user_scores) + ] + + case _: + raise HTTPException(status_code=404, detail="Invalid period") + + +@module.router.get( + "/meme/leaderboard/me", + status_code=200, + response_model=schemas_meme.Score, +) +async def get_my_leaderbord( + period: types_meme.PeriodLeaderboard, + db: AsyncSession = Depends(get_db), + user: models_core.CoreUser = Depends(is_user_a_member), +): + match period: + case types_meme.PeriodLeaderboard.week: + n_jours = 7 + case types_meme.PeriodLeaderboard.month: + n_jours = 30 + case types_meme.PeriodLeaderboard.year: + n_jours = 365 + case types_meme.PeriodLeaderboard.always: + n_jours = -1 + case _: + raise HTTPException(status_code=404, detail="Invalid period") + + memes = await cruds_meme.get_all_memes(db=db, n_jours=n_jours) + + my_score = sum(meme.vote_score for meme in memes if meme.user.id == user.id) + + user_scores: dict[str, int] = {} + + for meme in memes: + user_id = meme.user.id + user_scores[user_id] = user_scores.get(user_id, 0) + meme.vote_score + + sorted_scores = sorted(user_scores.items(), key=lambda item: item[1], reverse=True) + + my_position = next( + (i + 1 for i, (user_id, _) in enumerate(sorted_scores) if user_id == user.id), + None, + ) + + return {"score": my_score, "position": my_position} diff --git a/app/modules/meme/models_meme.py b/app/modules/meme/models_meme.py new file mode 100644 index 0000000000..b747d6469c --- /dev/null +++ b/app/modules/meme/models_meme.py @@ -0,0 +1,62 @@ +import uuid +from datetime import datetime + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.models_core import CoreUser +from app.modules.meme.types_meme import MemeStatus +from app.types.sqlalchemy import Base, PrimaryKey + + +class Vote(Base): + __tablename__ = "meme_vote" + + id: Mapped[PrimaryKey] + user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + user: Mapped[CoreUser] = relationship("CoreUser", init=False) + meme_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("meme_meme.id")) + meme: Mapped["Meme"] = relationship( + "Meme", + init=False, + back_populates="votes", + lazy="selectin", + ) + positive: Mapped[bool] + + +class Meme(Base): + __tablename__ = "meme_meme" + + id: Mapped[PrimaryKey] + status: Mapped[MemeStatus] + user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + user: Mapped[CoreUser] = relationship("CoreUser", init=False) + creation_time: Mapped[datetime] + vote_score: Mapped[int] + votes: Mapped[list["Vote"]] = relationship( + "Vote", + default_factory=list, + lazy="selectin", + back_populates="meme", + ) + + +class Ban(Base): + __tablename__ = "meme_ban" + + id: Mapped[PrimaryKey] + user_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + user: Mapped[CoreUser] = relationship( + "CoreUser", + init=False, + foreign_keys=[user_id], + ) + creation_time: Mapped[datetime] + end_time: Mapped[datetime | None] + admin_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + admin: Mapped[CoreUser] = relationship( + "CoreUser", + init=False, + foreign_keys=[admin_id], + ) diff --git a/app/modules/meme/schemas_meme.py b/app/modules/meme/schemas_meme.py new file mode 100644 index 0000000000..8cee8d1077 --- /dev/null +++ b/app/modules/meme/schemas_meme.py @@ -0,0 +1,63 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.core.schemas_core import CoreUserSimple +from app.modules.meme.types_meme import MemeStatus +from app.types.floors_type import FloorsType + + +class VoteBase(BaseModel): + meme_id: str + positive: bool + + +class Vote(VoteBase): + user: CoreUserSimple + + +class VoteComplete(Vote): + id: uuid.UUID + + +class Meme(BaseModel): + model_config = ConfigDict(from_attributes=True) + user: CoreUserSimple + creation_time: datetime + vote_score: int + votes: list[Vote] + status: MemeStatus + + +class ShownMeme(BaseModel): + id: str + user: CoreUserSimple + creation_time: datetime + my_vote: bool | None + vote_score: int + status: MemeStatus + + +class Ban(BaseModel): + creation_time: datetime + end_time: datetime | None + user: CoreUserSimple + admin: CoreUserSimple + + +class Score(BaseModel): + score: int + position: int + + +class UserScore(Score): + user: CoreUserSimple + + +class PromoScore(Score): + promo: int + + +class FloorScore(Score): + floor: FloorsType diff --git a/app/modules/meme/types_meme.py b/app/modules/meme/types_meme.py new file mode 100644 index 0000000000..6d3a3bf497 --- /dev/null +++ b/app/modules/meme/types_meme.py @@ -0,0 +1,27 @@ +from enum import Enum + + +class MemeStatus(str, Enum): + neutral = "neutral" + hidden = "hidden" + + +class MemeSort(str, Enum): + best = "best" + worst = "worst" + trending = "trending" + newest = "newest" + oldest = "oldest" + + +class PeriodLeaderboard(str, Enum): + week = "week" + month = "month" + year = "year" + always = "always" + + +class EntityLeaderboard(str, Enum): + promo = "promo" + floor = "floor" + user = "user" diff --git a/assets/images/default_meme.png b/assets/images/default_meme.png new file mode 100644 index 0000000000..c86797b5a4 Binary files /dev/null and b/assets/images/default_meme.png differ diff --git a/migrations/versions/29-meme.py b/migrations/versions/29-meme.py new file mode 100644 index 0000000000..c605539ece --- /dev/null +++ b/migrations/versions/29-meme.py @@ -0,0 +1,90 @@ +"""meme + +Create Date: 2025-02-15 17:56:46.270061 +""" + +from collections.abc import Sequence +from enum import Enum +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 = "43baf64975fe" +down_revision: str | None = "c778706af06f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +class MemeStatus(Enum): + neutral = "neutral" + hidden = "hidden" + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "meme_ban", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("creation_time", TZDateTime(), nullable=False), + sa.Column("end_time", TZDateTime(), nullable=True), + sa.Column("admin_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint(["admin_id"], ["core_user.id"]), + sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "meme_meme", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column( + "status", + sa.Enum(MemeStatus, name="memestatus"), + nullable=False, + ), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("creation_time", TZDateTime(), nullable=False), + sa.Column("vote_score", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "meme_vote", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("meme_id", sa.Uuid(), nullable=False), + sa.Column("positive", sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(["meme_id"], ["meme_meme.id"]), + 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("meme_vote") + op.drop_table("meme_meme") + op.drop_table("meme_ban") + sa.Enum(MemeStatus).drop(op.get_bind()) + # ### 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 diff --git a/tests/test_meme.py b/tests/test_meme.py new file mode 100644 index 0000000000..ea5546dc21 --- /dev/null +++ b/tests/test_meme.py @@ -0,0 +1,174 @@ +""" +* [ ] All crud operations on meme + * [ ] Ajout de n memes avec les valeurs bien choisies pour avoir les sort par plusieurs users + * [ ] Delete de 1 meme + * [ ] read d'une page de meme dans tous les sens +* [ ] All crud operations on vote + * [ ] Vote sur 1 meme par plusieurs user + * [ ] Changement du vote + * [ ] Delete du vote + * [ ] Read d'une page meme avec mes votes +* [ ] All crud operations on ban + * [ ] Ban d'un user et impact sur ses memes + * [ ] Unban d'un user et impact sur ses memes +* [ ] Testing edge cases with ban + * [ ] Call d'une méthode par un user ban +""" + +import datetime +import uuid + +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core import models_core +from app.core.groups.groups_type import GroupType +from app.modules.meme import models_meme +from app.modules.meme.types_meme import MemeStatus +from tests.commons import ( + add_object_to_db, + create_api_access_token, + create_user_with_groups, +) + +meme_user_1: models_core.CoreUser +meme_user_2: models_core.CoreUser +meme_user_to_ban: models_core.CoreUser +meme_admin: models_core.CoreUser + +memes_1: list[models_meme.Meme] +votes_memes_1: list[models_meme.Vote] +memes_2: list[models_meme.Meme] +memes_to_ban: list[models_meme.Meme] +token_user_1: str +token_user_2: str +token_user_to_ban: str +token_admin: str + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def init_objects() -> None: + global meme_user_1 + meme_user_1 = await create_user_with_groups([]) + global token_meme_1 + token_meme_1 = create_api_access_token(meme_user_1) + + global meme_user_2 + meme_user_2 = await create_user_with_groups([]) + global token_meme_2 + token_meme_2 = create_api_access_token(meme_user_2) + + global meme_user_to_ban + meme_user_to_ban = await create_user_with_groups([]) + global token_user_to_ban + token_user_to_ban = create_api_access_token(meme_user_to_ban) + + global meme_admin + meme_admin = await create_user_with_groups([GroupType.admin]) + global token_admin + token_admin = create_api_access_token(meme_admin) + + global memes_1 + memes_1 = [ + models_meme.Meme( + id=uuid.uuid4(), + status=MemeStatus.neutral, + user_id=meme_user_1.id, + creation_time=datetime.datetime(24, i, 23, tzinfo=datetime.UTC), + vote_score=i, + ) + for i in range(1, 13) + ] + global votes_memes_1 + votes_memes_1 = [] + for i, meme in enumerate(memes_1): + await add_object_to_db(meme) + if i % 2 == 0: + vote = models_meme.Vote( + id=uuid.uuid4(), + user_id=meme_user_1.id, + meme_id=meme.id, + positive=True, + ) + votes_memes_1.append(vote) + await add_object_to_db(vote) + if i % 2 == 1: + vote2 = models_meme.Vote( + id=uuid.uuid4(), + user_id=meme_user_2.id, + meme_id=meme.id, + positive=True, + ) + votes_memes_1.append(vote2) + await add_object_to_db(vote2) + + global memes_2 + memes_2 = [ + models_meme.Meme( + id=uuid.uuid4(), + status=MemeStatus.neutral, + user_id=meme_user_1.id, + creation_time=datetime.datetime(24, i, 23, tzinfo=datetime.UTC), + vote_score=i, + ) + for i in range(1, 13) + ] + for meme in memes_2: + await add_object_to_db(meme) + global memes_to_ban + memes_to_ban = [ + models_meme.Meme( + id=uuid.uuid4(), + status=MemeStatus.neutral, + user_id=meme_user_to_ban.id, + creation_time=datetime.datetime(24, i, 23, tzinfo=datetime.UTC), + vote_score=200, + ) + for i in range(1, 13) + ] + for meme in memes_to_ban: + await add_object_to_db(meme) + + +def test_get_meme_page(client: TestClient) -> None: + response = client.get( + "/meme/memes/?sort_by=best&n_page=1", + headers={"Authorization": f"Bearer {token_meme_2}"}, + ) + print(response) + print(response.status_code) + print(response.json()) + assert 1 == 1 + + +def test_banning_user(client: TestClient) -> None: + # TODO: Add test to prove that memes are hidden + response = client.get( + "/meme/memes/?sort_by=best&n_page=1", + headers={"Authorization": f"Bearer {token_user_to_ban}"}, + ) + assert response.status_code == 200 + response = client.post( + f"/meme/users/{meme_user_to_ban.id}/ban", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 201 + response = client.get( + "/meme/memes/?sort_by=best&n_page=1", + headers={"Authorization": f"Bearer {token_user_to_ban}"}, + ) + assert response.status_code == 403 + response = client.get( + "/meme/memes/?sort_by=best&n_page=1", + headers={"Authorization": f"Bearer {token_meme_1}"}, + ) + response = client.post( + f"/meme/users/{meme_user_to_ban.id}/unban", + headers={"Authorization": f"Bearer {token_admin}"}, + ) + assert response.status_code == 201 + response = client.get( + "/meme/memes/?sort_by=best&n_page=1", + headers={"Authorization": f"Bearer {token_user_to_ban}"}, + ) + assert response.status_code == 200