From d0ad55b0f98a99ed2ea2c34bfe921bc4ca0d3ab1 Mon Sep 17 00:00:00 2001 From: armanddidierjean <95971503+armanddidierjean@users.noreply.github.com> Date: Sun, 28 Jun 2026 10:53:23 +0200 Subject: [PATCH] Calendar: update related feed news on patch --- app/core/feed/cruds_feed.py | 38 +++++- app/core/feed/utils_feed.py | 38 ++++++ app/modules/calendar/endpoints_calendar.py | 94 ++++++++++---- app/modules/calendar/utils_calendar.py | 25 +++- tests/modules/test_calendar.py | 138 +++++++++++++++++++++ 5 files changed, 306 insertions(+), 27 deletions(-) diff --git a/app/core/feed/cruds_feed.py b/app/core/feed/cruds_feed.py index 8bb9667bcf..84abe2ae24 100644 --- a/app/core/feed/cruds_feed.py +++ b/app/core/feed/cruds_feed.py @@ -1,7 +1,7 @@ from collections.abc import Sequence from uuid import UUID -from sqlalchemy import select, update +from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.feed import models_feed, schemas_feed @@ -62,6 +62,29 @@ async def change_news_status( ) +async def update_news_module_and_object_id( + module: str, + module_object_id: UUID, + new_module: str, + new_module_object_id: UUID, + db: AsyncSession, +) -> None: + """ + Change the module and module_object_id of a news in the feed + """ + await db.execute( + update(models_feed.News) + .where( + models_feed.News.module == module, + models_feed.News.module_object_id == module_object_id, + ) + .values( + module=new_module, + module_object_id=new_module_object_id, + ), + ) + + async def change_news_status_by_module_object_id( module: str, module_object_id: UUID, @@ -106,3 +129,16 @@ async def get_news_by_module_object_id( ), ) return result.scalars().first() + + +async def delete_news_by_module_object_id( + module: str, + module_object_id: UUID, + db: AsyncSession, +) -> None: + await db.execute( + delete(models_feed.News).where( + models_feed.News.module == module, + models_feed.News.module_object_id == module_object_id, + ), + ) diff --git a/app/core/feed/utils_feed.py b/app/core/feed/utils_feed.py index 13d0fca1e5..ec1d9d6db8 100644 --- a/app/core/feed/utils_feed.py +++ b/app/core/feed/utils_feed.py @@ -125,6 +125,44 @@ async def edit_feed_news( ) +async def update_news_module_and_object_id( + module: str, + module_object_id: uuid.UUID, + new_module: str, + new_module_object_id: uuid.UUID, + db: AsyncSession, +): + """ + Change the module and module_object_id of a news in the feed + """ + await cruds_feed.update_news_module_and_object_id( + module=module, + module_object_id=module_object_id, + new_module=new_module, + new_module_object_id=new_module_object_id, + db=db, + ) + + +async def delete_feed_news( + module: str, + module_object_id: uuid.UUID, + db: AsyncSession, +): + """ + Delete a news in the feed + + 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 + """ + + await cruds_feed.delete_news_by_module_object_id( + module=module, + module_object_id=module_object_id, + db=db, + ) + + async def check_if_module_object_id_is_linked_to_feed( module: str, module_object_id: uuid.UUID, diff --git a/app/modules/calendar/endpoints_calendar.py b/app/modules/calendar/endpoints_calendar.py index e96b6f374b..ce911f4916 100644 --- a/app/modules/calendar/endpoints_calendar.py +++ b/app/modules/calendar/endpoints_calendar.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.associations import cruds_associations -from app.core.feed import cruds_feed +from app.core.feed import cruds_feed, utils_feed from app.core.feed.types_feed import NewsStatus from app.core.groups.groups_type import AccountType from app.core.notification.schemas_notification import Message @@ -33,6 +33,7 @@ ) from app.modules.calendar.factory_calendar import CalendarFactory from app.modules.calendar.types_calendar import Decision +from app.modules.calendar.utils_calendar import delete_event_feed_news from app.types.content_type import ContentType from app.types.exceptions import NewlyAddedObjectInDbNotFoundError from app.types.module import Module @@ -417,7 +418,7 @@ async def add_event( "/calendar/events/{event_id}", status_code=204, ) -async def edit_envent( +async def edit_event( event_id: uuid.UUID, event_edit: schemas_calendar.EventEdit, db: AsyncSession = Depends(get_db), @@ -431,23 +432,44 @@ async def edit_envent( **Only usable by admins or members of the event's association** """ - event = await cruds_calendar.get_event(db=db, event_id=event_id) + old_event = await cruds_calendar.get_event(db=db, event_id=event_id) - if event is None: - raise HTTPException(status_code=404) + if old_event is None: + raise HTTPException(status_code=404, detail="Event not found") - if event_edit.ticket_url_opening and not event_edit.ticket_url: - if not event.ticket_url_opening: - raise HTTPException( - status_code=400, - detail="Ticket URL and opening time must be provided together", - ) - if event_edit.ticket_url and not event_edit.ticket_url_opening: - if not event.ticket_url: + previous_feed_module = ( + "tickets" if old_event.ticket_event_id else utils_calendar.root + ) + previous_feed_module_object_id = old_event.ticket_event_id or event_id + + if event_edit.ticket_event_id: + if event_edit.ticket_url or event_edit.ticket_url_opening: raise HTTPException( status_code=400, - detail="Ticket URL and opening time must be provided together", + detail="ticket_url and ticket_url_opening should not be provided when ticket_event_id is provided", ) + ticket_event = await cruds_tickets.get_event_simple_by_id( + event_id=event_edit.ticket_event_id, + db=db, + ) + if ticket_event is None: + raise HTTPException(status_code=404, detail="Ticket event not found") + event_edit.ticket_url_opening = ticket_event.open_datetime + + elif (event_edit.ticket_url_opening and not event_edit.ticket_url) or ( + event_edit.ticket_url and not event_edit.ticket_url_opening + ): + raise HTTPException( + status_code=400, + detail="Ticket URL and opening time must be provided together", + ) + + if event_edit.ticket_event_id: + # We want to set the ticket_url to None when a ticket_event_id is provided + event_edit.ticket_url = None + elif event_edit.ticket_url: + # We want to force the ticket_event_id to None when a ticket_url is provided + event_edit.ticket_event_id = None has_user_calendar_admin_access = await has_user_permission( user, @@ -458,7 +480,7 @@ async def edit_envent( if ( not is_user_member_of_an_association( user=user, - association=event.association, + association=old_event.association, ) and not has_user_calendar_admin_access ): @@ -467,8 +489,8 @@ async def edit_envent( detail="You are not allowed to edit this event", ) - new_decision = event.decision - if event.decision != Decision.pending and not has_user_calendar_admin_access: + new_decision = old_event.decision + if old_event.decision != Decision.pending and not has_user_calendar_admin_access: # If the event is not pending and the user is not a member of the group BDE, we will change the decision back to pending new_decision = Decision.pending @@ -481,11 +503,39 @@ async def edit_envent( event_db = await cruds_calendar.get_event(db=db, event_id=event_id) if event_db is None: raise NewlyAddedObjectInDbNotFoundError("event") - await utils_calendar.edit_event_feed_news( - event=event_db, - db=db, - notification_tool=notification_tool, - ) + + if old_event.decision != Decision.pending and not has_user_calendar_admin_access: + # We want to remove the feed related news if the event was previously approved + await delete_event_feed_news( + module=previous_feed_module, + module_object_id=previous_feed_module_object_id, + db=db, + ) + else: + # If we approve the event directly, we want to update the feed news + + new_feed_module = "tickets" if event_db.ticket_event_id else utils_calendar.root + new_feed_module_object_id = event_db.ticket_event_id or event_id + + if ( + new_feed_module != previous_feed_module + or new_feed_module_object_id != previous_feed_module_object_id + ): + await utils_feed.update_news_module_and_object_id( + module=previous_feed_module, + module_object_id=previous_feed_module_object_id, + new_module=new_feed_module, + new_module_object_id=new_feed_module_object_id, + db=db, + ) + + await utils_calendar.edit_event_feed_news( + event=event_db, + module=new_feed_module, + module_object_id=new_feed_module_object_id, + db=db, + notification_tool=notification_tool, + ) @module.router.patch( diff --git a/app/modules/calendar/utils_calendar.py b/app/modules/calendar/utils_calendar.py index 4bd11fb115..e1fa355283 100644 --- a/app/modules/calendar/utils_calendar.py +++ b/app/modules/calendar/utils_calendar.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.feed import schemas_feed -from app.core.feed.utils_feed import create_feed_news, edit_feed_news +from app.core.feed.utils_feed import create_feed_news, delete_feed_news, edit_feed_news from app.core.utils.config import Settings from app.modules.calendar import models_calendar from app.modules.calendar.types_calendar import Decision @@ -38,7 +38,7 @@ async def add_event_to_feed( action_start=event.ticket_url_opening, module=module_value, module_object_id=module_object_id_value, - image_directory="event", + image_directory=root, image_id=event.id, require_feed_admin_approval=False, db=db, @@ -48,12 +48,14 @@ async def add_event_to_feed( async def edit_event_feed_news( event: models_calendar.Event, + module: str, + module_object_id: UUID, db: AsyncSession, notification_tool: NotificationTool, ): await edit_feed_news( - module=root, - module_object_id=event.id, + module=module, + module_object_id=module_object_id, news_edit=schemas_feed.NewsEdit( title=event.name, start=event.start, @@ -68,6 +70,21 @@ async def edit_event_feed_news( ) +async def delete_event_feed_news( + module: str, + module_object_id: UUID, + db: AsyncSession, +): + """ + module: could be "event" or "ticket" + """ + await delete_feed_news( + module=module, + module_object_id=module_object_id, + db=db, + ) + + async def create_icalendar_file( all_events: Sequence[models_calendar.Event], settings: Settings, diff --git a/tests/modules/test_calendar.py b/tests/modules/test_calendar.py index 48325931bb..ad561b8da1 100644 --- a/tests/modules/test_calendar.py +++ b/tests/modules/test_calendar.py @@ -7,6 +7,9 @@ from app.core.associations import models_associations from app.core.groups import models_groups +from app.core.mypayment import models_mypayment +from app.core.mypayment.types_mypayment import WalletType +from app.core.tickets import models_tickets from app.core.users import models_users from app.modules.calendar import models_calendar from app.modules.calendar.endpoints_calendar import CalendarPermissions @@ -38,6 +41,9 @@ simple_user_ical_secret = "simple_user_ical_secret" +ticket_event: models_tickets.TicketEvent + + @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects() -> None: global admin_group, group_amap @@ -149,6 +155,61 @@ async def init_objects() -> None: ) await add_object_to_db(secret) + # Ticket event linked to feed + core_association = models_associations.CoreAssociation( + id=uuid.uuid4(), + name="Association", + group_id=admin_group.id, + ) + await add_object_to_db(core_association) + structure = models_mypayment.Structure( + id=uuid.uuid4(), + name="Test Structure", + creation=datetime.datetime.now(datetime.UTC), + association_membership_id=None, + manager_user_id=calendar_user_admin.id, + short_id="ABC", + siege_address_street="123 Test Street", + siege_address_city="Test City", + siege_address_zipcode="12345", + siege_address_country="Test Country", + siret="12345678901234", + iban="FR76 1234 5678 9012 3456 7890 123", + bic="AZERTYUIOP", + ) + await add_object_to_db(structure) + store_wallet = models_mypayment.Wallet( + id=uuid.uuid4(), + type=WalletType.STORE, + balance=5000, # 50€ + ) + await add_object_to_db(store_wallet) + + mypayment_store: models_mypayment.Store = models_mypayment.Store( + id=uuid.uuid4(), + name="Test Store", + structure_id=structure.id, + wallet_id=store_wallet.id, + creation=datetime.datetime.now(datetime.UTC), + association_id=core_association.id, + ) + await add_object_to_db(mypayment_store) + + global ticket_event + ticket_event = models_tickets.TicketEvent( + id=uuid.uuid4(), + name="Ticket Event", + open_datetime=datetime.datetime.now(datetime.UTC), + close_datetime=datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1), + quota=100, + disabled=False, + store_id=mypayment_store.id, + sessions=[], + categories=[], + questions=[], + ) + await add_object_to_db(ticket_event) + def test_get_all_events(client: TestClient) -> None: response = client.get( @@ -333,6 +394,16 @@ def test_add_event_non_existing_association(client: TestClient) -> None: assert response.status_code == 404 +def test_edit_event_non_existing_id(client: TestClient) -> None: + response = client.patch( + f"/calendar/events/{uuid.uuid4()}", + json={"description": "Apprendre à programmer"}, + headers={"Authorization": f"Bearer {token_amap}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Event not found" + + def test_edit_event(client: TestClient) -> None: response = client.patch( f"/calendar/events/{calendar_event.id}", @@ -342,6 +413,65 @@ def test_edit_event(client: TestClient) -> None: assert response.status_code == 204 +def test_edit_event_with_ticket_event_id_and_ticket_url(client: TestClient) -> None: + response = client.patch( + f"/calendar/events/{calendar_event.id}", + json={ + "ticket_url": "https://example.com/ticket", + "ticket_event_id": str(uuid.uuid4()), + }, + headers={"Authorization": f"Bearer {token_amap}"}, + ) + assert response.status_code == 400 + assert ( + response.json()["detail"] + == "ticket_url and ticket_url_opening should not be provided when ticket_event_id is provided" + ) + + +def test_edit_event_with_non_existing_ticket_event_id(client: TestClient) -> None: + response = client.patch( + f"/calendar/events/{calendar_event.id}", + json={ + "ticket_event_id": str(uuid.uuid4()), + }, + headers={"Authorization": f"Bearer {token_amap}"}, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Ticket event not found" + + +def test_edit_event_between_ticket_event_id_and_ticket_url(client: TestClient) -> None: + + response = client.patch( + f"/calendar/events/{calendar_event.id}", + json={ + "ticket_event_id": str(ticket_event.id), + }, + headers={"Authorization": f"Bearer {token_amap}"}, + ) + assert response.status_code == 204 + + response = client.patch( + f"/calendar/events/{calendar_event.id}", + json={ + "ticket_url": "https://example.com/ticket", + "ticket_url_opening": "2019-08-24T14:15:22Z", + }, + headers={"Authorization": f"Bearer {token_amap}"}, + ) + assert response.status_code == 204 + + response = client.patch( + f"/calendar/events/{calendar_event.id}", + json={ + "ticket_event_id": str(ticket_event.id), + }, + headers={"Authorization": f"Bearer {token_amap}"}, + ) + assert response.status_code == 204 + + def test_edit_event_with_missing_ticket_field(client: TestClient) -> None: response = client.patch( f"/calendar/events/{calendar_event_to_delete.id}", @@ -351,6 +481,10 @@ def test_edit_event_with_missing_ticket_field(client: TestClient) -> None: headers={"Authorization": f"Bearer {token_amap}"}, ) assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Ticket URL and opening time must be provided together" + ) response = client.patch( f"/calendar/events/{calendar_event_to_delete.id}", @@ -360,6 +494,10 @@ def test_edit_event_with_missing_ticket_field(client: TestClient) -> None: headers={"Authorization": f"Bearer {token_amap}"}, ) assert response.status_code == 400 + assert ( + response.json()["detail"] + == "Ticket URL and opening time must be provided together" + ) def test_edit_event_not_member(client: TestClient) -> None: