From d046f7329c6de8d081cf70d607760516a053f672 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:30:53 +0200 Subject: [PATCH 1/7] Misc Contacts for PE5 --- app/modules/misc/__init__.py | 0 app/modules/misc/cruds_misc.py | 73 +++++++++++++++++ app/modules/misc/endpoints_misc.py | 121 +++++++++++++++++++++++++++++ app/modules/misc/models_misc.py | 19 +++++ app/modules/misc/schemas_misc.py | 34 ++++++++ tests/test_contact.py | 119 ++++++++++++++++++++++++++++ 6 files changed, 366 insertions(+) create mode 100644 app/modules/misc/__init__.py create mode 100644 app/modules/misc/cruds_misc.py create mode 100644 app/modules/misc/endpoints_misc.py create mode 100644 app/modules/misc/models_misc.py create mode 100644 app/modules/misc/schemas_misc.py create mode 100644 tests/test_contact.py diff --git a/app/modules/misc/__init__.py b/app/modules/misc/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/modules/misc/cruds_misc.py b/app/modules/misc/cruds_misc.py new file mode 100644 index 0000000000..e867edddac --- /dev/null +++ b/app/modules/misc/cruds_misc.py @@ -0,0 +1,73 @@ +import uuid +from collections.abc import Sequence + +from sqlalchemy import delete, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.modules.misc import models_misc, schemas_misc + + +# <-- Contacts for PE5 --> +async def get_contacts( + db: AsyncSession, +) -> Sequence[models_misc.Contacts]: + result = await db.execute(select(models_misc.Contacts)) + return result.scalars().all() + + +async def create_contact( + contact: models_misc.Contacts, + db: AsyncSession, +) -> models_misc.Contacts: + db.add(contact) + await db.commit() + return contact + + +async def update_contact( + contact_id: uuid.UUID, + contact: schemas_misc.ContactEdit, + db: AsyncSession, +): + if not bool(contact.model_fields_set): + return + + result = await db.execute( + update(models_misc.Contacts) + .where(models_misc.Contacts.id == contact_id) + .values(**contact.model_dump(exclude_none=True)), + ) + if result.rowcount == 1: + await db.commit() + else: + await db.rollback() + raise ValueError + + +async def delete_contact( + contact_id: uuid.UUID, + db: AsyncSession, +): + result = await db.execute( + delete(models_misc.Contacts).where( + models_misc.Contacts.id == contact_id, + ), + ) + if result.rowcount == 1: + await db.commit() + else: + await db.rollback() + raise ValueError + + +async def get_contact_by_id( + contact_id: uuid.UUID, + db: AsyncSession, +) -> models_misc.Contacts | None: + result = await db.execute( + select(models_misc.Contacts).where( + models_misc.Contacts.id == contact_id, + ), + ) + return result.scalars().one_or_none() +# <-- End of Contacts for PE5 --> diff --git a/app/modules/misc/endpoints_misc.py b/app/modules/misc/endpoints_misc.py new file mode 100644 index 0000000000..97b4b49ffc --- /dev/null +++ b/app/modules/misc/endpoints_misc.py @@ -0,0 +1,121 @@ +import uuid +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.groups.groups_type import AccountType, GroupType +from app.core.users import models_users +from app.dependencies import get_db, is_user_a_member, is_user_in +from app.modules.misc import cruds_misc, models_misc, schemas_misc +from app.types.module import Module + +router = APIRouter() + + +# <-- Contacts for PE5 --> +module = Module( + root="contact", + tag="Contact", + default_allowed_account_types=[AccountType.student, AccountType.staff], +) + + +@module.router.get( + "/contact/contacts", + response_model=list[schemas_misc.Contact], + status_code=200, +) +async def get_contacts( + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_a_member), +): + """ + Get contacts. + + **The user must be authenticated to use this endpoint** + This the main purpose of this endpoint, because contacts (phone numbers, emails) should not be leaked in the prevention website + """ + + return await cruds_misc.get_contacts(db=db) + + +@module.router.post( + "/contact/contacts", + response_model=schemas_misc.Contact, + status_code=201, +) +async def create_contact( + contact: schemas_misc.ContactBase, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.eclair)), +): + """ + Create a contact. + + **This endpoint is only usable by members of the group eclair** + """ + + contact_db = models_misc.Contacts( + id=uuid.uuid4(), + creation=datetime.now(UTC), + **contact.model_dump(), + ) + + return await cruds_misc.create_contact( + contact=contact_db, + db=db, + ) + + +@module.router.patch( + "/contact/contacts/{contact_id}", + status_code=204, +) +async def edit_contact( + contact_id: uuid.UUID, + contact: schemas_misc.ContactEdit, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.eclair)), +): + """ + Edit a contact. + + **This endpoint is only usable by members of the group eclair** + """ + + try: + await cruds_misc.update_contact( + contact_id=contact_id, + contact=contact, + db=db, + ) + except ValueError: + raise HTTPException(status_code=404, detail="The contact does not exist") + + +@module.router.delete( + "/contact/contacts/{contact_id}", + status_code=204, +) +async def delete_contact( + contact_id: uuid.UUID, + db: AsyncSession = Depends(get_db), + user: models_users.CoreUser = Depends(is_user_in(GroupType.BDE)), +): + """ + Delete a contact. + + **This endpoint is only usable by members of the group BDE** + """ + + try: + await cruds_misc.delete_contact( + db=db, + contact_id=contact_id, + ) + except ValueError: + raise HTTPException(status_code=404, detail="The contact does not exist") + + +# <-- End of Contacts for PE5 --> diff --git a/app/modules/misc/models_misc.py b/app/modules/misc/models_misc.py new file mode 100644 index 0000000000..72d2641198 --- /dev/null +++ b/app/modules/misc/models_misc.py @@ -0,0 +1,19 @@ +from datetime import datetime + +from sqlalchemy.orm import Mapped + +from app.types.sqlalchemy import Base, PrimaryKey + + +# <-- Contacts for PE5 --> +class Contacts(Base): + __tablename__ = "misc_contacts" + + id: Mapped[PrimaryKey] + creation: Mapped[datetime] + name: Mapped[str] + email: Mapped[str | None] + phone: Mapped[str | None] + location: Mapped[str | None] + +# <-- End of contacts PE5 --> diff --git a/app/modules/misc/schemas_misc.py b/app/modules/misc/schemas_misc.py new file mode 100644 index 0000000000..1b20b31eae --- /dev/null +++ b/app/modules/misc/schemas_misc.py @@ -0,0 +1,34 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, field_validator + +from app.utils import validators + + +# <-- Contacts for PE5 --> +class ContactBase(BaseModel): + name: str + email: str | None = None + phone: str | None = None + location: str | None = None + + +class Contact(ContactBase): + id: uuid.UUID + creation: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ContactEdit(BaseModel): + name: str | None = None + email: str | None = None + phone: str | None = None + location: str | None = None + + _normalize_email = field_validator("email")(validators.email_normalizer) + _format_phone = field_validator("phone")(validators.phone_formatter) + + +# <-- End of Contacts for PE5 --> diff --git a/tests/test_contact.py b/tests/test_contact.py new file mode 100644 index 0000000000..b82df624dc --- /dev/null +++ b/tests/test_contact.py @@ -0,0 +1,119 @@ +import uuid +from datetime import UTC, datetime + +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core.groups.groups_type import GroupType +from app.modules.misc import models_misc +from tests.commons import ( + add_object_to_db, + create_api_access_token, + create_user_with_groups, +) + +token_simple: str +token_eclair: str +contact: models_misc.Contacts + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def init_objects() -> None: + user_simple = await create_user_with_groups( + [], + ) + + global token_simple + token_simple = create_api_access_token(user_simple) + + user_eclair = await create_user_with_groups([GroupType.eclair]) + + global token_eclair + token_eclair = create_api_access_token(user_eclair) + + global contact + contact = models_misc.Contacts( + id=uuid.uuid4(), + creation=datetime.now(UTC), + name="John Doe", + phone="123456789", + email="johndoe@example.com", + location="Lyon", + ) + await add_object_to_db(contact) + + +def test_get_contacts(client: TestClient) -> None: + response = client.get( + "/contact/contacts", + headers={"Authorization": f"Bearer {token_simple}"}, + ) + assert response.status_code == 200 + + +def test_create_contact(client: TestClient) -> None: + response = client.post( + "/contact/contacts", + json={ + "name": "Jane Doe", + "phone": "987654321", + "email": "janedoe@example.com", + "location": "Lyon", + }, + headers={"Authorization": f"Bearer {token_eclair}"}, + ) + assert response.status_code == 201 + + +def test_create_contact_with_no_body(client: TestClient) -> None: + response = client.post( + "/contact/contacts", + json={}, + headers={"Authorization": f"Bearer {token_eclair}"}, + ) + assert response.status_code == 422 + + +def test_edit_contact(client: TestClient) -> None: + response = client.patch( + f"/contact/contacts/{contact.id}", + json={"name": "John Smith"}, + headers={"Authorization": f"Bearer {token_eclair}"}, + ) + assert response.status_code == 204 + + +def test_edit_contact_with_no_body(client: TestClient) -> None: + response = client.patch( + f"/contact/contacts/{contact.id}", + json={}, + headers={"Authorization": f"Bearer {token_eclair}"}, + ) + assert response.status_code == 204 + + +def test_edit_for_non_existing_contact(client: TestClient) -> None: + false_id = "098cdfb7-609a-493f-8d5a-47bbdba213da" + response = client.patch( + f"/contact/contacts/{false_id}", + json={"name": "Nonexistent Contact"}, + headers={"Authorization": f"Bearer {token_eclair}"}, + ) + assert response.status_code == 404 + + +def test_delete_contact(client: TestClient) -> None: + response = client.delete( + f"/contact/contacts/{contact.id}", + headers={"Authorization": f"Bearer {token_eclair}"}, + ) + assert response.status_code == 204 + + +def test_delete_for_non_existing_contact(client: TestClient) -> None: + false_id = "cfba17a6-58b8-4595-afb9-3c9e4e169a14" + response = client.delete( + f"/contact/contacts/{false_id}", + headers={"Authorization": f"Bearer {token_eclair}"}, + ) + assert response.status_code == 404 From 98029989097dbe4465029ea85b90eb73a5aec31b Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:41:09 +0200 Subject: [PATCH 2/7] format files --- app/modules/misc/cruds_misc.py | 2 ++ app/modules/misc/models_misc.py | 1 + 2 files changed, 3 insertions(+) diff --git a/app/modules/misc/cruds_misc.py b/app/modules/misc/cruds_misc.py index e867edddac..364e30aaec 100644 --- a/app/modules/misc/cruds_misc.py +++ b/app/modules/misc/cruds_misc.py @@ -70,4 +70,6 @@ async def get_contact_by_id( ), ) return result.scalars().one_or_none() + + # <-- End of Contacts for PE5 --> diff --git a/app/modules/misc/models_misc.py b/app/modules/misc/models_misc.py index 72d2641198..eb46d8b82f 100644 --- a/app/modules/misc/models_misc.py +++ b/app/modules/misc/models_misc.py @@ -16,4 +16,5 @@ class Contacts(Base): phone: Mapped[str | None] location: Mapped[str | None] + # <-- End of contacts PE5 --> From f0f589549972beff218da029a4838827e007b596 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:48:56 +0200 Subject: [PATCH 3/7] Missing migration --- .../versions/32-miscellaneous-contacts.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 migrations/versions/32-miscellaneous-contacts.py diff --git a/migrations/versions/32-miscellaneous-contacts.py b/migrations/versions/32-miscellaneous-contacts.py new file mode 100644 index 0000000000..944784040a --- /dev/null +++ b/migrations/versions/32-miscellaneous-contacts.py @@ -0,0 +1,56 @@ +"""miscellaneous + +Create Date: 2025-04-10 11:47:17.804600 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pytest_alembic import MigrationContext + +import sqlalchemy as sa +from alembic import op + +from app.types.sqlalchemy import TZDateTime + +# revision identifiers, used by Alembic. +revision: str = "6e70238b1070" +down_revision: str | None = "e382ba1d64c2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "misc_contacts", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("creation", TZDateTime(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("email", sa.String(), nullable=True), + sa.Column("phone", sa.String(), nullable=True), + sa.Column("location", sa.String(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("misc_contacts") + # ### 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 From 34606f4663c6674f6c0fdfc4402365aea1ffced8 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Thu, 10 Apr 2025 11:56:00 +0200 Subject: [PATCH 4/7] Failling test due to bad group checking --- app/modules/misc/endpoints_misc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/modules/misc/endpoints_misc.py b/app/modules/misc/endpoints_misc.py index 97b4b49ffc..2990e0e398 100644 --- a/app/modules/misc/endpoints_misc.py +++ b/app/modules/misc/endpoints_misc.py @@ -101,12 +101,12 @@ async def edit_contact( async def delete_contact( contact_id: uuid.UUID, db: AsyncSession = Depends(get_db), - user: models_users.CoreUser = Depends(is_user_in(GroupType.BDE)), + user: models_users.CoreUser = Depends(is_user_in(GroupType.eclair)), ): """ Delete a contact. - **This endpoint is only usable by members of the group BDE** + **This endpoint is only usable by members of the group eclair** """ try: From 6808057172a13b294b64e15a139f477be280e3df Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:21:15 +0200 Subject: [PATCH 5/7] Comments + AuthClient --- app/modules/misc/cruds_misc.py | 4 ++-- app/modules/misc/endpoints_misc.py | 4 ++-- app/modules/misc/models_misc.py | 4 ++-- app/modules/misc/schemas_misc.py | 4 ++-- app/utils/auth/providers.py | 22 ++++++++++++++++++++++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/app/modules/misc/cruds_misc.py b/app/modules/misc/cruds_misc.py index 364e30aaec..635b2a3566 100644 --- a/app/modules/misc/cruds_misc.py +++ b/app/modules/misc/cruds_misc.py @@ -7,7 +7,7 @@ from app.modules.misc import models_misc, schemas_misc -# <-- Contacts for PE5 --> +# <-- Contacts for PE5 SafetyCards --> async def get_contacts( db: AsyncSession, ) -> Sequence[models_misc.Contacts]: @@ -72,4 +72,4 @@ async def get_contact_by_id( return result.scalars().one_or_none() -# <-- End of Contacts for PE5 --> +# <-- End of Contacts for PE5 SafetyCards --> diff --git a/app/modules/misc/endpoints_misc.py b/app/modules/misc/endpoints_misc.py index 2990e0e398..c89ae0830e 100644 --- a/app/modules/misc/endpoints_misc.py +++ b/app/modules/misc/endpoints_misc.py @@ -13,7 +13,7 @@ router = APIRouter() -# <-- Contacts for PE5 --> +# <-- Contacts for PE5 SafetyCards --> module = Module( root="contact", tag="Contact", @@ -118,4 +118,4 @@ async def delete_contact( raise HTTPException(status_code=404, detail="The contact does not exist") -# <-- End of Contacts for PE5 --> +# <-- End of Contacts for PE5 SafetyCards --> diff --git a/app/modules/misc/models_misc.py b/app/modules/misc/models_misc.py index eb46d8b82f..399569c405 100644 --- a/app/modules/misc/models_misc.py +++ b/app/modules/misc/models_misc.py @@ -5,7 +5,7 @@ from app.types.sqlalchemy import Base, PrimaryKey -# <-- Contacts for PE5 --> +# <-- Contacts for PE5 SafetyCards --> class Contacts(Base): __tablename__ = "misc_contacts" @@ -17,4 +17,4 @@ class Contacts(Base): location: Mapped[str | None] -# <-- End of contacts PE5 --> +# <-- End of contacts PE5 SafetyCards --> diff --git a/app/modules/misc/schemas_misc.py b/app/modules/misc/schemas_misc.py index 1b20b31eae..d6384acecd 100644 --- a/app/modules/misc/schemas_misc.py +++ b/app/modules/misc/schemas_misc.py @@ -6,7 +6,7 @@ from app.utils import validators -# <-- Contacts for PE5 --> +# <-- Contacts for PE5 SafetyCards --> class ContactBase(BaseModel): name: str email: str | None = None @@ -31,4 +31,4 @@ class ContactEdit(BaseModel): _format_phone = field_validator("phone")(validators.phone_formatter) -# <-- End of Contacts for PE5 --> +# <-- End of Contacts for PE5 SafetyCards --> diff --git a/app/utils/auth/providers.py b/app/utils/auth/providers.py index 33d29fad9e..58da3f5869 100644 --- a/app/utils/auth/providers.py +++ b/app/utils/auth/providers.py @@ -418,3 +418,25 @@ def get_userinfo(self, user: models_users.CoreUser) -> dict[str, Any]: "name": user.full_name, "email": user.email, } + +class SafetyCardsAuthClient(BaseAuthClient): + # When set to `None`, users from any group can use the auth client + allowed_account_types: list[AccountType] | None = get_ecl_account_types() + + def get_userinfo(self, user: models_users.CoreUser) -> dict[str, Any]: + """ + See oidc specifications and `app.endpoints.auth.auth_get_userinfo` for more information: + https://openid.net/specs/openid-connect-core-1_0.html#UserInfo + + """ + # Override this method with custom information adapted for the client + # WARNING: The sub (subject) Claim MUST always be returned in the UserInfo Response. + return { + "sub": user.id, + "name": get_display_name( + firstname=user.firstname, + name=user.name, + nickname=user.nickname, + ), + "email": user.email, + } From 085129826496b6d74d37c32fb025cc6be234974f Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Fri, 11 Apr 2025 13:25:25 +0200 Subject: [PATCH 6/7] Ruff format --- app/utils/auth/providers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/utils/auth/providers.py b/app/utils/auth/providers.py index 58da3f5869..049f0f287b 100644 --- a/app/utils/auth/providers.py +++ b/app/utils/auth/providers.py @@ -419,6 +419,7 @@ def get_userinfo(self, user: models_users.CoreUser) -> dict[str, Any]: "email": user.email, } + class SafetyCardsAuthClient(BaseAuthClient): # When set to `None`, users from any group can use the auth client allowed_account_types: list[AccountType] | None = get_ecl_account_types() From 99cdeed166ad3e1cc272002d973e583aae5567e9 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Fri, 16 May 2025 18:16:07 +0200 Subject: [PATCH 7/7] Moved to coredata --- app/modules/misc/core_data_misc.py | 8 ++ app/modules/misc/cruds_misc.py | 75 ----------- app/modules/misc/endpoints_misc.py | 100 ++++----------- app/modules/misc/models_misc.py | 20 --- app/modules/misc/schemas_misc.py | 30 +---- .../versions/32-miscellaneous-contacts.py | 56 --------- tests/test_contact.py | 119 ------------------ tests/test_contacts_safety_cards.py | 53 ++++++++ 8 files changed, 89 insertions(+), 372 deletions(-) create mode 100644 app/modules/misc/core_data_misc.py delete mode 100644 app/modules/misc/cruds_misc.py delete mode 100644 app/modules/misc/models_misc.py delete mode 100644 migrations/versions/32-miscellaneous-contacts.py delete mode 100644 tests/test_contact.py create mode 100644 tests/test_contacts_safety_cards.py diff --git a/app/modules/misc/core_data_misc.py b/app/modules/misc/core_data_misc.py new file mode 100644 index 0000000000..e851ff4e3b --- /dev/null +++ b/app/modules/misc/core_data_misc.py @@ -0,0 +1,8 @@ +from app.types.core_data import BaseCoreData + + +# <-- Contacts for PE5 SafetyCards 2025 --> +class ContactSafetyCards(BaseCoreData): + contacts: str = "" + +# <-- End of Contacts for PE5 SafetyCards 2025 --> diff --git a/app/modules/misc/cruds_misc.py b/app/modules/misc/cruds_misc.py deleted file mode 100644 index 635b2a3566..0000000000 --- a/app/modules/misc/cruds_misc.py +++ /dev/null @@ -1,75 +0,0 @@ -import uuid -from collections.abc import Sequence - -from sqlalchemy import delete, select, update -from sqlalchemy.ext.asyncio import AsyncSession - -from app.modules.misc import models_misc, schemas_misc - - -# <-- Contacts for PE5 SafetyCards --> -async def get_contacts( - db: AsyncSession, -) -> Sequence[models_misc.Contacts]: - result = await db.execute(select(models_misc.Contacts)) - return result.scalars().all() - - -async def create_contact( - contact: models_misc.Contacts, - db: AsyncSession, -) -> models_misc.Contacts: - db.add(contact) - await db.commit() - return contact - - -async def update_contact( - contact_id: uuid.UUID, - contact: schemas_misc.ContactEdit, - db: AsyncSession, -): - if not bool(contact.model_fields_set): - return - - result = await db.execute( - update(models_misc.Contacts) - .where(models_misc.Contacts.id == contact_id) - .values(**contact.model_dump(exclude_none=True)), - ) - if result.rowcount == 1: - await db.commit() - else: - await db.rollback() - raise ValueError - - -async def delete_contact( - contact_id: uuid.UUID, - db: AsyncSession, -): - result = await db.execute( - delete(models_misc.Contacts).where( - models_misc.Contacts.id == contact_id, - ), - ) - if result.rowcount == 1: - await db.commit() - else: - await db.rollback() - raise ValueError - - -async def get_contact_by_id( - contact_id: uuid.UUID, - db: AsyncSession, -) -> models_misc.Contacts | None: - result = await db.execute( - select(models_misc.Contacts).where( - models_misc.Contacts.id == contact_id, - ), - ) - return result.scalars().one_or_none() - - -# <-- End of Contacts for PE5 SafetyCards --> diff --git a/app/modules/misc/endpoints_misc.py b/app/modules/misc/endpoints_misc.py index c89ae0830e..c1589c4a79 100644 --- a/app/modules/misc/endpoints_misc.py +++ b/app/modules/misc/endpoints_misc.py @@ -1,29 +1,29 @@ -import uuid -from datetime import UTC, datetime +import json -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends from sqlalchemy.ext.asyncio import AsyncSession from app.core.groups.groups_type import AccountType, GroupType from app.core.users import models_users from app.dependencies import get_db, is_user_a_member, is_user_in -from app.modules.misc import cruds_misc, models_misc, schemas_misc +from app.modules.misc import core_data_misc, schemas_misc from app.types.module import Module +from app.utils import tools router = APIRouter() -# <-- Contacts for PE5 SafetyCards --> +# <-- Contacts for PE5 SafetyCards 2025 --> module = Module( - root="contact", + root="contacts_safety_cards", tag="Contact", default_allowed_account_types=[AccountType.student, AccountType.staff], ) @module.router.get( - "/contact/contacts", - response_model=list[schemas_misc.Contact], + "/contacts_safety_cards/contacts", + response_model=list[schemas_misc.ContactBase], status_code=200, ) async def get_contacts( @@ -37,85 +37,35 @@ async def get_contacts( This the main purpose of this endpoint, because contacts (phone numbers, emails) should not be leaked in the prevention website """ - return await cruds_misc.get_contacts(db=db) - - -@module.router.post( - "/contact/contacts", - response_model=schemas_misc.Contact, - status_code=201, -) -async def create_contact( - contact: schemas_misc.ContactBase, - db: AsyncSession = Depends(get_db), - user: models_users.CoreUser = Depends(is_user_in(GroupType.eclair)), -): - """ - Create a contact. - - **This endpoint is only usable by members of the group eclair** - """ - - contact_db = models_misc.Contacts( - id=uuid.uuid4(), - creation=datetime.now(UTC), - **contact.model_dump(), - ) - - return await cruds_misc.create_contact( - contact=contact_db, - db=db, + contacts_from_core_data = await tools.get_core_data( + core_data_misc.ContactSafetyCards, + db, ) + serialized_json_contacts = contacts_from_core_data.contacts -@module.router.patch( - "/contact/contacts/{contact_id}", - status_code=204, -) -async def edit_contact( - contact_id: uuid.UUID, - contact: schemas_misc.ContactEdit, - db: AsyncSession = Depends(get_db), - user: models_users.CoreUser = Depends(is_user_in(GroupType.eclair)), -): - """ - Edit a contact. - - **This endpoint is only usable by members of the group eclair** - """ - - try: - await cruds_misc.update_contact( - contact_id=contact_id, - contact=contact, - db=db, - ) - except ValueError: - raise HTTPException(status_code=404, detail="The contact does not exist") + return json.loads(serialized_json_contacts) -@module.router.delete( - "/contact/contacts/{contact_id}", - status_code=204, +@module.router.put( + "/contacts_safety_cards/contacts", + status_code=201, ) -async def delete_contact( - contact_id: uuid.UUID, +async def set_contacts( + contacts: list[schemas_misc.ContactBase], db: AsyncSession = Depends(get_db), user: models_users.CoreUser = Depends(is_user_in(GroupType.eclair)), ): """ - Delete a contact. + Create a contact. **This endpoint is only usable by members of the group eclair** """ - try: - await cruds_misc.delete_contact( - db=db, - contact_id=contact_id, - ) - except ValueError: - raise HTTPException(status_code=404, detail="The contact does not exist") - + contacts_serialized_json = json.dumps(contacts) -# <-- End of Contacts for PE5 SafetyCards --> + await tools.set_core_data( + core_data_misc.ContactSafetyCards(contacts=contacts_serialized_json), + db, + ) +# <-- End of Contacts for PE5 SafetyCards 2025 --> diff --git a/app/modules/misc/models_misc.py b/app/modules/misc/models_misc.py deleted file mode 100644 index 399569c405..0000000000 --- a/app/modules/misc/models_misc.py +++ /dev/null @@ -1,20 +0,0 @@ -from datetime import datetime - -from sqlalchemy.orm import Mapped - -from app.types.sqlalchemy import Base, PrimaryKey - - -# <-- Contacts for PE5 SafetyCards --> -class Contacts(Base): - __tablename__ = "misc_contacts" - - id: Mapped[PrimaryKey] - creation: Mapped[datetime] - name: Mapped[str] - email: Mapped[str | None] - phone: Mapped[str | None] - location: Mapped[str | None] - - -# <-- End of contacts PE5 SafetyCards --> diff --git a/app/modules/misc/schemas_misc.py b/app/modules/misc/schemas_misc.py index d6384acecd..1542c73442 100644 --- a/app/modules/misc/schemas_misc.py +++ b/app/modules/misc/schemas_misc.py @@ -1,34 +1,10 @@ -import uuid -from datetime import datetime +from pydantic import BaseModel -from pydantic import BaseModel, ConfigDict, field_validator -from app.utils import validators - - -# <-- Contacts for PE5 SafetyCards --> +# <-- Contacts for PE5 SafetyCards 2025 --> class ContactBase(BaseModel): name: str email: str | None = None phone: str | None = None location: str | None = None - - -class Contact(ContactBase): - id: uuid.UUID - creation: datetime - - model_config = ConfigDict(from_attributes=True) - - -class ContactEdit(BaseModel): - name: str | None = None - email: str | None = None - phone: str | None = None - location: str | None = None - - _normalize_email = field_validator("email")(validators.email_normalizer) - _format_phone = field_validator("phone")(validators.phone_formatter) - - -# <-- End of Contacts for PE5 SafetyCards --> +# <--End of Contacts for PE5 SafetyCards 2025 --> diff --git a/migrations/versions/32-miscellaneous-contacts.py b/migrations/versions/32-miscellaneous-contacts.py deleted file mode 100644 index 944784040a..0000000000 --- a/migrations/versions/32-miscellaneous-contacts.py +++ /dev/null @@ -1,56 +0,0 @@ -"""miscellaneous - -Create Date: 2025-04-10 11:47:17.804600 -""" - -from collections.abc import Sequence -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from pytest_alembic import MigrationContext - -import sqlalchemy as sa -from alembic import op - -from app.types.sqlalchemy import TZDateTime - -# revision identifiers, used by Alembic. -revision: str = "6e70238b1070" -down_revision: str | None = "e382ba1d64c2" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "misc_contacts", - sa.Column("id", sa.Uuid(), nullable=False), - sa.Column("creation", TZDateTime(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("email", sa.String(), nullable=True), - sa.Column("phone", sa.String(), nullable=True), - sa.Column("location", sa.String(), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("misc_contacts") - # ### 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_contact.py b/tests/test_contact.py deleted file mode 100644 index b82df624dc..0000000000 --- a/tests/test_contact.py +++ /dev/null @@ -1,119 +0,0 @@ -import uuid -from datetime import UTC, datetime - -import pytest_asyncio -from fastapi.testclient import TestClient - -from app.core.groups.groups_type import GroupType -from app.modules.misc import models_misc -from tests.commons import ( - add_object_to_db, - create_api_access_token, - create_user_with_groups, -) - -token_simple: str -token_eclair: str -contact: models_misc.Contacts - - -@pytest_asyncio.fixture(scope="module", autouse=True) -async def init_objects() -> None: - user_simple = await create_user_with_groups( - [], - ) - - global token_simple - token_simple = create_api_access_token(user_simple) - - user_eclair = await create_user_with_groups([GroupType.eclair]) - - global token_eclair - token_eclair = create_api_access_token(user_eclair) - - global contact - contact = models_misc.Contacts( - id=uuid.uuid4(), - creation=datetime.now(UTC), - name="John Doe", - phone="123456789", - email="johndoe@example.com", - location="Lyon", - ) - await add_object_to_db(contact) - - -def test_get_contacts(client: TestClient) -> None: - response = client.get( - "/contact/contacts", - headers={"Authorization": f"Bearer {token_simple}"}, - ) - assert response.status_code == 200 - - -def test_create_contact(client: TestClient) -> None: - response = client.post( - "/contact/contacts", - json={ - "name": "Jane Doe", - "phone": "987654321", - "email": "janedoe@example.com", - "location": "Lyon", - }, - headers={"Authorization": f"Bearer {token_eclair}"}, - ) - assert response.status_code == 201 - - -def test_create_contact_with_no_body(client: TestClient) -> None: - response = client.post( - "/contact/contacts", - json={}, - headers={"Authorization": f"Bearer {token_eclair}"}, - ) - assert response.status_code == 422 - - -def test_edit_contact(client: TestClient) -> None: - response = client.patch( - f"/contact/contacts/{contact.id}", - json={"name": "John Smith"}, - headers={"Authorization": f"Bearer {token_eclair}"}, - ) - assert response.status_code == 204 - - -def test_edit_contact_with_no_body(client: TestClient) -> None: - response = client.patch( - f"/contact/contacts/{contact.id}", - json={}, - headers={"Authorization": f"Bearer {token_eclair}"}, - ) - assert response.status_code == 204 - - -def test_edit_for_non_existing_contact(client: TestClient) -> None: - false_id = "098cdfb7-609a-493f-8d5a-47bbdba213da" - response = client.patch( - f"/contact/contacts/{false_id}", - json={"name": "Nonexistent Contact"}, - headers={"Authorization": f"Bearer {token_eclair}"}, - ) - assert response.status_code == 404 - - -def test_delete_contact(client: TestClient) -> None: - response = client.delete( - f"/contact/contacts/{contact.id}", - headers={"Authorization": f"Bearer {token_eclair}"}, - ) - assert response.status_code == 204 - - -def test_delete_for_non_existing_contact(client: TestClient) -> None: - false_id = "cfba17a6-58b8-4595-afb9-3c9e4e169a14" - response = client.delete( - f"/contact/contacts/{false_id}", - headers={"Authorization": f"Bearer {token_eclair}"}, - ) - assert response.status_code == 404 diff --git a/tests/test_contacts_safety_cards.py b/tests/test_contacts_safety_cards.py new file mode 100644 index 0000000000..0b2519bccd --- /dev/null +++ b/tests/test_contacts_safety_cards.py @@ -0,0 +1,53 @@ +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core.groups.groups_type import GroupType +from tests.commons import create_api_access_token, create_user_with_groups + +token_simple: str +token_eclair: str + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def init_objects() -> None: + user_simple = await create_user_with_groups( + [], + ) + + global token_simple + token_simple = create_api_access_token(user_simple) + + user_eclair = await create_user_with_groups([GroupType.eclair]) + + global token_eclair + token_eclair = create_api_access_token(user_eclair) + + +def test_get_contacts(client: TestClient) -> None: + response = client.get( + "/contacts_safety_cards/contacts", + headers={"Authorization": f"Bearer {token_simple}"}, + ) + assert response.status_code == 200 + + +def test_set_contacts(client: TestClient) -> None: + response = client.put( + "/contacts_safety_cards/contacts/", + json=[ + { + "name": "John Doe", + "phone": "123456789", + "email": "johndoe@example.com", + "location": "Lyon", + }, + { + "name": "John Doe bis", + "phone": "323456789", + "email": "johndoebis@example.com", + "location": "Ecully", + }, + ], + headers={"Authorization": f"Bearer {token_eclair}"}, + ) + assert response.status_code == 201