From bad3d5e119d8169cf0751f063a41be6d1f436c4f Mon Sep 17 00:00:00 2001 From: Steventog Date: Wed, 3 Jun 2026 23:10:25 +0000 Subject: [PATCH 1/2] feat: add job board endpoints, fix JSONB lists, harden startup config and add swagger doc only in dev --- .gitignore | 66 ++++++++++++-- app/core/imagekit.py | 20 +++-- app/core/settings.py | 22 ++--- app/database/generate_sql_queries.py | 6 +- app/database/migrations.py | 33 +++++++ app/main.py | 8 +- app/routers/api.py | 2 + app/routers/job_offers.py | 111 ++++++++++++++++++++++++ app/schemas/config.py | 22 ++--- app/schemas/models.py | 56 ++++++++++++ app/utils/job_offers.py | 124 +++++++++++++++++++++++++++ docker-compose.yml | 2 +- dockerfile | 2 +- 13 files changed, 432 insertions(+), 42 deletions(-) create mode 100644 app/routers/job_offers.py create mode 100644 app/utils/job_offers.py diff --git a/.gitignore b/.gitignore index bf39464..0e378be 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,65 @@ -.env -secret* +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.so +*.egg +*.egg-info/ +dist/ +build/ + +# Environnements virtuels .venv/ venv/ -__pycache__/ -*.pyc +env/ +ENV/ + +# Variables d'environnement — ne jamais commiter +.env +app/.env +!*.env.example +!app/.env.example + +# Secrets +secret* +*.key +*.pem + +# Bases de données locales +*.db +*.sqlite3 + +# Logs +*.log +logs/ + +# IDEs +.vscode/ +.idea/ +*.iml +*.sublime-project +*.sublime-workspace + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Tests +.coverage +.pytest_cache/ +.mypy_cache/ +.tox/ +htmlcov/ +coverage.xml mock* fake* test* tests/ -.vscode/ -.idea/ -.DS_Store + +# Divers +*.bak +*.tmp +*.swp +*.swo diff --git a/app/core/imagekit.py b/app/core/imagekit.py index 76d54dd..cc1d177 100644 --- a/app/core/imagekit.py +++ b/app/core/imagekit.py @@ -3,21 +3,27 @@ from imagekitio import ImageKit from app.core.settings import settings +_client: ImageKit | None = None -imagekit = ImageKit( - private_key=settings.imagekit_private_key -) -URL_ENDPOINT = settings.imagekit_url_endpoint +def _get_client() -> ImageKit: + global _client + if _client is None: + if not settings.imagekit_private_key: + raise RuntimeError( + "IMAGEKIT_PRIVATE_KEY is not set. Configure it to use ImageKit." + ) + _client = ImageKit(private_key=settings.imagekit_private_key) + return _client -def upload_image_base64_url(image_name, base64_string, folder=""): +def upload_image_base64_url(image_name: str, base64_string: str, folder: str = ""): try: - upload_response = imagekit.files.upload( + client = _get_client() + upload_response = client.files.upload( file=base64.b64decode(base64_string), file_name=image_name, folder="/pythontogo/" + folder.lstrip("/"), - ) return upload_response except Exception as e: diff --git a/app/core/settings.py b/app/core/settings.py index 96ecddf..aa65d2c 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -31,17 +31,17 @@ smtp_port=config("SMTP_PORT", default=587, cast=int), smtp_user=config("SMTP_USER", default="user"), smtp_password=config("SMTP_PASSWORD", default="password"), - paydunya_public_key=config("PAYDUNYA_PUBLIC_KEY"), - paydunya_private_key=config("PAYDUNYA_PRIVATE_KEY"), - paydunya_token=config("PAYDUNYA_TOKEN"), - paydunya_master_key=config("PAYDUNYA_MASTER_KEY"), - imagekit_private_key=config("IMAGEKIT_PRIVATE_KEY"), - imagekit_public_key=config("IMAGEKIT_PUBLIC_KEY"), - imagekit_url_endpoint=config("IMAGEKIT_URL_ENDPOINT"), - student_pass_template_url=config("STUDENT_PASS_TEMPLATE_URL"), - professional_pass_template_url=config("PROFESSIONAL_PASS_TEMPLATE_URL"), - premium_pass_template_url=config("PREMIUM_PASS_TEMPLATE_URL"), - dinner_pass_template_url=config("DINNER_PASS_TEMPLATE_URL") + paydunya_public_key=config("PAYDUNYA_PUBLIC_KEY", default=None), + paydunya_private_key=config("PAYDUNYA_PRIVATE_KEY", default=None), + paydunya_token=config("PAYDUNYA_TOKEN", default=None), + paydunya_master_key=config("PAYDUNYA_MASTER_KEY", default=None), + imagekit_private_key=config("IMAGEKIT_PRIVATE_KEY", default=None), + imagekit_public_key=config("IMAGEKIT_PUBLIC_KEY", default=None), + imagekit_url_endpoint=config("IMAGEKIT_URL_ENDPOINT", default=None), + student_pass_template_url=config("STUDENT_PASS_TEMPLATE_URL", default=None), + professional_pass_template_url=config("PROFESSIONAL_PASS_TEMPLATE_URL", default=None), + premium_pass_template_url=config("PREMIUM_PASS_TEMPLATE_URL", default=None), + dinner_pass_template_url=config("DINNER_PASS_TEMPLATE_URL", default=None) ) diff --git a/app/database/generate_sql_queries.py b/app/database/generate_sql_queries.py index 4c75d52..bfcde25 100644 --- a/app/database/generate_sql_queries.py +++ b/app/database/generate_sql_queries.py @@ -12,9 +12,9 @@ def normalize_value(value): Returns: ------- - The normalized value, ready for use in SQL queries. For dictionaries, it returns a Jsonb object. + The normalized value, ready for use in SQL queries. For dictionaries and lists, it returns a Jsonb object. """ - if isinstance(value, dict): + if isinstance(value, (dict, list)): return Jsonb(value) return value @@ -22,7 +22,7 @@ def normalize_value(value): def normalize_data(data: dict): return { k: str(v) if not isinstance( - v, (int, float, bool, dict, type(None))) else v + v, (int, float, bool, dict, list, type(None))) else v for k, v in data.items() } diff --git a/app/database/migrations.py b/app/database/migrations.py index 77c2211..f5f3bce 100644 --- a/app/database/migrations.py +++ b/app/database/migrations.py @@ -86,6 +86,14 @@ 'manual_correction' ); END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'job_location_enum') THEN + CREATE TYPE job_location_enum AS ENUM ('remote', 'onsite', 'hybrid'); + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'contract_type_enum') THEN + CREATE TYPE contract_type_enum AS ENUM ('full-time', 'part-time', 'internship', 'contract'); + END IF; END $$; """ @@ -417,6 +425,29 @@ ON DELETE CASCADE );""", + """ + CREATE TABLE IF NOT EXISTS job_offers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + company VARCHAR(255) NOT NULL, + logo_url TEXT, + location job_location_enum NOT NULL, + contract_type contract_type_enum NOT NULL, + apply_url TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + salary_range VARCHAR(255), + application_deadline TIMESTAMPTZ, + tags JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_job_offers_event + FOREIGN KEY (event_id) + REFERENCES events(id) + ON DELETE CASCADE, + CONSTRAINT uq_job_offers_event_company_title UNIQUE (event_id, company, title) + );""", ] @@ -424,6 +455,8 @@ CREATE_INDEX_QUERIES = [ "CREATE INDEX IF NOT EXISTS idx_sponsors_partners_event_id ON sponsors_partners(event_id);", "CREATE INDEX IF NOT EXISTS idx_api_keys_event_id ON api_keys(event_id);", + "CREATE INDEX IF NOT EXISTS idx_job_offers_event_id ON job_offers(event_id);", + "CREATE INDEX IF NOT EXISTS idx_job_offers_is_active ON job_offers(is_active);", ] diff --git a/app/main.py b/app/main.py index 1ab531d..db9eb6a 100644 --- a/app/main.py +++ b/app/main.py @@ -47,6 +47,8 @@ async def lifespan(app: FastAPI): await app.state.redis_client.close() +_is_dev = settings.env in ["dev", "local", "development"] + app = FastAPI( title=settings.app_name, version="2.1.0", @@ -54,7 +56,11 @@ async def lifespan(app: FastAPI): "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - lifespan=lifespan) + lifespan=lifespan, + openapi_url="/openapi.json" if _is_dev else None, + docs_url="/docs" if _is_dev else None, + redoc_url="/redoc" if _is_dev else None, +) app.add_middleware( diff --git a/app/routers/api.py b/app/routers/api.py index d63ec2c..97ec851 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -12,6 +12,7 @@ from app.routers.tickets import api_router as tickets_router from app.routers.registrations import api_router as registrations_router from app.routers.helper import app_router as helper_router +from app.routers.job_offers import api_router as job_offers_router from fastapi import APIRouter from app.core.security import verify_api_key @@ -31,4 +32,5 @@ api_routers.include_router(checkout_router) api_routers.include_router(registrations_router) api_routers.include_router(tickets_router) +api_routers.include_router(job_offers_router) api_routers.include_router(helper_router) diff --git a/app/routers/job_offers.py b/app/routers/job_offers.py new file mode 100644 index 0000000..f60dd3c --- /dev/null +++ b/app/routers/job_offers.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status + +from app.database.connection import get_db_connection +from app.schemas.models import JobOfferCreate, JobOfferSummary, JobOfferUpdate, MessageResponse +from app.utils.job_offers import ( + add_job_offer, + delete_job_offer, + get_all_job_offers, + get_job_offer_by_id, + get_job_offers_by_event, + update_job_offer, +) +from app.core.settings import logger + + +api_router = APIRouter(prefix="/job-offers", tags=["job-offers"]) + + +@api_router.post( + "/create/{event_code}", + response_model=MessageResponse, + status_code=status.HTTP_201_CREATED, +) +async def create_job_offer( + job_offer: JobOfferCreate, + event_code: str, + background_tasks: BackgroundTasks, + db=Depends(get_db_connection), +): + """Create a new job offer for an event.""" + try: + return await add_job_offer(db, job_offer, event_code, background_tasks) + except Exception as e: + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +@api_router.get("/list/{event_code}", response_model=list[JobOfferSummary]) +async def list_job_offers(event_code: str, db=Depends(get_db_connection)): + """List all active job offers for a specific event.""" + try: + job_offers = await get_job_offers_by_event(db, event_code) + if not job_offers: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No job offers found for this event", + ) + return job_offers + except Exception as e: + logger.error(f"Error listing job offers: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +@api_router.get("/list", response_model=list[JobOfferSummary]) +async def list_all_job_offers(db=Depends(get_db_connection)): + """List all job offers across all events.""" + try: + return await get_all_job_offers(db) + except Exception as e: + logger.error(f"Error listing all job offers: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +@api_router.get("/{job_offer_id}", response_model=JobOfferSummary) +async def get_job_offer(job_offer_id: str, db=Depends(get_db_connection)): + """Retrieve a job offer by its ID.""" + try: + return await get_job_offer_by_id(db, job_offer_id) + except Exception as e: + logger.error(f"Error retrieving job offer {job_offer_id}: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +@api_router.put("/update/{job_offer_id}", response_model=MessageResponse) +async def update_job_offer_details( + job_offer_id: str, + job_offer_update: JobOfferUpdate, + background_tasks: BackgroundTasks, + db=Depends(get_db_connection), +): + """Update an existing job offer.""" + try: + return await update_job_offer(db, job_offer_id, job_offer_update, background_tasks) + except Exception as e: + logger.error(f"Error updating job offer {job_offer_id}: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +@api_router.delete("/delete/{job_offer_id}", response_model=MessageResponse) +async def delete_job_offer_by_id( + job_offer_id: str, + background_tasks: BackgroundTasks, + db=Depends(get_db_connection), +): + """Delete a job offer by its ID.""" + try: + return await delete_job_offer(db, job_offer_id, background_tasks) + except Exception as e: + logger.error(f"Error deleting job offer {job_offer_id}: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/app/schemas/config.py b/app/schemas/config.py index 938a7f1..1669b84 100644 --- a/app/schemas/config.py +++ b/app/schemas/config.py @@ -25,14 +25,14 @@ class Config(BaseModel): smtp_port: int = 587 smtp_user: str = "user" smtp_password: str = "password" - paydunya_public_key: str - paydunya_private_key: str - paydunya_token: str - paydunya_master_key: str - imagekit_public_key: str - imagekit_private_key: str - imagekit_url_endpoint: str - student_pass_template_url: str - professional_pass_template_url: str - premium_pass_template_url: str - dinner_pass_template_url: str + paydunya_public_key: str | None = None + paydunya_private_key: str | None = None + paydunya_token: str | None = None + paydunya_master_key: str | None = None + imagekit_public_key: str | None = None + imagekit_private_key: str | None = None + imagekit_url_endpoint: str | None = None + student_pass_template_url: str | None = None + professional_pass_template_url: str | None = None + premium_pass_template_url: str | None = None + dinner_pass_template_url: str | None = None diff --git a/app/schemas/models.py b/app/schemas/models.py index 24fee79..6fd13c7 100644 --- a/app/schemas/models.py +++ b/app/schemas/models.py @@ -637,3 +637,59 @@ class TicketSubmissionPayload(BaseModel): class StudentProof(RegistrationCreate): file_url: str file_type: str + + +# JOB BOARD + +class JobLocation(str, Enum): + REMOTE = "remote" + ONSITE = "onsite" + HYBRID = "hybrid" + + +class ContractType(str, Enum): + FULL_TIME = "full-time" + PART_TIME = "part-time" + INTERNSHIP = "internship" + CONTRACT = "contract" + + +class JobOfferBase(BaseModel): + title: str + description: str + company: str + logo_url: str | None = None + location: JobLocation + contract_type: ContractType + apply_url: str + is_active: bool = True + salary_range: str | None = None + application_deadline: datetime | None = None + tags: List[str] | None = None + + +class JobOfferCreate(JobOfferBase): + pass + + +class JobOfferUpdate(BaseModel): + title: str | None = None + description: str | None = None + company: str | None = None + logo_url: str | None = None + location: JobLocation | None = None + contract_type: ContractType | None = None + apply_url: str | None = None + is_active: bool | None = None + salary_range: str | None = None + application_deadline: datetime | None = None + tags: List[str] | None = None + updated_at: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc)) + + +class JobOfferSummary(JobOfferBase): + id: UUID + event_id: UUID + created_at: datetime + updated_at: datetime diff --git a/app/utils/job_offers.py b/app/utils/job_offers.py new file mode 100644 index 0000000..5c7fb3a --- /dev/null +++ b/app/utils/job_offers.py @@ -0,0 +1,124 @@ +from uuid import uuid4 + +from fastapi import BackgroundTasks, HTTPException + +from app.database.orm import delete, insert, select, select_with_join, update +from app.schemas.models import JobOfferCreate, JobOfferUpdate +from app.core.settings import logger +from app.utils.helpers import remove_null_values + + +async def get_all_job_offers(db): + try: + job_offers = await select(db, "job_offers") + if not job_offers: + raise HTTPException(status_code=404, detail="No job offers found") + return job_offers + except Exception as e: + logger.error(f"Error retrieving job offers: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +async def get_job_offers_by_event(db, event_code: str): + try: + event_code = event_code.strip().upper() + job_offers = await select_with_join( + db, + table="job_offers", + join_table="events", + join_condition="job_offers.event_id = events.id", + columns=[ + "job_offers.id", "job_offers.title", "job_offers.description", + "job_offers.company", "job_offers.logo_url", "job_offers.location", + "job_offers.contract_type", "job_offers.apply_url", "job_offers.is_active", + "job_offers.salary_range", "job_offers.application_deadline", "job_offers.tags", + "job_offers.event_id", "job_offers.created_at", "job_offers.updated_at", + ], + filter={"events.code": event_code, "job_offers.is_active": True}, + ) + return job_offers or [] + except Exception as e: + logger.error(f"Error retrieving job offers for event {event_code}: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +async def get_job_offer_by_id(db, job_offer_id: str): + try: + job_offer = await select(db, "job_offers", filter={"id": job_offer_id}) + if not job_offer: + raise HTTPException(status_code=404, detail="Job offer not found") + return job_offer[0] + except Exception as e: + logger.error(f"Error retrieving job offer {job_offer_id}: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +async def add_job_offer(db, job_offer: JobOfferCreate, event_code: str, background_tasks: BackgroundTasks): + try: + event_code = event_code.strip().upper() + job_offer_data = job_offer.model_dump(mode="json") + + event_data = await select(db, "events", filter={"code": event_code}) + if not event_data: + raise HTTPException(status_code=404, detail="Event not found") + + event_id = event_data[0]["id"] + existing = await select(db, "job_offers", filter={ + "title": job_offer_data["title"], + "company": job_offer_data["company"], + "event_id": event_id, + }) + if existing: + raise HTTPException( + status_code=400, + detail="A job offer with the same title and company already exists for this event", + ) + + job_offer_data.update({"id": str(uuid4()), "event_id": event_id}) + background_tasks.add_task(insert, db, "job_offers", job_offer_data) + return {"message": "Job offer created successfully"} + except Exception as e: + logger.error(f"Error adding job offer: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +async def update_job_offer(db, job_offer_id: str, job_offer_update: JobOfferUpdate, background_tasks: BackgroundTasks): + try: + update_data = remove_null_values(job_offer_update.model_dump(mode="json")) + if not update_data: + raise HTTPException(status_code=400, detail="No valid fields provided for update") + + existing = await select(db, "job_offers", filter={"id": job_offer_id}) + if not existing: + raise HTTPException(status_code=404, detail="Job offer not found") + + background_tasks.add_task(update, db, "job_offers", update_data, filter={"id": job_offer_id}) + return {"message": "Job offer updated successfully"} + except Exception as e: + logger.error(f"Error updating job offer {job_offer_id}: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") + + +async def delete_job_offer(db, job_offer_id: str, background_tasks: BackgroundTasks): + try: + existing = await select(db, "job_offers", filter={"id": job_offer_id}) + if not existing: + raise HTTPException(status_code=404, detail="Job offer not found") + + background_tasks.add_task(delete, db, "job_offers", filter={"id": job_offer_id}) + return {"message": "Job offer deleted successfully"} + except Exception as e: + logger.error(f"Error deleting job offer {job_offer_id}: {str(e)}") + if isinstance(e, HTTPException): + raise e + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/docker-compose.yml b/docker-compose.yml index 43b184b..8be6cb6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: - path: ./app/.env required: false environment: - - DATABASE_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-password}@db:5432/${DB_NAME:-pythontogo_db} + - DB_URL=postgresql://${DB_USER:-postgres}:${DB_PASSWORD:-password}@db:5432/${DB_NAME:-pythontogo_db} - REDIS_URL=redis://redis:6379/0 depends_on: - db diff --git a/dockerfile b/dockerfile index aeadef7..2e74819 100644 --- a/dockerfile +++ b/dockerfile @@ -23,5 +23,5 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . -RUN chmod +x entrypoint.sh +RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh ENTRYPOINT ["./entrypoint.sh"] \ No newline at end of file From 813c761e93946aabe000c5ad8c79567f7e92df09 Mon Sep 17 00:00:00 2001 From: Steventog Date: Fri, 5 Jun 2026 07:58:22 +0000 Subject: [PATCH 2/2] feat: add country to job table --- app/database/migrations.py | 10 +++------- app/routers/job_offers.py | 30 +++++++++++------------------- app/schemas/models.py | 3 ++- app/utils/job_offers.py | 35 +++++++---------------------------- 4 files changed, 23 insertions(+), 55 deletions(-) diff --git a/app/database/migrations.py b/app/database/migrations.py index f5f3bce..ce91b2d 100644 --- a/app/database/migrations.py +++ b/app/database/migrations.py @@ -428,13 +428,13 @@ """ CREATE TABLE IF NOT EXISTS job_offers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - event_id UUID NOT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, company VARCHAR(255) NOT NULL, logo_url TEXT, location job_location_enum NOT NULL, contract_type contract_type_enum NOT NULL, + country VARCHAR(255), apply_url TEXT NOT NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, salary_range VARCHAR(255), @@ -442,11 +442,7 @@ tags JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT fk_job_offers_event - FOREIGN KEY (event_id) - REFERENCES events(id) - ON DELETE CASCADE, - CONSTRAINT uq_job_offers_event_company_title UNIQUE (event_id, company, title) + CONSTRAINT uq_job_offers_company_title UNIQUE (company, title) );""", ] @@ -455,8 +451,8 @@ CREATE_INDEX_QUERIES = [ "CREATE INDEX IF NOT EXISTS idx_sponsors_partners_event_id ON sponsors_partners(event_id);", "CREATE INDEX IF NOT EXISTS idx_api_keys_event_id ON api_keys(event_id);", - "CREATE INDEX IF NOT EXISTS idx_job_offers_event_id ON job_offers(event_id);", "CREATE INDEX IF NOT EXISTS idx_job_offers_is_active ON job_offers(is_active);", + "CREATE INDEX IF NOT EXISTS idx_job_offers_company ON job_offers(company);", ] diff --git a/app/routers/job_offers.py b/app/routers/job_offers.py index f60dd3c..325cae7 100644 --- a/app/routers/job_offers.py +++ b/app/routers/job_offers.py @@ -5,9 +5,9 @@ from app.utils.job_offers import ( add_job_offer, delete_job_offer, + get_active_job_offers, get_all_job_offers, get_job_offer_by_id, - get_job_offers_by_event, update_job_offer, ) from app.core.settings import logger @@ -16,39 +16,34 @@ api_router = APIRouter(prefix="/job-offers", tags=["job-offers"]) -@api_router.post( - "/create/{event_code}", - response_model=MessageResponse, - status_code=status.HTTP_201_CREATED, -) +@api_router.post("/create", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) async def create_job_offer( job_offer: JobOfferCreate, - event_code: str, background_tasks: BackgroundTasks, db=Depends(get_db_connection), ): - """Create a new job offer for an event.""" + """Create a new job offer.""" try: - return await add_job_offer(db, job_offer, event_code, background_tasks) + return await add_job_offer(db, job_offer, background_tasks) except Exception as e: if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="Internal server error") -@api_router.get("/list/{event_code}", response_model=list[JobOfferSummary]) -async def list_job_offers(event_code: str, db=Depends(get_db_connection)): - """List all active job offers for a specific event.""" +@api_router.get("/list/active", response_model=list[JobOfferSummary]) +async def list_active_job_offers(db=Depends(get_db_connection)): + """List all active job offers.""" try: - job_offers = await get_job_offers_by_event(db, event_code) + job_offers = await get_active_job_offers(db) if not job_offers: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No job offers found for this event", + detail="No active job offers found", ) return job_offers except Exception as e: - logger.error(f"Error listing job offers: {str(e)}") + logger.error(f"Error listing active job offers: {str(e)}") if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="Internal server error") @@ -56,7 +51,7 @@ async def list_job_offers(event_code: str, db=Depends(get_db_connection)): @api_router.get("/list", response_model=list[JobOfferSummary]) async def list_all_job_offers(db=Depends(get_db_connection)): - """List all job offers across all events.""" + """List all job offers (admin).""" try: return await get_all_job_offers(db) except Exception as e: @@ -72,7 +67,6 @@ async def get_job_offer(job_offer_id: str, db=Depends(get_db_connection)): try: return await get_job_offer_by_id(db, job_offer_id) except Exception as e: - logger.error(f"Error retrieving job offer {job_offer_id}: {str(e)}") if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="Internal server error") @@ -89,7 +83,6 @@ async def update_job_offer_details( try: return await update_job_offer(db, job_offer_id, job_offer_update, background_tasks) except Exception as e: - logger.error(f"Error updating job offer {job_offer_id}: {str(e)}") if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="Internal server error") @@ -105,7 +98,6 @@ async def delete_job_offer_by_id( try: return await delete_job_offer(db, job_offer_id, background_tasks) except Exception as e: - logger.error(f"Error deleting job offer {job_offer_id}: {str(e)}") if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="Internal server error") diff --git a/app/schemas/models.py b/app/schemas/models.py index 6fd13c7..91422e4 100644 --- a/app/schemas/models.py +++ b/app/schemas/models.py @@ -661,6 +661,7 @@ class JobOfferBase(BaseModel): logo_url: str | None = None location: JobLocation contract_type: ContractType + country: str | None = None apply_url: str is_active: bool = True salary_range: str | None = None @@ -679,6 +680,7 @@ class JobOfferUpdate(BaseModel): logo_url: str | None = None location: JobLocation | None = None contract_type: ContractType | None = None + country: str | None = None apply_url: str | None = None is_active: bool | None = None salary_range: str | None = None @@ -690,6 +692,5 @@ class JobOfferUpdate(BaseModel): class JobOfferSummary(JobOfferBase): id: UUID - event_id: UUID created_at: datetime updated_at: datetime diff --git a/app/utils/job_offers.py b/app/utils/job_offers.py index 5c7fb3a..48df485 100644 --- a/app/utils/job_offers.py +++ b/app/utils/job_offers.py @@ -2,7 +2,7 @@ from fastapi import BackgroundTasks, HTTPException -from app.database.orm import delete, insert, select, select_with_join, update +from app.database.orm import delete, insert, select, update from app.schemas.models import JobOfferCreate, JobOfferUpdate from app.core.settings import logger from app.utils.helpers import remove_null_values @@ -21,26 +21,12 @@ async def get_all_job_offers(db): raise HTTPException(status_code=500, detail="Internal server error") -async def get_job_offers_by_event(db, event_code: str): +async def get_active_job_offers(db): try: - event_code = event_code.strip().upper() - job_offers = await select_with_join( - db, - table="job_offers", - join_table="events", - join_condition="job_offers.event_id = events.id", - columns=[ - "job_offers.id", "job_offers.title", "job_offers.description", - "job_offers.company", "job_offers.logo_url", "job_offers.location", - "job_offers.contract_type", "job_offers.apply_url", "job_offers.is_active", - "job_offers.salary_range", "job_offers.application_deadline", "job_offers.tags", - "job_offers.event_id", "job_offers.created_at", "job_offers.updated_at", - ], - filter={"events.code": event_code, "job_offers.is_active": True}, - ) + job_offers = await select(db, "job_offers", filter={"is_active": True}) return job_offers or [] except Exception as e: - logger.error(f"Error retrieving job offers for event {event_code}: {str(e)}") + logger.error(f"Error retrieving active job offers: {str(e)}") if isinstance(e, HTTPException): raise e raise HTTPException(status_code=500, detail="Internal server error") @@ -59,28 +45,21 @@ async def get_job_offer_by_id(db, job_offer_id: str): raise HTTPException(status_code=500, detail="Internal server error") -async def add_job_offer(db, job_offer: JobOfferCreate, event_code: str, background_tasks: BackgroundTasks): +async def add_job_offer(db, job_offer: JobOfferCreate, background_tasks: BackgroundTasks): try: - event_code = event_code.strip().upper() job_offer_data = job_offer.model_dump(mode="json") - event_data = await select(db, "events", filter={"code": event_code}) - if not event_data: - raise HTTPException(status_code=404, detail="Event not found") - - event_id = event_data[0]["id"] existing = await select(db, "job_offers", filter={ "title": job_offer_data["title"], "company": job_offer_data["company"], - "event_id": event_id, }) if existing: raise HTTPException( status_code=400, - detail="A job offer with the same title and company already exists for this event", + detail="A job offer with the same title and company already exists", ) - job_offer_data.update({"id": str(uuid4()), "event_id": event_id}) + job_offer_data["id"] = str(uuid4()) background_tasks.add_task(insert, db, "job_offers", job_offer_data) return {"message": "Job offer created successfully"} except Exception as e: