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/cruds_pmf.py b/app/modules/pmf/cruds_pmf.py new file mode 100644 index 0000000000..8741edeeb1 --- /dev/null +++ b/app/modules/pmf/cruds_pmf.py @@ -0,0 +1,188 @@ +from datetime import date +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, + created_at=date.today(), + 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: + # 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), + ) + + +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, + 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() + ) + ) + + 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, + end_date=offer.end_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( + select(models_pmf.Tags).distinct(models_pmf.Tags.tag), + ) + 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, +) -> models_pmf.Tags | None: + result = await db.execute( + select(models_pmf.Tags).where(models_pmf.Tags.tag == tag_name), + ) + return result.scalars().first() + + +async def get_tag_by_id( + tag_id: UUID, + db: AsyncSession, +) -> models_pmf.Tags | None: + result = await db.execute( + select(models_pmf.Tags).where(models_pmf.Tags.id == tag_id), + ) + return 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, + created_at=tag.created_at, + ) + 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: + # 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 new file mode 100644 index 0000000000..920397ac30 --- /dev/null +++ b/app/modules/pmf/endpoints_pmf.py @@ -0,0 +1,274 @@ +import uuid +from datetime import date +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 get_db, is_user, is_user_in +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 + +router = APIRouter(tags=["pmf"]) + +module = Module( + root="pmf", + tag="Pmf", + router=router, + default_allowed_account_types=[ + AccountType.student, + AccountType.staff, + AccountType.former_student, + ], + factory=factory_pmf.PmfFactory, +) + + +@router.get( + "/pmf/offers/{offer_id}", + response_model=schemas_pmf.OfferComplete, + status_code=200, +) +async def get_offer( + offer_id: UUID, + db: AsyncSession = Depends(get_db), + # 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 offer is None: + 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 = 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), + # 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, + limit=limit, + offset=offset, + ) + + +@router.post( + "/pmf/offer/", + response_model=schemas_pmf.OfferComplete, + status_code=200, +) +async def create_offer( + 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]), + ), +): + # 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(), + ) + 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( + "/pmf/offer/{offer_id}", + response_model=None, + status_code=204, +) +async def update_offer( + offer_id: UUID, + 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]), + ), +): + 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 = Depends(get_db), + # 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 = Depends(get_db), +) -> 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 = Depends(get_db), +) -> 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( + "/pmf/tag/", + response_model=schemas_pmf.TagComplete, + status_code=201, +) +async def create_tag( + tag: schemas_pmf.TagBase, + db: AsyncSession = Depends(get_db), + # 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(), + created_at=date.today(), + ) + 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 = Depends(get_db), + # 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 = Depends(get_db), + # 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/factory_pmf.py b/app/modules/pmf/factory_pmf.py new file mode 100644 index 0000000000..a1654f560f --- /dev/null +++ b/app/modules/pmf/factory_pmf.py @@ -0,0 +1,57 @@ +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, + ) + 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 + 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/models_pmf.py b/app/modules/pmf/models_pmf.py new file mode 100644 index 0000000000..0861fef665 --- /dev/null +++ b/app/modules/pmf/models_pmf.py @@ -0,0 +1,73 @@ +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 + + created_at: Mapped[date] = mapped_column(insert_default=date.today) + + tags: Mapped[list["Tags"]] = 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(insert_default=date.today) + + offers: Mapped[list["PmfOffer"]] = 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..8a242cb4ba --- /dev/null +++ b/app/modules/pmf/schemas_pmf.py @@ -0,0 +1,55 @@ +from datetime import date +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 + created_at: date + + +class OfferBase(BaseModel): + author_id: str + + company_name: str + title: str + description: str + offer_type: OfferType + location: str + location_type: LocationType + + start_date: date + end_date: date + 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: date | None = None + end_date: date | 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..816245b5d1 --- /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" + APP = "APP" + EXE = "EXE" + CDI = "CDI" + CDD = "CDD" + + +class LocationType(str, Enum): + On_site = "On_site" + Hybrid = "Hybrid" + Remote = "Remote" diff --git a/app/utils/auth/providers.py b/app/utils/auth/providers.py index 12c9ceb2f2..d93d4c94f1 100644 --- a/app/utils/auth/providers.py +++ b/app/utils/auth/providers.py @@ -354,6 +354,12 @@ class SiarnaqAuthClient(BaseAuthClient): allowed_account_types: list[AccountType] | None = None +class PMFAuthClient(BaseAuthClient): + allowed_scopes: set[ScopeType | str] = {ScopeType.API} + + allowed_account_types: list[AccountType] | None = None + + class OverleafAuthClient(BaseAuthClient): allowed_account_types: list[AccountType] | None = get_ecl_account_types() diff --git a/migrations/versions/45-pmf.py b/migrations/versions/45-pmf.py new file mode 100644 index 0000000000..8064254135 --- /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", "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 diff --git a/tests/modules/test_pmf.py b/tests/modules/test_pmf.py new file mode 100644 index 0000000000..56ad020b0b --- /dev/null +++ b/tests/modules/test_pmf.py @@ -0,0 +1,512 @@ +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 +admin_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") +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") + +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, admin_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, + ) + admin_user = await create_user_with_groups( + groups=[GroupType.admin], + account_type=AccountType.former_student, + ) + + 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( + tag="Aeronautics", + id=tag1_id, + created_at=date(2023, 5, 1), + ) + tag2 = models_pmf.Tags( + tag="Artificial Intelligence", + id=tag2_id, + created_at=date(2023, 5, 1), + ) + await add_object_to_db(tag1) + await add_object_to_db(tag2) + + # 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=tag1.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.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=tag2.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=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) + + +@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"/pmf/offers/{offer_id}", + headers={"Authorization": f"Bearer {student_token}"}, + ) + assert response.status_code == expected_code + + +@pytest.mark.parametrize( + ("query", "expected_code", "expected_length"), + [ + ("", 200, 3), + ("?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.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"/pmf/offers/{query}", + ) + assert response.status_code == expected_code + if expected_code == 200: + 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.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