Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f2b9373
Merge develop into main: data pipeline + analysis-aware inference
femi23 Apr 15, 2026
1257e7a
feat(api): enforce API key auth with dev bypass, surface is_synthetic…
femi23 Apr 19, 2026
256fbf6
ci: add pytest scaffolding and GitHub Actions workflow
Apr 19, 2026
139ed61
test(models): add UNet and Siamese architecture tests
Godswill-code Apr 19, 2026
0da6c79
docs: add first-time and intermediate contributor issue guides
Goldokpa Apr 19, 2026
ff21090
fix(frontend): correct case-sensitive import paths for Map components
Apr 19, 2026
cf96100
fix(pipeline): remove unnecessary global declaration causing flake8 F824
femi23 Apr 19, 2026
c3d02c1
ci: install system deps before pip install (GDAL, OpenGL)
Apr 19, 2026
f7a7564
ci: remove redundant gdal pip package and simplify system deps
Apr 19, 2026
7c317df
ci: install package in editable mode for pytest
Apr 19, 2026
b8e34ea
feat(data): add dataset, augmentation, and synthetic data modules
Apr 19, 2026
aa643ea
fix(deps): add email-validator for pydantic EmailStr support
Apr 19, 2026
6ac29d1
docs: update Victor's role doc with sprint progress and live CI config
Apr 19, 2026
7492304
Add: SMTP environment variables to .env.example
Presmanes3 May 16, 2026
89d61b2
Add: alert delivery helper functions in db.py
Presmanes3 May 16, 2026
d70356d
Add: alert delivery worker with SMTP, webhook and retry logic
Presmanes3 May 16, 2026
69757a7
Add: GET /alerts/pending endpoint and BackgroundTasks integration
Presmanes3 May 16, 2026
bab0cbb
Test: alert delivery worker and pending endpoint
Presmanes3 May 16, 2026
b27bafe
Merge branch 'develop' into feature/issue-10-alert-delivery-worker
Presmanes3 May 16, 2026
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,13 @@ API_SECRET_KEY=your_secret_key_here

# Database (optional - for later)
DATABASE_URL=postgresql://user:password@localhost:5432/climatevision

# Alert Delivery — SMTP Configuration (optional)
# Leave empty to skip email delivery (alerts will be logged to console instead)
# For development/testing: https://mailtrap.io (free, no real emails sent)
# For production: Gmail App Password, SendGrid, Mailgun, etc.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=alerts@climatevision.dev
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: CI

on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]

jobs:
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgl1

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .

- name: Lint with flake8
run: |
flake8 src/ --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 src/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics

- name: Test with pytest
run: |
pytest tests/ -v --tb=short

frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Type check and build
run: npm run build
28 changes: 27 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,33 @@ We are committed to providing a welcoming and inclusive environment. Please be r

#### First Time Contributors

Look for issues labeled `good first issue` - these are specifically chosen for newcomers.
Look for issues labeled `good first issue` — these are specifically chosen for newcomers.

**Recommended first issues (ready to pick up):**

| Issue | What You'll Learn | Time Estimate |
|-------|-----------------|---------------|
| [#9: Add frontend unit tests](https://github.com/Climate-Vision/ClimateVision/issues/9) | Vitest, React Testing Library, Vite | 2–4 hours |
| [#13: Add Docker Compose](https://github.com/Climate-Vision/ClimateVision/issues/13) | Docker, multi-service orchestration | 3–6 hours |

**How to claim an issue:**
1. Read the issue description and acceptance criteria
2. Comment "I'd like to work on this" — a maintainer will assign you
3. Fork the repo and create a branch: `git checkout -b feature/issue-9-frontend-tests`
4. Open a **draft PR** within 48 hours (even if incomplete) so we can give early feedback

**Need help?** Tag `@Climate-Vision/maintainers` in the issue or open a [Discussion](https://github.com/Climate-Vision/ClimateVision/discussions).

#### Intermediate Contributors

Ready for something meatier? These issues close critical gaps in our production pipeline:

| Issue | Area | Skills You'll Build |
|-------|------|-------------------|
| [#10: Alert delivery worker](https://github.com/Climate-Vision/ClimateVision/issues/10) | Backend | FastAPI BackgroundTasks, SMTP, webhooks |
| [#11: WebSocket real-time updates](https://github.com/Climate-Vision/ClimateVision/issues/11) | Full-stack | FastAPI WebSockets, React hooks, graceful degradation |
| [#12: ONNX Runtime inference](https://github.com/Climate-Vision/ClimateVision/issues/12) | MLOps | ONNX Runtime, PyTorch export, latency benchmarking |
| [#14: Carbon analytics API](https://github.com/Climate-Vision/ClimateVision/issues/14) | Analytics | Feature flags, API schema design, geospatial math |

#### Development Process

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/NewAnalysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import type { AnalysisType } from '../api'
import { predictJson } from '../api'
import { MapBBoxPicker } from '../components/map/MapBBoxPicker'
import { MapBBoxPicker } from '../components/Map/MapBBoxPicker'
import { AnalysisTypeSelector } from '../components/ui/AnalysisTypeSelector'
import { ResultsPanel } from '../components/results/ResultsPanel'
import { ErrorBoundary } from '../components/ui/ErrorBoundary'
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/pages/Upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { CloudUpload, FileText, X, ChevronDown, ChevronUp, Loader2 } from 'lucid
import type { AnalysisType } from '../api'
import { predictUpload } from '../api'
import { AnalysisTypeSelector } from '../components/ui/AnalysisTypeSelector'
import { MapBBoxPicker } from '../components/map/MapBBoxPicker'
import { MapBBoxPicker } from '../components/Map/MapBBoxPicker'
import { ErrorBoundary } from '../components/ui/ErrorBoundary'
import { useToast } from '../contexts/ToastContext'
import { useApp } from '../contexts/AppContext'
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ scikit-learn>=1.0.0

# Geospatial Data Processing
rasterio>=1.3.0
gdal>=3.4.0
geopandas>=0.12.0
shapely>=2.0.0
pyproj>=3.4.0
Expand Down Expand Up @@ -40,6 +39,7 @@ dask[complete]>=2023.1.0
fastapi>=0.95.0
uvicorn[standard]>=0.20.0
pydantic>=2.0.0
email-validator>=2.0.0
python-multipart>=0.0.5

# MLOps (optional)
Expand Down
8 changes: 8 additions & 0 deletions src/climatevision/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ def validate_key(self, api_key: str) -> Optional[dict]:
if not api_key or not api_key.startswith("cv_"):
return None

# Development bypass — allow cv_dev for local testing
if api_key == "cv_dev":
return {
"id": 0,
"name": "Development",
"demo": True,
}

# Check cache first
key_hash = self.hash_key(api_key)
if key_hash in self._key_cache:
Expand Down
105 changes: 96 additions & 9 deletions src/climatevision/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from pydantic import field_validator

from fastapi import FastAPI, File, Form, HTTPException, UploadFile, Header, Query, Depends, Request
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, Header, Query, Depends, Request, BackgroundTasks
from fastapi.responses import RedirectResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
Expand All @@ -39,11 +39,14 @@
get_subscriptions_for_organization,
create_organization_alert,
get_alerts_for_organization,
get_pending_alerts,
acknowledge_alert,
mark_alert_delivered,
)
from climatevision.inference import run_inference_from_file, run_inference_from_gee
from climatevision.workers.alert_delivery import process_alert_delivery
from climatevision.governance import explain_prediction, SHAPExplainer
from climatevision.api.auth import require_api_key

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -412,11 +415,49 @@ def root() -> RedirectResponse:

@app.get("/api/health")
def health() -> dict[str, Any]:
"""Health check endpoint with API information."""
"""Health check endpoint with API information and config validation."""
from climatevision.data.band_mapping import get_model_config

enabled_types = [t for t in SUPPORTED_ANALYSIS_TYPES if t["enabled"]]
config_issues: list[dict[str, Any]] = []

for atype in enabled_types:
name = atype["name"]
try:
cfg = get_model_config(name)
expected_channels = len(atype["bands"])
expected_classes = len(atype["classes"])
if cfg.get("in_channels") != expected_channels:
config_issues.append(
{
"analysis_type": name,
"issue": "in_channels mismatch",
"expected": expected_channels,
"got": cfg.get("in_channels"),
}
)
if cfg.get("num_classes") != expected_classes:
config_issues.append(
{
"analysis_type": name,
"issue": "num_classes mismatch",
"expected": expected_classes,
"got": cfg.get("num_classes"),
}
)
except Exception as exc:
config_issues.append(
{"analysis_type": name, "issue": "config missing", "error": str(exc)}
)

health_status = "ok" if not config_issues else "degraded"

return {
"status": "ok",
"status": health_status,
"version": "0.2.0",
"analysis_types": [t["name"] for t in SUPPORTED_ANALYSIS_TYPES if t["enabled"]],
"analysis_types": [t["name"] for t in enabled_types],
"config_valid": len(config_issues) == 0,
"config_issues": config_issues,
}

@app.get("/api/analysis-types")
Expand Down Expand Up @@ -546,7 +587,10 @@ def get_run(run_id: int) -> dict[str, Any]:
# ===== Prediction Endpoints =====

@app.post("/api/predict")
async def predict_json(body: PredictRequest) -> dict[str, Any]:
async def predict_json(
body: PredictRequest,
org: dict[str, Any] = Depends(require_api_key),
) -> dict[str, Any]:
"""Run prediction using bounding box and date range."""
if body.start_date and body.end_date and body.start_date > body.end_date:
raise HTTPException(status_code=400, detail="start_date must be before end_date")
Expand Down Expand Up @@ -614,6 +658,7 @@ async def predict_json(body: PredictRequest) -> dict[str, Any]:
@app.post("/api/predict/upload")
async def predict_upload(
kind: str = Form(default="upload"),
org: dict[str, Any] = Depends(require_api_key),
analysis_type: str = Form(default="deforestation"),
bbox: str | None = Form(default=None),
start_date: str | None = Form(default=None),
Expand Down Expand Up @@ -810,7 +855,10 @@ async def get_explanation(
# ===== Organization (NGO) Endpoints =====

@app.post("/api/organizations", response_model=OrganizationWithKeyResponse)
def create_org(body: CreateOrganizationRequest) -> dict[str, Any]:
def create_org(
body: CreateOrganizationRequest,
org: dict[str, Any] = Depends(require_api_key),
) -> dict[str, Any]:
"""Register a new organization. Returns API key (save it securely)."""
result = create_organization(
name=body.name,
Expand Down Expand Up @@ -879,6 +927,7 @@ def get_org(org_id: int) -> OrganizationResponse:
def create_org_subscription(
org_id: int,
body: CreateSubscriptionRequest,
org: dict[str, Any] = Depends(require_api_key),
) -> SubscriptionResponse:
"""Create a new region subscription for an organization."""
org = get_organization(org_id)
Expand Down Expand Up @@ -970,9 +1019,41 @@ def list_org_alerts(
for alert in alerts
]

@app.get("/api/organizations/{org_id}/alerts/pending")
def list_pending_alerts(
org_id: int,
limit: int = Query(default=50, le=200),
) -> list[AlertResponse]:
"""List pending (undelivered) alerts for monitoring."""
org = get_organization(org_id)
if not org:
raise HTTPException(status_code=404, detail="Organization not found")

alerts = get_pending_alerts(org_id, limit=limit)

return [
AlertResponse(
id=alert["id"],
organization_id=alert["organization_id"],
alert_type=alert["alert_type"],
severity=alert["severity"],
title=alert["title"],
message=alert["message"],
delivered=bool(alert["delivered"]),
acknowledged=bool(alert["acknowledged"]),
created_at=alert["created_at"],
)
for alert in alerts
]

@app.post("/api/organizations/{org_id}/alerts")
def create_org_alert(org_id: int, body: CreateAlertRequest) -> AlertResponse:
"""Create a new alert for an organization."""
def create_org_alert(
org_id: int,
body: CreateAlertRequest,
background_tasks: BackgroundTasks,
org: dict[str, Any] = Depends(require_api_key),
) -> AlertResponse:
"""Create a new alert for an organization and queue background delivery."""
org = get_organization(org_id)
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
Expand All @@ -988,6 +1069,8 @@ def create_org_alert(org_id: int, body: CreateAlertRequest) -> AlertResponse:
details=body.details,
)

background_tasks.add_task(process_alert_delivery, alert_id)

return AlertResponse(
id=alert_id,
organization_id=org_id,
Expand All @@ -1004,6 +1087,7 @@ def create_org_alert(org_id: int, body: CreateAlertRequest) -> AlertResponse:
def acknowledge_org_alert(
alert_id: int,
acknowledged_by: Optional[str] = None,
org: dict[str, Any] = Depends(require_api_key),
) -> dict[str, Any]:
"""Acknowledge an alert."""
success = acknowledge_alert(alert_id, acknowledged_by)
Expand All @@ -1012,7 +1096,10 @@ def acknowledge_org_alert(
return {"success": True, "alert_id": alert_id}

@app.post("/api/alerts/{alert_id}/deliver")
def mark_alert_as_delivered(alert_id: int) -> dict[str, Any]:
def mark_alert_as_delivered(
alert_id: int,
org: dict[str, Any] = Depends(require_api_key),
) -> dict[str, Any]:
"""Mark an alert as delivered."""
success = mark_alert_delivered(alert_id)
if not success:
Expand Down
Loading
Loading