From 6917319dd11a10241e954baffe4442d0388475a9 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sat, 28 Jun 2025 21:03:58 +0200 Subject: [PATCH 01/10] Feed --- app/core/feed/__init__.py | 0 app/core/feed/cruds_feed.py | 67 ++++++++++++++++++++ app/core/feed/endpoints_feed.py | 108 ++++++++++++++++++++++++++++++++ app/core/feed/models_feed.py | 29 +++++++++ app/core/feed/schemas_feed.py | 26 ++++++++ app/core/feed/types_feed.py | 7 +++ app/core/feed/utils_feed.py | 64 +++++++++++++++++++ app/core/groups/groups_type.py | 1 + 8 files changed, 302 insertions(+) create mode 100644 app/core/feed/__init__.py create mode 100644 app/core/feed/cruds_feed.py create mode 100644 app/core/feed/endpoints_feed.py create mode 100644 app/core/feed/models_feed.py create mode 100644 app/core/feed/schemas_feed.py create mode 100644 app/core/feed/types_feed.py create mode 100644 app/core/feed/utils_feed.py 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..65a1ab10f5 --- /dev/null +++ b/app/core/feed/cruds_feed.py @@ -0,0 +1,67 @@ +from uuid import UUID + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.feed import models_feed, schemas_feed +from app.core.feed.types_feed import NewsStatus + + +async def create_news( + news: schemas_feed.News, + db: AsyncSession, +) -> None: + """ + Create a news + """ + news_model = models_feed.News( + id=news.id, + title=news.title, + start=news.start, + end=news.end, + entity=news.entity, + module=news.module, + module_object_id=news.module_object_id, + image_folder=news.image_folder, + image_id=news.image_id, + status=news.status, + ) + db.add(news_model) + + +async def get_news( + status: list[NewsStatus], + db: AsyncSession, +) -> list[schemas_feed.News]: + result = await db.execute( + select(models_feed.News).where( + models_feed.News.status.in_(status), + ), + ) + return [ + schemas_feed.News( + id=news.id, + title=news.title, + start=news.start, + end=news.end, + entity=news.entity, + module=news.module, + module_object_id=news.module_object_id, + image_folder=news.image_folder, + image_id=news.image_id, + status=news.status, + ) + for news in result.scalars().all() + ] + + +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..9ee0d8c9ba --- /dev/null +++ b/app/core/feed/endpoints_feed.py @@ -0,0 +1,108 @@ +import logging +from uuid import UUID + +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.feed import cruds_feed +from app.core.feed.models_feed import News +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_an_ecl_member, + is_user_in, +) +from app.types.module import CoreModule + +router = APIRouter(tags=["Feed"]) + +core_module = CoreModule( + root="feed", + tag="Feed", + router=router, +) + +hyperion_error_logger = logging.getLogger("hyperion.error") + + +@router.get( + "/feed/news", + response_model=list[News], + status_code=200, +) +async def get_published_news( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_an_ecl_member), +): + """ + Return published news from the feed + """ + + return await cruds_feed.get_news(status=[NewsStatus.PUBLISHED], db=db) + + +@router.get( + "/feed/admin/news", + response_model=list[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", + response_model=list[News], + status_code=200, +) +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", + response_model=list[News], + status_code=200, +) +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..2a1de41682 --- /dev/null +++ b/app/core/feed/models_feed.py @@ -0,0 +1,29 @@ +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] + + module: Mapped[str] + # UUID of the related object in the module database + module_object_id: Mapped[UUID] + + image_folder: 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..721bcae566 --- /dev/null +++ b/app/core/feed/schemas_feed.py @@ -0,0 +1,26 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + +from app.core.feed.types_feed import NewsStatus + + +class News(BaseModel): + id: UUID + title: str + + start: datetime + end: datetime | None + + # Name of the entity that created the news + entity: str + + module: str + # UUID of the related object in the module database + module_object_id: UUID + + image_folder: str + image_id: UUID + + status: NewsStatus 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..5090dde741 --- /dev/null +++ b/app/core/feed/utils_feed.py @@ -0,0 +1,64 @@ +import uuid + +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.notification.schemas_notification import Message +from app.utils.communication.notifications import NotificationTool + + +async def create_feed_news( + title: str, + start: str, + end: str | None, + entity: str, + module: str, + module_object_id: uuid.UUID, + image_folder: 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_folder: 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 = schemas_feed.News( + id=uuid.uuid4(), + title=title, + start=start, + end=end, + entity=entity, + module=module, + module_object_id=module_object_id, + image_folder=image_folder, + 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/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 From a2166947fc98fc0dbdeaeac892ed3760cded9bc2 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 6 Jul 2025 12:22:51 +0200 Subject: [PATCH 02/10] Image endpoint and location and action fields --- app/core/feed/cruds_feed.py | 48 +++++++++++++-------------------- app/core/feed/endpoints_feed.py | 44 +++++++++++++++++++++++------- app/core/feed/models_feed.py | 9 ++++++- app/core/feed/schemas_feed.py | 21 ++++++++++----- app/core/feed/utils_feed.py | 13 ++++++--- 5 files changed, 85 insertions(+), 50 deletions(-) diff --git a/app/core/feed/cruds_feed.py b/app/core/feed/cruds_feed.py index 65a1ab10f5..18e55ac216 100644 --- a/app/core/feed/cruds_feed.py +++ b/app/core/feed/cruds_feed.py @@ -1,3 +1,4 @@ +from typing import Sequence from uuid import UUID from sqlalchemy import select, update @@ -8,51 +9,38 @@ async def create_news( - news: schemas_feed.News, + news: models_feed.News, db: AsyncSession, ) -> None: """ Create a news """ - news_model = models_feed.News( - id=news.id, - title=news.title, - start=news.start, - end=news.end, - entity=news.entity, - module=news.module, - module_object_id=news.module_object_id, - image_folder=news.image_folder, - image_id=news.image_id, - status=news.status, - ) - db.add(news_model) + + db.add(news) async def get_news( status: list[NewsStatus], db: AsyncSession, -) -> list[schemas_feed.News]: +) -> Sequence[models_feed.News]: result = await db.execute( select(models_feed.News).where( models_feed.News.status.in_(status), ), ) - return [ - schemas_feed.News( - id=news.id, - title=news.title, - start=news.start, - end=news.end, - entity=news.entity, - module=news.module, - module_object_id=news.module_object_id, - image_folder=news.image_folder, - image_id=news.image_id, - status=news.status, - ) - for news in result.scalars().all() - ] + 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( diff --git a/app/core/feed/endpoints_feed.py b/app/core/feed/endpoints_feed.py index 9ee0d8c9ba..c5f4dc5ade 100644 --- a/app/core/feed/endpoints_feed.py +++ b/app/core/feed/endpoints_feed.py @@ -1,11 +1,11 @@ import logging from uuid import UUID -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession -from app.core.feed import cruds_feed -from app.core.feed.models_feed import News +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 @@ -15,6 +15,7 @@ is_user_in, ) from app.types.module import CoreModule +from app.utils.tools import get_file_from_data router = APIRouter(tags=["Feed"]) @@ -29,7 +30,7 @@ @router.get( "/feed/news", - response_model=list[News], + response_model=list[schemas_feed.News], status_code=200, ) async def get_published_news( @@ -43,9 +44,36 @@ async def get_published_news( 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_an_ecl_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[News], + response_model=list[schemas_feed.News], status_code=200, ) async def get_admin_news( @@ -64,8 +92,7 @@ async def get_admin_news( @router.post( "/feed/admin/news/{news_id}/approve", - response_model=list[News], - status_code=200, + status_code=204, ) async def approve_news( news_id: UUID, @@ -87,8 +114,7 @@ async def approve_news( @router.post( "/feed/admin/news/{news_id}/reject", - response_model=list[News], - status_code=200, + status_code=204, ) async def reject_news( news_id: UUID, diff --git a/app/core/feed/models_feed.py b/app/core/feed/models_feed.py index 2a1de41682..39048df65e 100644 --- a/app/core/feed/models_feed.py +++ b/app/core/feed/models_feed.py @@ -19,11 +19,18 @@ class News(Base): # 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_folder: Mapped[str] + 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 index 721bcae566..f74560641d 100644 --- a/app/core/feed/schemas_feed.py +++ b/app/core/feed/schemas_feed.py @@ -1,7 +1,7 @@ from datetime import datetime from uuid import UUID -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.core.feed.types_feed import NewsStatus @@ -13,14 +13,23 @@ class News(BaseModel): start: datetime end: datetime | None - # Name of the entity that created the news - entity: str + 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 - image_folder: str - image_id: UUID - status: NewsStatus + + +class NewsComplete(News): + image_directory: str + image_id: UUID diff --git a/app/core/feed/utils_feed.py b/app/core/feed/utils_feed.py index 5090dde741..ab9ea14545 100644 --- a/app/core/feed/utils_feed.py +++ b/app/core/feed/utils_feed.py @@ -1,8 +1,9 @@ import uuid +from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession -from app.core.feed import cruds_feed, schemas_feed +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 @@ -11,9 +12,11 @@ async def create_feed_news( title: str, - start: str, - end: str | None, + start: datetime, + end: datetime | None, entity: str, + location: str | None, + action_start: datetime | None, module: str, module_object_id: uuid.UUID, image_folder: str, @@ -36,12 +39,14 @@ async def create_feed_news( require_feed_admin_approval: if the news can be published directly or if it requires approval from a feed administrator """ - news = schemas_feed.News( + 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_folder=image_folder, From 6ddbcb32c6b1b948e2f8b39906c8a9d19d56c168 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 6 Jul 2025 12:24:01 +0200 Subject: [PATCH 03/10] Lint --- app/core/feed/cruds_feed.py | 4 ++-- app/core/feed/schemas_feed.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/core/feed/cruds_feed.py b/app/core/feed/cruds_feed.py index 18e55ac216..61d2f27f39 100644 --- a/app/core/feed/cruds_feed.py +++ b/app/core/feed/cruds_feed.py @@ -1,10 +1,10 @@ -from typing import Sequence +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, schemas_feed +from app.core.feed import models_feed from app.core.feed.types_feed import NewsStatus diff --git a/app/core/feed/schemas_feed.py b/app/core/feed/schemas_feed.py index f74560641d..bb55690192 100644 --- a/app/core/feed/schemas_feed.py +++ b/app/core/feed/schemas_feed.py @@ -16,11 +16,11 @@ class News(BaseModel): 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" + 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" + description="The news may be related to a specific action. If so, the action button should be displayed at this datetime", ) module: str From 60d6c1648bce49280ad89ced2ec7de6d3182b6e8 Mon Sep 17 00:00:00 2001 From: Thonyk Date: Tue, 5 Aug 2025 21:17:26 +0000 Subject: [PATCH 04/10] fix: rebase --- app/core/feed/endpoints_feed.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/core/feed/endpoints_feed.py b/app/core/feed/endpoints_feed.py index c5f4dc5ade..3e362ec31c 100644 --- a/app/core/feed/endpoints_feed.py +++ b/app/core/feed/endpoints_feed.py @@ -23,6 +23,7 @@ root="feed", tag="Feed", router=router, + factory=None, ) hyperion_error_logger = logging.getLogger("hyperion.error") From 5a531115eeb8d818de18a2b2b98781e5e9272154 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:05:40 +0200 Subject: [PATCH 05/10] Add a logo for groups --- app/core/groups/endpoints_groups.py | 87 ++++++++++++++++++++++++----- app/core/users/endpoints_users.py | 3 +- app/utils/tools.py | 15 ++++- 3 files changed, 89 insertions(+), 16 deletions(-) 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/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/utils/tools.py b/app/utils/tools.py index 65775e656c..ca1df53691 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,6 +292,9 @@ 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}.*") @@ -296,6 +302,7 @@ def get_file_from_data( directory: str, filename: str, default_asset: str, + 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) From 18cd1ef25ef8c631fce9a8797d2310a917f55dd7 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:36:34 +0200 Subject: [PATCH 06/10] Feed: rename image_folder to image_directory --- app/core/feed/utils_feed.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/feed/utils_feed.py b/app/core/feed/utils_feed.py index ab9ea14545..d558542332 100644 --- a/app/core/feed/utils_feed.py +++ b/app/core/feed/utils_feed.py @@ -19,7 +19,7 @@ async def create_feed_news( action_start: datetime | None, module: str, module_object_id: uuid.UUID, - image_folder: str, + image_directory: str, image_id: uuid.UUID, require_feed_admin_approval: bool, db: AsyncSession, @@ -34,7 +34,7 @@ async def create_feed_news( 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_folder: folder where the image is stored, used to display the image in the news + 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 """ @@ -49,7 +49,7 @@ async def create_feed_news( action_start=action_start, module=module, module_object_id=module_object_id, - image_folder=image_folder, + image_directory=image_directory, image_id=image_id, status=NewsStatus.WAITING_APPROVAL if require_feed_admin_approval From 188ff3c7f55f47c840f476831c6ace016cd16193 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:36:50 +0200 Subject: [PATCH 07/10] Advert post to feed and remove tag --- app/modules/advert/endpoints_advert.py | 31 +++++++++++++++++++++----- app/modules/advert/factory_advert.py | 3 --- app/modules/advert/models_advert.py | 2 +- app/modules/advert/schemas_advert.py | 5 ++++- 4 files changed, 31 insertions(+), 10 deletions(-) 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..b8fd6bf248 100644 --- a/app/modules/advert/factory_advert.py +++ b/app/modules/advert/factory_advert.py @@ -28,7 +28,6 @@ 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, ), @@ -41,7 +40,6 @@ 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, ), @@ -61,7 +59,6 @@ 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, ), 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): From 83c376556bbe548d76a969c3d81d9a62946f31c6 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:51:41 +0200 Subject: [PATCH 08/10] Fix rebase --- app/core/feed/endpoints_feed.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/core/feed/endpoints_feed.py b/app/core/feed/endpoints_feed.py index 3e362ec31c..87e05bd2b7 100644 --- a/app/core/feed/endpoints_feed.py +++ b/app/core/feed/endpoints_feed.py @@ -11,7 +11,7 @@ from app.core.users import models_users from app.dependencies import ( get_db, - is_user_an_ecl_member, + is_user_a_school_member, is_user_in, ) from app.types.module import CoreModule @@ -36,7 +36,7 @@ ) async def get_published_news( db: AsyncSession = Depends(get_db), - user: models_users.CoreUser = Depends(is_user_an_ecl_member), + user: models_users.CoreUser = Depends(is_user_a_school_member), ): """ Return published news from the feed @@ -53,7 +53,7 @@ async def get_published_news( async def get_news_image( news_id: UUID, db: AsyncSession = Depends(get_db), - user: models_users.CoreUser = Depends(is_user_an_ecl_member), + user: models_users.CoreUser = Depends(is_user_a_school_member), ): """ Return the image of a news From cc156162e1295bf4af0b06b84dc0451f30949470 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 10 Aug 2025 15:52:02 +0200 Subject: [PATCH 09/10] Lint and migration --- app/modules/advert/factory_advert.py | 3 + app/utils/tools.py | 4 +- migrations/versions/39-feed.py | 87 ++++++++++++++++++++++++++++ tests/test_advert.py | 2 +- 4 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 migrations/versions/39-feed.py diff --git a/app/modules/advert/factory_advert.py b/app/modules/advert/factory_advert.py index b8fd6bf248..a4bc5327c1 100644 --- a/app/modules/advert/factory_advert.py +++ b/app/modules/advert/factory_advert.py @@ -30,6 +30,7 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: date=datetime.now(UTC), advertiser_id=advertiser.id, advertiser=advertiser, + post_to_feed=False, ), ) @@ -42,6 +43,7 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: date=datetime.now(UTC) - timedelta(days=1), advertiser_id=advertiser.id, advertiser=advertiser, + post_to_feed=False, ), ) @@ -61,6 +63,7 @@ async def run(cls, db: AsyncSession, settings: Settings) -> None: date=datetime.now(UTC) - timedelta(days=2), advertiser_id=advertiser2.id, advertiser=advertiser2, + post_to_feed=False, ), ) diff --git a/app/utils/tools.py b/app/utils/tools.py index ca1df53691..071aaa82c8 100644 --- a/app/utils/tools.py +++ b/app/utils/tools.py @@ -300,8 +300,8 @@ def get_file_path_from_data( 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: """ diff --git a/migrations/versions/39-feed.py b/migrations/versions/39-feed.py new file mode 100644 index 0000000000..6dcda803a0 --- /dev/null +++ b/migrations/versions/39-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 = "5a034ba207dd" +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) From bedf610902f74a6e5565ed6b95c19b782acfeb3d Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Fri, 15 Aug 2025 11:39:00 +0200 Subject: [PATCH 10/10] Rebase migration --- migrations/versions/{39-feed.py => 40-feed.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{39-feed.py => 40-feed.py} (98%) diff --git a/migrations/versions/39-feed.py b/migrations/versions/40-feed.py similarity index 98% rename from migrations/versions/39-feed.py rename to migrations/versions/40-feed.py index 6dcda803a0..8bf141582a 100644 --- a/migrations/versions/39-feed.py +++ b/migrations/versions/40-feed.py @@ -17,7 +17,7 @@ # revision identifiers, used by Alembic. revision: str = "ca44192be52b" -down_revision: str | None = "5a034ba207dd" +down_revision: str | None = "52ce71775f" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None