From 8c2dbbba0e1b5bbb7d7898790bf4bc4d1bc7176e Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 01/29] Init pmf module --- app/modules/pmf/__init__.py | 0 app/modules/pmf/endpoints_pmf.py | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 app/modules/pmf/__init__.py create mode 100644 app/modules/pmf/endpoints_pmf.py diff --git a/app/modules/pmf/__init__.py b/app/modules/pmf/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py new file mode 100644 index 0000000000..cddb86e9f0 --- /dev/null +++ b/app/modules/pmf/endpoints_pmf.py @@ -0,0 +1,9 @@ +from app.core.groups.groups_type import AccountType +from app.types.module import Module + +module = Module( + root="pmf", + tag="Pmf", + default_allowed_account_types=[AccountType.student, AccountType.staff], + factory=None, +) From fe3dbaf70f05925f8a0a5fcf30cca442c116cb5e Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 02/29] MVP WIP --- app/modules/pmf/cruds_pmf.py | 160 ++++++++++++++++++++ app/modules/pmf/endpoints_pmf.py | 248 ++++++++++++++++++++++++++++++- app/modules/pmf/models_pmf.py | 70 +++++++++ app/modules/pmf/schemas_pmf.py | 50 +++++++ app/modules/pmf/types_pmf.py | 15 ++ 5 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 app/modules/pmf/cruds_pmf.py create mode 100644 app/modules/pmf/models_pmf.py create mode 100644 app/modules/pmf/schemas_pmf.py create mode 100644 app/modules/pmf/types_pmf.py diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py new file mode 100644 index 0000000000..b28e807b4b --- /dev/null +++ b/app/modules/pmf/cruds_pmf.py @@ -0,0 +1,160 @@ +from uuid import UUID + +from sqlalchemy import delete, select, true, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.users import schemas_users +from app.modules.pmf import models_pmf, schemas_pmf, types_pmf + + +async def create_offer(offer: schemas_pmf.OfferSimple, db: AsyncSession) -> None: + """Create a new PMF offer with associated tags.""" + db.add( + models_pmf.PmfOffer( + id=offer.id, + author_id=offer.author_id, + company_name=offer.company_name, + title=offer.title, + description=offer.description, + offer_type=offer.offer_type, + location=offer.location, + location_type=offer.location_type, + start_date=offer.start_date, + end_date=offer.end_date, + duration=offer.duration, + tags=[], + ), + ) + + +async def update_offer( + offer_id: UUID, + structure_update: schemas_pmf.OfferUpdate, + db: AsyncSession, +) -> None: + await db.execute( + update(models_pmf.PmfOffer) + .where(models_pmf.PmfOffer.id == offer_id) + .values(**structure_update.model_dump(exclude_unset=True)), + ) + + +async def delete_offer(offer_id: UUID, db: AsyncSession) -> None: + await db.execute( + delete(models_pmf.PmfOffer).where(models_pmf.PmfOffer.id == offer_id), + ) + + +async def get_offer_by_id( + offer_id: UUID, + db: AsyncSession, +) -> models_pmf.PmfOffer | None: + result = await db.execute( + select(models_pmf.PmfOffer).where(models_pmf.PmfOffer.id == offer_id), + ) + return result.scalars().first() + + +async def get_offers( + db: AsyncSession, + included_offer_types: list[types_pmf.OfferType] | None = None, + included_tags: list[str] | None = None, + included_location_types: list[types_pmf.LocationType] | None = None, +) -> list[schemas_pmf.OfferComplete]: + where_clause = ( + ( + models_pmf.PmfOffer.offer_type.in_(included_offer_types) + if included_offer_types + else true() + ) + & ( + models_pmf.PmfOffer.tags.any(models_pmf.Tags.tag.in_(included_tags)) + if included_tags + else true() + ) + & ( + models_pmf.PmfOffer.location_type.in_(included_location_types) + if included_location_types + else true() + ) + ) + + offers = await db.execute( + select(models_pmf.PmfOffer).where(where_clause), + ) + return [ + schemas_pmf.OfferComplete( + id=offer.id, + author_id=offer.author_id, + company_name=offer.company_name, + title=offer.title, + description=offer.description, + offer_type=offer.offer_type, + location=offer.location, + location_type=offer.location_type, + start_date=offer.start_date, + end_date=offer.end_date, + duration=offer.duration, + author=schemas_users.CoreUserSimple.model_validate(offer.author), + tags=[schemas_pmf.TagComplete.model_validate(tag) for tag in offer.tags], + ) + for offer in offers.scalars().all() + ] + +async def get_all_tags(db: AsyncSession) -> list[schemas_pmf.TagComplete]: + tags = await db.execute( + select(models_pmf.Tags).distinct(models_pmf.Tags.tag), + ) + return [ + schemas_pmf.TagComplete.model_validate(tag) + for tag in tags.scalars().all() + ] + +async def get_tag_by_name( + tag_name: str, + db: AsyncSession, +) -> schemas_pmf.TagComplete | None: + result = await db.execute( + select(models_pmf.Tags).where(models_pmf.Tags.tag == tag_name), + ) + return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + +async def get_tag_by_id( + tag_id: UUID, + db: AsyncSession, +) -> schemas_pmf.TagComplete | None: + result = await db.execute( + select(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), + ) + return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + +async def create_tag( + tag: schemas_pmf.TagComplete, + db: AsyncSession, +) -> None: + tag_db = models_pmf.Tags( + id=tag.id, + tag=tag.tag, + ) + db.add(tag_db) + +async def update_tag( + tag_id: UUID, + tag_update: schemas_pmf.TagBase, + db: AsyncSession, +) -> None: + await db.execute( + update(models_pmf.Tags) + .where(models_pmf.Tags.id == tag_id) + .values(**tag_update.model_dump(exclude_unset=True)), + ) + +async def delete_tag( + tag_id: UUID, + db: AsyncSession, +) -> None: + + await db.execute( + delete(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), + ) + diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index cddb86e9f0..a6a6ad1942 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -1,9 +1,253 @@ -from app.core.groups.groups_type import AccountType +import uuid +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.groups.groups_type import AccountType, GroupType +from app.core.users.models_users import CoreUser +from app.dependencies import is_user, is_user_in +from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf from app.types.module import Module +from app.utils.tools import is_user_member_of_any_group + +router = APIRouter(tags=["pmf"]) module = Module( root="pmf", tag="Pmf", - default_allowed_account_types=[AccountType.student, AccountType.staff], + default_allowed_account_types=[ + AccountType.student, + AccountType.staff, + AccountType.former_student, + ], factory=None, ) + + +@router.get( + "/pmf/offers/{offer_id}", + response_model=schemas_pmf.OfferComplete, + status_code=200, +) +async def get_offer( + offer_id: UUID, + db: AsyncSession, + # Allow only former students to access this endpoint + # user: CoreUser = Depends(is_user(included_account_types=[AccountType.former_student])), +): + offer = await cruds_pmf.get_offer_by_id( + offer_id=offer_id, + db=db, + ) + + if not offer: + raise HTTPException(status_code=404, detail="Offer not found") + + return offer + + +@router.get( + "/pmf/offers/", + response_model=list[schemas_pmf.OfferSimple], + status_code=200, +) +async def get_offers( + db: AsyncSession, + includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), + includedTags: list[str] = Query(default=[]), + includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), + # Allow only former students to access this endpoint + # user: CoreUser = Depends(is_user(included_account_types=[AccountType.former_student])), +): + return await cruds_pmf.get_offers( + db=db, + included_offer_types=includedOfferTypes, + included_tags=includedTags, + included_location_types=includedLocationTypes, + ) + + +@router.post( + "/pmf/offer/", + response_model=list[schemas_pmf.OfferComplete], + status_code=200, +) +async def create_offer( + db: AsyncSession, + offer: schemas_pmf.OfferBase, + # Allow only former students to create offer + user: CoreUser = Depends( + is_user(included_account_types=[AccountType.former_student]), + ), +): + # Only admin can post offers on behalf of others + if offer.author_id != user.id and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], + ): + raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + + offer_db = schemas_pmf.OfferSimple( + **offer.model_dump(), + id=uuid.uuid4(), + author_id=user.id, + ) + return await cruds_pmf.create_offer(db=db, offer=offer_db) + +@router.put( + "/pmf/offer/{offer_id}", + response_model=None, + status_code=204, +) +async def update_offer( + offer_id: UUID, + db: AsyncSession, + offer_update: schemas_pmf.OfferUpdate, + # Allow only former students to update offer + user: CoreUser = Depends( + is_user(included_account_types=[AccountType.former_student]), + ), +): + offer_db = await cruds_pmf.get_offer_by_id(offer_id=offer_id, db=db) + if not offer_db: + raise HTTPException(status_code=404, detail="Offer not found") + + # Only the author or admin can update the offer + if ( + offer_db.author_id != user.id + and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], + ) + ): + raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + + await cruds_pmf.update_offer( + offer_id=offer_id, + structure_update=offer_update, + db=db, + ) + +@router.delete( + "/pmf/offer/{offer_id}", + response_model=None, + status_code=204, +) +async def delete_offer( + offer_id: UUID, + db: AsyncSession, + # Allow only former students to delete offer + user: CoreUser = Depends( + is_user(included_account_types=[AccountType.former_student]), + ), +): + offer_db = await cruds_pmf.get_offer_by_id(offer_id=offer_id, db=db) + if not offer_db: + raise HTTPException(status_code=404, detail="Offer not found") + + # Only the author or admin can delete the offer + if ( + offer_db.author_id != user.id + and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], + ) + ): + raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + + await cruds_pmf.delete_offer(offer_id=offer_id, db=db) + +@router.get( + "/pmf/tags/", + response_model=list[schemas_pmf.TagComplete], + status_code=200, +) +async def get_all_tags( + db: AsyncSession, +) -> list[schemas_pmf.TagComplete]: + return await cruds_pmf.get_all_tags(db=db) + +@router.get( + "/pmf/tag/{tag_id}", + response_model=schemas_pmf.TagComplete | None, + status_code=200, +) +async def get_tag( + tag_id: UUID, + db: AsyncSession, +) -> schemas_pmf.TagComplete | None: + tags = await cruds_pmf.get_all_tags(db=db) + for tag in tags: + if tag.id == tag_id: + return tag + return None + +@router.post( + "/pmf/tag/", + response_model=schemas_pmf.TagComplete, + status_code=201, +) +async def create_tag( + tag: schemas_pmf.TagBase, + db: AsyncSession, + # Allow only admin to create tags + user: CoreUser = Depends( + is_user_in(group_id=GroupType.admin), + ), +): + existing_tag = await cruds_pmf.get_tag_by_name(tag_name=tag.tag, db=db) + if existing_tag: + raise HTTPException(status_code=400, detail="Tag already exists") + + tag_db = schemas_pmf.TagComplete( + **tag.model_dump(), + id=uuid.uuid4(), + ) + await cruds_pmf.create_tag(tag=tag_db, db=db) + return tag_db + +@router.put( + "/pmf/tag/{tag_id}", + response_model=None, + status_code=204, +) +async def update_tag( + tag_id: UUID, + tag_update: schemas_pmf.TagBase, + db: AsyncSession, + # Allow only admin to update tags + user: CoreUser = Depends( + is_user_in(group_id=GroupType.admin), + ), +): + existing_tag = await cruds_pmf.get_tag_by_id(tag_id=tag_id, db=db) + if not existing_tag: + raise HTTPException(status_code=404, detail="Tag not found") + + await cruds_pmf.update_tag(tag_id=tag_id, tag_update=tag_update, db=db) + +@router.delete( + "/pmf/tag/{tag_id}", + response_model=None, + status_code=204, +) +async def delete_tag( + tag_id: UUID, + db: AsyncSession, + # Allow only admin to delete tags + user: CoreUser = Depends( + is_user_in(group_id=GroupType.admin), + ), +): + existing_tag = await cruds_pmf.get_tag_by_id(tag_id=tag_id, db=db) + if not existing_tag: + raise HTTPException(status_code=404, detail="Tag not found") + + await cruds_pmf.delete_tag(tag_id=tag_id, db=db) diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py new file mode 100644 index 0000000000..ae86c396b7 --- /dev/null +++ b/app/modules/pmf/models_pmf.py @@ -0,0 +1,70 @@ +from datetime import date +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.modules.pmf.types_pmf import LocationType, OfferType +from app.types.sqlalchemy import Base, PrimaryKey + +if TYPE_CHECKING: + from app.core.users.models_users import CoreUser + + +class OfferTags(Base): + __tablename__ = "pmf_offer_tags" + + offer_id: Mapped[UUID] = mapped_column( + ForeignKey("pmf_offers.id"), primary_key=True, + ) + tag_id: Mapped[UUID] = mapped_column(ForeignKey("pmf_tags.id"), primary_key=True) + + +class PmfOffer(Base): + __tablename__ = "pmf_offers" + + id: Mapped[PrimaryKey] + + # TODO: Decide if the offer can remain if the author is deleted + author_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + author: Mapped["CoreUser"] = relationship( + init=False, + lazy="joined", + innerjoin=True, # INNER JOIN since author_id is NOT NULL + ) + + company_name: Mapped[str] + title: Mapped[str] + description: Mapped[str] + offer_type: Mapped[OfferType] + location: Mapped[str] + location_type: Mapped[LocationType] # Enum (On_site, Hybrid, Remote) + + start_date: Mapped[date] + end_date: Mapped[date] + duration: Mapped[int] # days + + tags: Mapped[list["OfferTags"]] = relationship( + "Tags", + back_populates="offers", + lazy="selectin", # Small collection + secondary="pmf_offer_tags", + default_factory=list, + ) + + +class Tags(Base): + __tablename__ = "pmf_tags" + + id: Mapped[PrimaryKey] + tag: Mapped[str] + + created_at: Mapped[date] = mapped_column(default=date.today) + + offers: Mapped[list["OfferTags"]] = relationship( + "PmfOffer", + back_populates="tags", + secondary="pmf_offer_tags", + default_factory=list, + ) diff --git a/app/modules/pmf/schemas_pmf.py b/app/modules/pmf/schemas_pmf.py new file mode 100644 index 0000000000..5dce778ee5 --- /dev/null +++ b/app/modules/pmf/schemas_pmf.py @@ -0,0 +1,50 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + +from app.core.users import schemas_users +from app.modules.pmf.types_pmf import LocationType, OfferType + + +class TagBase(BaseModel): + tag: str + +class TagComplete(TagBase): + id: UUID + + +class OfferBase(BaseModel): + author_id: str + + company_name: str + title: str + description: str + offer_type: OfferType + location: str + location_type: LocationType + + start_date: datetime + end_date: datetime + duration: int # days + +class OfferSimple(OfferBase): + id: UUID + +class OfferUpdate(BaseModel): + author_id: str | None = None + company_name: str | None = None + title: str | None = None + description: str | None = None + offer_type: OfferType | None = None + location: str | None = None + location_type: LocationType | None = None + start_date: datetime | None = None + end_date: datetime | None = None + duration: int | None = None # days + + tags: list[TagBase] | None = None + +class OfferComplete(OfferSimple): + author: schemas_users.CoreUserSimple + tags: list[TagComplete] diff --git a/app/modules/pmf/types_pmf.py b/app/modules/pmf/types_pmf.py new file mode 100644 index 0000000000..cd9e8cc57f --- /dev/null +++ b/app/modules/pmf/types_pmf.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class OfferType(str, Enum): # for the T-shirt and the bike + TFE = "TFE" + S_APP = "Stage_Application" + EXE = "EXE" + CDI = "CDI" + CDD = "CDD" + + +class LocationType(str, Enum): + On_site = "On_site" + Hybrid = "Hybrid" + Remote = "Remote" From 1feb068309a87a278098133fa51f3372f929e73d Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 03/29] Lint --- app/modules/pmf/cruds_pmf.py | 13 ++++----- app/modules/pmf/endpoints_pmf.py | 45 ++++++++++++++++++-------------- app/modules/pmf/models_pmf.py | 3 ++- app/modules/pmf/schemas_pmf.py | 4 +++ 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index b28e807b4b..84a9c9689d 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -101,14 +101,13 @@ async def get_offers( for offer in offers.scalars().all() ] + async def get_all_tags(db: AsyncSession) -> list[schemas_pmf.TagComplete]: tags = await db.execute( select(models_pmf.Tags).distinct(models_pmf.Tags.tag), ) - return [ - schemas_pmf.TagComplete.model_validate(tag) - for tag in tags.scalars().all() - ] + return [schemas_pmf.TagComplete.model_validate(tag) for tag in tags.scalars().all()] + async def get_tag_by_name( tag_name: str, @@ -119,6 +118,7 @@ async def get_tag_by_name( ) return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + async def get_tag_by_id( tag_id: UUID, db: AsyncSession, @@ -128,6 +128,7 @@ async def get_tag_by_id( ) return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + async def create_tag( tag: schemas_pmf.TagComplete, db: AsyncSession, @@ -138,6 +139,7 @@ async def create_tag( ) db.add(tag_db) + async def update_tag( tag_id: UUID, tag_update: schemas_pmf.TagBase, @@ -149,12 +151,11 @@ async def update_tag( .values(**tag_update.model_dump(exclude_unset=True)), ) + async def delete_tag( tag_id: UUID, db: AsyncSession, ) -> None: - await db.execute( delete(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), ) - diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index a6a6ad1942..d0011c1c5f 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -88,7 +88,9 @@ async def create_offer( GroupType.admin, ], ): - raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + raise HTTPException( + status_code=403, detail="Forbidden, you are not the author of this offer" + ) offer_db = schemas_pmf.OfferSimple( **offer.model_dump(), @@ -97,6 +99,7 @@ async def create_offer( ) return await cruds_pmf.create_offer(db=db, offer=offer_db) + @router.put( "/pmf/offer/{offer_id}", response_model=None, @@ -116,16 +119,15 @@ async def update_offer( raise HTTPException(status_code=404, detail="Offer not found") # Only the author or admin can update the offer - if ( - offer_db.author_id != user.id - and not is_user_member_of_any_group( - user, - [ - GroupType.admin, - ], - ) + if offer_db.author_id != user.id and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], ): - raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + raise HTTPException( + status_code=403, detail="Forbidden, you are not the author of this offer" + ) await cruds_pmf.update_offer( offer_id=offer_id, @@ -133,6 +135,7 @@ async def update_offer( db=db, ) + @router.delete( "/pmf/offer/{offer_id}", response_model=None, @@ -151,19 +154,19 @@ async def delete_offer( raise HTTPException(status_code=404, detail="Offer not found") # Only the author or admin can delete the offer - if ( - offer_db.author_id != user.id - and not is_user_member_of_any_group( - user, - [ - GroupType.admin, - ], - ) + if offer_db.author_id != user.id and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], ): - raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + raise HTTPException( + status_code=403, detail="Forbidden, you are not the author of this offer" + ) await cruds_pmf.delete_offer(offer_id=offer_id, db=db) + @router.get( "/pmf/tags/", response_model=list[schemas_pmf.TagComplete], @@ -174,6 +177,7 @@ async def get_all_tags( ) -> list[schemas_pmf.TagComplete]: return await cruds_pmf.get_all_tags(db=db) + @router.get( "/pmf/tag/{tag_id}", response_model=schemas_pmf.TagComplete | None, @@ -189,6 +193,7 @@ async def get_tag( return tag return None + @router.post( "/pmf/tag/", response_model=schemas_pmf.TagComplete, @@ -213,6 +218,7 @@ async def create_tag( await cruds_pmf.create_tag(tag=tag_db, db=db) return tag_db + @router.put( "/pmf/tag/{tag_id}", response_model=None, @@ -233,6 +239,7 @@ async def update_tag( await cruds_pmf.update_tag(tag_id=tag_id, tag_update=tag_update, db=db) + @router.delete( "/pmf/tag/{tag_id}", response_model=None, diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py index ae86c396b7..fc1bcad43a 100644 --- a/app/modules/pmf/models_pmf.py +++ b/app/modules/pmf/models_pmf.py @@ -16,7 +16,8 @@ class OfferTags(Base): __tablename__ = "pmf_offer_tags" offer_id: Mapped[UUID] = mapped_column( - ForeignKey("pmf_offers.id"), primary_key=True, + ForeignKey("pmf_offers.id"), + primary_key=True, ) tag_id: Mapped[UUID] = mapped_column(ForeignKey("pmf_tags.id"), primary_key=True) diff --git a/app/modules/pmf/schemas_pmf.py b/app/modules/pmf/schemas_pmf.py index 5dce778ee5..338e372f6d 100644 --- a/app/modules/pmf/schemas_pmf.py +++ b/app/modules/pmf/schemas_pmf.py @@ -10,6 +10,7 @@ class TagBase(BaseModel): tag: str + class TagComplete(TagBase): id: UUID @@ -28,9 +29,11 @@ class OfferBase(BaseModel): end_date: datetime duration: int # days + class OfferSimple(OfferBase): id: UUID + class OfferUpdate(BaseModel): author_id: str | None = None company_name: str | None = None @@ -45,6 +48,7 @@ class OfferUpdate(BaseModel): tags: list[TagBase] | None = None + class OfferComplete(OfferSimple): author: schemas_users.CoreUserSimple tags: list[TagComplete] From 16d8b556aea6d97c557a08a59e6131ba6bb20ad4 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 04/29] Pagination --- app/modules/pmf/cruds_pmf.py | 4 +++- app/modules/pmf/endpoints_pmf.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index 84a9c9689d..c6435aed43 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -60,6 +60,8 @@ async def get_offers( included_offer_types: list[types_pmf.OfferType] | None = None, included_tags: list[str] | None = None, included_location_types: list[types_pmf.LocationType] | None = None, + limit: int | None = None, + offset: int | None = None, ) -> list[schemas_pmf.OfferComplete]: where_clause = ( ( @@ -80,7 +82,7 @@ async def get_offers( ) offers = await db.execute( - select(models_pmf.PmfOffer).where(where_clause), + select(models_pmf.PmfOffer).where(where_clause).limit(limit).offset(offset), ) return [ schemas_pmf.OfferComplete( diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index d0011c1c5f..829aae623b 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -57,6 +57,8 @@ async def get_offers( includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), includedTags: list[str] = Query(default=[]), includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), + limit: int | None = Query(default=50, gt=0, le=50), + offset: int | None = Query(default=0, ge=0), # Allow only former students to access this endpoint # user: CoreUser = Depends(is_user(included_account_types=[AccountType.former_student])), ): @@ -89,7 +91,8 @@ async def create_offer( ], ): raise HTTPException( - status_code=403, detail="Forbidden, you are not the author of this offer" + status_code=403, + detail="Forbidden, you are not the author of this offer", ) offer_db = schemas_pmf.OfferSimple( @@ -126,7 +129,8 @@ async def update_offer( ], ): raise HTTPException( - status_code=403, detail="Forbidden, you are not the author of this offer" + status_code=403, + detail="Forbidden, you are not the author of this offer", ) await cruds_pmf.update_offer( @@ -161,7 +165,8 @@ async def delete_offer( ], ): raise HTTPException( - status_code=403, detail="Forbidden, you are not the author of this offer" + status_code=403, + detail="Forbidden, you are not the author of this offer", ) await cruds_pmf.delete_offer(offer_id=offer_id, db=db) From 994385fe857a1ef505b5eab076fb2170bbd72e2d Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 05/29] Added get_tests --- app/modules/pmf/endpoints_pmf.py | 24 ++--- app/modules/pmf/models_pmf.py | 4 +- tests/modules/test_pmf.py | 163 +++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 tests/modules/test_pmf.py diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index 829aae623b..db1c439c3e 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -6,7 +6,7 @@ from app.core.groups.groups_type import AccountType, GroupType from app.core.users.models_users import CoreUser -from app.dependencies import is_user, is_user_in +from app.dependencies import get_db, is_user, is_user_in from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf from app.types.module import Module from app.utils.tools import is_user_member_of_any_group @@ -32,7 +32,7 @@ ) async def get_offer( offer_id: UUID, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only former students to access this endpoint # user: CoreUser = Depends(is_user(included_account_types=[AccountType.former_student])), ): @@ -53,7 +53,7 @@ async def get_offer( status_code=200, ) async def get_offers( - db: AsyncSession, + db: AsyncSession = Depends(get_db), includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), includedTags: list[str] = Query(default=[]), includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), @@ -67,6 +67,8 @@ async def get_offers( included_offer_types=includedOfferTypes, included_tags=includedTags, included_location_types=includedLocationTypes, + limit=limit, + offset=offset, ) @@ -76,8 +78,8 @@ async def get_offers( status_code=200, ) async def create_offer( - db: AsyncSession, offer: schemas_pmf.OfferBase, + db: AsyncSession = Depends(get_db), # Allow only former students to create offer user: CoreUser = Depends( is_user(included_account_types=[AccountType.former_student]), @@ -110,8 +112,8 @@ async def create_offer( ) async def update_offer( offer_id: UUID, - db: AsyncSession, offer_update: schemas_pmf.OfferUpdate, + db: AsyncSession = Depends(get_db), # Allow only former students to update offer user: CoreUser = Depends( is_user(included_account_types=[AccountType.former_student]), @@ -147,7 +149,7 @@ async def update_offer( ) async def delete_offer( offer_id: UUID, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only former students to delete offer user: CoreUser = Depends( is_user(included_account_types=[AccountType.former_student]), @@ -178,7 +180,7 @@ async def delete_offer( status_code=200, ) async def get_all_tags( - db: AsyncSession, + db: AsyncSession = Depends(get_db), ) -> list[schemas_pmf.TagComplete]: return await cruds_pmf.get_all_tags(db=db) @@ -190,7 +192,7 @@ async def get_all_tags( ) async def get_tag( tag_id: UUID, - db: AsyncSession, + db: AsyncSession = Depends(get_db), ) -> schemas_pmf.TagComplete | None: tags = await cruds_pmf.get_all_tags(db=db) for tag in tags: @@ -206,7 +208,7 @@ async def get_tag( ) async def create_tag( tag: schemas_pmf.TagBase, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only admin to create tags user: CoreUser = Depends( is_user_in(group_id=GroupType.admin), @@ -232,7 +234,7 @@ async def create_tag( async def update_tag( tag_id: UUID, tag_update: schemas_pmf.TagBase, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only admin to update tags user: CoreUser = Depends( is_user_in(group_id=GroupType.admin), @@ -252,7 +254,7 @@ async def update_tag( ) async def delete_tag( tag_id: UUID, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only admin to delete tags user: CoreUser = Depends( is_user_in(group_id=GroupType.admin), diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py index fc1bcad43a..7a27e48efd 100644 --- a/app/modules/pmf/models_pmf.py +++ b/app/modules/pmf/models_pmf.py @@ -46,6 +46,8 @@ class PmfOffer(Base): end_date: Mapped[date] duration: Mapped[int] # days + created_at: Mapped[date] = mapped_column(insert_default=date.today) + tags: Mapped[list["OfferTags"]] = relationship( "Tags", back_populates="offers", @@ -61,7 +63,7 @@ class Tags(Base): id: Mapped[PrimaryKey] tag: Mapped[str] - created_at: Mapped[date] = mapped_column(default=date.today) + created_at: Mapped[date] = mapped_column(insert_default=date.today) offers: Mapped[list["OfferTags"]] = relationship( "PmfOffer", diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py new file mode 100644 index 0000000000..dffba3a5fc --- /dev/null +++ b/tests/modules/test_pmf.py @@ -0,0 +1,163 @@ +import uuid +from datetime import date + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core.groups.groups_type import AccountType, GroupType +from app.core.users import models_users +from app.modules.pmf import models_pmf, schemas_pmf + +# We need to import event_loop for pytest-asyncio routine defined bellow +from app.modules.pmf.types_pmf import LocationType, OfferType +from tests.commons import ( + add_object_to_db, + create_api_access_token, + create_user_with_groups, +) + +not_alumni_user: models_users.CoreUser +student_user: models_users.CoreUser +alumni_user: models_users.CoreUser + +tag1_id = uuid.UUID("0b7dc7bf-0ab4-421a-bbe7-7ec064fcec8d") +tag2_id = uuid.UUID("1c8dc7bf-1ab4-421a-bbe7-7ec064fcec8d") +tag_fake_id = uuid.UUID("5e8ec7bf-4ab4-421a-bbe7-7ec064fcec8d") + +offer1_id = uuid.UUID("2b7dc7bf-2ab4-421a-bbe7-7ec064fcec8d") +offer2_id = uuid.UUID("3c8dc7bf-3ab4-421a-bbe7-7ec064fcec8d") +offer3_id = uuid.UUID("4d9ec7bf-4ab4-421a-bbe7-7ec064fcec8d") +offer_fake_id = uuid.UUID("5e9ec7bf-0ab4-421a-bbe7-7ec064fcec8d") + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def init_objects(): + global not_alumni_user, student_user, alumni_user + + # We create an user in the test database + not_alumni_user = await create_user_with_groups( + groups=[], + account_type=AccountType.external, + ) + student_user = await create_user_with_groups( + groups=[], + account_type=AccountType.student, + ) + alumni_user = await create_user_with_groups( + groups=[], + account_type=AccountType.former_student, + ) + + tag_aero = models_pmf.Tags( + tag="Aeronautics", + id=tag1_id, + created_at=date(2023, 5, 1), + ) + tag_ai = models_pmf.Tags( + tag="Artificial Intelligence", + id=tag2_id, + created_at=date(2023, 5, 1), + ) + await add_object_to_db(tag_aero) + await add_object_to_db(tag_ai) + + # Creat 5 offers + offer_1 = models_pmf.PmfOffer( + id=offer1_id, + author_id=alumni_user.id, + company_name="AeroCorp", + title="Aerospace Engineer Internship", + description="Join our team to work on cutting-edge aerospace projects.", + offer_type=OfferType.TFE, + location="Toulouse, France", + location_type=LocationType.On_site, + start_date=date(2023, 6, 1), + end_date=date(2023, 8, 31), + created_at=date(2023, 5, 1), + duration=92, + ) + await add_object_to_db(offer_1) + offer_tag = models_pmf.OfferTags(offer_id=offer_1.id, tag_id=tag_aero.id) + await add_object_to_db(offer_tag) + + offer_2 = models_pmf.PmfOffer( + id=offer2_id, + author_id=alumni_user.id, + company_name="TechAI", + title="AI Research Internship", + description="Work on innovative AI research projects with our expert team.", + offer_type=OfferType.S_APP, + location="Remote", + location_type=LocationType.Remote, + start_date=date(2023, 7, 1), + end_date=date(2023, 9, 30), + created_at=date(2023, 6, 1), + duration=92, + ) + await add_object_to_db(offer_2) + offer_tag = models_pmf.OfferTags(offer_id=offer_2.id, tag_id=tag_ai.id) + await add_object_to_db(offer_tag) + + # A 3rd offer with the two tags + offer_3 = models_pmf.PmfOffer( + id=offer3_id, + author_id=alumni_user.id, + company_name="RoboAero", + title="Robotics and Aerospace Internship", + description="Combine robotics and aerospace in this exciting internship.", + offer_type=OfferType.TFE, + location="Hybrid - Paris, France / Remote", + location_type=LocationType.Hybrid, + start_date=date(2023, 8, 1), + end_date=date(2023, 10, 31), + created_at=date(2023, 7, 1), + duration=92, + ) + await add_object_to_db(offer_3) + offer_tag1 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag_aero.id) + offer_tag2 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag_ai.id) + await add_object_to_db(offer_tag1) + await add_object_to_db(offer_tag2) + + +@pytest.mark.parametrize( + ("offer_id", "expected_code"), + [ + (offer1_id, 200), + (offer2_id, 200), + (offer3_id, 200), + (offer_fake_id, 404), + ], +) +def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): + response = client.get( + f"/offers/{offer_id}", + ) + assert response.status_code == expected_code + + +@pytest.mark.parametrize( + ("query", "expected_code", "expected_length"), + [ + ("", 200, 3), + (f"?tag={tag1_id}", 200, 2), + (f"?tag={tag2_id}", 200, 2), + (f"?tag={tag1_id}&tag={tag2_id}", 200, 1), + (f"?tag={tag_fake_id}", 200, 0), + (f"?offer_type={OfferType.TFE}", 200, 2), + (f"?offer_type={OfferType.S_APP}", 200, 1), + (f"?location_type={LocationType.On_site}", 200, 1), + (f"?location_type={LocationType.Remote}", 200, 1), + ("?location_type=Fake", 200, 1), + (f"?tag={tag1_id}&offer_type={OfferType.TFE}", 200, 2), + ], +) +def test_get_offers( + query: uuid.UUID, expected_code: int, expected_length: int, client: TestClient +): + response = client.get( + f"/offers{query}", + ) + assert response.status_code == expected_code + assert len(response.json()) == expected_length From 67eaeffac3731c35f255e579c43ebde689aee41e Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 06/29] added migrations --- migrations/versions/45-pmf.py | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 migrations/versions/45-pmf.py diff --git a/migrations/versions/45-pmf.py b/migrations/versions/45-pmf.py new file mode 100644 index 0000000000..f54fac3d5c --- /dev/null +++ b/migrations/versions/45-pmf.py @@ -0,0 +1,88 @@ +"""PMF + +Create Date: 2025-11-22 19:49:38.136247 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Union + +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 = "ca5a9c9c64e5" +down_revision: str | None = "91fadc90f892" +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( + "pmf_tags", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("tag", sa.String(), nullable=False), + sa.Column("created_at", sa.Date(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "pmf_offers", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("author_id", sa.String(), nullable=False), + sa.Column("company_name", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column( + "offer_type", + sa.Enum("TFE", "S_APP", "EXE", "CDI", "CDD", name="offertype"), + nullable=False, + ), + sa.Column("location", sa.String(), nullable=False), + sa.Column( + "location_type", + sa.Enum("On_site", "Hybrid", "Remote", name="locationtype"), + nullable=False, + ), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=False), + sa.Column("duration", sa.Integer(), nullable=False), + sa.Column("created_at", sa.Date(), nullable=False), + sa.ForeignKeyConstraint(["author_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "pmf_offer_tags", + sa.Column("offer_id", sa.Uuid(), nullable=False), + sa.Column("tag_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["offer_id"], ["pmf_offers.id"]), + sa.ForeignKeyConstraint(["tag_id"], ["pmf_tags.id"]), + sa.PrimaryKeyConstraint("offer_id", "tag_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("pmf_offer_tags") + op.drop_table("pmf_offers") + op.drop_table("pmf_tags") + # ### 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 d6aacf16b87ffcd76482e9b92eae0fcd7039f087 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 07/29] Fixed get tests --- app/modules/pmf/cruds_pmf.py | 12 ++++++- app/modules/pmf/endpoints_pmf.py | 5 ++- app/modules/pmf/models_pmf.py | 4 +-- app/modules/pmf/schemas_pmf.py | 11 +++--- tests/modules/test_pmf.py | 58 ++++++++++++++++++++------------ 5 files changed, 60 insertions(+), 30 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index c6435aed43..7600bb8b5d 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -1,3 +1,4 @@ +from datetime import date from uuid import UUID from sqlalchemy import delete, select, true, update @@ -22,6 +23,7 @@ async def create_offer(offer: schemas_pmf.OfferSimple, db: AsyncSession) -> None start_date=offer.start_date, end_date=offer.end_date, duration=offer.duration, + created_at=date.today(), tags=[], ), ) @@ -98,7 +100,14 @@ async def get_offers( end_date=offer.end_date, duration=offer.duration, author=schemas_users.CoreUserSimple.model_validate(offer.author), - tags=[schemas_pmf.TagComplete.model_validate(tag) for tag in offer.tags], + tags=[ + schemas_pmf.TagComplete( + id=tag.id, + tag=tag.tag, + created_at=tag.created_at, + ) + for tag in offer.tags + ], ) for offer in offers.scalars().all() ] @@ -138,6 +147,7 @@ async def create_tag( tag_db = models_pmf.Tags( id=tag.id, tag=tag.tag, + created_at=tag.created_at, ) db.add(tag_db) diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index db1c439c3e..cfb69f2714 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -1,4 +1,5 @@ import uuid +from datetime import date from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query @@ -16,6 +17,7 @@ module = Module( root="pmf", tag="Pmf", + router=router, default_allowed_account_types=[ AccountType.student, AccountType.staff, @@ -41,7 +43,7 @@ async def get_offer( db=db, ) - if not offer: + if offer is None: raise HTTPException(status_code=404, detail="Offer not found") return offer @@ -221,6 +223,7 @@ async def create_tag( tag_db = schemas_pmf.TagComplete( **tag.model_dump(), id=uuid.uuid4(), + created_at=date.today(), ) await cruds_pmf.create_tag(tag=tag_db, db=db) return tag_db diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py index 7a27e48efd..0861fef665 100644 --- a/app/modules/pmf/models_pmf.py +++ b/app/modules/pmf/models_pmf.py @@ -48,7 +48,7 @@ class PmfOffer(Base): created_at: Mapped[date] = mapped_column(insert_default=date.today) - tags: Mapped[list["OfferTags"]] = relationship( + tags: Mapped[list["Tags"]] = relationship( "Tags", back_populates="offers", lazy="selectin", # Small collection @@ -65,7 +65,7 @@ class Tags(Base): created_at: Mapped[date] = mapped_column(insert_default=date.today) - offers: Mapped[list["OfferTags"]] = relationship( + offers: Mapped[list["PmfOffer"]] = relationship( "PmfOffer", back_populates="tags", secondary="pmf_offer_tags", diff --git a/app/modules/pmf/schemas_pmf.py b/app/modules/pmf/schemas_pmf.py index 338e372f6d..8a242cb4ba 100644 --- a/app/modules/pmf/schemas_pmf.py +++ b/app/modules/pmf/schemas_pmf.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date from uuid import UUID from pydantic import BaseModel @@ -13,6 +13,7 @@ class TagBase(BaseModel): class TagComplete(TagBase): id: UUID + created_at: date class OfferBase(BaseModel): @@ -25,8 +26,8 @@ class OfferBase(BaseModel): location: str location_type: LocationType - start_date: datetime - end_date: datetime + start_date: date + end_date: date duration: int # days @@ -42,8 +43,8 @@ class OfferUpdate(BaseModel): offer_type: OfferType | None = None location: str | None = None location_type: LocationType | None = None - start_date: datetime | None = None - end_date: datetime | None = None + start_date: date | None = None + end_date: date | None = None duration: int | None = None # days tags: list[TagBase] | None = None diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py index dffba3a5fc..97f61c21a4 100644 --- a/tests/modules/test_pmf.py +++ b/tests/modules/test_pmf.py @@ -22,7 +22,9 @@ alumni_user: models_users.CoreUser tag1_id = uuid.UUID("0b7dc7bf-0ab4-421a-bbe7-7ec064fcec8d") +tag1: models_pmf.Tags tag2_id = uuid.UUID("1c8dc7bf-1ab4-421a-bbe7-7ec064fcec8d") +tag2: models_pmf.Tags tag_fake_id = uuid.UUID("5e8ec7bf-4ab4-421a-bbe7-7ec064fcec8d") offer1_id = uuid.UUID("2b7dc7bf-2ab4-421a-bbe7-7ec064fcec8d") @@ -30,6 +32,9 @@ offer3_id = uuid.UUID("4d9ec7bf-4ab4-421a-bbe7-7ec064fcec8d") offer_fake_id = uuid.UUID("5e9ec7bf-0ab4-421a-bbe7-7ec064fcec8d") +not_alumni_token: str +student_token: str +alumni_token: str @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects(): @@ -49,18 +54,24 @@ async def init_objects(): account_type=AccountType.former_student, ) - tag_aero = models_pmf.Tags( + global not_alumni_token, student_token, alumni_token + not_alumni_token = create_api_access_token(not_alumni_user) + student_token = create_api_access_token(student_user) + alumni_token = create_api_access_token(alumni_user) + + global tag1, tag2 + tag1 = models_pmf.Tags( tag="Aeronautics", id=tag1_id, created_at=date(2023, 5, 1), ) - tag_ai = models_pmf.Tags( + tag2 = models_pmf.Tags( tag="Artificial Intelligence", id=tag2_id, created_at=date(2023, 5, 1), ) - await add_object_to_db(tag_aero) - await add_object_to_db(tag_ai) + await add_object_to_db(tag1) + await add_object_to_db(tag2) # Creat 5 offers offer_1 = models_pmf.PmfOffer( @@ -78,7 +89,7 @@ async def init_objects(): duration=92, ) await add_object_to_db(offer_1) - offer_tag = models_pmf.OfferTags(offer_id=offer_1.id, tag_id=tag_aero.id) + offer_tag = models_pmf.OfferTags(offer_id=offer_1.id, tag_id=tag1.id) await add_object_to_db(offer_tag) offer_2 = models_pmf.PmfOffer( @@ -96,7 +107,7 @@ async def init_objects(): duration=92, ) await add_object_to_db(offer_2) - offer_tag = models_pmf.OfferTags(offer_id=offer_2.id, tag_id=tag_ai.id) + offer_tag = models_pmf.OfferTags(offer_id=offer_2.id, tag_id=tag2.id) await add_object_to_db(offer_tag) # A 3rd offer with the two tags @@ -115,8 +126,8 @@ async def init_objects(): duration=92, ) await add_object_to_db(offer_3) - offer_tag1 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag_aero.id) - offer_tag2 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag_ai.id) + offer_tag1 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag1.id) + offer_tag2 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag2.id) await add_object_to_db(offer_tag1) await add_object_to_db(offer_tag2) @@ -132,7 +143,8 @@ async def init_objects(): ) def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): response = client.get( - f"/offers/{offer_id}", + f"/pmf/offers/{offer_id}", + headers={"Authorization": f"Bearer {student_token}"}, ) assert response.status_code == expected_code @@ -141,23 +153,27 @@ def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): ("query", "expected_code", "expected_length"), [ ("", 200, 3), - (f"?tag={tag1_id}", 200, 2), - (f"?tag={tag2_id}", 200, 2), - (f"?tag={tag1_id}&tag={tag2_id}", 200, 1), - (f"?tag={tag_fake_id}", 200, 0), - (f"?offer_type={OfferType.TFE}", 200, 2), - (f"?offer_type={OfferType.S_APP}", 200, 1), - (f"?location_type={LocationType.On_site}", 200, 1), - (f"?location_type={LocationType.Remote}", 200, 1), - ("?location_type=Fake", 200, 1), - (f"?tag={tag1_id}&offer_type={OfferType.TFE}", 200, 2), + ("?includedTags=Aeronautics", 200, 2), + ("?includedTags=Artificial+Intelligence", 200, 2), + ("?includedTags=Aeronautics&includedTags=Artificial+Intelligence", 200, 3), + ("?includedTags=Fake", 200, 0), + (f"?includedOfferTypes={OfferType.TFE.value}", 200, 2), + (f"?includedOfferTypes={OfferType.S_APP.value}", 200, 1), + (f"?includedLocationTypes={LocationType.On_site.value}", 200, 1), + (f"?includedLocationTypes={LocationType.Remote.value}", 200, 1), + ("?includedLocationTypes=FakeLocation", 422, 0), + (f"?includedTags=Aeronautics&includedOfferTypes={OfferType.TFE.value}", 200, 2), + ("?limit=2", 200, 2), + ("?offset=1", 200, 2), + ("?limit=2&offset=2", 200, 1), ], ) def test_get_offers( query: uuid.UUID, expected_code: int, expected_length: int, client: TestClient ): response = client.get( - f"/offers{query}", + f"/pmf/offers/{query}", ) assert response.status_code == expected_code - assert len(response.json()) == expected_length + if expected_code == 200: + assert len(response.json()) == expected_length \ No newline at end of file From baf8e7943ad5c11fc56437fd4bf071fbbe096e62 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 08/29] Working tests --- app/modules/pmf/cruds_pmf.py | 25 ++- app/modules/pmf/endpoints_pmf.py | 22 +- tests/modules/test_pmf.py | 341 ++++++++++++++++++++++++++++++- 3 files changed, 370 insertions(+), 18 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index 7600bb8b5d..8741edeeb1 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -42,6 +42,10 @@ async def update_offer( async def delete_offer(offer_id: UUID, db: AsyncSession) -> None: + # First, delete associations in pmf_offer_tags + await db.execute( + delete(models_pmf.OfferTags).where(models_pmf.OfferTags.offer_id == offer_id), + ) await db.execute( delete(models_pmf.PmfOffer).where(models_pmf.PmfOffer.id == offer_id), ) @@ -117,27 +121,34 @@ async def get_all_tags(db: AsyncSession) -> list[schemas_pmf.TagComplete]: tags = await db.execute( select(models_pmf.Tags).distinct(models_pmf.Tags.tag), ) - return [schemas_pmf.TagComplete.model_validate(tag) for tag in tags.scalars().all()] + return [ + schemas_pmf.TagComplete( + id=tag.id, + tag=tag.tag, + created_at=tag.created_at, + ) + for tag in tags.scalars().all() + ] async def get_tag_by_name( tag_name: str, db: AsyncSession, -) -> schemas_pmf.TagComplete | None: +) -> models_pmf.Tags | None: result = await db.execute( select(models_pmf.Tags).where(models_pmf.Tags.tag == tag_name), ) - return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + return result.scalars().first() async def get_tag_by_id( tag_id: UUID, db: AsyncSession, -) -> schemas_pmf.TagComplete | None: +) -> models_pmf.Tags | None: result = await db.execute( select(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), ) - return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + return result.scalars().first() async def create_tag( @@ -168,6 +179,10 @@ async def delete_tag( tag_id: UUID, db: AsyncSession, ) -> None: + # First, delete associations in pmf_offer_tags + await db.execute( + delete(models_pmf.OfferTags).where(models_pmf.OfferTags.tag_id == tag_id), + ) await db.execute( delete(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), ) diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index cfb69f2714..c8c8dc30f5 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -76,7 +76,7 @@ async def get_offers( @router.post( "/pmf/offer/", - response_model=list[schemas_pmf.OfferComplete], + response_model=schemas_pmf.OfferComplete, status_code=200, ) async def create_offer( @@ -102,9 +102,10 @@ async def create_offer( offer_db = schemas_pmf.OfferSimple( **offer.model_dump(), id=uuid.uuid4(), - author_id=user.id, ) - return await cruds_pmf.create_offer(db=db, offer=offer_db) + await cruds_pmf.create_offer(db=db, offer=offer_db) + await db.flush() + return await cruds_pmf.get_offer_by_id(offer_id=offer_db.id, db=db) @router.put( @@ -195,12 +196,15 @@ async def get_all_tags( async def get_tag( tag_id: UUID, db: AsyncSession = Depends(get_db), -) -> schemas_pmf.TagComplete | None: - tags = await cruds_pmf.get_all_tags(db=db) - for tag in tags: - if tag.id == tag_id: - return tag - return None +) -> schemas_pmf.TagComplete: + tag = await cruds_pmf.get_tag_by_id(tag_id=tag_id, db=db) + if not tag: + raise HTTPException(status_code=404, detail="Tag not found") + return schemas_pmf.TagComplete( + tag=tag.tag, + id=tag.id, + created_at=tag.created_at, + ) @router.post( diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py index 97f61c21a4..2b1bf2b580 100644 --- a/tests/modules/test_pmf.py +++ b/tests/modules/test_pmf.py @@ -20,6 +20,7 @@ not_alumni_user: models_users.CoreUser student_user: models_users.CoreUser alumni_user: models_users.CoreUser +admin_user: models_users.CoreUser tag1_id = uuid.UUID("0b7dc7bf-0ab4-421a-bbe7-7ec064fcec8d") tag1: models_pmf.Tags @@ -35,10 +36,12 @@ not_alumni_token: str student_token: str alumni_token: str +admin_token: str + @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects(): - global not_alumni_user, student_user, alumni_user + global not_alumni_user, student_user, alumni_user, admin_user # We create an user in the test database not_alumni_user = await create_user_with_groups( @@ -53,11 +56,16 @@ async def init_objects(): groups=[], account_type=AccountType.former_student, ) + admin_user = await create_user_with_groups( + groups=[GroupType.admin], + account_type=AccountType.former_student, + ) - global not_alumni_token, student_token, alumni_token + global not_alumni_token, student_token, alumni_token, admin_token not_alumni_token = create_api_access_token(not_alumni_user) student_token = create_api_access_token(student_user) alumni_token = create_api_access_token(alumni_user) + admin_token = create_api_access_token(admin_user) global tag1, tag2 tag1 = models_pmf.Tags( @@ -169,11 +177,336 @@ def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): ], ) def test_get_offers( - query: uuid.UUID, expected_code: int, expected_length: int, client: TestClient + query: uuid.UUID, + expected_code: int, + expected_length: int, + client: TestClient, ): response = client.get( f"/pmf/offers/{query}", ) assert response.status_code == expected_code if expected_code == 200: - assert len(response.json()) == expected_length \ No newline at end of file + assert len(response.json()) == expected_length + + +# Tests for POST, PUT, DELETE offers +@pytest.mark.parametrize( + ("token", "author_id", "expected_code"), + [ + ("alumni_token", "alumni_user", 200), # Alumni can create offer for themselves + ("admin_token", "alumni_user", 200), # Admin can create offer for others + ("student_token", "student_user", 403), # Student cannot create offers + ( + "not_alumni_token", + "not_alumni_user", + 403, + ), # External user cannot create offers + ( + "alumni_token", + "admin_user", + 403, + ), # Alumni cannot create offer for others (non-admin) + ], +) +def test_create_offer( + token: str, + author_id: str, + expected_code: int, + client: TestClient, +): + # Get the actual token and user id + actual_token = globals()[token] + actual_author_id = globals()[author_id].id + + offer_data = { + "author_id": actual_author_id, + "company_name": "Test Company", + "title": "Test Position", + "description": "This is a test offer description", + "offer_type": OfferType.TFE.value, + "location": "Test City", + "location_type": LocationType.On_site.value, + "start_date": "2024-01-01", + "end_date": "2024-06-30", + "duration": 181, + } + + response = client.post( + "/pmf/offer/", + json=offer_data, + headers={"Authorization": f"Bearer {actual_token}"}, + ) + assert response.status_code == expected_code + + +def test_update_offer_success(client: TestClient): + """Test successful offer update by the author""" + offer_update = { + "title": "Updated Title", + "description": "Updated description", + "company_name": "Updated Company", + } + + response = client.put( + f"/pmf/offer/{offer1_id}", + json=offer_update, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 204 + + +def test_update_offer_by_admin(client: TestClient): + """Test successful offer update by admin""" + offer_update = { + "title": "Admin Updated Title", + } + + response = client.put( + f"/pmf/offer/{offer2_id}", + json=offer_update, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + +def test_update_offer_forbidden(client: TestClient): + """Test forbidden offer update by non-author""" + offer_update = { + "title": "Unauthorized Update", + } + + response = client.put( + f"/pmf/offer/{offer1_id}", + json=offer_update, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +def test_update_nonexistent_offer(client: TestClient): + """Test update of non-existent offer""" + offer_update = { + "title": "Updated Title", + } + + response = client.put( + f"/pmf/offer/{offer_fake_id}", + json=offer_update, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 404 + + +def test_delete_offer_success(client: TestClient): + """Test successful offer deletion by the author""" + response = client.delete( + f"/pmf/offer/{offer3_id}", + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 204 + + +def test_delete_offer_by_admin(client: TestClient): + """Test successful offer deletion by admin""" + # First create a new offer to delete + offer_data = { + "author_id": alumni_user.id, + "company_name": "Delete Test Company", + "title": "Delete Test Position", + "description": "This offer will be deleted by admin", + "offer_type": OfferType.S_APP.value, + "location": "Delete Test City", + "location_type": LocationType.Remote.value, + "start_date": "2024-01-01", + "end_date": "2024-06-30", + "duration": 181, + } + + create_response = client.post( + "/pmf/offer/", + json=offer_data, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert create_response.status_code == 200 + created_offer_id = create_response.json()["id"] + + # Now delete it as admin + response = client.delete( + f"/pmf/offer/{created_offer_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + +def test_delete_offer_forbidden(client: TestClient): + """Test forbidden offer deletion by non-author""" + response = client.delete( + f"/pmf/offer/{offer2_id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +def test_delete_nonexistent_offer(client: TestClient): + """Test deletion of non-existent offer""" + response = client.delete( + f"/pmf/offer/{offer_fake_id}", + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 404 + + +# Tests for tags endpoints +def test_get_all_tags(client: TestClient): + """Test getting all tags""" + response = client.get("/pmf/tags/") + assert response.status_code == 200 + tags = response.json() + assert len(tags) >= 2 # We have at least the 2 created tags + assert any(tag["tag"] == "Aeronautics" for tag in tags) + assert any(tag["tag"] == "Artificial Intelligence" for tag in tags) + + +def test_get_tag_by_id(client: TestClient): + """Test getting a specific tag by ID""" + response = client.get(f"/pmf/tag/{tag1_id}") + assert response.status_code == 200 + tag = response.json() + assert tag["id"] == str(tag1_id) + assert tag["tag"] == "Aeronautics" + + +def test_get_nonexistent_tag(client: TestClient): + """Test getting a non-existent tag""" + response = client.get(f"/pmf/tag/{tag_fake_id}") + assert response.status_code == 404 + + +def test_create_tag_success(client: TestClient): + """Test successful tag creation by admin""" + tag_data = { + "tag": "Machine Learning", + } + + response = client.post( + "/pmf/tag/", + json=tag_data, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 201 + created_tag = response.json() + assert created_tag["tag"] == "Machine Learning" + assert "id" in created_tag + assert "created_at" in created_tag + + +def test_create_tag_forbidden(client: TestClient): + """Test tag creation by non-admin user""" + tag_data = { + "tag": "Unauthorized Tag", + } + + response = client.post( + "/pmf/tag/", + json=tag_data, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 403 + + +def test_create_duplicate_tag(client: TestClient): + """Test creating a duplicate tag""" + tag_data = { + "tag": "Aeronautics", # This tag already exists + } + + response = client.post( + "/pmf/tag/", + json=tag_data, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 400 + + +def test_update_tag_success(client: TestClient): + """Test successful tag update by admin""" + tag_update = { + "tag": "Updated Aeronautics", + } + + response = client.put( + f"/pmf/tag/{tag2_id}", + json=tag_update, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + +def test_update_tag_forbidden(client: TestClient): + """Test tag update by non-admin user""" + tag_update = { + "tag": "Unauthorized Update", + } + + response = client.put( + f"/pmf/tag/{tag1_id}", + json=tag_update, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 403 + + +def test_update_nonexistent_tag(client: TestClient): + """Test update of non-existent tag""" + tag_update = { + "tag": "Updated Non-existent", + } + + response = client.put( + f"/pmf/tag/{tag_fake_id}", + json=tag_update, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 404 + + +def test_delete_tag_success(client: TestClient): + """Test successful tag deletion by admin""" + # First create a tag to delete + tag_data = { + "tag": "Tag to Delete", + } + + create_response = client.post( + "/pmf/tag/", + json=tag_data, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert create_response.status_code == 201 + created_tag_id = create_response.json()["id"] + + # Now delete it + response = client.delete( + f"/pmf/tag/{created_tag_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + +def test_delete_tag_forbidden(client: TestClient): + """Test tag deletion by non-admin user""" + response = client.delete( + f"/pmf/tag/{tag1_id}", + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 403 + + +def test_delete_nonexistent_tag(client: TestClient): + """Test deletion of non-existent tag""" + response = client.delete( + f"/pmf/tag/{tag_fake_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 404 From 5cfa1cb6c26934a484ecb46b1b572869bc709076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 09/29] added factories and a basic provider --- app/modules/pmf/endpoints_pmf.py | 4 ++-- app/modules/pmf/factory_pmf.py | 40 ++++++++++++++++++++++++++++++++ app/modules/pmf/types_pmf.py | 2 +- app/utils/auth/providers.py | 6 +++++ migrations/versions/45-pmf.py | 2 +- tests/modules/test_pmf.py | 6 ++--- 6 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 app/modules/pmf/factory_pmf.py diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index c8c8dc30f5..920397ac30 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -8,7 +8,7 @@ from app.core.groups.groups_type import AccountType, GroupType from app.core.users.models_users import CoreUser from app.dependencies import get_db, is_user, is_user_in -from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf +from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf, factory_pmf from app.types.module import Module from app.utils.tools import is_user_member_of_any_group @@ -23,7 +23,7 @@ AccountType.staff, AccountType.former_student, ], - factory=None, + factory=factory_pmf.PmfFactory, ) diff --git a/app/modules/pmf/factory_pmf.py b/app/modules/pmf/factory_pmf.py new file mode 100644 index 0000000000..65d0aabd9b --- /dev/null +++ b/app/modules/pmf/factory_pmf.py @@ -0,0 +1,40 @@ +from app.types.factory import Factory +from app.core.users.factory_users import CoreUsersFactory +from app.core.utils.config import Settings +from sqlalchemy.ext.asyncio import AsyncSession +from app.modules.pmf import cruds_pmf,models_pmf,types_pmf +import uuid +from datetime import date + +class PmfFactory(Factory): + depends_on = [CoreUsersFactory] + + @classmethod + async def create_offers(cls, db: AsyncSession): + await cruds_pmf.create_offer( + offer=models_pmf.PmfOffer( + id=uuid.uuid4(), + author_id=CoreUsersFactory.demo_users_id[0], + company_name="Centrale Innovation", + start_date=date(2025,12,1), + end_date=date(2026,4,17), + duration=0, + title="Stage", + description="Stageant", + location="Montcuq", + location_type=types_pmf.LocationType.On_site, + offer_type=types_pmf.OfferType.TFE, + created_at=date.today + ), + db=db, + ) + + + @classmethod + async def run(cls, db: AsyncSession, settings: Settings) -> None: + await cls.create_offers(db) + + @classmethod + async def should_run(cls, db: AsyncSession): + assos = await cruds_pmf.get_offers(db=db) + return len(assos) == 0 diff --git a/app/modules/pmf/types_pmf.py b/app/modules/pmf/types_pmf.py index cd9e8cc57f..816245b5d1 100644 --- a/app/modules/pmf/types_pmf.py +++ b/app/modules/pmf/types_pmf.py @@ -3,7 +3,7 @@ class OfferType(str, Enum): # for the T-shirt and the bike TFE = "TFE" - S_APP = "Stage_Application" + APP = "APP" EXE = "EXE" CDI = "CDI" CDD = "CDD" diff --git a/app/utils/auth/providers.py b/app/utils/auth/providers.py index dc0b8c3685..909c1982c0 100644 --- a/app/utils/auth/providers.py +++ b/app/utils/auth/providers.py @@ -359,6 +359,12 @@ class SiarnaqAuthClient(BaseAuthClient): permission = AuthPermissions.siarnaq +class PMFAuthClient(BaseAuthClient): + allowed_scopes: set[ScopeType | str] = {ScopeType.API} + + allowed_account_types: list[AccountType] | None = None + + class OverleafAuthClient(BaseAuthClient): permission = AuthPermissions.overleaf diff --git a/migrations/versions/45-pmf.py b/migrations/versions/45-pmf.py index f54fac3d5c..8064254135 100644 --- a/migrations/versions/45-pmf.py +++ b/migrations/versions/45-pmf.py @@ -39,7 +39,7 @@ def upgrade() -> None: sa.Column("description", sa.String(), nullable=False), sa.Column( "offer_type", - sa.Enum("TFE", "S_APP", "EXE", "CDI", "CDD", name="offertype"), + sa.Enum("TFE", "APP", "EXE", "CDI", "CDD", name="offertype"), nullable=False, ), sa.Column("location", sa.String(), nullable=False), diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py index 2b1bf2b580..56ad020b0b 100644 --- a/tests/modules/test_pmf.py +++ b/tests/modules/test_pmf.py @@ -106,7 +106,7 @@ async def init_objects(): company_name="TechAI", title="AI Research Internship", description="Work on innovative AI research projects with our expert team.", - offer_type=OfferType.S_APP, + offer_type=OfferType.APP, location="Remote", location_type=LocationType.Remote, start_date=date(2023, 7, 1), @@ -166,7 +166,7 @@ def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): ("?includedTags=Aeronautics&includedTags=Artificial+Intelligence", 200, 3), ("?includedTags=Fake", 200, 0), (f"?includedOfferTypes={OfferType.TFE.value}", 200, 2), - (f"?includedOfferTypes={OfferType.S_APP.value}", 200, 1), + (f"?includedOfferTypes={OfferType.APP.value}", 200, 1), (f"?includedLocationTypes={LocationType.On_site.value}", 200, 1), (f"?includedLocationTypes={LocationType.Remote.value}", 200, 1), ("?includedLocationTypes=FakeLocation", 422, 0), @@ -315,7 +315,7 @@ def test_delete_offer_by_admin(client: TestClient): "company_name": "Delete Test Company", "title": "Delete Test Position", "description": "This offer will be deleted by admin", - "offer_type": OfferType.S_APP.value, + "offer_type": OfferType.APP.value, "location": "Delete Test City", "location_type": LocationType.Remote.value, "start_date": "2024-01-01", From 2a13752df83dda92afbaef0a2269197132caecbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Sat, 10 Jan 2026 01:54:06 +0100 Subject: [PATCH 10/29] added more factories --- app/modules/pmf/factory_pmf.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/modules/pmf/factory_pmf.py b/app/modules/pmf/factory_pmf.py index 65d0aabd9b..a1654f560f 100644 --- a/app/modules/pmf/factory_pmf.py +++ b/app/modules/pmf/factory_pmf.py @@ -28,6 +28,23 @@ async def create_offers(cls, db: AsyncSession): ), db=db, ) + await cruds_pmf.create_offer( + offer=models_pmf.PmfOffer( + id=uuid.uuid4(), + author_id=CoreUsersFactory.demo_users_id[1], + company_name="EDF", + start_date=date(2025,12,12), + end_date=date(2026,6,5), + duration=0, + title="Ingeneirue", + description="elec", + location="Ecully", + location_type=types_pmf.LocationType.On_site, + offer_type=types_pmf.OfferType.CDI, + created_at=date.today + ), + db=db, + ) @classmethod From e8bd8c015e49fe2b1a8379d37d92730103f4108b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Sat, 10 Jan 2026 01:54:06 +0100 Subject: [PATCH 11/29] update migrations order --- migrations/versions/{45-pmf.py => 54-pmf.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{45-pmf.py => 54-pmf.py} (98%) diff --git a/migrations/versions/45-pmf.py b/migrations/versions/54-pmf.py similarity index 98% rename from migrations/versions/45-pmf.py rename to migrations/versions/54-pmf.py index 8064254135..a80aa47a31 100644 --- a/migrations/versions/45-pmf.py +++ b/migrations/versions/54-pmf.py @@ -16,7 +16,7 @@ # revision identifiers, used by Alembic. revision: str = "ca5a9c9c64e5" -down_revision: str | None = "91fadc90f892" +down_revision: str | None = "9fc3dc926600" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 7c044bee8f0250f19e862ae09dfbffe9315fb9e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Sat, 28 Feb 2026 12:42:58 +0100 Subject: [PATCH 12/29] renamed the auth provider --- app/utils/auth/providers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/auth/providers.py b/app/utils/auth/providers.py index 909c1982c0..2738f78163 100644 --- a/app/utils/auth/providers.py +++ b/app/utils/auth/providers.py @@ -359,7 +359,7 @@ class SiarnaqAuthClient(BaseAuthClient): permission = AuthPermissions.siarnaq -class PMFAuthClient(BaseAuthClient): +class EnceladusAuthClient(BaseAuthClient): allowed_scopes: set[ScopeType | str] = {ScopeType.API} allowed_account_types: list[AccountType] | None = None From 0da145175c1a6f70ee2dfb662acff100f8db44bd Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 13/29] Init pmf module --- app/modules/pmf/__init__.py | 0 app/modules/pmf/endpoints_pmf.py | 9 +++++++++ 2 files changed, 9 insertions(+) create mode 100644 app/modules/pmf/__init__.py create mode 100644 app/modules/pmf/endpoints_pmf.py diff --git a/app/modules/pmf/__init__.py b/app/modules/pmf/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py new file mode 100644 index 0000000000..cddb86e9f0 --- /dev/null +++ b/app/modules/pmf/endpoints_pmf.py @@ -0,0 +1,9 @@ +from app.core.groups.groups_type import AccountType +from app.types.module import Module + +module = Module( + root="pmf", + tag="Pmf", + default_allowed_account_types=[AccountType.student, AccountType.staff], + factory=None, +) From 2c9762454332961ee3f5349cbc822dc32cf4f13c Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 14/29] MVP WIP --- app/modules/pmf/cruds_pmf.py | 160 ++++++++++++++++++++ app/modules/pmf/endpoints_pmf.py | 248 ++++++++++++++++++++++++++++++- app/modules/pmf/models_pmf.py | 70 +++++++++ app/modules/pmf/schemas_pmf.py | 50 +++++++ app/modules/pmf/types_pmf.py | 15 ++ 5 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 app/modules/pmf/cruds_pmf.py create mode 100644 app/modules/pmf/models_pmf.py create mode 100644 app/modules/pmf/schemas_pmf.py create mode 100644 app/modules/pmf/types_pmf.py diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py new file mode 100644 index 0000000000..b28e807b4b --- /dev/null +++ b/app/modules/pmf/cruds_pmf.py @@ -0,0 +1,160 @@ +from uuid import UUID + +from sqlalchemy import delete, select, true, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.users import schemas_users +from app.modules.pmf import models_pmf, schemas_pmf, types_pmf + + +async def create_offer(offer: schemas_pmf.OfferSimple, db: AsyncSession) -> None: + """Create a new PMF offer with associated tags.""" + db.add( + models_pmf.PmfOffer( + id=offer.id, + author_id=offer.author_id, + company_name=offer.company_name, + title=offer.title, + description=offer.description, + offer_type=offer.offer_type, + location=offer.location, + location_type=offer.location_type, + start_date=offer.start_date, + end_date=offer.end_date, + duration=offer.duration, + tags=[], + ), + ) + + +async def update_offer( + offer_id: UUID, + structure_update: schemas_pmf.OfferUpdate, + db: AsyncSession, +) -> None: + await db.execute( + update(models_pmf.PmfOffer) + .where(models_pmf.PmfOffer.id == offer_id) + .values(**structure_update.model_dump(exclude_unset=True)), + ) + + +async def delete_offer(offer_id: UUID, db: AsyncSession) -> None: + await db.execute( + delete(models_pmf.PmfOffer).where(models_pmf.PmfOffer.id == offer_id), + ) + + +async def get_offer_by_id( + offer_id: UUID, + db: AsyncSession, +) -> models_pmf.PmfOffer | None: + result = await db.execute( + select(models_pmf.PmfOffer).where(models_pmf.PmfOffer.id == offer_id), + ) + return result.scalars().first() + + +async def get_offers( + db: AsyncSession, + included_offer_types: list[types_pmf.OfferType] | None = None, + included_tags: list[str] | None = None, + included_location_types: list[types_pmf.LocationType] | None = None, +) -> list[schemas_pmf.OfferComplete]: + where_clause = ( + ( + models_pmf.PmfOffer.offer_type.in_(included_offer_types) + if included_offer_types + else true() + ) + & ( + models_pmf.PmfOffer.tags.any(models_pmf.Tags.tag.in_(included_tags)) + if included_tags + else true() + ) + & ( + models_pmf.PmfOffer.location_type.in_(included_location_types) + if included_location_types + else true() + ) + ) + + offers = await db.execute( + select(models_pmf.PmfOffer).where(where_clause), + ) + return [ + schemas_pmf.OfferComplete( + id=offer.id, + author_id=offer.author_id, + company_name=offer.company_name, + title=offer.title, + description=offer.description, + offer_type=offer.offer_type, + location=offer.location, + location_type=offer.location_type, + start_date=offer.start_date, + end_date=offer.end_date, + duration=offer.duration, + author=schemas_users.CoreUserSimple.model_validate(offer.author), + tags=[schemas_pmf.TagComplete.model_validate(tag) for tag in offer.tags], + ) + for offer in offers.scalars().all() + ] + +async def get_all_tags(db: AsyncSession) -> list[schemas_pmf.TagComplete]: + tags = await db.execute( + select(models_pmf.Tags).distinct(models_pmf.Tags.tag), + ) + return [ + schemas_pmf.TagComplete.model_validate(tag) + for tag in tags.scalars().all() + ] + +async def get_tag_by_name( + tag_name: str, + db: AsyncSession, +) -> schemas_pmf.TagComplete | None: + result = await db.execute( + select(models_pmf.Tags).where(models_pmf.Tags.tag == tag_name), + ) + return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + +async def get_tag_by_id( + tag_id: UUID, + db: AsyncSession, +) -> schemas_pmf.TagComplete | None: + result = await db.execute( + select(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), + ) + return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + +async def create_tag( + tag: schemas_pmf.TagComplete, + db: AsyncSession, +) -> None: + tag_db = models_pmf.Tags( + id=tag.id, + tag=tag.tag, + ) + db.add(tag_db) + +async def update_tag( + tag_id: UUID, + tag_update: schemas_pmf.TagBase, + db: AsyncSession, +) -> None: + await db.execute( + update(models_pmf.Tags) + .where(models_pmf.Tags.id == tag_id) + .values(**tag_update.model_dump(exclude_unset=True)), + ) + +async def delete_tag( + tag_id: UUID, + db: AsyncSession, +) -> None: + + await db.execute( + delete(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), + ) + diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index cddb86e9f0..a6a6ad1942 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -1,9 +1,253 @@ -from app.core.groups.groups_type import AccountType +import uuid +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.groups.groups_type import AccountType, GroupType +from app.core.users.models_users import CoreUser +from app.dependencies import is_user, is_user_in +from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf from app.types.module import Module +from app.utils.tools import is_user_member_of_any_group + +router = APIRouter(tags=["pmf"]) module = Module( root="pmf", tag="Pmf", - default_allowed_account_types=[AccountType.student, AccountType.staff], + default_allowed_account_types=[ + AccountType.student, + AccountType.staff, + AccountType.former_student, + ], factory=None, ) + + +@router.get( + "/pmf/offers/{offer_id}", + response_model=schemas_pmf.OfferComplete, + status_code=200, +) +async def get_offer( + offer_id: UUID, + db: AsyncSession, + # Allow only former students to access this endpoint + # user: CoreUser = Depends(is_user(included_account_types=[AccountType.former_student])), +): + offer = await cruds_pmf.get_offer_by_id( + offer_id=offer_id, + db=db, + ) + + if not offer: + raise HTTPException(status_code=404, detail="Offer not found") + + return offer + + +@router.get( + "/pmf/offers/", + response_model=list[schemas_pmf.OfferSimple], + status_code=200, +) +async def get_offers( + db: AsyncSession, + includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), + includedTags: list[str] = Query(default=[]), + includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), + # Allow only former students to access this endpoint + # user: CoreUser = Depends(is_user(included_account_types=[AccountType.former_student])), +): + return await cruds_pmf.get_offers( + db=db, + included_offer_types=includedOfferTypes, + included_tags=includedTags, + included_location_types=includedLocationTypes, + ) + + +@router.post( + "/pmf/offer/", + response_model=list[schemas_pmf.OfferComplete], + status_code=200, +) +async def create_offer( + db: AsyncSession, + offer: schemas_pmf.OfferBase, + # Allow only former students to create offer + user: CoreUser = Depends( + is_user(included_account_types=[AccountType.former_student]), + ), +): + # Only admin can post offers on behalf of others + if offer.author_id != user.id and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], + ): + raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + + offer_db = schemas_pmf.OfferSimple( + **offer.model_dump(), + id=uuid.uuid4(), + author_id=user.id, + ) + return await cruds_pmf.create_offer(db=db, offer=offer_db) + +@router.put( + "/pmf/offer/{offer_id}", + response_model=None, + status_code=204, +) +async def update_offer( + offer_id: UUID, + db: AsyncSession, + offer_update: schemas_pmf.OfferUpdate, + # Allow only former students to update offer + user: CoreUser = Depends( + is_user(included_account_types=[AccountType.former_student]), + ), +): + offer_db = await cruds_pmf.get_offer_by_id(offer_id=offer_id, db=db) + if not offer_db: + raise HTTPException(status_code=404, detail="Offer not found") + + # Only the author or admin can update the offer + if ( + offer_db.author_id != user.id + and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], + ) + ): + raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + + await cruds_pmf.update_offer( + offer_id=offer_id, + structure_update=offer_update, + db=db, + ) + +@router.delete( + "/pmf/offer/{offer_id}", + response_model=None, + status_code=204, +) +async def delete_offer( + offer_id: UUID, + db: AsyncSession, + # Allow only former students to delete offer + user: CoreUser = Depends( + is_user(included_account_types=[AccountType.former_student]), + ), +): + offer_db = await cruds_pmf.get_offer_by_id(offer_id=offer_id, db=db) + if not offer_db: + raise HTTPException(status_code=404, detail="Offer not found") + + # Only the author or admin can delete the offer + if ( + offer_db.author_id != user.id + and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], + ) + ): + raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + + await cruds_pmf.delete_offer(offer_id=offer_id, db=db) + +@router.get( + "/pmf/tags/", + response_model=list[schemas_pmf.TagComplete], + status_code=200, +) +async def get_all_tags( + db: AsyncSession, +) -> list[schemas_pmf.TagComplete]: + return await cruds_pmf.get_all_tags(db=db) + +@router.get( + "/pmf/tag/{tag_id}", + response_model=schemas_pmf.TagComplete | None, + status_code=200, +) +async def get_tag( + tag_id: UUID, + db: AsyncSession, +) -> schemas_pmf.TagComplete | None: + tags = await cruds_pmf.get_all_tags(db=db) + for tag in tags: + if tag.id == tag_id: + return tag + return None + +@router.post( + "/pmf/tag/", + response_model=schemas_pmf.TagComplete, + status_code=201, +) +async def create_tag( + tag: schemas_pmf.TagBase, + db: AsyncSession, + # Allow only admin to create tags + user: CoreUser = Depends( + is_user_in(group_id=GroupType.admin), + ), +): + existing_tag = await cruds_pmf.get_tag_by_name(tag_name=tag.tag, db=db) + if existing_tag: + raise HTTPException(status_code=400, detail="Tag already exists") + + tag_db = schemas_pmf.TagComplete( + **tag.model_dump(), + id=uuid.uuid4(), + ) + await cruds_pmf.create_tag(tag=tag_db, db=db) + return tag_db + +@router.put( + "/pmf/tag/{tag_id}", + response_model=None, + status_code=204, +) +async def update_tag( + tag_id: UUID, + tag_update: schemas_pmf.TagBase, + db: AsyncSession, + # Allow only admin to update tags + user: CoreUser = Depends( + is_user_in(group_id=GroupType.admin), + ), +): + existing_tag = await cruds_pmf.get_tag_by_id(tag_id=tag_id, db=db) + if not existing_tag: + raise HTTPException(status_code=404, detail="Tag not found") + + await cruds_pmf.update_tag(tag_id=tag_id, tag_update=tag_update, db=db) + +@router.delete( + "/pmf/tag/{tag_id}", + response_model=None, + status_code=204, +) +async def delete_tag( + tag_id: UUID, + db: AsyncSession, + # Allow only admin to delete tags + user: CoreUser = Depends( + is_user_in(group_id=GroupType.admin), + ), +): + existing_tag = await cruds_pmf.get_tag_by_id(tag_id=tag_id, db=db) + if not existing_tag: + raise HTTPException(status_code=404, detail="Tag not found") + + await cruds_pmf.delete_tag(tag_id=tag_id, db=db) diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py new file mode 100644 index 0000000000..ae86c396b7 --- /dev/null +++ b/app/modules/pmf/models_pmf.py @@ -0,0 +1,70 @@ +from datetime import date +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.modules.pmf.types_pmf import LocationType, OfferType +from app.types.sqlalchemy import Base, PrimaryKey + +if TYPE_CHECKING: + from app.core.users.models_users import CoreUser + + +class OfferTags(Base): + __tablename__ = "pmf_offer_tags" + + offer_id: Mapped[UUID] = mapped_column( + ForeignKey("pmf_offers.id"), primary_key=True, + ) + tag_id: Mapped[UUID] = mapped_column(ForeignKey("pmf_tags.id"), primary_key=True) + + +class PmfOffer(Base): + __tablename__ = "pmf_offers" + + id: Mapped[PrimaryKey] + + # TODO: Decide if the offer can remain if the author is deleted + author_id: Mapped[str] = mapped_column(ForeignKey("core_user.id")) + author: Mapped["CoreUser"] = relationship( + init=False, + lazy="joined", + innerjoin=True, # INNER JOIN since author_id is NOT NULL + ) + + company_name: Mapped[str] + title: Mapped[str] + description: Mapped[str] + offer_type: Mapped[OfferType] + location: Mapped[str] + location_type: Mapped[LocationType] # Enum (On_site, Hybrid, Remote) + + start_date: Mapped[date] + end_date: Mapped[date] + duration: Mapped[int] # days + + tags: Mapped[list["OfferTags"]] = relationship( + "Tags", + back_populates="offers", + lazy="selectin", # Small collection + secondary="pmf_offer_tags", + default_factory=list, + ) + + +class Tags(Base): + __tablename__ = "pmf_tags" + + id: Mapped[PrimaryKey] + tag: Mapped[str] + + created_at: Mapped[date] = mapped_column(default=date.today) + + offers: Mapped[list["OfferTags"]] = relationship( + "PmfOffer", + back_populates="tags", + secondary="pmf_offer_tags", + default_factory=list, + ) diff --git a/app/modules/pmf/schemas_pmf.py b/app/modules/pmf/schemas_pmf.py new file mode 100644 index 0000000000..5dce778ee5 --- /dev/null +++ b/app/modules/pmf/schemas_pmf.py @@ -0,0 +1,50 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + +from app.core.users import schemas_users +from app.modules.pmf.types_pmf import LocationType, OfferType + + +class TagBase(BaseModel): + tag: str + +class TagComplete(TagBase): + id: UUID + + +class OfferBase(BaseModel): + author_id: str + + company_name: str + title: str + description: str + offer_type: OfferType + location: str + location_type: LocationType + + start_date: datetime + end_date: datetime + duration: int # days + +class OfferSimple(OfferBase): + id: UUID + +class OfferUpdate(BaseModel): + author_id: str | None = None + company_name: str | None = None + title: str | None = None + description: str | None = None + offer_type: OfferType | None = None + location: str | None = None + location_type: LocationType | None = None + start_date: datetime | None = None + end_date: datetime | None = None + duration: int | None = None # days + + tags: list[TagBase] | None = None + +class OfferComplete(OfferSimple): + author: schemas_users.CoreUserSimple + tags: list[TagComplete] diff --git a/app/modules/pmf/types_pmf.py b/app/modules/pmf/types_pmf.py new file mode 100644 index 0000000000..cd9e8cc57f --- /dev/null +++ b/app/modules/pmf/types_pmf.py @@ -0,0 +1,15 @@ +from enum import Enum + + +class OfferType(str, Enum): # for the T-shirt and the bike + TFE = "TFE" + S_APP = "Stage_Application" + EXE = "EXE" + CDI = "CDI" + CDD = "CDD" + + +class LocationType(str, Enum): + On_site = "On_site" + Hybrid = "Hybrid" + Remote = "Remote" From 4c43d3e66f881dcafadf910c621677c2faa60168 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 15/29] Lint --- app/modules/pmf/cruds_pmf.py | 13 ++++----- app/modules/pmf/endpoints_pmf.py | 45 ++++++++++++++++++-------------- app/modules/pmf/models_pmf.py | 3 ++- app/modules/pmf/schemas_pmf.py | 4 +++ 4 files changed, 39 insertions(+), 26 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index b28e807b4b..84a9c9689d 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -101,14 +101,13 @@ async def get_offers( for offer in offers.scalars().all() ] + async def get_all_tags(db: AsyncSession) -> list[schemas_pmf.TagComplete]: tags = await db.execute( select(models_pmf.Tags).distinct(models_pmf.Tags.tag), ) - return [ - schemas_pmf.TagComplete.model_validate(tag) - for tag in tags.scalars().all() - ] + return [schemas_pmf.TagComplete.model_validate(tag) for tag in tags.scalars().all()] + async def get_tag_by_name( tag_name: str, @@ -119,6 +118,7 @@ async def get_tag_by_name( ) return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + async def get_tag_by_id( tag_id: UUID, db: AsyncSession, @@ -128,6 +128,7 @@ async def get_tag_by_id( ) return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + async def create_tag( tag: schemas_pmf.TagComplete, db: AsyncSession, @@ -138,6 +139,7 @@ async def create_tag( ) db.add(tag_db) + async def update_tag( tag_id: UUID, tag_update: schemas_pmf.TagBase, @@ -149,12 +151,11 @@ async def update_tag( .values(**tag_update.model_dump(exclude_unset=True)), ) + async def delete_tag( tag_id: UUID, db: AsyncSession, ) -> None: - await db.execute( delete(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), ) - diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index a6a6ad1942..d0011c1c5f 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -88,7 +88,9 @@ async def create_offer( GroupType.admin, ], ): - raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + raise HTTPException( + status_code=403, detail="Forbidden, you are not the author of this offer" + ) offer_db = schemas_pmf.OfferSimple( **offer.model_dump(), @@ -97,6 +99,7 @@ async def create_offer( ) return await cruds_pmf.create_offer(db=db, offer=offer_db) + @router.put( "/pmf/offer/{offer_id}", response_model=None, @@ -116,16 +119,15 @@ async def update_offer( raise HTTPException(status_code=404, detail="Offer not found") # Only the author or admin can update the offer - if ( - offer_db.author_id != user.id - and not is_user_member_of_any_group( - user, - [ - GroupType.admin, - ], - ) + if offer_db.author_id != user.id and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], ): - raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + raise HTTPException( + status_code=403, detail="Forbidden, you are not the author of this offer" + ) await cruds_pmf.update_offer( offer_id=offer_id, @@ -133,6 +135,7 @@ async def update_offer( db=db, ) + @router.delete( "/pmf/offer/{offer_id}", response_model=None, @@ -151,19 +154,19 @@ async def delete_offer( raise HTTPException(status_code=404, detail="Offer not found") # Only the author or admin can delete the offer - if ( - offer_db.author_id != user.id - and not is_user_member_of_any_group( - user, - [ - GroupType.admin, - ], - ) + if offer_db.author_id != user.id and not is_user_member_of_any_group( + user, + [ + GroupType.admin, + ], ): - raise HTTPException(status_code=403, detail="Forbidden, you are not the author of this offer") + raise HTTPException( + status_code=403, detail="Forbidden, you are not the author of this offer" + ) await cruds_pmf.delete_offer(offer_id=offer_id, db=db) + @router.get( "/pmf/tags/", response_model=list[schemas_pmf.TagComplete], @@ -174,6 +177,7 @@ async def get_all_tags( ) -> list[schemas_pmf.TagComplete]: return await cruds_pmf.get_all_tags(db=db) + @router.get( "/pmf/tag/{tag_id}", response_model=schemas_pmf.TagComplete | None, @@ -189,6 +193,7 @@ async def get_tag( return tag return None + @router.post( "/pmf/tag/", response_model=schemas_pmf.TagComplete, @@ -213,6 +218,7 @@ async def create_tag( await cruds_pmf.create_tag(tag=tag_db, db=db) return tag_db + @router.put( "/pmf/tag/{tag_id}", response_model=None, @@ -233,6 +239,7 @@ async def update_tag( await cruds_pmf.update_tag(tag_id=tag_id, tag_update=tag_update, db=db) + @router.delete( "/pmf/tag/{tag_id}", response_model=None, diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py index ae86c396b7..fc1bcad43a 100644 --- a/app/modules/pmf/models_pmf.py +++ b/app/modules/pmf/models_pmf.py @@ -16,7 +16,8 @@ class OfferTags(Base): __tablename__ = "pmf_offer_tags" offer_id: Mapped[UUID] = mapped_column( - ForeignKey("pmf_offers.id"), primary_key=True, + ForeignKey("pmf_offers.id"), + primary_key=True, ) tag_id: Mapped[UUID] = mapped_column(ForeignKey("pmf_tags.id"), primary_key=True) diff --git a/app/modules/pmf/schemas_pmf.py b/app/modules/pmf/schemas_pmf.py index 5dce778ee5..338e372f6d 100644 --- a/app/modules/pmf/schemas_pmf.py +++ b/app/modules/pmf/schemas_pmf.py @@ -10,6 +10,7 @@ class TagBase(BaseModel): tag: str + class TagComplete(TagBase): id: UUID @@ -28,9 +29,11 @@ class OfferBase(BaseModel): end_date: datetime duration: int # days + class OfferSimple(OfferBase): id: UUID + class OfferUpdate(BaseModel): author_id: str | None = None company_name: str | None = None @@ -45,6 +48,7 @@ class OfferUpdate(BaseModel): tags: list[TagBase] | None = None + class OfferComplete(OfferSimple): author: schemas_users.CoreUserSimple tags: list[TagComplete] From 03700af4be3b166c8ac747fdc96d1a5a24877503 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 16/29] Pagination --- app/modules/pmf/cruds_pmf.py | 4 +++- app/modules/pmf/endpoints_pmf.py | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index 84a9c9689d..c6435aed43 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -60,6 +60,8 @@ async def get_offers( included_offer_types: list[types_pmf.OfferType] | None = None, included_tags: list[str] | None = None, included_location_types: list[types_pmf.LocationType] | None = None, + limit: int | None = None, + offset: int | None = None, ) -> list[schemas_pmf.OfferComplete]: where_clause = ( ( @@ -80,7 +82,7 @@ async def get_offers( ) offers = await db.execute( - select(models_pmf.PmfOffer).where(where_clause), + select(models_pmf.PmfOffer).where(where_clause).limit(limit).offset(offset), ) return [ schemas_pmf.OfferComplete( diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index d0011c1c5f..829aae623b 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -57,6 +57,8 @@ async def get_offers( includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), includedTags: list[str] = Query(default=[]), includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), + limit: int | None = Query(default=50, gt=0, le=50), + offset: int | None = Query(default=0, ge=0), # Allow only former students to access this endpoint # user: CoreUser = Depends(is_user(included_account_types=[AccountType.former_student])), ): @@ -89,7 +91,8 @@ async def create_offer( ], ): raise HTTPException( - status_code=403, detail="Forbidden, you are not the author of this offer" + status_code=403, + detail="Forbidden, you are not the author of this offer", ) offer_db = schemas_pmf.OfferSimple( @@ -126,7 +129,8 @@ async def update_offer( ], ): raise HTTPException( - status_code=403, detail="Forbidden, you are not the author of this offer" + status_code=403, + detail="Forbidden, you are not the author of this offer", ) await cruds_pmf.update_offer( @@ -161,7 +165,8 @@ async def delete_offer( ], ): raise HTTPException( - status_code=403, detail="Forbidden, you are not the author of this offer" + status_code=403, + detail="Forbidden, you are not the author of this offer", ) await cruds_pmf.delete_offer(offer_id=offer_id, db=db) From c716a3490d74b7d1f7ab5d564e82989e95c0e926 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 17/29] Added get_tests --- app/modules/pmf/endpoints_pmf.py | 24 ++--- app/modules/pmf/models_pmf.py | 4 +- tests/modules/test_pmf.py | 163 +++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 tests/modules/test_pmf.py diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index 829aae623b..db1c439c3e 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -6,7 +6,7 @@ from app.core.groups.groups_type import AccountType, GroupType from app.core.users.models_users import CoreUser -from app.dependencies import is_user, is_user_in +from app.dependencies import get_db, is_user, is_user_in from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf from app.types.module import Module from app.utils.tools import is_user_member_of_any_group @@ -32,7 +32,7 @@ ) async def get_offer( offer_id: UUID, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only former students to access this endpoint # user: CoreUser = Depends(is_user(included_account_types=[AccountType.former_student])), ): @@ -53,7 +53,7 @@ async def get_offer( status_code=200, ) async def get_offers( - db: AsyncSession, + db: AsyncSession = Depends(get_db), includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), includedTags: list[str] = Query(default=[]), includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), @@ -67,6 +67,8 @@ async def get_offers( included_offer_types=includedOfferTypes, included_tags=includedTags, included_location_types=includedLocationTypes, + limit=limit, + offset=offset, ) @@ -76,8 +78,8 @@ async def get_offers( status_code=200, ) async def create_offer( - db: AsyncSession, offer: schemas_pmf.OfferBase, + db: AsyncSession = Depends(get_db), # Allow only former students to create offer user: CoreUser = Depends( is_user(included_account_types=[AccountType.former_student]), @@ -110,8 +112,8 @@ async def create_offer( ) async def update_offer( offer_id: UUID, - db: AsyncSession, offer_update: schemas_pmf.OfferUpdate, + db: AsyncSession = Depends(get_db), # Allow only former students to update offer user: CoreUser = Depends( is_user(included_account_types=[AccountType.former_student]), @@ -147,7 +149,7 @@ async def update_offer( ) async def delete_offer( offer_id: UUID, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only former students to delete offer user: CoreUser = Depends( is_user(included_account_types=[AccountType.former_student]), @@ -178,7 +180,7 @@ async def delete_offer( status_code=200, ) async def get_all_tags( - db: AsyncSession, + db: AsyncSession = Depends(get_db), ) -> list[schemas_pmf.TagComplete]: return await cruds_pmf.get_all_tags(db=db) @@ -190,7 +192,7 @@ async def get_all_tags( ) async def get_tag( tag_id: UUID, - db: AsyncSession, + db: AsyncSession = Depends(get_db), ) -> schemas_pmf.TagComplete | None: tags = await cruds_pmf.get_all_tags(db=db) for tag in tags: @@ -206,7 +208,7 @@ async def get_tag( ) async def create_tag( tag: schemas_pmf.TagBase, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only admin to create tags user: CoreUser = Depends( is_user_in(group_id=GroupType.admin), @@ -232,7 +234,7 @@ async def create_tag( async def update_tag( tag_id: UUID, tag_update: schemas_pmf.TagBase, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only admin to update tags user: CoreUser = Depends( is_user_in(group_id=GroupType.admin), @@ -252,7 +254,7 @@ async def update_tag( ) async def delete_tag( tag_id: UUID, - db: AsyncSession, + db: AsyncSession = Depends(get_db), # Allow only admin to delete tags user: CoreUser = Depends( is_user_in(group_id=GroupType.admin), diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py index fc1bcad43a..7a27e48efd 100644 --- a/app/modules/pmf/models_pmf.py +++ b/app/modules/pmf/models_pmf.py @@ -46,6 +46,8 @@ class PmfOffer(Base): end_date: Mapped[date] duration: Mapped[int] # days + created_at: Mapped[date] = mapped_column(insert_default=date.today) + tags: Mapped[list["OfferTags"]] = relationship( "Tags", back_populates="offers", @@ -61,7 +63,7 @@ class Tags(Base): id: Mapped[PrimaryKey] tag: Mapped[str] - created_at: Mapped[date] = mapped_column(default=date.today) + created_at: Mapped[date] = mapped_column(insert_default=date.today) offers: Mapped[list["OfferTags"]] = relationship( "PmfOffer", diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py new file mode 100644 index 0000000000..dffba3a5fc --- /dev/null +++ b/tests/modules/test_pmf.py @@ -0,0 +1,163 @@ +import uuid +from datetime import date + +import pytest +import pytest_asyncio +from fastapi.testclient import TestClient + +from app.core.groups.groups_type import AccountType, GroupType +from app.core.users import models_users +from app.modules.pmf import models_pmf, schemas_pmf + +# We need to import event_loop for pytest-asyncio routine defined bellow +from app.modules.pmf.types_pmf import LocationType, OfferType +from tests.commons import ( + add_object_to_db, + create_api_access_token, + create_user_with_groups, +) + +not_alumni_user: models_users.CoreUser +student_user: models_users.CoreUser +alumni_user: models_users.CoreUser + +tag1_id = uuid.UUID("0b7dc7bf-0ab4-421a-bbe7-7ec064fcec8d") +tag2_id = uuid.UUID("1c8dc7bf-1ab4-421a-bbe7-7ec064fcec8d") +tag_fake_id = uuid.UUID("5e8ec7bf-4ab4-421a-bbe7-7ec064fcec8d") + +offer1_id = uuid.UUID("2b7dc7bf-2ab4-421a-bbe7-7ec064fcec8d") +offer2_id = uuid.UUID("3c8dc7bf-3ab4-421a-bbe7-7ec064fcec8d") +offer3_id = uuid.UUID("4d9ec7bf-4ab4-421a-bbe7-7ec064fcec8d") +offer_fake_id = uuid.UUID("5e9ec7bf-0ab4-421a-bbe7-7ec064fcec8d") + + +@pytest_asyncio.fixture(scope="module", autouse=True) +async def init_objects(): + global not_alumni_user, student_user, alumni_user + + # We create an user in the test database + not_alumni_user = await create_user_with_groups( + groups=[], + account_type=AccountType.external, + ) + student_user = await create_user_with_groups( + groups=[], + account_type=AccountType.student, + ) + alumni_user = await create_user_with_groups( + groups=[], + account_type=AccountType.former_student, + ) + + tag_aero = models_pmf.Tags( + tag="Aeronautics", + id=tag1_id, + created_at=date(2023, 5, 1), + ) + tag_ai = models_pmf.Tags( + tag="Artificial Intelligence", + id=tag2_id, + created_at=date(2023, 5, 1), + ) + await add_object_to_db(tag_aero) + await add_object_to_db(tag_ai) + + # Creat 5 offers + offer_1 = models_pmf.PmfOffer( + id=offer1_id, + author_id=alumni_user.id, + company_name="AeroCorp", + title="Aerospace Engineer Internship", + description="Join our team to work on cutting-edge aerospace projects.", + offer_type=OfferType.TFE, + location="Toulouse, France", + location_type=LocationType.On_site, + start_date=date(2023, 6, 1), + end_date=date(2023, 8, 31), + created_at=date(2023, 5, 1), + duration=92, + ) + await add_object_to_db(offer_1) + offer_tag = models_pmf.OfferTags(offer_id=offer_1.id, tag_id=tag_aero.id) + await add_object_to_db(offer_tag) + + offer_2 = models_pmf.PmfOffer( + id=offer2_id, + author_id=alumni_user.id, + company_name="TechAI", + title="AI Research Internship", + description="Work on innovative AI research projects with our expert team.", + offer_type=OfferType.S_APP, + location="Remote", + location_type=LocationType.Remote, + start_date=date(2023, 7, 1), + end_date=date(2023, 9, 30), + created_at=date(2023, 6, 1), + duration=92, + ) + await add_object_to_db(offer_2) + offer_tag = models_pmf.OfferTags(offer_id=offer_2.id, tag_id=tag_ai.id) + await add_object_to_db(offer_tag) + + # A 3rd offer with the two tags + offer_3 = models_pmf.PmfOffer( + id=offer3_id, + author_id=alumni_user.id, + company_name="RoboAero", + title="Robotics and Aerospace Internship", + description="Combine robotics and aerospace in this exciting internship.", + offer_type=OfferType.TFE, + location="Hybrid - Paris, France / Remote", + location_type=LocationType.Hybrid, + start_date=date(2023, 8, 1), + end_date=date(2023, 10, 31), + created_at=date(2023, 7, 1), + duration=92, + ) + await add_object_to_db(offer_3) + offer_tag1 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag_aero.id) + offer_tag2 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag_ai.id) + await add_object_to_db(offer_tag1) + await add_object_to_db(offer_tag2) + + +@pytest.mark.parametrize( + ("offer_id", "expected_code"), + [ + (offer1_id, 200), + (offer2_id, 200), + (offer3_id, 200), + (offer_fake_id, 404), + ], +) +def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): + response = client.get( + f"/offers/{offer_id}", + ) + assert response.status_code == expected_code + + +@pytest.mark.parametrize( + ("query", "expected_code", "expected_length"), + [ + ("", 200, 3), + (f"?tag={tag1_id}", 200, 2), + (f"?tag={tag2_id}", 200, 2), + (f"?tag={tag1_id}&tag={tag2_id}", 200, 1), + (f"?tag={tag_fake_id}", 200, 0), + (f"?offer_type={OfferType.TFE}", 200, 2), + (f"?offer_type={OfferType.S_APP}", 200, 1), + (f"?location_type={LocationType.On_site}", 200, 1), + (f"?location_type={LocationType.Remote}", 200, 1), + ("?location_type=Fake", 200, 1), + (f"?tag={tag1_id}&offer_type={OfferType.TFE}", 200, 2), + ], +) +def test_get_offers( + query: uuid.UUID, expected_code: int, expected_length: int, client: TestClient +): + response = client.get( + f"/offers{query}", + ) + assert response.status_code == expected_code + assert len(response.json()) == expected_length From ca0bc736795234c313fe3be50a9b2ca226b7ff5c Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 18/29] added migrations --- migrations/versions/45-pmf.py | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 migrations/versions/45-pmf.py diff --git a/migrations/versions/45-pmf.py b/migrations/versions/45-pmf.py new file mode 100644 index 0000000000..f54fac3d5c --- /dev/null +++ b/migrations/versions/45-pmf.py @@ -0,0 +1,88 @@ +"""PMF + +Create Date: 2025-11-22 19:49:38.136247 +""" + +from collections.abc import Sequence +from typing import TYPE_CHECKING, Union + +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 = "ca5a9c9c64e5" +down_revision: str | None = "91fadc90f892" +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( + "pmf_tags", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("tag", sa.String(), nullable=False), + sa.Column("created_at", sa.Date(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "pmf_offers", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("author_id", sa.String(), nullable=False), + sa.Column("company_name", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.String(), nullable=False), + sa.Column( + "offer_type", + sa.Enum("TFE", "S_APP", "EXE", "CDI", "CDD", name="offertype"), + nullable=False, + ), + sa.Column("location", sa.String(), nullable=False), + sa.Column( + "location_type", + sa.Enum("On_site", "Hybrid", "Remote", name="locationtype"), + nullable=False, + ), + sa.Column("start_date", sa.Date(), nullable=False), + sa.Column("end_date", sa.Date(), nullable=False), + sa.Column("duration", sa.Integer(), nullable=False), + sa.Column("created_at", sa.Date(), nullable=False), + sa.ForeignKeyConstraint(["author_id"], ["core_user.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "pmf_offer_tags", + sa.Column("offer_id", sa.Uuid(), nullable=False), + sa.Column("tag_id", sa.Uuid(), nullable=False), + sa.ForeignKeyConstraint(["offer_id"], ["pmf_offers.id"]), + sa.ForeignKeyConstraint(["tag_id"], ["pmf_tags.id"]), + sa.PrimaryKeyConstraint("offer_id", "tag_id"), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("pmf_offer_tags") + op.drop_table("pmf_offers") + op.drop_table("pmf_tags") + # ### 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 4cc15fd8b44fada4aef4ae142825721861d55d3b Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 19/29] Fixed get tests --- app/modules/pmf/cruds_pmf.py | 12 ++++++- app/modules/pmf/endpoints_pmf.py | 5 ++- app/modules/pmf/models_pmf.py | 4 +-- app/modules/pmf/schemas_pmf.py | 11 +++--- tests/modules/test_pmf.py | 58 ++++++++++++++++++++------------ 5 files changed, 60 insertions(+), 30 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index c6435aed43..7600bb8b5d 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -1,3 +1,4 @@ +from datetime import date from uuid import UUID from sqlalchemy import delete, select, true, update @@ -22,6 +23,7 @@ async def create_offer(offer: schemas_pmf.OfferSimple, db: AsyncSession) -> None start_date=offer.start_date, end_date=offer.end_date, duration=offer.duration, + created_at=date.today(), tags=[], ), ) @@ -98,7 +100,14 @@ async def get_offers( end_date=offer.end_date, duration=offer.duration, author=schemas_users.CoreUserSimple.model_validate(offer.author), - tags=[schemas_pmf.TagComplete.model_validate(tag) for tag in offer.tags], + tags=[ + schemas_pmf.TagComplete( + id=tag.id, + tag=tag.tag, + created_at=tag.created_at, + ) + for tag in offer.tags + ], ) for offer in offers.scalars().all() ] @@ -138,6 +147,7 @@ async def create_tag( tag_db = models_pmf.Tags( id=tag.id, tag=tag.tag, + created_at=tag.created_at, ) db.add(tag_db) diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index db1c439c3e..cfb69f2714 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -1,4 +1,5 @@ import uuid +from datetime import date from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query @@ -16,6 +17,7 @@ module = Module( root="pmf", tag="Pmf", + router=router, default_allowed_account_types=[ AccountType.student, AccountType.staff, @@ -41,7 +43,7 @@ async def get_offer( db=db, ) - if not offer: + if offer is None: raise HTTPException(status_code=404, detail="Offer not found") return offer @@ -221,6 +223,7 @@ async def create_tag( tag_db = schemas_pmf.TagComplete( **tag.model_dump(), id=uuid.uuid4(), + created_at=date.today(), ) await cruds_pmf.create_tag(tag=tag_db, db=db) return tag_db diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py index 7a27e48efd..0861fef665 100644 --- a/app/modules/pmf/models_pmf.py +++ b/app/modules/pmf/models_pmf.py @@ -48,7 +48,7 @@ class PmfOffer(Base): created_at: Mapped[date] = mapped_column(insert_default=date.today) - tags: Mapped[list["OfferTags"]] = relationship( + tags: Mapped[list["Tags"]] = relationship( "Tags", back_populates="offers", lazy="selectin", # Small collection @@ -65,7 +65,7 @@ class Tags(Base): created_at: Mapped[date] = mapped_column(insert_default=date.today) - offers: Mapped[list["OfferTags"]] = relationship( + offers: Mapped[list["PmfOffer"]] = relationship( "PmfOffer", back_populates="tags", secondary="pmf_offer_tags", diff --git a/app/modules/pmf/schemas_pmf.py b/app/modules/pmf/schemas_pmf.py index 338e372f6d..8a242cb4ba 100644 --- a/app/modules/pmf/schemas_pmf.py +++ b/app/modules/pmf/schemas_pmf.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date from uuid import UUID from pydantic import BaseModel @@ -13,6 +13,7 @@ class TagBase(BaseModel): class TagComplete(TagBase): id: UUID + created_at: date class OfferBase(BaseModel): @@ -25,8 +26,8 @@ class OfferBase(BaseModel): location: str location_type: LocationType - start_date: datetime - end_date: datetime + start_date: date + end_date: date duration: int # days @@ -42,8 +43,8 @@ class OfferUpdate(BaseModel): offer_type: OfferType | None = None location: str | None = None location_type: LocationType | None = None - start_date: datetime | None = None - end_date: datetime | None = None + start_date: date | None = None + end_date: date | None = None duration: int | None = None # days tags: list[TagBase] | None = None diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py index dffba3a5fc..97f61c21a4 100644 --- a/tests/modules/test_pmf.py +++ b/tests/modules/test_pmf.py @@ -22,7 +22,9 @@ alumni_user: models_users.CoreUser tag1_id = uuid.UUID("0b7dc7bf-0ab4-421a-bbe7-7ec064fcec8d") +tag1: models_pmf.Tags tag2_id = uuid.UUID("1c8dc7bf-1ab4-421a-bbe7-7ec064fcec8d") +tag2: models_pmf.Tags tag_fake_id = uuid.UUID("5e8ec7bf-4ab4-421a-bbe7-7ec064fcec8d") offer1_id = uuid.UUID("2b7dc7bf-2ab4-421a-bbe7-7ec064fcec8d") @@ -30,6 +32,9 @@ offer3_id = uuid.UUID("4d9ec7bf-4ab4-421a-bbe7-7ec064fcec8d") offer_fake_id = uuid.UUID("5e9ec7bf-0ab4-421a-bbe7-7ec064fcec8d") +not_alumni_token: str +student_token: str +alumni_token: str @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects(): @@ -49,18 +54,24 @@ async def init_objects(): account_type=AccountType.former_student, ) - tag_aero = models_pmf.Tags( + global not_alumni_token, student_token, alumni_token + not_alumni_token = create_api_access_token(not_alumni_user) + student_token = create_api_access_token(student_user) + alumni_token = create_api_access_token(alumni_user) + + global tag1, tag2 + tag1 = models_pmf.Tags( tag="Aeronautics", id=tag1_id, created_at=date(2023, 5, 1), ) - tag_ai = models_pmf.Tags( + tag2 = models_pmf.Tags( tag="Artificial Intelligence", id=tag2_id, created_at=date(2023, 5, 1), ) - await add_object_to_db(tag_aero) - await add_object_to_db(tag_ai) + await add_object_to_db(tag1) + await add_object_to_db(tag2) # Creat 5 offers offer_1 = models_pmf.PmfOffer( @@ -78,7 +89,7 @@ async def init_objects(): duration=92, ) await add_object_to_db(offer_1) - offer_tag = models_pmf.OfferTags(offer_id=offer_1.id, tag_id=tag_aero.id) + offer_tag = models_pmf.OfferTags(offer_id=offer_1.id, tag_id=tag1.id) await add_object_to_db(offer_tag) offer_2 = models_pmf.PmfOffer( @@ -96,7 +107,7 @@ async def init_objects(): duration=92, ) await add_object_to_db(offer_2) - offer_tag = models_pmf.OfferTags(offer_id=offer_2.id, tag_id=tag_ai.id) + offer_tag = models_pmf.OfferTags(offer_id=offer_2.id, tag_id=tag2.id) await add_object_to_db(offer_tag) # A 3rd offer with the two tags @@ -115,8 +126,8 @@ async def init_objects(): duration=92, ) await add_object_to_db(offer_3) - offer_tag1 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag_aero.id) - offer_tag2 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag_ai.id) + offer_tag1 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag1.id) + offer_tag2 = models_pmf.OfferTags(offer_id=offer_3.id, tag_id=tag2.id) await add_object_to_db(offer_tag1) await add_object_to_db(offer_tag2) @@ -132,7 +143,8 @@ async def init_objects(): ) def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): response = client.get( - f"/offers/{offer_id}", + f"/pmf/offers/{offer_id}", + headers={"Authorization": f"Bearer {student_token}"}, ) assert response.status_code == expected_code @@ -141,23 +153,27 @@ def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): ("query", "expected_code", "expected_length"), [ ("", 200, 3), - (f"?tag={tag1_id}", 200, 2), - (f"?tag={tag2_id}", 200, 2), - (f"?tag={tag1_id}&tag={tag2_id}", 200, 1), - (f"?tag={tag_fake_id}", 200, 0), - (f"?offer_type={OfferType.TFE}", 200, 2), - (f"?offer_type={OfferType.S_APP}", 200, 1), - (f"?location_type={LocationType.On_site}", 200, 1), - (f"?location_type={LocationType.Remote}", 200, 1), - ("?location_type=Fake", 200, 1), - (f"?tag={tag1_id}&offer_type={OfferType.TFE}", 200, 2), + ("?includedTags=Aeronautics", 200, 2), + ("?includedTags=Artificial+Intelligence", 200, 2), + ("?includedTags=Aeronautics&includedTags=Artificial+Intelligence", 200, 3), + ("?includedTags=Fake", 200, 0), + (f"?includedOfferTypes={OfferType.TFE.value}", 200, 2), + (f"?includedOfferTypes={OfferType.S_APP.value}", 200, 1), + (f"?includedLocationTypes={LocationType.On_site.value}", 200, 1), + (f"?includedLocationTypes={LocationType.Remote.value}", 200, 1), + ("?includedLocationTypes=FakeLocation", 422, 0), + (f"?includedTags=Aeronautics&includedOfferTypes={OfferType.TFE.value}", 200, 2), + ("?limit=2", 200, 2), + ("?offset=1", 200, 2), + ("?limit=2&offset=2", 200, 1), ], ) def test_get_offers( query: uuid.UUID, expected_code: int, expected_length: int, client: TestClient ): response = client.get( - f"/offers{query}", + f"/pmf/offers/{query}", ) assert response.status_code == expected_code - assert len(response.json()) == expected_length + if expected_code == 200: + assert len(response.json()) == expected_length \ No newline at end of file From 2f0c887fc64dcb27269666edd6325283237850e6 Mon Sep 17 00:00:00 2001 From: Warix <39554785+warix8@users.noreply.github.com> Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 20/29] Working tests --- app/modules/pmf/cruds_pmf.py | 25 ++- app/modules/pmf/endpoints_pmf.py | 22 +- tests/modules/test_pmf.py | 341 ++++++++++++++++++++++++++++++- 3 files changed, 370 insertions(+), 18 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index 7600bb8b5d..8741edeeb1 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -42,6 +42,10 @@ async def update_offer( async def delete_offer(offer_id: UUID, db: AsyncSession) -> None: + # First, delete associations in pmf_offer_tags + await db.execute( + delete(models_pmf.OfferTags).where(models_pmf.OfferTags.offer_id == offer_id), + ) await db.execute( delete(models_pmf.PmfOffer).where(models_pmf.PmfOffer.id == offer_id), ) @@ -117,27 +121,34 @@ async def get_all_tags(db: AsyncSession) -> list[schemas_pmf.TagComplete]: tags = await db.execute( select(models_pmf.Tags).distinct(models_pmf.Tags.tag), ) - return [schemas_pmf.TagComplete.model_validate(tag) for tag in tags.scalars().all()] + return [ + schemas_pmf.TagComplete( + id=tag.id, + tag=tag.tag, + created_at=tag.created_at, + ) + for tag in tags.scalars().all() + ] async def get_tag_by_name( tag_name: str, db: AsyncSession, -) -> schemas_pmf.TagComplete | None: +) -> models_pmf.Tags | None: result = await db.execute( select(models_pmf.Tags).where(models_pmf.Tags.tag == tag_name), ) - return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + return result.scalars().first() async def get_tag_by_id( tag_id: UUID, db: AsyncSession, -) -> schemas_pmf.TagComplete | None: +) -> models_pmf.Tags | None: result = await db.execute( select(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), ) - return schemas_pmf.TagComplete.model_validate(result.scalars().first()) + return result.scalars().first() async def create_tag( @@ -168,6 +179,10 @@ async def delete_tag( tag_id: UUID, db: AsyncSession, ) -> None: + # First, delete associations in pmf_offer_tags + await db.execute( + delete(models_pmf.OfferTags).where(models_pmf.OfferTags.tag_id == tag_id), + ) await db.execute( delete(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), ) diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index cfb69f2714..c8c8dc30f5 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -76,7 +76,7 @@ async def get_offers( @router.post( "/pmf/offer/", - response_model=list[schemas_pmf.OfferComplete], + response_model=schemas_pmf.OfferComplete, status_code=200, ) async def create_offer( @@ -102,9 +102,10 @@ async def create_offer( offer_db = schemas_pmf.OfferSimple( **offer.model_dump(), id=uuid.uuid4(), - author_id=user.id, ) - return await cruds_pmf.create_offer(db=db, offer=offer_db) + await cruds_pmf.create_offer(db=db, offer=offer_db) + await db.flush() + return await cruds_pmf.get_offer_by_id(offer_id=offer_db.id, db=db) @router.put( @@ -195,12 +196,15 @@ async def get_all_tags( async def get_tag( tag_id: UUID, db: AsyncSession = Depends(get_db), -) -> schemas_pmf.TagComplete | None: - tags = await cruds_pmf.get_all_tags(db=db) - for tag in tags: - if tag.id == tag_id: - return tag - return None +) -> schemas_pmf.TagComplete: + tag = await cruds_pmf.get_tag_by_id(tag_id=tag_id, db=db) + if not tag: + raise HTTPException(status_code=404, detail="Tag not found") + return schemas_pmf.TagComplete( + tag=tag.tag, + id=tag.id, + created_at=tag.created_at, + ) @router.post( diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py index 97f61c21a4..2b1bf2b580 100644 --- a/tests/modules/test_pmf.py +++ b/tests/modules/test_pmf.py @@ -20,6 +20,7 @@ not_alumni_user: models_users.CoreUser student_user: models_users.CoreUser alumni_user: models_users.CoreUser +admin_user: models_users.CoreUser tag1_id = uuid.UUID("0b7dc7bf-0ab4-421a-bbe7-7ec064fcec8d") tag1: models_pmf.Tags @@ -35,10 +36,12 @@ not_alumni_token: str student_token: str alumni_token: str +admin_token: str + @pytest_asyncio.fixture(scope="module", autouse=True) async def init_objects(): - global not_alumni_user, student_user, alumni_user + global not_alumni_user, student_user, alumni_user, admin_user # We create an user in the test database not_alumni_user = await create_user_with_groups( @@ -53,11 +56,16 @@ async def init_objects(): groups=[], account_type=AccountType.former_student, ) + admin_user = await create_user_with_groups( + groups=[GroupType.admin], + account_type=AccountType.former_student, + ) - global not_alumni_token, student_token, alumni_token + global not_alumni_token, student_token, alumni_token, admin_token not_alumni_token = create_api_access_token(not_alumni_user) student_token = create_api_access_token(student_user) alumni_token = create_api_access_token(alumni_user) + admin_token = create_api_access_token(admin_user) global tag1, tag2 tag1 = models_pmf.Tags( @@ -169,11 +177,336 @@ def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): ], ) def test_get_offers( - query: uuid.UUID, expected_code: int, expected_length: int, client: TestClient + query: uuid.UUID, + expected_code: int, + expected_length: int, + client: TestClient, ): response = client.get( f"/pmf/offers/{query}", ) assert response.status_code == expected_code if expected_code == 200: - assert len(response.json()) == expected_length \ No newline at end of file + assert len(response.json()) == expected_length + + +# Tests for POST, PUT, DELETE offers +@pytest.mark.parametrize( + ("token", "author_id", "expected_code"), + [ + ("alumni_token", "alumni_user", 200), # Alumni can create offer for themselves + ("admin_token", "alumni_user", 200), # Admin can create offer for others + ("student_token", "student_user", 403), # Student cannot create offers + ( + "not_alumni_token", + "not_alumni_user", + 403, + ), # External user cannot create offers + ( + "alumni_token", + "admin_user", + 403, + ), # Alumni cannot create offer for others (non-admin) + ], +) +def test_create_offer( + token: str, + author_id: str, + expected_code: int, + client: TestClient, +): + # Get the actual token and user id + actual_token = globals()[token] + actual_author_id = globals()[author_id].id + + offer_data = { + "author_id": actual_author_id, + "company_name": "Test Company", + "title": "Test Position", + "description": "This is a test offer description", + "offer_type": OfferType.TFE.value, + "location": "Test City", + "location_type": LocationType.On_site.value, + "start_date": "2024-01-01", + "end_date": "2024-06-30", + "duration": 181, + } + + response = client.post( + "/pmf/offer/", + json=offer_data, + headers={"Authorization": f"Bearer {actual_token}"}, + ) + assert response.status_code == expected_code + + +def test_update_offer_success(client: TestClient): + """Test successful offer update by the author""" + offer_update = { + "title": "Updated Title", + "description": "Updated description", + "company_name": "Updated Company", + } + + response = client.put( + f"/pmf/offer/{offer1_id}", + json=offer_update, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 204 + + +def test_update_offer_by_admin(client: TestClient): + """Test successful offer update by admin""" + offer_update = { + "title": "Admin Updated Title", + } + + response = client.put( + f"/pmf/offer/{offer2_id}", + json=offer_update, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + +def test_update_offer_forbidden(client: TestClient): + """Test forbidden offer update by non-author""" + offer_update = { + "title": "Unauthorized Update", + } + + response = client.put( + f"/pmf/offer/{offer1_id}", + json=offer_update, + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +def test_update_nonexistent_offer(client: TestClient): + """Test update of non-existent offer""" + offer_update = { + "title": "Updated Title", + } + + response = client.put( + f"/pmf/offer/{offer_fake_id}", + json=offer_update, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 404 + + +def test_delete_offer_success(client: TestClient): + """Test successful offer deletion by the author""" + response = client.delete( + f"/pmf/offer/{offer3_id}", + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 204 + + +def test_delete_offer_by_admin(client: TestClient): + """Test successful offer deletion by admin""" + # First create a new offer to delete + offer_data = { + "author_id": alumni_user.id, + "company_name": "Delete Test Company", + "title": "Delete Test Position", + "description": "This offer will be deleted by admin", + "offer_type": OfferType.S_APP.value, + "location": "Delete Test City", + "location_type": LocationType.Remote.value, + "start_date": "2024-01-01", + "end_date": "2024-06-30", + "duration": 181, + } + + create_response = client.post( + "/pmf/offer/", + json=offer_data, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert create_response.status_code == 200 + created_offer_id = create_response.json()["id"] + + # Now delete it as admin + response = client.delete( + f"/pmf/offer/{created_offer_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + +def test_delete_offer_forbidden(client: TestClient): + """Test forbidden offer deletion by non-author""" + response = client.delete( + f"/pmf/offer/{offer2_id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == 403 + + +def test_delete_nonexistent_offer(client: TestClient): + """Test deletion of non-existent offer""" + response = client.delete( + f"/pmf/offer/{offer_fake_id}", + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 404 + + +# Tests for tags endpoints +def test_get_all_tags(client: TestClient): + """Test getting all tags""" + response = client.get("/pmf/tags/") + assert response.status_code == 200 + tags = response.json() + assert len(tags) >= 2 # We have at least the 2 created tags + assert any(tag["tag"] == "Aeronautics" for tag in tags) + assert any(tag["tag"] == "Artificial Intelligence" for tag in tags) + + +def test_get_tag_by_id(client: TestClient): + """Test getting a specific tag by ID""" + response = client.get(f"/pmf/tag/{tag1_id}") + assert response.status_code == 200 + tag = response.json() + assert tag["id"] == str(tag1_id) + assert tag["tag"] == "Aeronautics" + + +def test_get_nonexistent_tag(client: TestClient): + """Test getting a non-existent tag""" + response = client.get(f"/pmf/tag/{tag_fake_id}") + assert response.status_code == 404 + + +def test_create_tag_success(client: TestClient): + """Test successful tag creation by admin""" + tag_data = { + "tag": "Machine Learning", + } + + response = client.post( + "/pmf/tag/", + json=tag_data, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 201 + created_tag = response.json() + assert created_tag["tag"] == "Machine Learning" + assert "id" in created_tag + assert "created_at" in created_tag + + +def test_create_tag_forbidden(client: TestClient): + """Test tag creation by non-admin user""" + tag_data = { + "tag": "Unauthorized Tag", + } + + response = client.post( + "/pmf/tag/", + json=tag_data, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 403 + + +def test_create_duplicate_tag(client: TestClient): + """Test creating a duplicate tag""" + tag_data = { + "tag": "Aeronautics", # This tag already exists + } + + response = client.post( + "/pmf/tag/", + json=tag_data, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 400 + + +def test_update_tag_success(client: TestClient): + """Test successful tag update by admin""" + tag_update = { + "tag": "Updated Aeronautics", + } + + response = client.put( + f"/pmf/tag/{tag2_id}", + json=tag_update, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + +def test_update_tag_forbidden(client: TestClient): + """Test tag update by non-admin user""" + tag_update = { + "tag": "Unauthorized Update", + } + + response = client.put( + f"/pmf/tag/{tag1_id}", + json=tag_update, + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 403 + + +def test_update_nonexistent_tag(client: TestClient): + """Test update of non-existent tag""" + tag_update = { + "tag": "Updated Non-existent", + } + + response = client.put( + f"/pmf/tag/{tag_fake_id}", + json=tag_update, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 404 + + +def test_delete_tag_success(client: TestClient): + """Test successful tag deletion by admin""" + # First create a tag to delete + tag_data = { + "tag": "Tag to Delete", + } + + create_response = client.post( + "/pmf/tag/", + json=tag_data, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert create_response.status_code == 201 + created_tag_id = create_response.json()["id"] + + # Now delete it + response = client.delete( + f"/pmf/tag/{created_tag_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 204 + + +def test_delete_tag_forbidden(client: TestClient): + """Test tag deletion by non-admin user""" + response = client.delete( + f"/pmf/tag/{tag1_id}", + headers={"Authorization": f"Bearer {alumni_token}"}, + ) + assert response.status_code == 403 + + +def test_delete_nonexistent_tag(client: TestClient): + """Test deletion of non-existent tag""" + response = client.delete( + f"/pmf/tag/{tag_fake_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 404 From a92d936466932810b0ac0a25e0e77347a056f026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Sat, 10 Jan 2026 01:54:05 +0100 Subject: [PATCH 21/29] added factories and a basic provider --- app/modules/pmf/endpoints_pmf.py | 4 ++-- app/modules/pmf/factory_pmf.py | 40 ++++++++++++++++++++++++++++++++ app/modules/pmf/types_pmf.py | 2 +- app/utils/auth/providers.py | 6 +++++ migrations/versions/45-pmf.py | 2 +- tests/modules/test_pmf.py | 6 ++--- 6 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 app/modules/pmf/factory_pmf.py diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index c8c8dc30f5..920397ac30 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -8,7 +8,7 @@ from app.core.groups.groups_type import AccountType, GroupType from app.core.users.models_users import CoreUser from app.dependencies import get_db, is_user, is_user_in -from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf +from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf, factory_pmf from app.types.module import Module from app.utils.tools import is_user_member_of_any_group @@ -23,7 +23,7 @@ AccountType.staff, AccountType.former_student, ], - factory=None, + factory=factory_pmf.PmfFactory, ) diff --git a/app/modules/pmf/factory_pmf.py b/app/modules/pmf/factory_pmf.py new file mode 100644 index 0000000000..65d0aabd9b --- /dev/null +++ b/app/modules/pmf/factory_pmf.py @@ -0,0 +1,40 @@ +from app.types.factory import Factory +from app.core.users.factory_users import CoreUsersFactory +from app.core.utils.config import Settings +from sqlalchemy.ext.asyncio import AsyncSession +from app.modules.pmf import cruds_pmf,models_pmf,types_pmf +import uuid +from datetime import date + +class PmfFactory(Factory): + depends_on = [CoreUsersFactory] + + @classmethod + async def create_offers(cls, db: AsyncSession): + await cruds_pmf.create_offer( + offer=models_pmf.PmfOffer( + id=uuid.uuid4(), + author_id=CoreUsersFactory.demo_users_id[0], + company_name="Centrale Innovation", + start_date=date(2025,12,1), + end_date=date(2026,4,17), + duration=0, + title="Stage", + description="Stageant", + location="Montcuq", + location_type=types_pmf.LocationType.On_site, + offer_type=types_pmf.OfferType.TFE, + created_at=date.today + ), + db=db, + ) + + + @classmethod + async def run(cls, db: AsyncSession, settings: Settings) -> None: + await cls.create_offers(db) + + @classmethod + async def should_run(cls, db: AsyncSession): + assos = await cruds_pmf.get_offers(db=db) + return len(assos) == 0 diff --git a/app/modules/pmf/types_pmf.py b/app/modules/pmf/types_pmf.py index cd9e8cc57f..816245b5d1 100644 --- a/app/modules/pmf/types_pmf.py +++ b/app/modules/pmf/types_pmf.py @@ -3,7 +3,7 @@ class OfferType(str, Enum): # for the T-shirt and the bike TFE = "TFE" - S_APP = "Stage_Application" + APP = "APP" EXE = "EXE" CDI = "CDI" CDD = "CDD" diff --git a/app/utils/auth/providers.py b/app/utils/auth/providers.py index dc0b8c3685..909c1982c0 100644 --- a/app/utils/auth/providers.py +++ b/app/utils/auth/providers.py @@ -359,6 +359,12 @@ class SiarnaqAuthClient(BaseAuthClient): permission = AuthPermissions.siarnaq +class PMFAuthClient(BaseAuthClient): + allowed_scopes: set[ScopeType | str] = {ScopeType.API} + + allowed_account_types: list[AccountType] | None = None + + class OverleafAuthClient(BaseAuthClient): permission = AuthPermissions.overleaf diff --git a/migrations/versions/45-pmf.py b/migrations/versions/45-pmf.py index f54fac3d5c..8064254135 100644 --- a/migrations/versions/45-pmf.py +++ b/migrations/versions/45-pmf.py @@ -39,7 +39,7 @@ def upgrade() -> None: sa.Column("description", sa.String(), nullable=False), sa.Column( "offer_type", - sa.Enum("TFE", "S_APP", "EXE", "CDI", "CDD", name="offertype"), + sa.Enum("TFE", "APP", "EXE", "CDI", "CDD", name="offertype"), nullable=False, ), sa.Column("location", sa.String(), nullable=False), diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py index 2b1bf2b580..56ad020b0b 100644 --- a/tests/modules/test_pmf.py +++ b/tests/modules/test_pmf.py @@ -106,7 +106,7 @@ async def init_objects(): company_name="TechAI", title="AI Research Internship", description="Work on innovative AI research projects with our expert team.", - offer_type=OfferType.S_APP, + offer_type=OfferType.APP, location="Remote", location_type=LocationType.Remote, start_date=date(2023, 7, 1), @@ -166,7 +166,7 @@ def test_get_offer(offer_id: uuid.UUID, expected_code: int, client: TestClient): ("?includedTags=Aeronautics&includedTags=Artificial+Intelligence", 200, 3), ("?includedTags=Fake", 200, 0), (f"?includedOfferTypes={OfferType.TFE.value}", 200, 2), - (f"?includedOfferTypes={OfferType.S_APP.value}", 200, 1), + (f"?includedOfferTypes={OfferType.APP.value}", 200, 1), (f"?includedLocationTypes={LocationType.On_site.value}", 200, 1), (f"?includedLocationTypes={LocationType.Remote.value}", 200, 1), ("?includedLocationTypes=FakeLocation", 422, 0), @@ -315,7 +315,7 @@ def test_delete_offer_by_admin(client: TestClient): "company_name": "Delete Test Company", "title": "Delete Test Position", "description": "This offer will be deleted by admin", - "offer_type": OfferType.S_APP.value, + "offer_type": OfferType.APP.value, "location": "Delete Test City", "location_type": LocationType.Remote.value, "start_date": "2024-01-01", From 4d80439bb15a3e09f89ca16fcaa0ded0115ba2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Sat, 10 Jan 2026 01:54:06 +0100 Subject: [PATCH 22/29] added more factories --- app/modules/pmf/factory_pmf.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/modules/pmf/factory_pmf.py b/app/modules/pmf/factory_pmf.py index 65d0aabd9b..a1654f560f 100644 --- a/app/modules/pmf/factory_pmf.py +++ b/app/modules/pmf/factory_pmf.py @@ -28,6 +28,23 @@ async def create_offers(cls, db: AsyncSession): ), db=db, ) + await cruds_pmf.create_offer( + offer=models_pmf.PmfOffer( + id=uuid.uuid4(), + author_id=CoreUsersFactory.demo_users_id[1], + company_name="EDF", + start_date=date(2025,12,12), + end_date=date(2026,6,5), + duration=0, + title="Ingeneirue", + description="elec", + location="Ecully", + location_type=types_pmf.LocationType.On_site, + offer_type=types_pmf.OfferType.CDI, + created_at=date.today + ), + db=db, + ) @classmethod From 78c3946ac21c50505eedd4026d32e51164d50959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Sat, 10 Jan 2026 01:54:06 +0100 Subject: [PATCH 23/29] update migrations order --- migrations/versions/{45-pmf.py => 54-pmf.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename migrations/versions/{45-pmf.py => 54-pmf.py} (98%) diff --git a/migrations/versions/45-pmf.py b/migrations/versions/54-pmf.py similarity index 98% rename from migrations/versions/45-pmf.py rename to migrations/versions/54-pmf.py index 8064254135..a80aa47a31 100644 --- a/migrations/versions/45-pmf.py +++ b/migrations/versions/54-pmf.py @@ -16,7 +16,7 @@ # revision identifiers, used by Alembic. revision: str = "ca5a9c9c64e5" -down_revision: str | None = "91fadc90f892" +down_revision: str | None = "9fc3dc926600" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 8a03e11b57beae3e8f01b8ce78817376d58b2d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Sat, 28 Feb 2026 12:42:58 +0100 Subject: [PATCH 24/29] renamed the auth provider --- app/utils/auth/providers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/utils/auth/providers.py b/app/utils/auth/providers.py index 909c1982c0..2738f78163 100644 --- a/app/utils/auth/providers.py +++ b/app/utils/auth/providers.py @@ -359,7 +359,7 @@ class SiarnaqAuthClient(BaseAuthClient): permission = AuthPermissions.siarnaq -class PMFAuthClient(BaseAuthClient): +class EnceladusAuthClient(BaseAuthClient): allowed_scopes: set[ScopeType | str] = {ScopeType.API} allowed_account_types: list[AccountType] | None = None From 05b4225cfdab3b85c6b9d996305cfcebbb6fd5e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Mon, 2 Mar 2026 22:25:10 +0100 Subject: [PATCH 25/29] fixed endpoint names --- app/modules/pmf/endpoints_pmf.py | 8 ++++---- migrations/versions/{54-pmf.py => 57-pmf.py} | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename migrations/versions/{54-pmf.py => 57-pmf.py} (98%) diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index 920397ac30..f13d21a335 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -75,7 +75,7 @@ async def get_offers( @router.post( - "/pmf/offer/", + "/pmf/offers/", response_model=schemas_pmf.OfferComplete, status_code=200, ) @@ -108,8 +108,8 @@ async def create_offer( return await cruds_pmf.get_offer_by_id(offer_id=offer_db.id, db=db) -@router.put( - "/pmf/offer/{offer_id}", +@router.patch( + "/pmf/offers/{offer_id}", response_model=None, status_code=204, ) @@ -146,7 +146,7 @@ async def update_offer( @router.delete( - "/pmf/offer/{offer_id}", + "/pmf/offers/{offer_id}", response_model=None, status_code=204, ) diff --git a/migrations/versions/54-pmf.py b/migrations/versions/57-pmf.py similarity index 98% rename from migrations/versions/54-pmf.py rename to migrations/versions/57-pmf.py index a80aa47a31..8c4b56c1ab 100644 --- a/migrations/versions/54-pmf.py +++ b/migrations/versions/57-pmf.py @@ -16,7 +16,7 @@ # revision identifiers, used by Alembic. revision: str = "ca5a9c9c64e5" -down_revision: str | None = "9fc3dc926600" +down_revision: str | None = "a1b2c3d4e5f6" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None From 9161e349bdca064e376aff2a613432c2117f0891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Mon, 2 Mar 2026 22:28:26 +0100 Subject: [PATCH 26/29] removed end date as it's useless --- app/modules/pmf/cruds_pmf.py | 2 -- app/modules/pmf/factory_pmf.py | 8 +++----- app/modules/pmf/models_pmf.py | 1 - app/modules/pmf/schemas_pmf.py | 2 -- migrations/versions/57-pmf.py | 1 - 5 files changed, 3 insertions(+), 11 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index 8741edeeb1..24ad481bf3 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -21,7 +21,6 @@ async def create_offer(offer: schemas_pmf.OfferSimple, db: AsyncSession) -> None location=offer.location, location_type=offer.location_type, start_date=offer.start_date, - end_date=offer.end_date, duration=offer.duration, created_at=date.today(), tags=[], @@ -101,7 +100,6 @@ async def get_offers( location=offer.location, location_type=offer.location_type, start_date=offer.start_date, - end_date=offer.end_date, duration=offer.duration, author=schemas_users.CoreUserSimple.model_validate(offer.author), tags=[ diff --git a/app/modules/pmf/factory_pmf.py b/app/modules/pmf/factory_pmf.py index a1654f560f..f321f52b58 100644 --- a/app/modules/pmf/factory_pmf.py +++ b/app/modules/pmf/factory_pmf.py @@ -17,8 +17,7 @@ async def create_offers(cls, db: AsyncSession): author_id=CoreUsersFactory.demo_users_id[0], company_name="Centrale Innovation", start_date=date(2025,12,1), - end_date=date(2026,4,17), - duration=0, + duration=6, title="Stage", description="Stageant", location="Montcuq", @@ -34,13 +33,12 @@ async def create_offers(cls, db: AsyncSession): author_id=CoreUsersFactory.demo_users_id[1], company_name="EDF", start_date=date(2025,12,12), - end_date=date(2026,6,5), - duration=0, + duration=4, title="Ingeneirue", description="elec", location="Ecully", location_type=types_pmf.LocationType.On_site, - offer_type=types_pmf.OfferType.CDI, + offer_type=types_pmf.OfferType.APP, created_at=date.today ), db=db, diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py index 0861fef665..a84ab8c12b 100644 --- a/app/modules/pmf/models_pmf.py +++ b/app/modules/pmf/models_pmf.py @@ -43,7 +43,6 @@ class PmfOffer(Base): location_type: Mapped[LocationType] # Enum (On_site, Hybrid, Remote) start_date: Mapped[date] - end_date: Mapped[date] duration: Mapped[int] # days created_at: Mapped[date] = mapped_column(insert_default=date.today) diff --git a/app/modules/pmf/schemas_pmf.py b/app/modules/pmf/schemas_pmf.py index 8a242cb4ba..de31b73ec4 100644 --- a/app/modules/pmf/schemas_pmf.py +++ b/app/modules/pmf/schemas_pmf.py @@ -27,7 +27,6 @@ class OfferBase(BaseModel): location_type: LocationType start_date: date - end_date: date duration: int # days @@ -44,7 +43,6 @@ class OfferUpdate(BaseModel): location: str | None = None location_type: LocationType | None = None start_date: date | None = None - end_date: date | None = None duration: int | None = None # days tags: list[TagBase] | None = None diff --git a/migrations/versions/57-pmf.py b/migrations/versions/57-pmf.py index 8c4b56c1ab..589d643c7c 100644 --- a/migrations/versions/57-pmf.py +++ b/migrations/versions/57-pmf.py @@ -49,7 +49,6 @@ def upgrade() -> None: nullable=False, ), sa.Column("start_date", sa.Date(), nullable=False), - sa.Column("end_date", sa.Date(), nullable=False), sa.Column("duration", sa.Integer(), nullable=False), sa.Column("created_at", sa.Date(), nullable=False), sa.ForeignKeyConstraint(["author_id"], ["core_user.id"]), From 6d7dca3c046311a8ed67761c0ece1c35bd45a8bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Mon, 2 Mar 2026 22:40:20 +0100 Subject: [PATCH 27/29] forgot tests existed --- tests/modules/test_pmf.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py index 56ad020b0b..78f752bed7 100644 --- a/tests/modules/test_pmf.py +++ b/tests/modules/test_pmf.py @@ -92,7 +92,6 @@ async def init_objects(): location="Toulouse, France", location_type=LocationType.On_site, start_date=date(2023, 6, 1), - end_date=date(2023, 8, 31), created_at=date(2023, 5, 1), duration=92, ) @@ -110,7 +109,6 @@ async def init_objects(): location="Remote", location_type=LocationType.Remote, start_date=date(2023, 7, 1), - end_date=date(2023, 9, 30), created_at=date(2023, 6, 1), duration=92, ) @@ -129,7 +127,6 @@ async def init_objects(): location="Hybrid - Paris, France / Remote", location_type=LocationType.Hybrid, start_date=date(2023, 8, 1), - end_date=date(2023, 10, 31), created_at=date(2023, 7, 1), duration=92, ) @@ -228,12 +225,11 @@ def test_create_offer( "location": "Test City", "location_type": LocationType.On_site.value, "start_date": "2024-01-01", - "end_date": "2024-06-30", "duration": 181, } response = client.post( - "/pmf/offer/", + "/pmf/offers/", json=offer_data, headers={"Authorization": f"Bearer {actual_token}"}, ) @@ -249,7 +245,7 @@ def test_update_offer_success(client: TestClient): } response = client.put( - f"/pmf/offer/{offer1_id}", + f"/pmf/offers/{offer1_id}", json=offer_update, headers={"Authorization": f"Bearer {alumni_token}"}, ) @@ -263,7 +259,7 @@ def test_update_offer_by_admin(client: TestClient): } response = client.put( - f"/pmf/offer/{offer2_id}", + f"/pmf/offers/{offer2_id}", json=offer_update, headers={"Authorization": f"Bearer {admin_token}"}, ) @@ -277,7 +273,7 @@ def test_update_offer_forbidden(client: TestClient): } response = client.put( - f"/pmf/offer/{offer1_id}", + f"/pmf/offers/{offer1_id}", json=offer_update, headers={"Authorization": f"Bearer {student_token}"}, ) @@ -291,7 +287,7 @@ def test_update_nonexistent_offer(client: TestClient): } response = client.put( - f"/pmf/offer/{offer_fake_id}", + f"/pmf/offers/{offer_fake_id}", json=offer_update, headers={"Authorization": f"Bearer {alumni_token}"}, ) @@ -301,7 +297,7 @@ def test_update_nonexistent_offer(client: TestClient): def test_delete_offer_success(client: TestClient): """Test successful offer deletion by the author""" response = client.delete( - f"/pmf/offer/{offer3_id}", + f"/pmf/offers/{offer3_id}", headers={"Authorization": f"Bearer {alumni_token}"}, ) assert response.status_code == 204 @@ -319,12 +315,11 @@ def test_delete_offer_by_admin(client: TestClient): "location": "Delete Test City", "location_type": LocationType.Remote.value, "start_date": "2024-01-01", - "end_date": "2024-06-30", "duration": 181, } create_response = client.post( - "/pmf/offer/", + "/pmf/offers/", json=offer_data, headers={"Authorization": f"Bearer {alumni_token}"}, ) @@ -333,7 +328,7 @@ def test_delete_offer_by_admin(client: TestClient): # Now delete it as admin response = client.delete( - f"/pmf/offer/{created_offer_id}", + f"/pmf/offers/{created_offer_id}", headers={"Authorization": f"Bearer {admin_token}"}, ) assert response.status_code == 204 @@ -342,7 +337,7 @@ def test_delete_offer_by_admin(client: TestClient): def test_delete_offer_forbidden(client: TestClient): """Test forbidden offer deletion by non-author""" response = client.delete( - f"/pmf/offer/{offer2_id}", + f"/pmf/offers/{offer2_id}", headers={"Authorization": f"Bearer {student_token}"}, ) assert response.status_code == 403 @@ -351,7 +346,7 @@ def test_delete_offer_forbidden(client: TestClient): def test_delete_nonexistent_offer(client: TestClient): """Test deletion of non-existent offer""" response = client.delete( - f"/pmf/offer/{offer_fake_id}", + f"/pmf/offers/{offer_fake_id}", headers={"Authorization": f"Bearer {alumni_token}"}, ) assert response.status_code == 404 From 68a22cd5f4cdbcca37d1ca0977523a1326c1dd5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Tue, 3 Mar 2026 01:29:11 +0100 Subject: [PATCH 28/29] fixed format + wip get me or user offers --- .vscode/settings.json | 6 +- app/modules/pmf/cruds_pmf.py | 119 ++++++++++++++++++++++++++++++- app/modules/pmf/endpoints_pmf.py | 60 ++++++++++++++-- app/modules/pmf/factory_pmf.py | 17 +++-- app/utils/auth/providers.py | 6 +- migrations/versions/57-pmf.py | 4 +- tests/modules/test_pmf.py | 2 +- 7 files changed, 190 insertions(+), 24 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9cd47a8acb..0f45583afe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,15 +14,13 @@ "python.testing.unittestEnabled": false, "python.testing.pytestEnabled": true, "github.copilot.enable": { - "*": true, + "*": false, "plaintext": false, "markdown": false, "scminput": false, - // We don't want Copilot to be active - // on config.yaml and dotenv files "yaml": false, "properties": false - }, +}, "files.associations": { ".env": "properties", ".env.template": "properties" diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index 24ad481bf3..c432ffcaf3 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import UTC, datetime from uuid import UUID from sqlalchemy import delete, select, true, update @@ -22,7 +22,7 @@ async def create_offer(offer: schemas_pmf.OfferSimple, db: AsyncSession) -> None location_type=offer.location_type, start_date=offer.start_date, duration=offer.duration, - created_at=date.today(), + created_at=datetime.now(UTC).date(), tags=[], ), ) @@ -114,6 +114,121 @@ async def get_offers( for offer in offers.scalars().all() ] +async def get_offers_by_author_id( + author_id: str, + db: AsyncSession, + included_offer_types: list[types_pmf.OfferType] | None = None, + included_tags: list[str] | None = None, + included_location_types: list[types_pmf.LocationType] | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[schemas_pmf.OfferComplete]: + where_clause = ( + ( + models_pmf.PmfOffer.offer_type.in_(included_offer_types) + if included_offer_types + else true() + ) + & ( + models_pmf.PmfOffer.tags.any(models_pmf.Tags.tag.in_(included_tags)) + if included_tags + else true() + ) + & ( + models_pmf.PmfOffer.location_type.in_(included_location_types) + if included_location_types + else true() + ) + & ( + models_pmf.PmfOffer.author_id==author_id + ) + ) + + offers = await db.execute( + select(models_pmf.PmfOffer).where(where_clause).limit(limit).offset(offset), + ) + return [ + schemas_pmf.OfferComplete( + id=offer.id, + author_id=offer.author_id, + company_name=offer.company_name, + title=offer.title, + description=offer.description, + offer_type=offer.offer_type, + location=offer.location, + location_type=offer.location_type, + start_date=offer.start_date, + duration=offer.duration, + author=schemas_users.CoreUserSimple.model_validate(offer.author), + tags=[ + schemas_pmf.TagComplete( + id=tag.id, + tag=tag.tag, + created_at=tag.created_at, + ) + for tag in offer.tags + ], + ) + for offer in offers.scalars().all() + ] + +async def get_me_offers( + db: AsyncSession, + included_offer_types: list[types_pmf.OfferType] | None = None, + included_tags: list[str] | None = None, + included_location_types: list[types_pmf.LocationType] | None = None, + limit: int | None = None, + offset: int | None = None, +) -> list[schemas_pmf.OfferComplete]: + where_clause = ( + ( + models_pmf.PmfOffer.offer_type.in_(included_offer_types) + if included_offer_types + else true() + ) + & ( + models_pmf.PmfOffer.tags.any(models_pmf.Tags.tag.in_(included_tags)) + if included_tags + else true() + ) + & ( + models_pmf.PmfOffer.location_type.in_(included_location_types) + if included_location_types + else true() + ) + & ( + models_pmf.PmfOffer.author_id==user + ) + ) + + offers = await db.execute( + select(models_pmf.PmfOffer).where(where_clause).limit(limit).offset(offset), + ) + return [ + schemas_pmf.OfferComplete( + id=offer.id, + author_id=offer.author_id, + company_name=offer.company_name, + title=offer.title, + description=offer.description, + offer_type=offer.offer_type, + location=offer.location, + location_type=offer.location_type, + start_date=offer.start_date, + duration=offer.duration, + author=schemas_users.CoreUserSimple.model_validate(offer.author), + tags=[ + schemas_pmf.TagComplete( + id=tag.id, + tag=tag.tag, + created_at=tag.created_at, + ) + for tag in offer.tags + ], + ) + for offer in offers.scalars().all() + ] + async def get_all_tags(db: AsyncSession) -> list[schemas_pmf.TagComplete]: tags = await db.execute( diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index f13d21a335..219e7ed69f 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -1,5 +1,5 @@ import uuid -from datetime import date +from datetime import UTC, datetime from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query @@ -7,10 +7,12 @@ from app.core.groups.groups_type import AccountType, GroupType from app.core.users.models_users import CoreUser -from app.dependencies import get_db, is_user, is_user_in -from app.modules.pmf import cruds_pmf, schemas_pmf, types_pmf, factory_pmf +from app.dependencies import get_db, is_user, is_user_in, is_user_allowed_to +from app.modules.pmf import cruds_pmf, factory_pmf, schemas_pmf, types_pmf from app.types.module import Module from app.utils.tools import is_user_member_of_any_group +from app.utils.auth.providers import AuthPermissions +from app.core.users import models_users router = APIRouter(tags=["pmf"]) @@ -48,6 +50,56 @@ async def get_offer( return offer +@router.get( + "/pmf/users/{author_id}/offers", + response_model=list[schemas_pmf.OfferSimple], + status_code=200, +) +async def get_offers_by_author_id( + author_id:str, + db: AsyncSession = Depends(get_db), + includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), + includedTags: list[str] = Query(default=[]), + includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), + limit: int | None = Query(default=50, gt=0, le=50), + offset: int | None = Query(default=0, ge=0), +): + return await cruds_pmf.get_offers_by_author_id( + author_id=author_id, + db=db, + included_offer_types=includedOfferTypes, + included_tags=includedTags, + included_location_types=includedLocationTypes, + limit=limit, + offset=offset, + ) + +@router.get( + "/pmf/me/offers", + response_model=list[schemas_pmf.OfferSimple], + status_code=200, +) +async def get_me_offers( + db: AsyncSession = Depends(get_db), + includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), + includedTags: list[str] = Query(default=[]), + includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), + limit: int | None = Query(default=50, gt=0, le=50), + offset: int | None = Query(default=0, ge=0), + user: models_users.CoreUser = Depends( + is_user_allowed_to([AuthPermissions.pmf]), + ), +): + print(user.id) + return await cruds_pmf.get_offers_by_author_id( + author_id=user.id, + db=db, + included_offer_types=includedOfferTypes, + included_tags=includedTags, + included_location_types=includedLocationTypes, + limit=limit, + offset=offset, + ) @router.get( "/pmf/offers/", @@ -227,7 +279,7 @@ async def create_tag( tag_db = schemas_pmf.TagComplete( **tag.model_dump(), id=uuid.uuid4(), - created_at=date.today(), + created_at=datetime.now(UTC).date(), ) await cruds_pmf.create_tag(tag=tag_db, db=db) return tag_db diff --git a/app/modules/pmf/factory_pmf.py b/app/modules/pmf/factory_pmf.py index f321f52b58..a27c9ffef8 100644 --- a/app/modules/pmf/factory_pmf.py +++ b/app/modules/pmf/factory_pmf.py @@ -1,11 +1,14 @@ -from app.types.factory import Factory -from app.core.users.factory_users import CoreUsersFactory -from app.core.utils.config import Settings -from sqlalchemy.ext.asyncio import AsyncSession -from app.modules.pmf import cruds_pmf,models_pmf,types_pmf import uuid from datetime import date +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.users.factory_users import CoreUsersFactory +from app.core.utils.config import Settings +from app.modules.pmf import cruds_pmf, models_pmf, types_pmf +from app.types.factory import Factory + + class PmfFactory(Factory): depends_on = [CoreUsersFactory] @@ -23,7 +26,7 @@ async def create_offers(cls, db: AsyncSession): location="Montcuq", location_type=types_pmf.LocationType.On_site, offer_type=types_pmf.OfferType.TFE, - created_at=date.today + created_at=date.today, ), db=db, ) @@ -39,7 +42,7 @@ async def create_offers(cls, db: AsyncSession): location="Ecully", location_type=types_pmf.LocationType.On_site, offer_type=types_pmf.OfferType.APP, - created_at=date.today + created_at=date.today, ), db=db, ) diff --git a/app/utils/auth/providers.py b/app/utils/auth/providers.py index 2738f78163..7f231c3a69 100644 --- a/app/utils/auth/providers.py +++ b/app/utils/auth/providers.py @@ -114,6 +114,7 @@ class AuthPermissions(ModulePermissions): overleaf = "overleaf" planka = "planka" slash = "slash" + pmf = "pmf" class AppAuthClient(BaseAuthClient): @@ -359,10 +360,9 @@ class SiarnaqAuthClient(BaseAuthClient): permission = AuthPermissions.siarnaq -class EnceladusAuthClient(BaseAuthClient): +class PMFAuthClient(BaseAuthClient): allowed_scopes: set[ScopeType | str] = {ScopeType.API} - - allowed_account_types: list[AccountType] | None = None + permission = AuthPermissions.pmf class OverleafAuthClient(BaseAuthClient): diff --git a/migrations/versions/57-pmf.py b/migrations/versions/57-pmf.py index 589d643c7c..464756ec70 100644 --- a/migrations/versions/57-pmf.py +++ b/migrations/versions/57-pmf.py @@ -4,7 +4,7 @@ """ from collections.abc import Sequence -from typing import TYPE_CHECKING, Union +from typing import TYPE_CHECKING if TYPE_CHECKING: from pytest_alembic import MigrationContext @@ -12,8 +12,6 @@ import sqlalchemy as sa from alembic import op -from app.types.sqlalchemy import TZDateTime - # revision identifiers, used by Alembic. revision: str = "ca5a9c9c64e5" down_revision: str | None = "a1b2c3d4e5f6" diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py index 78f752bed7..ba20afb25b 100644 --- a/tests/modules/test_pmf.py +++ b/tests/modules/test_pmf.py @@ -7,7 +7,7 @@ from app.core.groups.groups_type import AccountType, GroupType from app.core.users import models_users -from app.modules.pmf import models_pmf, schemas_pmf +from app.modules.pmf import models_pmf # We need to import event_loop for pytest-asyncio routine defined bellow from app.modules.pmf.types_pmf import LocationType, OfferType From f0cdab3da1b879fdc9f85afc28f6e8c58aa89307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=BChmos?= Date: Fri, 6 Mar 2026 11:53:39 +0100 Subject: [PATCH 29/29] added hidden attribute to offers --- app/modules/pmf/cruds_pmf.py | 14 ++++++++------ app/modules/pmf/endpoints_pmf.py | 16 ++++++++-------- app/modules/pmf/models_pmf.py | 2 +- app/modules/pmf/schemas_pmf.py | 6 +++--- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/modules/pmf/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py index c432ffcaf3..f83bdfe181 100644 --- a/app/modules/pmf/cruds_pmf.py +++ b/app/modules/pmf/cruds_pmf.py @@ -23,6 +23,7 @@ async def create_offer(offer: schemas_pmf.OfferSimple, db: AsyncSession) -> None start_date=offer.start_date, duration=offer.duration, created_at=datetime.now(UTC).date(), + hidden=True, tags=[], ), ) @@ -114,6 +115,7 @@ async def get_offers( for offer in offers.scalars().all() ] + async def get_offers_by_author_id( author_id: str, db: AsyncSession, @@ -139,9 +141,8 @@ async def get_offers_by_author_id( if included_location_types else true() ) - & ( - models_pmf.PmfOffer.author_id==author_id - ) + & (models_pmf.PmfOffer.author_id == author_id) + & (not models_pmf.PmfOffer.hidden) ) offers = await db.execute( @@ -172,7 +173,9 @@ async def get_offers_by_author_id( for offer in offers.scalars().all() ] + async def get_me_offers( + author_id: str, db: AsyncSession, included_offer_types: list[types_pmf.OfferType] | None = None, included_tags: list[str] | None = None, @@ -196,9 +199,7 @@ async def get_me_offers( if included_location_types else true() ) - & ( - models_pmf.PmfOffer.author_id==user - ) + & (models_pmf.PmfOffer.author_id == author_id) ) offers = await db.execute( @@ -216,6 +217,7 @@ async def get_me_offers( location_type=offer.location_type, start_date=offer.start_date, duration=offer.duration, + hidden=offer.hidden, author=schemas_users.CoreUserSimple.model_validate(offer.author), tags=[ schemas_pmf.TagComplete( diff --git a/app/modules/pmf/endpoints_pmf.py b/app/modules/pmf/endpoints_pmf.py index 219e7ed69f..2aafeb8994 100644 --- a/app/modules/pmf/endpoints_pmf.py +++ b/app/modules/pmf/endpoints_pmf.py @@ -6,13 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.groups.groups_type import AccountType, GroupType +from app.core.users import models_users from app.core.users.models_users import CoreUser -from app.dependencies import get_db, is_user, is_user_in, is_user_allowed_to +from app.dependencies import get_db, is_user, is_user_allowed_to, is_user_in from app.modules.pmf import cruds_pmf, factory_pmf, schemas_pmf, types_pmf from app.types.module import Module -from app.utils.tools import is_user_member_of_any_group from app.utils.auth.providers import AuthPermissions -from app.core.users import models_users +from app.utils.tools import is_user_member_of_any_group router = APIRouter(tags=["pmf"]) @@ -50,13 +50,14 @@ async def get_offer( return offer + @router.get( "/pmf/users/{author_id}/offers", response_model=list[schemas_pmf.OfferSimple], status_code=200, ) async def get_offers_by_author_id( - author_id:str, + author_id: str, db: AsyncSession = Depends(get_db), includedOfferTypes: list[types_pmf.OfferType] = Query(default=[]), includedTags: list[str] = Query(default=[]), @@ -74,6 +75,7 @@ async def get_offers_by_author_id( offset=offset, ) + @router.get( "/pmf/me/offers", response_model=list[schemas_pmf.OfferSimple], @@ -86,11 +88,8 @@ async def get_me_offers( includedLocationTypes: list[types_pmf.LocationType] = Query(default=[]), limit: int | None = Query(default=50, gt=0, le=50), offset: int | None = Query(default=0, ge=0), - user: models_users.CoreUser = Depends( - is_user_allowed_to([AuthPermissions.pmf]), - ), + user: models_users.CoreUser = Depends(is_user()), ): - print(user.id) return await cruds_pmf.get_offers_by_author_id( author_id=user.id, db=db, @@ -101,6 +100,7 @@ async def get_me_offers( offset=offset, ) + @router.get( "/pmf/offers/", response_model=list[schemas_pmf.OfferSimple], diff --git a/app/modules/pmf/models_pmf.py b/app/modules/pmf/models_pmf.py index a84ab8c12b..7c33ebed36 100644 --- a/app/modules/pmf/models_pmf.py +++ b/app/modules/pmf/models_pmf.py @@ -46,7 +46,7 @@ class PmfOffer(Base): duration: Mapped[int] # days created_at: Mapped[date] = mapped_column(insert_default=date.today) - + hidden: Mapped[bool] tags: Mapped[list["Tags"]] = relationship( "Tags", back_populates="offers", diff --git a/app/modules/pmf/schemas_pmf.py b/app/modules/pmf/schemas_pmf.py index de31b73ec4..10f1e37b9f 100644 --- a/app/modules/pmf/schemas_pmf.py +++ b/app/modules/pmf/schemas_pmf.py @@ -25,13 +25,13 @@ class OfferBase(BaseModel): offer_type: OfferType location: str location_type: LocationType - start_date: date duration: int # days class OfferSimple(OfferBase): id: UUID + hidden: bool class OfferUpdate(BaseModel): @@ -43,8 +43,8 @@ class OfferUpdate(BaseModel): location: str | None = None location_type: LocationType | None = None start_date: date | None = None - duration: int | None = None # days - + duration: int | None = None # months + hidden: bool | None = None tags: list[TagBase] | None = None