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..ce91b2d 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,25 @@ ON DELETE CASCADE );""", + """ + CREATE TABLE IF NOT EXISTS job_offers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + 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), + application_deadline TIMESTAMPTZ, + tags JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_job_offers_company_title UNIQUE (company, title) + );""", ] @@ -424,6 +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_is_active ON job_offers(is_active);", + "CREATE INDEX IF NOT EXISTS idx_job_offers_company ON job_offers(company);", ] 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..325cae7 --- /dev/null +++ b/app/routers/job_offers.py @@ -0,0 +1,103 @@ +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_active_job_offers, + get_all_job_offers, + get_job_offer_by_id, + update_job_offer, +) +from app.core.settings import logger + + +api_router = APIRouter(prefix="/job-offers", tags=["job-offers"]) + + +@api_router.post("/create", response_model=MessageResponse, status_code=status.HTTP_201_CREATED) +async def create_job_offer( + job_offer: JobOfferCreate, + background_tasks: BackgroundTasks, + db=Depends(get_db_connection), +): + """Create a new job offer.""" + try: + 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/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_active_job_offers(db) + if not job_offers: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="No active job offers found", + ) + return job_offers + except Exception as 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") + + +@api_router.get("/list", response_model=list[JobOfferSummary]) +async def list_all_job_offers(db=Depends(get_db_connection)): + """List all job offers (admin).""" + 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: + 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: + 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: + 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..91422e4 100644 --- a/app/schemas/models.py +++ b/app/schemas/models.py @@ -637,3 +637,60 @@ 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 + country: str | None = None + 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 + country: str | 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 + 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..48df485 --- /dev/null +++ b/app/utils/job_offers.py @@ -0,0 +1,103 @@ +from uuid import uuid4 + +from fastapi import BackgroundTasks, HTTPException + +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 + + +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_active_job_offers(db): + try: + job_offers = await select(db, "job_offers", filter={"is_active": True}) + return job_offers or [] + except Exception as 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") + + +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, background_tasks: BackgroundTasks): + try: + job_offer_data = job_offer.model_dump(mode="json") + + existing = await select(db, "job_offers", filter={ + "title": job_offer_data["title"], + "company": job_offer_data["company"], + }) + if existing: + raise HTTPException( + status_code=400, + detail="A job offer with the same title and company already exists", + ) + + 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: + 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