feat: implement daily-ice-usage endpoint for Profile API
Browse filesAdd custom action to ProfileViewSet that returns aggregated daily ice usage
for a specific profile and date. Endpoint accepts date query parameter in
YYYY-MM-DD format and returns total feet built, total ice cubic yards, and
per-section breakdowns.
Implementation uses get_object_or_404 for proper 404 handling, explicit None
checking for aggregate results (no implicit defaults), and proper type
annotations throughout.
Tests added for success case, no-data case, invalid profile, and missing
date parameter validation.
- .coverage +0 -0
- apps/profiles/__pycache__/serializers.cpython-312.pyc +0 -0
- apps/profiles/__pycache__/views.cpython-312.pyc +0 -0
- apps/profiles/views.py +68 -1
- tests/integration/__pycache__/test_profile_api.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/integration/__pycache__/test_profile_api.cpython-312.pyc +0 -0
- tests/integration/test_profile_api.py +80 -0
.coverage
CHANGED
|
Binary files a/.coverage and b/.coverage differ
|
|
|
apps/profiles/__pycache__/serializers.cpython-312.pyc
CHANGED
|
Binary files a/apps/profiles/__pycache__/serializers.cpython-312.pyc and b/apps/profiles/__pycache__/serializers.cpython-312.pyc differ
|
|
|
apps/profiles/__pycache__/views.cpython-312.pyc
CHANGED
|
Binary files a/apps/profiles/__pycache__/views.cpython-312.pyc and b/apps/profiles/__pycache__/views.cpython-312.pyc differ
|
|
|
apps/profiles/views.py
CHANGED
|
@@ -2,7 +2,15 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
from apps.profiles.models import DailyProgress, Profile, WallSection
|
| 8 |
from apps.profiles.serializers import (
|
|
@@ -18,6 +26,65 @@ class ProfileViewSet(viewsets.ModelViewSet[Profile]):
|
|
| 18 |
queryset = Profile.objects.all()
|
| 19 |
serializer_class = ProfileSerializer
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
class WallSectionViewSet(viewsets.ModelViewSet[WallSection]):
|
| 23 |
"""ViewSet for WallSection CRUD operations."""
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from decimal import Decimal
|
| 7 |
+
|
| 8 |
+
from django.db.models import Sum
|
| 9 |
+
from django.shortcuts import get_object_or_404
|
| 10 |
+
from rest_framework import status, viewsets
|
| 11 |
+
from rest_framework.decorators import action
|
| 12 |
+
from rest_framework.request import Request
|
| 13 |
+
from rest_framework.response import Response
|
| 14 |
|
| 15 |
from apps.profiles.models import DailyProgress, Profile, WallSection
|
| 16 |
from apps.profiles.serializers import (
|
|
|
|
| 26 |
queryset = Profile.objects.all()
|
| 27 |
serializer_class = ProfileSerializer
|
| 28 |
|
| 29 |
+
@action(detail=True, methods=["get"], url_path="daily-ice-usage")
|
| 30 |
+
def daily_ice_usage(self, request: Request, pk: int) -> Response:
|
| 31 |
+
"""Get daily ice usage aggregated by profile for a specific date."""
|
| 32 |
+
profile = get_object_or_404(Profile, pk=pk)
|
| 33 |
+
target_date = request.query_params.get("date")
|
| 34 |
+
|
| 35 |
+
if not target_date:
|
| 36 |
+
return Response(
|
| 37 |
+
{"error": "date parameter is required"},
|
| 38 |
+
status=status.HTTP_400_BAD_REQUEST,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
# Validate date format (YYYY-MM-DD)
|
| 42 |
+
try:
|
| 43 |
+
datetime.strptime(target_date, "%Y-%m-%d")
|
| 44 |
+
except ValueError:
|
| 45 |
+
return Response(
|
| 46 |
+
{"error": "date must be in YYYY-MM-DD format"},
|
| 47 |
+
status=status.HTTP_400_BAD_REQUEST,
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# Get all wall sections for this profile
|
| 51 |
+
wall_sections = WallSection.objects.filter(profile=profile)
|
| 52 |
+
|
| 53 |
+
# Get daily progress for all sections on target date
|
| 54 |
+
daily_progress = DailyProgress.objects.filter(wall_section__in=wall_sections, date=target_date)
|
| 55 |
+
|
| 56 |
+
# Aggregate totals
|
| 57 |
+
aggregates = daily_progress.aggregate(
|
| 58 |
+
total_feet=Sum("feet_built"),
|
| 59 |
+
total_ice=Sum("ice_cubic_yards"),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
# Explicit None checking with ternary operators
|
| 63 |
+
total_feet_built = Decimal("0.00") if aggregates["total_feet"] is None else aggregates["total_feet"]
|
| 64 |
+
total_ice_cubic_yards = Decimal("0.00") if aggregates["total_ice"] is None else aggregates["total_ice"]
|
| 65 |
+
|
| 66 |
+
# Build section breakdown
|
| 67 |
+
sections = []
|
| 68 |
+
for progress in daily_progress:
|
| 69 |
+
sections.append(
|
| 70 |
+
{
|
| 71 |
+
"section_name": progress.wall_section.section_name,
|
| 72 |
+
"feet_built": str(progress.feet_built),
|
| 73 |
+
"ice_cubic_yards": str(progress.ice_cubic_yards),
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
return Response(
|
| 78 |
+
{
|
| 79 |
+
"profile_id": profile.id,
|
| 80 |
+
"profile_name": profile.name,
|
| 81 |
+
"date": target_date,
|
| 82 |
+
"total_feet_built": str(total_feet_built),
|
| 83 |
+
"total_ice_cubic_yards": str(total_ice_cubic_yards),
|
| 84 |
+
"sections": sections,
|
| 85 |
+
}
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
|
| 89 |
class WallSectionViewSet(viewsets.ModelViewSet[WallSection]):
|
| 90 |
"""ViewSet for WallSection CRUD operations."""
|
tests/integration/__pycache__/test_profile_api.cpython-312-pytest-8.4.2.pyc
CHANGED
|
Binary files a/tests/integration/__pycache__/test_profile_api.cpython-312-pytest-8.4.2.pyc and b/tests/integration/__pycache__/test_profile_api.cpython-312-pytest-8.4.2.pyc differ
|
|
|
tests/integration/__pycache__/test_profile_api.cpython-312.pyc
ADDED
|
Binary file (14.2 kB). View file
|
|
|
tests/integration/test_profile_api.py
CHANGED
|
@@ -2,11 +2,16 @@
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
|
|
|
| 5 |
import pytest
|
| 6 |
from django.urls import reverse
|
| 7 |
from rest_framework import status
|
| 8 |
from rest_framework.test import APIClient
|
| 9 |
|
|
|
|
|
|
|
| 10 |
|
| 11 |
@pytest.mark.django_db
|
| 12 |
@pytest.mark.integration
|
|
@@ -192,3 +197,78 @@ class TestProfileAPI:
|
|
| 192 |
|
| 193 |
assert response.status_code == status.HTTP_201_CREATED
|
| 194 |
assert response.data["is_active"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
from datetime import date
|
| 6 |
+
from decimal import Decimal
|
| 7 |
+
|
| 8 |
import pytest
|
| 9 |
from django.urls import reverse
|
| 10 |
from rest_framework import status
|
| 11 |
from rest_framework.test import APIClient
|
| 12 |
|
| 13 |
+
from apps.profiles.models import DailyProgress, Profile, WallSection
|
| 14 |
+
|
| 15 |
|
| 16 |
@pytest.mark.django_db
|
| 17 |
@pytest.mark.integration
|
|
|
|
| 197 |
|
| 198 |
assert response.status_code == status.HTTP_201_CREATED
|
| 199 |
assert response.data["is_active"] is True
|
| 200 |
+
|
| 201 |
+
def test_daily_ice_usage_success(self, api_client: APIClient) -> None:
|
| 202 |
+
"""Test daily ice usage endpoint returns aggregated data."""
|
| 203 |
+
# Create profile
|
| 204 |
+
profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
|
| 205 |
+
|
| 206 |
+
# Create wall sections
|
| 207 |
+
section1 = WallSection.objects.create(
|
| 208 |
+
profile=profile,
|
| 209 |
+
section_name="Tower 1-2",
|
| 210 |
+
start_position=Decimal("0.00"),
|
| 211 |
+
target_length_feet=Decimal("500.00"),
|
| 212 |
+
)
|
| 213 |
+
section2 = WallSection.objects.create(
|
| 214 |
+
profile=profile,
|
| 215 |
+
section_name="Tower 2-3",
|
| 216 |
+
start_position=Decimal("500.00"),
|
| 217 |
+
target_length_feet=Decimal("500.00"),
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Create daily progress for same date
|
| 221 |
+
target_date = date(2025, 10, 15)
|
| 222 |
+
DailyProgress.objects.create(
|
| 223 |
+
wall_section=section1,
|
| 224 |
+
date=target_date,
|
| 225 |
+
feet_built=Decimal("12.50"),
|
| 226 |
+
ice_cubic_yards=Decimal("2437.50"),
|
| 227 |
+
cost_gold_dragons=Decimal("4631250.00"),
|
| 228 |
+
)
|
| 229 |
+
DailyProgress.objects.create(
|
| 230 |
+
wall_section=section2,
|
| 231 |
+
date=target_date,
|
| 232 |
+
feet_built=Decimal("16.25"),
|
| 233 |
+
ice_cubic_yards=Decimal("3168.75"),
|
| 234 |
+
cost_gold_dragons=Decimal("6020625.00"),
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
url = reverse("profile-daily-ice-usage", kwargs={"pk": profile.id})
|
| 238 |
+
response = api_client.get(url, {"date": "2025-10-15"})
|
| 239 |
+
|
| 240 |
+
assert response.status_code == status.HTTP_200_OK
|
| 241 |
+
assert response.data["profile_id"] == profile.id
|
| 242 |
+
assert response.data["profile_name"] == "Northern Watch"
|
| 243 |
+
assert response.data["date"] == "2025-10-15"
|
| 244 |
+
assert response.data["total_feet_built"] == "28.75"
|
| 245 |
+
assert response.data["total_ice_cubic_yards"] == "5606.25"
|
| 246 |
+
assert len(response.data["sections"]) == 2
|
| 247 |
+
|
| 248 |
+
def test_daily_ice_usage_no_data_for_date(self, api_client: APIClient) -> None:
|
| 249 |
+
"""Test daily ice usage when no progress exists for given date."""
|
| 250 |
+
profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
|
| 251 |
+
|
| 252 |
+
url = reverse("profile-daily-ice-usage", kwargs={"pk": profile.id})
|
| 253 |
+
response = api_client.get(url, {"date": "2025-10-15"})
|
| 254 |
+
|
| 255 |
+
assert response.status_code == status.HTTP_200_OK
|
| 256 |
+
assert response.data["total_feet_built"] == "0.00"
|
| 257 |
+
assert response.data["total_ice_cubic_yards"] == "0.00"
|
| 258 |
+
assert response.data["sections"] == []
|
| 259 |
+
|
| 260 |
+
def test_daily_ice_usage_invalid_profile(self, api_client: APIClient) -> None:
|
| 261 |
+
"""Test daily ice usage with non-existent profile returns 404."""
|
| 262 |
+
url = reverse("profile-daily-ice-usage", kwargs={"pk": 9999})
|
| 263 |
+
response = api_client.get(url, {"date": "2025-10-15"})
|
| 264 |
+
|
| 265 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 266 |
+
|
| 267 |
+
def test_daily_ice_usage_missing_date_param(self, api_client: APIClient) -> None:
|
| 268 |
+
"""Test daily ice usage without date parameter returns 400."""
|
| 269 |
+
profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
|
| 270 |
+
|
| 271 |
+
url = reverse("profile-daily-ice-usage", kwargs={"pk": profile.id})
|
| 272 |
+
response = api_client.get(url)
|
| 273 |
+
|
| 274 |
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|