diff --git a/app/core/feed/__init__.py b/app/core/feed/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/core/feed/cruds_feed.py b/app/core/feed/cruds_feed.py new file mode 100644 index 0000000000..61d2f27f39 --- /dev/null +++ b/app/core/feed/cruds_feed.py @@ -0,0 +1,55 @@ +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.feed import models_feed +from app.core.feed.types_feed import NewsStatus + + +async def create_news( + news: models_feed.News, + db: AsyncSession, +) -> None: + """ + Create a news + """ + + db.add(news) + + +async def get_news( + status: list[NewsStatus], + db: AsyncSession, +) -> Sequence[models_feed.News]: + result = await db.execute( + select(models_feed.News).where( + models_feed.News.status.in_(status), + ), + ) + return result.scalars().all() + + +async def get_news_by_id( + news_id: UUID, + db: AsyncSession, +) -> models_feed.News | None: + result = await db.execute( + select(models_feed.News).where( + models_feed.News.id == news_id, + ), + ) + return result.scalars().first() + + +async def change_news_status( + news_id: UUID, + status: NewsStatus, + db: AsyncSession, +) -> None: + await db.execute( + update(models_feed.News) + .where(models_feed.News.id == news_id) + .values(status=status), + ) diff --git a/app/core/feed/endpoints_feed.py b/app/core/feed/endpoints_feed.py new file mode 100644 index 0000000000..87e05bd2b7 --- /dev/null +++ b/app/core/feed/endpoints_feed.py @@ -0,0 +1,135 @@ +import logging +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.feed import cruds_feed, schemas_feed +from app.core.feed.types_feed import NewsStatus +from app.core.groups.groups_type import GroupType +from app.core.users import models_users +from app.dependencies import ( + get_db, + is_user_a_school_member, + is_user_in, +) +from app.types.module import CoreModule +from app.utils.tools import get_file_from_data + +router = APIRouter(tags=["Feed"]) + +core_module = CoreModule( + root="feed", + tag="Feed", + router=router, + factory=None, +) + +hyperion_error_logger = logging.getLogger("hyperion.error") + + +@router.get( + "/feed/news", + response_model=list[schemas_feed.News], + status_code=200, +) +async def get_published_news( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_a_school_member), +): + """ + Return published news from the feed + """ + + return await cruds_feed.get_news(status=[NewsStatus.PUBLISHED], db=db) + + +@router.get( + "/feed/news/{news_id}/image", + response_class=FileResponse, + status_code=200, +) +async def get_news_image( + news_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_a_school_member), +): + """ + Return the image of a news + """ + + news = await cruds_feed.get_news_by_id(news_id=news_id, db=db) + if news is None: + raise HTTPException( + status_code=404, + detail="The news does not exist", + ) + + return get_file_from_data( + directory=news.image_directory, + filename=news.image_id, + ) + + +@router.get( + "/feed/admin/news", + response_model=list[schemas_feed.News], + status_code=200, +) +async def get_admin_news( + status: list[NewsStatus], + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.feed_admin)), +): + """ + Return news from the feed + + **This endpoint is only usable by feed administrators** + """ + + return await cruds_feed.get_news(status=status, db=db) + + +@router.post( + "/feed/admin/news/{news_id}/approve", + status_code=204, +) +async def approve_news( + news_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.feed_admin)), +): + """ + Approve a news + + **This endpoint is only usable by feed administrators** + """ + + return await cruds_feed.change_news_status( + news_id=news_id, + status=NewsStatus.PUBLISHED, + db=db, + ) + + +@router.post( + "/feed/admin/news/{news_id}/reject", + status_code=204, +) +async def reject_news( + news_id: UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.feed_admin)), +): + """ + Reject a news + + **This endpoint is only usable by feed administrators** + """ + + await cruds_feed.change_news_status( + news_id=news_id, + status=NewsStatus.REJECTED, + db=db, + ) diff --git a/app/core/feed/models_feed.py b/app/core/feed/models_feed.py new file mode 100644 index 0000000000..39048df65e --- /dev/null +++ b/app/core/feed/models_feed.py @@ -0,0 +1,36 @@ +from datetime import datetime +from uuid import UUID + +from sqlalchemy.orm import Mapped + +from app.core.feed.types_feed import NewsStatus +from app.types.sqlalchemy import Base, PrimaryKey + + +class News(Base): + __tablename__ = "feed_news" + + id: Mapped[PrimaryKey] + title: Mapped[str] + + start: Mapped[datetime] + end: Mapped[datetime | None] + + # Name of the entity that created the news + entity: Mapped[str] + + # The news may be related to a specific location + location: Mapped[str | None] + + # The news may be related to a specific action + # If so, the action button should be displayed at this datetime + action_start: Mapped[datetime | None] + + module: Mapped[str] + # UUID of the related object in the module database + module_object_id: Mapped[UUID] + + image_directory: Mapped[str] + image_id: Mapped[UUID] + + status: Mapped[NewsStatus] diff --git a/app/core/feed/schemas_feed.py b/app/core/feed/schemas_feed.py new file mode 100644 index 0000000000..bb55690192 --- /dev/null +++ b/app/core/feed/schemas_feed.py @@ -0,0 +1,35 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.core.feed.types_feed import NewsStatus + + +class News(BaseModel): + id: UUID + title: str + + start: datetime + end: datetime | None + + entity: str = Field(description="Name of the entity that created the news") + + location: str | None = Field( + description="The news may be related to a specific location", + ) + + action_start: datetime | None = Field( + description="The news may be related to a specific action. If so, the action button should be displayed at this datetime", + ) + + module: str + # UUID of the related object in the module database + module_object_id: UUID + + status: NewsStatus + + +class NewsComplete(News): + image_directory: str + image_id: UUID diff --git a/app/core/feed/types_feed.py b/app/core/feed/types_feed.py new file mode 100644 index 0000000000..5a0ba13088 --- /dev/null +++ b/app/core/feed/types_feed.py @@ -0,0 +1,7 @@ +from enum import Enum + + +class NewsStatus(str, Enum): + WAITING_APPROVAL = "waiting_approval" + REJECTED = "rejected" + PUBLISHED = "published" diff --git a/app/core/feed/utils_feed.py b/app/core/feed/utils_feed.py new file mode 100644 index 0000000000..d558542332 --- /dev/null +++ b/app/core/feed/utils_feed.py @@ -0,0 +1,69 @@ +import uuid +from datetime import datetime + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.feed import cruds_feed, models_feed +from app.core.feed.types_feed import NewsStatus +from app.core.groups.groups_type import GroupType +from app.core.notification.schemas_notification import Message +from app.utils.communication.notifications import NotificationTool + + +async def create_feed_news( + title: str, + start: datetime, + end: datetime | None, + entity: str, + location: str | None, + action_start: datetime | None, + module: str, + module_object_id: uuid.UUID, + image_directory: str, + image_id: uuid.UUID, + require_feed_admin_approval: bool, + db: AsyncSession, + notification_tool: NotificationTool, +): + """ + Create a news in the feed + + title: title of the news, + start: datetime corresponding to the start of the news. The news should be visible at this day + end: optional end datetime, may be used to compute the news subtitle + entity: name of the entity that created the news, usually the name of an association or a group + module: identifier of the module that created the news, may be used to open the right page in the app + module_object_id: identifier of the object that is linked to the news in the module, may be used to open the right page in the app + image_directory: folder where the image is stored, used to display the image in the news + image_id: uuid of the image is stored, used to display the image in the news, + require_feed_admin_approval: if the news can be published directly or if it requires approval from a feed administrator + """ + + news = models_feed.News( + id=uuid.uuid4(), + title=title, + start=start, + end=end, + entity=entity, + location=location, + action_start=action_start, + module=module, + module_object_id=module_object_id, + image_directory=image_directory, + image_id=image_id, + status=NewsStatus.WAITING_APPROVAL + if require_feed_admin_approval + else NewsStatus.PUBLISHED, + ) + await cruds_feed.create_news(news=news, db=db) + + if require_feed_admin_approval: + message = Message( + title="🔔 Feed - a news require approval", + content=f"{entity} has created {title}", + action_module="feed", + ) + await notification_tool.send_notification_to_group( + group_id=GroupType.feed_admin, + message=message, + ) diff --git a/app/core/groups/endpoints_groups.py b/app/core/groups/endpoints_groups.py index 70a85eee04..9dd1bd86ac 100644 --- a/app/core/groups/endpoints_groups.py +++ b/app/core/groups/endpoints_groups.py @@ -7,23 +7,26 @@ import logging import uuid -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, UploadFile +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.core.groups import cruds_groups, models_groups, schemas_groups from app.core.groups.factory_groups import CoreGroupsFactory from app.core.groups.groups_type import GroupType from app.core.notification.utils_notification import get_topics_restricted_to_group_id -from app.core.users import cruds_users +from app.core.users import cruds_users, models_users from app.dependencies import ( get_db, get_notification_manager, get_request_id, - is_user_a_school_member, + is_user, is_user_in, ) +from app.types.content_type import ContentType from app.types.module import CoreModule from app.utils.communication.notifications import NotificationManager +from app.utils.tools import get_file_from_data, save_file_as_data router = APIRouter(tags=["Groups"]) @@ -44,7 +47,7 @@ ) async def read_groups( db: AsyncSession = Depends(get_db), - user=Depends(is_user_a_school_member), + user=Depends(is_user_in(GroupType.admin)), ): """ Return all groups from database as a list of dictionaries @@ -96,15 +99,12 @@ async def create_group( detail="A group with the name {group.name} already exist", ) - try: - db_group = models_groups.CoreGroup( - id=str(uuid.uuid4()), - name=group.name, - description=group.description, - ) - return await cruds_groups.create_group(group=db_group, db=db) - except ValueError as error: - raise HTTPException(status_code=422, detail=str(error)) + db_group = models_groups.CoreGroup( + id=str(uuid.uuid4()), + name=group.name, + description=group.description, + ) + return await cruds_groups.create_group(group=db_group, db=db) @router.patch( @@ -340,3 +340,64 @@ async def delete_group( await cruds_groups.delete_membership_by_group_id(group_id=group_id, db=db) await cruds_groups.delete_group(db=db, group_id=group_id) + + +@router.post( + "/groups/{group_id}/logo", + status_code=204, +) +async def create_group_logo( + group_id: str, + image: UploadFile, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.admin)), +): + """ + Upload a logo for a group. + + **This endpoint is only usable by administrators** + """ + + group = await cruds_groups.get_group_by_id(db=db, group_id=group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + + await save_file_as_data( + upload_file=image, + directory="groups/logos", + filename=group_id, + max_file_size=4 * 1024 * 1024, + accepted_content_types=[ + ContentType.jpg, + ContentType.png, + ContentType.webp, + ], + ) + + +@router.get( + "/groups/{group_id}/logo", + response_class=FileResponse, + status_code=200, +) +async def read_user_profile_picture( + group_id: str, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user()), +): + """ + Get the logo of a group. + + **User must be authenticated** + """ + + group = await cruds_groups.get_group_by_id(db=db, group_id=group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + + return get_file_from_data( + directory="groups/logos", + filename=group_id, + default_asset="assets/images/default_profile_picture.png", + raise_http_exception=True, + ) diff --git a/app/core/groups/groups_type.py b/app/core/groups/groups_type.py index fe6165971d..2dd446f187 100644 --- a/app/core/groups/groups_type.py +++ b/app/core/groups/groups_type.py @@ -26,6 +26,7 @@ class GroupType(str, Enum): eclair = "1f841bd9-00be-41a7-96e1-860a18a46105" BDS = "61af3e52-7ef9-4608-823a-39d51e83d1db" seed_library = "09153d2a-14f4-49a4-be57-5d0f265261b9" + feed_admin = "59e3c4c2-e60f-44b6-b0d2-fa1b248423bb" # Auth related groups diff --git a/app/core/users/endpoints_users.py b/app/core/users/endpoints_users.py index 270d7ca185..032867622f 100644 --- a/app/core/users/endpoints_users.py +++ b/app/core/users/endpoints_users.py @@ -1113,7 +1113,6 @@ async def update_user( async def create_current_user_profile_picture( image: UploadFile = File(...), user: models_users.CoreUser = Depends(is_user()), - request_id: str = Depends(get_request_id), ): """ Upload a profile picture for the current user. @@ -1124,7 +1123,7 @@ async def create_current_user_profile_picture( await save_file_as_data( upload_file=image, directory="profile-pictures", - filename=str(user.id), + filename=user.id, max_file_size=4 * 1024 * 1024, accepted_content_types=[ ContentType.jpg, diff --git a/app/modules/advert/endpoints_advert.py b/app/modules/advert/endpoints_advert.py index 2654e853da..8a44562e00 100644 --- a/app/modules/advert/endpoints_advert.py +++ b/app/modules/advert/endpoints_advert.py @@ -6,6 +6,7 @@ from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession +from app.core.feed.utils_feed import create_feed_news from app.core.groups.groups_type import AccountType, GroupType from app.core.notification.schemas_notification import Message from app.core.notification.utils_notification import get_topic_by_root_and_identifier @@ -14,6 +15,7 @@ get_db, get_notification_manager, get_notification_tool, + is_user, is_user_a_school_member, is_user_in, ) @@ -179,7 +181,7 @@ async def update_advertiser( ) async def get_current_user_advertisers( db: AsyncSession = Depends(get_db), - user: models_users.CoreUser = Depends(is_user_a_school_member), + user: models_users.CoreUser = Depends(is_user()), ): """ Return all advertisers the current user can manage. @@ -269,13 +271,15 @@ async def create_advert( detail=f"Unauthorized to manage {advertiser.name} adverts", ) - advert_params = advert.model_dump() - + advert_id = uuid.uuid4() db_advert = models_advert.Advert( - id=str(uuid.uuid4()), + id=str(advert_id), date=datetime.now(UTC), advertiser=advertiser, - **advert_params, + advertiser_id=advert.advertiser_id, + title=advert.title, + content=advert.content, + post_to_feed=advert.post_to_feed, ) result = await cruds_advert.create_advert(db_advert=db_advert, db=db) @@ -297,6 +301,23 @@ async def create_advert( message=message, ) + if advert.post_to_feed: + await create_feed_news( + title=advert.title, + start=datetime.now(UTC), + end=None, + entity=advertiser.name, + location=None, + action_start=None, + module=module.root, + module_object_id=advert_id, + image_directory="adverts", + image_id=advert_id, + require_feed_admin_approval=True, + db=db, + notification_tool=notification_tool, + ) + return result diff --git a/app/modules/advert/factory_advert.py b/app/modules/advert/factory_advert.py index 521f508e8e..a4bc5327c1 100644 --- a/app/modules/advert/factory_advert.py +++ b/app/modules/advert/factory_advert.py @@ -28,9 +28,9 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: content="Nous sommes heureux de vous annoncer notre nouveau mandat !", id=str(uuid.uuid4()), date=datetime.now(UTC), - tags="important", advertiser_id=advertiser.id, advertiser=advertiser, + post_to_feed=False, ), ) @@ -41,9 +41,9 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: content="Ne cassez pas les tables svp !", id=str(uuid.uuid4()), date=datetime.now(UTC) - timedelta(days=1), - tags="important", advertiser_id=advertiser.id, advertiser=advertiser, + post_to_feed=False, ), ) @@ -61,9 +61,9 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: content="Codons comme des fous !", id=str(uuid.uuid4()), date=datetime.now(UTC) - timedelta(days=2), - tags="myecl", advertiser_id=advertiser2.id, advertiser=advertiser2, + post_to_feed=False, ), ) diff --git a/app/modules/advert/models_advert.py b/app/modules/advert/models_advert.py index c568e8e248..4ba0450865 100644 --- a/app/modules/advert/models_advert.py +++ b/app/modules/advert/models_advert.py @@ -29,7 +29,7 @@ class Advert(Base): title: Mapped[str] content: Mapped[str] date: Mapped[datetime] - tags: Mapped[str | None] + post_to_feed: Mapped[bool] advertiser: Mapped[Advertiser] = relationship( "Advertiser", lazy="joined", diff --git a/app/modules/advert/schemas_advert.py b/app/modules/advert/schemas_advert.py index bb52c24d93..17ef36b4dd 100644 --- a/app/modules/advert/schemas_advert.py +++ b/app/modules/advert/schemas_advert.py @@ -24,7 +24,10 @@ class AdvertBase(BaseModel): title: str content: str advertiser_id: str - tags: str | None = None + post_to_feed: bool = Field( + default=False, + description="If the advert should be posted in the feed. It will be pending validation be admin", + ) class AdvertComplete(AdvertBase): diff --git a/app/utils/tools.py b/app/utils/tools.py index 65775e656c..071aaa82c8 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -264,11 +264,14 @@ def get_file_path_from_data( directory: str, filename: str | UUID, default_asset: str | None = None, + raise_http_exception: bool = False, ) -> Path: """ If there is a file with the provided filename in the data folder, return it. The file extension will be inferred from the provided content file. > "data/{directory}/{filename}.ext" + Otherwise, return the default asset if provided, or raise an exception. + If `raise_http_exception`, then a 404 error will be returned, otherwise a `FileDoesNotExistError` server error will be raised The filename should be a uuid. @@ -289,13 +292,17 @@ def get_file_path_from_data( if default_asset is not None: return Path(default_asset) + if raise_http_exception: + raise HTTPException(status_code=404, detail="File does not exist") + raise FileDoesNotExistError(name=f"{directory}/{filename}.*") def get_file_from_data( directory: str, - filename: str, - default_asset: str, + filename: str | UUID, + default_asset: str | None = None, + raise_http_exception: bool = False, ) -> FileResponse: """ If there is a file with the provided filename in the data folder, return it. The file extension will be inferred from the provided content file. @@ -306,7 +313,13 @@ def get_file_from_data( WARNING: **NEVER** trust user input when calling this function. Always check that parameters are valid. """ - path = get_file_path_from_data(directory, filename, default_asset) + + path = get_file_path_from_data( + directory, + filename, + default_asset, + raise_http_exception, + ) return FileResponse(path) diff --git a/migrations/versions/40-feed.py b/migrations/versions/40-feed.py new file mode 100644 index 0000000000..8bf141582a --- /dev/null +++ b/migrations/versions/40-feed.py @@ -0,0 +1,87 @@ +"""empty message + +Create Date: 2025-08-10 15:40:44.191402 +""" + +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 = "ca44192be52b" +down_revision: str | None = "52ce71775f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +class Status(str, Enum): + WAITING_APPROVAL = "WAITING_APPROVAL" + REJECTED = "REJECTED" + PUBLISHED = "PUBLISHED" + + +def upgrade() -> None: + op.create_table( + "feed_news", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("start", TZDateTime(), nullable=False), + sa.Column("end", TZDateTime(), nullable=True), + sa.Column("entity", sa.String(), nullable=False), + sa.Column("location", sa.String(), nullable=True), + sa.Column("action_start", TZDateTime(), nullable=True), + sa.Column("module", sa.String(), nullable=False), + sa.Column("module_object_id", sa.Uuid(), nullable=False), + sa.Column("image_directory", sa.String(), nullable=False), + sa.Column("image_id", sa.Uuid(), nullable=False), + sa.Column( + "status", + sa.Enum(Status, name="newsstatus"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.add_column( + "advert_adverts", + sa.Column( + "post_to_feed", + sa.Boolean(), + nullable=False, + server_default=sa.sql.false(), + ), + ) + op.drop_column("advert_adverts", "tags") + + +def downgrade() -> None: + op.add_column( + "advert_adverts", + sa.Column("tags", sa.String(), nullable=False, server_default=""), + ) + op.drop_column("advert_adverts", "post_to_feed") + op.drop_table("feed_news") + sa.Enum(Status, name="newsstatus").drop( + op.get_bind(), + ) + + +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_advert.py b/tests/test_advert.py index b2f3ae35ce..c20eea14f1 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -64,7 +64,7 @@ async def init_objects() -> None: title="Advert", content="Example of advert", date=datetime.datetime.now(tz=datetime.UTC), - tags="Tag1, Tag2, Tag3", + post_to_feed=False, ) await add_object_to_db(advert)