Skip to content

Commit 88d258e

Browse files
✨(ingestion) load offer for vacancy webhooks (#592)
* ✨(ingestion) load offer for vacancy webhooks * ♻️(ingestion) refactor code and handle PR comments * ✅(tooling) refactor ingestion GitHub Actions workflow * 🔧(tooling) trigger CodeQL scan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3c610c3 commit 88d258e

34 files changed

Lines changed: 1758 additions & 23 deletions

.github/workflows/ingestion.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
name: Ingestion
2+
3+
on:
4+
push:
5+
branches: ["main"]
6+
pull_request:
7+
branches: ["main"]
8+
types: [opened, synchronize, reopened, edited]
9+
paths:
10+
- "src/ingestion/**"
11+
- ".github/workflows/ingestion.yml"
12+
- ".github/actions/**"
13+
14+
permissions:
15+
contents: read
16+
17+
jobs:
18+
build-ingestion:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v5
22+
- uses: ./.github/actions/setup-uv-python
23+
with:
24+
service-path: src/ingestion
25+
26+
lint-ingestion:
27+
needs: build-ingestion
28+
runs-on: ubuntu-latest
29+
defaults:
30+
run:
31+
working-directory: ./src/ingestion
32+
steps:
33+
- uses: actions/checkout@v5
34+
- uses: ./.github/actions/setup-uv-python
35+
with:
36+
service-path: src/ingestion
37+
- name: Lint with Ruff
38+
run: uv run ruff check .
39+
- name: Lint format with Ruff
40+
run: uv run ruff format --check .
41+
- name: Lint with MyPy
42+
run: uv run mypy .
43+
44+
test-ingestion:
45+
needs: build-ingestion
46+
runs-on: ubuntu-latest
47+
defaults:
48+
run:
49+
working-directory: ./src/ingestion
50+
steps:
51+
- uses: actions/checkout@v5
52+
- uses: ./.github/actions/setup-uv-python
53+
with:
54+
service-path: src/ingestion
55+
- name: Test with pytest
56+
run: uv run pytest --cov=. --cov-report=term-missing --cov-fail-under=95

Makefile

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,10 +404,23 @@ test-cov-ocr: ## run ocr tests with detailed HTML coverage report
404404
@open src/ocr/tests/cov_html/index.html
405405
.PHONY: test-cov-ocr
406406

407+
test-cov-ingestion: ## run ingestion tests with detailed HTML coverage report
408+
@echo 'test:cov-ingestion started…'
409+
@echo 'Generating detailed HTML coverage report for ingestion…'
410+
@if [ ! -f "src/ingestion/tests/cov_html/index.html" ]; then \
411+
echo '⚠️ Coverage report not found. Creating directory structure...'; \
412+
mkdir -p src/ingestion/tests/cov_html; \
413+
fi
414+
$(INGESTION_UV) pytest --cov=. --cov-report=html:tests/cov_html --cov-report=term-missing $(ARGS)
415+
@echo '✅ Coverage report generated in src/ingestion/tests/cov_html/'
416+
@open src/ingestion/tests/cov_html/index.html
417+
.PHONY: test-cov-ingestion
418+
407419
test-cov: ## run tests with detailed HTML coverage report for all services
408420
test-cov: \
409421
test-cov-web \
410-
test-cov-ocr
422+
test-cov-ocr \
423+
test-cov-ingestion
411424
.PHONY: test-cov
412425

413426
## MANAGE docker services

env.d/ingestion-example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
TALENTSOFT_CLIENT_ID=
22
TALENTSOFT_CLIENT_SECRET=
3+
TALENTSOFT_BASE_URL=
34

45
WEB_BASE_URL=
56
WEB_API_KEY=

src/ingestion/api/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class Settings(BaseSettings):
1313

1414
talentsoft_client_id: str | None = None
1515
talentsoft_client_secret: str | None = None
16+
talentsoft_base_url: str | None = None
1617

1718
web_base_url: str | None = None
1819
web_api_key: str | None = None
@@ -27,6 +28,10 @@ class TestSettings(Settings):
2728
sentry_profiles_sample_rate: float | None = 0.0
2829
sentry_traces_sample_rate: float | None = 0.0
2930

31+
talentsoft_client_id: str | None = None
32+
talentsoft_client_secret: str | None = None
33+
talentsoft_base_url: str | None = None
34+
3035
web_base_url: str | None = None
3136
web_api_key: str | None = None
3237

src/ingestion/api/dependencies.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import logging
12
from collections.abc import AsyncGenerator
23

34
import httpx
4-
from fastapi import Depends, HTTPException
5+
from fastapi import Depends
56

67
from api.config import Settings, get_settings
78
from application.use_cases.archive_offer import ArchiveOfferUseCase
9+
from application.use_cases.load_offer_details import LoadOfferDetailsUseCase
10+
from infrastructure.external_gateways.talentsoft_client import (
11+
TalentsoftConfig,
12+
TalentsoftFrontClient,
13+
)
14+
15+
logger = logging.getLogger(__name__)
816

917

1018
async def get_http_client() -> AsyncGenerator[httpx.AsyncClient, None]:
@@ -15,11 +23,30 @@ async def get_http_client() -> AsyncGenerator[httpx.AsyncClient, None]:
1523
def get_archive_offer_use_case(
1624
settings: Settings = Depends(get_settings),
1725
client: httpx.AsyncClient = Depends(get_http_client),
18-
) -> ArchiveOfferUseCase:
26+
) -> ArchiveOfferUseCase | None:
1927
if not settings.web_base_url or not settings.web_api_key:
20-
raise HTTPException(status_code=500, detail="Web service not configured")
28+
return None
2129
return ArchiveOfferUseCase(
2230
client=client,
2331
web_base_url=settings.web_base_url,
2432
web_api_key=settings.web_api_key,
2533
)
34+
35+
36+
async def get_load_offer_details_use_case(
37+
settings: Settings = Depends(get_settings),
38+
) -> AsyncGenerator[LoadOfferDetailsUseCase | None, None]:
39+
if (
40+
not settings.talentsoft_client_id
41+
or not settings.talentsoft_client_secret
42+
or not settings.talentsoft_base_url
43+
):
44+
yield None
45+
else:
46+
config = TalentsoftConfig(
47+
base_url=settings.talentsoft_base_url,
48+
client_id=settings.talentsoft_client_id,
49+
client_secret=settings.talentsoft_client_secret,
50+
)
51+
async with TalentsoftFrontClient(config=config, logger=logger) as client:
52+
yield LoadOfferDetailsUseCase(talentsoft_client=client)

src/ingestion/api/routes.py

Lines changed: 62 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
import logging
22

3-
from fastapi import APIRouter, Depends, Query, Request
3+
from fastapi import APIRouter, Depends, HTTPException, Query, Request
44
from pydantic import ValidationError
55

6-
from api.dependencies import get_archive_offer_use_case
6+
from api.dependencies import get_archive_offer_use_case, get_load_offer_details_use_case
77
from api.talentsoft import verify_talentsoft_signature
88
from application.use_cases.archive_offer import ArchiveOfferUseCase
9+
from application.use_cases.load_offer_details import LoadOfferDetailsUseCase
910
from presentation.dtos.talentsoft_webhook import (
1011
TalentsoftWebhookPayload,
1112
should_archive,
13+
should_load_offer_details,
1214
)
1315

1416
logger = logging.getLogger(__name__)
1517

1618
public_router = APIRouter()
1719

20+
_OK = {"status": "ok"}
21+
1822

1923
@public_router.get("/health")
2024
def health():
@@ -27,35 +31,61 @@ def health():
2731
async def talentsoft_webhook(
2832
request: Request,
2933
client_id: str = Query(...),
30-
use_case: ArchiveOfferUseCase = Depends(get_archive_offer_use_case),
34+
archive_use_case: ArchiveOfferUseCase | None = Depends(get_archive_offer_use_case),
35+
load_offer_use_case: LoadOfferDetailsUseCase | None = Depends(
36+
get_load_offer_details_use_case
37+
),
3138
):
3239
body = await request.body()
3340
logger.debug(
3441
"Received TalentSoft webhook body",
3542
extra={"body": body.decode(), "client_id": client_id},
3643
)
3744
if not body:
38-
return {"status": "ok"}
45+
return _OK
46+
47+
payload = _parse_payload(body, client_id)
48+
if payload is None:
49+
return _OK
50+
51+
if should_archive(payload):
52+
if archive_use_case is None:
53+
raise HTTPException(status_code=500, detail="Web service not configured")
54+
return await _handle_archive(payload, client_id, archive_use_case)
55+
56+
if should_load_offer_details(payload):
57+
if load_offer_use_case is None:
58+
raise HTTPException(
59+
status_code=500, detail="Talentsoft client not configured"
60+
)
61+
return await _handle_load_offer_details(payload, client_id, load_offer_use_case)
62+
63+
logger.info(
64+
"Unhandled event type %s for reference %s and status_id %s",
65+
payload.event_type,
66+
payload.reference,
67+
payload.status_id,
68+
extra={"client_id": client_id},
69+
)
70+
return _OK
71+
3972

73+
def _parse_payload(body: bytes, client_id: str) -> TalentsoftWebhookPayload | None:
4074
try:
41-
payload = TalentsoftWebhookPayload.model_validate_json(body)
75+
return TalentsoftWebhookPayload.model_validate_json(body)
4276
except ValidationError:
4377
logger.warning(
4478
"Unrecognised TalentSoft webhook payload, ignoring",
4579
extra={"body": body.decode(), "client_id": client_id},
4680
)
47-
return {"status": "ok"}
48-
49-
if not should_archive(payload):
50-
logger.info(
51-
"Unhandled event type %s for reference %s and status_id %s",
52-
payload.event_type,
53-
payload.reference,
54-
payload.status_id,
55-
extra={"client_id": client_id},
56-
)
57-
return {"status": "ok"}
81+
return None
82+
5883

84+
async def _handle_archive(
85+
payload: TalentsoftWebhookPayload,
86+
client_id: str,
87+
use_case: ArchiveOfferUseCase,
88+
) -> dict:
5989
# `source_id` will not be `client_id` soon
6090
# https://github.com/betagouv/csplab/issues/573 is required
6191
await use_case.execute(reference=payload.reference, source_id=client_id)
@@ -65,4 +95,19 @@ async def talentsoft_webhook(
6595
payload.reference,
6696
extra={"client_id": client_id},
6797
)
68-
return {"status": "ok"}
98+
return _OK
99+
100+
101+
async def _handle_load_offer_details(
102+
payload: TalentsoftWebhookPayload,
103+
client_id: str,
104+
use_case: LoadOfferDetailsUseCase,
105+
) -> dict:
106+
await use_case.execute(reference=payload.reference)
107+
logger.info(
108+
"Handled event type %s for reference %s",
109+
payload.event_type,
110+
payload.reference,
111+
extra={"client_id": client_id},
112+
)
113+
return _OK

src/ingestion/application/interfaces/__init__.py

Whitespace-only changes.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from typing import Protocol
2+
3+
from infrastructure.external_gateways.dtos.talentsoft_dtos import TalentsoftDetailOffer
4+
5+
6+
class ITalentsoftFrontClient(Protocol):
7+
async def get_detail(self, reference: str) -> TalentsoftDetailOffer: ...
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from application.interfaces.talentsoft_client_interface import ITalentsoftFrontClient
2+
from infrastructure.external_gateways.dtos.talentsoft_dtos import TalentsoftDetailOffer
3+
4+
5+
class LoadOfferDetailsUseCase:
6+
def __init__(self, talentsoft_client: ITalentsoftFrontClient) -> None:
7+
self._talentsoft_client = talentsoft_client
8+
9+
async def execute(self, reference: str) -> TalentsoftDetailOffer:
10+
return await self._talentsoft_client.get_detail(reference)

src/ingestion/infrastructure/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)