Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,28 @@ uvicorn backend.app.main:app --reload
The service listens on `http://127.0.0.1:8000` by default. OpenAPI
documentation is available at `http://127.0.0.1:8000/docs`.

### Backend Environment Variables

The backend reads configuration from environment variables or a local `.env`
file. Defaults are suitable for local development.

| Variable | Default | Purpose |
| --- | --- | --- |
| `LIFELINE_DATABASE_URL` | `sqlite+aiosqlite:///./lifeline.db` | SQLAlchemy database connection string |
| `LIFELINE_API_VERSION` | `0.1.0` | Version shown in the OpenAPI schema |
| `LIFELINE_CONTACT_EMAIL` | `ict-support@lifeline.example.edu` | API support contact |
| `LIFELINE_PAGINATION_DEFAULT_LIMIT` | `20` | Default page size for list endpoints |
| `LIFELINE_PAGINATION_MAX_LIMIT` | `100` | Maximum accepted page size |
| `LIFELINE_CORS_ALLOWED_ORIGINS` | Localhost frontend origins | Comma-separated browser origins allowed to call the API |
| `LIFELINE_CORS_ALLOW_CREDENTIALS` | `true` | Allows browser credentials on cross-origin requests |

Example `.env` entry for a deployed frontend:

```bash
LIFELINE_CORS_ALLOWED_ORIGINS=https://ict.example.edu,https://admin.ict.example.edu
LIFELINE_CORS_ALLOW_CREDENTIALS=true
```

### Core Endpoints

| Entity | Base Path | Notes |
Expand Down
6 changes: 4 additions & 2 deletions backend/app/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/token")

get_db_session = get_session


from ..services.sensor_sites import SensorSiteService

Expand Down Expand Up @@ -75,7 +77,7 @@ async def get_current_user(
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_key, algorithms=[ALGORITHM])
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
Expand All @@ -85,4 +87,4 @@ async def get_current_user(
user = await user_repository.get_user_by_username(username)
if user is None:
raise credentials_exception
return user
return user
29 changes: 29 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ def _int_from_env(var_name: str, default: int) -> int:
return default


def _csv_from_env(var_name: str, default: tuple[str, ...]) -> tuple[str, ...]:
"""Read a comma-separated string list from the environment."""

raw_value = os.getenv(var_name)
if raw_value is None:
return default

values = tuple(value.strip() for value in raw_value.split(",") if value.strip())
return values or default


@dataclass
class Settings:
"""
Expand All @@ -51,6 +62,11 @@ class Settings:
pagination_max_limit:
Safety guard to prevent accidental data dumps that could strain shared
infrastructure.
cors_allowed_origins:
Browser origins allowed to call the API. Production deployments should
configure this explicitly for their frontend domains.
cors_allow_credentials:
Whether browsers may include credentials on cross-origin requests.
"""

database_url: str = os.getenv(
Expand All @@ -70,6 +86,19 @@ class Settings:
"LIFELINE_PAGINATION_MAX_LIMIT",
100,
)
cors_allowed_origins: tuple[str, ...] = _csv_from_env(
"LIFELINE_CORS_ALLOWED_ORIGINS",
(
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
),
)
cors_allow_credentials: bool = os.getenv(
"LIFELINE_CORS_ALLOW_CREDENTIALS",
"true",
).lower() in {"1", "true", "yes", "on"}


settings = Settings()
9 changes: 9 additions & 0 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"""

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from .core.config import settings
from .core.logging import configure_logging
Expand Down Expand Up @@ -54,6 +55,14 @@ def create_app() -> FastAPI:
},
)

app.add_middleware(
CORSMiddleware,
allow_origins=list(settings.cors_allowed_origins),
allow_credentials=settings.cors_allow_credentials,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "Accept"],
)

errors.register_exception_handlers(app)

app.include_router(projects_router)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/schemas/alert.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ class AlertRead(BaseSchema):
metric: str
value: float
threshold: float
created_at: datetime
timestamp: datetime
8 changes: 3 additions & 5 deletions backend/app/schemas/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@

from typing import Generic, List, Optional, TypeVar

from pydantic import BaseModel, Field
from pydantic.generics import GenericModel
from pydantic import BaseModel, ConfigDict, Field


class BaseSchema(BaseModel):
"""Base schema that enables ORM compatibility."""

class Config:
orm_mode = True
model_config = ConfigDict(from_attributes=True)


class PaginationQuery(BaseModel):
Expand Down Expand Up @@ -56,7 +54,7 @@ class PaginationMeta(BaseModel):
T = TypeVar("T")


class PaginatedResponse(GenericModel, Generic[T]):
class PaginatedResponse(BaseModel, Generic[T]):
"""
Envelope for paginated API responses.

Expand Down
5 changes: 5 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ aiosqlite>=0.19.0,<1.0.0
alembic>=1.12.0,<2.0.0
pydantic>=2.4.0,<3.0.0
email-validator>=2.0.0,<3.0.0
python-jose[cryptography]>=3.3.0,<4.0.0
passlib[bcrypt]>=1.7.4,<2.0.0
bcrypt>=4.0.0,<4.1.0
python-multipart>=0.0.9,<1.0.0
python-dotenv>=1.0.0,<2.0.0
httpx>=0.25.0,<1.0.0
pytest>=7.4.0,<8.0.0
pytest-asyncio>=0.21.0,<1.0.0
psycopg2-binary>=2.9.9,<3.0.0
GeoAlchemy2>=0.14.0,<1.0.0
shapely>=2.0.0,<3.0.0
32 changes: 29 additions & 3 deletions backend/tests/alert_engine/test_alert_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

from __future__ import annotations

from datetime import datetime
from unittest.mock import AsyncMock

import pytest
Expand All @@ -20,6 +21,15 @@ async def test_create_alert_when_value_exceeds_threshold(
alert_repository: AlertRepository,
) -> None:
alert_service = AlertService(alert_repository)
alert_repository.create.return_value = Alert(
id=1,
sensor_id=1,
metric="temperature",
value=30.0,
threshold=25.0,
timestamp=datetime.utcnow(),
)

alert = await alert_service.create_alert(
sensor_id=1,
metric="temperature",
Expand Down Expand Up @@ -55,12 +65,28 @@ async def test_create_alert_when_value_does_not_exceed_threshold(
async def test_get_alerts_by_sensor_id(alert_repository: AlertRepository) -> None:
alert_service = AlertService(alert_repository)
alerts = [
Alert(sensor_id=1, metric="temperature", value=30.0, threshold=25.0),
Alert(sensor_id=1, metric="humidity", value=80.0, threshold=70.0),
Alert(
id=1,
sensor_id=1,
metric="temperature",
value=30.0,
threshold=25.0,
timestamp=datetime.utcnow(),
),
Alert(
id=2,
sensor_id=1,
metric="humidity",
value=80.0,
threshold=70.0,
timestamp=datetime.utcnow(),
),
]
alert_repository.get_alerts_by_sensor_id.return_value = alerts

result = await alert_service.get_alerts_by_sensor_id(1)

assert result == alerts
assert len(result) == 2
assert result[0].metric == "temperature"
assert result[1].metric == "humidity"
alert_repository.get_alerts_by_sensor_id.assert_called_once_with(1)
25 changes: 25 additions & 0 deletions backend/tests/api/test_cors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""CORS policy tests for the LifeLine-ICT API."""

from __future__ import annotations

import pytest
from httpx import AsyncClient


@pytest.mark.asyncio
async def test_configured_frontend_origin_can_preflight(client: AsyncClient) -> None:
"""Allow configured development frontends to call API routes."""

response = await client.options(
"/api/v1/projects",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "Authorization",
},
)

assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "http://localhost:3000"
assert "GET" in response.headers["access-control-allow-methods"]
assert "Authorization" in response.headers["access-control-allow-headers"]
5 changes: 5 additions & 0 deletions backend/tests/auth/test_auth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ def user_repository() -> UserRepository:
@pytest.mark.asyncio
async def test_create_user(user_repository: UserRepository) -> None:
auth_service = AuthService(user_repository)
user_repository.create.return_value = User(
username="testuser",
hashed_password="hashed-testpassword",
)

user = await auth_service.create_user("testuser", "testpassword")

user_repository.create.assert_called_once()
Expand Down
51 changes: 44 additions & 7 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
import pytest
import pytest_asyncio
from fastapi import FastAPI
from geoalchemy2 import Geography, Geometry
from geoalchemy2.admin.dialects import sqlite as geoalchemy_sqlite
from geoalchemy2.admin.dialects.common import _check_spatial_type
from httpx import ASGITransport, AsyncClient
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
Expand All @@ -19,30 +23,59 @@
from sqlalchemy.pool import StaticPool

from ..app import create_app
from ..app.api.deps import get_db_session
from ..app.api.deps import get_current_user, get_db_session
from ..app.core.database import Base
from ..app.models.user import User


@pytest.fixture(scope="session")
def test_engine() -> AsyncEngine:
"""Create an in-memory SQLite engine for tests."""

return create_async_engine(
engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)

@event.listens_for(engine.sync_engine, "connect")
def register_spatial_functions(dbapi_connection, connection_record) -> None:
dbapi_connection.create_function("GeomFromEWKT", 1, lambda value: value)
dbapi_connection.create_function("AsEWKB", 1, lambda value: value)

return engine


@pytest_asyncio.fixture(scope="session", autouse=True)
async def prepare_database(test_engine: AsyncEngine) -> AsyncIterator[None]:
"""Create all tables before running tests."""

async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
after_create = geoalchemy_sqlite.after_create
before_drop = geoalchemy_sqlite.before_drop
after_drop = geoalchemy_sqlite.after_drop

def after_create_without_spatialite(table, bind, **kw):
table.columns = table.info.pop("_saved_columns")
table.info.pop("_after_create_indexes", None)

for col in table.columns:
if _check_spatial_type(col.type, (Geometry, Geography), bind.dialect):
col.type = col._actual_type
del col._actual_type

geoalchemy_sqlite.after_create = after_create_without_spatialite
geoalchemy_sqlite.before_drop = lambda table, bind, **kw: None
geoalchemy_sqlite.after_drop = lambda table, bind, **kw: None
try:
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
finally:
geoalchemy_sqlite.after_create = after_create
geoalchemy_sqlite.before_drop = before_drop
geoalchemy_sqlite.after_drop = after_drop


@pytest_asyncio.fixture
Expand All @@ -64,7 +97,11 @@ async def app(session: AsyncSession) -> AsyncIterator[FastAPI]:
async def get_test_session() -> AsyncGenerator[AsyncSession, None]:
yield session

async def get_test_user() -> User:
return User(id=1, username="testuser", hashed_password="unused")

app.dependency_overrides[get_db_session] = get_test_session
app.dependency_overrides[get_current_user] = get_test_user
yield app
app.dependency_overrides.clear()

Expand Down
Loading
Loading