AIVLAD commited on
Commit
2d81cf3
·
1 Parent(s): 6dd0453

feat: implement daily-ice-usage endpoint for Profile API

Browse files

Add 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 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 rest_framework import viewsets
 
 
 
 
 
 
 
 
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