diff --git a/.githooks/check-commit-msg.sh b/.githooks/check-commit-msg.sh index eacf2a8..497a32f 100755 --- a/.githooks/check-commit-msg.sh +++ b/.githooks/check-commit-msg.sh @@ -30,7 +30,7 @@ HEADER=$(head -n 1 "$COMMIT_MSG_FILE") # Check for Merge commits (covers 'git merge' and PR merges from GitHub/GitLab) # Examples: "Merge branch 'main' into ...", "Merge pull request #123 from ..." -MERGE_PATTERN="^Merge (branch|pull request|tag) .*" +MERGE_PATTERN="^Merge (remote-tracking )?(branch|pull request|tag) .*" if [[ "$HEADER" =~ $MERGE_PATTERN ]]; then echo -e "${GREEN}Merge commit detected by message content. Skipping validation.${NC}" exit 0 diff --git a/src/control_backend/api/v1/endpoints/program.py b/src/control_backend/api/v1/endpoints/program.py new file mode 100644 index 0000000..fe0fbac --- /dev/null +++ b/src/control_backend/api/v1/endpoints/program.py @@ -0,0 +1,52 @@ +import json +import logging + +from fastapi import APIRouter, HTTPException, Request + +from control_backend.schemas.message import Message +from control_backend.schemas.program import Phase + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/program", status_code=202) +async def receive_message(program: Message, request: Request): + """ + Receives a BehaviorProgram as a stringified JSON list inside `message`. + Converts it into real Phase objects. + """ + logger.info("Received raw program: ") + logger.debug("%s", program) + raw_str = program.message # This is the JSON string + + # Convert Json into dict. + try: + program_list = json.loads(raw_str) + except json.JSONDecodeError as e: + logger.error("Failed to decode program JSON: %s", e) + raise HTTPException(status_code=400, detail="Undecodeable Json string") from None + + # Validate Phases + try: + phases: list[Phase] = [Phase(**phase) for phase in program_list] + except Exception as e: + logger.error("❌ Failed to convert to Phase objects: %s", e) + raise HTTPException(status_code=400, detail="Non-Phase String") from None + + logger.info(f"Succesfully recieved {len(phases)} Phase(s).") + for p in phases: + logger.info( + f"Phase {p.id}: " + f"{len(p.phaseData.norms)} norms, " + f"{len(p.phaseData.goals)} goals, " + f"{len(p.phaseData.triggers) if hasattr(p.phaseData, 'triggers') else 0} triggers" + ) + + # send away + topic = b"program" + body = json.dumps([p.model_dump() for p in phases]).encode("utf-8") + pub_socket = request.app.state.endpoints_pub_socket + await pub_socket.send_multipart([topic, body]) + + return {"status": "Program parsed", "phase_count": len(phases)} diff --git a/src/control_backend/api/v1/router.py b/src/control_backend/api/v1/router.py index f11dc9c..809b412 100644 --- a/src/control_backend/api/v1/router.py +++ b/src/control_backend/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi.routing import APIRouter -from control_backend.api.v1.endpoints import command, logs, message, sse +from control_backend.api.v1.endpoints import command, logs, message, program, sse api_router = APIRouter() @@ -11,3 +11,5 @@ api_router.include_router(sse.router, tags=["SSE"]) api_router.include_router(command.router, tags=["Commands"]) api_router.include_router(logs.router, tags=["Logs"]) + +api_router.include_router(program.router, tags=["Program"]) diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py new file mode 100644 index 0000000..c207757 --- /dev/null +++ b/src/control_backend/schemas/program.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel + + +class Norm(BaseModel): + id: str + name: str + value: str + + +class Goal(BaseModel): + id: str + name: str + description: str + achieved: bool + + +class Trigger(BaseModel): + id: str + label: str + type: str + value: list[str] + + +class PhaseData(BaseModel): + norms: list[Norm] + goals: list[Goal] + triggers: list[Trigger] + + +class Phase(BaseModel): + id: str + name: str + nextPhaseId: str + phaseData: PhaseData + + +class Program(BaseModel): + phases: list[Phase]