From 6f440a88aa6aafee27ee57e7abcfb923cc414bd3 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Mon, 16 Mar 2026 23:58:08 -0400 Subject: [PATCH 01/12] Docker - analytics docker files --- analytics_service/Dockerfile | 36 ++++++++++++++++++++++++++ analytics_service/docker-entrypoint.sh | 27 +++++++++++++++++++ analytics_service/requirements.txt | 4 +++ docker-compose.yml | 24 ++++++++++++----- 4 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 analytics_service/Dockerfile create mode 100644 analytics_service/docker-entrypoint.sh create mode 100644 analytics_service/requirements.txt diff --git a/analytics_service/Dockerfile b/analytics_service/Dockerfile new file mode 100644 index 0000000..69b90e7 --- /dev/null +++ b/analytics_service/Dockerfile @@ -0,0 +1,36 @@ +FROM python:3.11-slim AS base + +WORKDIR /app/analytics_service + +COPY requirements.txt . +RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +COPY docker-entrypoint.sh . +RUN chmod +x docker-entrypoint.sh + +ENV PYTHONPATH=/app + +ENTRYPOINT ["./docker-entrypoint.sh"] + +FROM base AS dev + +# COPY requirements-dev.txt . +# RUN pip install --no-cache-dir -r requirements-dev.txt + +ENV ENV=DEV + +# TEST +FROM dev AS test + +# COPY pytest.ini . +COPY tests ./tests/ + +RUN pip install --no-cache-dir pytest-cov +ENV ENV=TEST + +# ---------- production ---------- +FROM base AS prod + +ENV ENV=PROD diff --git a/analytics_service/docker-entrypoint.sh b/analytics_service/docker-entrypoint.sh new file mode 100644 index 0000000..88aea0e --- /dev/null +++ b/analytics_service/docker-entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -e + +echo "ENV = $ENV" + +if [ "$ENV" = "DEV" ]; then + echo "Running dev" + exec uvicorn analytics_service.app.main:app \ + --host 0.0.0.0 \ + --port 8001 \ + --reload +elif [ "$ENV" = "TEST" ]; then + echo "Running tests" + exec pytest /app/analytics_service --rootdir=/app + # exec pytest -v --cov=orders_service +elif [ "$ENV" = "PROD" ]; then + echo "PROD ENVIRONMENT" + exec uvicorn analytics_service.app.main:app \ + --host 0.0.0.0 \ + --port 8001 + exec uvicorn analytics_service.app.main:app \ + --host 0.0.0.0 \ + --port 8001 +else + echo "No env provided - stopped" + exit 1 +fi \ No newline at end of file diff --git a/analytics_service/requirements.txt b/analytics_service/requirements.txt new file mode 100644 index 0000000..ee1b09a --- /dev/null +++ b/analytics_service/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.128.0 +psycopg==3.3.2 +requests==2.32.5 +uvicorn==0.40.0 diff --git a/docker-compose.yml b/docker-compose.yml index f2c0ec5..4c6cd11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,7 +7,7 @@ services: POSTGRES_PASSWORD: ${DATABASE_PSWD} POSTGRES_DB: ${DATABASE_DB} ports: - - "5432:5432" + - "15432:5432" volumes: - postgres_data:/var/lib/postgresql/data healthcheck: @@ -32,6 +32,22 @@ services: db: condition: service_healthy + analytics_service: + build: + context: analytics_service/ + target: dev + dockerfile: ./Dockerfile + restart: on-failure + ports: + - "8001:8001" + environment: + ENV: DEV + # env_file: + # - analytics_service/.env + depends_on: + orders_service: + condition: service_started + # test: test-db: image: postgres:16-bookworm @@ -50,7 +66,6 @@ services: timeout: 5s retries: 5 - orders_service_test: profiles: ["test"] build: @@ -67,8 +82,3 @@ services: volumes: postgres_data: - - # analytics_service: - # build: ./analytics_service - # ports: - # - "8002:8002" From 4bcc4f9383c7dd7f6349766418c0d604196b1a28 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Mon, 16 Mar 2026 23:58:38 -0400 Subject: [PATCH 02/12] analytics service - foundation and base routes running --- analytics_service/app/__init__.py | 0 analytics_service/app/api/__init__.py | 0 analytics_service/app/api/v1/__init.py | 0 analytics_service/app/api/v1/health.py | 11 +++++++++++ analytics_service/app/api/v1/stats.py | 15 +++++++++++++++ analytics_service/app/main.py | 20 ++++++++++++++++++++ orders_service/app/api/v1/orders.py | 1 + 7 files changed, 47 insertions(+) create mode 100644 analytics_service/app/__init__.py create mode 100644 analytics_service/app/api/__init__.py create mode 100644 analytics_service/app/api/v1/__init.py create mode 100644 analytics_service/app/api/v1/health.py create mode 100644 analytics_service/app/api/v1/stats.py create mode 100644 analytics_service/app/main.py diff --git a/analytics_service/app/__init__.py b/analytics_service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/api/__init__.py b/analytics_service/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/api/v1/__init.py b/analytics_service/app/api/v1/__init.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/api/v1/health.py b/analytics_service/app/api/v1/health.py new file mode 100644 index 0000000..cf81a9b --- /dev/null +++ b/analytics_service/app/api/v1/health.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + + +router = APIRouter( + prefix="/health", + tags=["health"], +) + +@router.get("/") +def get_health(): + return {"status": "OK"} diff --git a/analytics_service/app/api/v1/stats.py b/analytics_service/app/api/v1/stats.py new file mode 100644 index 0000000..c85a420 --- /dev/null +++ b/analytics_service/app/api/v1/stats.py @@ -0,0 +1,15 @@ +import requests as r +from typing import Annotated, List +from fastapi import APIRouter, HTTPException + + +router = APIRouter( + prefix="/analytics", + tags=["stats"], +) + +@router.get("/") +def get_total_orders(): + orders = r.get("http://orders_service:8000/orders",timeout=10) + total = len(orders.json()['items']) + return total diff --git a/analytics_service/app/main.py b/analytics_service/app/main.py new file mode 100644 index 0000000..203e640 --- /dev/null +++ b/analytics_service/app/main.py @@ -0,0 +1,20 @@ +from fastapi import FastAPI +from .api.v1 import health, stats + + +def create_app(): + fastapi_app = FastAPI( + title="Analytics Service", + description="Microservice 2", + version="1.0.0", + ) + @fastapi_app.get("/") + async def root(): + return {"message": "analytics_service"} + + fastapi_app.include_router(stats.router) + fastapi_app.include_router(health.router) + + return fastapi_app + +app = create_app() diff --git a/orders_service/app/api/v1/orders.py b/orders_service/app/api/v1/orders.py index a3e8805..af42772 100644 --- a/orders_service/app/api/v1/orders.py +++ b/orders_service/app/api/v1/orders.py @@ -5,6 +5,7 @@ from ...schemas.order import OrderCreate, OrderResponse, OrderUpdate, OrderListResponse from ...dependencies.db import get_db from ...db_logic import crud + router = APIRouter( prefix="/orders", tags=["orders"], From 79ce79f02d9678d26426de6abbdf641c64963228 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Wed, 18 Mar 2026 23:46:17 -0400 Subject: [PATCH 03/12] introduced service layer for analytics service --- analytics_service/app/api/v1/stats.py | 28 ++++++++++++---- .../app/business_logic/analytics_service.py | 20 +++++++++++ .../app/business_logic/metrics_util.py | 33 +++++++++++++++++++ .../app/clients/orders_client.py | 18 ++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) create mode 100644 analytics_service/app/business_logic/analytics_service.py create mode 100644 analytics_service/app/business_logic/metrics_util.py create mode 100644 analytics_service/app/clients/orders_client.py diff --git a/analytics_service/app/api/v1/stats.py b/analytics_service/app/api/v1/stats.py index c85a420..abb66db 100644 --- a/analytics_service/app/api/v1/stats.py +++ b/analytics_service/app/api/v1/stats.py @@ -1,6 +1,6 @@ -import requests as r -from typing import Annotated, List +# from typing import Annotated, List from fastapi import APIRouter, HTTPException +from analytics_service.app.business_logic import analytics_service router = APIRouter( @@ -8,8 +8,22 @@ tags=["stats"], ) -@router.get("/") -def get_total_orders(): - orders = r.get("http://orders_service:8000/orders",timeout=10) - total = len(orders.json()['items']) - return total + +# could also get summary per user/customer +@router.get("/summary") +def get_summary(): + return analytics_service.get_summary() + + + + +# @router.get("/orders") + +# @router.get("/revenue") + +# @router.get("/distribution") + +# @router.get("/dashboard") # return everything + + + diff --git a/analytics_service/app/business_logic/analytics_service.py b/analytics_service/app/business_logic/analytics_service.py new file mode 100644 index 0000000..8e97571 --- /dev/null +++ b/analytics_service/app/business_logic/analytics_service.py @@ -0,0 +1,20 @@ +from analytics_service.app.clients import orders_client +from analytics_service.app.business_logic import metrics_util + + +def get_summary(): + + orders = orders_client.get_orders() + + revenue = metrics_util.calc_total_revenue(orders=orders) + total = metrics_util.calc_total_order_count(orders=orders) + + avg_value = 0 + if total > 0: + avg_value = revenue / total + + return {'total_orders': total, + 'total_revenue': revenue, + "avg_value": avg_value} + + diff --git a/analytics_service/app/business_logic/metrics_util.py b/analytics_service/app/business_logic/metrics_util.py new file mode 100644 index 0000000..3e501e4 --- /dev/null +++ b/analytics_service/app/business_logic/metrics_util.py @@ -0,0 +1,33 @@ + + +# Total Revenue +# Revenue per day +# Revenue per month +# Revenue growth rate + +# Average Order Value (AOV) +# Order Distribution + +# Total Orders +# Orders per day +# Orders per week +# Orders per month +# Orders growth rate + + +# Average Order Value +# Largest Order +# Smallest Order +# Median Order Value + + +def calc_total_order_count(orders: dict) -> int: + total = len(orders['items']) + return total + +def calc_total_revenue(orders: dict) -> float: + profit = sum(order['total'] for order in orders['items']) + return profit + + + diff --git a/analytics_service/app/clients/orders_client.py b/analytics_service/app/clients/orders_client.py new file mode 100644 index 0000000..da428b9 --- /dev/null +++ b/analytics_service/app/clients/orders_client.py @@ -0,0 +1,18 @@ +from cachetools import TTLCache +import requests as r + +cache = TTLCache(maxsize=1, ttl=60) + +def get_orders(): + + if "orders" in cache: + return cache["orders"] + + print("Fetching orders from Orders Service...") + response = r.get("http://orders_service/orders") + response.raise_for_status() + + data = response.json() + cache["orders"] = data + + return data From c26e248892a4283644dbbc726199069d6fceff06 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Thu, 19 Mar 2026 14:40:22 -0400 Subject: [PATCH 04/12] update requirments --- analytics_service/requirements-dev.txt | 5 +++++ analytics_service/requirements.txt | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 analytics_service/requirements-dev.txt diff --git a/analytics_service/requirements-dev.txt b/analytics_service/requirements-dev.txt new file mode 100644 index 0000000..6727264 --- /dev/null +++ b/analytics_service/requirements-dev.txt @@ -0,0 +1,5 @@ +cachetools==7.0.5 +fastapi==0.128.0 +pytest==9.0.2 +requests==2.32.5 +uvicorn==0.40.0 diff --git a/analytics_service/requirements.txt b/analytics_service/requirements.txt index ee1b09a..06343eb 100644 --- a/analytics_service/requirements.txt +++ b/analytics_service/requirements.txt @@ -1,4 +1,4 @@ +cachetools==7.0.5 fastapi==0.128.0 -psycopg==3.3.2 requests==2.32.5 uvicorn==0.40.0 From ed3892a21e61659a81dea3e9b8b56a10a365325e Mon Sep 17 00:00:00 2001 From: wiIliu Date: Thu, 19 Mar 2026 14:40:44 -0400 Subject: [PATCH 05/12] add init files --- analytics_service/app/api/v1/{__init.py => __init__.py} | 0 analytics_service/app/business_logic/__init__.py | 0 analytics_service/app/clients/__init__.py | 0 analytics_service/app/schemas/__init__.py | 0 analytics_service/tests/__init__.py | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename analytics_service/app/api/v1/{__init.py => __init__.py} (100%) create mode 100644 analytics_service/app/business_logic/__init__.py create mode 100644 analytics_service/app/clients/__init__.py create mode 100644 analytics_service/app/schemas/__init__.py create mode 100644 analytics_service/tests/__init__.py diff --git a/analytics_service/app/api/v1/__init.py b/analytics_service/app/api/v1/__init__.py similarity index 100% rename from analytics_service/app/api/v1/__init.py rename to analytics_service/app/api/v1/__init__.py diff --git a/analytics_service/app/business_logic/__init__.py b/analytics_service/app/business_logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/clients/__init__.py b/analytics_service/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/app/schemas/__init__.py b/analytics_service/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/__init__.py b/analytics_service/tests/__init__.py new file mode 100644 index 0000000..e69de29 From ce3d201f6a8c008814052fa222a53d1d8e8e1274 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Thu, 19 Mar 2026 14:41:17 -0400 Subject: [PATCH 06/12] update to include pagination in orders client --- .../app/clients/orders_client.py | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/analytics_service/app/clients/orders_client.py b/analytics_service/app/clients/orders_client.py index da428b9..4b882b6 100644 --- a/analytics_service/app/clients/orders_client.py +++ b/analytics_service/app/clients/orders_client.py @@ -1,18 +1,35 @@ from cachetools import TTLCache +from fastapi import HTTPException import requests as r -cache = TTLCache(maxsize=1, ttl=60) +cache = TTLCache(maxsize=1, ttl=300) -def get_orders(): +ORDERS_URL = "http://orders_service/orders" +PAGE_SIZE = 100 +def get_orders() -> dict: if "orders" in cache: return cache["orders"] print("Fetching orders from Orders Service...") - response = r.get("http://orders_service/orders") - response.raise_for_status() - data = response.json() - cache["orders"] = data + all_items = [] + offset = 0 - return data + try: + while True: + response = r.get(ORDERS_URL, params={"limit": PAGE_SIZE, "offset": offset}) + response.raise_for_status() + data = response.json() + + all_items.extend(data["items"]) + offset += len(data["items"]) + + if offset >= data["total"] or not data["items"]: + break + except r.exceptions.RequestException: + raise HTTPException(status_code=503, detail="Orders service unavailable") + + result = {"items": all_items, "total": len(all_items)} + cache["orders"] = result + return result From 0abe9a8cf8fa3ec74d0bed99605139726f9ea4b1 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Fri, 20 Mar 2026 14:06:43 -0400 Subject: [PATCH 07/12] fill out rest of analytics service methods --- .../app/business_logic/analytics_service.py | 107 ++++++++++++++-- .../app/business_logic/metrics_util.py | 120 +++++++++++++++--- 2 files changed, 198 insertions(+), 29 deletions(-) diff --git a/analytics_service/app/business_logic/analytics_service.py b/analytics_service/app/business_logic/analytics_service.py index 8e97571..04caf67 100644 --- a/analytics_service/app/business_logic/analytics_service.py +++ b/analytics_service/app/business_logic/analytics_service.py @@ -1,20 +1,107 @@ +from datetime import date, datetime from analytics_service.app.clients import orders_client from analytics_service.app.business_logic import metrics_util +from analytics_service.app.schemas.analytics import ( + SummaryResponse, RevenueResponse, OrderStatsResponse, + DistributionResponse, DashboardResponse, +) -def get_summary(): +def _filter_orders(orders: dict, start_date: date | None, end_date: date | None) -> dict: + """Returns a new dict with items filtered by date range. Does not mutate the cache.""" + if not start_date and not end_date: + return orders + filtered = [ + o for o in orders['items'] + if (start_date is None or datetime.fromisoformat(o['created_at']).date() >= start_date) + and (end_date is None or datetime.fromisoformat(o['created_at']).date() <= end_date) + ] + return {'items': filtered, 'total': len(filtered)} - orders = orders_client.get_orders() - revenue = metrics_util.calc_total_revenue(orders=orders) - total = metrics_util.calc_total_order_count(orders=orders) +def get_summary(start_date: date | None = None, end_date: date | None = None) -> SummaryResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + total = metrics_util.calc_total_order_count(orders) + revenue = metrics_util.calc_total_revenue(orders) + return SummaryResponse( + total_orders=total, + total_revenue=revenue, + avg_order_value=revenue / total if total > 0 else 0.0, + ) - avg_value = 0 - if total > 0: - avg_value = revenue / total - return {'total_orders': total, - 'total_revenue': revenue, - "avg_value": avg_value} +def get_revenue_stats(start_date: date | None = None, end_date: date | None = None) -> RevenueResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + per_month = metrics_util.calc_revenue_per_month(orders) + return RevenueResponse( + total_revenue=metrics_util.calc_total_revenue(orders), + per_day=metrics_util.calc_revenue_per_day(orders), + per_week=metrics_util.calc_revenue_per_week(orders), + per_month=per_month, + growth_rate=metrics_util.calc_growth_rate(per_month), + ) +def get_order_stats(start_date: date | None = None, end_date: date | None = None) -> OrderStatsResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + per_month = metrics_util.calc_orders_per_month(orders) + return OrderStatsResponse( + total_orders=metrics_util.calc_total_order_count(orders), + per_day=metrics_util.calc_orders_per_day(orders), + per_week=metrics_util.calc_orders_per_week(orders), + per_month=per_month, + growth_rate=metrics_util.calc_growth_rate(per_month), + ) + + +def get_distribution(start_date: date | None = None, end_date: date | None = None) -> DistributionResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + largest = metrics_util.largest_order_by_revenue(orders) + smallest = metrics_util.smallest_order_by_revenue(orders) + return DistributionResponse( + largest=largest['total'] if largest else 0.0, + smallest=smallest['total'] if smallest else 0.0, + median=metrics_util.median_order_total(orders), + avg_order_value=metrics_util.avg_order_value(orders), + ) + + +def get_dashboard(start_date: date | None = None, end_date: date | None = None) -> DashboardResponse: + orders = _filter_orders(orders_client.get_orders(), start_date, end_date) + + # pre-compute shared values so metrics_util isn't called redundantly + total_orders = metrics_util.calc_total_order_count(orders) + total_revenue = metrics_util.calc_total_revenue(orders) + aov = metrics_util.avg_order_value(orders) + per_revenue_month = metrics_util.calc_revenue_per_month(orders) + per_orders_month = metrics_util.calc_orders_per_month(orders) + largest = metrics_util.largest_order_by_revenue(orders) + smallest = metrics_util.smallest_order_by_revenue(orders) + + return DashboardResponse( + summary=SummaryResponse( + total_orders=total_orders, + total_revenue=total_revenue, + avg_order_value=aov, + ), + revenue=RevenueResponse( + total_revenue=total_revenue, + per_day=metrics_util.calc_revenue_per_day(orders), + per_week=metrics_util.calc_revenue_per_week(orders), + per_month=per_revenue_month, + growth_rate=metrics_util.calc_growth_rate(per_revenue_month), + ), + orders=OrderStatsResponse( + total_orders=total_orders, + per_day=metrics_util.calc_orders_per_day(orders), + per_week=metrics_util.calc_orders_per_week(orders), + per_month=per_orders_month, + growth_rate=metrics_util.calc_growth_rate(per_orders_month), + ), + distribution=DistributionResponse( + largest=largest['total'] if largest else 0.0, + smallest=smallest['total'] if smallest else 0.0, + median=metrics_util.median_order_total(orders), + avg_order_value=aov, + ), + ) \ No newline at end of file diff --git a/analytics_service/app/business_logic/metrics_util.py b/analytics_service/app/business_logic/metrics_util.py index 3e501e4..bb8a036 100644 --- a/analytics_service/app/business_logic/metrics_util.py +++ b/analytics_service/app/business_logic/metrics_util.py @@ -1,33 +1,115 @@ +import statistics +from datetime import datetime +from collections import defaultdict -# Total Revenue -# Revenue per day -# Revenue per month -# Revenue growth rate +### DISTRIBUTION ### -# Average Order Value (AOV) -# Order Distribution +def avg_order_value(orders: dict) -> float: + total = calc_total_order_count(orders) + if total == 0: + return 0.0 + return calc_total_revenue(orders=orders) / total -# Total Orders -# Orders per day -# Orders per week -# Orders per month -# Orders growth rate +def largest_order_by_revenue(orders: dict) -> dict | None: + if not orders['items']: + return None + return max(orders['items'], key=lambda order: order['total']) +def largest_order_by_size(orders: dict) -> dict | None: + if not orders['items']: + return None + return max(orders['items'], key=lambda order: order['count']) -# Average Order Value -# Largest Order -# Smallest Order -# Median Order Value +def smallest_order_by_revenue(orders: dict) -> dict | None: + if not orders['items']: + return None + return min(orders['items'], key=lambda order: order['total']) +def median_order_total(orders: dict) -> float: + totals = [float(order['total']) for order in orders.get('items', []) if 'total' in order] + if not totals: + return 0.0 + return statistics.median(totals) + +# calc_order_value_percentiles — p50/p75/p90 via statistics.quantiles + + +### ORDER COUNTS ### def calc_total_order_count(orders: dict) -> int: - total = len(orders['items']) - return total + return len(orders['items']) + +def calc_orders_per_day(orders: dict) -> dict[str, int]: + day_totals = defaultdict(int) + for order in orders['items']: + date = datetime.fromisoformat(order['created_at']).date().isoformat() + day_totals[date] += 1 + return dict(sorted(day_totals.items())) + +def calc_orders_per_week(orders: dict) -> dict[str, int]: + week_totals = defaultdict(int) + for order in orders['items']: + dt = datetime.fromisoformat(order['created_at']) + week_key = f"{dt.year}-W{dt.isocalendar().week:02d}" + week_totals[week_key] += 1 + return dict(sorted(week_totals.items())) + +def calc_orders_per_month(orders: dict) -> dict[str, int]: + month_totals = defaultdict(int) + for order in orders['items']: + month = datetime.fromisoformat(order['created_at']).strftime("%Y-%m") + month_totals[month] += 1 + return dict(sorted(month_totals.items())) + +def calc_orders_per_product(orders: dict) -> dict[str, int]: + product_totals = defaultdict(int) + for order in orders['items']: + product_totals[order['product']] += 1 + return dict(sorted(product_totals.items(), key=lambda x: x[1], reverse=True)) + + +### REVENUE ### def calc_total_revenue(orders: dict) -> float: - profit = sum(order['total'] for order in orders['items']) - return profit + return sum(order['total'] for order in orders['items']) + +def calc_revenue_per_day(orders: dict) -> dict[str, float]: + day_totals = defaultdict(float) + for order in orders['items']: + date = datetime.fromisoformat(order['created_at']).date().isoformat() + day_totals[date] += order['total'] + return dict(sorted(day_totals.items())) + +def calc_revenue_per_week(orders: dict) -> dict[str, float]: + week_totals = defaultdict(float) + for order in orders['items']: + dt = datetime.fromisoformat(order['created_at']) + week_key = f"{dt.year}-W{dt.isocalendar().week:02d}" + week_totals[week_key] += order['total'] + return dict(sorted(week_totals.items())) + +def calc_revenue_per_month(orders: dict) -> dict[str, float]: + month_totals = defaultdict(float) + for order in orders['items']: + month = datetime.fromisoformat(order['created_at']).strftime("%Y-%m") + month_totals[month] += order['total'] + return dict(sorted(month_totals.items())) + +def calc_revenue_per_product(orders: dict) -> dict[str, float]: + product_totals = defaultdict(float) + for order in orders['items']: + product_totals[order['product']] += order['total'] + return dict(sorted(product_totals.items(), key=lambda x: x[1], reverse=True)) +### GROWTH ### +def calc_growth_rate(period_totals: dict[str, float | int]) -> float | None: + if len(period_totals) < 2: + return None + values = list(period_totals.values()) + previous, current = values[-2], values[-1] + if previous == 0: + return None + return round(((current - previous) / previous) * 100, 2) From 47b89e4a492d231aa00521143e061d2b267f38f7 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Fri, 20 Mar 2026 14:07:01 -0400 Subject: [PATCH 08/12] update schema --- analytics_service/app/schemas/analytics.py | 41 ++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 analytics_service/app/schemas/analytics.py diff --git a/analytics_service/app/schemas/analytics.py b/analytics_service/app/schemas/analytics.py new file mode 100644 index 0000000..e53553c --- /dev/null +++ b/analytics_service/app/schemas/analytics.py @@ -0,0 +1,41 @@ +from pydantic import BaseModel, field_serializer + + +class SummaryResponse(BaseModel): + total_orders: int + total_revenue: float + avg_order_value: float + + @field_serializer('total_revenue', 'avg_order_value') + def round_currency(self, v: float) -> float: + return round(v, 2) + + +class RevenueResponse(BaseModel): + total_revenue: float + per_day: dict[str, float] + per_week: dict[str, float] + per_month: dict[str, float] + growth_rate: float | None + + +class OrderStatsResponse(BaseModel): + total_orders: int + per_day: dict[str, int] + per_week: dict[str, int] + per_month: dict[str, int] + growth_rate: float | None + + +class DistributionResponse(BaseModel): + largest: float + smallest: float + median: float + avg_order_value: float + + +class DashboardResponse(BaseModel): + summary: SummaryResponse + revenue: RevenueResponse + orders: OrderStatsResponse + distribution: DistributionResponse \ No newline at end of file From 7c31a3b194791e3dd3d99b327c6042cbc996da86 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Fri, 20 Mar 2026 14:08:16 -0400 Subject: [PATCH 09/12] add more analytics endpoints --- analytics_service/app/api/v1/stats.py | 34 +++++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/analytics_service/app/api/v1/stats.py b/analytics_service/app/api/v1/stats.py index abb66db..087abc5 100644 --- a/analytics_service/app/api/v1/stats.py +++ b/analytics_service/app/api/v1/stats.py @@ -1,7 +1,10 @@ -# from typing import Annotated, List -from fastapi import APIRouter, HTTPException +from datetime import date +from fastapi import APIRouter from analytics_service.app.business_logic import analytics_service - +from analytics_service.app.schemas.analytics import ( + SummaryResponse, RevenueResponse, OrderStatsResponse, + DistributionResponse, DashboardResponse, +) router = APIRouter( prefix="/analytics", @@ -9,21 +12,26 @@ ) -# could also get summary per user/customer -@router.get("/summary") -def get_summary(): - return analytics_service.get_summary() - - +@router.get("/summary", response_model=SummaryResponse) +def get_summary(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_summary(start_date, end_date) -# @router.get("/orders") +@router.get("/orders", response_model=OrderStatsResponse) +def get_order_stats(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_order_stats(start_date, end_date) -# @router.get("/revenue") -# @router.get("/distribution") +@router.get("/revenue", response_model=RevenueResponse) +def get_revenue_stats(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_revenue_stats(start_date, end_date) -# @router.get("/dashboard") # return everything +@router.get("/distribution", response_model=DistributionResponse) +def get_distribution(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_distribution(start_date, end_date) +@router.get("/dashboard", response_model=DashboardResponse) +def get_dashboard(start_date: date | None = None, end_date: date | None = None): + return analytics_service.get_dashboard(start_date, end_date) From b7de4d7702c5c88d912d70fb35f2e3a2bfbf11a6 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Fri, 20 Mar 2026 23:32:00 -0400 Subject: [PATCH 10/12] build test structure --- .../{business_logic => service}/__init__.py | 0 analytics_service/requirements-dev.txt | 2 ++ analytics_service/tests/conftest.py | 20 +++++++++++++++++++ analytics_service/tests/factories/__init__.py | 0 .../tests/integration_tests/__init__.py | 0 .../tests/unit_tests/__init__.py | 0 .../tests/unit_tests/schemas/__init__.py | 0 .../tests/unit_tests/services/__init__.py | 0 8 files changed, 22 insertions(+) rename analytics_service/app/{business_logic => service}/__init__.py (100%) create mode 100644 analytics_service/tests/conftest.py create mode 100644 analytics_service/tests/factories/__init__.py create mode 100644 analytics_service/tests/integration_tests/__init__.py create mode 100644 analytics_service/tests/unit_tests/__init__.py create mode 100644 analytics_service/tests/unit_tests/schemas/__init__.py create mode 100644 analytics_service/tests/unit_tests/services/__init__.py diff --git a/analytics_service/app/business_logic/__init__.py b/analytics_service/app/service/__init__.py similarity index 100% rename from analytics_service/app/business_logic/__init__.py rename to analytics_service/app/service/__init__.py diff --git a/analytics_service/requirements-dev.txt b/analytics_service/requirements-dev.txt index 6727264..c5b87b7 100644 --- a/analytics_service/requirements-dev.txt +++ b/analytics_service/requirements-dev.txt @@ -1,5 +1,7 @@ cachetools==7.0.5 +faker==37.1.0 fastapi==0.128.0 +httpx==0.28.1 pytest==9.0.2 requests==2.32.5 uvicorn==0.40.0 diff --git a/analytics_service/tests/conftest.py b/analytics_service/tests/conftest.py new file mode 100644 index 0000000..38378a6 --- /dev/null +++ b/analytics_service/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest +from analytics_service.tests.factories.order_dict_factory import make_order + + +@pytest.fixture +def empty_orders(): + return {"items": [], "total": 0} + + +@pytest.fixture +def sample_orders(): + return { + "items": [ + make_order(product="Widget", count=2, price=10.0, total=20.0, created_at="2025-01-15T10:00:00"), + make_order(product="Widget", count=1, price=5.0, total=5.0, created_at="2025-01-15T14:00:00"), + make_order(product="Gadget", count=3, price=15.0, total=45.0, created_at="2025-02-10T09:00:00"), + make_order(product="Gadget", count=1, price=100.0, total=100.0, created_at="2025-03-01T08:00:00"), + ], + "total": 4, + } diff --git a/analytics_service/tests/factories/__init__.py b/analytics_service/tests/factories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/integration_tests/__init__.py b/analytics_service/tests/integration_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/unit_tests/__init__.py b/analytics_service/tests/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/unit_tests/schemas/__init__.py b/analytics_service/tests/unit_tests/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analytics_service/tests/unit_tests/services/__init__.py b/analytics_service/tests/unit_tests/services/__init__.py new file mode 100644 index 0000000..e69de29 From 8f5868db00d4dd064fac0826b614e772425165e5 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Fri, 20 Mar 2026 23:32:44 -0400 Subject: [PATCH 11/12] rename folder --- analytics_service/app/api/v1/stats.py | 2 +- analytics_service/app/schemas/analytics.py | 3 +- .../analytics_service.py | 6 +- .../metrics_util.py | 72 ++++++++++--------- 4 files changed, 43 insertions(+), 40 deletions(-) rename analytics_service/app/{business_logic => service}/analytics_service.py (95%) rename analytics_service/app/{business_logic => service}/metrics_util.py (96%) diff --git a/analytics_service/app/api/v1/stats.py b/analytics_service/app/api/v1/stats.py index 087abc5..d5cc3f1 100644 --- a/analytics_service/app/api/v1/stats.py +++ b/analytics_service/app/api/v1/stats.py @@ -1,6 +1,6 @@ from datetime import date from fastapi import APIRouter -from analytics_service.app.business_logic import analytics_service +from analytics_service.app.service import analytics_service from analytics_service.app.schemas.analytics import ( SummaryResponse, RevenueResponse, OrderStatsResponse, DistributionResponse, DashboardResponse, diff --git a/analytics_service/app/schemas/analytics.py b/analytics_service/app/schemas/analytics.py index e53553c..6dc96f2 100644 --- a/analytics_service/app/schemas/analytics.py +++ b/analytics_service/app/schemas/analytics.py @@ -38,4 +38,5 @@ class DashboardResponse(BaseModel): summary: SummaryResponse revenue: RevenueResponse orders: OrderStatsResponse - distribution: DistributionResponse \ No newline at end of file + distribution: DistributionResponse + \ No newline at end of file diff --git a/analytics_service/app/business_logic/analytics_service.py b/analytics_service/app/service/analytics_service.py similarity index 95% rename from analytics_service/app/business_logic/analytics_service.py rename to analytics_service/app/service/analytics_service.py index 04caf67..1676930 100644 --- a/analytics_service/app/business_logic/analytics_service.py +++ b/analytics_service/app/service/analytics_service.py @@ -1,6 +1,6 @@ from datetime import date, datetime from analytics_service.app.clients import orders_client -from analytics_service.app.business_logic import metrics_util +from analytics_service.app.service import metrics_util from analytics_service.app.schemas.analytics import ( SummaryResponse, RevenueResponse, OrderStatsResponse, DistributionResponse, DashboardResponse, @@ -8,7 +8,6 @@ def _filter_orders(orders: dict, start_date: date | None, end_date: date | None) -> dict: - """Returns a new dict with items filtered by date range. Does not mutate the cache.""" if not start_date and not end_date: return orders filtered = [ @@ -69,7 +68,6 @@ def get_distribution(start_date: date | None = None, end_date: date | None = Non def get_dashboard(start_date: date | None = None, end_date: date | None = None) -> DashboardResponse: orders = _filter_orders(orders_client.get_orders(), start_date, end_date) - # pre-compute shared values so metrics_util isn't called redundantly total_orders = metrics_util.calc_total_order_count(orders) total_revenue = metrics_util.calc_total_revenue(orders) aov = metrics_util.avg_order_value(orders) @@ -104,4 +102,4 @@ def get_dashboard(start_date: date | None = None, end_date: date | None = None) median=metrics_util.median_order_total(orders), avg_order_value=aov, ), - ) \ No newline at end of file + ) diff --git a/analytics_service/app/business_logic/metrics_util.py b/analytics_service/app/service/metrics_util.py similarity index 96% rename from analytics_service/app/business_logic/metrics_util.py rename to analytics_service/app/service/metrics_util.py index bb8a036..424debb 100644 --- a/analytics_service/app/business_logic/metrics_util.py +++ b/analytics_service/app/service/metrics_util.py @@ -3,38 +3,6 @@ from collections import defaultdict -### DISTRIBUTION ### - -def avg_order_value(orders: dict) -> float: - total = calc_total_order_count(orders) - if total == 0: - return 0.0 - return calc_total_revenue(orders=orders) / total - -def largest_order_by_revenue(orders: dict) -> dict | None: - if not orders['items']: - return None - return max(orders['items'], key=lambda order: order['total']) - -def largest_order_by_size(orders: dict) -> dict | None: - if not orders['items']: - return None - return max(orders['items'], key=lambda order: order['count']) - -def smallest_order_by_revenue(orders: dict) -> dict | None: - if not orders['items']: - return None - return min(orders['items'], key=lambda order: order['total']) - -def median_order_total(orders: dict) -> float: - totals = [float(order['total']) for order in orders.get('items', []) if 'total' in order] - if not totals: - return 0.0 - return statistics.median(totals) - -# calc_order_value_percentiles — p50/p75/p90 via statistics.quantiles - - ### ORDER COUNTS ### def calc_total_order_count(orders: dict) -> int: @@ -51,7 +19,8 @@ def calc_orders_per_week(orders: dict) -> dict[str, int]: week_totals = defaultdict(int) for order in orders['items']: dt = datetime.fromisoformat(order['created_at']) - week_key = f"{dt.year}-W{dt.isocalendar().week:02d}" + iso = dt.isocalendar() + week_key = f"{iso.year}-W{iso.week:02d}" week_totals[week_key] += 1 return dict(sorted(week_totals.items())) @@ -85,7 +54,8 @@ def calc_revenue_per_week(orders: dict) -> dict[str, float]: week_totals = defaultdict(float) for order in orders['items']: dt = datetime.fromisoformat(order['created_at']) - week_key = f"{dt.year}-W{dt.isocalendar().week:02d}" + iso = dt.isocalendar() + week_key = f"{iso.year}-W{iso.week:02d}" week_totals[week_key] += order['total'] return dict(sorted(week_totals.items())) @@ -113,3 +83,37 @@ def calc_growth_rate(period_totals: dict[str, float | int]) -> float | None: if previous == 0: return None return round(((current - previous) / previous) * 100, 2) + + +### DISTRIBUTION ### + +def avg_order_value(orders: dict) -> float: + total = calc_total_order_count(orders) + if total == 0: + return 0.0 + return calc_total_revenue(orders=orders) / total + +def largest_order_by_revenue(orders: dict) -> dict | None: + if not orders['items']: + return None + return max(orders['items'], key=lambda order: order['total']) + +def largest_order_by_size(orders: dict) -> dict | None: + if not orders['items']: + return None + return max(orders['items'], key=lambda order: order['count']) + +def smallest_order_by_revenue(orders: dict) -> dict | None: + if not orders['items']: + return None + return min(orders['items'], key=lambda order: order['total']) + +def median_order_total(orders: dict) -> float: + totals = [float(order['total']) for order in orders.get('items', []) if 'total' in order] + if not totals: + return 0.0 + return statistics.median(totals) + +# calc_order_value_percentiles — p50/p75/p90 via statistics.quantiles + + From 8eb1d29f1b49f5d911877e822f56e96de2316c88 Mon Sep 17 00:00:00 2001 From: wiIliu Date: Fri, 3 Apr 2026 00:38:39 -0400 Subject: [PATCH 12/12] AS - Tests --- analytics_service/Dockerfile | 4 +- analytics_service/docker-entrypoint.sh | 3 - .../tests/factories/order_dict_factory.py | 22 ++ .../test_analytics_endpoints.py | 252 ++++++++++++++++ .../schemas/analytics_schemas_test.py | 196 +++++++++++++ .../unit_tests/services/metrics_util_test.py | 277 ++++++++++++++++++ docker-compose.yml | 27 +- 7 files changed, 770 insertions(+), 11 deletions(-) create mode 100644 analytics_service/tests/factories/order_dict_factory.py create mode 100644 analytics_service/tests/integration_tests/test_analytics_endpoints.py create mode 100644 analytics_service/tests/unit_tests/schemas/analytics_schemas_test.py create mode 100644 analytics_service/tests/unit_tests/services/metrics_util_test.py diff --git a/analytics_service/Dockerfile b/analytics_service/Dockerfile index 69b90e7..f2d094a 100644 --- a/analytics_service/Dockerfile +++ b/analytics_service/Dockerfile @@ -16,8 +16,8 @@ ENTRYPOINT ["./docker-entrypoint.sh"] FROM base AS dev -# COPY requirements-dev.txt . -# RUN pip install --no-cache-dir -r requirements-dev.txt +COPY requirements-dev.txt . +RUN pip install --no-cache-dir -r requirements-dev.txt ENV ENV=DEV diff --git a/analytics_service/docker-entrypoint.sh b/analytics_service/docker-entrypoint.sh index 88aea0e..20ba604 100644 --- a/analytics_service/docker-entrypoint.sh +++ b/analytics_service/docker-entrypoint.sh @@ -18,9 +18,6 @@ elif [ "$ENV" = "PROD" ]; then exec uvicorn analytics_service.app.main:app \ --host 0.0.0.0 \ --port 8001 - exec uvicorn analytics_service.app.main:app \ - --host 0.0.0.0 \ - --port 8001 else echo "No env provided - stopped" exit 1 diff --git a/analytics_service/tests/factories/order_dict_factory.py b/analytics_service/tests/factories/order_dict_factory.py new file mode 100644 index 0000000..cfa4478 --- /dev/null +++ b/analytics_service/tests/factories/order_dict_factory.py @@ -0,0 +1,22 @@ +from faker import Faker + +fake = Faker() + +def make_order(**overrides) -> dict: + count = overrides.pop("count", fake.random_int(min=1, max=10)) + price = overrides.pop("price", round(fake.pyfloat(min_value=1, max_value=200, right_digits=2), 2)) + total = overrides.pop("total", round(count * price, 2)) + return { + "id": overrides.pop("id", fake.random_int(min=1, max=9999)), + "name": overrides.pop("name", fake.name()), + "product": overrides.pop("product", fake.word()), + "count": count, + "price": price, + "total": total, + "created_at": overrides.pop("created_at", fake.date_time_this_year().isoformat()), + **overrides, + } + +def make_orders(n: int = 3, **overrides) -> dict: + items = [make_order(**overrides) for _ in range(n)] + return {"items": items, "total": len(items)} diff --git a/analytics_service/tests/integration_tests/test_analytics_endpoints.py b/analytics_service/tests/integration_tests/test_analytics_endpoints.py new file mode 100644 index 0000000..379e117 --- /dev/null +++ b/analytics_service/tests/integration_tests/test_analytics_endpoints.py @@ -0,0 +1,252 @@ +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from analytics_service.app.main import app +from analytics_service.app.clients import orders_client + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def mock_orders(monkeypatch, sample_orders): + monkeypatch.setattr(orders_client, "get_orders", lambda: sample_orders) + + +@pytest.fixture +def mock_empty(monkeypatch, empty_orders): + monkeypatch.setattr(orders_client, "get_orders", lambda: empty_orders) + + +# --------------------------------------------------------------------------- +# /analytics/summary +# --------------------------------------------------------------------------- + +def test_summary_200(client, mock_orders): + assert client.get("/analytics/summary").status_code == 200 + + +def test_summary_totals(client, mock_orders): + # sample_orders: 4 orders, totals 20+5+45+100 = 170, avg = 42.5 + data = client.get("/analytics/summary").json() + assert data["total_orders"] == 4 + assert data["total_revenue"] == 170.0 + assert data["avg_order_value"] == 42.5 + + +def test_summary_empty_orders(client, mock_empty): + data = client.get("/analytics/summary").json() + assert data["total_orders"] == 0 + assert data["total_revenue"] == 0.0 + assert data["avg_order_value"] == 0.0 + + +def test_summary_start_date_filter(client, mock_orders): + # Feb + Mar only: 2 orders, 45 + 100 = 145 + data = client.get("/analytics/summary?start_date=2025-02-01").json() + assert data["total_orders"] == 2 + assert data["total_revenue"] == 145.0 + + +def test_summary_end_date_filter(client, mock_orders): + # Jan only: 2 orders, 20 + 5 = 25 + data = client.get("/analytics/summary?end_date=2025-01-31").json() + assert data["total_orders"] == 2 + assert data["total_revenue"] == 25.0 + + +def test_summary_date_range_filter(client, mock_orders): + # Jan + Feb: 3 orders, 20 + 5 + 45 = 70 + data = client.get("/analytics/summary?start_date=2025-01-01&end_date=2025-02-28").json() + assert data["total_orders"] == 3 + assert data["total_revenue"] == 70.0 + + +def test_summary_date_range_no_match(client, mock_orders): + data = client.get("/analytics/summary?start_date=2024-01-01&end_date=2024-12-31").json() + assert data["total_orders"] == 0 + assert data["total_revenue"] == 0.0 + + +def test_summary_invalid_date_422(client, mock_orders): + assert client.get("/analytics/summary?start_date=not-a-date").status_code == 422 + + +# --------------------------------------------------------------------------- +# /analytics/orders +# --------------------------------------------------------------------------- + +def test_orders_200(client, mock_orders): + assert client.get("/analytics/orders").status_code == 200 + + +def test_orders_response_shape(client, mock_orders): + data = client.get("/analytics/orders").json() + assert {"total_orders", "per_day", "per_week", "per_month", "growth_rate"} <= data.keys() + + +def test_orders_total(client, mock_orders): + assert client.get("/analytics/orders").json()["total_orders"] == 4 + + +def test_orders_per_month(client, mock_orders): + data = client.get("/analytics/orders").json() + assert data["per_month"] == {"2025-01": 2, "2025-02": 1, "2025-03": 1} + + +def test_orders_growth_rate_multiple_months(client, mock_orders): + # last two months: Feb=1, Mar=1 → 0% growth + assert client.get("/analytics/orders").json()["growth_rate"] == 0.0 + + +def test_orders_growth_rate_null_single_month(client, monkeypatch): + monkeypatch.setattr(orders_client, "get_orders", lambda: { + "items": [{"id": 1, "name": "A", "product": "X", "count": 1, + "price": 10.0, "total": 10.0, "created_at": "2025-01-15T00:00:00"}], + "total": 1, + }) + assert client.get("/analytics/orders").json()["growth_rate"] is None + + +def test_orders_date_filter(client, mock_orders): + # Feb onward: 2 orders + data = client.get("/analytics/orders?start_date=2025-02-01").json() + assert data["total_orders"] == 2 + assert "2025-01" not in data["per_month"] + + +def test_orders_invalid_date_422(client, mock_orders): + assert client.get("/analytics/orders?end_date=baddate").status_code == 422 + + +# --------------------------------------------------------------------------- +# /analytics/revenue +# --------------------------------------------------------------------------- + +def test_revenue_200(client, mock_orders): + assert client.get("/analytics/revenue").status_code == 200 + + +def test_revenue_response_shape(client, mock_orders): + data = client.get("/analytics/revenue").json() + assert {"total_revenue", "per_day", "per_week", "per_month", "growth_rate"} <= data.keys() + + +def test_revenue_total(client, mock_orders): + assert client.get("/analytics/revenue").json()["total_revenue"] == 170.0 + + +def test_revenue_per_month(client, mock_orders): + data = client.get("/analytics/revenue").json() + assert data["per_month"]["2025-01"] == 25.0 + assert data["per_month"]["2025-02"] == 45.0 + assert data["per_month"]["2025-03"] == 100.0 + + +def test_revenue_growth_rate(client, mock_orders): + # last two months: Feb=45, Mar=100 → ((100-45)/45)*100 = 122.22 + assert client.get("/analytics/revenue").json()["growth_rate"] == 122.22 + + +def test_revenue_date_filter(client, mock_orders): + data = client.get("/analytics/revenue?start_date=2025-01-01&end_date=2025-01-31").json() + assert data["total_revenue"] == 25.0 + assert list(data["per_month"].keys()) == ["2025-01"] + + +def test_revenue_invalid_date_422(client, mock_orders): + assert client.get("/analytics/revenue?start_date=2025-13-01").status_code == 422 + + +# --------------------------------------------------------------------------- +# /analytics/distribution +# --------------------------------------------------------------------------- + +def test_distribution_200(client, mock_orders): + assert client.get("/analytics/distribution").status_code == 200 + + +def test_distribution_values(client, mock_orders): + # totals: [5, 20, 45, 100] — sorted median = (20+45)/2 = 32.5 + data = client.get("/analytics/distribution").json() + assert data["largest"] == 100.0 + assert data["smallest"] == 5.0 + assert data["median"] == 32.5 + assert data["avg_order_value"] == 42.5 + + +def test_distribution_empty_orders(client, mock_empty): + data = client.get("/analytics/distribution").json() + assert data["largest"] == 0.0 + assert data["smallest"] == 0.0 + assert data["median"] == 0.0 + assert data["avg_order_value"] == 0.0 + + +def test_distribution_date_filter(client, mock_orders): + # Jan only: totals [5, 20] + data = client.get("/analytics/distribution?start_date=2025-01-01&end_date=2025-01-31").json() + assert data["largest"] == 20.0 + assert data["smallest"] == 5.0 + assert data["median"] == 12.5 + assert data["avg_order_value"] == 12.5 + + +# --------------------------------------------------------------------------- +# /analytics/dashboard +# --------------------------------------------------------------------------- + +def test_dashboard_200(client, mock_orders): + assert client.get("/analytics/dashboard").status_code == 200 + + +def test_dashboard_top_level_keys(client, mock_orders): + data = client.get("/analytics/dashboard").json() + assert set(data.keys()) == {"summary", "revenue", "orders", "distribution"} + + +def test_dashboard_summary_consistent(client, mock_orders): + # dashboard summary must match the dedicated summary endpoint + dashboard = client.get("/analytics/dashboard").json() + summary = client.get("/analytics/summary").json() + assert dashboard["summary"] == summary + + +def test_dashboard_revenue_consistent(client, mock_orders): + dashboard = client.get("/analytics/dashboard").json() + revenue = client.get("/analytics/revenue").json() + assert dashboard["revenue"] == revenue + + +def test_dashboard_date_filter(client, mock_orders): + data = client.get("/analytics/dashboard?start_date=2025-01-01&end_date=2025-01-31").json() + assert data["summary"]["total_orders"] == 2 + assert data["summary"]["total_revenue"] == 25.0 + assert data["distribution"]["largest"] == 20.0 + + +def test_dashboard_invalid_date_422(client, mock_orders): + assert client.get("/analytics/dashboard?start_date=2025-99-99").status_code == 422 + + +# --------------------------------------------------------------------------- +# Error handling +# --------------------------------------------------------------------------- + +def test_orders_service_unavailable_503(client, monkeypatch): + def raise_503(): + raise HTTPException(status_code=503, detail="Orders service unavailable") + monkeypatch.setattr(orders_client, "get_orders", raise_503) + assert client.get("/analytics/summary").status_code == 503 + + +def test_orders_service_unavailable_all_endpoints(client, monkeypatch): + def raise_503(): + raise HTTPException(status_code=503, detail="Orders service unavailable") + monkeypatch.setattr(orders_client, "get_orders", raise_503) + for endpoint in ["/analytics/summary", "/analytics/orders", + "/analytics/revenue", "/analytics/distribution", "/analytics/dashboard"]: + assert client.get(endpoint).status_code == 503, f"Expected 503 for {endpoint}" diff --git a/analytics_service/tests/unit_tests/schemas/analytics_schemas_test.py b/analytics_service/tests/unit_tests/schemas/analytics_schemas_test.py new file mode 100644 index 0000000..6fd6c33 --- /dev/null +++ b/analytics_service/tests/unit_tests/schemas/analytics_schemas_test.py @@ -0,0 +1,196 @@ +import pytest +from pydantic import ValidationError +from analytics_service.app.schemas import analytics + + +### SummaryResponse ### + +def test_valid_summary(): + summary = analytics.SummaryResponse( + total_orders=5, + total_revenue=12.7, + avg_order_value=12.7 / 5, + ) + assert summary.total_orders == 5 + assert summary.total_revenue == 12.7 + assert summary.avg_order_value == 2.54 + +def test_zero_order_summary(): + summary = analytics.SummaryResponse( + total_orders=0, + total_revenue=0, + avg_order_value=0.0, + ) + assert summary.total_orders == 0 + assert summary.total_revenue == 0.0 + assert summary.avg_order_value == 0.0 + +def test_summary_serializer(): + summary = analytics.SummaryResponse( + total_orders=2, + total_revenue=5.998, + avg_order_value=5.998 / 2, + ) + dumped = summary.model_dump() + assert dumped["total_revenue"] == 6.00 + assert dumped["avg_order_value"] == 3.00 + +def test_summary_missing_field_raises(): + with pytest.raises(ValidationError): + analytics.SummaryResponse(total_orders=1, total_revenue=10.0) + +def test_summary_wrong_type_raises(): + with pytest.raises(ValidationError): + analytics.SummaryResponse( + total_orders="not-a-number", + total_revenue=10.0, + avg_order_value=10.0, + ) + + +### RevenueResponse ### + +def test_valid_revenue_response(): + revenue = analytics.RevenueResponse( + total_revenue=150.0, + per_day={"2025-01-15": 50.0, "2025-01-16": 100.0}, + per_week={"2025-W03": 150.0}, + per_month={"2025-01": 150.0}, + growth_rate=25.0, + ) + assert revenue.total_revenue == 150.0 + assert revenue.per_day["2025-01-15"] == 50.0 + +def test_revenue_response_empty(): + revenue = analytics.RevenueResponse( + total_revenue=0.0, + per_day={}, + per_week={}, + per_month={}, + growth_rate=None, + ) + assert revenue.growth_rate is None + +def test_revenue_response_missing_field_raises(): + with pytest.raises(ValidationError): + analytics.RevenueResponse( + total_revenue=100.0, + per_day={}, + per_week={}, + ) + + +### OrderStatsResponse ### + +def test_valid_order_stats_response(): + stats = analytics.OrderStatsResponse( + total_orders=10, + per_day={"2025-01-15": 4, "2025-01-16": 6}, + per_week={"2025-W03": 10}, + per_month={"2025-01": 10}, + growth_rate=-10.0, + ) + assert stats.total_orders == 10 + assert stats.per_month["2025-01"] == 10 + +def test_order_stats_null_growth_rate(): + stats = analytics.OrderStatsResponse( + total_orders=0, + per_day={}, + per_week={}, + per_month={}, + growth_rate=None, + ) + assert stats.growth_rate is None + +def test_order_stats_missing_field_raises(): + with pytest.raises(ValidationError): + analytics.OrderStatsResponse( + total_orders=5, + per_day={}, + ) + + +### DistributionResponse ### + +def test_valid_distribution_response(): + dist = analytics.DistributionResponse( + largest=100.0, + smallest=5.0, + median=20.0, + avg_order_value=42.5, + ) + assert dist.largest == 100.0 + assert dist.smallest == 5.0 + assert dist.median == 20.0 + +def test_distribution_zeros(): + dist = analytics.DistributionResponse( + largest=0.0, + smallest=0.0, + median=0.0, + avg_order_value=0.0, + ) + assert dist.largest == 0.0 + +def test_distribution_missing_field_raises(): + with pytest.raises(ValidationError): + analytics.DistributionResponse(largest=100.0, smallest=5.0, median=20.0) + + +### DashboardResponse ### + +def _make_summary(**overrides): + defaults = {"total_orders": 4, "total_revenue": 170.0, "avg_order_value": 42.5} + return analytics.SummaryResponse(**{**defaults, **overrides}) + +def _make_revenue(**overrides): + defaults = { + "total_revenue": 170.0, + "per_day": {}, + "per_week": {}, + "per_month": {"2025-01": 25.0}, + "growth_rate": None, + } + return analytics.RevenueResponse(**{**defaults, **overrides}) + +def _make_order_stats(**overrides): + defaults = { + "total_orders": 4, + "per_day": {}, + "per_week": {}, + "per_month": {"2025-01": 2}, + "growth_rate": None, + } + return analytics.OrderStatsResponse(**{**defaults, **overrides}) + +def _make_distribution(**overrides): + defaults = {"largest": 100.0, "smallest": 5.0, "median": 32.5, "avg_order_value": 42.5} + return analytics.DistributionResponse(**{**defaults, **overrides}) + +def test_valid_dashboard_response(): + dashboard = analytics.DashboardResponse( + summary=_make_summary(), + revenue=_make_revenue(), + orders=_make_order_stats(), + distribution=_make_distribution(), + ) + assert dashboard.summary.total_orders == 4 + assert dashboard.revenue.total_revenue == 170.0 + assert dashboard.distribution.largest == 100.0 + +def test_dashboard_missing_nested_field_raises(): + with pytest.raises(ValidationError): + analytics.DashboardResponse( + summary=_make_summary(), + revenue=_make_revenue(), + ) + +def test_dashboard_wrong_nested_type_raises(): + with pytest.raises(ValidationError): + analytics.DashboardResponse( + summary={"not": "a SummaryResponse"}, + revenue=_make_revenue(), + orders=_make_order_stats(), + distribution=_make_distribution(), + ) \ No newline at end of file diff --git a/analytics_service/tests/unit_tests/services/metrics_util_test.py b/analytics_service/tests/unit_tests/services/metrics_util_test.py new file mode 100644 index 0000000..6d91810 --- /dev/null +++ b/analytics_service/tests/unit_tests/services/metrics_util_test.py @@ -0,0 +1,277 @@ +from analytics_service.app.service.metrics_util import ( + avg_order_value, + largest_order_by_revenue, + largest_order_by_size, + smallest_order_by_revenue, + median_order_total, + calc_total_order_count, + calc_orders_per_day, + calc_orders_per_week, + calc_orders_per_month, + calc_orders_per_product, + calc_total_revenue, + calc_revenue_per_day, + calc_revenue_per_week, + calc_revenue_per_month, + calc_revenue_per_product, + calc_growth_rate, +) +from analytics_service.tests.factories.order_dict_factory import make_order, make_orders + + +### Order counts ### + +def test_calc_total_order_count(): + assert calc_total_order_count(make_orders(7)) == 7 + +def test_calc_total_order_count_empty(empty_orders): + assert calc_total_order_count(empty_orders) == 0 + + +def test_calc_orders_per_day(): + orders = {"items": [ + make_order(created_at="2025-01-15T10:06:00"), + make_order(created_at="2025-01-15T11:59:59"), + make_order(created_at="2025-01-16T09:00:00"), + make_order(created_at="2026-01-14T09:00:00"), + + ], "total": 4} + result = calc_orders_per_day(orders) + assert result == {"2025-01-15": 2, "2025-01-16": 1, "2026-01-14": 1} + keys = list(result.keys()) + assert keys == sorted(keys) + +def test_calc_orders_per_day_empty(): + orders = {"items": [], "total": 0} + result = calc_orders_per_day(orders) + assert not result + + +def test_calc_orders_per_week_count(): + orders = {"items": [ + make_order(created_at="2025-01-04T00:00:00"), # week 1 - Jan. 4 is always week 1 + make_order(created_at="2025-01-06T00:00:00"), # week 2 + make_order(created_at="2025-01-07T00:00:00"), # week 2 + make_order(created_at="2025-01-13T00:00:00"), # week 3 + ], "total": 4} + result = calc_orders_per_week(orders) + assert result["2025-W01"] == 1 + assert result["2025-W02"] == 2 + assert result["2025-W03"] == 1 + key = list(result.keys())[0] + assert key == "2025-W01" + +def test_calc_orders_per_week_empty(): + orders = {"items": [], "total": 0} + result = calc_orders_per_week(orders) + assert not result + +def test_calc_orders_per_week_year_boundary(): + # 2024-12-30 is ISO week 2025-W01 (Monday of the week containing Jan 2 Thu) + # using dt.year would incorrectly produce "2024-W01" + orders = {"items": [make_order(created_at="2024-12-30T00:00:00")], "total": 1} + result = calc_orders_per_week(orders) + assert "2025-W01" in result + + +def test_calc_orders_per_month(): + orders = {"items": [ + make_order(created_at="2025-01-15T00:00:00"), + make_order(created_at="2025-01-20T00:00:00"), + make_order(created_at="2025-02-05T00:00:00"), + ], "total": 3} + result = calc_orders_per_month(orders) + assert result == {"2025-01": 2, "2025-02": 1} + keys = list(result.keys()) + assert keys == sorted(keys) + +def test_calc_orders_per_month_empty(): + orders = {"items": [], "total": 0} + assert not calc_orders_per_month(orders) + + +def test_calc_orders_per_product_counts(): + orders = {"items": [ + make_order(product="Widget"), + make_order(product="Widget"), + make_order(product="Gadget"), + ], "total": 3} + result = calc_orders_per_product(orders) + assert result["Widget"] == 2 + assert result["Gadget"] == 1 + keys = list(result.keys()) + assert keys[0] == "Widget" + +def test_calc_orders_per_product_empty(empty_orders): + assert not calc_orders_per_product(empty_orders) + + +### Revenue ### + +def test_calc_total_revenue(sample_orders): + assert calc_total_revenue(sample_orders) == 170.0 + +def test_calc_total_revenue_single(): + orders = {"items": [make_order(total=99.99)], "total": 1} + assert calc_total_revenue(orders) == 99.99 + +def test_calc_total_revenue_empty(empty_orders): + assert calc_total_revenue(empty_orders) == 0.0 + + +def test_calc_revenue_per_day(): + orders = {"items": [ + make_order(created_at="2025-01-15T10:00:00", total=10.0), + make_order(created_at="2025-01-15T14:00:00", total=20.0), + make_order(created_at="2025-01-16T09:00:00", total=5.0), + ], "total": 3} + assert calc_revenue_per_day(orders) == {"2025-01-15": 30.0, "2025-01-16": 5.0} + +def test_calc_revenue_per_day_is_sorted(): + orders = {"items": [ + make_order(created_at="2025-01-16T00:00:00", total=10.0), + make_order(created_at="2025-01-15T00:00:00", total=10.0), + ], "total": 2} + keys = list(calc_revenue_per_day(orders).keys()) + assert keys == sorted(keys) + + +def test_calc_revenue_per_week(): + orders = {"items": [ + make_order(created_at="2025-01-06T00:00:00", total=40.0), # week 2 + make_order(created_at="2025-01-07T00:00:00", total=60.0), # week 2 + make_order(created_at="2025-01-13T00:00:00", total=25.0), # week 3 + ], "total": 3} + result = calc_revenue_per_week(orders) + assert result["2025-W02"] == 100.0 + assert result["2025-W03"] == 25.0 + +def test_calc_revenue_per_week_year_boundary(): + # 2024-12-30 is ISO week 2025-W01 — year from dt.year would be wrong + orders = {"items": [make_order(created_at="2024-12-30T00:00:00", total=50.0)], "total": 1} + result = calc_revenue_per_week(orders) + assert "2025-W01" in result + assert result["2025-W01"] == 50.0 + +def test_calc_revenue_per_week_is_sorted(): + orders = {"items": [ + make_order(created_at="2025-01-13T00:00:00", total=10.0), # week 3 + make_order(created_at="2025-01-06T00:00:00", total=10.0), # week 2 + ], "total": 2} + keys = list(calc_revenue_per_week(orders).keys()) + assert keys == sorted(keys) + + +def test_calc_revenue_per_month(sample_orders): + result = calc_revenue_per_month(sample_orders) + assert result["2025-01"] == 25.0 # 20 + 5 + assert result["2025-02"] == 45.0 + assert result["2025-03"] == 100.0 + +def test_calc_revenue_per_month_is_sorted(): + orders = {"items": [ + make_order(created_at="2025-03-01T00:00:00", total=10.0), + make_order(created_at="2025-01-01T00:00:00", total=10.0), + make_order(created_at="2025-02-01T00:00:00", total=10.0), + ], "total": 3} + keys = list(calc_revenue_per_month(orders).keys()) + assert keys == sorted(keys) + + +def test_calc_revenue_per_product(sample_orders): + result = calc_revenue_per_product(sample_orders) + assert result["Widget"] == 25.0 + assert result["Gadget"] == 145.0 + +def test_calc_revenue_per_product_sorted_descending(sample_orders): + keys = list(calc_revenue_per_product(sample_orders).keys()) + assert keys[0] == "Gadget" # 145 > 25 + +def test_calc_revenue_per_product_empty(empty_orders): + assert calc_revenue_per_product(empty_orders) == {} + + +# --- Growth --- + +def test_calc_growth_rate_positive(): + assert calc_growth_rate({"2025-01": 100.0, "2025-02": 120.0}) == 20.0 + +def test_calc_growth_rate_negative(): + assert calc_growth_rate({"2025-01": 100.0, "2025-02": 80.0}) == -20.0 + +def test_calc_growth_rate_single_period(): + assert calc_growth_rate({"2025-01": 100.0}) is None + +def test_calc_growth_rate_empty(): + assert calc_growth_rate({}) is None + +def test_calc_growth_rate_zero_previous(): + assert calc_growth_rate({"2025-01": 0.0, "2025-02": 100.0}) is None + +def test_calc_growth_rate_no_change(): + assert calc_growth_rate({"2025-01": 100.0, "2025-02": 100.0}) == 0.0 + +def test_calc_growth_rate_rounding(): + assert calc_growth_rate({"2025-01": 3.0, "2025-02": 4.0}) == 33.33 + +def test_calc_growth_rate_uses_last_two_periods(): + # only the last two values should be compared, regardless of earlier history + result = calc_growth_rate({"2024-11": 999.0, "2024-12": 999.0, "2025-01": 100.0, "2025-02": 200.0}) + assert result == 100.0 + + + + +# --- Distribution --- + +def test_avg_order_value(sample_orders): + # totals: 20 + 5 + 45 + 100 = 170, count = 4, avg = 42.5 + assert avg_order_value(sample_orders) == 42.5 + +def test_avg_order_value_single(): + orders = {"items": [make_order(total=37.5)], "total": 1} + assert avg_order_value(orders) == 37.5 + +def test_avg_order_value_empty(empty_orders): + assert avg_order_value(empty_orders) == 0.0 + + +def test_largest_order_by_revenue(): + orders = {"items": [make_order(total=5.0), make_order(total=100.0), make_order(total=20.0)], "total": 3} + assert largest_order_by_revenue(orders)["total"] == 100.0 + +def test_largest_order_by_revenue_empty(empty_orders): + assert largest_order_by_revenue(empty_orders) is None + + +def test_largest_order_by_size(): + orders = {"items": [make_order(count=1), make_order(count=10), make_order(count=5)], "total": 3} + assert largest_order_by_size(orders)["count"] == 10 + +def test_largest_order_by_size_empty(empty_orders): + assert largest_order_by_size(empty_orders) is None + + +def test_smallest_order_by_revenue(): + orders = {"items": [make_order(total=5.0), make_order(total=100.0), make_order(total=20.0)], "total": 3} + assert smallest_order_by_revenue(orders)["total"] == 5.0 + +def test_smallest_order_by_revenue_empty(empty_orders): + assert smallest_order_by_revenue(empty_orders) is None + + +def test_median_order_total(): + orders = {"items": [make_order(total=10.0), make_order(total=30.0), make_order(total=20.0)], "total": 3} + assert median_order_total(orders) == 20.0 + +def test_median_order_total_even_count(): + orders = {"items": [make_order(total=10.0), make_order(total=20.0)], "total": 2} + assert median_order_total(orders) == 15.0 + +def test_median_order_total_single(): + orders = {"items": [make_order(total=42.0)], "total": 1} + assert median_order_total(orders) == 42.0 + +def test_median_order_total_empty(empty_orders): + assert median_order_total(empty_orders) == 0.0 + diff --git a/docker-compose.yml b/docker-compose.yml index 4c6cd11..705619d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,17 +33,19 @@ services: condition: service_healthy analytics_service: - build: - context: analytics_service/ - target: dev - dockerfile: ./Dockerfile + build: + context: analytics_service/ + target: dev + dockerfile: ./Dockerfile restart: on-failure ports: - "8001:8001" environment: ENV: DEV - # env_file: - # - analytics_service/.env + env_file: + - analytics_service/.env + volumes: + - ./analytics_service:/app/analytics_service depends_on: orders_service: condition: service_started @@ -79,6 +81,19 @@ services: test-db: condition: service_healthy + analytics_service_test: + profiles: ["test"] + build: + context: analytics_service/ + target: test + dockerfile: ./Dockerfile + environment: + ENV: TEST + env_file: analytics_service/.env + depends_on: + orders_service_test: + condition: service_started + volumes: postgres_data: