Skip to content

Commit a374198

Browse files
authored
Merge pull request #4 from Steventog/main
feat: add job board endpoints, fix JSONB lists, harden startup config and add swagger doc only in dev
2 parents f89ba01 + fbf5ffd commit a374198

13 files changed

Lines changed: 389 additions & 42 deletions

File tree

.gitignore

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,65 @@
1-
.env
2-
secret*
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*.pyo
5+
*.pyd
6+
*.so
7+
*.egg
8+
*.egg-info/
9+
dist/
10+
build/
11+
12+
# Environnements virtuels
313
.venv/
414
venv/
5-
__pycache__/
6-
*.pyc
15+
env/
16+
ENV/
17+
18+
# Variables d'environnement — ne jamais commiter
19+
.env
20+
app/.env
21+
!*.env.example
22+
!app/.env.example
23+
24+
# Secrets
25+
secret*
26+
*.key
27+
*.pem
28+
29+
# Bases de données locales
30+
*.db
31+
*.sqlite3
32+
33+
# Logs
34+
*.log
35+
logs/
36+
37+
# IDEs
38+
.vscode/
39+
.idea/
40+
*.iml
41+
*.sublime-project
42+
*.sublime-workspace
43+
44+
# OS
45+
.DS_Store
46+
Thumbs.db
47+
desktop.ini
48+
49+
# Tests
50+
.coverage
51+
.pytest_cache/
52+
.mypy_cache/
53+
.tox/
54+
htmlcov/
55+
coverage.xml
756
mock*
857
fake*
958
test*
1059
tests/
11-
.vscode/
12-
.idea/
13-
.DS_Store
60+
61+
# Divers
62+
*.bak
63+
*.tmp
64+
*.swp
65+
*.swo

app/core/imagekit.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,27 @@
33
from imagekitio import ImageKit
44
from app.core.settings import settings
55

6+
_client: ImageKit | None = None
67

7-
imagekit = ImageKit(
8-
private_key=settings.imagekit_private_key
9-
)
108

11-
URL_ENDPOINT = settings.imagekit_url_endpoint
9+
def _get_client() -> ImageKit:
10+
global _client
11+
if _client is None:
12+
if not settings.imagekit_private_key:
13+
raise RuntimeError(
14+
"IMAGEKIT_PRIVATE_KEY is not set. Configure it to use ImageKit."
15+
)
16+
_client = ImageKit(private_key=settings.imagekit_private_key)
17+
return _client
1218

1319

14-
def upload_image_base64_url(image_name, base64_string, folder=""):
20+
def upload_image_base64_url(image_name: str, base64_string: str, folder: str = ""):
1521
try:
16-
upload_response = imagekit.files.upload(
22+
client = _get_client()
23+
upload_response = client.files.upload(
1724
file=base64.b64decode(base64_string),
1825
file_name=image_name,
1926
folder="/pythontogo/" + folder.lstrip("/"),
20-
2127
)
2228
return upload_response
2329
except Exception as e:

app/core/settings.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,17 +31,17 @@
3131
smtp_port=config("SMTP_PORT", default=587, cast=int),
3232
smtp_user=config("SMTP_USER", default="user"),
3333
smtp_password=config("SMTP_PASSWORD", default="password"),
34-
paydunya_public_key=config("PAYDUNYA_PUBLIC_KEY"),
35-
paydunya_private_key=config("PAYDUNYA_PRIVATE_KEY"),
36-
paydunya_token=config("PAYDUNYA_TOKEN"),
37-
paydunya_master_key=config("PAYDUNYA_MASTER_KEY"),
38-
imagekit_private_key=config("IMAGEKIT_PRIVATE_KEY"),
39-
imagekit_public_key=config("IMAGEKIT_PUBLIC_KEY"),
40-
imagekit_url_endpoint=config("IMAGEKIT_URL_ENDPOINT"),
41-
student_pass_template_url=config("STUDENT_PASS_TEMPLATE_URL"),
42-
professional_pass_template_url=config("PROFESSIONAL_PASS_TEMPLATE_URL"),
43-
premium_pass_template_url=config("PREMIUM_PASS_TEMPLATE_URL"),
44-
dinner_pass_template_url=config("DINNER_PASS_TEMPLATE_URL")
34+
paydunya_public_key=config("PAYDUNYA_PUBLIC_KEY", default=None),
35+
paydunya_private_key=config("PAYDUNYA_PRIVATE_KEY", default=None),
36+
paydunya_token=config("PAYDUNYA_TOKEN", default=None),
37+
paydunya_master_key=config("PAYDUNYA_MASTER_KEY", default=None),
38+
imagekit_private_key=config("IMAGEKIT_PRIVATE_KEY", default=None),
39+
imagekit_public_key=config("IMAGEKIT_PUBLIC_KEY", default=None),
40+
imagekit_url_endpoint=config("IMAGEKIT_URL_ENDPOINT", default=None),
41+
student_pass_template_url=config("STUDENT_PASS_TEMPLATE_URL", default=None),
42+
professional_pass_template_url=config("PROFESSIONAL_PASS_TEMPLATE_URL", default=None),
43+
premium_pass_template_url=config("PREMIUM_PASS_TEMPLATE_URL", default=None),
44+
dinner_pass_template_url=config("DINNER_PASS_TEMPLATE_URL", default=None)
4545
)
4646

4747

app/database/generate_sql_queries.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,17 @@ def normalize_value(value):
1212
1313
Returns:
1414
-------
15-
The normalized value, ready for use in SQL queries. For dictionaries, it returns a Jsonb object.
15+
The normalized value, ready for use in SQL queries. For dictionaries and lists, it returns a Jsonb object.
1616
"""
17-
if isinstance(value, dict):
17+
if isinstance(value, (dict, list)):
1818
return Jsonb(value)
1919
return value
2020

2121

2222
def normalize_data(data: dict):
2323
return {
2424
k: str(v) if not isinstance(
25-
v, (int, float, bool, dict, type(None))) else v
25+
v, (int, float, bool, dict, list, type(None))) else v
2626
for k, v in data.items()
2727
}
2828

app/database/migrations.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@
8686
'manual_correction'
8787
);
8888
END IF;
89+
90+
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'job_location_enum') THEN
91+
CREATE TYPE job_location_enum AS ENUM ('remote', 'onsite', 'hybrid');
92+
END IF;
93+
94+
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'contract_type_enum') THEN
95+
CREATE TYPE contract_type_enum AS ENUM ('full-time', 'part-time', 'internship', 'contract');
96+
END IF;
8997
END
9098
$$;
9199
"""
@@ -417,13 +425,33 @@
417425
ON DELETE CASCADE
418426
);""",
419427

428+
"""
429+
CREATE TABLE IF NOT EXISTS job_offers (
430+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
431+
title VARCHAR(255) NOT NULL,
432+
description TEXT NOT NULL,
433+
company VARCHAR(255) NOT NULL,
434+
logo_url TEXT,
435+
location job_location_enum NOT NULL,
436+
contract_type contract_type_enum NOT NULL,
437+
country VARCHAR(255),
438+
apply_url TEXT NOT NULL,
439+
is_active BOOLEAN NOT NULL DEFAULT TRUE,
440+
salary_range VARCHAR(255),
441+
application_deadline TIMESTAMPTZ,
442+
tags JSONB,
443+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
444+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
445+
);""",
420446

421447
]
422448

423449

424450
CREATE_INDEX_QUERIES = [
425451
"CREATE INDEX IF NOT EXISTS idx_sponsors_partners_event_id ON sponsors_partners(event_id);",
426452
"CREATE INDEX IF NOT EXISTS idx_api_keys_event_id ON api_keys(event_id);",
453+
"CREATE INDEX IF NOT EXISTS idx_job_offers_is_active ON job_offers(is_active);",
454+
"CREATE INDEX IF NOT EXISTS idx_job_offers_company ON job_offers(company);",
427455
]
428456

429457

app/main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,20 @@ async def lifespan(app: FastAPI):
4747
await app.state.redis_client.close()
4848

4949

50+
_is_dev = settings.env in ["dev", "local", "development"]
51+
5052
app = FastAPI(
5153
title=settings.app_name,
5254
version="2.1.0",
5355
license_info={
5456
"name": "Apache 2.0",
5557
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
5658
},
57-
lifespan=lifespan)
59+
lifespan=lifespan,
60+
openapi_url="/openapi.json" if _is_dev else None,
61+
docs_url="/docs" if _is_dev else None,
62+
redoc_url="/redoc" if _is_dev else None,
63+
)
5864

5965

6066
app.add_middleware(

app/routers/api.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from app.routers.tickets import api_router as tickets_router
1313
from app.routers.registrations import api_router as registrations_router
1414
from app.routers.helper import app_router as helper_router
15+
from app.routers.job_offers import api_router as job_offers_router
1516
from fastapi import APIRouter
1617
from app.core.security import verify_api_key
1718

@@ -31,4 +32,5 @@
3132
api_routers.include_router(checkout_router)
3233
api_routers.include_router(registrations_router)
3334
api_routers.include_router(tickets_router)
35+
api_routers.include_router(job_offers_router)
3436
api_routers.include_router(helper_router)

app/routers/job_offers.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
2+
3+
from app.database.connection import get_db_connection
4+
from app.schemas.models import JobOfferCreate, JobOfferSummary, JobOfferUpdate, MessageResponse
5+
from app.utils.job_offers import (
6+
add_job_offer,
7+
delete_job_offer,
8+
get_active_job_offers,
9+
get_all_job_offers,
10+
get_job_offer_by_id,
11+
update_job_offer,
12+
)
13+
from app.core.settings import logger
14+
15+
16+
api_router = APIRouter(prefix="/job-offers", tags=["job-offers"])
17+
18+
19+
@api_router.post("/create", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
20+
async def create_job_offer(
21+
job_offer: JobOfferCreate,
22+
background_tasks: BackgroundTasks,
23+
db=Depends(get_db_connection),
24+
):
25+
"""Create a new job offer."""
26+
try:
27+
return await add_job_offer(db, job_offer, background_tasks)
28+
except Exception as e:
29+
if isinstance(e, HTTPException):
30+
raise e
31+
raise HTTPException(status_code=500, detail="Internal server error")
32+
33+
34+
@api_router.get("/list/active", response_model=list[JobOfferSummary])
35+
async def list_active_job_offers(db=Depends(get_db_connection)):
36+
"""List all active job offers."""
37+
try:
38+
job_offers = await get_active_job_offers(db)
39+
if not job_offers:
40+
raise HTTPException(
41+
status_code=status.HTTP_404_NOT_FOUND,
42+
detail="No active job offers found",
43+
)
44+
return job_offers
45+
except Exception as e:
46+
logger.error(f"Error listing active job offers: {str(e)}")
47+
if isinstance(e, HTTPException):
48+
raise e
49+
raise HTTPException(status_code=500, detail="Internal server error")
50+
51+
52+
@api_router.get("/list", response_model=list[JobOfferSummary])
53+
async def list_all_job_offers(db=Depends(get_db_connection)):
54+
"""List all job offers (admin)."""
55+
try:
56+
return await get_all_job_offers(db)
57+
except Exception as e:
58+
logger.error(f"Error listing all job offers: {str(e)}")
59+
if isinstance(e, HTTPException):
60+
raise e
61+
raise HTTPException(status_code=500, detail="Internal server error")
62+
63+
64+
@api_router.get("/{job_offer_id}", response_model=JobOfferSummary)
65+
async def get_job_offer(job_offer_id: str, db=Depends(get_db_connection)):
66+
"""Retrieve a job offer by its ID."""
67+
try:
68+
return await get_job_offer_by_id(db, job_offer_id)
69+
except Exception as e:
70+
if isinstance(e, HTTPException):
71+
raise e
72+
raise HTTPException(status_code=500, detail="Internal server error")
73+
74+
75+
@api_router.put("/update/{job_offer_id}", response_model=MessageResponse)
76+
async def update_job_offer_details(
77+
job_offer_id: str,
78+
job_offer_update: JobOfferUpdate,
79+
background_tasks: BackgroundTasks,
80+
db=Depends(get_db_connection),
81+
):
82+
"""Update an existing job offer."""
83+
try:
84+
return await update_job_offer(db, job_offer_id, job_offer_update, background_tasks)
85+
except Exception as e:
86+
if isinstance(e, HTTPException):
87+
raise e
88+
raise HTTPException(status_code=500, detail="Internal server error")
89+
90+
91+
@api_router.delete("/delete/{job_offer_id}", response_model=MessageResponse)
92+
async def delete_job_offer_by_id(
93+
job_offer_id: str,
94+
background_tasks: BackgroundTasks,
95+
db=Depends(get_db_connection),
96+
):
97+
"""Delete a job offer by its ID."""
98+
try:
99+
return await delete_job_offer(db, job_offer_id, background_tasks)
100+
except Exception as e:
101+
if isinstance(e, HTTPException):
102+
raise e
103+
raise HTTPException(status_code=500, detail="Internal server error")

app/schemas/config.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,14 @@ class Config(BaseModel):
2525
smtp_port: int = 587
2626
smtp_user: str = "user"
2727
smtp_password: str = "password"
28-
paydunya_public_key: str
29-
paydunya_private_key: str
30-
paydunya_token: str
31-
paydunya_master_key: str
32-
imagekit_public_key: str
33-
imagekit_private_key: str
34-
imagekit_url_endpoint: str
35-
student_pass_template_url: str
36-
professional_pass_template_url: str
37-
premium_pass_template_url: str
38-
dinner_pass_template_url: str
28+
paydunya_public_key: str | None = None
29+
paydunya_private_key: str | None = None
30+
paydunya_token: str | None = None
31+
paydunya_master_key: str | None = None
32+
imagekit_public_key: str | None = None
33+
imagekit_private_key: str | None = None
34+
imagekit_url_endpoint: str | None = None
35+
student_pass_template_url: str | None = None
36+
professional_pass_template_url: str | None = None
37+
premium_pass_template_url: str | None = None
38+
dinner_pass_template_url: str | None = None

0 commit comments

Comments
 (0)