AIVLAD commited on
Commit
742ea3f
·
1 Parent(s): 2d81cf3

feat: implement repository layer and cost-overview endpoint

Browse files

Add DailyProgressRepository with aggregation methods for efficient data
access layer. Implement cost-overview endpoint that returns profile cost
summary and daily breakdown for a date range.

Repository Features:
- get_by_date: Query progress for profile on specific date with select_related
- get_aggregates_by_profile: Aggregate totals, averages, and counts for date range
- Explicit None handling with ternary operators (no implicit defaults)

Endpoint Features:
- Date range validation with proper YYYY-MM-DD format checking
- Summary statistics: total days, total feet/ice/cost, averages
- Daily breakdown showing only days with actual progress
- Uses repository layer for clean separation of concerns

Tests:
- 7 unit tests for repository methods (filtering, aggregation, edge cases)
- 6 integration tests for cost-overview endpoint (success, validation, errors)

All code passes ruff and mypy with zero violations.

.coverage CHANGED
Binary files a/.coverage and b/.coverage differ
 
apps/profiles/repositories.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repository layer for Profile app data access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from decimal import Decimal
7
+
8
+ from django.db.models import Avg, Count, QuerySet, Sum
9
+
10
+ from apps.profiles.models import DailyProgress
11
+
12
+
13
+ class DailyProgressRepository:
14
+ """Data access layer for DailyProgress model."""
15
+
16
+ def get_by_date(self, profile_id: int, target_date: date) -> QuerySet[DailyProgress]:
17
+ """Retrieve all progress records for a profile on a specific date.
18
+
19
+ Args:
20
+ profile_id: Profile ID to filter by
21
+ target_date: Date to retrieve progress for
22
+
23
+ Returns:
24
+ QuerySet of DailyProgress records with wall_section pre-fetched
25
+ """
26
+ return DailyProgress.objects.filter(wall_section__profile_id=profile_id, date=target_date).select_related("wall_section")
27
+
28
+ def get_aggregates_by_profile(self, profile_id: int, start_date: date, end_date: date) -> dict[str, Decimal | int]:
29
+ """Get aggregated statistics for a profile within date range.
30
+
31
+ Args:
32
+ profile_id: Profile ID to aggregate for
33
+ start_date: Start date of range (inclusive)
34
+ end_date: End date of range (inclusive)
35
+
36
+ Returns:
37
+ Dictionary with aggregated statistics:
38
+ - total_feet: Sum of feet_built
39
+ - total_ice: Sum of ice_cubic_yards
40
+ - total_cost: Sum of cost_gold_dragons
41
+ - avg_feet: Average feet_built per day
42
+ - record_count: Number of records in range
43
+ """
44
+ result = DailyProgress.objects.filter(
45
+ wall_section__profile_id=profile_id,
46
+ date__gte=start_date,
47
+ date__lte=end_date,
48
+ ).aggregate(
49
+ total_feet=Sum("feet_built"),
50
+ total_ice=Sum("ice_cubic_yards"),
51
+ total_cost=Sum("cost_gold_dragons"),
52
+ avg_feet=Avg("feet_built"),
53
+ record_count=Count("id"),
54
+ )
55
+
56
+ # Explicit None handling - no implicit defaults
57
+ return {
58
+ "total_feet": Decimal("0") if result["total_feet"] is None else result["total_feet"],
59
+ "total_ice": Decimal("0") if result["total_ice"] is None else result["total_ice"],
60
+ "total_cost": Decimal("0") if result["total_cost"] is None else result["total_cost"],
61
+ "avg_feet": Decimal("0") if result["avg_feet"] is None else result["avg_feet"],
62
+ "record_count": result["record_count"],
63
+ }
apps/profiles/views.py CHANGED
@@ -2,7 +2,7 @@
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
@@ -13,6 +13,7 @@ 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 (
17
  DailyProgressSerializer,
18
  ProfileSerializer,
@@ -85,6 +86,96 @@ class ProfileViewSet(viewsets.ModelViewSet[Profile]):
85
  }
86
  )
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
 
89
  class WallSectionViewSet(viewsets.ModelViewSet[WallSection]):
90
  """ViewSet for WallSection CRUD operations."""
 
2
 
3
  from __future__ import annotations
4
 
5
+ from datetime import datetime, timedelta
6
  from decimal import Decimal
7
 
8
  from django.db.models import Sum
 
13
  from rest_framework.response import Response
14
 
15
  from apps.profiles.models import DailyProgress, Profile, WallSection
16
+ from apps.profiles.repositories import DailyProgressRepository
17
  from apps.profiles.serializers import (
18
  DailyProgressSerializer,
19
  ProfileSerializer,
 
86
  }
87
  )
88
 
89
+ @action(detail=True, methods=["get"], url_path="cost-overview")
90
+ def cost_overview(self, request: Request, pk: int) -> Response:
91
+ """Get cost overview for a profile within a date range."""
92
+ profile = get_object_or_404(Profile, pk=pk)
93
+ start_date_str = request.query_params.get("start_date")
94
+ end_date_str = request.query_params.get("end_date")
95
+
96
+ if not start_date_str:
97
+ return Response(
98
+ {"error": "start_date parameter is required"},
99
+ status=status.HTTP_400_BAD_REQUEST,
100
+ )
101
+
102
+ if not end_date_str:
103
+ return Response(
104
+ {"error": "end_date parameter is required"},
105
+ status=status.HTTP_400_BAD_REQUEST,
106
+ )
107
+
108
+ # Validate date formats (YYYY-MM-DD)
109
+ try:
110
+ start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date()
111
+ except ValueError:
112
+ return Response(
113
+ {"error": "start_date must be in YYYY-MM-DD format"},
114
+ status=status.HTTP_400_BAD_REQUEST,
115
+ )
116
+
117
+ try:
118
+ end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date()
119
+ except ValueError:
120
+ return Response(
121
+ {"error": "end_date must be in YYYY-MM-DD format"},
122
+ status=status.HTTP_400_BAD_REQUEST,
123
+ )
124
+
125
+ # Get aggregated statistics using repository
126
+ repo = DailyProgressRepository()
127
+ aggregates = repo.get_aggregates_by_profile(profile.id, start_date, end_date)
128
+
129
+ # Calculate total days in range
130
+ total_days = (end_date - start_date).days + 1
131
+
132
+ # Calculate average cost per day
133
+ total_cost = aggregates["total_cost"]
134
+ record_count = aggregates["record_count"]
135
+ average_cost_per_day = total_cost / record_count if record_count > 0 else Decimal("0")
136
+
137
+ # Build daily breakdown - only include days with actual progress
138
+ daily_breakdown = []
139
+ current_date = start_date
140
+ while current_date <= end_date:
141
+ day_progress = repo.get_by_date(profile.id, current_date)
142
+ if day_progress.exists():
143
+ day_aggregates = day_progress.aggregate(
144
+ total_feet=Sum("feet_built"),
145
+ total_ice=Sum("ice_cubic_yards"),
146
+ total_cost=Sum("cost_gold_dragons"),
147
+ )
148
+ # Since day_progress.exists() is True, aggregates cannot be None
149
+ daily_breakdown.append(
150
+ {
151
+ "date": current_date.isoformat(),
152
+ "feet_built": str(day_aggregates["total_feet"]),
153
+ "ice_cubic_yards": str(day_aggregates["total_ice"]),
154
+ "cost_gold_dragons": str(day_aggregates["total_cost"]),
155
+ }
156
+ )
157
+ current_date += timedelta(days=1)
158
+
159
+ return Response(
160
+ {
161
+ "profile_id": profile.id,
162
+ "profile_name": profile.name,
163
+ "date_range": {
164
+ "start": start_date_str,
165
+ "end": end_date_str,
166
+ },
167
+ "summary": {
168
+ "total_days": total_days,
169
+ "total_feet_built": str(aggregates["total_feet"]),
170
+ "total_ice_cubic_yards": str(aggregates["total_ice"]),
171
+ "total_cost_gold_dragons": str(aggregates["total_cost"]),
172
+ "average_feet_per_day": str(aggregates["avg_feet"]),
173
+ "average_cost_per_day": str(average_cost_per_day),
174
+ },
175
+ "daily_breakdown": daily_breakdown,
176
+ }
177
+ )
178
+
179
 
180
  class WallSectionViewSet(viewsets.ModelViewSet[WallSection]):
181
  """ViewSet for WallSection CRUD operations."""
tests/integration/test_profile_api.py CHANGED
@@ -272,3 +272,100 @@ class TestProfileAPI:
272
  response = api_client.get(url)
273
 
274
  assert response.status_code == status.HTTP_400_BAD_REQUEST
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  response = api_client.get(url)
273
 
274
  assert response.status_code == status.HTTP_400_BAD_REQUEST
275
+
276
+ def test_cost_overview_success(self, api_client: APIClient) -> None:
277
+ """Test cost overview endpoint returns aggregated data for date range."""
278
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
279
+ section = WallSection.objects.create(
280
+ profile=profile,
281
+ section_name="Tower 1-2",
282
+ start_position=Decimal("0.00"),
283
+ target_length_feet=Decimal("500.00"),
284
+ )
285
+
286
+ DailyProgress.objects.create(
287
+ wall_section=section,
288
+ date=date(2025, 10, 1),
289
+ feet_built=Decimal("10.00"),
290
+ ice_cubic_yards=Decimal("1950.00"),
291
+ cost_gold_dragons=Decimal("3705000.00"),
292
+ )
293
+ DailyProgress.objects.create(
294
+ wall_section=section,
295
+ date=date(2025, 10, 2),
296
+ feet_built=Decimal("15.00"),
297
+ ice_cubic_yards=Decimal("2925.00"),
298
+ cost_gold_dragons=Decimal("5557500.00"),
299
+ )
300
+ DailyProgress.objects.create(
301
+ wall_section=section,
302
+ date=date(2025, 10, 3),
303
+ feet_built=Decimal("20.00"),
304
+ ice_cubic_yards=Decimal("3900.00"),
305
+ cost_gold_dragons=Decimal("7410000.00"),
306
+ )
307
+
308
+ url = reverse("profile-cost-overview", kwargs={"pk": profile.id})
309
+ response = api_client.get(url, {"start_date": "2025-10-01", "end_date": "2025-10-03"})
310
+
311
+ assert response.status_code == status.HTTP_200_OK
312
+ assert response.data["profile_id"] == profile.id
313
+ assert response.data["profile_name"] == "Northern Watch"
314
+ assert response.data["date_range"]["start"] == "2025-10-01"
315
+ assert response.data["date_range"]["end"] == "2025-10-03"
316
+ assert response.data["summary"]["total_days"] == 3
317
+ assert response.data["summary"]["total_feet_built"] == "45.00"
318
+ assert response.data["summary"]["total_ice_cubic_yards"] == "8775.00"
319
+ assert response.data["summary"]["total_cost_gold_dragons"] == "16672500.00"
320
+ assert response.data["summary"]["average_feet_per_day"] == "15.00"
321
+ assert response.data["summary"]["average_cost_per_day"] == "5557500.00"
322
+ assert len(response.data["daily_breakdown"]) == 3
323
+
324
+ def test_cost_overview_no_data_for_range(self, api_client: APIClient) -> None:
325
+ """Test cost overview when no progress exists for given date range."""
326
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
327
+
328
+ url = reverse("profile-cost-overview", kwargs={"pk": profile.id})
329
+ response = api_client.get(url, {"start_date": "2025-10-01", "end_date": "2025-10-15"})
330
+
331
+ assert response.status_code == status.HTTP_200_OK
332
+ assert response.data["summary"]["total_feet_built"] == "0.00"
333
+ assert response.data["summary"]["total_ice_cubic_yards"] == "0.00"
334
+ assert response.data["summary"]["total_cost_gold_dragons"] == "0.00"
335
+ assert response.data["summary"]["average_feet_per_day"] == "0.00"
336
+ assert response.data["summary"]["average_cost_per_day"] == "0.00"
337
+ assert response.data["daily_breakdown"] == []
338
+
339
+ def test_cost_overview_invalid_profile(self, api_client: APIClient) -> None:
340
+ """Test cost overview with non-existent profile returns 404."""
341
+ url = reverse("profile-cost-overview", kwargs={"pk": 9999})
342
+ response = api_client.get(url, {"start_date": "2025-10-01", "end_date": "2025-10-15"})
343
+
344
+ assert response.status_code == status.HTTP_404_NOT_FOUND
345
+
346
+ def test_cost_overview_missing_start_date_param(self, api_client: APIClient) -> None:
347
+ """Test cost overview without start_date parameter returns 400."""
348
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
349
+
350
+ url = reverse("profile-cost-overview", kwargs={"pk": profile.id})
351
+ response = api_client.get(url, {"end_date": "2025-10-15"})
352
+
353
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
354
+
355
+ def test_cost_overview_missing_end_date_param(self, api_client: APIClient) -> None:
356
+ """Test cost overview without end_date parameter returns 400."""
357
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
358
+
359
+ url = reverse("profile-cost-overview", kwargs={"pk": profile.id})
360
+ response = api_client.get(url, {"start_date": "2025-10-01"})
361
+
362
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
363
+
364
+ def test_cost_overview_invalid_date_format(self, api_client: APIClient) -> None:
365
+ """Test cost overview with invalid date format returns 400."""
366
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
367
+
368
+ url = reverse("profile-cost-overview", kwargs={"pk": profile.id})
369
+ response = api_client.get(url, {"start_date": "2025/10/01", "end_date": "2025-10-15"})
370
+
371
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
tests/unit/__pycache__/test_models.cpython-312.pyc ADDED
Binary file (19.4 kB). View file
 
tests/unit/test_models.py CHANGED
@@ -1,4 +1,4 @@
1
- """Unit tests for Profile models."""
2
 
3
  from __future__ import annotations
4
 
@@ -9,6 +9,7 @@ import pytest
9
  from django.db import IntegrityError
10
 
11
  from apps.profiles.models import DailyProgress, Profile, WallSection
 
12
 
13
 
14
  @pytest.mark.django_db
@@ -152,3 +153,229 @@ class TestDailyProgressModel:
152
  )
153
 
154
  assert str(progress) == "Tower 1-2: 10.00 ft on 2025-10-20"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for Profile models and repositories."""
2
 
3
  from __future__ import annotations
4
 
 
9
  from django.db import IntegrityError
10
 
11
  from apps.profiles.models import DailyProgress, Profile, WallSection
12
+ from apps.profiles.repositories import DailyProgressRepository
13
 
14
 
15
  @pytest.mark.django_db
 
153
  )
154
 
155
  assert str(progress) == "Tower 1-2: 10.00 ft on 2025-10-20"
156
+
157
+
158
+ @pytest.mark.django_db
159
+ class TestDailyProgressRepository:
160
+ """Test DailyProgressRepository data access methods."""
161
+
162
+ def test_get_by_date_returns_progress_for_profile_and_date(self) -> None:
163
+ """Test retrieving all progress records for a profile on a specific date."""
164
+ repo = DailyProgressRepository()
165
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
166
+ section1 = WallSection.objects.create(
167
+ profile=profile,
168
+ section_name="Tower 1-2",
169
+ start_position=Decimal("0.00"),
170
+ target_length_feet=Decimal("500.00"),
171
+ )
172
+ section2 = WallSection.objects.create(
173
+ profile=profile,
174
+ section_name="Tower 2-3",
175
+ start_position=Decimal("500.00"),
176
+ target_length_feet=Decimal("500.00"),
177
+ )
178
+
179
+ target_date = date(2025, 10, 15)
180
+ DailyProgress.objects.create(
181
+ wall_section=section1,
182
+ date=target_date,
183
+ feet_built=Decimal("12.50"),
184
+ ice_cubic_yards=Decimal("2437.50"),
185
+ cost_gold_dragons=Decimal("4631250.00"),
186
+ )
187
+ DailyProgress.objects.create(
188
+ wall_section=section2,
189
+ date=target_date,
190
+ feet_built=Decimal("16.25"),
191
+ ice_cubic_yards=Decimal("3168.75"),
192
+ cost_gold_dragons=Decimal("6020625.00"),
193
+ )
194
+
195
+ results = repo.get_by_date(profile.id, target_date)
196
+
197
+ assert results.count() == 2
198
+ assert all(r.date == target_date for r in results)
199
+ assert all(r.wall_section.profile_id == profile.id for r in results)
200
+
201
+ def test_get_by_date_returns_empty_when_no_data(self) -> None:
202
+ """Test get_by_date returns empty queryset when no progress exists."""
203
+ repo = DailyProgressRepository()
204
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
205
+ target_date = date(2025, 10, 15)
206
+
207
+ results = repo.get_by_date(profile.id, target_date)
208
+
209
+ assert results.count() == 0
210
+
211
+ def test_get_by_date_filters_by_profile(self) -> None:
212
+ """Test get_by_date only returns progress for specified profile."""
213
+ repo = DailyProgressRepository()
214
+ profile1 = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
215
+ profile2 = Profile.objects.create(name="Eastern Defense", team_lead="Tormund")
216
+ section1 = WallSection.objects.create(
217
+ profile=profile1,
218
+ section_name="Tower 1-2",
219
+ start_position=Decimal("0.00"),
220
+ target_length_feet=Decimal("500.00"),
221
+ )
222
+ section2 = WallSection.objects.create(
223
+ profile=profile2,
224
+ section_name="Tower 5-6",
225
+ start_position=Decimal("0.00"),
226
+ target_length_feet=Decimal("500.00"),
227
+ )
228
+
229
+ target_date = date(2025, 10, 15)
230
+ DailyProgress.objects.create(
231
+ wall_section=section1,
232
+ date=target_date,
233
+ feet_built=Decimal("12.50"),
234
+ ice_cubic_yards=Decimal("2437.50"),
235
+ cost_gold_dragons=Decimal("4631250.00"),
236
+ )
237
+ DailyProgress.objects.create(
238
+ wall_section=section2,
239
+ date=target_date,
240
+ feet_built=Decimal("20.00"),
241
+ ice_cubic_yards=Decimal("3900.00"),
242
+ cost_gold_dragons=Decimal("7410000.00"),
243
+ )
244
+
245
+ results = repo.get_by_date(profile1.id, target_date)
246
+
247
+ assert results.count() == 1
248
+ first_result = results.first()
249
+ assert first_result is not None
250
+ assert first_result.wall_section.profile_id == profile1.id
251
+
252
+ def test_get_aggregates_by_profile_returns_summary_stats(self) -> None:
253
+ """Test aggregated statistics for a profile within date range."""
254
+ repo = DailyProgressRepository()
255
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
256
+ section = WallSection.objects.create(
257
+ profile=profile,
258
+ section_name="Tower 1-2",
259
+ start_position=Decimal("0.00"),
260
+ target_length_feet=Decimal("500.00"),
261
+ )
262
+
263
+ DailyProgress.objects.create(
264
+ wall_section=section,
265
+ date=date(2025, 10, 1),
266
+ feet_built=Decimal("10.00"),
267
+ ice_cubic_yards=Decimal("1950.00"),
268
+ cost_gold_dragons=Decimal("3705000.00"),
269
+ )
270
+ DailyProgress.objects.create(
271
+ wall_section=section,
272
+ date=date(2025, 10, 2),
273
+ feet_built=Decimal("15.00"),
274
+ ice_cubic_yards=Decimal("2925.00"),
275
+ cost_gold_dragons=Decimal("5557500.00"),
276
+ )
277
+ DailyProgress.objects.create(
278
+ wall_section=section,
279
+ date=date(2025, 10, 3),
280
+ feet_built=Decimal("20.00"),
281
+ ice_cubic_yards=Decimal("3900.00"),
282
+ cost_gold_dragons=Decimal("7410000.00"),
283
+ )
284
+
285
+ result = repo.get_aggregates_by_profile(profile.id, date(2025, 10, 1), date(2025, 10, 3))
286
+
287
+ assert result["total_feet"] == Decimal("45.00")
288
+ assert result["total_ice"] == Decimal("8775.00")
289
+ assert result["total_cost"] == Decimal("16672500.00")
290
+ assert result["avg_feet"] == Decimal("15.00")
291
+ assert result["record_count"] == 3
292
+
293
+ def test_get_aggregates_by_profile_filters_date_range(self) -> None:
294
+ """Test aggregates only include data within specified date range."""
295
+ repo = DailyProgressRepository()
296
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
297
+ section = WallSection.objects.create(
298
+ profile=profile,
299
+ section_name="Tower 1-2",
300
+ start_position=Decimal("0.00"),
301
+ target_length_feet=Decimal("500.00"),
302
+ )
303
+
304
+ DailyProgress.objects.create(
305
+ wall_section=section,
306
+ date=date(2025, 9, 30),
307
+ feet_built=Decimal("10.00"),
308
+ ice_cubic_yards=Decimal("1950.00"),
309
+ cost_gold_dragons=Decimal("3705000.00"),
310
+ )
311
+ DailyProgress.objects.create(
312
+ wall_section=section,
313
+ date=date(2025, 10, 1),
314
+ feet_built=Decimal("15.00"),
315
+ ice_cubic_yards=Decimal("2925.00"),
316
+ cost_gold_dragons=Decimal("5557500.00"),
317
+ )
318
+ DailyProgress.objects.create(
319
+ wall_section=section,
320
+ date=date(2025, 10, 4),
321
+ feet_built=Decimal("20.00"),
322
+ ice_cubic_yards=Decimal("3900.00"),
323
+ cost_gold_dragons=Decimal("7410000.00"),
324
+ )
325
+
326
+ result = repo.get_aggregates_by_profile(profile.id, date(2025, 10, 1), date(2025, 10, 3))
327
+
328
+ assert result["total_feet"] == Decimal("15.00")
329
+ assert result["record_count"] == 1
330
+
331
+ def test_get_aggregates_by_profile_returns_zeros_when_no_data(self) -> None:
332
+ """Test aggregates return explicit zeros when no data exists."""
333
+ repo = DailyProgressRepository()
334
+ profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
335
+
336
+ result = repo.get_aggregates_by_profile(profile.id, date(2025, 10, 1), date(2025, 10, 15))
337
+
338
+ assert result["total_feet"] == Decimal("0")
339
+ assert result["total_ice"] == Decimal("0")
340
+ assert result["total_cost"] == Decimal("0")
341
+ assert result["avg_feet"] == Decimal("0")
342
+ assert result["record_count"] == 0
343
+
344
+ def test_get_aggregates_by_profile_filters_by_profile(self) -> None:
345
+ """Test aggregates only include data for specified profile."""
346
+ repo = DailyProgressRepository()
347
+ profile1 = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
348
+ profile2 = Profile.objects.create(name="Eastern Defense", team_lead="Tormund")
349
+ section1 = WallSection.objects.create(
350
+ profile=profile1,
351
+ section_name="Tower 1-2",
352
+ start_position=Decimal("0.00"),
353
+ target_length_feet=Decimal("500.00"),
354
+ )
355
+ section2 = WallSection.objects.create(
356
+ profile=profile2,
357
+ section_name="Tower 5-6",
358
+ start_position=Decimal("0.00"),
359
+ target_length_feet=Decimal("500.00"),
360
+ )
361
+
362
+ target_date = date(2025, 10, 1)
363
+ DailyProgress.objects.create(
364
+ wall_section=section1,
365
+ date=target_date,
366
+ feet_built=Decimal("10.00"),
367
+ ice_cubic_yards=Decimal("1950.00"),
368
+ cost_gold_dragons=Decimal("3705000.00"),
369
+ )
370
+ DailyProgress.objects.create(
371
+ wall_section=section2,
372
+ date=target_date,
373
+ feet_built=Decimal("50.00"),
374
+ ice_cubic_yards=Decimal("9750.00"),
375
+ cost_gold_dragons=Decimal("18525000.00"),
376
+ )
377
+
378
+ result = repo.get_aggregates_by_profile(profile1.id, target_date, target_date)
379
+
380
+ assert result["total_feet"] == Decimal("10.00")
381
+ assert result["record_count"] == 1