diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4cbad74 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.env.secrets diff --git a/.env.config b/.env.config new file mode 100644 index 0000000..2605300 --- /dev/null +++ b/.env.config @@ -0,0 +1,28 @@ +# Document Intelligence +AZURE_API_ENDPOINT=https://fertiscan-computer-vision-service.cognitiveservices.azure.com/ + +# OpenAI +AZURE_OPENAI_ENDPOINT=https://fertiscan-openai.openai.azure.com/ +AZURE_OPENAI_DEPLOYMENT=gpt-4o + +# For CORS purposes +ALLOWED_ORIGINS=["*"] + +# API base path for deployment +API_BASE_PATH= +SWAGGER_PATH=/docs + +# DB +FERTISCAN_SCHEMA=fertiscan_0.0.15 + +# Phoenix API configuration +PHOENIX_ENDPOINT= +OTEL_EXPORTER_OTLP_ENDPOINT= + +# Other +UPLOAD_PATH=./uploads +# LOG_FILENAME=fertiscan.log + +# Azure Storage Configuration +AZURE_STORAGE_DEFAULT_ENDPOINT_PROTOCOL=https +AZURE_STORAGE_ENDPOINT_SUFFIX=core.windows.net diff --git a/.env.secrets.template b/.env.secrets.template new file mode 100644 index 0000000..d33d413 --- /dev/null +++ b/.env.secrets.template @@ -0,0 +1,16 @@ +# Document Intelligence +AZURE_API_KEY= + +# OpenAI +AZURE_OPENAI_KEY= + +# DB +DB_USER= +DB_PASSWORD= +DB_HOST= +DB_PORT= +DB_NAME= + +# Azure Storage Configuration +AZURE_STORAGE_ACCOUNT_NAME= +AZURE_STORAGE_ACCOUNT_KEY= diff --git a/.env.template b/.env.template deleted file mode 100644 index 42eb333..0000000 --- a/.env.template +++ /dev/null @@ -1,26 +0,0 @@ -# Document Intelligence -AZURE_API_ENDPOINT="" -AZURE_API_KEY="" -# OpenAI -AZURE_OPENAI_ENDPOINT="" -AZURE_OPENAI_KEY="" -AZURE_OPENAI_DEPLOYMENT="" - -UPLOAD_PATH='./uploads' -# LOG_FILENAME='fertiscan.log' - -# For CORS purposes -ALLOWED_ORIGINS=[""] - -# API base path for deployment -API_BASE_PATH='/swagger' -SWAGGER_PATH='/docs' - -# Fertiscan storage configuration -FERTISCAN_STORAGE_URL= -FERTISCAN_SCHEMA= -FERTISCAN_DB_URL= - -# Phoenix API configuration -PHOENIX_ENDPOINT= -OTEL_EXPORTER_OTLP_ENDPOINT= diff --git a/.gitignore b/.gitignore index bb130b1..104d703 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,7 @@ celerybeat.pid # Environments .env +.env.secrets .venv env/ venv/ diff --git a/Dockerfile b/Dockerfile index 77bcba9..9e48acd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,30 +7,6 @@ COPY . . RUN pip install --no-cache-dir -r requirements.txt RUN opentelemetry-bootstrap --action=install -ARG ARG_AZURE_API_ENDPOINT -ARG ARG_AZURE_API_KEY -ARG ARG_AZURE_OPENAI_DEPLOYMENT -ARG ARG_AZURE_OPENAI_ENDPOINT -ARG ARG_AZURE_OPENAI_KEY -ARG ARG_FERTISCAN_STORAGE_URL -ARG ARG_FERTISCAN_DB_URL -ARG ARG_FERTISCAN_SCHEMA -ARG ARG_ALLOWED_ORIGINS -ARG ARG_PROMPT_PATH -ARG ARG_UPLOAD_PATH - -ENV AZURE_API_ENDPOINT=${ARG_AZURE_API_ENDPOINT:-your_azure_form_recognizer_endpoint} -ENV AZURE_API_KEY=${ARG_AZURE_API_KEY:-your_azure_form_recognizer_key} -ENV AZURE_OPENAI_DEPLOYMENT=${ARG_AZURE_OPENAI_DEPLOYMENT:-your_azure_openai_deployment} -ENV AZURE_OPENAI_ENDPOINT=${ARG_AZURE_OPENAI_ENDPOINT:-your_azure_openai_endpoint} -ENV AZURE_OPENAI_KEY=${ARG_AZURE_OPENAI_KEY:-your_azure_openai_key} -ENV FERTISCAN_STORAGE_URL=${ARG_FERTISCAN_STORAGE_URL:-your_fertiscan_storage_url} -ENV FERTISCAN_DB_URL=${ARG_FERTISCAN_DB_URL:-your_fertiscan_db_url} -ENV FERTISCAN_SCHEMA=${ARG_FERTISCAN_SCHEMA:-your_fertiscan_schema} -ENV ALLOWED_ORIGINS=${ARG_ALLOWED_ORIGINS:-["http://url.to_frontend/"]} -ENV PROMPT_PATH=${ARG_PROMPT_PATH:-path/to/file} -ENV UPLOAD_PATH=${ARG_UPLOAD_PATH:-path/to/file} - EXPOSE 5000 RUN chown -R 1000:1000 /app diff --git a/README.md b/README.md index b1c706b..a6db2d2 100644 --- a/README.md +++ b/README.md @@ -49,35 +49,27 @@ generation using an [LLM](https://en.wikipedia.org/wiki/Large_language_model). 1. Build the Docker image: ```sh - docker build -t fertiscan-backend \ - --build-arg ARG_AZURE_API_ENDPOINT=your_azure_form_recognizer_endpoint \ - --build-arg ARG_AZURE_API_KEY=your_azure_form_recognizer_key \ - --build-arg ARG_AZURE_OPENAI_DEPLOYMENT=your_azure_openai_deployment \ - --build-arg ARG_AZURE_OPENAI_ENDPOINT=your_azure_openai_endpoint \ - --build-arg ARG_AZURE_OPENAI_KEY=your_azure_openai_key \ - --build-arg ARG_FERTISCAN_STORAGE_URL=your_fertiscan_storage_url \ - --build-arg ARG_FERTISCAN_DB_URL=your_fertiscan_db_url \ - --build-arg ARG_FERTISCAN_SCHEMA=your_fertiscan_schema \ - --build-arg ARG_ALLOWED_ORIGINS=["http://url.to_frontend/"] \ - --build-arg OTEL_EXPORTER_OTLP_ENDPOINT=your_phoenix_endpoint \ - --build-arg ARG_PROMPT_PATH=path/to/file \ - --build-arg ARG_UPLOAD_PATH=path/to/file \ - . + docker build -t fertiscan-backend . ``` 2. Run the Docker container: ```sh - docker run -p 5000:5000 fertiscan-backend + docker run -p 5000:5000 --env-file .env.secrets fertiscan-backend ``` #### Docker Compose -1. Create a `.env` file from [.env.template](./.env.template). Include the - following environment variables: +1. Create a `.env.secrets` file from + [.env.secrets.template](./.env.secrets.template). Include the following + environment variables: ```ini - FERTISCAN_DB_URL=postgresql://postgres:postgres@postgres:5432/fertiscan + DB_USER=postgres + DB_PASSWORD=postgres + DB_HOST=postgres + DB_PORT=5432 + DB_NAME=fertiscan BB_URL=bytebase_url BB_SERVICE_ACCOUNT=your-bytebase-sa@service.bytebase.com BB_SERVICE_KEY=your-bytebase-sa-key @@ -111,7 +103,7 @@ following details: ### Environment Variables -Create a `.env` file from [.env.template](./.env.template). +Create a `.env.secrets` file from [.env.secrets.template](./.env.secrets.template). ```ini AZURE_API_ENDPOINT=your_azure_form_recognizer_endpoint @@ -120,7 +112,11 @@ AZURE_OPENAI_API_ENDPOINT=your_azure_openai_endpoint AZURE_OPENAI_API_KEY=your_azure_openai_key AZURE_OPENAI_DEPLOYMENT=your_azure_openai_deployment -FERTISCAN_DB_URL=your_fertiscan_db_url +DB_USER=your_db_user +DB_PASSWORD=your_db_password +DB_HOST=your_db_host +DB_PORT=your_db_port +DB_NAME=your_db_name FERTISCAN_SCHEMA=your_fertiscan_schema UPLOAD_PATH=path/to/file diff --git a/app/config.py b/app/config.py index d87f465..65d23ac 100644 --- a/app/config.py +++ b/app/config.py @@ -1,13 +1,11 @@ from contextlib import asynccontextmanager +from http import HTTPStatus from dotenv import load_dotenv -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI, Request +from fastapi.logger import logger from fastapi.middleware.cors import CORSMiddleware -from pipeline import GPT, OCR -from psycopg_pool import ConnectionPool -from pydantic import Field, PostgresDsn -from pydantic_settings import BaseSettings - +from fastapi.responses import JSONResponse from opentelemetry import trace from opentelemetry._logs import set_logger_provider from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter @@ -17,18 +15,32 @@ from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor +from pipeline import GPT, OCR +from psycopg.conninfo import make_conninfo +from psycopg_pool import ConnectionPool +from pydantic import Field, computed_field +from pydantic_settings import BaseSettings -from fastapi.logger import logger +from app.exceptions import log_error + +load_dotenv(".env.secrets") +load_dotenv(".env.config") -load_dotenv() class Settings(BaseSettings): api_endpoint: str = Field(alias="azure_api_endpoint") api_key: str = Field(alias="azure_api_key") base_path: str = Field("", alias="api_base_path") - fertiscan_db_url: PostgresDsn + db_user: str + db_password: str + db_host: str + db_port: int + db_name: str fertiscan_schema: str - fertiscan_storage_url: str + azure_storage_account_name: str + azure_storage_account_key: str + azure_storage_default_endpoint_protocol: str + azure_storage_endpoint_suffix: str openai_api_deployment: str = Field(alias="azure_openai_deployment") openai_api_endpoint: str = Field(alias="azure_openai_endpoint") openai_api_key: str = Field(alias="azure_openai_key") @@ -38,9 +50,31 @@ class Settings(BaseSettings): allowed_origins: list[str] otel_exporter_otlp_endpoint: str = Field(alias="otel_exporter_otlp_endpoint") + @computed_field + @property + def azure_storage_connection_string(self) -> str: + return ( + f"DefaultEndpointsProtocol={self.azure_storage_default_endpoint_protocol};" + f"AccountName={self.azure_storage_account_name};" + f"AccountKey={self.azure_storage_account_key};" + f"EndpointSuffix={self.azure_storage_endpoint_suffix}" + ) + + @computed_field + @property + def db_conn_info(self) -> str: + return make_conninfo( + user=self.db_user, + password=self.db_password, + host=self.db_host, + port=self.db_port, + dbname=self.db_name, + ) + + @asynccontextmanager async def lifespan(app: FastAPI): - settings = app.state.settings + settings: Settings = app.settings app.pool.open() resource = Resource.create( { @@ -52,13 +86,21 @@ async def lifespan(app: FastAPI): tracer_provider = TracerProvider(resource=resource) trace.set_tracer_provider(tracer_provider) tracer_provider.add_span_processor( - BatchSpanProcessor(OTLPSpanExporter(endpoint=settings.otel_exporter_otlp_endpoint, insecure=True)) + BatchSpanProcessor( + OTLPSpanExporter( + endpoint=settings.otel_exporter_otlp_endpoint, insecure=True + ) + ) ) # Logging setup logger_provider = LoggerProvider(resource=resource) set_logger_provider(logger_provider) logger_provider.add_log_record_processor( - BatchLogRecordProcessor(OTLPLogExporter(endpoint=settings.otel_exporter_otlp_endpoint, insecure=True)) + BatchLogRecordProcessor( + OTLPLogExporter( + endpoint=settings.otel_exporter_otlp_endpoint, insecure=True + ) + ) ) handler = LoggingHandler(logger_provider=logger_provider) logger.addHandler(handler) @@ -68,11 +110,11 @@ async def lifespan(app: FastAPI): tracer_provider.shutdown() -def create_app(settings: Settings): +def create_app(settings: Settings, router: APIRouter, lifespan=None): app = FastAPI( lifespan=lifespan, docs_url=settings.swagger_path, root_path=settings.base_path ) - app.state.settings = settings + app.settings = settings app.add_middleware( CORSMiddleware, @@ -84,7 +126,7 @@ def create_app(settings: Settings): pool = ConnectionPool( open=False, - conninfo=settings.fertiscan_db_url.unicode_string(), + conninfo=settings.db_conn_info, kwargs={"options": f"-c search_path={settings.fertiscan_schema},public"}, ) app.pool = pool @@ -100,4 +142,13 @@ def create_app(settings: Settings): ) app.gpt = gpt + app.include_router(router) + + @app.exception_handler(Exception) + async def global_exception_handler(_: Request, e: Exception): + log_error(e) + return JSONResponse( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, content=str(e) + ) + return app diff --git a/app/dependencies.py b/app/dependencies.py index f82e0da..62df042 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,4 +1,3 @@ -from functools import lru_cache from http import HTTPStatus from fastapi import Depends, File, HTTPException, Request, UploadFile @@ -7,7 +6,6 @@ from psycopg_pool import ConnectionPool from app.config import Settings - from app.controllers.users import sign_in from app.exceptions import UserNotFoundError from app.models.users import User @@ -15,36 +13,23 @@ auth = HTTPBasic() -@lru_cache -def get_settings(): - return Settings() +def get_settings(request: Request) -> Settings: + return request.app.settings def get_connection_pool(request: Request) -> ConnectionPool: - """ - Returns the app's connection pool. - """ return request.app.pool def get_ocr(request: Request) -> OCR: - """ - Returns the app's OCR instance. - """ return request.app.ocr def get_gpt(request: Request) -> GPT: - """ - Returns the app's GPT instance. - """ return request.app.gpt def authenticate_user(credentials: HTTPBasicCredentials = Depends(auth)): - """ - Authenticates a user. - """ if not credentials.username: raise HTTPException( status_code=HTTPStatus.BAD_REQUEST, detail="Missing email address!" @@ -57,9 +42,6 @@ async def fetch_user( auth_user: User = Depends(authenticate_user), cp: ConnectionPool = Depends(get_connection_pool), ) -> User: - """ - Fetches the authenticated user's info from db. - """ try: return await sign_in(cp, auth_user) except UserNotFoundError: @@ -69,9 +51,6 @@ async def fetch_user( def validate_files(files: list[UploadFile] = File(..., min_length=1)): - """ - Validates uploaded files. - """ for f in files: if f.size == 0: raise HTTPException( diff --git a/app/main.py b/app/main.py index 82d76c1..2573b61 100644 --- a/app/main.py +++ b/app/main.py @@ -1,152 +1,4 @@ -from http import HTTPStatus -from typing import Annotated -from uuid import UUID +from app.config import Settings, create_app, lifespan +from app.routes import router -from fastapi import Depends, Form, HTTPException, Request, UploadFile -from fastapi.responses import JSONResponse, RedirectResponse -from pipeline import GPT, OCR -from psycopg_pool import ConnectionPool - -from app.config import Settings, create_app -from app.controllers.data_extraction import extract_data -from app.controllers.inspections import ( - create_inspection, - delete_inspection, - read_all_inspections, - read_inspection, - update_inspection, -) -from app.controllers.users import sign_up -from app.dependencies import ( - authenticate_user, - fetch_user, - get_connection_pool, - get_gpt, - get_ocr, - get_settings, - validate_files, -) -from app.exceptions import InspectionNotFoundError, UserConflictError, log_error -from app.models.inspections import ( - DeletedInspection, - Inspection, - InspectionData, - InspectionUpdate, -) -from app.models.label_data import LabelData -from app.models.monitoring import HealthStatus -from app.models.users import User -from app.sanitization import custom_secure_filename - -app = create_app(get_settings()) - - -@app.exception_handler(Exception) -async def global_exception_handler(_: Request, e: Exception): - log_error(e) - return JSONResponse(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, content=str(e)) - - -@app.get("/", tags=["Home"]) -async def home(): - return RedirectResponse(url=app.docs_url) - - -@app.get("/health", tags=["Monitoring"], response_model=HealthStatus) -async def health_check(): - return HealthStatus() - - -@app.post("/analyze", response_model=LabelData, tags=["Pipeline"]) -async def analyze_document( - ocr: Annotated[OCR, Depends(get_ocr)], - gpt: Annotated[GPT, Depends(get_gpt)], - settings: Annotated[Settings, Depends(get_settings)], - files: Annotated[list[UploadFile], Depends(validate_files)], -): - file_dict = {custom_secure_filename(f.filename): f.file for f in files} - return extract_data(file_dict, ocr, gpt, settings.upload_folder) - - -@app.post("/signup", tags=["Users"], status_code=201, response_model=User) -async def signup( - cp: Annotated[ConnectionPool, Depends(get_connection_pool)], - user: Annotated[User, Depends(authenticate_user)], - settings: Annotated[Settings, Depends(get_settings)], -): - try: - return await sign_up(cp, user, settings.fertiscan_storage_url) - except UserConflictError: - raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="User exists!") - - -@app.post("/login", tags=["Users"], status_code=200, response_model=User) -async def login(user: User = Depends(fetch_user)): - return user - - -@app.get("/inspections", tags=["Inspections"], response_model=list[InspectionData]) -async def get_inspections( - cp: Annotated[ConnectionPool, Depends(get_connection_pool)], - user: User = Depends(fetch_user), -): - return await read_all_inspections(cp, user) - - -@app.get("/inspections/{id}", tags=["Inspections"], response_model=Inspection) -async def get_inspection( - cp: Annotated[ConnectionPool, Depends(get_connection_pool)], - user: Annotated[User, Depends(fetch_user)], - id: UUID, -): - try: - return await read_inspection(cp, user, id) - except InspectionNotFoundError: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found" - ) - - -@app.post("/inspections", tags=["Inspections"], response_model=Inspection) -async def post_inspection( - cp: Annotated[ConnectionPool, Depends(get_connection_pool)], - user: Annotated[User, Depends(fetch_user)], - settings: Annotated[Settings, Depends(get_settings)], - label_data: Annotated[LabelData, Form(...)], - files: Annotated[list[UploadFile], Depends(validate_files)], -): - # Note: later on, we might handle label images as their own domain - label_images = [await f.read() for f in files] - conn_string = settings.fertiscan_storage_url - return await create_inspection(cp, user, label_data, label_images, conn_string) - - -@app.put("/inspections/{id}", tags=["Inspections"], response_model=Inspection) -async def put_inspection( - cp: Annotated[ConnectionPool, Depends(get_connection_pool)], - user: Annotated[User, Depends(fetch_user)], - id: UUID, - inspection: InspectionUpdate, -): - try: - return await update_inspection(cp, user, id, inspection) - except InspectionNotFoundError: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found" - ) - - -@app.delete("/inspections/{id}", tags=["Inspections"], response_model=DeletedInspection) -async def delete_inspection_( - cp: Annotated[ConnectionPool, Depends(get_connection_pool)], - user: Annotated[User, Depends(fetch_user)], - settings: Annotated[Settings, Depends(get_settings)], - id: UUID, -): - try: - conn_string = settings.fertiscan_storage_url - return await delete_inspection(cp, user, id, conn_string) - except InspectionNotFoundError: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found" - ) +app = create_app(Settings(), router, lifespan) diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..4216341 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,147 @@ +from http import HTTPStatus +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Form, HTTPException, Request, UploadFile +from fastapi.responses import RedirectResponse +from pipeline import GPT, OCR +from psycopg_pool import ConnectionPool + +from app.config import Settings +from app.controllers.data_extraction import extract_data +from app.controllers.inspections import ( + create_inspection, + delete_inspection, + read_all_inspections, + read_inspection, + update_inspection, +) +from app.controllers.users import sign_up +from app.dependencies import ( + authenticate_user, + fetch_user, + get_connection_pool, + get_gpt, + get_ocr, + get_settings, + validate_files, +) +from app.exceptions import InspectionNotFoundError, UserConflictError +from app.models.inspections import ( + DeletedInspection, + Inspection, + InspectionData, + InspectionUpdate, +) +from app.models.label_data import LabelData +from app.models.monitoring import HealthStatus +from app.models.users import User +from app.sanitization import custom_secure_filename + +router = APIRouter() + + +@router.get("/", tags=["Home"]) +async def home(request: Request): + return RedirectResponse(url=request.app.docs_url) + + +@router.get("/health", tags=["Monitoring"], response_model=HealthStatus) +async def health_check(): + return HealthStatus() + + +@router.post("/analyze", response_model=LabelData, tags=["Pipeline"]) +async def analyze_document( + ocr: Annotated[OCR, Depends(get_ocr)], + gpt: Annotated[GPT, Depends(get_gpt)], + settings: Annotated[Settings, Depends(get_settings)], + files: Annotated[list[UploadFile], Depends(validate_files)], +): + file_dict = {custom_secure_filename(f.filename): f.file for f in files} + return extract_data(file_dict, ocr, gpt, settings.upload_folder) + + +@router.post("/signup", tags=["Users"], status_code=201, response_model=User) +async def signup( + cp: Annotated[ConnectionPool, Depends(get_connection_pool)], + user: Annotated[User, Depends(authenticate_user)], + settings: Annotated[Settings, Depends(get_settings)], +): + try: + return await sign_up(cp, user, settings.azure_storage_connection_string) + except UserConflictError: + raise HTTPException(status_code=HTTPStatus.CONFLICT, detail="User exists!") + + +@router.post("/login", tags=["Users"], status_code=200, response_model=User) +async def login(user: User = Depends(fetch_user)): + return user + + +@router.get("/inspections", tags=["Inspections"], response_model=list[InspectionData]) +async def get_inspections( + cp: Annotated[ConnectionPool, Depends(get_connection_pool)], + user: User = Depends(fetch_user), +): + return await read_all_inspections(cp, user) + + +@router.get("/inspections/{id}", tags=["Inspections"], response_model=Inspection) +async def get_inspection( + cp: Annotated[ConnectionPool, Depends(get_connection_pool)], + user: Annotated[User, Depends(fetch_user)], + id: UUID, +): + try: + return await read_inspection(cp, user, id) + except InspectionNotFoundError: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found" + ) + + +@router.post("/inspections", tags=["Inspections"], response_model=Inspection) +async def post_inspection( + cp: Annotated[ConnectionPool, Depends(get_connection_pool)], + user: Annotated[User, Depends(fetch_user)], + settings: Annotated[Settings, Depends(get_settings)], + label_data: Annotated[LabelData, Form(...)], + files: Annotated[list[UploadFile], Depends(validate_files)], +): + label_images = [await f.read() for f in files] + conn_string = settings.azure_storage_connection_string + return await create_inspection(cp, user, label_data, label_images, conn_string) + + +@router.put("/inspections/{id}", tags=["Inspections"], response_model=Inspection) +async def put_inspection( + cp: Annotated[ConnectionPool, Depends(get_connection_pool)], + user: Annotated[User, Depends(fetch_user)], + id: UUID, + inspection: InspectionUpdate, +): + try: + return await update_inspection(cp, user, id, inspection) + except InspectionNotFoundError: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found" + ) + + +@router.delete( + "/inspections/{id}", tags=["Inspections"], response_model=DeletedInspection +) +async def delete_inspection_( + cp: Annotated[ConnectionPool, Depends(get_connection_pool)], + user: Annotated[User, Depends(fetch_user)], + settings: Annotated[Settings, Depends(get_settings)], + id: UUID, +): + try: + conn_string = settings.azure_storage_connection_string + return await delete_inspection(cp, user, id, conn_string) + except InspectionNotFoundError: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Inspection not found" + ) diff --git a/docker-compose.yml b/docker-compose.yml index 777d94e..a08acb9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,7 +35,7 @@ services: container_name: backend restart: always env_file: - - .env + - .env.secrets environment: - OTEL_EXPORTER_OTLP_ENDPOINT=http://alloy:4317 - OTEL_EXPORTER_OTLP_INSECURE=true @@ -76,7 +76,7 @@ services: - INSTANCE_ID=${BB_INSTANCE_ID} - DATABASE_ID=${BB_DATABASE_ID} env_file: - - .env + - .env.secrets ports: - "5432:5432" healthcheck: diff --git a/tests/__init__.py b/tests/__init__.py index 7d3cadc..1301f24 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,9 +1,17 @@ -import requests +from app.config import Settings, create_app +from app.routes import router -def curl_file(url:str, path: str): # pragma: no cover - """ - Pull a file from an URL and save its content. - """ - data = requests.get(url).content - with open(path, 'wb') as handler: - handler.write(data) +test_settings = Settings( + azure_api_key="test_api_key", + db_user="test_user", + db_password="test_password", + db_host="test_host", + db_port=5432, + db_name="test_db", + azure_storage_account_name="test_account_name", + azure_storage_account_key="test_account_key", + azure_openai_key="test_openai_key", +) + + +app = create_app(test_settings, router) diff --git a/tests/test_app.py b/tests/test_app.py index 82290c5..954420d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -18,10 +18,11 @@ get_settings, ) from app.exceptions import InspectionNotFoundError, UserConflictError, UserNotFoundError -from app.main import app + from app.models.inspections import DeletedInspection, Inspection, InspectionData from app.models.label_data import LabelData from app.models.users import User +from tests import app class TestAPIMonitoring(unittest.TestCase): @@ -45,7 +46,7 @@ def override_service_dep(): app.dependency_overrides[get_ocr] = override_service_dep app.dependency_overrides[get_gpt] = override_service_dep - @patch("app.main.extract_data") + @patch("app.routes.extract_data") def test_analyze_document(self, mock_extract_data): mock_inspection_data = { "company_name": "Test Company", @@ -79,7 +80,7 @@ def test_analyze_document(self, mock_extract_data): mock_inspection.registration_number, ) - @patch("app.main.extract_data") + @patch("app.routes.extract_data") def test_analyze_empty_file(self, mock_extract_data): """Test analyze_document with an empty file that triggers ResponseValidationError""" mock_extract_data.return_value = None @@ -87,7 +88,7 @@ def test_analyze_empty_file(self, mock_extract_data): response = self.client.post("/analyze", files=files) self.assertEqual(response.status_code, 422) - @patch("app.main.extract_data") + @patch("app.routes.extract_data") def test_analyze_file_list_with_empty_files(self, mock_extract_data): """Test analyze_document with a file list containing empty files""" mock_inspection_data = { @@ -104,7 +105,7 @@ def test_analyze_file_list_with_empty_files(self, mock_extract_data): response = self.client.post("/analyze", files=files) self.assertEqual(response.status_code, 422) - @patch("app.main.extract_data") + @patch("app.routes.extract_data") def test_analyze_empty_file_list(self, mock_extract_data): """Test analyze_document with an empty file list""" mock_extract_data.return_value = None @@ -132,20 +133,20 @@ def override_dep(): app.dependency_overrides[get_settings] = override_dep app.dependency_overrides[fetch_user] = lambda: self.test_user - @patch("app.main.sign_up") + @patch("app.routes.sign_up") def test_signup(self, mock_sign_up): mock_sign_up.return_value = self.test_user response = self.client.post("/signup", json={"username": "test_user"}) self.assertEqual(response.status_code, 201) User.model_validate(response.json()) - @patch("app.main.sign_up") + @patch("app.routes.sign_up") def test_signup_existing_user(self, mock_sign_up): mock_sign_up.side_effect = UserConflictError() response = self.client.post("/signup", json={"username": "test_user"}) self.assertEqual(response.status_code, 409) - @patch("app.main.sign_up") + @patch("app.routes.sign_up") def test_signup_bad_authentication(self, _): del app.dependency_overrides[authenticate_user] # Test with no authentication @@ -161,7 +162,7 @@ def test_signup_bad_authentication(self, _): ) self.assertEqual(response.status_code, 400) - @patch("app.main.sign_up") + @patch("app.routes.sign_up") def test_signup_authentication_success(self, mock_sign_up): del app.dependency_overrides[authenticate_user] mock_sign_up.return_value = self.test_user @@ -329,7 +330,7 @@ def setUp(self) -> None: ("files", ("image2.png", BytesIO(b"fake_image_data_2"), "image/png")), ] - @patch("app.main.read_all_inspections") + @patch("app.routes.read_all_inspections") def test_get_inspections(self, mock_read_all_inspections): mock_read_all_inspections.return_value = self.mock_inspection_data response = self.client.get("/inspections") @@ -341,14 +342,14 @@ def test_get_inspections_unauthenticated(self): response = self.client.get("/inspections") self.assertEqual(response.status_code, 401) - @patch("app.main.read_inspection") + @patch("app.routes.read_inspection") def test_get_inspection(self, mock_read_inspection): mock_read_inspection.return_value = self.mock_inspection response = self.client.get(f"/inspections/{uuid.uuid4()}") self.assertEqual(response.status_code, 200) Inspection.model_validate(response.json()) - @patch("app.main.read_inspection") + @patch("app.routes.read_inspection") def test_get_inspection_not_found(self, mock_read_inspection): mock_read_inspection.side_effect = InspectionNotFoundError() response = self.client.get(f"/inspections/{uuid.uuid4()}") @@ -359,7 +360,7 @@ def test_get_inspection_unauthenticated(self): response = self.client.get(f"/inspections/{uuid.uuid4()}") self.assertEqual(response.status_code, 401) - @patch("app.main.create_inspection") + @patch("app.routes.create_inspection") def test_create_inspection(self, mock_create_inspection): mock_create_inspection.return_value = self.mock_inspection response = self.client.post( @@ -370,7 +371,7 @@ def test_create_inspection(self, mock_create_inspection): self.assertEqual(response.status_code, 200) Inspection.model_validate(response.json()) - @patch("app.main.create_inspection") + @patch("app.routes.create_inspection") def test_create_inspection_empty_files(self, mock_create_inspection): response = self.client.post("/inspections") self.assertEqual(response.status_code, 422) @@ -384,7 +385,7 @@ def test_create_inspection_unauthenticated(self): ) self.assertEqual(response.status_code, 401) - @patch("app.main.delete_inspection") + @patch("app.routes.delete_inspection") def test_delete_inspection(self, mock_delete_inspection): mock_deleted_inspection = DeletedInspection(id=uuid.uuid4()) mock_delete_inspection.return_value = mock_deleted_inspection @@ -392,7 +393,7 @@ def test_delete_inspection(self, mock_delete_inspection): self.assertEqual(response.status_code, 200) DeletedInspection.model_validate(response.json()) - @patch("app.main.delete_inspection") + @patch("app.routes.delete_inspection") def test_delete_inspection_not_found(self, mock_delete_inspection): mock_delete_inspection.side_effect = InspectionNotFoundError() response = self.client.delete(f"/inspections/{uuid.uuid4()}") @@ -403,7 +404,7 @@ def test_delete_inspection_unauthenticated(self): response = self.client.delete(f"/inspections/{uuid.uuid4()}") self.assertEqual(response.status_code, 401) - @patch("app.main.update_inspection") + @patch("app.routes.update_inspection") def test_update_inspection(self, mock_update_inspection): return_data = self.sample_inspection_dict.copy() return_data["inspection_id"] = uuid.uuid4() @@ -421,7 +422,7 @@ def test_update_inspection(self, mock_update_inspection): self.assertEqual(inspection_response.verified, False) self.assertEqual(inspection_response.product.registration_number, "2224256A") - @patch("app.main.update_inspection") + @patch("app.routes.update_inspection") def test_update_inspection_not_found(self, mock_update_inspection): mock_update_inspection.side_effect = InspectionNotFoundError() inspection_id = uuid.uuid4() @@ -447,91 +448,3 @@ def test_update_inspection_invalid_data(self): f"/inspections/{inspection_id}", json={"verified": True} ) self.assertEqual(response.status_code, 422) - - -# class TestAPIUpdateInspection(unittest.TestCase): -# def setUp(self) -> None: -# self.client = TestClient(app) - -# self.test_user = User(username="test_user", id=uuid.uuid4()) - -# app.dependency_overrides.clear() -# app.dependency_overrides[get_connection_pool] = lambda: Mock() -# app.dependency_overrides[fetch_user] = lambda: self.test_user - -# self.update_data = { -# "inspection_comment": "string", -# "verified": False, -# "company": {}, -# "manufacturer": {}, -# "product": { -# "name": "string", -# "label_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", -# "registration_number": "2536456V", -# "lot_number": "string", -# "metrics": { -# "weight": [], -# "volume": {"edited": False}, -# "density": {"edited": False}, -# }, -# "npk": "string", -# "warranty": "string", -# "n": 0, -# "p": 0, -# "k": 0, -# }, -# "cautions": {"en": [], "fr": []}, -# "instructions": {"en": [], "fr": []}, -# "guaranteed_analysis": { -# "title": {"en": "string", "fr": "string"}, -# "is_minimal": False, -# "en": [], -# "fr": [], -# }, -# } - -# return_data = self.update_data.copy() -# return_data["inspection_id"] = uuid.uuid4() -# self.return_inspection = Inspection.model_validate(return_data) - -# @patch("app.main.update_inspection") -# def test_update_inspection(self, mock_update_inspection): -# mock_update_inspection.return_value = self.return_inspection -# inspection_id = uuid.uuid4() - -# response = self.client.put( -# f"/inspections/{inspection_id}", json=self.update_data -# ) -# self.assertEqual(response.status_code, 200) - -# inspection_response = Inspection.model_validate(response.json()) -# self.assertEqual(inspection_response.inspection_comment, "string") -# self.assertEqual(inspection_response.verified, False) -# self.assertEqual(inspection_response.product.registration_number, "2536456V") - -# @patch("app.main.update_inspection") -# def test_update_inspection_not_found(self, mock_update_inspection): -# mock_update_inspection.side_effect = InspectionNotFoundError() -# inspection_id = uuid.uuid4() - -# response = self.client.put( -# f"/inspections/{inspection_id}", json=self.update_data -# ) -# self.assertEqual(response.status_code, 404) - -# def test_update_inspection_unauthenticated(self): -# del app.dependency_overrides[fetch_user] -# inspection_id = uuid.uuid4() - -# response = self.client.put( -# f"/inspections/{inspection_id}", json=self.update_data -# ) -# self.assertEqual(response.status_code, 401) - -# def test_update_inspection_invalid_data(self): -# inspection_id = uuid.uuid4() - -# response = self.client.put( -# f"/inspections/{inspection_id}", json={"verified": True} -# ) -# self.assertEqual(response.status_code, 422)