From 4ba8b1586bf6e0270b5f4fe26a00c8255f7d5e77 Mon Sep 17 00:00:00 2001 From: NicoleGomes Date: Tue, 2 Jun 2026 11:40:44 +0100 Subject: [PATCH 1/2] adjust endpoints user, organizations --- backend/README.md | 191 +++++++-- backend/api/__init__.py | 1 + backend/api/v1/__init__.py | 1 + backend/api/v1/organizations/__init__.py | 1 + .../v1/organizations/organizations_routes.py | 64 +++ .../api/v1/organizations_members/__init__.py | 0 .../organizations_members_routes.py | 69 ++++ backend/api/v1/users/__init__.py | 1 + backend/api/v1/users/users_routes.py | 40 ++ backend/core/__init__.py | 1 + backend/core/config.py | 77 ++++ backend/core/database.py | 50 +++ backend/core/errors.py | 13 + backend/core/security.py | 7 + backend/main.py | 158 ++++---- backend/models/__init__.py | 1 + backend/models/models.py | 145 +++++++ backend/models/rf_model.pkl | 3 - backend/repositories/__init__.py | 1 + .../organization_member_repository.py | 85 ++++ .../repositories/organization_repository.py | 64 +++ backend/repositories/user_repository.py | 47 +++ backend/requirements.txt | 5 + backend/schemas/__init__.py | 1 + backend/schemas/base.py | 15 + .../schemas/organization_member_schemas.py | 31 ++ backend/schemas/organization_schemas.py | 72 ++++ backend/schemas/user_schemas.py | 28 ++ backend/services/__init__.py | 1 + .../services/organization_member_service.py | 167 ++++++++ backend/services/organization_service.py | 120 ++++++ backend/services/user_service.py | 93 +++++ backend/tests/test_api.py | 372 ++++++++++++++++++ backend/yolov8n.pt | 3 - 34 files changed, 1823 insertions(+), 105 deletions(-) create mode 100644 backend/api/__init__.py create mode 100644 backend/api/v1/__init__.py create mode 100644 backend/api/v1/organizations/__init__.py create mode 100644 backend/api/v1/organizations/organizations_routes.py create mode 100644 backend/api/v1/organizations_members/__init__.py create mode 100644 backend/api/v1/organizations_members/organizations_members_routes.py create mode 100644 backend/api/v1/users/__init__.py create mode 100644 backend/api/v1/users/users_routes.py create mode 100644 backend/core/__init__.py create mode 100644 backend/core/config.py create mode 100644 backend/core/database.py create mode 100644 backend/core/errors.py create mode 100644 backend/core/security.py create mode 100644 backend/models/__init__.py create mode 100644 backend/models/models.py delete mode 100644 backend/models/rf_model.pkl create mode 100644 backend/repositories/__init__.py create mode 100644 backend/repositories/organization_member_repository.py create mode 100644 backend/repositories/organization_repository.py create mode 100644 backend/repositories/user_repository.py create mode 100644 backend/schemas/__init__.py create mode 100644 backend/schemas/base.py create mode 100644 backend/schemas/organization_member_schemas.py create mode 100644 backend/schemas/organization_schemas.py create mode 100644 backend/schemas/user_schemas.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/organization_member_service.py create mode 100644 backend/services/organization_service.py create mode 100644 backend/services/user_service.py create mode 100644 backend/tests/test_api.py delete mode 100644 backend/yolov8n.pt diff --git a/backend/README.md b/backend/README.md index 7b89bdc..c1e08db 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,26 +6,78 @@ FastAPI service for the PondiFarmApp pipeline. Receives an image, runs object de - Python 3.9+ - FastAPI 0.111 +- SQLAlchemy 2.0 +- Pydantic +- passlib with PBKDF2-SHA256 for password hashing - Ultralytics YOLOv8 (object detection) - scikit-learn Random Forest (weight estimator) - OpenCV, NumPy, Pillow ## Project structure -``` +```txt backend/ -├── main.py FastAPI entry point and HTTP routes -├── models/ -│ ├── detector.py YOLOv8 wrapper used by /api/v1/scan -│ ├── weight_estimator.py Random Forest inference -│ └── rf_model.pkl Trained Random Forest (Git LFS) -├── utils/ -│ └── geometry.py Bounding-box → morphometric features -├── requirements.txt Pinned dependencies -└── ruff.toml Linter configuration +|-- api/ +| `-- v1/ +| |-- auth/ +| | `-- auth_routes.py +| |-- organizations/ +| | `-- organizations_routes.py +| `-- users/ +| `-- users_routes.py +|-- core/ +| |-- config.py +| |-- database.py +| |-- errors.py +| `-- security.py +|-- models/ +| |-- detector.py +| |-- models.py +| |-- rf_model.pkl +| `-- weight_estimator.py +|-- repositories/ +| |-- organization_member_repository.py +| |-- organization_repository.py +| `-- user_repository.py +|-- schemas/ +| |-- base.py +| |-- organization_member_schemas.py +| |-- organization_schemas.py +| `-- user_schemas.py +|-- services/ +| |-- organization_member_service.py +| |-- organization_service.py +| `-- user_service.py +|-- tests/ +| `-- test_api.py +|-- utils/ +| `-- geometry.py +|-- main.py +|-- requirements.txt +`-- ruff.toml ``` -The trained Random Forest (`rf_model.pkl`) and the YOLOv8 weights (`yolov8n.pt`) are tracked via **Git LFS**. Make sure Git LFS is installed and `git lfs pull` has been run before starting the server. +## Database configuration + +The application is configured to use your Azure SQL / SQL Server database. Do not hardcode production credentials in the repository. + +Examples: + +```bash +# Full SQLAlchemy URL +export DATABASE_URL="mssql+pyodbc://USER:PASSWORD@HOST:1433/DATABASE?driver=ODBC+Driver+17+for+SQL+Server&Encrypt=yes&TrustServerCertificate=no" + +# Or compose the Azure SQL connection from environment variables +export AZURE_SQL_SERVER="your-server.database.windows.net" +export AZURE_SQL_DATABASE="pondifarm" +export AZURE_SQL_USERNAME="your-user" +export AZURE_SQL_PASSWORD="your-password" +export AZURE_SQL_DRIVER="ODBC Driver 17 for SQL Server" +export AZURE_SQL_ENCRYPT="yes" +export AZURE_SQL_TRUST_SERVER_CERTIFICATE="no" +``` + +The backend no longer falls back to SQLite at runtime. It expects `DATABASE_URL` or the Azure SQL variables above to be present. The service does not auto-create schema objects in Azure SQL; it maps to the existing tables. ## Local development @@ -50,41 +102,118 @@ pip install -r requirements.txt uvicorn main:app --reload --host 0.0.0.0 --port 8000 ``` -The API is then available at `http://localhost:8000`. +## API surface + +### Health + +- `GET /health` +- `GET /` + +### Scan + +- `POST /api/v1/scan` + +Multipart form upload: + +| Field | Type | Required | Default | +|-------|------|----------|---------| +| `file` | file | yes | - | +| `animal_id` | string | no | `DEMO-001` | +| `breed` | string | no | `default` | + +### Organizations + +- `POST /api/v1/organizations` +- `GET /api/v1/organizations` +- `GET /api/v1/organizations/{organizationId}` +- `PATCH /api/v1/organizations/{organizationId}` +- `DELETE /api/v1/organizations/{organizationId}` + +Create example: + +```bash +curl -X POST http://localhost:8000/api/v1/organizations \ + -H "Content-Type: application/json" \ + -d '{ + "name": "PondiFarm", + "documentNumber": "123456789", + "phone": "+351900000000", + "email": "contact@pondifarm.com", + "address": "Porto, Portugal" + }' +``` + +### Users + +- `POST /api/v1/users` +- `GET /api/v1/users` +- `GET /api/v1/users/{userId}` +- `PATCH /api/v1/users/{userId}` +- `DELETE /api/v1/users/{userId}` -### 4. Check the service +Create example: ```bash -curl http://localhost:8000/health -# → {"status":"ok"} +curl -X POST http://localhost:8000/api/v1/users \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Bruno Silva", + "email": "bruno@example.com", + "password": "password-value" + }' +``` + +### Organization members + +- `GET /api/v1/organizations/{organizationId}/members` +- `POST /api/v1/organizations/{organizationId}/members` +- `PATCH /api/v1/organizations/{organizationId}/members/{memberId}` +- `DELETE /api/v1/organizations/{organizationId}/members/{memberId}` + +Add member example: -curl http://localhost:8000/ -# → {"status":"ok","service":"PondiFarm API v0.1 — Phase 0"} +```bash +curl -X POST http://localhost:8000/api/v1/organizations/{organizationId}/members \ + -H "Content-Type: application/json" \ + -d '{ + "userId": "00000000-0000-0000-0000-000000000001", + "role": "manager" + }' ``` -## API surface +List members example: + +```bash +curl http://localhost:8000/api/v1/organizations/{organizationId}/members +``` + +## Soft delete behavior -### `GET /health` +The `organizations`, `users`, and `organization_members` resources now use soft delete only. -Lightweight liveness probe. Returns `{"status": "ok"}`. +- `DELETE` sets `deleted_at` and `updated_at` instead of removing the row. +- Normal `GET` endpoints return only active rows where `deleted_at IS NULL`. +- `PATCH` on a soft-deleted record returns `404`. +- User responses still never expose `password_hash`. -### `GET /` +If you need to prepare Azure SQL for this behavior, run [backend/sql/add_deleted_at_soft_delete.sql](/C:/EUCInovacao/PondiFarmApp/backend/sql/add_deleted_at_soft_delete.sql) against the existing database. -Service banner — name and phase. +## Registration rules -### `POST /api/v1/scan` +Current user and organization registration rules: -Multipart form upload with the following fields: +- User emails are normalized to lowercase and must be unique across the whole system. +- Organization document numbers are normalized by removing formatting characters and must be unique across the whole system. +- All organization memberships currently use the role `viewer`. +- No RBAC or differentiated organization roles are implemented yet. -| Field | Type | Required | Default | -|--------------|-----------|----------|-------------| -| `file` | file | yes | — | -| `animal_id` | string | no | `DEMO-001` | -| `breed` | string | no | `default` | +If you need to prepare Azure SQL for these rules, run [backend/sql/add_registration_business_rules.sql](/C:/EUCInovacao/PondiFarmApp/backend/sql/add_registration_business_rules.sql). -Returns a JSON object containing the detection, the five morphometric measurements (`body_length_cm`, `withers_height_cm`, `thoracic_depth_cm`, `rump_width_cm`, `chest_girth_cm`), and the weight estimate with a confidence score. +## Tests -A `422` is returned when no subject is detected in the image. +```bash +python -m unittest discover tests +``` ## Linting and formatting diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/api/v1/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/v1/organizations/__init__.py b/backend/api/v1/organizations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/api/v1/organizations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/v1/organizations/organizations_routes.py b/backend/api/v1/organizations/organizations_routes.py new file mode 100644 index 0000000..1e40f3e --- /dev/null +++ b/backend/api/v1/organizations/organizations_routes.py @@ -0,0 +1,64 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Response, status +from sqlalchemy.orm import Session + +from core.database import get_db + +from schemas.organization_schemas import ( + OrganizationCreate, + OrganizationResponse, + OrganizationUpdate, +) +from services import organization_service + +organizations_router = APIRouter( + prefix="/api/v1/organizations", + tags=["organizations"], +) + + +@organizations_router.post( + "", + response_model=OrganizationResponse, + status_code=status.HTTP_201_CREATED, +) +def create_organization( + payload: OrganizationCreate, + db: Session = Depends(get_db), +): + return organization_service.create_organization(db, payload) + + +@organizations_router.get("", response_model=list[OrganizationResponse]) +def list_organizations(db: Session = Depends(get_db)): + return organization_service.list_organizations(db) + + +@organizations_router.get( + "/{organization_id}", + response_model=OrganizationResponse, +) +def get_organization(organization_id: UUID, db: Session = Depends(get_db)): + return organization_service.get_organization(db, organization_id) + + +@organizations_router.patch( + "/{organization_id}", + response_model=OrganizationResponse, +) +def update_organization( + organization_id: UUID, + payload: OrganizationUpdate, + db: Session = Depends(get_db), +): + return organization_service.update_organization(db, organization_id, payload) + + +@organizations_router.delete( + "/{organization_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_organization(organization_id: UUID, db: Session = Depends(get_db)): + organization_service.delete_organization(db, organization_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/v1/organizations_members/__init__.py b/backend/api/v1/organizations_members/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/organizations_members/organizations_members_routes.py b/backend/api/v1/organizations_members/organizations_members_routes.py new file mode 100644 index 0000000..5aebf49 --- /dev/null +++ b/backend/api/v1/organizations_members/organizations_members_routes.py @@ -0,0 +1,69 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Response, status +from sqlalchemy.orm import Session + +from core.database import get_db +from schemas.organization_member_schemas import ( + OrganizationMemberCreate, + OrganizationMemberResponse, + OrganizationMemberUpdate, +) + +from services import organization_member_service + +organizations_member_router = APIRouter( + prefix="/api/v1/organizations", + tags=["members"], +) + +@organizations_member_router.get( + "/{organization_id}/members", + response_model=list[OrganizationMemberResponse], +) +def list_members(organization_id: UUID, db: Session = Depends(get_db)): + return organization_member_service.list_members(db, organization_id) + + +@organizations_member_router.post( + "/{organization_id}/members", + response_model=OrganizationMemberResponse, + status_code=status.HTTP_201_CREATED, +) +def create_member( + organization_id: UUID, + payload: OrganizationMemberCreate, + db: Session = Depends(get_db), +): + return organization_member_service.create_member(db, organization_id, payload) + + +@organizations_member_router.patch( + "/{organization_id}/members/{member_id}", + response_model=OrganizationMemberResponse, +) +def update_member( + organization_id: UUID, + member_id: UUID, + payload: OrganizationMemberUpdate, + db: Session = Depends(get_db), +): + return organization_member_service.update_member( + db, + organization_id, + member_id, + payload, + ) + + +@organizations_member_router.delete( + "/{organization_id}/members/{member_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def delete_member( + organization_id: UUID, + member_id: UUID, + db: Session = Depends(get_db), +): + organization_member_service.delete_member(db, organization_id, member_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/v1/users/__init__.py b/backend/api/v1/users/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/api/v1/users/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/api/v1/users/users_routes.py b/backend/api/v1/users/users_routes.py new file mode 100644 index 0000000..8f36ec5 --- /dev/null +++ b/backend/api/v1/users/users_routes.py @@ -0,0 +1,40 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Response, status +from sqlalchemy.orm import Session + +from core.database import get_db +from schemas.user_schemas import UserCreate, UserResponse, UserUpdate +from services import user_service + +users_router = APIRouter(prefix="/api/v1/users", tags=["users"]) + + +@users_router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +def create_user(payload: UserCreate, db: Session = Depends(get_db)): + return user_service.create_user(db, payload) + + +@users_router.get("", response_model=list[UserResponse]) +def list_users(db: Session = Depends(get_db)): + return user_service.list_users(db) + + +@users_router.get("/{user_id}", response_model=UserResponse) +def get_user(user_id: UUID, db: Session = Depends(get_db)): + return user_service.get_user(db, user_id) + + +@users_router.patch("/{user_id}", response_model=UserResponse) +def update_user( + user_id: UUID, + payload: UserUpdate, + db: Session = Depends(get_db), +): + return user_service.update_user(db, user_id, payload) + + +@users_router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_user(user_id: UUID, db: Session = Depends(get_db)): + user_service.delete_user(db, user_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/core/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/core/config.py b/backend/core/config.py new file mode 100644 index 0000000..f35354d --- /dev/null +++ b/backend/core/config.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import quote_plus + +from sqlalchemy.engine import URL + + +@dataclass(frozen=True) +class Settings: + database_url: str + + +def _load_env_file() -> None: + env_path = Path(__file__).resolve().parent.parent / ".env" + if not env_path.exists(): + return + + for raw_line in env_path.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'").strip('"') + os.environ.setdefault(key, value) + + +def _build_database_url() -> str: + _load_env_file() + + direct_url = os.getenv("DATABASE_URL") + if direct_url: + return direct_url + + server = os.getenv("AZURE_SQL_SERVER") + database = os.getenv("AZURE_SQL_DATABASE") + username = os.getenv("AZURE_SQL_USERNAME") + password = os.getenv("AZURE_SQL_PASSWORD") + driver = os.getenv("AZURE_SQL_DRIVER", "ODBC Driver 18 for SQL Server") + encrypt = os.getenv("AZURE_SQL_ENCRYPT", "yes") + trust_certificate = os.getenv("AZURE_SQL_TRUST_SERVER_CERTIFICATE", "no") + + required_values = { + "AZURE_SQL_SERVER": server, + "AZURE_SQL_DATABASE": database, + "AZURE_SQL_USERNAME": username, + "AZURE_SQL_PASSWORD": password, + } + missing = [key for key, value in required_values.items() if not value] + if missing: + missing_str = ", ".join(missing) + raise RuntimeError( + "Database configuration is missing. Set DATABASE_URL or the Azure SQL " + f"variables: {missing_str}.", + ) + + odbc_connection_string = ( + f"DRIVER={{{driver}}};" + f"SERVER={server},1433;" + f"DATABASE={database};" + f"UID={username};" + f"PWD={password};" + f"Encrypt={encrypt};" + f"TrustServerCertificate={trust_certificate};" + "Connection Timeout=10;" + ) + return URL.create( + "mssql+pyodbc", + query={"odbc_connect": quote_plus(odbc_connection_string)}, + ).render_as_string(hide_password=False) + + +settings = Settings(database_url=_build_database_url()) diff --git a/backend/core/database.py b/backend/core/database.py new file mode 100644 index 0000000..ac098c1 --- /dev/null +++ b/backend/core/database.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from collections.abc import Generator + +from sqlalchemy import create_engine, event +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, declarative_base, sessionmaker + +from core.config import settings + +Base = declarative_base() + + +def _create_engine() -> Engine: + connect_args = {} + if settings.database_url.startswith("sqlite"): + connect_args["check_same_thread"] = False + + engine = create_engine( + settings.database_url, + future=True, + connect_args=connect_args, + ) + + if settings.database_url.startswith("sqlite"): + event.listen(engine, "connect", _enable_sqlite_foreign_keys) + + return engine + + +def _enable_sqlite_foreign_keys(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +engine = _create_engine() +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + + +def get_db() -> Generator[Session, None, None]: + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def initialize_database() -> None: + import models.models # noqa: F401 diff --git a/backend/core/errors.py b/backend/core/errors.py new file mode 100644 index 0000000..a0c2d96 --- /dev/null +++ b/backend/core/errors.py @@ -0,0 +1,13 @@ +from fastapi.encoders import jsonable_encoder +from fastapi import FastAPI +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(RequestValidationError) + async def validation_exception_handler(request, exc): + return JSONResponse( + status_code=400, + content={"detail": jsonable_encoder(exc.errors())}, + ) diff --git a/backend/core/security.py b/backend/core/security.py new file mode 100644 index 0000000..68f5e63 --- /dev/null +++ b/backend/core/security.py @@ -0,0 +1,7 @@ +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto") + + +def hash_password(password: str) -> str: + return pwd_context.hash(password) diff --git a/backend/main.py b/backend/main.py index 44530bd..fe852a1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,83 +1,105 @@ -from fastapi import FastAPI, UploadFile, File, Form, HTTPException -from fastapi.middleware.cors import CORSMiddleware -import numpy as np -import cv2 -from PIL import Image import io -from models.detector import detect_subject -from models.weight_estimator import estimate_weight -from utils.geometry import bbox_to_measurements +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware + +from api.v1.organizations.organizations_routes import organizations_router +from api.v1.users.users_routes import users_router +from api.v1.organizations_members.organizations_members_routes import organizations_member_router +from core.database import initialize_database +from core.errors import register_exception_handlers + + +def create_app() -> FastAPI: + app = FastAPI(title="PondiFarm API", version="0.1.0") + # CORS aberto para demo Fase 0 — restringir em produção + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + register_exception_handlers(app) + + @app.on_event("startup") + def startup_event(): + initialize_database() -app = FastAPI(title="PondiFarm API", version="0.1.0") + @app.get("/") + def health(): + return {"status": "ok", "service": "PondiFarm API v0.1 — Phase 0"} -# CORS aberto para demo Fase 0 — restringir em produção -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) + @app.get("/health") + def health_check(): + return {"status": "ok"} -@app.get("/") -def health(): - return {"status": "ok", "service": "PondiFarm API v0.1 — Phase 0"} + @app.post("/api/v1/scan") + async def scan( + file: UploadFile = File(...), + animal_id: str = Form(default="DEMO-001"), + breed: str = Form(default="default"), + ): + import cv2 + import numpy as np + from PIL import Image -@app.get("/health") -def health_check(): - return {"status": "ok"} + from models.detector import detect_subject + from models.weight_estimator import estimate_weight + from utils.geometry import bbox_to_measurements + contents = await file.read() + img_pil = Image.open(io.BytesIO(contents)).convert("RGB") + img_np = np.array(img_pil) + img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) -@app.post("/api/v1/scan") -async def scan( - file: UploadFile = File(...), - animal_id: str = Form(default="DEMO-001"), - breed: str = Form(default="default"), -): - contents = await file.read() - img_pil = Image.open(io.BytesIO(contents)).convert("RGB") - img_np = np.array(img_pil) - img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) + detection = detect_subject(img_bgr) - detection = detect_subject(img_bgr) + if detection is None: + raise HTTPException( + status_code=422, + detail="Nenhum objeto detectado na imagem. Certifique-se de que o animal/objeto está visível e bem iluminado.", + ) - if detection is None: - raise HTTPException( - status_code=422, - detail="Nenhum objeto detectado na imagem. Certifique-se de que o animal/objeto está visível e bem iluminado.", + h, w = img_bgr.shape[:2] + measurements = bbox_to_measurements( + bbox=detection["bbox"], + img_h=h, + img_w=w, + breed=breed.lower(), ) - h, w = img_bgr.shape[:2] - measurements = bbox_to_measurements( - bbox=detection["bbox"], - img_h=h, - img_w=w, - breed=breed.lower(), - ) + peso_kg, confianca = estimate_weight(measurements) + + return { + "animal_id": animal_id, + "breed": breed, + "detection": { + "class": detection["class_name"], + "confidence_pct": detection["confidence"], + "is_real_animal": detection["is_real_animal"], + "mode": "2D sem LiDAR — Fase 0", + }, + "measurements": { + "body_length_cm": measurements["body_length_cm"], + "withers_height_cm": measurements["withers_height_cm"], + "thoracic_depth_cm": measurements["thoracic_depth_cm"], + "rump_width_cm": measurements["rump_width_cm"], + "chest_girth_cm": measurements["chest_girth_cm"], + }, + "result": { + "estimated_weight_kg": peso_kg, + "confidence_pct": confianca, + "accuracy_note": "Estimativa por visão computacional 2D · Precisão aumentada com LiDAR na versão final", + }, + } + + app.include_router(organizations_router) + app.include_router(users_router) + app.include_router(organizations_member_router) + return app + - peso_kg, confianca = estimate_weight(measurements) - - return { - "animal_id": animal_id, - "breed": breed, - "detection": { - "class": detection["class_name"], - "confidence_pct": detection["confidence"], - "is_real_animal": detection["is_real_animal"], - "mode": "2D sem LiDAR — Fase 0", - }, - "measurements": { - "body_length_cm": measurements["body_length_cm"], - "withers_height_cm": measurements["withers_height_cm"], - "thoracic_depth_cm": measurements["thoracic_depth_cm"], - "rump_width_cm": measurements["rump_width_cm"], - "chest_girth_cm": measurements["chest_girth_cm"], - }, - "result": { - "estimated_weight_kg": peso_kg, - "confidence_pct": confianca, - "accuracy_note": "Estimativa por visão computacional 2D · Precisão aumentada com LiDAR na versão final", - }, - } +app = create_app() diff --git a/backend/models/__init__.py b/backend/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/models/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/models/models.py b/backend/models/models.py new file mode 100644 index 0000000..eec30c2 --- /dev/null +++ b/backend/models/models.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from datetime import datetime +import uuid + +from sqlalchemy import ( + CHAR, + CheckConstraint, + DateTime, + ForeignKey, + String, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.mssql import UNIQUEIDENTIFIER +from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.types import TypeDecorator + +from core.database import Base + + +class GUID(TypeDecorator): + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "mssql": + return dialect.type_descriptor(UNIQUEIDENTIFIER()) + return dialect.type_descriptor(CHAR(36)) + + def process_bind_param(self, value, dialect): + if value is None: + return value + if isinstance(value, uuid.UUID): + return str(value) + return str(uuid.UUID(str(value))) + + def process_result_value(self, value, dialect): + if value is None: + return value + return uuid.UUID(str(value)) + + +class Organization(Base): + __tablename__ = "organizations" + + id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(255), nullable=False) + document_number: Mapped[str] = mapped_column( + String(100), + nullable=False, + unique=True, + ) + phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + address: Mapped[str | None] = mapped_column(String(500), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + members: Mapped[list["OrganizationMember"]] = relationship( + back_populates="organization", + ) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + memberships: Mapped[list["OrganizationMember"]] = relationship( + back_populates="user", + ) + + +class OrganizationMember(Base): + __tablename__ = "organization_members" + __table_args__ = ( + UniqueConstraint( + "organization_id", + "user_id", + name="uq_organization_members_organization_user", + ), + CheckConstraint( + "role = 'viewer'", + name="ck_organization_members_role_viewer", + ), + ) + + id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4) + organization_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("organizations.id"), + nullable=False, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + GUID(), + ForeignKey("users.id"), + nullable=False, + ) + role: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="viewer", + server_default=text("'viewer'"), + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=datetime.utcnow, + onupdate=datetime.utcnow, + ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + + organization: Mapped[Organization] = relationship(back_populates="members") + user: Mapped[User] = relationship(back_populates="memberships") diff --git a/backend/models/rf_model.pkl b/backend/models/rf_model.pkl deleted file mode 100644 index 61c15cc..0000000 --- a/backend/models/rf_model.pkl +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:899dbaaf6945a91fe14fdf2177811e1d4239d0a8a6f8eef3abacfad1594d6bc1 -size 14593495 diff --git a/backend/repositories/__init__.py b/backend/repositories/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/repositories/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/repositories/organization_member_repository.py b/backend/repositories/organization_member_repository.py new file mode 100644 index 0000000..53fee9d --- /dev/null +++ b/backend/repositories/organization_member_repository.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from models.models import OrganizationMember, User + + +def create_member(db: Session, member: OrganizationMember) -> OrganizationMember: + db.add(member) + db.commit() + db.refresh(member) + return member + + +def list_active_members_by_organization( + db: Session, + organization_id: UUID, +) -> list[OrganizationMember]: + statement = ( + select(OrganizationMember) + .options(joinedload(OrganizationMember.user)) + .where(OrganizationMember.organization_id == organization_id) + .where(OrganizationMember.deleted_at.is_(None)) + .join(User, OrganizationMember.user_id == User.id) + .where(User.deleted_at.is_(None)) + .order_by(OrganizationMember.created_at.asc()) + ) + return list(db.scalars(statement).all()) + + +def get_active_member_by_id( + db: Session, + organization_id: UUID, + member_id: UUID, +) -> OrganizationMember | None: + statement = ( + select(OrganizationMember) + .options(joinedload(OrganizationMember.user)) + .where(OrganizationMember.organization_id == organization_id) + .where(OrganizationMember.id == member_id) + .where(OrganizationMember.deleted_at.is_(None)) + .join(User, OrganizationMember.user_id == User.id) + .where(User.deleted_at.is_(None)) + ) + return db.scalar(statement) + + +def get_active_member_by_organization_and_user( + db: Session, + organization_id: UUID, + user_id: UUID, +) -> OrganizationMember | None: + statement = select(OrganizationMember).where( + OrganizationMember.organization_id == organization_id, + OrganizationMember.user_id == user_id, + OrganizationMember.deleted_at.is_(None), + ) + return db.scalar(statement) + + +def get_member_by_organization_and_user( + db: Session, + organization_id: UUID, + user_id: UUID, +) -> OrganizationMember | None: + statement = ( + select(OrganizationMember) + .options(joinedload(OrganizationMember.user)) + .where( + OrganizationMember.organization_id == organization_id, + OrganizationMember.user_id == user_id, + ) + ) + return db.scalar(statement) + + +def delete_member(db: Session, member: OrganizationMember) -> None: + now = datetime.utcnow() + member.deleted_at = now + member.updated_at = now + db.commit() diff --git a/backend/repositories/organization_repository.py b/backend/repositories/organization_repository.py new file mode 100644 index 0000000..eb22eb7 --- /dev/null +++ b/backend/repositories/organization_repository.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.models import Organization + + +def create_organization(db: Session, organization: Organization) -> Organization: + db.add(organization) + db.commit() + db.refresh(organization) + return organization + + +def list_active_organizations(db: Session) -> list[Organization]: + statement = ( + select(Organization) + .where(Organization.deleted_at.is_(None)) + .order_by(Organization.created_at.asc()) + ) + return list(db.scalars(statement).all()) + + +def get_active_organization_by_id( + db: Session, + organization_id: UUID, +) -> Organization | None: + statement = select(Organization).where( + Organization.id == organization_id, + Organization.deleted_at.is_(None), + ) + return db.scalar(statement) + + +def get_active_organization_by_document_number( + db: Session, + document_number: str, +) -> Organization | None: + statement = select(Organization).where( + Organization.document_number == document_number, + Organization.deleted_at.is_(None), + ) + return db.scalar(statement) + + +def get_organization_by_document_number( + db: Session, + document_number: str, +) -> Organization | None: + statement = select(Organization).where( + Organization.document_number == document_number, + ) + return db.scalar(statement) + + +def delete_organization(db: Session, organization: Organization) -> None: + now = datetime.utcnow() + organization.deleted_at = now + organization.updated_at = now + db.commit() diff --git a/backend/repositories/user_repository.py b/backend/repositories/user_repository.py new file mode 100644 index 0000000..aac6994 --- /dev/null +++ b/backend/repositories/user_repository.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.models import User + + +def create_user(db: Session, user: User) -> User: + db.add(user) + db.commit() + db.refresh(user) + return user + + +def list_active_users(db: Session) -> list[User]: + statement = ( + select(User) + .where(User.deleted_at.is_(None)) + .order_by(User.created_at.asc()) + ) + return list(db.scalars(statement).all()) + + +def get_active_user_by_id(db: Session, user_id: UUID) -> User | None: + statement = select(User).where(User.id == user_id, User.deleted_at.is_(None)) + return db.scalar(statement) + + +def get_active_user_by_email(db: Session, email: str) -> User | None: + statement = select(User).where(User.email == email, User.deleted_at.is_(None)) + return db.scalar(statement) + + +def get_user_by_email(db: Session, email: str) -> User | None: + statement = select(User).where(User.email == email) + return db.scalar(statement) + + +def delete_user(db: Session, user: User) -> None: + now = datetime.utcnow() + user.deleted_at = now + user.updated_at = now + db.commit() diff --git a/backend/requirements.txt b/backend/requirements.txt index d83dc8e..d2f8f40 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,6 +1,11 @@ fastapi==0.111.0 uvicorn[standard]==0.29.0 python-multipart==0.0.9 +httpx==0.27.2 +SQLAlchemy==2.0.36 +pyodbc==5.1.0 +passlib==1.7.4 +email-validator==2.2.0 ultralytics==8.2.18 opencv-python==4.9.0.80 scikit-learn==1.4.2 diff --git a/backend/schemas/__init__.py b/backend/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/schemas/base.py b/backend/schemas/base.py new file mode 100644 index 0000000..91f59b2 --- /dev/null +++ b/backend/schemas/base.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict + + +def to_camel(value: str) -> str: + parts = value.split("_") + return parts[0] + "".join(part.capitalize() for part in parts[1:]) + + +class APIModel(BaseModel): + model_config = ConfigDict( + from_attributes=True, + populate_by_name=True, + alias_generator=to_camel, + str_strip_whitespace=True, + ) diff --git a/backend/schemas/organization_member_schemas.py b/backend/schemas/organization_member_schemas.py new file mode 100644 index 0000000..11a5336 --- /dev/null +++ b/backend/schemas/organization_member_schemas.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal +from uuid import UUID + +from pydantic import Field + +from schemas.base import APIModel + +MemberRole = Literal["viewer"] + + +class OrganizationMemberCreate(APIModel): + user_id: UUID + role: MemberRole = Field(default="viewer") + + +class OrganizationMemberUpdate(APIModel): + role: MemberRole = Field(default="viewer") + + +class OrganizationMemberResponse(APIModel): + id: UUID + organization_id: UUID + user_id: UUID + user_name: str + user_email: str + role: MemberRole + created_at: datetime + updated_at: datetime diff --git a/backend/schemas/organization_schemas.py b/backend/schemas/organization_schemas.py new file mode 100644 index 0000000..802ee3a --- /dev/null +++ b/backend/schemas/organization_schemas.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import EmailStr, Field, field_validator + +from schemas.base import APIModel + + +def normalize_portuguese_nif(value: str | None) -> str | None: + if value is None: + return None + + digits = "".join(character for character in value if character.isdigit()) + if not digits: + return None + return digits + + +def validate_portuguese_nif(value: str) -> str: + digits = normalize_portuguese_nif(value) + if digits is None or len(digits) != 9 or digits[0] not in "1235689": + raise ValueError("documentNumber must be a valid Portuguese NIF") + + total = sum(int(digit) * weight for digit, weight in zip(digits[:8], range(9, 1, -1))) + remainder = total % 11 + check_digit = 0 if remainder < 2 else 11 - remainder + + if check_digit != int(digits[8]): + raise ValueError("documentNumber must be a valid Portuguese NIF") + + return digits + + +class OrganizationCreate(APIModel): + name: str = Field(min_length=1) + document_number: str = Field(min_length=1) + phone: str | None = None + email: EmailStr | None = None + address: str | None = None + + @field_validator("document_number") + @classmethod + def validate_document_number(cls, value: str) -> str: + return validate_portuguese_nif(value) + + +class OrganizationUpdate(APIModel): + name: str | None = Field(default=None, min_length=1) + document_number: str | None = None + phone: str | None = None + email: EmailStr | None = None + address: str | None = None + + @field_validator("document_number") + @classmethod + def validate_document_number(cls, value: str | None) -> str | None: + if value is None: + return None + return validate_portuguese_nif(value) + + +class OrganizationResponse(APIModel): + id: UUID + name: str + document_number: str + phone: str | None = None + email: EmailStr | None = None + address: str | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/schemas/user_schemas.py b/backend/schemas/user_schemas.py new file mode 100644 index 0000000..77016d4 --- /dev/null +++ b/backend/schemas/user_schemas.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import EmailStr, Field + +from schemas.base import APIModel + + +class UserCreate(APIModel): + name: str = Field(min_length=1) + email: EmailStr + password: str = Field(min_length=1) + + +class UserUpdate(APIModel): + name: str | None = Field(default=None, min_length=1) + email: EmailStr | None = None + password: str | None = Field(default=None, min_length=1) + + +class UserResponse(APIModel): + id: UUID + name: str + email: EmailStr + created_at: datetime + updated_at: datetime diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/services/organization_member_service.py b/backend/services/organization_member_service.py new file mode 100644 index 0000000..8165b0a --- /dev/null +++ b/backend/services/organization_member_service.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +from datetime import datetime +import logging +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from models.models import OrganizationMember +from repositories import organization_member_repository +from schemas.organization_member_schemas import ( + OrganizationMemberCreate, + OrganizationMemberResponse, + OrganizationMemberUpdate, +) +from services.organization_service import get_organization_entity +from services.user_service import get_user_entity + +logger = logging.getLogger(__name__) + + +def _to_response(member: OrganizationMember) -> OrganizationMemberResponse: + return OrganizationMemberResponse.model_validate( + { + "id": member.id, + "organization_id": member.organization_id, + "user_id": member.user_id, + "user_name": member.user.name, + "user_email": member.user.email, + "role": member.role, + "created_at": member.created_at, + "updated_at": member.updated_at, + }, + ) + + +def list_members( + db: Session, + organization_id: UUID, +) -> list[OrganizationMemberResponse]: + get_organization_entity(db, organization_id) + members = organization_member_repository.list_active_members_by_organization( + db, + organization_id, + ) + return [_to_response(member) for member in members] + + +def create_member( + db: Session, + organization_id: UUID, + payload: OrganizationMemberCreate, +) -> OrganizationMemberResponse: + get_organization_entity(db, organization_id) + get_user_entity(db, payload.user_id) + + existing = organization_member_repository.get_active_member_by_organization_and_user( + db, + organization_id, + payload.user_id, + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Organization member already exists", + ) + + existing_soft_deleted = ( + organization_member_repository.get_member_by_organization_and_user( + db, + organization_id, + payload.user_id, + ) + ) + if existing_soft_deleted and existing_soft_deleted.deleted_at is not None: + existing_soft_deleted.deleted_at = None + existing_soft_deleted.role = "viewer" + existing_soft_deleted.updated_at = datetime.utcnow() + db.commit() + db.refresh(existing_soft_deleted) + hydrated = organization_member_repository.get_active_member_by_id( + db, + organization_id, + existing_soft_deleted.id, + ) + return _to_response(hydrated) + + member = OrganizationMember( + organization_id=organization_id, + user_id=payload.user_id, + role="viewer", + ) + try: + created = organization_member_repository.create_member(db, member) + except IntegrityError as exc: + db.rollback() + error_message = str(exc.orig) if exc.orig else str(exc) + logger.exception( + "Failed to create organization member for organization_id=%s user_id=%s", + organization_id, + payload.user_id, + ) + if ( + "uq_organization_members_organization_user" in error_message + or "UNIQUE KEY constraint" in error_message + or "duplicate key" in error_message.lower() + ): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Organization member already exists", + ) from exc + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to create organization member: {error_message}", + ) from exc + hydrated = organization_member_repository.get_active_member_by_id( + db, + organization_id, + created.id, + ) + return _to_response(hydrated) + + +def update_member( + db: Session, + organization_id: UUID, + member_id: UUID, + payload: OrganizationMemberUpdate, +) -> OrganizationMemberResponse: + get_organization_entity(db, organization_id) + member = organization_member_repository.get_active_member_by_id( + db, + organization_id, + member_id, + ) + if member is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Organization member not found", + ) + + member.role = "viewer" + member.updated_at = datetime.utcnow() + db.commit() + db.refresh(member) + hydrated = organization_member_repository.get_active_member_by_id( + db, + organization_id, + member_id, + ) + return _to_response(hydrated) + + +def delete_member(db: Session, organization_id: UUID, member_id: UUID) -> None: + member = organization_member_repository.get_active_member_by_id( + db, + organization_id, + member_id, + ) + if member is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Organization member not found", + ) + organization_member_repository.delete_member(db, member) diff --git a/backend/services/organization_service.py b/backend/services/organization_service.py new file mode 100644 index 0000000..16550c0 --- /dev/null +++ b/backend/services/organization_service.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from models.models import Organization +from repositories import organization_repository +from schemas.organization_schemas import ( + OrganizationCreate, + OrganizationResponse, + OrganizationUpdate, + normalize_portuguese_nif, +) + + +def create_organization( + db: Session, + payload: OrganizationCreate, +) -> OrganizationResponse: + normalized_document_number = normalize_portuguese_nif(payload.document_number) + existing = organization_repository.get_organization_by_document_number( + db, + normalized_document_number, + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Organization document number already exists", + ) + + organization = Organization( + **payload.model_dump(exclude={"document_number"}), + document_number=normalized_document_number, + ) + try: + created = organization_repository.create_organization(db, organization) + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Organization document number already exists", + ) from exc + + return OrganizationResponse.model_validate(created) + + +def list_organizations(db: Session) -> list[OrganizationResponse]: + organizations = organization_repository.list_active_organizations(db) + return [OrganizationResponse.model_validate(item) for item in organizations] + + +def get_organization(db: Session, organization_id: UUID) -> OrganizationResponse: + organization = organization_repository.get_active_organization_by_id( + db, + organization_id, + ) + if organization is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Organization not found", + ) + return OrganizationResponse.model_validate(organization) + + +def get_organization_entity(db: Session, organization_id: UUID) -> Organization: + organization = organization_repository.get_active_organization_by_id( + db, + organization_id, + ) + if organization is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Organization not found", + ) + return organization + + +def update_organization( + db: Session, + organization_id: UUID, + payload: OrganizationUpdate, +) -> OrganizationResponse: + organization = get_organization_entity(db, organization_id) + update_data = payload.model_dump(exclude_unset=True) + + if "document_number" in update_data and update_data["document_number"]: + normalized_document_number = normalize_portuguese_nif( + update_data["document_number"], + ) + existing = organization_repository.get_organization_by_document_number( + db, + normalized_document_number, + ) + if existing and existing.id != organization.id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Organization document number already exists", + ) + update_data["document_number"] = normalized_document_number + elif "document_number" in update_data: + update_data["document_number"] = normalize_portuguese_nif( + update_data["document_number"], + ) + + for field, value in update_data.items(): + setattr(organization, field, value) + + organization.updated_at = datetime.utcnow() + db.commit() + db.refresh(organization) + return OrganizationResponse.model_validate(organization) + + +def delete_organization(db: Session, organization_id: UUID) -> None: + organization = get_organization_entity(db, organization_id) + organization_repository.delete_organization(db, organization) diff --git a/backend/services/user_service.py b/backend/services/user_service.py new file mode 100644 index 0000000..cdfe97c --- /dev/null +++ b/backend/services/user_service.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from fastapi import HTTPException, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from core.security import hash_password +from models.models import User +from repositories import user_repository +from schemas.user_schemas import UserCreate, UserResponse, UserUpdate + + +def _normalize_email(email: str) -> str: + return email.strip().lower() + + +def create_user(db: Session, payload: UserCreate) -> UserResponse: + normalized_email = _normalize_email(payload.email) + if user_repository.get_user_by_email(db, normalized_email): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User email already exists", + ) + + user = User( + name=payload.name, + email=normalized_email, + password_hash=hash_password(payload.password), + ) + try: + created = user_repository.create_user(db, user) + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User email already exists", + ) from exc + + return UserResponse.model_validate(created) + + +def list_users(db: Session) -> list[UserResponse]: + users = user_repository.list_active_users(db) + return [UserResponse.model_validate(item) for item in users] + + +def get_user(db: Session, user_id: UUID) -> UserResponse: + user = get_user_entity(db, user_id) + return UserResponse.model_validate(user) + + +def get_user_entity(db: Session, user_id: UUID) -> User: + user = user_repository.get_active_user_by_id(db, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + return user + + +def update_user(db: Session, user_id: UUID, payload: UserUpdate) -> UserResponse: + user = get_user_entity(db, user_id) + update_data = payload.model_dump(exclude_unset=True) + + if "email" in update_data and update_data["email"]: + normalized_email = _normalize_email(update_data["email"]) + existing = user_repository.get_user_by_email(db, normalized_email) + if existing and existing.id != user.id: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="User email already exists", + ) + user.email = normalized_email + + if "name" in update_data: + user.name = update_data["name"] + + if "password" in update_data and update_data["password"]: + user.password_hash = hash_password(update_data["password"]) + + user.updated_at = datetime.utcnow() + db.commit() + db.refresh(user) + return UserResponse.model_validate(user) + + +def delete_user(db: Session, user_id: UUID) -> None: + user = get_user_entity(db, user_id) + user_repository.delete_user(db, user) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..ffa7694 --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,372 @@ +import unittest +import os + +os.environ["DATABASE_URL"] = "sqlite://" + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from core.database import Base, get_db +from main import app +from models.models import Organization, OrganizationMember, User + + +class ApiCrudTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.engine = create_engine( + "sqlite://", + future=True, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + + @event.listens_for(cls.engine, "connect") + def _enable_foreign_keys(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + cls.TestSessionLocal = sessionmaker( + bind=cls.engine, + autoflush=False, + autocommit=False, + future=True, + ) + + def override_get_db(): + db = cls.TestSessionLocal() + try: + yield db + finally: + db.close() + + app.dependency_overrides[get_db] = override_get_db + cls.client = TestClient(app) + + @classmethod + def tearDownClass(cls): + app.dependency_overrides.clear() + Base.metadata.drop_all(bind=cls.engine) + cls.engine.dispose() + + def setUp(self): + Base.metadata.drop_all(bind=self.engine) + Base.metadata.create_all(bind=self.engine) + + def create_organization(self): + response = self.client.post( + "/api/v1/organizations", + json={ + "name": "PondiFarm", + "documentNumber": "123456789", + "phone": "+351900000000", + "email": "contact@pondifarm.com", + "address": "Porto, Portugal", + }, + ) + self.assertEqual(response.status_code, 201) + return response.json() + + def create_user(self, email="bruno@example.com"): + response = self.client.post( + "/api/v1/users", + json={ + "name": "Bruno Silva", + "email": email, + "password": "password-value", + }, + ) + self.assertEqual(response.status_code, 201) + return response.json() + + def add_member(self, organization_id, user_id, role=None): + payload = {"userId": user_id} + if role is not None: + payload["role"] = role + response = self.client.post( + f"/api/v1/organizations/{organization_id}/members", + json=payload, + ) + self.assertEqual(response.status_code, 201) + return response.json() + + def test_create_organization(self): + payload = self.create_organization() + self.assertEqual(payload["name"], "PondiFarm") + self.assertEqual(payload["documentNumber"], "123456789") + + def test_create_organization_accepts_formatted_portuguese_nif(self): + response = self.client.post( + "/api/v1/organizations", + json={ + "name": "Formatted PondiFarm", + "documentNumber": "123 456 789", + "phone": "+351900000000", + "email": "formatted@pondifarm.com", + "address": "Porto, Portugal", + }, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["documentNumber"], "123456789") + + def test_create_organization_requires_document_number(self): + response = self.client.post( + "/api/v1/organizations", + json={ + "name": "PondiFarm", + "phone": "+351900000000", + "email": "contact@pondifarm.com", + "address": "Porto, Portugal", + }, + ) + self.assertEqual(response.status_code, 400) + + def test_create_organization_rejects_invalid_portuguese_nif(self): + response = self.client.post( + "/api/v1/organizations", + json={ + "name": "Invalid PondiFarm", + "documentNumber": "123456780", + "phone": "+351900000000", + "email": "invalid@pondifarm.com", + "address": "Porto, Portugal", + }, + ) + self.assertEqual(response.status_code, 400) + + def test_list_organizations(self): + self.create_organization() + response = self.client.get("/api/v1/organizations") + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.json()), 1) + + def test_get_organization_by_id_and_404(self): + organization = self.create_organization() + found = self.client.get(f"/api/v1/organizations/{organization['id']}") + self.assertEqual(found.status_code, 200) + missing = self.client.get( + "/api/v1/organizations/00000000-0000-0000-0000-000000000999", + ) + self.assertEqual(missing.status_code, 404) + + def test_update_organization(self): + organization = self.create_organization() + response = self.client.patch( + f"/api/v1/organizations/{organization['id']}", + json={"phone": "+351911111111"}, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["phone"], "+351911111111") + + def test_soft_delete_organization_hides_record_and_blocks_updates(self): + organization = self.create_organization() + user = self.create_user() + self.add_member(organization["id"], user["id"]) + response = self.client.delete(f"/api/v1/organizations/{organization['id']}") + self.assertEqual(response.status_code, 204) + + list_response = self.client.get("/api/v1/organizations") + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.json(), []) + + get_response = self.client.get(f"/api/v1/organizations/{organization['id']}") + self.assertEqual(get_response.status_code, 404) + + patch_response = self.client.patch( + f"/api/v1/organizations/{organization['id']}", + json={"phone": "+351922222222"}, + ) + self.assertEqual(patch_response.status_code, 404) + + with self.TestSessionLocal() as session: + stored = session.get(Organization, organization["id"]) + self.assertIsNotNone(stored.deleted_at) + + def test_create_user_hashes_password_and_hides_password_hash(self): + user = self.create_user() + self.assertNotIn("passwordHash", user) + + with self.TestSessionLocal() as session: + stored = session.get(User, user["id"]) + self.assertIsNotNone(stored) + self.assertNotEqual(stored.password_hash, "password-value") + + def test_duplicate_email_returns_conflict(self): + self.create_user() + response = self.client.post( + "/api/v1/users", + json={ + "name": "Other User", + "email": "bruno@example.com", + "password": "other-password", + }, + ) + self.assertEqual(response.status_code, 409) + + def test_duplicate_email_is_case_insensitive(self): + self.create_user(email="john@email.com") + response = self.client.post( + "/api/v1/users", + json={ + "name": "John Duplicate", + "email": " John@Email.com ", + "password": "other-password", + }, + ) + self.assertEqual(response.status_code, 409) + + def test_duplicate_organization_document_is_normalized(self): + self.create_organization() + response = self.client.post( + "/api/v1/organizations", + json={ + "name": "PondiFarm 2", + "documentNumber": "123 456 789", + "phone": "+351911111111", + "email": "other@pondifarm.com", + "address": "Lisbon, Portugal", + }, + ) + self.assertEqual(response.status_code, 409) + + def test_list_and_update_users_without_password_hash(self): + user = self.create_user() + list_response = self.client.get("/api/v1/users") + self.assertEqual(list_response.status_code, 200) + self.assertNotIn("passwordHash", list_response.json()[0]) + + update_response = self.client.patch( + f"/api/v1/users/{user['id']}", + json={"name": "Bruno Updated", "password": "new-password"}, + ) + self.assertEqual(update_response.status_code, 200) + self.assertEqual(update_response.json()["name"], "Bruno Updated") + self.assertNotIn("passwordHash", update_response.json()) + + def test_soft_delete_user_hides_record_and_blocks_updates(self): + user = self.create_user(email="delete@example.com") + response = self.client.delete(f"/api/v1/users/{user['id']}") + self.assertEqual(response.status_code, 204) + + list_response = self.client.get("/api/v1/users") + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.json(), []) + + get_response = self.client.get(f"/api/v1/users/{user['id']}") + self.assertEqual(get_response.status_code, 404) + + patch_response = self.client.patch( + f"/api/v1/users/{user['id']}", + json={"name": "Should Fail"}, + ) + self.assertEqual(patch_response.status_code, 404) + + with self.TestSessionLocal() as session: + stored = session.get(User, user["id"]) + self.assertIsNotNone(stored.deleted_at) + + def test_list_add_update_soft_delete_members(self): + organization = self.create_organization() + user = self.create_user() + member = self.add_member(organization["id"], user["id"], role=None) + + list_response = self.client.get( + f"/api/v1/organizations/{organization['id']}/members", + ) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.json()[0]["userEmail"], "bruno@example.com") + self.assertEqual(list_response.json()[0]["role"], "viewer") + + update_response = self.client.patch( + f"/api/v1/organizations/{organization['id']}/members/{member['id']}", + json={"role": "viewer"}, + ) + self.assertEqual(update_response.status_code, 200) + self.assertEqual(update_response.json()["role"], "viewer") + + delete_response = self.client.delete( + f"/api/v1/organizations/{organization['id']}/members/{member['id']}", + ) + self.assertEqual(delete_response.status_code, 204) + + list_after_delete = self.client.get( + f"/api/v1/organizations/{organization['id']}/members", + ) + self.assertEqual(list_after_delete.status_code, 200) + self.assertEqual(list_after_delete.json(), []) + + patch_after_delete = self.client.patch( + f"/api/v1/organizations/{organization['id']}/members/{member['id']}", + json={"role": "viewer"}, + ) + self.assertEqual(patch_after_delete.status_code, 404) + + with self.TestSessionLocal() as session: + stored = session.get(OrganizationMember, member["id"]) + self.assertIsNotNone(stored.deleted_at) + + def test_prevent_duplicate_membership(self): + organization = self.create_organization() + user = self.create_user() + self.add_member(organization["id"], user["id"]) + response = self.client.post( + f"/api/v1/organizations/{organization['id']}/members", + json={"userId": user["id"], "role": "viewer"}, + ) + self.assertEqual(response.status_code, 409) + + def test_reactivate_soft_deleted_membership(self): + organization = self.create_organization() + user = self.create_user(email="reactivate@example.com") + member = self.add_member(organization["id"], user["id"]) + + delete_response = self.client.delete( + f"/api/v1/organizations/{organization['id']}/members/{member['id']}", + ) + self.assertEqual(delete_response.status_code, 204) + + recreate_response = self.client.post( + f"/api/v1/organizations/{organization['id']}/members", + json={"userId": user["id"], "role": "viewer"}, + ) + self.assertEqual(recreate_response.status_code, 201) + self.assertEqual(recreate_response.json()["id"], member["id"]) + self.assertEqual(recreate_response.json()["role"], "viewer") + + list_response = self.client.get( + f"/api/v1/organizations/{organization['id']}/members", + ) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(len(list_response.json()), 1) + + def test_member_list_ignores_soft_deleted_users(self): + organization = self.create_organization() + user = self.create_user(email="member-hidden@example.com") + self.add_member(organization["id"], user["id"]) + + delete_user_response = self.client.delete(f"/api/v1/users/{user['id']}") + self.assertEqual(delete_user_response.status_code, 204) + + list_response = self.client.get( + f"/api/v1/organizations/{organization['id']}/members", + ) + self.assertEqual(list_response.status_code, 200) + self.assertEqual(list_response.json(), []) + + def test_reject_invalid_role_and_member_not_found(self): + organization = self.create_organization() + user = self.create_user() + + invalid_role_response = self.client.post( + f"/api/v1/organizations/{organization['id']}/members", + json={"userId": user["id"], "role": "manager"}, + ) + self.assertEqual(invalid_role_response.status_code, 400) + + missing_member_response = self.client.delete( + f"/api/v1/organizations/{organization['id']}/members/00000000-0000-0000-0000-000000000999", + ) + self.assertEqual(missing_member_response.status_code, 404) diff --git a/backend/yolov8n.pt b/backend/yolov8n.pt deleted file mode 100644 index 719e6f1..0000000 --- a/backend/yolov8n.pt +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f59b3d833e2ff32e194b5bb8e08d211dc7c5bdf144b90d2c8412c47ccfc83b36 -size 6549796 From 3ba14d4c9370f537a279f5cb3acf0eb2eaa1b51d Mon Sep 17 00:00:00 2001 From: Tcordeiro Date: Tue, 2 Jun 2026 11:51:52 +0100 Subject: [PATCH 2/2] Potential fix for pull request finding 'CodeQL / Log Injection' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- backend/services/organization_member_service.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/services/organization_member_service.py b/backend/services/organization_member_service.py index 8165b0a..c36bc85 100644 --- a/backend/services/organization_member_service.py +++ b/backend/services/organization_member_service.py @@ -21,6 +21,10 @@ logger = logging.getLogger(__name__) +def _sanitize_for_log(value: object) -> str: + return str(value).replace("\r", "").replace("\n", "") + + def _to_response(member: OrganizationMember) -> OrganizationMemberResponse: return OrganizationMemberResponse.model_validate( { @@ -97,10 +101,12 @@ def create_member( except IntegrityError as exc: db.rollback() error_message = str(exc.orig) if exc.orig else str(exc) + safe_organization_id = _sanitize_for_log(organization_id) + safe_user_id = _sanitize_for_log(payload.user_id) logger.exception( "Failed to create organization member for organization_id=%s user_id=%s", - organization_id, - payload.user_id, + safe_organization_id, + safe_user_id, ) if ( "uq_organization_members_organization_user" in error_message