diff --git a/src/Components/API/app/database.py b/src/Components/API/app/database.py index 1d265fea2..6093ef155 100644 --- a/src/Components/API/app/database.py +++ b/src/Components/API/app/database.py @@ -87,4 +87,13 @@ Detections.create_index( [("microphoneLLA.0", pymongo.ASCENDING), ("microphoneLLA.1", pymongo.ASCENDING)], name="idx_microphone_lat_lon" -) \ No newline at end of file +) + +AdminBudgets = db.admin_budgets +ServiceStates = db.service_states + +Projects = db["projects"] +Projects.create_index("name") +Projects.create_index("status") +Projects.create_index("location") +Projects.create_index("ecologists") \ No newline at end of file diff --git a/src/Components/API/app/main.py b/src/Components/API/app/main.py index c50b9420a..402d1f768 100644 --- a/src/Components/API/app/main.py +++ b/src/Components/API/app/main.py @@ -1,11 +1,12 @@ - import os -import time -import logging -import threading -import json -from fastapi import FastAPI, Body, HTTPException, status, APIRouter, Request +# from Components.API.app.routers import add_csv_output_option, audio_upload_router +from .routers import add_csv_output_option, audio_upload_router + +from fastapi import FastAPI, Body, HTTPException, status, APIRouter from fastapi.middleware.cors import CORSMiddleware +from app.routers import species_predictor +from app.routers import auth_router +from app.routers import admin_budget, admin_services from fastapi.responses import Response, JSONResponse from fastapi.encoders import jsonable_encoder from pydantic import BaseModel, Field, EmailStr @@ -14,12 +15,27 @@ from app.routers import insights import datetime import pymongo +import json + +from app.routers import hmi, engine, sim, two_factor +from app.routers import public +app = FastAPI() +# Add the CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8080"], # 可根据实际需求配置 +) # Routers from .routers import add_csv_output_option, audio_upload_router from app.routers import species_predictor, auth_router, hmi, engine, sim, two_factor, public, iot, live, sensors #Websocket -# --- FastAPI App Setup --- +from app.routers import projects +app.include_router(projects.router) + +from app.routers import hmi, engine, sim, iot + +# ✅ Add metadata here app = FastAPI( title="Project Echo API", description=""" @@ -33,33 +49,58 @@ version="1.0.0" ) +# ✅ CORS Middleware app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=["*"], # Allow all origins + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# --- Routers --- + +# app.include_router(hmi.router, tags=['hmi'], prefix='/hmi') +# app.include_router(engine.router, tags=['engine'], prefix='/engine') +# app.include_router(sim.router, tags=['sim'], prefix='/sim') +# app.include_router(add_csv_output_option.router, tags=['csv'], prefix='/api') app.include_router(audio_upload_router.router, tags=['audio'], prefix='/api') + + +# ✅ Include routers app.include_router(hmi.router, tags=['hmi'], prefix='/hmi') app.include_router(engine.router, tags=['engine'], prefix='/engine') app.include_router(sim.router, tags=['sim'], prefix='/sim') app.include_router(two_factor.router) +app.include_router(admin_budget.router, tags=["admin"], prefix="/api") +app.include_router(admin_services.router, tags=["admin"], prefix="/api") + app.include_router(public.router, tags=['public'], prefix='/public') + +'''try: + file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'echo_config.json') + with open(file_path, 'r') as f: + echo_config = json.load(f) + print(f"Echo API echo_config successfully loaded", flush=True) +except: + print(f"Could not API echo_config : {file_path}") +print(f" database names: {client.list_database_names()}") +''' + app.include_router(iot.router, tags=['iot'], prefix='/iot') app.include_router(sensors.router, tags=['sensors'], prefix='/sensors') app.include_router(species_predictor.router, tags=["predict"]) -app.include_router(auth_router.router, tags=["auth"], prefix="/api") -app.include_router(live.router) #Websocket + + +# ✅ Root endpoint app.include_router(insights.router, tags=["insights"]) # --- Root Endpoint --- @app.get("/", response_description="API Root") def show_home(): return 'Welcome to echo api, move to /docs for more' + return 'Welcome to Project Echo API. Visit /docs for interactive documentation.' app.include_router(auth_router.router, tags=["auth"], prefix="/api") from app.routers import detections @@ -68,10 +109,18 @@ def show_home(): # ✅ /openapi-export - fetch live OpenAPI spec @app.get("/openapi-export", include_in_schema=False) async def get_openapi_spec(): + """ + Returns the current OpenAPI spec generated by FastAPI. + Used for downloading and converting to YAML. + """ return app.openapi() +# ✅ /spec/summary - for OpenAPI spec verification/debug @app.get("/spec/summary", tags=["debug"], include_in_schema=False) async def get_spec_summary(): + """ + Returns a summary of the OpenAPI spec for deployment verification. + """ spec = app.openapi() return { "title": spec.get("info", {}).get("title"), @@ -80,50 +129,19 @@ async def get_spec_summary(): "tags": [tag.get("name") for tag in spec.get("tags", []) if "name" in tag] } +# ✅ Save OpenAPI spec to file when app starts def export_openapi_to_file(): + """ + Saves the OpenAPI spec to a file on startup. + Creates the 'backend' folder if it doesn't exist. + """ output_dir = "backend" - os.makedirs(output_dir, exist_ok=True) + os.makedirs(output_dir, exist_ok=True) # creates the folder if it doesn't exist output_path = os.path.join(output_dir, "project-echo-openapi.json") + with open(output_path, "w") as f: json.dump(app.openapi(), f, indent=2) + print(f"✅ OpenAPI spec exported to {output_path}") export_openapi_to_file() - -# --- 24/7 Engine Background Task --- - -# Load engine interval from config -try: - with open(os.path.join(os.path.dirname(__file__), 'echo_config.json')) as f: - config = json.load(f) - ENGINE_INTERVAL = config.get('engine_interval_seconds', 5) -except Exception as e: - ENGINE_INTERVAL = 5 - logging.warning(f"Could not load engine interval from config: {e}") - -def continuous_engine_task(): - logging.basicConfig( - level=logging.INFO, - format='%(asctime)s %(levelname)s %(message)s', - handlers=[logging.StreamHandler()] - ) - while True: - try: - # Replace with your actual processing logic - logging.info("\n [ Place holder for future engine tasks~ ]") - # Example: engine.process_new_data() - time.sleep(ENGINE_INTERVAL) - except Exception as e: - logging.error(f"Engine error: {e}") - time.sleep(ENGINE_INTERVAL) - -def start_background_engine(): - thread = threading.Thread(target=continuous_engine_task, daemon=True) - thread.start() - logging.info("Continuous engine background task started.") - - -start_background_engine() - - - diff --git a/src/Components/API/app/middleware/pause_guard.py b/src/Components/API/app/middleware/pause_guard.py new file mode 100644 index 000000000..c841c13b8 --- /dev/null +++ b/src/Components/API/app/middleware/pause_guard.py @@ -0,0 +1,17 @@ +from fastapi import HTTPException, status +from app.services.service_state import get_service_state + + +def pause_guard(service_name: str): + """ + FastAPI dependency. If service is paused -> 503. + """ + def _guard(): + if get_service_state(service_name): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"Service '{service_name}' is temporarily paused by admin.", + ) + return True + + return _guard \ No newline at end of file diff --git a/src/Components/API/app/routers/admin_budget.py b/src/Components/API/app/routers/admin_budget.py new file mode 100644 index 000000000..4dff954f6 --- /dev/null +++ b/src/Components/API/app/routers/admin_budget.py @@ -0,0 +1,56 @@ +from typing import List, Optional, Dict, Any +from fastapi import APIRouter, Body, Depends, HTTPException + +from app.middleware.pause_guard import pause_guard +from app.services.budget import ( + set_budget_limits, + get_budget_limits, + get_usage, + reset_usage, +) + +router = APIRouter( + prefix="/admin/budget", + tags=["admin-budget"], +) + + +@router.get( + "/limits", + summary="Get current budget limits config", + dependencies=[Depends(pause_guard("admin_budget"))], +) +def get_limits_endpoint(): + return get_budget_limits() + + +@router.post( + "/limits", + summary="Set budget limits config (rules)", + dependencies=[Depends(pause_guard("admin_budget"))], +) +def set_limits_endpoint( + rules: List[Dict[str, Any]] = Body(..., description="List of budget rules"), +): + if not isinstance(rules, list) or len(rules) == 0: + raise HTTPException(status_code=400, detail="rules must be a non-empty list") + return set_budget_limits(rules) + + +@router.get( + "/usage", + summary="Get current usage for a service (optionally month)", + dependencies=[Depends(pause_guard("admin_budget"))], +) +def get_usage_endpoint(service: str, month_key: Optional[str] = None): + return get_usage(service=service, month_key=month_key) + + +@router.post( + "/usage/reset", + summary="Reset usage counter for a service (optionally month)", + dependencies=[Depends(pause_guard("admin_budget"))], +) +def reset_usage_endpoint(service: str, month_key: Optional[str] = None): + return reset_usage(service=service, month_key=month_key) + diff --git a/src/Components/API/app/routers/admin_services.py b/src/Components/API/app/routers/admin_services.py new file mode 100644 index 000000000..01c639b8c --- /dev/null +++ b/src/Components/API/app/routers/admin_services.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter +from app.schemas import ServicePauseIn, ServicePauseOut +from app.services.service_state import get_service_state, set_service_state +from datetime import datetime + +router = APIRouter() + +@router.get("/admin/services/{service}/status", response_model=ServicePauseOut) +def get_pause_status(service: str): + paused = get_service_state(service) + # if no doc exists, we still return a valid status + return ServicePauseOut(service=service, paused=paused, updated_at=datetime.utcnow()) + +@router.post("/admin/services/pause", response_model=ServicePauseOut) +def pause_or_resume_service(payload: ServicePauseIn): + result = set_service_state(payload.service, payload.paused) + return ServicePauseOut(**result) + +@router.get("/admin/services", response_model=list[ServicePauseOut]) +def list_services_status(): + # list everything known in DB + docs = list(__import__("app.database").database.ServiceStates.find({}, {"_id": 0})) + out = [] + for d in docs: + out.append(ServicePauseOut( + service=d.get("service"), + paused=bool(d.get("paused", False)), + updated_at=d.get("updated_at") or datetime.utcnow(), + )) + return out diff --git a/src/Components/API/app/routers/detections.py b/src/Components/API/app/routers/detections.py index 6ebdaef25..0156e4caf 100644 --- a/src/Components/API/app/routers/detections.py +++ b/src/Components/API/app/routers/detections.py @@ -1,23 +1,26 @@ from datetime import datetime from typing import Optional, Dict, Any -from fastapi import APIRouter, Query, Path, Body, HTTPException +from fastapi import APIRouter, Query, Path, Body, HTTPException, Depends from app.schemas import Detection, DetectionCreate, DetectionListResponses from app import detections as detections_service +from app.middleware.pause_guard import pause_guard +from app.services.budget import enforce_and_consume router = APIRouter( prefix="/detections", tags=["detections"], ) - @router.post( "", response_model=Detection, summary="Create a new detection", + dependencies=[Depends(pause_guard("detections"))], ) def create_detection_endpoint(payload: DetectionCreate = Body(...)): + enforce_and_consume("detections", cost=1) return detections_service.create_detection(payload) @@ -25,43 +28,24 @@ def create_detection_endpoint(payload: DetectionCreate = Body(...)): "", response_model=DetectionListResponses, summary="List detections with pagination and filtering", + dependencies=[Depends(pause_guard("detections"))], ) def list_detections_endpoint( - species: Optional[str] = Query( - None, description="Filter by species name (exact match)" - ), + species: Optional[str] = Query(None, description="Filter by species name (exact match)"), start_time: Optional[datetime] = Query( - None, - description="Filter detections from this timestamp (inclusive, ISO 8601)", + None, description="Filter detections from this timestamp (inclusive, ISO 8601)" ), end_time: Optional[datetime] = Query( - None, - description="Filter detections up to this timestamp (inclusive, ISO 8601)", - ), - lat: Optional[float] = Query( - None, - description="Latitude for location filter (requires lon & radius_km)", - ), - lon: Optional[float] = Query( - None, - description="Longitude for location filter (requires lat & radius_km)", - ), - radius_km: Optional[float] = Query( - None, - description="Radius in kilometres for location filter (requires lat & lon)", + None, description="Filter detections up to this timestamp (inclusive, ISO 8601)" ), - page: int = Query( - 1, - ge=1, - description="Page number (1-based)", - ), - page_size: int = Query( - 20, - ge=1, - le=100, - description="Number of detections per page (max 100)", - ), -) -> Dict[str, Any]: + lat: Optional[float] = Query(None, description="Latitude for location filter (requires lon & radius_km)"), + lon: Optional[float] = Query(None, description="Longitude for location filter (requires lat & radius_km)"), + radius_km: Optional[float] = Query(None, description="Radius in kilometres for location filter (requires lat & lon)"), + page: int = Query(1, ge=1, description="Page number (1-based)"), + page_size: int = Query(20, ge=1, le=100, description="Number of detections per page (max 100)"), +): + enforce_and_consume("detections", cost=1) + return detections_service.list_detections( species=species, start_time=start_time, @@ -78,6 +62,7 @@ def list_detections_endpoint( "/{detection_id}", response_model=Detection, summary="Get a single detection by id", + dependencies=[Depends(pause_guard("detections"))], ) def get_detection_endpoint( detection_id: str = Path(..., description="MongoDB ObjectId of the detection"), @@ -89,6 +74,7 @@ def get_detection_endpoint( "/{detection_id}", response_model=Detection, summary="Update fields of a detection", + dependencies=[Depends(pause_guard("detections"))], ) def update_detection_endpoint( detection_id: str = Path(..., description="MongoDB ObjectId of the detection"), @@ -96,15 +82,32 @@ def update_detection_endpoint( ): if not updates: raise HTTPException(status_code=400, detail="No fields provided for update") + + enforce_and_consume("detections", cost=1) + return detections_service.update_detection(detection_id, updates) @router.delete( "/{detection_id}", summary="Delete a detection by id", + dependencies=[Depends(pause_guard("detections"))], ) def delete_detection_endpoint( detection_id: str = Path(..., description="MongoDB ObjectId of the detection"), ): + enforce_and_consume("detections", cost=1) + detections_service.delete_detection(detection_id) return {"deleted": True} + + +@router.post( + "/predict", + summary="Run species prediction (budget-controlled)", + dependencies=[Depends(pause_guard("species_predictor"))], +) +def predict_endpoint(payload: Dict[str, Any] = Body(...)): + enforce_and_consume("species_predictor", cost=5) + + return {"status": "not_implemented_here", "received": payload} diff --git a/src/Components/API/app/routers/projects.py b/src/Components/API/app/routers/projects.py new file mode 100644 index 000000000..85ac3b83b --- /dev/null +++ b/src/Components/API/app/routers/projects.py @@ -0,0 +1,45 @@ +from typing import Optional +from fastapi import APIRouter, Body, Path, Query + +from app.schemas import ( + Project, + ProjectCreate, + ProjectUpdate, + ProjectListResponse, +) +from app.services import projects as projects_service + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +@router.get("", response_model=ProjectListResponse, summary="List projects") +def get_projects( + status: Optional[str] = Query(None), + location: Optional[str] = Query(None), + ecologist: Optional[str] = Query(None), +): + return projects_service.list_projects(status=status, location=location, ecologist=ecologist) + + +@router.post("", response_model=Project, summary="Create a project") +def create_project(payload: ProjectCreate = Body(...)): + return projects_service.create_project(payload) + + +@router.put("/{project_id}", response_model=Project, summary="Update a project (replace)") +def update_project( + project_id: str = Path(..., description="Mongo ObjectId"), + payload: ProjectUpdate = Body(...), +): + return projects_service.update_project(project_id, payload) + + +@router.delete("/{project_id}", summary="Delete a project") +def delete_project(project_id: str = Path(..., description="Mongo ObjectId")): + return projects_service.delete_project(project_id) + + +# Optional endpoint +@router.get("/_meta/ecologists", summary="List ecologists") +def get_ecologists(): + return {"ecologists": projects_service.list_ecologists()} diff --git a/src/Components/API/app/schemas.py b/src/Components/API/app/schemas.py index 70d8136dc..f391a1220 100644 --- a/src/Components/API/app/schemas.py +++ b/src/Components/API/app/schemas.py @@ -1,6 +1,6 @@ ## app.schemas.py from datetime import datetime -from typing import Optional, List, Dict +from typing import Optional, List, Dict, Any from pydantic import BaseModel, Field, validator, constr, conlist, condecimal from bson.objectid import ObjectId from app.database import GENDER, STATES_CODE, AUS_STATES @@ -27,10 +27,9 @@ class EventSchema(BaseModel): timestamp: datetime # Event timestamp sensorId: constr(min_length=1) # Non-empty string for sensor ID species: constr(min_length=1) # Non-empty string for species name - microphoneLLA: List[float] = Field(..., min_items=3, max_items=3) # List of exactly 3 floats for microphone location - #microphoneLLA: List[float] = Field(..., min_items=3, max_items=3) #updated 19/01/26 - animalEstLLA: List[float] = Field(..., min_items=3, max_items=3) # List of exactly 3 floats for estimated animal location - animalTrueLLA: List[float] = Field(..., min_items=3, max_items=3) # List of exactly 3 floats for true animal location + microphoneLLA: conlist(float, min_items=3, max_items=3) # List of exactly 3 floats for microphone location + animalEstLLA: conlist(float, min_items=3, max_items=3) # List of exactly 3 floats for estimated animal location + animalTrueLLA: conlist(float, min_items=3, max_items=3) # List of exactly 3 floats for true animal location animalLLAUncertainty: int # Uncertainty value audioClip: str # Audio clip data confidence: condecimal(gt=0, lt=100) # Confidence value between 0 and 100 @@ -61,7 +60,7 @@ class MovementSchema(BaseModel): timestamp: datetime # Movement timestamp species: constr(min_length=1) # Non-empty string for species name animalId: constr(min_length=1) # Non-empty string for animal ID - animalTrueLLA: List[float] = Field(..., min_items=3, max_items=3) # List of exactly 3 floats for true animal location + animalTrueLLA: conlist(float, min_items=3, max_items=3) # List of exactly 3 floats for true animal location # Configuration and schema example class Config: @@ -80,7 +79,7 @@ class Config: # Schema to validate microphone location data class MicrophoneSchema(BaseModel): sensorId: constr(min_length=1) # Non-empty string for sensor ID - microphoneLLA: List[float] = Field(..., min_items=3, max_items=3) # List of exactly 3 floats for microphone location + microphoneLLA: conlist(float, min_items=3, max_items=3) # List of exactly 3 floats for microphone location # Configuration and schema example class Config: @@ -303,9 +302,9 @@ class Config: class RecordingData(BaseModel): timestamp: datetime # Timestamp for recording sensorId: str # Sensor ID - microphoneLLA: List[float] = Field(..., min_items=3, max_items=3) # List of 3 floats for microphone location - animalEstLLA: List[float] = Field(..., min_items=3, max_items=3) # List of 3 floats for estimated animal location - animalTrueLLA: List[float] = Field(..., min_items=3, max_items=3) # List of 3 floats for true animal location + microphoneLLA: conlist(float, min_items=3, max_items=3) # List of 3 floats for microphone location + animalEstLLA: conlist(float, min_items=3, max_items=3) # List of 3 floats for estimated animal location + animalTrueLLA: conlist(float, min_items=3, max_items=3) # List of 3 floats for true animal location animalLLAUncertainty: condecimal(gt=0) # Uncertainty greater than 0 audioClip: str # Audio clip data mode: str # Recording mode @@ -401,7 +400,54 @@ class config: arbitrary_types_allowed = True json_encoders = {ObjectId: str} +class BudgetRule(BaseModel): + service: str = Field(..., min_length=1) + monthly_limit: int = Field(..., ge=0) + + +class BudgetConfig(BaseModel): + rules: list[BudgetRule] = [] + + +class BudgetUsageOut(BaseModel): + service: str + monthly_limit: int + month_key: str + used: int + remaining: int + + +class ServicePauseIn(BaseModel): + service: str = Field(..., min_length=1) + paused: bool + + +class ServicePauseOut(BaseModel): + service: str + paused: bool + updated_at: datetime + +class ProjectBase(BaseModel): + name: str = Field(..., min_length=1) + description: Optional[str] = None + location: Optional[str] = None + status: str = Field(..., min_length=1) + sensorIds: List[str] = Field(default_factory=list) + ecologists: List[str] = Field(default_factory=list) + +class ProjectCreate(ProjectBase): + pass + +class ProjectUpdate(ProjectBase): + pass + +class Project(ProjectBase): + id: str + +class ProjectListResponse(BaseModel): + items: List[Project] + total: int # Backwards/expected import name used by app.routers.detections class DetectionListResponse(DetectionListResponses): - pass \ No newline at end of file + pass diff --git a/src/Components/API/app/services/budget.py b/src/Components/API/app/services/budget.py new file mode 100644 index 000000000..6e2078099 --- /dev/null +++ b/src/Components/API/app/services/budget.py @@ -0,0 +1,71 @@ +from datetime import datetime, timezone +from fastapi import HTTPException, status +from app.database import AdminBudgets + + +def _month_key(dt: datetime) -> str: + return dt.strftime("%Y-%m") + + +def set_budget_limits(rules: list[dict]) -> dict: + now = datetime.now(timezone.utc) + AdminBudgets.update_one( + {"_type": "config"}, + {"$set": {"_type": "config", "rules": rules, "updated_at": now}}, + upsert=True, + ) + return {"updated_at": now, "rules": rules} + + +def get_budget_limits() -> dict: + doc = AdminBudgets.find_one({"_type": "config"}) or {} + return {"rules": doc.get("rules", []), "updated_at": doc.get("updated_at")} + + +def _get_limit_for(service: str) -> int: + config = AdminBudgets.find_one({"_type": "config"}) or {} + rules = config.get("rules", []) + for r in rules: + if r.get("service") == service: + return int(r.get("monthly_limit", 0)) + return 0 + + +def get_usage(service: str, when: datetime | None = None) -> dict: + when = when or datetime.now(timezone.utc) + mk = _month_key(when) + + limit = _get_limit_for(service) + usage_doc = AdminBudgets.find_one({"_type": "usage", "service": service, "month_key": mk}) or {} + used = int(usage_doc.get("used", 0)) + remaining = max(limit - used, 0) + + return {"service": service, "monthly_limit": limit, "month_key": mk, "used": used, "remaining": remaining} + + +def enforce_and_consume(service: str, cost: int = 1): + now = datetime.now(timezone.utc) + mk = _month_key(now) + limit = _get_limit_for(service) + + if limit <= 0: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Budget is not configured for '{service}' (monthly_limit=0).", + ) + + usage_doc = AdminBudgets.find_one({"_type": "usage", "service": service, "month_key": mk}) or {} + used = int(usage_doc.get("used", 0)) + + if used + cost > limit: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Budget exceeded for '{service}'. Used {used}/{limit} this month.", + ) + + AdminBudgets.update_one( + {"_type": "usage", "service": service, "month_key": mk}, + {"$set": {"_type": "usage", "service": service, "month_key": mk, "updated_at": now}, + "$inc": {"used": cost}}, + upsert=True, + ) diff --git a/src/Components/API/app/services/projects.py b/src/Components/API/app/services/projects.py new file mode 100644 index 000000000..309aaa57e --- /dev/null +++ b/src/Components/API/app/services/projects.py @@ -0,0 +1,89 @@ +from typing import Any, Dict, List, Optional +from datetime import datetime, timezone +from fastapi import HTTPException +from bson import ObjectId +from pymongo import ReturnDocument + +from app.database import Projects +from app.schemas import ProjectCreate, ProjectUpdate, Project, ProjectListResponse + + +def _to_project(doc: Dict[str, Any]) -> Project: + return Project( + id=str(doc["_id"]), + name=doc.get("name", ""), + description=doc.get("description"), + location=doc.get("location"), + status=doc.get("status", ""), + sensorIds=doc.get("sensorIds", []), + ecologists=doc.get("ecologists", []), + ) + + +def list_projects( + status: Optional[str] = None, + location: Optional[str] = None, + ecologist: Optional[str] = None, +) -> ProjectListResponse: + q: Dict[str, Any] = {} + if status: + q["status"] = status + if location: + q["location"] = location + if ecologist: + q["ecologists"] = ecologist + + total = Projects.count_documents(q) + items = [_to_project(d) for d in Projects.find(q).sort("name", 1)] + return ProjectListResponse(items=items, total=total) + + +def create_project(payload: ProjectCreate) -> Project: + doc = payload.model_dump() + now = datetime.now(timezone.utc) + doc["created_at"] = now + doc["updated_at"] = now + + res = Projects.insert_one(doc) + created = Projects.find_one({"_id": res.inserted_id}) + return _to_project(created) + + +def update_project(project_id: str, payload: ProjectUpdate) -> Project: + if not ObjectId.is_valid(project_id): + raise HTTPException(status_code=400, detail="Invalid project id") + + now = datetime.now(timezone.utc) + new_doc = payload.model_dump() + new_doc["updated_at"] = now + + updated = Projects.find_one_and_update( + {"_id": ObjectId(project_id)}, + {"$set": new_doc}, + return_document=ReturnDocument.AFTER, + ) + if not updated: + raise HTTPException(status_code=404, detail="Project not found") + + return _to_project(updated) + + +def delete_project(project_id: str) -> Dict[str, Any]: + if not ObjectId.is_valid(project_id): + raise HTTPException(status_code=400, detail="Invalid project id") + + res = Projects.delete_one({"_id": ObjectId(project_id)}) + if res.deleted_count == 0: + raise HTTPException(status_code=404, detail="Project not found") + + return {"deleted": True} + + +def list_ecologists() -> List[str]: + pipeline = [ + {"$unwind": "$ecologists"}, + {"$group": {"_id": "$ecologists"}}, + {"$sort": {"_id": 1}}, + ] + rows = list(Projects.aggregate(pipeline)) + return [r["_id"] for r in rows if r.get("_id")] diff --git a/src/Components/API/app/services/service_state.py b/src/Components/API/app/services/service_state.py new file mode 100644 index 000000000..c56866950 --- /dev/null +++ b/src/Components/API/app/services/service_state.py @@ -0,0 +1,19 @@ +from datetime import datetime, timezone +from app.database import ServiceStates + + +def get_service_state(service: str) -> bool: + doc = ServiceStates.find_one({"service": service}) + if not doc: + return False + return bool(doc.get("paused", False)) + + +def set_service_state(service: str, paused: bool) -> dict: + now = datetime.now(timezone.utc) + ServiceStates.update_one( + {"service": service}, + {"$set": {"service": service, "paused": paused, "updated_at": now}}, + upsert=True, + ) + return {"service": service, "paused": paused, "updated_at": now} diff --git a/src/loadtest/k6_echo_test.js b/src/loadtest/k6_echo_test.js new file mode 100644 index 000000000..e01bba8d3 --- /dev/null +++ b/src/loadtest/k6_echo_test.js @@ -0,0 +1,132 @@ +import http from "k6/http"; +import { check, sleep } from "k6"; + +export const options = { + stages: [ + { duration: "10s", target: 20 }, + { duration: "20s", target: 60 }, + { duration: "30s", target: 110 }, + { duration: "20s", target: 0 }, + ], + thresholds: { + http_req_failed: ["rate<0.03"], + http_req_duration: ["p(95)<1200"], + }, +}; + +const BASE_URL = __ENV.BASE_URL || "http://localhost:9000"; + +const DETECTIONS = `${BASE_URL}/detections`; + +function isControlledBlock(status) { + return status === 429 || status === 503 || status === 403; +} + +function extractId(json) { + if (!json || typeof json !== "object") return null; + return json._id || json.id || (json.data && (json.data._id || json.data.id)) || null; +} + +function isoNowMinusMinutes(min) { + const d = new Date(Date.now() - min * 60 * 1000); + return d.toISOString(); +} + +export default function () { + const q = Math.random(); + + let url = `${DETECTIONS}?page=1&page_size=20`; + if (q < 0.33) { + url = `${DETECTIONS}?species=TestSpecies&page=1&page_size=20`; + } else if (q < 0.66) { + url = `${DETECTIONS}?start_time=${encodeURIComponent(isoNowMinusMinutes(60))}&end_time=${encodeURIComponent( + new Date().toISOString() + )}&page=1&page_size=20`; + } else { + url = `${DETECTIONS}?lat=-37.81&lon=144.96&radius_km=5&page=1&page_size=20`; + } + + const listRes = http.get(url); + check(listRes, { + "list: status ok/controlled": (r) => r.status === 200 || isControlledBlock(r.status), + }); + + sleep(0.1); + + const createPayload = JSON.stringify({ + timestamp: new Date().toISOString(), + species: "TestSpecies", + confidence: 0.91, + }); + + const createRes = http.post(DETECTIONS, createPayload, { + headers: { "Content-Type": "application/json" }, + }); + + const createdOk = createRes.status === 200 || createRes.status === 201; + check(createRes, { + "create: status ok/controlled": (r) => createdOk || isControlledBlock(r.status), + }); + + if (!createdOk) { + sleep(0.2); + return; + } + + let createdId = null; + try { + createdId = extractId(createRes.json()); + } catch (e) { + createdId = null; + } + + if (!createdId) { + sleep(0.2); + return; + } + + const itemUrl = `${DETECTIONS}/${createdId}`; + + const getRes = http.get(itemUrl); + check(getRes, { + "get: status ok/controlled": (r) => r.status === 200 || isControlledBlock(r.status), + }); + + sleep(0.05); + + const patchPayload = JSON.stringify({ + confidence: 0.92, + }); + + const patchRes = http.patch(itemUrl, patchPayload, { + headers: { "Content-Type": "application/json" }, + }); + + check(patchRes, { + "patch: status ok/controlled": (r) => + r.status === 200 || r.status === 204 || isControlledBlock(r.status), + }); + + sleep(0.05); + + const delRes = http.del(itemUrl); + check(delRes, { + "delete: status ok/controlled": (r) => + r.status === 200 || r.status === 204 || isControlledBlock(r.status), + }); + + sleep(0.1); + + const doPredict = Math.random() < 0.2; + if (doPredict) { + const predRes = http.post( + `${DETECTIONS}/predict`, + JSON.stringify({ detection_id: createdId, note: "k6 test" }), + { headers: { "Content-Type": "application/json" } } + ); + + check(predRes, { + "predict: status ok/controlled": (r) => r.status === 200 || isControlledBlock(r.status), + }); + } +}