AIVLAD commited on
Commit
6754f1c
·
1 Parent(s): ebb16fa

cleanup: remove obsolete code and unused fields

Browse files
.gitignore CHANGED
@@ -6,3 +6,6 @@
6
  .coverage
7
  __pycache__/
8
  *.pyc
 
 
 
 
6
  .coverage
7
  __pycache__/
8
  *.pyc
9
+
10
+ # Database
11
+ db.sqlite3
SPEC-DEMO-GUI.md CHANGED
@@ -94,7 +94,8 @@ wall-construction-gui/
94
  │ ├── pages/ # Page-level components
95
  │ │ ├── Dashboard.jsx
96
  │ │ ├── ProfileDetail.jsx
97
- │ │ ├── ProgressForm.jsx
 
98
  │ │ ├── DailyIceUsage.jsx
99
  │ │ └── CostAnalytics.jsx
100
  │ ├── hooks/ # Custom React hooks
@@ -354,9 +355,9 @@ export default function BarChart({ data, dataKey, xKey, color = '#10b981' }) {
354
  ### 1. Dashboard
355
  **File**: `src/pages/Dashboard.jsx`
356
 
357
- **Purpose**: Display all construction profiles as cards with summary statistics
358
 
359
- **Data Source**: `GET /api/profiles/` + parallel `GET /api/profiles/{id}/cost-overview/`
360
 
361
  **Components**:
362
  - Grid of profile cards
@@ -373,9 +374,9 @@ export default function BarChart({ data, dataKey, xKey, color = '#10b981' }) {
373
  ### 2. ProfileDetail
374
  **File**: `src/pages/ProfileDetail.jsx`
375
 
376
- **Purpose**: Detailed view of a single profile with cost analytics and daily breakdown
377
 
378
- **Data Source**: `GET /api/profiles/{id}/cost-overview/?start_date=X&end_date=Y`
379
 
380
  **Components**:
381
  - Profile header (name, team lead)
@@ -391,30 +392,49 @@ export default function BarChart({ data, dataKey, xKey, color = '#10b981' }) {
391
  - Download CSV button (client-side generation)
392
  - Print-friendly layout
393
 
394
- ### 3. ProgressForm
395
- **File**: `src/pages/ProgressForm.jsx`
396
 
397
- **Purpose**: Record daily construction progress for a wall section
398
 
399
- **Data Source**: `POST /api/profiles/{id}/progress/`
400
 
401
  **Components**:
402
- - Profile selector dropdown
403
- - Wall section selector
404
- - Date picker (default: today)
405
- - Feet built input (number)
406
- - Notes textarea (optional)
407
- - Real-time calculation display (ice usage, cost)
408
- - Submit button
409
 
410
  **Key Features**:
411
- - Form validation (required fields, number validation)
412
- - Real-time calculations using constants (195 yd³/ft, 1,900 GD/yd³)
413
- - Success toast notification
414
- - Error handling with user-friendly messages
415
- - Clear form after submission
 
416
 
417
- ### 4. DailyIceUsage
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
418
  **File**: `src/pages/DailyIceUsage.jsx`
419
 
420
  **Purpose**: Breakdown of ice usage by wall section for a specific date
@@ -434,12 +454,12 @@ export default function BarChart({ data, dataKey, xKey, color = '#10b981' }) {
434
  - Sortable table columns
435
  - Export to CSV
436
 
437
- ### 5. CostAnalytics
438
  **File**: `src/pages/CostAnalytics.jsx`
439
 
440
  **Purpose**: Multi-chart cost analytics dashboard
441
 
442
- **Data Source**: `GET /api/profiles/{id}/cost-overview/?start_date=X&end_date=Y`
443
 
444
  **Components**:
445
  - Date range selector
@@ -507,7 +527,7 @@ async function request(endpoint, options = {}) {
507
  }
508
 
509
  export const api = {
510
- // Profiles
511
  getProfiles: () => request('/profiles/'),
512
  getProfile: (id) => request(`/profiles/${id}/`),
513
  createProfile: (data) => request('/profiles/', {
@@ -515,18 +535,16 @@ export const api = {
515
  body: JSON.stringify(data)
516
  }),
517
 
518
- // Progress
519
- recordProgress: (profileId, data) => request(`/profiles/${profileId}/progress/`, {
520
  method: 'POST',
521
- body: JSON.stringify(data)
522
  }),
523
 
524
  // Analytics
525
- getDailyIceUsage: (profileId, date) =>
526
- request(`/profiles/${profileId}/daily-ice-usage/?date=${date}`),
527
-
528
- getCostOverview: (profileId, startDate, endDate) =>
529
- request(`/profiles/${profileId}/cost-overview/?start_date=${startDate}&end_date=${endDate}`)
530
  }
531
  ```
532
 
@@ -649,7 +667,8 @@ Only add React Context if:
649
  import { useState, useEffect } from 'react'
650
  import Dashboard from './pages/Dashboard'
651
  import ProfileDetail from './pages/ProfileDetail'
652
- import ProgressForm from './pages/ProgressForm'
 
653
 
654
  function App() {
655
  const [route, setRoute] = useState(window.location.hash.slice(1) || 'dashboard')
@@ -688,9 +707,13 @@ function App() {
688
  className={route === 'dashboard' ? 'font-bold' : ''}>
689
  Dashboard
690
  </button>
691
- <button onClick={() => navigate('progress')}
692
- className={route === 'progress' ? 'font-bold' : ''}>
693
- Record Progress
 
 
 
 
694
  </button>
695
  </div>
696
  </div>
@@ -699,7 +722,8 @@ function App() {
699
  <main className="max-w-7xl mx-auto px-4">
700
  {route === 'dashboard' && <Dashboard navigate={navigate} />}
701
  {route === 'profile' && <ProfileDetail profileId={params.id} navigate={navigate} />}
702
- {route === 'progress' && <ProgressForm navigate={navigate} />}
 
703
  </main>
704
  </div>
705
  )
@@ -712,8 +736,8 @@ export default App
712
  ```
713
  /#dashboard
714
  /#profile?id=1
715
- /#profile?id=1&start=2025-10-01&end=2025-10-20
716
- /#progress
717
  /#ice-usage?id=1&date=2025-10-15
718
  ```
719
 
 
94
  │ ├── pages/ # Page-level components
95
  │ │ ├── Dashboard.jsx
96
  │ │ ├── ProfileDetail.jsx
97
+ │ │ ├── SimulationForm.jsx
98
+ │ │ ├── SimulationResults.jsx
99
  │ │ ├── DailyIceUsage.jsx
100
  │ │ └── CostAnalytics.jsx
101
  │ ├── hooks/ # Custom React hooks
 
355
  ### 1. Dashboard
356
  **File**: `src/pages/Dashboard.jsx`
357
 
358
+ **Purpose**: Display simulation results overview with total statistics
359
 
360
+ **Data Source**: `GET /api/simulation/overview/total/`
361
 
362
  **Components**:
363
  - Grid of profile cards
 
374
  ### 2. ProfileDetail
375
  **File**: `src/pages/ProfileDetail.jsx`
376
 
377
+ **Purpose**: Detailed view of simulation results by day
378
 
379
+ **Data Source**: `GET /api/simulation/overview/{day}/` or `GET /api/simulation/overview/all-by-day/`
380
 
381
  **Components**:
382
  - Profile header (name, team lead)
 
392
  - Download CSV button (client-side generation)
393
  - Print-friendly layout
394
 
395
+ ### 3. SimulationForm
396
+ **File**: `src/pages/SimulationForm.jsx`
397
 
398
+ **Purpose**: Run wall construction simulation with multi-profile configuration
399
 
400
+ **Data Source**: `POST /api/simulation/simulate/`
401
 
402
  **Components**:
403
+ - Configuration textarea (multi-line input for wall heights)
404
+ - Number of teams input (default: 10)
405
+ - Run Simulation button
406
+ - Example config link/tooltip
407
+ - Results display area (summary statistics)
408
+ - View Logs button (navigates to SimulationResults)
 
409
 
410
  **Key Features**:
411
+ - Config format validation (numbers only, range 0-30)
412
+ - Example: "5, 10, 15" creates 3 profiles with heights 5, 10, 15
413
+ - Real-time simulation execution
414
+ - Summary display: total ice used, total cost, days to completion
415
+ - Success/error notifications
416
+ - Link to detailed logs and daily breakdown
417
 
418
+ ### 4. SimulationResults
419
+ **File**: `src/pages/SimulationResults.jsx`
420
+
421
+ **Purpose**: Display detailed simulation logs and daily progress
422
+
423
+ **Data Source**: Read from `/logs/team_*.log` files or use `GET /api/simulation/overview/all-by-day/`
424
+
425
+ **Components**:
426
+ - Log viewer (scrollable text area with team logs)
427
+ - Day-by-day summary table
428
+ - Team status indicators (working/relieved)
429
+ - Simulation summary statistics
430
+
431
+ **Key Features**:
432
+ - Syntax-highlighted log display
433
+ - Filter logs by team number
434
+ - Download logs button
435
+ - Refresh simulation button
436
+
437
+ ### 5. DailyIceUsage
438
  **File**: `src/pages/DailyIceUsage.jsx`
439
 
440
  **Purpose**: Breakdown of ice usage by wall section for a specific date
 
454
  - Sortable table columns
455
  - Export to CSV
456
 
457
+ ### 6. CostAnalytics
458
  **File**: `src/pages/CostAnalytics.jsx`
459
 
460
  **Purpose**: Multi-chart cost analytics dashboard
461
 
462
+ **Data Source**: `GET /api/simulation/overview/all-by-day/`
463
 
464
  **Components**:
465
  - Date range selector
 
527
  }
528
 
529
  export const api = {
530
+ // Profiles (CRUD endpoints for manual management)
531
  getProfiles: () => request('/profiles/'),
532
  getProfile: (id) => request(`/profiles/${id}/`),
533
  createProfile: (data) => request('/profiles/', {
 
535
  body: JSON.stringify(data)
536
  }),
537
 
538
+ // Simulation
539
+ runSimulation: (config, numTeams = 10) => request('/simulation/simulate/', {
540
  method: 'POST',
541
+ body: JSON.stringify({ config, num_teams: numTeams })
542
  }),
543
 
544
  // Analytics
545
+ getOverviewTotal: () => request('/simulation/overview/total/'),
546
+ getOverviewByDay: (day) => request(`/simulation/overview/${day}/`),
547
+ getOverviewAllByDay: () => request('/simulation/overview/all-by-day/')
 
 
548
  }
549
  ```
550
 
 
667
  import { useState, useEffect } from 'react'
668
  import Dashboard from './pages/Dashboard'
669
  import ProfileDetail from './pages/ProfileDetail'
670
+ import SimulationForm from './pages/SimulationForm'
671
+ import SimulationResults from './pages/SimulationResults'
672
 
673
  function App() {
674
  const [route, setRoute] = useState(window.location.hash.slice(1) || 'dashboard')
 
707
  className={route === 'dashboard' ? 'font-bold' : ''}>
708
  Dashboard
709
  </button>
710
+ <button onClick={() => navigate('simulation')}
711
+ className={route === 'simulation' ? 'font-bold' : ''}>
712
+ Run Simulation
713
+ </button>
714
+ <button onClick={() => navigate('results')}
715
+ className={route === 'results' ? 'font-bold' : ''}>
716
+ View Results
717
  </button>
718
  </div>
719
  </div>
 
722
  <main className="max-w-7xl mx-auto px-4">
723
  {route === 'dashboard' && <Dashboard navigate={navigate} />}
724
  {route === 'profile' && <ProfileDetail profileId={params.id} navigate={navigate} />}
725
+ {route === 'simulation' && <SimulationForm navigate={navigate} />}
726
+ {route === 'results' && <SimulationResults navigate={navigate} />}
727
  </main>
728
  </div>
729
  )
 
736
  ```
737
  /#dashboard
738
  /#profile?id=1
739
+ /#simulation
740
+ /#results
741
  /#ice-usage?id=1&date=2025-10-15
742
  ```
743
 
SPEC-DEMO-TDD.md DELETED
@@ -1,1399 +0,0 @@
1
- # Wall Construction API - Test-Driven Development Specification
2
-
3
- ## Philosophy: Test-First Development
4
-
5
- This specification defines a comprehensive test-driven development (TDD) strategy for the Wall Construction API. Every feature begins with tests, follows the Red-Green-Refactor cycle, and maintains high code coverage without sacrificing code quality.
6
-
7
- **Core Principle**: Write the test first, watch it fail, make it pass, then refactor. No production code without a failing test.
8
-
9
- ---
10
-
11
- ## TDD Cycle
12
-
13
- ### The Red-Green-Refactor Loop
14
-
15
- ```
16
- 1. 🔴 RED: Write a failing test
17
-
18
- 2. 🟢 GREEN: Write minimal code to pass
19
-
20
- 3. 🔵 REFACTOR: Improve code quality
21
-
22
- (repeat)
23
- ```
24
-
25
- ### Workflow Example
26
-
27
- ```python
28
- # Step 1: RED - Write failing test
29
- def test_ice_usage_calculation():
30
- calculator = IceUsageCalculator()
31
- result = calculator.calculate_ice_usage(Decimal("10.0"))
32
- assert result == Decimal("1950.00") # 10 feet * 195 yd³/ft
33
-
34
- # Run test → FAIL (IceUsageCalculator doesn't exist)
35
-
36
- # Step 2: GREEN - Minimal implementation
37
- class IceUsageCalculator:
38
- ICE_PER_FOOT = Decimal("195")
39
-
40
- def calculate_ice_usage(self, feet_built):
41
- return feet_built * self.ICE_PER_FOOT
42
-
43
- # Run test → PASS
44
-
45
- # Step 3: REFACTOR - Improve (if needed)
46
- # Add type hints, docstrings, extract constants
47
- ```
48
-
49
- ---
50
-
51
- ## Test Pyramid
52
-
53
- ```
54
- ╱╲
55
- ╱ ╲
56
- ╱ E2E ╲ ← Few: Full user flows (10%)
57
- ╱────────╲
58
- ╱ ╲
59
- ╱ Integration╲ ← Some: API endpoints (30%)
60
- ╱──────────────╲
61
- ╱ ╲
62
- ╱ Unit Tests ╲ ← Many: Models, services, utils (60%)
63
- ╱────────────────────╲
64
- ```
65
-
66
- ### Test Distribution
67
-
68
- - **Unit Tests (60%)**: Fast, isolated, test single functions/methods
69
- - **Integration Tests (30%)**: Test component interactions (API + DB)
70
- - **E2E Tests (10%)**: Full workflows from HTTP request to response
71
-
72
- ---
73
-
74
- ## Test Stack
75
-
76
- ### Dependencies
77
-
78
- **Required**:
79
- ```toml
80
- [project.optional-dependencies]
81
- test = [
82
- "pytest==8.4.2",
83
- "pytest-django==4.9.0",
84
- "pytest-xdist==3.6.1", # Parallel test execution
85
- "factory-boy==3.3.1",
86
- "Faker==33.3.0",
87
- ]
88
- ```
89
-
90
- **Optional (Coverage)**:
91
- ```toml
92
- coverage = [
93
- "pytest-cov==6.0.0",
94
- "coverage[toml]==7.6.0",
95
- ]
96
- ```
97
-
98
- ### Why These Tools?
99
-
100
- - **pytest**: Modern test runner, better fixtures than unittest
101
- - **pytest-django**: Django integration (@pytest.mark.django_db)
102
- - **factory-boy**: Test data factories (replaces fixtures.json)
103
- - **Faker**: Realistic fake data (names, dates, text)
104
- - **pytest-xdist**: Run tests in parallel (faster CI)
105
- - **pytest-cov**: Code coverage reporting
106
-
107
- ---
108
-
109
- ## Project Structure
110
-
111
- ```
112
- wall-construction-api/
113
- ├── apps/
114
- │ ├── profiles/
115
- │ │ ├── models.py
116
- │ │ ├── serializers.py
117
- │ │ ├── views.py
118
- │ │ ├── services.py
119
- │ │ └── repositories.py
120
- │ └── progress/
121
- │ ├── models.py
122
- │ ├── serializers.py
123
- │ └── views.py
124
- ├── tests/
125
- │ ├── conftest.py # Shared fixtures
126
- │ ├── factories.py # Model factories
127
- │ ├── unit/
128
- │ │ ├── __init__.py
129
- │ │ ├── test_models.py # Model tests
130
- │ │ ├── test_services.py # Service layer tests
131
- │ │ ├── test_repositories.py # Repository tests
132
- │ │ └── test_utils.py # Utility function tests
133
- │ ├── integration/
134
- │ │ ├── __init__.py
135
- │ │ ├── test_api_profiles.py
136
- │ │ ├── test_api_progress.py
137
- │ │ └── test_api_analytics.py
138
- │ └── e2e/
139
- │ ├── __init__.py
140
- │ └── test_workflows.py # Full user workflows
141
- ├── pytest.ini
142
- └── pyproject.toml
143
- ```
144
-
145
- ---
146
-
147
- ## Configuration
148
-
149
- ### pytest.ini
150
-
151
- ```ini
152
- [pytest]
153
- DJANGO_SETTINGS_MODULE = config.settings.test
154
- python_files = test_*.py
155
- python_classes = Test*
156
- python_functions = test_*
157
-
158
- # Enable django-db access
159
- addopts =
160
- --strict-markers
161
- --tb=short
162
- --reuse-db
163
- --nomigrations
164
- -v
165
-
166
- # Coverage settings
167
- --cov=apps
168
- --cov-report=term-missing:skip-covered
169
- --cov-report=html
170
- --cov-fail-under=90
171
-
172
- markers =
173
- unit: Unit tests (fast, isolated)
174
- integration: Integration tests (API + DB)
175
- e2e: End-to-end tests (full workflows)
176
- slow: Slow tests (run separately)
177
-
178
- # Parallel execution
179
- -n auto
180
-
181
- # Django settings
182
- testpaths = tests
183
- ```
184
-
185
- ### pyproject.toml (test settings)
186
-
187
- ```toml
188
- [tool.coverage.run]
189
- omit = [
190
- "*/migrations/*",
191
- "*/tests/*",
192
- "*/admin.py",
193
- "*/apps.py",
194
- "manage.py",
195
- ]
196
-
197
- [tool.coverage.report]
198
- exclude_lines = [
199
- "pragma: no cover",
200
- "def __repr__",
201
- "raise AssertionError",
202
- "raise NotImplementedError",
203
- "if TYPE_CHECKING:",
204
- ]
205
- ```
206
-
207
- ---
208
-
209
- ## Fixtures and Factories
210
-
211
- ### conftest.py (Shared Fixtures)
212
-
213
- ```python
214
- # tests/conftest.py
215
- import pytest
216
- from rest_framework.test import APIClient
217
- from decimal import Decimal
218
-
219
- # ============================================================================
220
- # API Client Fixtures
221
- # ============================================================================
222
-
223
- @pytest.fixture
224
- def api_client():
225
- """Unauthenticated API client."""
226
- return APIClient()
227
-
228
-
229
- @pytest.fixture
230
- def authenticated_client(api_client, profile_factory):
231
- """Authenticated API client (if auth is added later)."""
232
- # For now, no auth required
233
- return api_client
234
-
235
-
236
- # ============================================================================
237
- # Constants Fixtures
238
- # ============================================================================
239
-
240
- @pytest.fixture
241
- def ice_per_foot():
242
- """Ice consumption constant: 195 cubic yards per foot."""
243
- return Decimal("195")
244
-
245
-
246
- @pytest.fixture
247
- def cost_per_yard():
248
- """Ice cost constant: 1,900 Gold Dragons per cubic yard."""
249
- return Decimal("1900")
250
-
251
-
252
- # ============================================================================
253
- # Date Fixtures
254
- # ============================================================================
255
-
256
- @pytest.fixture
257
- def today():
258
- """Today's date."""
259
- from datetime.date import today
260
- return today()
261
-
262
-
263
- @pytest.fixture
264
- def date_range():
265
- """Sample date range for testing."""
266
- from datetime import date
267
- return {
268
- "start": date(2025, 10, 1),
269
- "end": date(2025, 10, 15)
270
- }
271
- ```
272
-
273
- ### factories.py (Model Factories)
274
-
275
- ```python
276
- # tests/factories.py
277
- import factory
278
- from factory.django import DjangoModelFactory
279
- from faker import Faker
280
- from decimal import Decimal
281
- from datetime import date
282
-
283
- from apps.profiles.models import Profile, WallSection, DailyProgress
284
-
285
- fake = Faker()
286
-
287
- # ============================================================================
288
- # Profile Factory
289
- # ============================================================================
290
-
291
- class ProfileFactory(DjangoModelFactory):
292
- """Factory for creating Profile instances."""
293
-
294
- class Meta:
295
- model = Profile
296
- django_get_or_create = ("name",) # Avoid unique constraint errors
297
-
298
- name = factory.Sequence(lambda n: f"Profile {n}")
299
- team_lead = factory.Faker("name")
300
- is_active = True
301
-
302
-
303
- # ============================================================================
304
- # WallSection Factory
305
- # ============================================================================
306
-
307
- class WallSectionFactory(DjangoModelFactory):
308
- """Factory for creating WallSection instances."""
309
-
310
- class Meta:
311
- model = WallSection
312
- django_get_or_create = ("profile", "section_name")
313
-
314
- profile = factory.SubFactory(ProfileFactory)
315
- section_name = factory.Sequence(lambda n: f"Section {n}")
316
- start_position = factory.Faker(
317
- "pydecimal",
318
- left_digits=4,
319
- right_digits=2,
320
- positive=True
321
- )
322
- target_length_feet = factory.Faker(
323
- "pydecimal",
324
- left_digits=4,
325
- right_digits=2,
326
- positive=True,
327
- min_value=100,
328
- max_value=1000
329
- )
330
-
331
-
332
- # ============================================================================
333
- # DailyProgress Factory
334
- # ============================================================================
335
-
336
- class DailyProgressFactory(DjangoModelFactory):
337
- """Factory for creating DailyProgress instances."""
338
-
339
- class Meta:
340
- model = DailyProgress
341
- django_get_or_create = ("wall_section", "date")
342
-
343
- wall_section = factory.SubFactory(WallSectionFactory)
344
- date = factory.LazyFunction(date.today)
345
- feet_built = factory.Faker(
346
- "pydecimal",
347
- left_digits=3,
348
- right_digits=2,
349
- positive=True,
350
- min_value=1,
351
- max_value=50
352
- )
353
-
354
- # Auto-calculate ice and cost based on feet_built
355
- ice_cubic_yards = factory.LazyAttribute(
356
- lambda obj: obj.feet_built * Decimal("195")
357
- )
358
- cost_gold_dragons = factory.LazyAttribute(
359
- lambda obj: obj.ice_cubic_yards * Decimal("1900")
360
- )
361
-
362
- notes = factory.Faker("sentence")
363
-
364
-
365
- # ============================================================================
366
- # Factory Traits (Variants)
367
- # ============================================================================
368
-
369
- class ProfileFactory_Inactive(ProfileFactory):
370
- """Inactive profile variant."""
371
- is_active = False
372
-
373
-
374
- class DailyProgressFactory_ZeroProgress(DailyProgressFactory):
375
- """Zero progress variant (edge case)."""
376
- feet_built = Decimal("0.00")
377
- ice_cubic_yards = Decimal("0.00")
378
- cost_gold_dragons = Decimal("0.00")
379
- notes = "No work done today"
380
- ```
381
-
382
- ---
383
-
384
- ## Unit Tests
385
-
386
- ### Test Models
387
-
388
- ```python
389
- # tests/unit/test_models.py
390
- import pytest
391
- from decimal import Decimal
392
- from datetime import date
393
-
394
- from apps.profiles.models import Profile, WallSection, DailyProgress
395
- from tests.factories import (
396
- ProfileFactory,
397
- WallSectionFactory,
398
- DailyProgressFactory
399
- )
400
-
401
- # ============================================================================
402
- # Profile Model Tests
403
- # ============================================================================
404
-
405
- @pytest.mark.unit
406
- @pytest.mark.django_db
407
- class TestProfileModel:
408
- """Test Profile model validation and behavior."""
409
-
410
- def test_create_profile_with_valid_data(self):
411
- """Should create profile with valid data."""
412
- profile = ProfileFactory(
413
- name="Northern Watch",
414
- team_lead="Jon Snow"
415
- )
416
-
417
- assert profile.id is not None
418
- assert profile.name == "Northern Watch"
419
- assert profile.team_lead == "Jon Snow"
420
- assert profile.is_active is True
421
-
422
- def test_profile_name_must_be_unique(self):
423
- """Should raise error when creating duplicate profile name."""
424
- ProfileFactory(name="Northern Watch")
425
-
426
- with pytest.raises(Exception): # IntegrityError
427
- ProfileFactory(name="Northern Watch")
428
-
429
- def test_profile_ordering_by_created_at_desc(self):
430
- """Should order profiles by created_at descending."""
431
- profile1 = ProfileFactory(name="First")
432
- profile2 = ProfileFactory(name="Second")
433
- profile3 = ProfileFactory(name="Third")
434
-
435
- profiles = Profile.objects.all()
436
- assert profiles[0] == profile3 # Most recent first
437
- assert profiles[1] == profile2
438
- assert profiles[2] == profile1
439
-
440
- def test_profile_string_representation(self):
441
- """Should return profile name as string."""
442
- profile = ProfileFactory(name="Northern Watch")
443
- assert str(profile) == "Northern Watch"
444
-
445
-
446
- # ============================================================================
447
- # WallSection Model Tests
448
- # ============================================================================
449
-
450
- @pytest.mark.unit
451
- @pytest.mark.django_db
452
- class TestWallSectionModel:
453
- """Test WallSection model validation and behavior."""
454
-
455
- def test_create_wall_section_with_valid_data(self):
456
- """Should create wall section with valid data."""
457
- profile = ProfileFactory()
458
- section = WallSectionFactory(
459
- profile=profile,
460
- section_name="Tower 1-2",
461
- start_position=Decimal("0.00"),
462
- target_length_feet=Decimal("500.00")
463
- )
464
-
465
- assert section.id is not None
466
- assert section.profile == profile
467
- assert section.section_name == "Tower 1-2"
468
-
469
- def test_unique_together_profile_section_name(self):
470
- """Should enforce unique constraint on (profile, section_name)."""
471
- profile = ProfileFactory()
472
- WallSectionFactory(profile=profile, section_name="Tower 1-2")
473
-
474
- with pytest.raises(Exception): # IntegrityError
475
- WallSectionFactory(profile=profile, section_name="Tower 1-2")
476
-
477
- def test_different_profiles_can_have_same_section_name(self):
478
- """Different profiles can use the same section name."""
479
- profile1 = ProfileFactory(name="Profile 1")
480
- profile2 = ProfileFactory(name="Profile 2")
481
-
482
- section1 = WallSectionFactory(profile=profile1, section_name="Tower 1")
483
- section2 = WallSectionFactory(profile=profile2, section_name="Tower 1")
484
-
485
- assert section1.section_name == section2.section_name
486
- assert section1.profile != section2.profile
487
-
488
- def test_cascade_delete_when_profile_deleted(self):
489
- """Should delete wall sections when profile is deleted."""
490
- profile = ProfileFactory()
491
- section = WallSectionFactory(profile=profile)
492
- section_id = section.id
493
-
494
- profile.delete()
495
-
496
- assert not WallSection.objects.filter(id=section_id).exists()
497
-
498
-
499
- # ============================================================================
500
- # DailyProgress Model Tests
501
- # ============================================================================
502
-
503
- @pytest.mark.unit
504
- @pytest.mark.django_db
505
- class TestDailyProgressModel:
506
- """Test DailyProgress model validation and calculations."""
507
-
508
- def test_create_daily_progress_with_valid_data(self):
509
- """Should create daily progress with valid data."""
510
- section = WallSectionFactory()
511
- progress = DailyProgressFactory(
512
- wall_section=section,
513
- date=date(2025, 10, 15),
514
- feet_built=Decimal("12.50"),
515
- ice_cubic_yards=Decimal("2437.50"),
516
- cost_gold_dragons=Decimal("4631250.00")
517
- )
518
-
519
- assert progress.id is not None
520
- assert progress.feet_built == Decimal("12.50")
521
- assert progress.ice_cubic_yards == Decimal("2437.50")
522
- assert progress.cost_gold_dragons == Decimal("4631250.00")
523
-
524
- def test_unique_together_wall_section_date(self):
525
- """Should enforce unique constraint on (wall_section, date)."""
526
- section = WallSectionFactory()
527
- today = date.today()
528
-
529
- DailyProgressFactory(wall_section=section, date=today)
530
-
531
- with pytest.raises(Exception): # IntegrityError
532
- DailyProgressFactory(wall_section=section, date=today)
533
-
534
- def test_ordering_by_date_descending(self):
535
- """Should order progress by date descending."""
536
- section = WallSectionFactory()
537
- progress1 = DailyProgressFactory(
538
- wall_section=section,
539
- date=date(2025, 10, 1)
540
- )
541
- progress2 = DailyProgressFactory(
542
- wall_section=section,
543
- date=date(2025, 10, 15)
544
- )
545
-
546
- progress_list = DailyProgress.objects.filter(wall_section=section)
547
- assert progress_list[0] == progress2 # Most recent first
548
- assert progress_list[1] == progress1
549
-
550
- @pytest.mark.parametrize("feet,expected_ice", [
551
- (Decimal("10.00"), Decimal("1950.00")),
552
- (Decimal("0.00"), Decimal("0.00")),
553
- (Decimal("1.00"), Decimal("195.00")),
554
- (Decimal("100.50"), Decimal("19597.50")),
555
- ])
556
- def test_ice_calculation_formula(self, feet, expected_ice):
557
- """Should correctly calculate ice usage (195 yd³ per foot)."""
558
- calculated_ice = feet * Decimal("195")
559
- assert calculated_ice == expected_ice
560
-
561
- @pytest.mark.parametrize("ice,expected_cost", [
562
- (Decimal("195.00"), Decimal("370500.00")),
563
- (Decimal("0.00"), Decimal("0.00")),
564
- (Decimal("1950.00"), Decimal("3705000.00")),
565
- ])
566
- def test_cost_calculation_formula(self, ice, expected_cost):
567
- """Should correctly calculate cost (1900 GD per yd³)."""
568
- calculated_cost = ice * Decimal("1900")
569
- assert calculated_cost == expected_cost
570
- ```
571
-
572
- ### Test Services
573
-
574
- ```python
575
- # tests/unit/test_services.py
576
- import pytest
577
- from decimal import Decimal
578
- from datetime import date
579
-
580
- from apps.profiles.services import IceUsageCalculator, CostAggregator
581
- from tests.factories import ProfileFactory, WallSectionFactory, DailyProgressFactory
582
-
583
- # ============================================================================
584
- # IceUsageCalculator Tests
585
- # ============================================================================
586
-
587
- @pytest.mark.unit
588
- class TestIceUsageCalculator:
589
- """Test IceUsageCalculator service."""
590
-
591
- @pytest.fixture
592
- def calculator(self):
593
- return IceUsageCalculator()
594
-
595
- def test_calculate_ice_usage_standard_value(self, calculator):
596
- """Should calculate ice usage for standard value."""
597
- result = calculator.calculate_ice_usage(Decimal("10.0"))
598
- expected = Decimal("1950.00")
599
- assert result == expected
600
-
601
- def test_calculate_ice_usage_zero_feet(self, calculator):
602
- """Should return zero for zero feet built."""
603
- result = calculator.calculate_ice_usage(Decimal("0.00"))
604
- assert result == Decimal("0.00")
605
-
606
- def test_calculate_ice_usage_decimal_precision(self, calculator):
607
- """Should handle decimal precision correctly."""
608
- result = calculator.calculate_ice_usage(Decimal("12.50"))
609
- expected = Decimal("2437.50")
610
- assert result == expected
611
-
612
- def test_calculate_cost_standard_value(self, calculator):
613
- """Should calculate cost for standard ice amount."""
614
- ice = Decimal("1950.00")
615
- result = calculator.calculate_cost(ice)
616
- expected = Decimal("3705000.00")
617
- assert result == expected
618
-
619
- def test_calculate_cost_zero_ice(self, calculator):
620
- """Should return zero cost for zero ice."""
621
- result = calculator.calculate_cost(Decimal("0.00"))
622
- assert result == Decimal("0.00")
623
-
624
- def test_calculate_full_cost_returns_tuple(self, calculator):
625
- """Should return (ice, cost) tuple."""
626
- ice, cost = calculator.calculate_full_cost(Decimal("10.00"))
627
- assert ice == Decimal("1950.00")
628
- assert cost == Decimal("3705000.00")
629
-
630
- def test_constants_are_correct(self, calculator):
631
- """Should have correct constant values."""
632
- assert calculator.ICE_PER_FOOT == Decimal("195")
633
- assert calculator.COST_PER_CUBIC_YARD == Decimal("1900")
634
-
635
-
636
- # ============================================================================
637
- # CostAggregator Tests
638
- # ============================================================================
639
-
640
- @pytest.mark.unit
641
- @pytest.mark.django_db
642
- class TestCostAggregator:
643
- """Test CostAggregator service."""
644
-
645
- @pytest.fixture
646
- def aggregator(self):
647
- return CostAggregator(max_workers=2)
648
-
649
- def test_calculate_profile_cost_single_progress(self, aggregator):
650
- """Should calculate cost for single progress entry."""
651
- profile = ProfileFactory()
652
- section = WallSectionFactory(profile=profile)
653
- DailyProgressFactory(
654
- wall_section=section,
655
- date=date(2025, 10, 15),
656
- feet_built=Decimal("10.00"),
657
- ice_cubic_yards=Decimal("1950.00"),
658
- cost_gold_dragons=Decimal("3705000.00")
659
- )
660
-
661
- result = aggregator.calculate_profile_cost(
662
- profile.id,
663
- "2025-10-15",
664
- "2025-10-15"
665
- )
666
-
667
- assert result["total_feet_built"] == "10.00"
668
- assert result["total_ice_cubic_yards"] == "1950.00"
669
- assert result["total_cost_gold_dragons"] == "3705000.00"
670
-
671
- def test_calculate_multi_profile_costs_parallel(self, aggregator):
672
- """Should calculate costs for multiple profiles in parallel."""
673
- profile1 = ProfileFactory(name="Profile 1")
674
- profile2 = ProfileFactory(name="Profile 2")
675
-
676
- section1 = WallSectionFactory(profile=profile1)
677
- section2 = WallSectionFactory(profile=profile2)
678
-
679
- DailyProgressFactory(wall_section=section1, feet_built=Decimal("10.00"))
680
- DailyProgressFactory(wall_section=section2, feet_built=Decimal("20.00"))
681
-
682
- results = aggregator.calculate_multi_profile_costs(
683
- [profile1.id, profile2.id],
684
- "2025-10-01",
685
- "2025-10-31"
686
- )
687
-
688
- assert len(results) == 2
689
- assert all("total_cost_gold_dragons" in r for r in results)
690
-
691
- def test_shutdown_executor(self, aggregator):
692
- """Should gracefully shutdown thread pool."""
693
- aggregator.shutdown()
694
- # No exception should be raised
695
- ```
696
-
697
- ### Test Repositories
698
-
699
- ```python
700
- # tests/unit/test_repositories.py
701
- import pytest
702
- from decimal import Decimal
703
- from datetime import date
704
-
705
- from apps.profiles.repositories import DailyProgressRepository
706
- from tests.factories import ProfileFactory, WallSectionFactory, DailyProgressFactory
707
-
708
- @pytest.mark.unit
709
- @pytest.mark.django_db
710
- class TestDailyProgressRepository:
711
- """Test DailyProgressRepository data access layer."""
712
-
713
- @pytest.fixture
714
- def repository(self):
715
- return DailyProgressRepository()
716
-
717
- def test_get_by_date_returns_progress_for_date(self, repository):
718
- """Should return all progress for a specific date."""
719
- profile = ProfileFactory()
720
- section1 = WallSectionFactory(profile=profile, section_name="Section 1")
721
- section2 = WallSectionFactory(profile=profile, section_name="Section 2")
722
-
723
- target_date = date(2025, 10, 15)
724
- other_date = date(2025, 10, 14)
725
-
726
- progress1 = DailyProgressFactory(wall_section=section1, date=target_date)
727
- progress2 = DailyProgressFactory(wall_section=section2, date=target_date)
728
- DailyProgressFactory(wall_section=section1, date=other_date) # Should not be returned
729
-
730
- results = repository.get_by_date(profile.id, target_date)
731
-
732
- assert results.count() == 2
733
- assert progress1 in results
734
- assert progress2 in results
735
-
736
- def test_get_aggregates_by_profile_sums_correctly(self, repository):
737
- """Should aggregate totals correctly."""
738
- profile = ProfileFactory()
739
- section = WallSectionFactory(profile=profile)
740
-
741
- DailyProgressFactory(
742
- wall_section=section,
743
- date=date(2025, 10, 1),
744
- feet_built=Decimal("10.00"),
745
- ice_cubic_yards=Decimal("1950.00"),
746
- cost_gold_dragons=Decimal("3705000.00")
747
- )
748
- DailyProgressFactory(
749
- wall_section=section,
750
- date=date(2025, 10, 2),
751
- feet_built=Decimal("15.00"),
752
- ice_cubic_yards=Decimal("2925.00"),
753
- cost_gold_dragons=Decimal("5557500.00")
754
- )
755
-
756
- result = repository.get_aggregates_by_profile(
757
- profile.id,
758
- "2025-10-01",
759
- "2025-10-02"
760
- )
761
-
762
- assert result["total_feet"] == Decimal("25.00")
763
- assert result["total_ice"] == Decimal("4875.00")
764
- assert result["total_cost"] == Decimal("9262500.00")
765
- assert result["record_count"] == 2
766
-
767
- def test_get_aggregates_empty_queryset_returns_zeros(self, repository):
768
- """Should return zeros for empty queryset."""
769
- profile = ProfileFactory()
770
-
771
- result = repository.get_aggregates_by_profile(
772
- profile.id,
773
- "2025-10-01",
774
- "2025-10-31"
775
- )
776
-
777
- assert result["total_feet"] == Decimal("0")
778
- assert result["total_ice"] == Decimal("0")
779
- assert result["total_cost"] == Decimal("0")
780
- assert result["record_count"] == 0
781
- ```
782
-
783
- ---
784
-
785
- ## Integration Tests (API)
786
-
787
- ### Test Profile API
788
-
789
- ```python
790
- # tests/integration/test_api_profiles.py
791
- import pytest
792
- from rest_framework import status
793
- from django.urls import reverse
794
-
795
- from tests.factories import ProfileFactory
796
-
797
- @pytest.mark.integration
798
- @pytest.mark.django_db
799
- class TestProfileListAPI:
800
- """Test GET /api/profiles/ endpoint."""
801
-
802
- @pytest.fixture
803
- def url(self):
804
- return reverse("profile-list")
805
-
806
- def test_list_profiles_returns_200(self, api_client, url):
807
- """Should return 200 OK."""
808
- response = api_client.get(url)
809
- assert response.status_code == status.HTTP_200_OK
810
-
811
- def test_list_profiles_returns_pagination(self, api_client, url):
812
- """Should return paginated response."""
813
- ProfileFactory.create_batch(5)
814
-
815
- response = api_client.get(url)
816
- data = response.json()
817
-
818
- assert "count" in data
819
- assert "results" in data
820
- assert data["count"] == 5
821
- assert len(data["results"]) == 5
822
-
823
- def test_list_profiles_filters_by_active(self, api_client, url):
824
- """Should filter by is_active parameter."""
825
- ProfileFactory(name="Active", is_active=True)
826
- ProfileFactory(name="Inactive", is_active=False)
827
-
828
- response = api_client.get(url, {"is_active": "true"})
829
- data = response.json()
830
-
831
- assert data["count"] == 1
832
- assert data["results"][0]["name"] == "Active"
833
-
834
- def test_list_profiles_ordered_by_created_desc(self, api_client, url):
835
- """Should order profiles by created_at descending."""
836
- profile1 = ProfileFactory(name="First")
837
- profile2 = ProfileFactory(name="Second")
838
- profile3 = ProfileFactory(name="Third")
839
-
840
- response = api_client.get(url)
841
- data = response.json()
842
-
843
- assert data["results"][0]["name"] == "Third"
844
- assert data["results"][1]["name"] == "Second"
845
- assert data["results"][2]["name"] == "First"
846
-
847
-
848
- @pytest.mark.integration
849
- @pytest.mark.django_db
850
- class TestProfileCreateAPI:
851
- """Test POST /api/profiles/ endpoint."""
852
-
853
- @pytest.fixture
854
- def url(self):
855
- return reverse("profile-list")
856
-
857
- def test_create_profile_with_valid_data(self, api_client, url):
858
- """Should create profile with valid data."""
859
- payload = {
860
- "name": "Northern Watch",
861
- "team_lead": "Jon Snow",
862
- "is_active": True
863
- }
864
-
865
- response = api_client.post(url, payload, format="json")
866
-
867
- assert response.status_code == status.HTTP_201_CREATED
868
- data = response.json()
869
- assert data["name"] == "Northern Watch"
870
- assert data["team_lead"] == "Jon Snow"
871
- assert data["is_active"] is True
872
- assert "id" in data
873
-
874
- def test_create_profile_with_duplicate_name_fails(self, api_client, url):
875
- """Should return 400 for duplicate name."""
876
- ProfileFactory(name="Northern Watch")
877
-
878
- payload = {
879
- "name": "Northern Watch",
880
- "team_lead": "Jon Snow"
881
- }
882
-
883
- response = api_client.post(url, payload, format="json")
884
- assert response.status_code == status.HTTP_400_BAD_REQUEST
885
-
886
- def test_create_profile_with_missing_name_fails(self, api_client, url):
887
- """Should return 400 for missing required field."""
888
- payload = {
889
- "team_lead": "Jon Snow"
890
- }
891
-
892
- response = api_client.post(url, payload, format="json")
893
- assert response.status_code == status.HTTP_400_BAD_REQUEST
894
- assert "name" in response.json()
895
- ```
896
-
897
- ### Test Progress API
898
-
899
- ```python
900
- # tests/integration/test_api_progress.py
901
- import pytest
902
- from decimal import Decimal
903
- from datetime import date
904
- from rest_framework import status
905
- from django.urls import reverse
906
-
907
- from tests.factories import ProfileFactory, WallSectionFactory, DailyProgressFactory
908
-
909
- @pytest.mark.integration
910
- @pytest.mark.django_db
911
- class TestRecordProgressAPI:
912
- """Test POST /api/profiles/{id}/progress/ endpoint."""
913
-
914
- def test_record_progress_with_valid_data(self, api_client):
915
- """Should record progress and auto-calculate ice/cost."""
916
- profile = ProfileFactory()
917
- section = WallSectionFactory(profile=profile)
918
-
919
- url = reverse("profile-progress", kwargs={"pk": profile.id})
920
- payload = {
921
- "wall_section_id": section.id,
922
- "date": "2025-10-15",
923
- "feet_built": "12.50",
924
- "notes": "Good progress today"
925
- }
926
-
927
- response = api_client.post(url, payload, format="json")
928
-
929
- assert response.status_code == status.HTTP_201_CREATED
930
- data = response.json()
931
- assert data["feet_built"] == "12.50"
932
- assert data["ice_cubic_yards"] == "2437.50" # 12.5 * 195
933
- assert data["cost_gold_dragons"] == "4631250.00" # 2437.5 * 1900
934
-
935
- def test_record_progress_for_same_section_and_date_fails(self, api_client):
936
- """Should return 400 for duplicate (section, date)."""
937
- profile = ProfileFactory()
938
- section = WallSectionFactory(profile=profile)
939
- today = date.today()
940
-
941
- DailyProgressFactory(wall_section=section, date=today)
942
-
943
- url = reverse("profile-progress", kwargs={"pk": profile.id})
944
- payload = {
945
- "wall_section_id": section.id,
946
- "date": str(today),
947
- "feet_built": "10.00"
948
- }
949
-
950
- response = api_client.post(url, payload, format="json")
951
- assert response.status_code == status.HTTP_400_BAD_REQUEST
952
-
953
- def test_record_progress_with_zero_feet_allowed(self, api_client):
954
- """Should allow zero feet built (no work day)."""
955
- profile = ProfileFactory()
956
- section = WallSectionFactory(profile=profile)
957
-
958
- url = reverse("profile-progress", kwargs={"pk": profile.id})
959
- payload = {
960
- "wall_section_id": section.id,
961
- "date": "2025-10-15",
962
- "feet_built": "0.00",
963
- "notes": "No work today"
964
- }
965
-
966
- response = api_client.post(url, payload, format="json")
967
-
968
- assert response.status_code == status.HTTP_201_CREATED
969
- data = response.json()
970
- assert data["feet_built"] == "0.00"
971
- assert data["ice_cubic_yards"] == "0.00"
972
- assert data["cost_gold_dragons"] == "0.00"
973
- ```
974
-
975
- ### Test Analytics API
976
-
977
- ```python
978
- # tests/integration/test_api_analytics.py
979
- import pytest
980
- from decimal import Decimal
981
- from datetime import date
982
- from rest_framework import status
983
- from django.urls import reverse
984
-
985
- from tests.factories import ProfileFactory, WallSectionFactory, DailyProgressFactory
986
-
987
- @pytest.mark.integration
988
- @pytest.mark.django_db
989
- class TestDailyIceUsageAPI:
990
- """Test GET /api/profiles/{id}/daily-ice-usage/?date=YYYY-MM-DD"""
991
-
992
- def test_daily_ice_usage_returns_breakdown(self, api_client):
993
- """Should return daily ice usage breakdown by section."""
994
- profile = ProfileFactory()
995
- section1 = WallSectionFactory(profile=profile, section_name="Tower 1-2")
996
- section2 = WallSectionFactory(profile=profile, section_name="Tower 2-3")
997
-
998
- target_date = date(2025, 10, 15)
999
- DailyProgressFactory(
1000
- wall_section=section1,
1001
- date=target_date,
1002
- feet_built=Decimal("12.50"),
1003
- ice_cubic_yards=Decimal("2437.50")
1004
- )
1005
- DailyProgressFactory(
1006
- wall_section=section2,
1007
- date=target_date,
1008
- feet_built=Decimal("16.25"),
1009
- ice_cubic_yards=Decimal("3168.75")
1010
- )
1011
-
1012
- url = reverse("profile-daily-ice-usage", kwargs={"pk": profile.id})
1013
- response = api_client.get(url, {"date": "2025-10-15"})
1014
-
1015
- assert response.status_code == status.HTTP_200_OK
1016
- data = response.json()
1017
- assert data["total_feet_built"] == "28.75"
1018
- assert data["total_ice_cubic_yards"] == "5606.25"
1019
- assert len(data["sections"]) == 2
1020
-
1021
-
1022
- @pytest.mark.integration
1023
- @pytest.mark.django_db
1024
- class TestCostOverviewAPI:
1025
- """Test GET /api/profiles/{id}/cost-overview/?start_date&end_date"""
1026
-
1027
- def test_cost_overview_returns_summary_and_breakdown(self, api_client):
1028
- """Should return summary stats and daily breakdown."""
1029
- profile = ProfileFactory()
1030
- section = WallSectionFactory(profile=profile)
1031
-
1032
- DailyProgressFactory(
1033
- wall_section=section,
1034
- date=date(2025, 10, 1),
1035
- feet_built=Decimal("10.00"),
1036
- ice_cubic_yards=Decimal("1950.00"),
1037
- cost_gold_dragons=Decimal("3705000.00")
1038
- )
1039
- DailyProgressFactory(
1040
- wall_section=section,
1041
- date=date(2025, 10, 2),
1042
- feet_built=Decimal("15.00"),
1043
- ice_cubic_yards=Decimal("2925.00"),
1044
- cost_gold_dragons=Decimal("5557500.00")
1045
- )
1046
-
1047
- url = reverse("profile-cost-overview", kwargs={"pk": profile.id})
1048
- response = api_client.get(url, {
1049
- "start_date": "2025-10-01",
1050
- "end_date": "2025-10-02"
1051
- })
1052
-
1053
- assert response.status_code == status.HTTP_200_OK
1054
- data = response.json()
1055
-
1056
- assert data["summary"]["total_feet_built"] == "25.00"
1057
- assert data["summary"]["total_ice_cubic_yards"] == "4875.00"
1058
- assert data["summary"]["total_cost_gold_dragons"] == "9262500.00"
1059
- assert len(data["daily_breakdown"]) == 2
1060
-
1061
- def test_cost_overview_requires_date_parameters(self, api_client):
1062
- """Should return 400 if date parameters missing."""
1063
- profile = ProfileFactory()
1064
- url = reverse("profile-cost-overview", kwargs={"pk": profile.id})
1065
-
1066
- response = api_client.get(url)
1067
- assert response.status_code == status.HTTP_400_BAD_REQUEST
1068
- ```
1069
-
1070
- ---
1071
-
1072
- ## End-to-End Tests
1073
-
1074
- ```python
1075
- # tests/e2e/test_workflows.py
1076
- import pytest
1077
- from decimal import Decimal
1078
- from datetime import date
1079
- from rest_framework import status
1080
- from django.urls import reverse
1081
-
1082
- @pytest.mark.e2e
1083
- @pytest.mark.django_db
1084
- class TestFullConstructionWorkflow:
1085
- """Test complete user workflow from profile creation to analytics."""
1086
-
1087
- def test_complete_workflow(self, api_client):
1088
- """
1089
- Complete workflow:
1090
- 1. Create profile
1091
- 2. Create wall section
1092
- 3. Record daily progress (3 days)
1093
- 4. Query cost overview
1094
- 5. Query daily ice usage
1095
- """
1096
-
1097
- # Step 1: Create profile
1098
- profile_url = reverse("profile-list")
1099
- profile_payload = {
1100
- "name": "Northern Watch",
1101
- "team_lead": "Jon Snow",
1102
- "is_active": True
1103
- }
1104
- response = api_client.post(profile_url, profile_payload, format="json")
1105
- assert response.status_code == status.HTTP_201_CREATED
1106
- profile_id = response.json()["id"]
1107
-
1108
- # Step 2: Create wall section (assume endpoint exists)
1109
- section_payload = {
1110
- "section_name": "Tower 1-2",
1111
- "start_position": "0.00",
1112
- "target_length_feet": "500.00"
1113
- }
1114
- # ... create section via API
1115
-
1116
- # Step 3: Record progress for 3 days
1117
- progress_url = reverse("profile-progress", kwargs={"pk": profile_id})
1118
-
1119
- for day in [1, 2, 3]:
1120
- payload = {
1121
- "wall_section_id": 1, # Assuming ID 1
1122
- "date": f"2025-10-{day:02d}",
1123
- "feet_built": str(Decimal("10.00") + Decimal(day)),
1124
- "notes": f"Day {day} progress"
1125
- }
1126
- response = api_client.post(progress_url, payload, format="json")
1127
- assert response.status_code == status.HTTP_201_CREATED
1128
-
1129
- # Step 4: Query cost overview
1130
- overview_url = reverse("profile-cost-overview", kwargs={"pk": profile_id})
1131
- response = api_client.get(overview_url, {
1132
- "start_date": "2025-10-01",
1133
- "end_date": "2025-10-03"
1134
- })
1135
-
1136
- assert response.status_code == status.HTTP_200_OK
1137
- data = response.json()
1138
- assert data["summary"]["total_days"] == 3
1139
- assert Decimal(data["summary"]["total_feet_built"]) > Decimal("30.00")
1140
-
1141
- # Step 5: Query daily ice usage
1142
- ice_url = reverse("profile-daily-ice-usage", kwargs={"pk": profile_id})
1143
- response = api_client.get(ice_url, {"date": "2025-10-02"})
1144
-
1145
- assert response.status_code == status.HTTP_200_OK
1146
- assert "total_ice_cubic_yards" in response.json()
1147
- ```
1148
-
1149
- ---
1150
-
1151
- ## Running Tests
1152
-
1153
- ### Basic Commands
1154
-
1155
- ```bash
1156
- # Run all tests
1157
- pytest
1158
-
1159
- # Run specific test file
1160
- pytest tests/unit/test_models.py
1161
-
1162
- # Run specific test class
1163
- pytest tests/unit/test_models.py::TestProfileModel
1164
-
1165
- # Run specific test function
1166
- pytest tests/unit/test_models.py::TestProfileModel::test_create_profile_with_valid_data
1167
-
1168
- # Run tests by marker
1169
- pytest -m unit # Only unit tests
1170
- pytest -m integration # Only integration tests
1171
- pytest -m "not slow" # Skip slow tests
1172
-
1173
- # Run with coverage
1174
- pytest --cov=apps --cov-report=html
1175
-
1176
- # Run in parallel (faster)
1177
- pytest -n auto
1178
-
1179
- # Run with verbose output
1180
- pytest -v
1181
-
1182
- # Stop on first failure
1183
- pytest -x
1184
-
1185
- # Re-run only failed tests
1186
- pytest --lf
1187
- ```
1188
-
1189
- ### CI/CD Pipeline
1190
-
1191
- ```yaml
1192
- # .github/workflows/tests.yml
1193
- name: Tests
1194
-
1195
- on: [push, pull_request]
1196
-
1197
- jobs:
1198
- test:
1199
- runs-on: ubuntu-latest
1200
-
1201
- steps:
1202
- - uses: actions/checkout@v3
1203
-
1204
- - name: Set up Python
1205
- uses: actions/setup-python@v4
1206
- with:
1207
- python-version: '3.12'
1208
-
1209
- - name: Install dependencies
1210
- run: |
1211
- pip install -r requirements.txt
1212
- pip install -r requirements-test.txt
1213
-
1214
- - name: Run tests
1215
- run: |
1216
- pytest --cov=apps --cov-report=xml -n auto
1217
-
1218
- - name: Upload coverage
1219
- uses: codecov/codecov-action@v3
1220
- with:
1221
- file: ./coverage.xml
1222
- ```
1223
-
1224
- ---
1225
-
1226
- ## Best Practices
1227
-
1228
- ### Arrange-Act-Assert Pattern
1229
-
1230
- ```python
1231
- def test_calculate_ice_usage():
1232
- # Arrange: Set up test data
1233
- calculator = IceUsageCalculator()
1234
- feet_built = Decimal("10.00")
1235
-
1236
- # Act: Execute the code under test
1237
- result = calculator.calculate_ice_usage(feet_built)
1238
-
1239
- # Assert: Verify the result
1240
- expected = Decimal("1950.00")
1241
- assert result == expected
1242
- ```
1243
-
1244
- ### Descriptive Test Names
1245
-
1246
- ```python
1247
- # Good
1248
- def test_create_profile_with_duplicate_name_returns_400():
1249
- ...
1250
-
1251
- # Bad
1252
- def test_profile_creation():
1253
- ...
1254
- ```
1255
-
1256
- ### One Assertion Per Test (Generally)
1257
-
1258
- ```python
1259
- # Good - focused test
1260
- def test_profile_has_name():
1261
- profile = ProfileFactory(name="Test")
1262
- assert profile.name == "Test"
1263
-
1264
- def test_profile_has_team_lead():
1265
- profile = ProfileFactory(team_lead="Jon")
1266
- assert profile.team_lead == "Jon"
1267
-
1268
- # Acceptable - related assertions
1269
- def test_create_profile_returns_complete_data():
1270
- profile = ProfileFactory(name="Test", team_lead="Jon")
1271
- assert profile.name == "Test"
1272
- assert profile.team_lead == "Jon"
1273
- assert profile.is_active is True
1274
- ```
1275
-
1276
- ### Test Isolation
1277
-
1278
- ```python
1279
- # Each test should be independent
1280
- @pytest.mark.django_db
1281
- def test_one():
1282
- profile = ProfileFactory()
1283
- assert profile.is_active
1284
-
1285
- @pytest.mark.django_db
1286
- def test_two():
1287
- # Database is reset between tests
1288
- assert Profile.objects.count() == 0 # Fresh DB
1289
- ```
1290
-
1291
- ### Use Fixtures for Reusable Setup
1292
-
1293
- ```python
1294
- @pytest.fixture
1295
- def profile_with_sections():
1296
- """Profile with 3 wall sections."""
1297
- profile = ProfileFactory()
1298
- sections = WallSectionFactory.create_batch(3, profile=profile)
1299
- return profile, sections
1300
-
1301
- def test_profile_has_sections(profile_with_sections):
1302
- profile, sections = profile_with_sections
1303
- assert profile.wall_sections.count() == 3
1304
- ```
1305
-
1306
- ---
1307
-
1308
- ## Coverage Requirements
1309
-
1310
- ### Minimum Coverage: 90%
1311
-
1312
- ```bash
1313
- # Check coverage
1314
- pytest --cov=apps --cov-report=term-missing
1315
-
1316
- # Fail if below threshold
1317
- pytest --cov=apps --cov-fail-under=90
1318
- ```
1319
-
1320
- ### What to Test
1321
-
1322
- ✅ **Test**:
1323
- - Business logic (calculations, validations)
1324
- - API endpoints (status codes, response data)
1325
- - Model methods and properties
1326
- - Service layer functions
1327
- - Repository queries
1328
-
1329
- ❌ **Don't Test**:
1330
- - Django's built-in functionality
1331
- - Third-party libraries
1332
- - Simple getters/setters
1333
- - Auto-generated admin code
1334
-
1335
- ---
1336
-
1337
- ## TDD Workflow Example
1338
-
1339
- ### Feature: Add Profile Deactivation
1340
-
1341
- **Step 1: Write Failing Test**
1342
- ```python
1343
- def test_deactivate_profile_sets_is_active_false():
1344
- profile = ProfileFactory(is_active=True)
1345
- profile.deactivate()
1346
- assert profile.is_active is False
1347
- ```
1348
-
1349
- **Run**: `pytest tests/unit/test_models.py::test_deactivate_profile_sets_is_active_false`
1350
- **Result**: ❌ FAIL (AttributeError: 'Profile' object has no attribute 'deactivate')
1351
-
1352
- **Step 2: Minimal Implementation**
1353
- ```python
1354
- class Profile(models.Model):
1355
- # ... existing fields
1356
-
1357
- def deactivate(self):
1358
- self.is_active = False
1359
- self.save()
1360
- ```
1361
-
1362
- **Run**: `pytest tests/unit/test_models.py::test_deactivate_profile_sets_is_active_false`
1363
- **Result**: ✅ PASS
1364
-
1365
- **Step 3: Refactor (if needed)**
1366
- ```python
1367
- # Add additional test for edge case
1368
- def test_deactivate_already_inactive_profile_is_idempotent():
1369
- profile = ProfileFactory(is_active=False)
1370
- profile.deactivate()
1371
- assert profile.is_active is False # No error
1372
- ```
1373
-
1374
- **Step 4: Add API Test**
1375
- ```python
1376
- def test_deactivate_profile_via_api(api_client):
1377
- profile = ProfileFactory(is_active=True)
1378
- url = reverse("profile-deactivate", kwargs={"pk": profile.id})
1379
- response = api_client.post(url)
1380
-
1381
- assert response.status_code == status.HTTP_200_OK
1382
- profile.refresh_from_db()
1383
- assert profile.is_active is False
1384
- ```
1385
-
1386
- ---
1387
-
1388
- ## Summary
1389
-
1390
- This TDD specification provides:
1391
-
1392
- ✅ **Complete test stack**: pytest + factory_boy + Faker
1393
- ✅ **Test structure**: Unit, integration, E2E tests
1394
- ✅ **Code examples**: Models, services, repositories, API
1395
- ✅ **Best practices**: AAA pattern, descriptive names, isolation
1396
- ✅ **CI/CD ready**: Parallel execution, coverage reporting
1397
- ✅ **TDD workflow**: Red-Green-Refactor cycle
1398
-
1399
- **Philosophy**: Every line of production code has a test that drove its creation.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
SPEC-DEMO.md CHANGED
@@ -2,45 +2,49 @@
2
 
3
  ## Problem Overview
4
 
5
- The Great Wall of Westeros requires a tracking system for multi-profile wall construction operations. Each construction profile must track daily progress, ice material consumption, and associated costs.
6
 
7
  ### Business Rules
8
 
9
  - **Ice Consumption**: 195 cubic yards per linear foot of wall
10
  - **Ice Cost**: 1,900 Gold Dragons per cubic yard
11
  - **Daily Cost Formula**: `feet_built × 195 yd³/ft × 1,900 GD/yd³ = daily_cost`
 
 
 
12
 
13
  ### Requirements
14
 
15
- 1. Track multiple construction profiles simultaneously
16
- 2. Record daily wall construction progress (feet built per day)
17
- 3. Calculate daily ice usage for each profile
18
- 4. Provide cost overview reports with date range filtering
19
- 5. Support multi-threaded computation for aggregations
20
  6. Run in HuggingFace Docker Space (file-based, no external services)
21
 
22
  ## Technology Stack
23
 
24
  ### Core Framework
25
- - **Django 5.2.7 LTS** (released April 2, 2025)
26
- - Python 3.10-3.14 support
27
  - SQLite database (file-based persistence)
28
  - Built-in ORM with transaction support
 
29
 
30
- - **Django REST Framework 3.16** (released March 28, 2025)
31
- - ViewSets for CRUD operations
32
  - Serializers for data validation
33
- - Pagination and filtering support
34
 
35
  ### Multi-Threading
36
  - **Python concurrent.futures.ThreadPoolExecutor**
37
- - No external broker dependencies (Celery-free)
38
- - Configurable worker pool size
39
- - Suitable for CPU-bound aggregations
40
 
41
  ### Deployment
42
  - **HuggingFace Docker Space Compatible**
43
  - SQLite database file (`db.sqlite3`)
 
44
  - No PostgreSQL, Redis, or RabbitMQ required
45
  - Single container deployment
46
 
@@ -50,75 +54,49 @@ The Great Wall of Westeros requires a tracking system for multi-profile wall con
50
  ┌─────────────────────────────────────────────────────────────┐
51
  │ REST API Layer (DRF) │
52
  │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
53
- │ │ Profile │ │ Progress │ │ Analytics │ │
54
- │ │ ViewSet │ │ ViewSet │ │ ViewSet │ │
55
- └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
56
- └─────────┼──────────────────┼──────────────────┼─────────────┘
57
-
58
- ▼ ▼ ▼
 
 
59
  ┌─────────────────────────────────────────────────────────────┐
60
- Service Layer
61
- │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
62
- │ │ Profile │ │ Ice Usage │ │ Cost
63
- │ │ Service │ │ Calculator Aggregator │ │
64
- └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
65
- └───────────────────────────┼──────────────────┼─────────────
66
- │ │ │
67
- ▼ ▼ ▼
68
- ┌─────────────────────────────────────────────────────────────┐
69
- │ Repository Layer │
70
- │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
71
- │ │ Profile │ │ Wall │ │ Daily │ │
72
- │ │ Repository │ │ Section │ │ Progress │ │
73
- │ │ │ │ Repository │ │ Repository │ │
74
- │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
75
- └─────────┼──────────────────┼──────────────────┼─────────────┘
76
- │ │ │
77
- ▼ ▼ ▼
78
  ┌─────────────────────────────────────────────────────────────┐
79
  │ Django ORM + SQLite Database │
80
  │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
81
  │ │ Profile │ │ WallSection │ │ Daily │ │
82
- │ │ Model Model │ Progress │ │
83
- │ │ │ │ │ │ Model │ │
 
 
 
 
84
  │ └──────────────┘ └──────────────┘ └──────────────┘ │
85
  └─────────────────────────────────────────────────────────────┘
86
  ```
87
 
88
- ### ThreadPoolExecutor Integration
89
-
90
- ```python
91
- from concurrent.futures import ThreadPoolExecutor
92
- from django.conf import settings
93
-
94
- # services/cost_aggregator.py
95
- class CostAggregator:
96
- def __init__(self):
97
- self.executor = ThreadPoolExecutor(
98
- max_workers=settings.WORKER_POOL_SIZE
99
- )
100
-
101
- def calculate_parallel_costs(self, profiles, date_range):
102
- futures = [
103
- self.executor.submit(self._calculate_cost, profile, date_range)
104
- for profile in profiles
105
- ]
106
- return [f.result() for f in futures]
107
- ```
108
-
109
  ## Data Models
110
 
111
  ### Profile Model
112
  ```python
113
- from django.db import models
114
-
115
  class Profile(models.Model):
116
  """Construction profile for wall building operations."""
117
  name = models.CharField(max_length=255, unique=True)
118
  team_lead = models.CharField(max_length=255)
 
119
  created_at = models.DateTimeField(auto_now_add=True)
120
  updated_at = models.DateTimeField(auto_now=True)
121
- is_active = models.BooleanField(default=True)
122
 
123
  class Meta:
124
  db_table = 'profiles'
@@ -135,8 +113,16 @@ class WallSection(models.Model):
135
  related_name='wall_sections'
136
  )
137
  section_name = models.CharField(max_length=255)
138
- start_position = models.DecimalField(max_digits=10, decimal_places=2)
139
- target_length_feet = models.DecimalField(max_digits=10, decimal_places=2)
 
 
 
 
 
 
 
 
140
  created_at = models.DateTimeField(auto_now_add=True)
141
 
142
  class Meta:
@@ -178,6 +164,31 @@ class DailyProgress(models.Model):
178
  ]
179
  ```
180
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  ## API Endpoints
182
 
183
  ### Base URL
@@ -185,380 +196,367 @@ class DailyProgress(models.Model):
185
  http://localhost:8000/api/
186
  ```
187
 
188
- ### 1. List Profiles
189
  ```http
190
- GET /api/profiles/
 
 
 
 
 
 
 
191
  ```
192
 
193
- **Response**
194
  ```json
195
  {
196
- "count": 2,
197
- "next": null,
198
- "previous": null,
199
- "results": [
200
- {
201
- "id": 1,
202
- "name": "Northern Watch",
203
- "team_lead": "Jon Snow",
204
- "is_active": true,
205
- "created_at": "2025-10-01T08:00:00Z"
206
- }
207
- ]
208
  }
209
  ```
210
 
211
- ### 2. Create Profile
 
 
 
 
 
212
  ```http
213
- POST /api/profiles/
214
- Content-Type: application/json
215
 
 
 
216
  {
217
- "name": "Eastern Defense",
218
- "team_lead": "Tormund Giantsbane",
219
- "is_active": true
 
 
 
 
 
 
 
220
  }
221
  ```
222
 
223
- ### 3. Record Daily Progress
224
  ```http
225
- POST /api/profiles/{profile_id}/progress/
226
- Content-Type: application/json
227
-
228
- {
229
- "wall_section_id": 5,
230
- "date": "2025-10-15",
231
- "feet_built": 12.5,
232
- "notes": "Clear weather, good progress"
233
- }
234
  ```
235
 
236
  **Response**
237
  ```json
238
  {
239
- "id": 42,
240
- "wall_section_id": 5,
241
- "date": "2025-10-15",
242
- "feet_built": "12.50",
243
- "ice_cubic_yards": "2437.50",
244
- "cost_gold_dragons": "4631250.00",
245
- "notes": "Clear weather, good progress",
246
- "created_at": "2025-10-15T14:30:00Z"
247
  }
248
  ```
249
 
250
- **Calculation**
251
- - Ice usage: 12.5 feet × 195 yd³/ft = 2,437.5 yd³
252
- - Cost: 2,437.5 yd³ × 1,900 GD/yd³ = 4,631,250 GD
253
-
254
- ### 4. Daily Ice Usage by Profile
255
  ```http
256
- GET /api/profiles/{profile_id}/daily-ice-usage/?date=2025-10-15
257
  ```
258
 
259
  **Response**
260
  ```json
261
  {
262
- "profile_id": 1,
263
- "profile_name": "Northern Watch",
264
- "date": "2025-10-15",
265
- "total_feet_built": "28.75",
266
- "total_ice_cubic_yards": "5606.25",
267
- "sections": [
268
- {
269
- "section_name": "Tower 1-2",
270
- "feet_built": "12.50",
271
- "ice_cubic_yards": "2437.50"
272
- },
273
- {
274
- "section_name": "Tower 2-3",
275
- "feet_built": "16.25",
276
- "ice_cubic_yards": "3168.75"
277
- }
278
- ]
279
  }
280
  ```
281
 
282
- ### 5. Cost Overview with Date Range
283
  ```http
284
- GET /api/profiles/{profile_id}/cost-overview/?start_date=2025-10-01&end_date=2025-10-15
285
  ```
286
 
287
  **Response**
288
  ```json
289
  {
290
- "profile_id": 1,
291
- "profile_name": "Northern Watch",
292
- "date_range": {
293
- "start": "2025-10-01",
294
- "end": "2025-10-15"
295
- },
296
- "summary": {
297
- "total_days": 15,
298
- "total_feet_built": "425.50",
299
- "total_ice_cubic_yards": "82972.50",
300
- "total_cost_gold_dragons": "157647750.00",
301
- "average_feet_per_day": "28.37",
302
- "average_cost_per_day": "10509850.00"
303
- },
304
- "daily_breakdown": [
305
- {
306
- "date": "2025-10-15",
307
- "feet_built": "28.75",
308
- "ice_cubic_yards": "5606.25",
309
- "cost_gold_dragons": "10651875.00"
310
- },
311
- {
312
- "date": "2025-10-14",
313
- "feet_built": "31.00",
314
- "ice_cubic_yards": "6045.00",
315
- "cost_gold_dragons": "11485500.00"
316
- }
317
- ]
318
  }
319
  ```
320
 
321
- ## Multi-Threading Implementation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
 
323
- ### Cost Aggregation Service
324
 
325
  ```python
326
- from concurrent.futures import ThreadPoolExecutor, as_completed
327
- from decimal import Decimal
328
- from django.conf import settings
329
- from django.db import transaction
330
- from django.db.models import Sum
331
 
332
- class CostAggregatorService:
333
- """
334
- Service for parallel cost calculations across multiple profiles.
335
- Uses ThreadPoolExecutor for CPU-bound aggregation tasks.
336
- """
337
 
338
- def __init__(self, max_workers: int | None = None):
339
- self.max_workers = max_workers or settings.WORKER_POOL_SIZE
340
- self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
 
 
 
 
 
 
 
 
 
 
341
 
342
- def calculate_multi_profile_costs(
343
- self,
344
- profile_ids: list[int],
345
- start_date: str,
346
- end_date: str
347
- ) -> list[dict]:
348
- """
349
- Calculate costs for multiple profiles in parallel.
350
-
351
- Args:
352
- profile_ids: List of profile IDs to process
353
- start_date: Start date (YYYY-MM-DD)
354
- end_date: End date (YYYY-MM-DD)
355
-
356
- Returns:
357
- List of cost summaries per profile
358
- """
359
- futures = {
360
- self.executor.submit(
361
- self._calculate_profile_cost,
362
- profile_id,
363
- start_date,
364
- end_date
365
- ): profile_id
366
- for profile_id in profile_ids
367
- }
368
-
369
- results = []
370
- for future in as_completed(futures):
371
- profile_id = futures[future]
372
  try:
373
- result = future.result()
374
- results.append(result)
375
- except Exception as exc:
376
- # Log error and continue with other profiles
377
- logger.error(
378
- f"Profile {profile_id} cost calculation failed: {exc}"
 
 
 
 
 
 
 
379
  )
380
- results.append({
381
- "profile_id": profile_id,
382
- "error": str(exc)
383
- })
384
-
385
- return results
386
 
387
- def _calculate_profile_cost(
388
- self,
389
- profile_id: int,
390
- start_date: str,
391
- end_date: str
392
- ) -> dict:
393
- """Calculate cost summary for a single profile."""
394
- from .repositories import DailyProgressRepository
395
-
396
- repo = DailyProgressRepository()
397
-
398
- # Use Django ORM aggregation for efficient DB queries
399
- aggregates = repo.get_aggregates_by_profile(
400
- profile_id,
401
- start_date,
402
- end_date
403
- )
404
 
405
- return {
406
- "profile_id": profile_id,
407
- "total_feet_built": str(aggregates["total_feet"]),
408
- "total_ice_cubic_yards": str(aggregates["total_ice"]),
409
- "total_cost_gold_dragons": str(aggregates["total_cost"]),
410
- "calculation_thread": threading.current_thread().name
411
- }
412
 
413
- def shutdown(self):
414
- """Gracefully shutdown the thread pool."""
415
- self.executor.shutdown(wait=True)
416
  ```
417
 
418
- ### Configuration
419
 
420
  ```python
421
- # settings.py
422
- WORKER_POOL_SIZE = 4 # Configurable based on container resources
423
- ```
424
 
425
- ### Usage in ViewSet
 
426
 
427
- ```python
428
- from rest_framework import viewsets
429
- from rest_framework.decorators import action
430
- from rest_framework.response import Response
431
 
432
- class ProfileViewSet(viewsets.ModelViewSet):
 
 
 
 
 
433
 
434
- @action(detail=False, methods=['get'])
435
- def bulk_cost_overview(self, request):
436
- """Calculate costs for multiple profiles in parallel."""
437
- profile_ids = request.query_params.getlist('profile_ids[]')
438
- start_date = request.query_params.get('start_date')
439
- end_date = request.query_params.get('end_date')
440
 
441
- aggregator = CostAggregatorService()
442
- try:
443
- results = aggregator.calculate_multi_profile_costs(
444
- profile_ids,
445
- start_date,
446
- end_date
447
- )
448
- return Response({"results": results})
449
- finally:
450
- aggregator.shutdown()
451
- ```
452
 
453
- ## Service Layer Design
 
 
454
 
455
- ### IceUsageCalculator
 
456
 
457
- ```python
458
- from decimal import Decimal
459
 
460
- class IceUsageCalculator:
461
- """Business logic for ice usage calculations."""
462
 
463
- ICE_PER_FOOT = Decimal("195") # cubic yards per foot
464
- COST_PER_CUBIC_YARD = Decimal("1900") # Gold Dragons
465
 
466
- @classmethod
467
- def calculate_ice_usage(cls, feet_built: Decimal) -> Decimal:
468
- """Calculate ice usage in cubic yards."""
469
- return feet_built * cls.ICE_PER_FOOT
470
 
471
- @classmethod
472
- def calculate_cost(cls, ice_cubic_yards: Decimal) -> Decimal:
473
- """Calculate cost in Gold Dragons."""
474
- return ice_cubic_yards * cls.COST_PER_CUBIC_YARD
475
 
476
- @classmethod
477
- def calculate_full_cost(cls, feet_built: Decimal) -> tuple[Decimal, Decimal]:
478
- """Calculate both ice usage and cost."""
479
- ice = cls.calculate_ice_usage(feet_built)
480
- cost = cls.calculate_cost(ice)
481
- return ice, cost
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  ```
483
 
484
- ## Repository Layer Design
485
 
486
- ### DailyProgressRepository
487
 
488
  ```python
489
- from django.db.models import Sum, Avg, Count
490
- from decimal import Decimal
491
-
492
- class DailyProgressRepository:
493
- """Data access layer for DailyProgress model."""
 
 
 
 
 
494
 
495
- def get_by_date(self, profile_id: int, date: str):
496
- """Retrieve all progress records for a profile on a specific date."""
497
- return DailyProgress.objects.filter(
498
- wall_section__profile_id=profile_id,
499
- date=date
500
- ).select_related('wall_section')
501
 
502
- def get_aggregates_by_profile(
503
- self,
504
- profile_id: int,
505
- start_date: str,
506
- end_date: str
507
- ) -> dict:
508
- """Get aggregated statistics for a profile within date range."""
509
- result = DailyProgress.objects.filter(
510
- wall_section__profile_id=profile_id,
511
- date__gte=start_date,
512
- date__lte=end_date
513
- ).aggregate(
514
- total_feet=Sum('feet_built'),
515
- total_ice=Sum('ice_cubic_yards'),
516
- total_cost=Sum('cost_gold_dragons'),
517
- avg_feet=Avg('feet_built'),
518
- record_count=Count('id')
519
- )
520
 
521
- # Handle None values for empty querysets
522
- return {
523
- "total_feet": result["total_feet"] or Decimal("0"),
524
- "total_ice": result["total_ice"] or Decimal("0"),
525
- "total_cost": result["total_cost"] or Decimal("0"),
526
- "avg_feet": result["avg_feet"] or Decimal("0"),
527
- "record_count": result["record_count"]
528
- }
529
  ```
530
 
531
- ## HuggingFace Space Deployment
532
 
533
- ### Requirements
534
 
535
  ```python
536
- # requirements.txt
537
- Django==5.2.7
538
- djangorestframework==3.16.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
539
  ```
540
 
541
- ### Dockerfile
542
 
 
 
 
543
  ```dockerfile
544
  FROM python:3.12-slim
545
-
546
  WORKDIR /app
547
-
548
  COPY requirements.txt .
549
  RUN pip install --no-cache-dir -r requirements.txt
550
-
551
  COPY . .
552
-
553
- # Run migrations and start server
554
  CMD python manage.py migrate && \
555
  python manage.py runserver 0.0.0.0:7860
556
  ```
557
 
558
- ### Space Configuration
559
-
560
  ```yaml
561
- # README.md (HuggingFace Space header)
562
  ---
563
  title: Wall Construction API
564
  emoji: 🏰
@@ -569,99 +567,63 @@ app_port: 7860
569
  ---
570
  ```
571
 
572
- ### Database Persistence
573
-
574
- - SQLite database file: `db.sqlite3`
575
- - Persisted in HuggingFace Space persistent storage
576
- - Automatic migrations on container startup
577
- - No external database service required
578
-
579
- ## Error Handling
580
-
581
- ### Standard Error Response
582
-
583
- ```json
584
- {
585
- "error": "validation_error",
586
- "message": "Invalid date format",
587
- "details": {
588
- "date": ["Date must be in YYYY-MM-DD format"]
589
- }
590
- }
591
- ```
592
-
593
- ### HTTP Status Codes
594
 
595
- - `200 OK` - Successful GET request
596
- - `201 Created` - Successful POST request
597
- - `400 Bad Request` - Validation error
598
- - `404 Not Found` - Resource not found
599
- - `500 Internal Server Error` - Server error
600
-
601
- ## Testing Strategy
602
-
603
- ### Unit Tests
604
- - Service layer: IceUsageCalculator calculations
605
- - Repository layer: Query correctness
606
- - Model layer: Validation rules
607
-
608
- ### Integration Tests
609
- - API endpoints with test database
610
- - ThreadPoolExecutor parallel execution
611
- - Full request/response cycle
612
-
613
- ### Test Data
614
- - Sample profiles with known outputs
615
- - Edge cases: zero feet built, large numbers
616
- - Date range boundaries
617
-
618
- ## Performance Considerations
619
 
620
  ### Database Optimization
621
- - Indexes on `date` and `wall_section_id` fields
622
- - `select_related()` for FK queries
623
- - `aggregate()` for sum/avg calculations
624
- - Database connection pooling (built into Django)
625
 
626
  ### Thread Pool Sizing
627
- - Default: 4 workers
628
- - Configurable via `WORKER_POOL_SIZE` setting
629
- - Balance between parallelism and resource usage
630
- - HuggingFace Space constraints: 2-4 workers recommended
 
 
631
 
632
- ### Query Optimization
633
  ```python
634
- # Good: Single query with aggregation
635
- DailyProgress.objects.filter(...).aggregate(Sum('cost_gold_dragons'))
636
 
637
- # Bad: Multiple queries in loop
638
- for progress in DailyProgress.objects.filter(...):
639
- total += progress.cost_gold_dragons
640
  ```
641
 
642
- ## Future Enhancements
643
-
644
- 1. **Caching Layer**: Redis cache for frequently accessed aggregations
645
- 2. **Async Views**: Upgrade to Django 5.x async views when DRF adds native support
646
- 3. **Background Tasks**: True Celery integration for long-running reports
647
- 4. **PostgreSQL**: Upgrade to PostgreSQL for production deployments
648
- 5. **Metrics Dashboard**: Real-time construction progress visualization
649
- 6. **Export Features**: CSV/PDF report generation
650
- 7. **Authentication**: Token-based API authentication
651
- 8. **Rate Limiting**: Throttling for cost-intensive aggregations
652
-
653
- ## Appendix: Constants
654
 
655
  ```python
656
- # constants.py
657
- from decimal import Decimal
 
 
 
 
 
 
 
 
658
 
659
- # Wall Construction Constants
660
- ICE_CUBIC_YARDS_PER_FOOT = Decimal("195")
661
- GOLD_DRAGONS_PER_CUBIC_YARD = Decimal("1900")
662
 
663
- # Calculated Constants
664
- GOLD_DRAGONS_PER_FOOT = (
665
- ICE_CUBIC_YARDS_PER_FOOT * GOLD_DRAGONS_PER_CUBIC_YARD
666
- ) # 370,500 GD per foot
 
 
 
 
 
 
 
667
  ```
 
2
 
3
  ## Problem Overview
4
 
5
+ The Great Wall of Westeros requires a simulation system for multi-profile wall construction operations. The system must parse configuration files specifying wall sections with varying heights, simulate concurrent team construction, and track daily progress with ice consumption and cost metrics.
6
 
7
  ### Business Rules
8
 
9
  - **Ice Consumption**: 195 cubic yards per linear foot of wall
10
  - **Ice Cost**: 1,900 Gold Dragons per cubic yard
11
  - **Daily Cost Formula**: `feet_built × 195 yd³/ft × 1,900 GD/yd³ = daily_cost`
12
+ - **Target Height**: All sections must reach 30 feet
13
+ - **Daily Build Rate**: 1 foot per team per day
14
+ - **Team Assignment**: Round-robin across active sections
15
 
16
  ### Requirements
17
 
18
+ 1. Parse multi-profile configuration (heights per section per profile)
19
+ 2. Simulate concurrent wall construction with configurable team count
20
+ 3. Track daily progress with automatic ice/cost calculations
21
+ 4. Generate team activity logs to file system
22
+ 5. Provide simulation overview endpoints
23
  6. Run in HuggingFace Docker Space (file-based, no external services)
24
 
25
  ## Technology Stack
26
 
27
  ### Core Framework
28
+ - **Django 5.2.7** (Python 3.12.3)
 
29
  - SQLite database (file-based persistence)
30
  - Built-in ORM with transaction support
31
+ - Migration system
32
 
33
+ - **Django REST Framework 3.16**
34
+ - ViewSets for CRUD and custom actions
35
  - Serializers for data validation
36
+ - Pagination support
37
 
38
  ### Multi-Threading
39
  - **Python concurrent.futures.ThreadPoolExecutor**
40
+ - Parallel wall section processing during simulation
41
+ - No external broker dependencies
42
+ - Configurable worker pool (default: 10 workers)
43
 
44
  ### Deployment
45
  - **HuggingFace Docker Space Compatible**
46
  - SQLite database file (`db.sqlite3`)
47
+ - File-based team logs (`logs/team_*.log`)
48
  - No PostgreSQL, Redis, or RabbitMQ required
49
  - Single container deployment
50
 
 
54
  ┌─────────────────────────────────────────────────────────────┐
55
  │ REST API Layer (DRF) │
56
  │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
57
+ │ │ Profile │ │ WallSection │ │ Daily │ │
58
+ │ │ ViewSet │ │ ViewSet │ │ Progress │ │
59
+ + simulate │ │ (CRUD) │ │ ViewSet
60
+ │ │ + overview │ │ │ │ (CRUD) │ │
61
+ └──────┬───────┘ └──────────────┘ └──────────────┘
62
+ └─────────┼──────────────────────────────────────────────────┘
63
+
64
+
65
  ┌─────────────────────────────────────────────────────────────┐
66
+ Simulation Engine
67
+ │ ┌──────────────┐ ┌──────────────┐
68
+ │ │ Config │ │ Wall
69
+ │ │ Parser │ │ Simulator
70
+ │ │ │ +ThreadPool
71
+ └──────────────┘ └─────────────┘
72
+ └──────────────────────────┼──────────────────────────────────┘
73
+
74
+
 
 
 
 
 
 
 
 
 
75
  ┌─────────────────────────────────────────────────────────────┐
76
  │ Django ORM + SQLite Database │
77
  │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
78
  │ │ Profile │ │ WallSection │ │ Daily │ │
79
+ │ │ ── ──│ Progress │ │
80
+ │ │ - name │ │ - profile │ │ - section │ │
81
+ │ │ - lead │ │ - name │ │ - date │ │
82
+ │ │ - active │ │ - initial_h │ │ - feet │ │
83
+ │ │ │ │ - current_h │ │ - ice │ │
84
+ │ │ │ │ │ │ - cost │ │
85
  │ └──────────────┘ └──────────────┘ └──────────────┘ │
86
  └─────────────────────────────────────────────────────────────┘
87
  ```
88
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  ## Data Models
90
 
91
  ### Profile Model
92
  ```python
 
 
93
  class Profile(models.Model):
94
  """Construction profile for wall building operations."""
95
  name = models.CharField(max_length=255, unique=True)
96
  team_lead = models.CharField(max_length=255)
97
+ is_active = models.BooleanField(default=True)
98
  created_at = models.DateTimeField(auto_now_add=True)
99
  updated_at = models.DateTimeField(auto_now=True)
 
100
 
101
  class Meta:
102
  db_table = 'profiles'
 
113
  related_name='wall_sections'
114
  )
115
  section_name = models.CharField(max_length=255)
116
+ initial_height = models.IntegerField(
117
+ null=True,
118
+ blank=True,
119
+ help_text="Initial height in feet (0-30) for simulation"
120
+ )
121
+ current_height = models.IntegerField(
122
+ null=True,
123
+ blank=True,
124
+ help_text="Current height in feet during simulation"
125
+ )
126
  created_at = models.DateTimeField(auto_now_add=True)
127
 
128
  class Meta:
 
164
  ]
165
  ```
166
 
167
+ ## Configuration Format
168
+
169
+ ### Multi-Profile Config
170
+ ```
171
+ 21 25 28
172
+ 17
173
+ 17 22 17 19 17
174
+ ```
175
+
176
+ **Rules:**
177
+ - Each line = 1 profile
178
+ - Space-separated integers = wall section heights (0-30 feet)
179
+ - Max 2000 sections per profile
180
+ - Empty lines ignored
181
+ - Whitespace trimmed
182
+
183
+ ### Example
184
+ ```
185
+ 5 10 15
186
+ ```
187
+ Creates:
188
+ - 1 profile ("Profile 1", "Team Lead 1")
189
+ - 3 wall sections at heights 5ft, 10ft, 15ft
190
+ - Each must reach 30ft
191
+
192
  ## API Endpoints
193
 
194
  ### Base URL
 
196
  http://localhost:8000/api/
197
  ```
198
 
199
+ ### 1. Run Simulation
200
  ```http
201
+ POST /api/profiles/simulate/
202
+ Content-Type: application/json
203
+
204
+ {
205
+ "config": "21 25 28\n17\n17 22 17 19 17",
206
+ "num_teams": 10,
207
+ "start_date": "2025-10-20"
208
+ }
209
  ```
210
 
211
+ **Response (201 Created)**
212
  ```json
213
  {
214
+ "total_profiles": 3,
215
+ "total_sections": 9,
216
+ "total_days": 15,
217
+ "total_ice_cubic_yards": "82875.00",
218
+ "total_cost_gold_dragons": "157462500.00"
 
 
 
 
 
 
 
219
  }
220
  ```
221
 
222
+ **Validation:**
223
+ - `config`: Required, non-empty string
224
+ - `num_teams`: Optional integer (default: 10)
225
+ - `start_date`: Optional YYYY-MM-DD (default: today)
226
+
227
+ ### 2. Daily Ice Usage
228
  ```http
229
+ GET /api/profiles/{profile_id}/days/{day}/
230
+ ```
231
 
232
+ **Response**
233
+ ```json
234
  {
235
+ "day": 3,
236
+ "total_feet_built": "10.00",
237
+ "total_ice_cubic_yards": "1950.00",
238
+ "sections": [
239
+ {
240
+ "section_name": "Section 1",
241
+ "feet_built": "1.00",
242
+ "ice_cubic_yards": "195.00"
243
+ }
244
+ ]
245
  }
246
  ```
247
 
248
+ ### 3. Overview by Day (Single Profile)
249
  ```http
250
+ GET /api/profiles/{profile_id}/overview/{day}/
 
 
 
 
 
 
 
 
251
  ```
252
 
253
  **Response**
254
  ```json
255
  {
256
+ "day": 5,
257
+ "cost": "92625000.00"
 
 
 
 
 
 
258
  }
259
  ```
260
 
261
+ ### 4. Overview by Day (All Profiles)
 
 
 
 
262
  ```http
263
+ GET /api/profiles/overview/{day}/
264
  ```
265
 
266
  **Response**
267
  ```json
268
  {
269
+ "day": 10,
270
+ "cost": "157462500.00"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  }
272
  ```
273
 
274
+ ### 5. Total Overview
275
  ```http
276
+ GET /api/profiles/overview/
277
  ```
278
 
279
  **Response**
280
  ```json
281
  {
282
+ "day": null,
283
+ "cost": "157462500.00"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  }
285
  ```
286
 
287
+ ### CRUD Endpoints
288
+
289
+ **Profiles**
290
+ - `GET /api/profiles/` - List all
291
+ - `POST /api/profiles/` - Create
292
+ - `GET /api/profiles/{id}/` - Retrieve
293
+ - `PUT /api/profiles/{id}/` - Update
294
+ - `PATCH /api/profiles/{id}/` - Partial update
295
+ - `DELETE /api/profiles/{id}/` - Delete
296
+
297
+ **WallSections**
298
+ - `GET /api/wallsections/` - List all
299
+ - `POST /api/wallsections/` - Create
300
+ - `GET /api/wallsections/{id}/` - Retrieve
301
+ - `PUT /api/wallsections/{id}/` - Update
302
+ - `DELETE /api/wallsections/{id}/` - Delete
303
+ - Query param: `?profile={id}` - Filter by profile
304
+
305
+ **DailyProgress**
306
+ - `GET /api/progress/` - List all
307
+ - `POST /api/progress/` - Create (auto-calculates ice/cost)
308
+ - `GET /api/progress/{id}/` - Retrieve
309
+ - `PUT /api/progress/{id}/` - Update
310
+ - `DELETE /api/progress/{id}/` - Delete
311
+
312
+ ## Simulation Engine
313
 
314
+ ### ConfigParser
315
 
316
  ```python
317
+ @dataclass
318
+ class ProfileConfig:
319
+ """Configuration for a single profile's wall sections."""
320
+ profile_num: int
321
+ heights: list[int]
322
 
323
+ class ConfigParser:
324
+ """Parse multi-profile wall construction configuration."""
 
 
 
325
 
326
+ MAX_HEIGHT = 30
327
+ MAX_SECTIONS_PER_PROFILE = 2000
328
+
329
+ @classmethod
330
+ def parse(cls, config_text: str) -> list[ProfileConfig]:
331
+ """Parse config string into ProfileConfig objects."""
332
+ profiles: list[ProfileConfig] = []
333
+ lines = config_text.strip().split("\n")
334
+
335
+ for line_num, raw_line in enumerate(lines, 1):
336
+ line_text = raw_line.strip()
337
+ if not line_text:
338
+ continue # Skip empty lines
339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  try:
341
+ heights = [int(h) for h in line_text.split()]
342
+ except ValueError as e:
343
+ raise ValueError(f"Line {line_num}: Invalid number format") from e
344
+
345
+ for height in heights:
346
+ if not 0 <= height <= cls.MAX_HEIGHT:
347
+ raise ValueError(
348
+ f"Line {line_num}: Height {height} out of range"
349
+ )
350
+
351
+ if len(heights) > cls.MAX_SECTIONS_PER_PROFILE:
352
+ raise ValueError(
353
+ f"Line {line_num}: Too many sections (max {cls.MAX_SECTIONS_PER_PROFILE})"
354
  )
 
 
 
 
 
 
355
 
356
+ profiles.append(ProfileConfig(profile_num=line_num, heights=heights))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
 
358
+ if not profiles:
359
+ raise ValueError("Config must contain at least one profile")
 
 
 
 
 
360
 
361
+ return profiles
 
 
362
  ```
363
 
364
+ ### WallSimulator
365
 
366
  ```python
367
+ class WallSimulator:
368
+ """Simulate wall construction with parallel processing."""
 
369
 
370
+ TARGET_HEIGHT = 30
371
+ FEET_PER_DAY = 1
372
 
373
+ def __init__(self, num_teams: int = 10):
374
+ self.num_teams = num_teams
375
+ self.executor = ThreadPoolExecutor(max_workers=num_teams)
 
376
 
377
+ def simulate(
378
+ self,
379
+ profiles_config: list[ProfileConfig],
380
+ start_date: date
381
+ ) -> SimulationSummary:
382
+ """Run simulation from config."""
383
 
384
+ # 1. Initialize profiles and sections in database
385
+ section_data = self._initialize_profiles(profiles_config)
 
 
 
 
386
 
387
+ # 2. Simulate day-by-day until all sections reach 30ft
388
+ day = 1
389
+ current_date = start_date
 
 
 
 
 
 
 
 
390
 
391
+ while any(s.current_height < self.TARGET_HEIGHT for s in section_data):
392
+ # 3. Assign work (round-robin up to num_teams)
393
+ sections_to_process = self._assign_work(section_data)
394
 
395
+ if not sections_to_process:
396
+ break # No more work to assign
397
 
398
+ # 4. Process sections in parallel using ThreadPoolExecutor
399
+ results = self._process_day(sections_to_process, day)
400
 
401
+ # 5. Save progress to database
402
+ self._save_progress(results, current_date)
403
 
404
+ # 6. Update section heights
405
+ self._update_heights(section_data, results)
406
 
407
+ day += 1
408
+ current_date += timedelta(days=1)
 
 
409
 
410
+ # 7. Calculate totals
411
+ return self._calculate_summary(section_data, day - 1)
 
 
412
 
413
+ def _process_day(
414
+ self,
415
+ sections: list[SectionData],
416
+ day: int
417
+ ) -> list[ProcessingResult]:
418
+ """Process sections in parallel."""
419
+ futures = [
420
+ self.executor.submit(self._process_section, section, day)
421
+ for section in sections
422
+ ]
423
+ return [f.result() for f in futures]
424
+
425
+ def _process_section(
426
+ self,
427
+ section: SectionData,
428
+ day: int
429
+ ) -> ProcessingResult:
430
+ """Process single section (runs in thread)."""
431
+ feet_built = self.FEET_PER_DAY
432
+ remaining = self.TARGET_HEIGHT - section.current_height
433
+
434
+ if feet_built > remaining:
435
+ feet_built = remaining
436
+
437
+ ice = Decimal(str(feet_built)) * ICE_PER_FOOT
438
+ cost = ice * COST_PER_CUBIC_YARD
439
+
440
+ # Write team log
441
+ self._write_log(section.team_num, day, section.section_num, feet_built)
442
+
443
+ return ProcessingResult(
444
+ section_id=section.id,
445
+ feet_built=Decimal(str(feet_built)),
446
+ ice_cubic_yards=ice,
447
+ cost_gold_dragons=cost
448
+ )
449
  ```
450
 
451
+ ## Multi-Threading Details
452
 
453
+ ### ThreadPoolExecutor Usage
454
 
455
  ```python
456
+ # Initialization (in WallSimulator.__init__)
457
+ self.executor = ThreadPoolExecutor(max_workers=num_teams)
458
+
459
+ # Parallel section processing (in _process_day)
460
+ futures = [
461
+ self.executor.submit(self._process_section, section, day)
462
+ for section in sections_to_process
463
+ ]
464
+ results = [f.result() for f in futures]
465
+ ```
466
 
467
+ **Benefits:**
468
+ - Each wall section processed in separate thread
469
+ - Up to `num_teams` sections processed concurrently
470
+ - Simulates real concurrent construction
471
+ - No GIL contention (I/O-bound file writes)
 
472
 
473
+ ### Log File Output
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
474
 
475
+ Team logs written to `logs/team_{N}.log`:
476
+ ```
477
+ Team 1: working on Profile 1, Section 1, building 1.00ft
478
+ Team 1: working on Profile 1, Section 2, building 1.00ft
479
+ Team 1: Section 3 completed!
480
+ Team 1: relieved
 
 
481
  ```
482
 
483
+ ## Database Calculations
484
 
485
+ ### Auto-Calculated Fields (DailyProgressSerializer)
486
 
487
  ```python
488
+ def create(self, validated_data):
489
+ """Auto-calculate ice and cost from feet_built."""
490
+ feet_built = validated_data['feet_built']
491
+
492
+ ice_cubic_yards = feet_built * ICE_PER_FOOT # 195 yd³/ft
493
+ cost_gold_dragons = ice_cubic_yards * COST_PER_CUBIC_YARD # 1900 GD/yd³
494
+
495
+ return DailyProgress.objects.create(
496
+ **validated_data,
497
+ ice_cubic_yards=ice_cubic_yards,
498
+ cost_gold_dragons=cost_gold_dragons
499
+ )
500
+ ```
501
+
502
+ ### Aggregations (Overview Endpoints)
503
+
504
+ ```python
505
+ # Total cost across all progress records
506
+ daily_progress = DailyProgress.objects.all()
507
+ aggregates = daily_progress.aggregate(total_cost=Sum("cost_gold_dragons"))
508
+ total_cost = aggregates["total_cost"] or Decimal("0.00")
509
+ ```
510
+
511
+ ## Testing
512
+
513
+ ### Test Coverage
514
+ - **73 tests** across unit/integration/edge cases
515
+ - **98.41% code coverage**
516
+ - **0 MyPy/Ruff errors**
517
+
518
+ ### Test Categories
519
+
520
+ **Unit Tests:**
521
+ - Model validation and constraints
522
+ - ConfigParser edge cases
523
+ - WallSimulator logic
524
+ - Serializer auto-calculations
525
+
526
+ **Integration Tests:**
527
+ - Full simulation workflow
528
+ - API endpoint responses
529
+ - Database persistence
530
+ - CRUD operations
531
+
532
+ **Edge Cases:**
533
+ - Invalid date format handling
534
+ - Profile with no simulation data
535
+ - Empty database queries
536
+ - Config parsing errors
537
+
538
+ ### Running Tests
539
+ ```bash
540
+ ./scripts/run_tests.py
541
  ```
542
 
543
+ ## Deployment
544
 
545
+ ### HuggingFace Space
546
+
547
+ **Dockerfile**
548
  ```dockerfile
549
  FROM python:3.12-slim
 
550
  WORKDIR /app
 
551
  COPY requirements.txt .
552
  RUN pip install --no-cache-dir -r requirements.txt
 
553
  COPY . .
 
 
554
  CMD python manage.py migrate && \
555
  python manage.py runserver 0.0.0.0:7860
556
  ```
557
 
558
+ **Space Configuration (README.md)**
 
559
  ```yaml
 
560
  ---
561
  title: Wall Construction API
562
  emoji: 🏰
 
567
  ---
568
  ```
569
 
570
+ ### Persistence
571
+ - **Database**: `db.sqlite3` (auto-created, migrations applied on startup)
572
+ - **Logs**: `logs/team_*.log` (created during simulation)
573
+ - **No external services**: Self-contained deployment
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
 
575
+ ## Performance
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
576
 
577
  ### Database Optimization
578
+ - Indexes on `date` and `wall_section_id`
579
+ - `select_related()` for foreign key queries
580
+ - `aggregate()` for sum calculations
581
+ - Single atomic transactions per simulation
582
 
583
  ### Thread Pool Sizing
584
+ - Default: 10 workers (configurable via `num_teams` param)
585
+ - Each worker processes 1 section per day
586
+ - I/O-bound (file writes), minimal CPU contention
587
+ - Suitable for HuggingFace Space resource limits
588
+
589
+ ## Constants
590
 
 
591
  ```python
592
+ # constants.py
593
+ from decimal import Decimal
594
 
595
+ TARGET_HEIGHT = 30 # feet
596
+ ICE_PER_FOOT = Decimal("195") # cubic yards
597
+ COST_PER_CUBIC_YARD = Decimal("1900") # Gold Dragons
598
  ```
599
 
600
+ ## Example Workflow
 
 
 
 
 
 
 
 
 
 
 
601
 
602
  ```python
603
+ # 1. POST simulation config
604
+ POST /api/profiles/simulate/
605
+ {
606
+ "config": "5 10 15",
607
+ "num_teams": 10
608
+ }
609
+
610
+ # 2. Check total cost
611
+ GET /api/profiles/overview/
612
+ → {"day": null, "cost": "16965000.00"}
613
 
614
+ # 3. Check day 5 progress
615
+ GET /api/profiles/overview/5/
616
+ {"day": 5, "cost": "9262500.00"}
617
 
618
+ # 4. List all profiles
619
+ GET /api/profiles/
620
+ [{"id": 1, "name": "Profile 1", "team_lead": "Team Lead 1"}]
621
+
622
+ # 5. View section details
623
+ GET /api/wallsections/?profile=1
624
+ → [
625
+ {"section_name": "Section 1", "initial_height": 5, "current_height": 30},
626
+ {"section_name": "Section 2", "initial_height": 10, "current_height": 30},
627
+ {"section_name": "Section 3", "initial_height": 15, "current_height": 30}
628
+ ]
629
  ```
apps/profiles/__pycache__/models.cpython-312.pyc CHANGED
Binary files a/apps/profiles/__pycache__/models.cpython-312.pyc and b/apps/profiles/__pycache__/models.cpython-312.pyc 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/constants.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """Centralized constants for wall construction calculations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+
7
+ ICE_PER_FOOT = Decimal("195") # cubic yards per linear foot
8
+ COST_PER_CUBIC_YARD = Decimal("1900") # Gold Dragons per cubic yard
9
+ TARGET_HEIGHT = 30 # feet - maximum wall height
apps/profiles/migrations/0003_remove_wallsection_start_position_and_more.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Django 5.2.7 on 2025-10-20 23:05
2
+
3
+ from django.db import migrations
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+ dependencies = [
8
+ ("profiles", "0002_add_simulation_fields"),
9
+ ]
10
+
11
+ operations = [
12
+ migrations.RemoveField(
13
+ model_name="wallsection",
14
+ name="start_position",
15
+ ),
16
+ migrations.RemoveField(
17
+ model_name="wallsection",
18
+ name="target_length_feet",
19
+ ),
20
+ ]
apps/profiles/models.py CHANGED
@@ -2,8 +2,6 @@
2
 
3
  from __future__ import annotations
4
 
5
- from decimal import Decimal
6
-
7
  from django.db import models
8
 
9
 
@@ -34,8 +32,6 @@ class WallSection(models.Model):
34
  related_name="wall_sections",
35
  )
36
  section_name = models.CharField(max_length=255)
37
- start_position = models.DecimalField(max_digits=10, decimal_places=2)
38
- target_length_feet = models.DecimalField(max_digits=10, decimal_places=2)
39
  initial_height = models.IntegerField(
40
  null=True,
41
  blank=True,
@@ -61,9 +57,6 @@ class WallSection(models.Model):
61
  class DailyProgress(models.Model):
62
  """Daily construction progress for a wall section."""
63
 
64
- ICE_PER_FOOT = Decimal("195") # cubic yards per linear foot
65
- COST_PER_CUBIC_YARD = Decimal("1900") # Gold Dragons per cubic yard
66
-
67
  wall_section = models.ForeignKey(
68
  WallSection,
69
  on_delete=models.CASCADE,
 
2
 
3
  from __future__ import annotations
4
 
 
 
5
  from django.db import models
6
 
7
 
 
32
  related_name="wall_sections",
33
  )
34
  section_name = models.CharField(max_length=255)
 
 
35
  initial_height = models.IntegerField(
36
  null=True,
37
  blank=True,
 
57
  class DailyProgress(models.Model):
58
  """Daily construction progress for a wall section."""
59
 
 
 
 
60
  wall_section = models.ForeignKey(
61
  WallSection,
62
  on_delete=models.CASCADE,
apps/profiles/repositories.py DELETED
@@ -1,64 +0,0 @@
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
- # Normalize all decimals to 2 decimal places
57
- two_places = Decimal("0.01")
58
- return {
59
- "total_feet": (Decimal("0") if result["total_feet"] is None else result["total_feet"]).quantize(two_places),
60
- "total_ice": (Decimal("0") if result["total_ice"] is None else result["total_ice"]).quantize(two_places),
61
- "total_cost": (Decimal("0") if result["total_cost"] is None else result["total_cost"]).quantize(two_places),
62
- "avg_feet": (Decimal("0") if result["avg_feet"] is None else result["avg_feet"]).quantize(two_places),
63
- "record_count": result["record_count"],
64
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/profiles/serializers.py CHANGED
@@ -7,8 +7,8 @@ from typing import cast
7
 
8
  from rest_framework import serializers
9
 
 
10
  from apps.profiles.models import DailyProgress, Profile, WallSection
11
- from apps.profiles.services.calculators import IceUsageCalculator
12
 
13
 
14
  class ProfileSerializer(serializers.ModelSerializer[Profile]):
@@ -29,8 +29,8 @@ class WallSectionSerializer(serializers.ModelSerializer[WallSection]):
29
  "id",
30
  "profile",
31
  "section_name",
32
- "start_position",
33
- "target_length_feet",
34
  "created_at",
35
  ]
36
  read_only_fields = ["id", "created_at"]
@@ -55,11 +55,9 @@ class DailyProgressSerializer(serializers.ModelSerializer[DailyProgress]):
55
 
56
  def create(self, validated_data: dict[str, object]) -> DailyProgress:
57
  """Create DailyProgress with auto-calculated ice_cubic_yards and cost_gold_dragons."""
58
- calculator = IceUsageCalculator()
59
-
60
  feet_built = cast(Decimal, validated_data["feet_built"])
61
- ice_cubic_yards = calculator.calculate_ice_usage(feet_built)
62
- cost_gold_dragons = calculator.calculate_daily_cost(ice_cubic_yards)
63
 
64
  validated_data["ice_cubic_yards"] = ice_cubic_yards
65
  validated_data["cost_gold_dragons"] = cost_gold_dragons
 
7
 
8
  from rest_framework import serializers
9
 
10
+ from apps.profiles.constants import COST_PER_CUBIC_YARD, ICE_PER_FOOT
11
  from apps.profiles.models import DailyProgress, Profile, WallSection
 
12
 
13
 
14
  class ProfileSerializer(serializers.ModelSerializer[Profile]):
 
29
  "id",
30
  "profile",
31
  "section_name",
32
+ "initial_height",
33
+ "current_height",
34
  "created_at",
35
  ]
36
  read_only_fields = ["id", "created_at"]
 
55
 
56
  def create(self, validated_data: dict[str, object]) -> DailyProgress:
57
  """Create DailyProgress with auto-calculated ice_cubic_yards and cost_gold_dragons."""
 
 
58
  feet_built = cast(Decimal, validated_data["feet_built"])
59
+ ice_cubic_yards = feet_built * ICE_PER_FOOT
60
+ cost_gold_dragons = ice_cubic_yards * COST_PER_CUBIC_YARD
61
 
62
  validated_data["ice_cubic_yards"] = ice_cubic_yards
63
  validated_data["cost_gold_dragons"] = cost_gold_dragons
apps/profiles/services/__pycache__/calculators.cpython-312.pyc CHANGED
Binary files a/apps/profiles/services/__pycache__/calculators.cpython-312.pyc and b/apps/profiles/services/__pycache__/calculators.cpython-312.pyc differ
 
apps/profiles/services/aggregators.py DELETED
@@ -1,124 +0,0 @@
1
- """Service for cost calculations across multiple profiles."""
2
-
3
- from __future__ import annotations
4
-
5
- from concurrent.futures import Future, as_completed
6
- from datetime import date, datetime
7
-
8
- from apps.profiles.repositories import DailyProgressRepository
9
-
10
-
11
- class SynchronousExecutor:
12
- """Executor that runs tasks synchronously in the same thread.
13
-
14
- Provides ThreadPoolExecutor-compatible interface for SQLite compatibility.
15
- SQLite with Django test transactions requires same-thread execution.
16
- """
17
-
18
- def submit(self, fn, *args, **kwargs) -> Future[dict[str, int | str]]: # type: ignore[no-untyped-def]
19
- """Submit a callable to be executed synchronously."""
20
- future: Future[dict[str, int | str]] = Future()
21
- try:
22
- result = fn(*args, **kwargs)
23
- future.set_result(result)
24
- except Exception as e:
25
- future.set_exception(e)
26
- return future
27
-
28
- def shutdown(self) -> None:
29
- """Shutdown executor - synchronous execution requires no cleanup."""
30
- return
31
-
32
-
33
- class CostAggregatorService:
34
- """Service for cost calculations across multiple profiles.
35
-
36
- Uses synchronous executor for SQLite database compatibility.
37
- Processes profiles serially in the same thread to avoid database
38
- locking issues with SQLite transactions in Django tests.
39
- """
40
-
41
- def __init__(self) -> None:
42
- """Initialize service with synchronous executor."""
43
- self.executor = SynchronousExecutor()
44
-
45
- def calculate_multi_profile_costs(
46
- self,
47
- profile_ids: list[int],
48
- start_date: str,
49
- end_date: str,
50
- ) -> list[dict[str, int | str]]:
51
- """Calculate costs for multiple profiles.
52
-
53
- Args:
54
- profile_ids: List of profile IDs to process
55
- start_date: Start date (YYYY-MM-DD)
56
- end_date: End date (YYYY-MM-DD)
57
-
58
- Returns:
59
- List of cost summaries per profile
60
-
61
- Raises:
62
- ValueError: If date format is invalid
63
- Exception: Any exception raised during profile cost calculation
64
- """
65
- # Convert string dates to date objects (can raise ValueError)
66
- start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date()
67
- end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").date()
68
-
69
- futures = {
70
- self.executor.submit(
71
- self._calculate_profile_cost,
72
- profile_id,
73
- start_date_obj,
74
- end_date_obj,
75
- ): profile_id
76
- for profile_id in profile_ids
77
- }
78
-
79
- results = []
80
- for future in as_completed(futures):
81
- profile_id = futures[future]
82
- try:
83
- result = future.result()
84
- results.append(result)
85
- except Exception as e:
86
- raise RuntimeError(f"Failed to calculate costs for profile {profile_id}") from e
87
-
88
- return results
89
-
90
- def shutdown(self) -> None:
91
- """Shutdown the executor."""
92
- self.executor.shutdown()
93
-
94
- def _calculate_profile_cost(
95
- self,
96
- profile_id: int,
97
- start_date_obj: date,
98
- end_date_obj: date,
99
- ) -> dict[str, int | str]:
100
- """Calculate cost summary for a single profile.
101
-
102
- Args:
103
- profile_id: Profile ID to calculate for
104
- start_date_obj: Start date object
105
- end_date_obj: End date object
106
-
107
- Returns:
108
- Dictionary with profile cost summary
109
- """
110
- repo = DailyProgressRepository()
111
-
112
- # Use Django ORM aggregation for efficient DB queries
113
- aggregates = repo.get_aggregates_by_profile(
114
- profile_id,
115
- start_date_obj,
116
- end_date_obj,
117
- )
118
-
119
- return {
120
- "profile_id": profile_id,
121
- "total_feet_built": str(aggregates["total_feet"]),
122
- "total_ice_cubic_yards": str(aggregates["total_ice"]),
123
- "total_cost_gold_dragons": str(aggregates["total_cost"]),
124
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/profiles/services/calculators.py DELETED
@@ -1,34 +0,0 @@
1
- """Calculator services for ice usage and cost calculations."""
2
-
3
- from __future__ import annotations
4
-
5
- from decimal import Decimal
6
-
7
-
8
- class IceUsageCalculator:
9
- """Calculate ice usage and costs for wall construction."""
10
-
11
- ICE_PER_FOOT = Decimal("195") # cubic yards per linear foot
12
- COST_PER_CUBIC_YARD = Decimal("1900") # Gold Dragons per cubic yard
13
-
14
- def calculate_ice_usage(self, feet_built: Decimal) -> Decimal:
15
- """Calculate ice usage in cubic yards from feet built.
16
-
17
- Args:
18
- feet_built: Linear feet of wall built
19
-
20
- Returns:
21
- Ice usage in cubic yards (feet_built × 195)
22
- """
23
- return feet_built * self.ICE_PER_FOOT
24
-
25
- def calculate_daily_cost(self, ice_cubic_yards: Decimal) -> Decimal:
26
- """Calculate daily cost in Gold Dragons from ice usage.
27
-
28
- Args:
29
- ice_cubic_yards: Ice usage in cubic yards
30
-
31
- Returns:
32
- Daily cost in Gold Dragons (ice_cubic_yards × 1900)
33
- """
34
- return ice_cubic_yards * self.COST_PER_CUBIC_YARD
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
apps/profiles/services/simulator.py CHANGED
@@ -9,6 +9,7 @@ from pathlib import Path
9
 
10
  from pydantic import BaseModel, ConfigDict
11
 
 
12
  from apps.profiles.models import DailyProgress, Profile, WallSection
13
 
14
 
@@ -59,10 +60,6 @@ class WallSimulator:
59
  SQLite compatibility through main-thread-only database operations.
60
  """
61
 
62
- TARGET_HEIGHT = 30
63
- ICE_PER_FOOT = Decimal("195")
64
- COST_PER_CUBIC_YARD = Decimal("1900")
65
-
66
  def __init__(self, num_teams: int, log_dir: str = "logs"):
67
  """Initialize simulator with team pool.
68
 
@@ -97,7 +94,7 @@ class WallSimulator:
97
  current_date = start_date
98
  day = 1
99
 
100
- while any(s.current_height < self.TARGET_HEIGHT for s in section_data):
101
  sections_to_process = self._assign_work(section_data)
102
 
103
  if not sections_to_process:
@@ -160,8 +157,6 @@ class WallSimulator:
160
  section = WallSection.objects.create(
161
  profile=profile,
162
  section_name=f"Section {section_num}",
163
- start_position=Decimal(str(section_num - 1)),
164
- target_length_feet=Decimal("1.0"),
165
  initial_height=height,
166
  current_height=height,
167
  )
@@ -190,7 +185,7 @@ class WallSimulator:
190
  Returns:
191
  Sections assigned to teams for this day
192
  """
193
- incomplete_sections = [s for s in section_data if s.current_height < self.TARGET_HEIGHT]
194
 
195
  return incomplete_sections[: self.num_teams]
196
 
@@ -243,14 +238,14 @@ class WallSimulator:
243
  Processing result
244
  """
245
  feet_built = 1
246
- ice = Decimal(str(feet_built)) * self.ICE_PER_FOOT
247
- cost = ice * self.COST_PER_CUBIC_YARD
248
 
249
  new_height = section.current_height + feet_built
250
 
251
  log_file = self.log_dir / f"team_{team_id}.log"
252
  with log_file.open("a") as f:
253
- if new_height >= self.TARGET_HEIGHT:
254
  f.write(f"Day {day}: Team {team_id} completed {section.section_name} ({section.profile_name})\n")
255
  else:
256
  f.write(f"Day {day}: Team {team_id} worked on {section.section_name} ({section.profile_name}) - {new_height}/30 ft\n")
 
9
 
10
  from pydantic import BaseModel, ConfigDict
11
 
12
+ from apps.profiles.constants import COST_PER_CUBIC_YARD, ICE_PER_FOOT, TARGET_HEIGHT
13
  from apps.profiles.models import DailyProgress, Profile, WallSection
14
 
15
 
 
60
  SQLite compatibility through main-thread-only database operations.
61
  """
62
 
 
 
 
 
63
  def __init__(self, num_teams: int, log_dir: str = "logs"):
64
  """Initialize simulator with team pool.
65
 
 
94
  current_date = start_date
95
  day = 1
96
 
97
+ while any(s.current_height < TARGET_HEIGHT for s in section_data):
98
  sections_to_process = self._assign_work(section_data)
99
 
100
  if not sections_to_process:
 
157
  section = WallSection.objects.create(
158
  profile=profile,
159
  section_name=f"Section {section_num}",
 
 
160
  initial_height=height,
161
  current_height=height,
162
  )
 
185
  Returns:
186
  Sections assigned to teams for this day
187
  """
188
+ incomplete_sections = [s for s in section_data if s.current_height < TARGET_HEIGHT]
189
 
190
  return incomplete_sections[: self.num_teams]
191
 
 
238
  Processing result
239
  """
240
  feet_built = 1
241
+ ice = Decimal(str(feet_built)) * ICE_PER_FOOT
242
+ cost = ice * COST_PER_CUBIC_YARD
243
 
244
  new_height = section.current_height + feet_built
245
 
246
  log_file = self.log_dir / f"team_{team_id}.log"
247
  with log_file.open("a") as f:
248
+ if new_height >= TARGET_HEIGHT:
249
  f.write(f"Day {day}: Team {team_id} completed {section.section_name} ({section.profile_name})\n")
250
  else:
251
  f.write(f"Day {day}: Team {team_id} worked on {section.section_name} ({section.profile_name}) - {new_height}/30 ft\n")
apps/profiles/views.py CHANGED
@@ -14,13 +14,11 @@ from rest_framework.response import Response
14
 
15
  from apps.profiles.models import DailyProgress, Profile, WallSection
16
  from apps.profiles.parsers import ConfigParser
17
- from apps.profiles.repositories import DailyProgressRepository
18
  from apps.profiles.serializers import (
19
  DailyProgressSerializer,
20
  ProfileSerializer,
21
  WallSectionSerializer,
22
  )
23
- from apps.profiles.services.aggregators import CostAggregatorService
24
  from apps.profiles.services.simulator import WallSimulator
25
 
26
 
@@ -30,233 +28,6 @@ class ProfileViewSet(viewsets.ModelViewSet[Profile]):
30
  queryset = Profile.objects.all()
31
  serializer_class = ProfileSerializer
32
 
33
- @action(detail=True, methods=["get"], url_path="daily-ice-usage")
34
- def daily_ice_usage(self, request: Request, pk: int) -> Response:
35
- """Get daily ice usage aggregated by profile for a specific date."""
36
- profile = get_object_or_404(Profile, pk=pk)
37
- target_date = request.query_params.get("date")
38
-
39
- if not target_date:
40
- return Response(
41
- {"error": "date parameter is required"},
42
- status=status.HTTP_400_BAD_REQUEST,
43
- )
44
-
45
- # Validate date format (YYYY-MM-DD)
46
- try:
47
- datetime.strptime(target_date, "%Y-%m-%d")
48
- except ValueError:
49
- return Response(
50
- {"error": "date must be in YYYY-MM-DD format"},
51
- status=status.HTTP_400_BAD_REQUEST,
52
- )
53
-
54
- # Get all wall sections for this profile
55
- wall_sections = WallSection.objects.filter(profile=profile)
56
-
57
- # Get daily progress for all sections on target date
58
- daily_progress = DailyProgress.objects.filter(wall_section__in=wall_sections, date=target_date)
59
-
60
- # Aggregate totals
61
- aggregates = daily_progress.aggregate(
62
- total_feet=Sum("feet_built"),
63
- total_ice=Sum("ice_cubic_yards"),
64
- )
65
-
66
- # Explicit None checking with ternary operators
67
- total_feet_built = Decimal("0.00") if aggregates["total_feet"] is None else aggregates["total_feet"]
68
- total_ice_cubic_yards = Decimal("0.00") if aggregates["total_ice"] is None else aggregates["total_ice"]
69
-
70
- # Build section breakdown
71
- sections = []
72
- for progress in daily_progress:
73
- sections.append(
74
- {
75
- "section_name": progress.wall_section.section_name,
76
- "feet_built": str(progress.feet_built),
77
- "ice_cubic_yards": str(progress.ice_cubic_yards),
78
- }
79
- )
80
-
81
- return Response(
82
- {
83
- "profile_id": profile.id,
84
- "profile_name": profile.name,
85
- "date": target_date,
86
- "total_feet_built": str(total_feet_built),
87
- "total_ice_cubic_yards": str(total_ice_cubic_yards),
88
- "sections": sections,
89
- }
90
- )
91
-
92
- @action(detail=True, methods=["get"], url_path="cost-overview")
93
- def cost_overview(self, request: Request, pk: int) -> Response:
94
- """Get cost overview for a profile within a date range."""
95
- profile = get_object_or_404(Profile, pk=pk)
96
- start_date_str = request.query_params.get("start_date")
97
- end_date_str = request.query_params.get("end_date")
98
-
99
- if not start_date_str:
100
- return Response(
101
- {"error": "start_date parameter is required"},
102
- status=status.HTTP_400_BAD_REQUEST,
103
- )
104
-
105
- if not end_date_str:
106
- return Response(
107
- {"error": "end_date parameter is required"},
108
- status=status.HTTP_400_BAD_REQUEST,
109
- )
110
-
111
- # Validate date formats (YYYY-MM-DD)
112
- try:
113
- start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date()
114
- except ValueError:
115
- return Response(
116
- {"error": "start_date must be in YYYY-MM-DD format"},
117
- status=status.HTTP_400_BAD_REQUEST,
118
- )
119
-
120
- try:
121
- end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date()
122
- except ValueError:
123
- return Response(
124
- {"error": "end_date must be in YYYY-MM-DD format"},
125
- status=status.HTTP_400_BAD_REQUEST,
126
- )
127
-
128
- # Get aggregated statistics using repository
129
- repo = DailyProgressRepository()
130
- aggregates = repo.get_aggregates_by_profile(profile.id, start_date, end_date)
131
-
132
- # Calculate total days in range
133
- total_days = (end_date - start_date).days + 1
134
-
135
- # Calculate average cost per day
136
- total_cost = Decimal(str(aggregates["total_cost"]))
137
- record_count = aggregates["record_count"]
138
- average_cost_per_day = (total_cost / record_count).quantize(Decimal("0.01")) if record_count > 0 else Decimal("0.00")
139
-
140
- # Build daily breakdown - only include days with actual progress
141
- daily_breakdown = []
142
- current_date = start_date
143
- while current_date <= end_date:
144
- day_progress = repo.get_by_date(profile.id, current_date)
145
- if day_progress.exists():
146
- day_aggregates = day_progress.aggregate(
147
- total_feet=Sum("feet_built"),
148
- total_ice=Sum("ice_cubic_yards"),
149
- total_cost=Sum("cost_gold_dragons"),
150
- )
151
- # Since day_progress.exists() is True, aggregates cannot be None
152
- daily_breakdown.append(
153
- {
154
- "date": current_date.isoformat(),
155
- "feet_built": str(day_aggregates["total_feet"]),
156
- "ice_cubic_yards": str(day_aggregates["total_ice"]),
157
- "cost_gold_dragons": str(day_aggregates["total_cost"]),
158
- }
159
- )
160
- current_date += timedelta(days=1)
161
-
162
- return Response(
163
- {
164
- "profile_id": profile.id,
165
- "profile_name": profile.name,
166
- "date_range": {
167
- "start": start_date_str,
168
- "end": end_date_str,
169
- },
170
- "summary": {
171
- "total_days": total_days,
172
- "total_feet_built": str(aggregates["total_feet"]),
173
- "total_ice_cubic_yards": str(aggregates["total_ice"]),
174
- "total_cost_gold_dragons": str(aggregates["total_cost"]),
175
- "average_feet_per_day": str(aggregates["avg_feet"]),
176
- "average_cost_per_day": str(average_cost_per_day),
177
- },
178
- "daily_breakdown": daily_breakdown,
179
- }
180
- )
181
-
182
- @action(detail=False, methods=["get"], url_path="bulk-cost-overview")
183
- def bulk_cost_overview(self, request: Request) -> Response:
184
- """Calculate costs for multiple profiles."""
185
- profile_ids_str = request.query_params.get("profile_ids")
186
- start_date = request.query_params.get("start_date")
187
- end_date = request.query_params.get("end_date")
188
-
189
- if not profile_ids_str:
190
- return Response(
191
- {"error": "profile_ids parameter is required"},
192
- status=status.HTTP_400_BAD_REQUEST,
193
- )
194
-
195
- if not start_date:
196
- return Response(
197
- {"error": "start_date parameter is required"},
198
- status=status.HTTP_400_BAD_REQUEST,
199
- )
200
-
201
- if not end_date:
202
- return Response(
203
- {"error": "end_date parameter is required"},
204
- status=status.HTTP_400_BAD_REQUEST,
205
- )
206
-
207
- # Parse comma-separated profile IDs
208
- try:
209
- profile_ids = [int(pid.strip()) for pid in profile_ids_str.split(",")]
210
- except ValueError:
211
- return Response(
212
- {"error": "profile_ids must be comma-separated integers"},
213
- status=status.HTTP_400_BAD_REQUEST,
214
- )
215
-
216
- # Use CostAggregatorService for parallel processing
217
- aggregator = CostAggregatorService()
218
- try:
219
- results = aggregator.calculate_multi_profile_costs(profile_ids, start_date, end_date)
220
- return Response({"results": results})
221
- finally:
222
- aggregator.shutdown()
223
-
224
- @action(detail=True, methods=["post"], url_path="progress")
225
- def create_progress(self, request: Request, pk: int) -> Response:
226
- """Create daily progress for a wall section under this profile."""
227
- profile = get_object_or_404(Profile, pk=pk)
228
-
229
- wall_section_id = request.data.get("wall_section_id")
230
-
231
- if not wall_section_id:
232
- return Response(
233
- {"wall_section_id": ["This field is required."]},
234
- status=status.HTTP_400_BAD_REQUEST,
235
- )
236
-
237
- # Validate wall_section belongs to this profile
238
- wall_section = get_object_or_404(WallSection, pk=wall_section_id)
239
- if wall_section.profile_id != profile.id:
240
- return Response(
241
- {"wall_section_id": ["Wall section does not belong to this profile."]},
242
- status=status.HTTP_400_BAD_REQUEST,
243
- )
244
-
245
- # Prepare data for serializer (use wall_section instead of wall_section_id)
246
- progress_data = {
247
- "wall_section": wall_section_id,
248
- "date": request.data.get("date"),
249
- "feet_built": request.data.get("feet_built"),
250
- "notes": request.data.get("notes", ""),
251
- }
252
-
253
- serializer = DailyProgressSerializer(data=progress_data)
254
- if serializer.is_valid():
255
- serializer.save()
256
- return Response(serializer.data, status=status.HTTP_201_CREATED)
257
-
258
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
259
-
260
  @action(detail=False, methods=["post"], url_path="simulate")
261
  def simulate(self, request: Request) -> Response:
262
  """Trigger wall construction simulation from config.
 
14
 
15
  from apps.profiles.models import DailyProgress, Profile, WallSection
16
  from apps.profiles.parsers import ConfigParser
 
17
  from apps.profiles.serializers import (
18
  DailyProgressSerializer,
19
  ProfileSerializer,
20
  WallSectionSerializer,
21
  )
 
22
  from apps.profiles.services.simulator import WallSimulator
23
 
24
 
 
28
  queryset = Profile.objects.all()
29
  serializer_class = ProfileSerializer
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  @action(detail=False, methods=["post"], url_path="simulate")
32
  def simulate(self, request: Request) -> Response:
33
  """Trigger wall construction simulation from config.
db.sqlite3 DELETED
File without changes
scripts/run_tests.py CHANGED
@@ -1,31 +1,43 @@
1
  #!/usr/bin/env python3
2
- """Test runner for demo project."""
3
 
4
  from __future__ import annotations
5
 
6
- import logging
7
  import subprocess
8
  import sys
9
  from pathlib import Path
10
 
11
- logging.basicConfig(level=logging.INFO, format="%(message)s")
12
-
13
 
14
  def main() -> int:
15
- """Run pytest with all command line arguments passed through."""
16
  demo_root = Path(__file__).resolve().parent.parent
17
- tests_dir = demo_root / "tests"
 
 
 
 
18
 
 
19
  if not tests_dir.exists():
20
- logging.info(f"No tests directory found at {tests_dir}. Nothing to test.")
21
  return 0
22
 
23
- test_files = list(tests_dir.rglob("test_*.py")) + list(tests_dir.rglob("*_test.py"))
24
- if not test_files:
25
- logging.info("No test files found. Nothing to test.")
26
- return 0
 
 
 
 
 
 
 
 
 
 
 
27
 
28
- cmd = [sys.executable, "-m", "pytest"] + sys.argv[1:]
29
  try:
30
  subprocess.run(cmd, cwd=str(demo_root), check=True)
31
  return 0
 
1
  #!/usr/bin/env python3
2
+ """Minimal test runner for demo project - no base dependencies."""
3
 
4
  from __future__ import annotations
5
 
 
6
  import subprocess
7
  import sys
8
  from pathlib import Path
9
 
 
 
10
 
11
  def main() -> int:
12
+ """Run pytest using demo's .venv."""
13
  demo_root = Path(__file__).resolve().parent.parent
14
+ venv_python = demo_root / ".venv" / "bin" / "python"
15
+
16
+ if not venv_python.exists():
17
+ sys.stderr.write(f"Error: {venv_python} not found. Run module_setup.py first.\n")
18
+ return 1
19
 
20
+ tests_dir = demo_root / "tests"
21
  if not tests_dir.exists():
22
+ sys.stderr.write(f"No tests directory found at {tests_dir}. Nothing to test.\n")
23
  return 0
24
 
25
+ # Build pytest command with default args if none provided
26
+ cmd = [str(venv_python), "-m", "pytest"]
27
+
28
+ # Add default args only if no args provided
29
+ if len(sys.argv) == 1:
30
+ cmd.extend(
31
+ [
32
+ "tests/",
33
+ "-v",
34
+ "--cov=apps",
35
+ "--cov-report=term-missing",
36
+ ]
37
+ )
38
+ else:
39
+ cmd.extend(sys.argv[1:])
40
 
 
41
  try:
42
  subprocess.run(cmd, cwd=str(demo_root), check=True)
43
  return 0
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_wallsection_api.cpython-312-pytest-8.4.2.pyc CHANGED
Binary files a/tests/integration/__pycache__/test_wallsection_api.cpython-312-pytest-8.4.2.pyc and b/tests/integration/__pycache__/test_wallsection_api.cpython-312-pytest-8.4.2.pyc differ
 
tests/integration/test_edge_cases.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for edge cases and error paths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ from rest_framework import status
7
+ from rest_framework.test import APIClient
8
+
9
+
10
+ @pytest.mark.django_db
11
+ @pytest.mark.integration
12
+ class TestEdgeCases:
13
+ """Test edge cases and error handling paths."""
14
+
15
+ def test_simulate_with_invalid_start_date_format(self, api_client: APIClient) -> None:
16
+ """Test /simulate/ rejects invalid start_date format."""
17
+ response = api_client.post(
18
+ "/api/profiles/simulate/",
19
+ {"config": "5", "num_teams": 10, "start_date": "invalid-date"},
20
+ format="json",
21
+ )
22
+
23
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
24
+ assert "start_date must be in YYYY-MM-DD format" in response.data["error"]
25
+
26
+ def test_overview_by_day_with_no_simulation_data(self, api_client: APIClient) -> None:
27
+ """Test profile-specific /overview/<day>/ returns 404 when profile has no simulation data."""
28
+ # Create a profile but run no simulation
29
+ profile_response = api_client.post(
30
+ "/api/profiles/",
31
+ {"name": "Test Profile", "team_lead": "Test Lead"},
32
+ format="json",
33
+ )
34
+ profile_id = profile_response.data["id"]
35
+
36
+ # Request overview for day 1
37
+ response = api_client.get(f"/api/profiles/{profile_id}/overview/1/")
38
+
39
+ assert response.status_code == status.HTTP_404_NOT_FOUND
40
+ assert "No simulation data for this profile" in response.data["error"]
tests/integration/test_profile_api.py CHANGED
@@ -2,16 +2,11 @@
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,386 +192,3 @@ class TestProfileAPI:
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
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
372
-
373
- def test_bulk_cost_overview_success(self, api_client: APIClient) -> None:
374
- """Test bulk cost overview endpoint processes multiple profiles in parallel."""
375
- profile1 = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
376
- profile2 = Profile.objects.create(name="Eastern Defense", team_lead="Tormund")
377
- section1 = WallSection.objects.create(
378
- profile=profile1,
379
- section_name="Tower 1-2",
380
- start_position=Decimal("0.00"),
381
- target_length_feet=Decimal("500.00"),
382
- )
383
- section2 = WallSection.objects.create(
384
- profile=profile2,
385
- section_name="Tower 5-6",
386
- start_position=Decimal("0.00"),
387
- target_length_feet=Decimal("500.00"),
388
- )
389
-
390
- DailyProgress.objects.create(
391
- wall_section=section1,
392
- date=date(2025, 10, 1),
393
- feet_built=Decimal("10.00"),
394
- ice_cubic_yards=Decimal("1950.00"),
395
- cost_gold_dragons=Decimal("3705000.00"),
396
- )
397
- DailyProgress.objects.create(
398
- wall_section=section2,
399
- date=date(2025, 10, 1),
400
- feet_built=Decimal("20.00"),
401
- ice_cubic_yards=Decimal("3900.00"),
402
- cost_gold_dragons=Decimal("7410000.00"),
403
- )
404
-
405
- url = reverse("profile-bulk-cost-overview")
406
- response = api_client.get(
407
- url,
408
- {
409
- "profile_ids": f"{profile1.id},{profile2.id}",
410
- "start_date": "2025-10-01",
411
- "end_date": "2025-10-01",
412
- },
413
- )
414
-
415
- assert response.status_code == status.HTTP_200_OK
416
- assert len(response.data["results"]) == 2
417
- profile_ids = {r["profile_id"] for r in response.data["results"]}
418
- assert profile_ids == {profile1.id, profile2.id}
419
-
420
- def test_bulk_cost_overview_single_profile(self, api_client: APIClient) -> None:
421
- """Test bulk cost overview with single profile."""
422
- profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
423
- section = WallSection.objects.create(
424
- profile=profile,
425
- section_name="Tower 1-2",
426
- start_position=Decimal("0.00"),
427
- target_length_feet=Decimal("500.00"),
428
- )
429
-
430
- DailyProgress.objects.create(
431
- wall_section=section,
432
- date=date(2025, 10, 1),
433
- feet_built=Decimal("10.00"),
434
- ice_cubic_yards=Decimal("1950.00"),
435
- cost_gold_dragons=Decimal("3705000.00"),
436
- )
437
-
438
- url = reverse("profile-bulk-cost-overview")
439
- response = api_client.get(
440
- url,
441
- {"profile_ids": str(profile.id), "start_date": "2025-10-01", "end_date": "2025-10-01"},
442
- )
443
-
444
- assert response.status_code == status.HTTP_200_OK
445
- assert len(response.data["results"]) == 1
446
- assert response.data["results"][0]["profile_id"] == profile.id
447
- assert response.data["results"][0]["total_feet_built"] == "10.00"
448
-
449
- def test_bulk_cost_overview_missing_profile_ids(self, api_client: APIClient) -> None:
450
- """Test bulk cost overview without profile_ids parameter returns 400."""
451
- url = reverse("profile-bulk-cost-overview")
452
- response = api_client.get(url, {"start_date": "2025-10-01", "end_date": "2025-10-01"})
453
-
454
- assert response.status_code == status.HTTP_400_BAD_REQUEST
455
-
456
- def test_bulk_cost_overview_missing_start_date(self, api_client: APIClient) -> None:
457
- """Test bulk cost overview without start_date parameter returns 400."""
458
- url = reverse("profile-bulk-cost-overview")
459
- response = api_client.get(url, {"profile_ids": "1,2", "end_date": "2025-10-01"})
460
-
461
- assert response.status_code == status.HTTP_400_BAD_REQUEST
462
-
463
- def test_bulk_cost_overview_missing_end_date(self, api_client: APIClient) -> None:
464
- """Test bulk cost overview without end_date parameter returns 400."""
465
- url = reverse("profile-bulk-cost-overview")
466
- response = api_client.get(url, {"profile_ids": "1,2", "start_date": "2025-10-01"})
467
-
468
- assert response.status_code == status.HTTP_400_BAD_REQUEST
469
-
470
- def test_create_progress_via_profile_success(self, api_client: APIClient) -> None:
471
- """Test creating progress via profile nested endpoint returns 201 with auto-calculated values."""
472
- profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
473
- wall_section = WallSection.objects.create(
474
- profile=profile,
475
- section_name="Tower 1-2",
476
- start_position=Decimal("0.00"),
477
- target_length_feet=Decimal("500.00"),
478
- )
479
-
480
- url = reverse("profile-create-progress", kwargs={"pk": profile.id})
481
- payload = {
482
- "wall_section_id": wall_section.id,
483
- "date": "2025-10-15",
484
- "feet_built": 12.5,
485
- "notes": "Clear weather, good progress",
486
- }
487
-
488
- response = api_client.post(url, payload, format="json")
489
-
490
- assert response.status_code == status.HTTP_201_CREATED
491
- assert "id" in response.data
492
- assert response.data["wall_section"] == wall_section.id
493
- assert response.data["date"] == "2025-10-15"
494
- assert response.data["feet_built"] == "12.50"
495
- assert response.data["ice_cubic_yards"] == "2437.50"
496
- assert response.data["cost_gold_dragons"] == "4631250.00"
497
- assert response.data["notes"] == "Clear weather, good progress"
498
- assert "created_at" in response.data
499
-
500
- def test_create_progress_via_profile_validates_wall_section_ownership(self, api_client: APIClient) -> None:
501
- """Test creating progress fails when wall_section does not belong to profile."""
502
- profile1 = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
503
- profile2 = Profile.objects.create(name="Eastern Defense", team_lead="Tormund")
504
- wall_section = WallSection.objects.create(
505
- profile=profile2,
506
- section_name="Tower 5-6",
507
- start_position=Decimal("0.00"),
508
- target_length_feet=Decimal("500.00"),
509
- )
510
-
511
- url = reverse("profile-create-progress", kwargs={"pk": profile1.id})
512
- payload = {
513
- "wall_section_id": wall_section.id,
514
- "date": "2025-10-15",
515
- "feet_built": 12.5,
516
- }
517
-
518
- response = api_client.post(url, payload, format="json")
519
-
520
- assert response.status_code == status.HTTP_400_BAD_REQUEST
521
- assert "wall_section_id" in response.data
522
-
523
- def test_create_progress_via_profile_missing_wall_section_id(self, api_client: APIClient) -> None:
524
- """Test creating progress without wall_section_id returns 400."""
525
- profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
526
-
527
- url = reverse("profile-create-progress", kwargs={"pk": profile.id})
528
- payload = {
529
- "date": "2025-10-15",
530
- "feet_built": 12.5,
531
- }
532
-
533
- response = api_client.post(url, payload, format="json")
534
-
535
- assert response.status_code == status.HTTP_400_BAD_REQUEST
536
- assert "wall_section_id" in response.data
537
-
538
- def test_create_progress_via_profile_invalid_date_format(self, api_client: APIClient) -> None:
539
- """Test creating progress with invalid date format returns 400."""
540
- profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
541
- wall_section = WallSection.objects.create(
542
- profile=profile,
543
- section_name="Tower 1-2",
544
- start_position=Decimal("0.00"),
545
- target_length_feet=Decimal("500.00"),
546
- )
547
-
548
- url = reverse("profile-create-progress", kwargs={"pk": profile.id})
549
- payload = {
550
- "wall_section_id": wall_section.id,
551
- "date": "2025/10/15",
552
- "feet_built": 12.5,
553
- }
554
-
555
- response = api_client.post(url, payload, format="json")
556
-
557
- assert response.status_code == status.HTTP_400_BAD_REQUEST
558
- assert "date" in response.data
559
-
560
- def test_create_progress_via_profile_auto_calculates_ice_and_cost(self, api_client: APIClient) -> None:
561
- """Test creating progress auto-calculates ice_cubic_yards and cost_gold_dragons."""
562
- profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
563
- wall_section = WallSection.objects.create(
564
- profile=profile,
565
- section_name="Tower 1-2",
566
- start_position=Decimal("0.00"),
567
- target_length_feet=Decimal("500.00"),
568
- )
569
-
570
- url = reverse("profile-create-progress", kwargs={"pk": profile.id})
571
- payload = {
572
- "wall_section_id": wall_section.id,
573
- "date": "2025-10-15",
574
- "feet_built": 10.0,
575
- }
576
-
577
- response = api_client.post(url, payload, format="json")
578
-
579
- assert response.status_code == status.HTTP_201_CREATED
580
- assert response.data["feet_built"] == "10.00"
581
- assert response.data["ice_cubic_yards"] == "1950.00"
582
- assert response.data["cost_gold_dragons"] == "3705000.00"
 
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
 
193
  assert response.status_code == status.HTTP_201_CREATED
194
  assert response.data["is_active"] is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/integration/test_wallsection_api.py CHANGED
@@ -26,16 +26,12 @@ class TestWallSectionAPI:
26
  payload = {
27
  "profile": profile["id"],
28
  "section_name": "Tower 1-2",
29
- "start_position": "0.00",
30
- "target_length_feet": "500.00",
31
  }
32
 
33
  response = api_client.post(url, payload, format="json")
34
 
35
  assert response.status_code == status.HTTP_201_CREATED
36
  assert response.data["section_name"] == "Tower 1-2"
37
- assert response.data["start_position"] == "0.00"
38
- assert response.data["target_length_feet"] == "500.00"
39
  assert response.data["profile"] == profile["id"]
40
  assert "id" in response.data
41
  assert "created_at" in response.data
@@ -53,8 +49,6 @@ class TestWallSectionAPI:
53
  payload = {
54
  "profile": profile["id"],
55
  "section_name": "Tower 1-2",
56
- "start_position": "0.00",
57
- "target_length_feet": "500.00",
58
  }
59
 
60
  api_client.post(url, payload, format="json")
@@ -80,14 +74,10 @@ class TestWallSectionAPI:
80
  payload1 = {
81
  "profile": profile1["id"],
82
  "section_name": "Tower 1-2",
83
- "start_position": "0.00",
84
- "target_length_feet": "500.00",
85
  }
86
  payload2 = {
87
  "profile": profile2["id"],
88
  "section_name": "Tower 1-2",
89
- "start_position": "0.00",
90
- "target_length_feet": "500.00",
91
  }
92
 
93
  response1 = api_client.post(url, payload1, format="json")
@@ -111,8 +101,6 @@ class TestWallSectionAPI:
111
  {
112
  "profile": profile["id"],
113
  "section_name": "Tower 1-2",
114
- "start_position": "0.00",
115
- "target_length_feet": "500.00",
116
  },
117
  format="json",
118
  )
@@ -121,8 +109,6 @@ class TestWallSectionAPI:
121
  {
122
  "profile": profile["id"],
123
  "section_name": "Tower 2-3",
124
- "start_position": "500.00",
125
- "target_length_feet": "600.00",
126
  },
127
  format="json",
128
  )
@@ -153,8 +139,6 @@ class TestWallSectionAPI:
153
  {
154
  "profile": profile1["id"],
155
  "section_name": "Tower 1-2",
156
- "start_position": "0.00",
157
- "target_length_feet": "500.00",
158
  },
159
  format="json",
160
  )
@@ -163,8 +147,6 @@ class TestWallSectionAPI:
163
  {
164
  "profile": profile2["id"],
165
  "section_name": "Tower 3-4",
166
- "start_position": "0.00",
167
- "target_length_feet": "400.00",
168
  },
169
  format="json",
170
  )
@@ -190,8 +172,6 @@ class TestWallSectionAPI:
190
  {
191
  "profile": profile["id"],
192
  "section_name": "Tower 1-2",
193
- "start_position": "0.00",
194
- "target_length_feet": "500.00",
195
  },
196
  format="json",
197
  ).data
@@ -218,8 +198,6 @@ class TestWallSectionAPI:
218
  {
219
  "profile": profile["id"],
220
  "section_name": "Tower 1-2",
221
- "start_position": "0.00",
222
- "target_length_feet": "500.00",
223
  },
224
  format="json",
225
  ).data
@@ -228,14 +206,11 @@ class TestWallSectionAPI:
228
  updated_payload = {
229
  "profile": profile["id"],
230
  "section_name": "Tower 1-2 Extended",
231
- "start_position": "0.00",
232
- "target_length_feet": "750.00",
233
  }
234
  response = api_client.put(detail_url, updated_payload, format="json")
235
 
236
  assert response.status_code == status.HTTP_200_OK
237
  assert response.data["section_name"] == "Tower 1-2 Extended"
238
- assert response.data["target_length_feet"] == "750.00"
239
 
240
  def test_delete_wall_section(self, api_client: APIClient) -> None:
241
  """Test deleting a wall section."""
@@ -252,8 +227,6 @@ class TestWallSectionAPI:
252
  {
253
  "profile": profile["id"],
254
  "section_name": "Tower 1-2",
255
- "start_position": "0.00",
256
- "target_length_feet": "500.00",
257
  },
258
  format="json",
259
  ).data
@@ -281,8 +254,6 @@ class TestWallSectionAPI:
281
  {
282
  "profile": profile["id"],
283
  "section_name": "Tower 1-2",
284
- "start_position": "0.00",
285
- "target_length_feet": "500.00",
286
  },
287
  format="json",
288
  ).data
@@ -299,8 +270,6 @@ class TestWallSectionAPI:
299
  url = reverse("wallsection-list")
300
  payload = {
301
  "section_name": "Tower 1-2",
302
- "start_position": "0.00",
303
- "target_length_feet": "500.00",
304
  }
305
 
306
  response = api_client.post(url, payload, format="json")
@@ -320,8 +289,6 @@ class TestWallSectionAPI:
320
  url = reverse("wallsection-list")
321
  payload = {
322
  "profile": profile["id"],
323
- "start_position": "0.00",
324
- "target_length_feet": "500.00",
325
  }
326
 
327
  response = api_client.post(url, payload, format="json")
 
26
  payload = {
27
  "profile": profile["id"],
28
  "section_name": "Tower 1-2",
 
 
29
  }
30
 
31
  response = api_client.post(url, payload, format="json")
32
 
33
  assert response.status_code == status.HTTP_201_CREATED
34
  assert response.data["section_name"] == "Tower 1-2"
 
 
35
  assert response.data["profile"] == profile["id"]
36
  assert "id" in response.data
37
  assert "created_at" in response.data
 
49
  payload = {
50
  "profile": profile["id"],
51
  "section_name": "Tower 1-2",
 
 
52
  }
53
 
54
  api_client.post(url, payload, format="json")
 
74
  payload1 = {
75
  "profile": profile1["id"],
76
  "section_name": "Tower 1-2",
 
 
77
  }
78
  payload2 = {
79
  "profile": profile2["id"],
80
  "section_name": "Tower 1-2",
 
 
81
  }
82
 
83
  response1 = api_client.post(url, payload1, format="json")
 
101
  {
102
  "profile": profile["id"],
103
  "section_name": "Tower 1-2",
 
 
104
  },
105
  format="json",
106
  )
 
109
  {
110
  "profile": profile["id"],
111
  "section_name": "Tower 2-3",
 
 
112
  },
113
  format="json",
114
  )
 
139
  {
140
  "profile": profile1["id"],
141
  "section_name": "Tower 1-2",
 
 
142
  },
143
  format="json",
144
  )
 
147
  {
148
  "profile": profile2["id"],
149
  "section_name": "Tower 3-4",
 
 
150
  },
151
  format="json",
152
  )
 
172
  {
173
  "profile": profile["id"],
174
  "section_name": "Tower 1-2",
 
 
175
  },
176
  format="json",
177
  ).data
 
198
  {
199
  "profile": profile["id"],
200
  "section_name": "Tower 1-2",
 
 
201
  },
202
  format="json",
203
  ).data
 
206
  updated_payload = {
207
  "profile": profile["id"],
208
  "section_name": "Tower 1-2 Extended",
 
 
209
  }
210
  response = api_client.put(detail_url, updated_payload, format="json")
211
 
212
  assert response.status_code == status.HTTP_200_OK
213
  assert response.data["section_name"] == "Tower 1-2 Extended"
 
214
 
215
  def test_delete_wall_section(self, api_client: APIClient) -> None:
216
  """Test deleting a wall section."""
 
227
  {
228
  "profile": profile["id"],
229
  "section_name": "Tower 1-2",
 
 
230
  },
231
  format="json",
232
  ).data
 
254
  {
255
  "profile": profile["id"],
256
  "section_name": "Tower 1-2",
 
 
257
  },
258
  format="json",
259
  ).data
 
270
  url = reverse("wallsection-list")
271
  payload = {
272
  "section_name": "Tower 1-2",
 
 
273
  }
274
 
275
  response = api_client.post(url, payload, format="json")
 
289
  url = reverse("wallsection-list")
290
  payload = {
291
  "profile": profile["id"],
 
 
292
  }
293
 
294
  response = api_client.post(url, payload, format="json")
tests/unit/__pycache__/test_models.cpython-312-pytest-8.4.2.pyc CHANGED
Binary files a/tests/unit/__pycache__/test_models.cpython-312-pytest-8.4.2.pyc and b/tests/unit/__pycache__/test_models.cpython-312-pytest-8.4.2.pyc differ
 
tests/unit/__pycache__/test_serializers.cpython-312-pytest-8.4.2.pyc CHANGED
Binary files a/tests/unit/__pycache__/test_serializers.cpython-312-pytest-8.4.2.pyc and b/tests/unit/__pycache__/test_serializers.cpython-312-pytest-8.4.2.pyc differ
 
tests/unit/test_aggregators.py DELETED
@@ -1,97 +0,0 @@
1
- """Unit tests for CostAggregatorService."""
2
-
3
- from __future__ import annotations
4
-
5
- from datetime import date
6
- from decimal import Decimal
7
-
8
- import pytest
9
-
10
- from apps.profiles.models import DailyProgress, Profile, WallSection
11
- from apps.profiles.services.aggregators import CostAggregatorService
12
-
13
-
14
- @pytest.mark.django_db
15
- class TestCostAggregatorService:
16
- """Test CostAggregatorService cost calculations."""
17
-
18
- def test_calculate_multi_profile_costs_single_profile(self) -> None:
19
- """Test calculating costs for a single profile."""
20
- profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
21
- section = WallSection.objects.create(
22
- profile=profile,
23
- section_name="Tower 1-2",
24
- start_position=Decimal("0.00"),
25
- target_length_feet=Decimal("500.00"),
26
- )
27
-
28
- DailyProgress.objects.create(
29
- wall_section=section,
30
- date=date(2025, 10, 1),
31
- feet_built=Decimal("10.00"),
32
- ice_cubic_yards=Decimal("1950.00"),
33
- cost_gold_dragons=Decimal("3705000.00"),
34
- )
35
-
36
- aggregator = CostAggregatorService()
37
- results = aggregator.calculate_multi_profile_costs([profile.id], "2025-10-01", "2025-10-01")
38
- aggregator.shutdown()
39
-
40
- assert len(results) == 1
41
- assert results[0]["profile_id"] == profile.id
42
- assert results[0]["total_feet_built"] == "10.00"
43
- assert results[0]["total_ice_cubic_yards"] == "1950.00"
44
- assert results[0]["total_cost_gold_dragons"] == "3705000.00"
45
-
46
- def test_calculate_multi_profile_costs_multiple_profiles(self) -> None:
47
- """Test calculating costs for multiple profiles."""
48
- profile1 = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
49
- profile2 = Profile.objects.create(name="Eastern Defense", team_lead="Tormund")
50
- section1 = WallSection.objects.create(
51
- profile=profile1,
52
- section_name="Tower 1-2",
53
- start_position=Decimal("0.00"),
54
- target_length_feet=Decimal("500.00"),
55
- )
56
- section2 = WallSection.objects.create(
57
- profile=profile2,
58
- section_name="Tower 5-6",
59
- start_position=Decimal("0.00"),
60
- target_length_feet=Decimal("500.00"),
61
- )
62
-
63
- DailyProgress.objects.create(
64
- wall_section=section1,
65
- date=date(2025, 10, 1),
66
- feet_built=Decimal("10.00"),
67
- ice_cubic_yards=Decimal("1950.00"),
68
- cost_gold_dragons=Decimal("3705000.00"),
69
- )
70
- DailyProgress.objects.create(
71
- wall_section=section2,
72
- date=date(2025, 10, 1),
73
- feet_built=Decimal("20.00"),
74
- ice_cubic_yards=Decimal("3900.00"),
75
- cost_gold_dragons=Decimal("7410000.00"),
76
- )
77
-
78
- aggregator = CostAggregatorService()
79
- results = aggregator.calculate_multi_profile_costs([profile1.id, profile2.id], "2025-10-01", "2025-10-01")
80
- aggregator.shutdown()
81
-
82
- assert len(results) == 2
83
- profile_ids = {r["profile_id"] for r in results}
84
- assert profile_ids == {profile1.id, profile2.id}
85
-
86
- def test_calculate_multi_profile_costs_no_data(self) -> None:
87
- """Test calculating costs when no progress data exists."""
88
- profile = Profile.objects.create(name="Northern Watch", team_lead="Jon Snow")
89
-
90
- aggregator = CostAggregatorService()
91
- results = aggregator.calculate_multi_profile_costs([profile.id], "2025-10-01", "2025-10-01")
92
- aggregator.shutdown()
93
-
94
- assert len(results) == 1
95
- assert results[0]["total_feet_built"] == "0.00"
96
- assert results[0]["total_ice_cubic_yards"] == "0.00"
97
- assert results[0]["total_cost_gold_dragons"] == "0.00"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/unit/test_calculators.py DELETED
@@ -1,63 +0,0 @@
1
- """Unit tests for calculator services."""
2
-
3
- from __future__ import annotations
4
-
5
- from decimal import Decimal
6
-
7
- from apps.profiles.services.calculators import IceUsageCalculator
8
-
9
-
10
- class TestIceUsageCalculator:
11
- """Test IceUsageCalculator service."""
12
-
13
- def test_calculate_ice_usage_for_10_feet(self) -> None:
14
- """Test ice usage calculation for 10 feet built."""
15
- calculator = IceUsageCalculator()
16
- result = calculator.calculate_ice_usage(Decimal("10.00"))
17
-
18
- # 10 feet × 195 yd³/ft = 1950 yd³
19
- assert result == Decimal("1950.00")
20
-
21
- def test_calculate_ice_usage_for_zero_feet(self) -> None:
22
- """Test ice usage calculation for zero feet built."""
23
- calculator = IceUsageCalculator()
24
- result = calculator.calculate_ice_usage(Decimal("0.00"))
25
-
26
- assert result == Decimal("0.00")
27
-
28
- def test_calculate_ice_usage_for_fractional_feet(self) -> None:
29
- """Test ice usage calculation for fractional feet."""
30
- calculator = IceUsageCalculator()
31
- result = calculator.calculate_ice_usage(Decimal("12.5"))
32
-
33
- # 12.5 feet × 195 yd³/ft = 2437.5 yd³
34
- assert result == Decimal("2437.50")
35
-
36
- def test_calculate_daily_cost_from_ice_usage(self) -> None:
37
- """Test daily cost calculation from ice usage."""
38
- calculator = IceUsageCalculator()
39
- ice_cubic_yards = Decimal("1950.00")
40
- result = calculator.calculate_daily_cost(ice_cubic_yards)
41
-
42
- # 1950 yd³ × 1900 GD/yd³ = 3,705,000 GD
43
- assert result == Decimal("3705000.00")
44
-
45
- def test_calculate_daily_cost_for_zero_ice(self) -> None:
46
- """Test daily cost calculation for zero ice usage."""
47
- calculator = IceUsageCalculator()
48
- result = calculator.calculate_daily_cost(Decimal("0.00"))
49
-
50
- assert result == Decimal("0.00")
51
-
52
- def test_full_calculation_pipeline(self) -> None:
53
- """Test complete calculation from feet to cost."""
54
- calculator = IceUsageCalculator()
55
- feet_built = Decimal("12.5")
56
-
57
- ice_usage = calculator.calculate_ice_usage(feet_built)
58
- daily_cost = calculator.calculate_daily_cost(ice_usage)
59
-
60
- # 12.5 feet × 195 yd³/ft = 2437.5 yd³
61
- assert ice_usage == Decimal("2437.50")
62
- # 2437.5 yd³ × 1900 GD/yd³ = 4,631,250 GD
63
- assert daily_cost == Decimal("4631250.00")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/unit/test_models.py CHANGED
@@ -8,8 +8,8 @@ from decimal import Decimal
8
  import pytest
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
@@ -55,15 +55,11 @@ class TestWallSectionModel:
55
  wall_section = WallSection.objects.create(
56
  profile=profile,
57
  section_name="Tower 1-2",
58
- start_position=Decimal("0.00"),
59
- target_length_feet=Decimal("500.00"),
60
  )
61
 
62
  assert wall_section.id is not None
63
  assert wall_section.profile == profile
64
  assert wall_section.section_name == "Tower 1-2"
65
- assert wall_section.start_position == Decimal("0.00")
66
- assert wall_section.target_length_feet == Decimal("500.00")
67
 
68
  def test_wall_section_str_representation(self) -> None:
69
  """Test wall section string representation."""
@@ -71,8 +67,6 @@ class TestWallSectionModel:
71
  wall_section = WallSection.objects.create(
72
  profile=profile,
73
  section_name="Tower 1-2",
74
- start_position=Decimal("0.00"),
75
- target_length_feet=Decimal("500.00"),
76
  )
77
 
78
  assert str(wall_section) == "Tower 1-2 (Northern Watch)"
@@ -88,13 +82,11 @@ class TestDailyProgressModel:
88
  wall_section = WallSection.objects.create(
89
  profile=profile,
90
  section_name="Tower 1-2",
91
- start_position=Decimal("0.00"),
92
- target_length_feet=Decimal("500.00"),
93
  )
94
 
95
  feet_built = Decimal("10.00")
96
- ice_cubic_yards = feet_built * DailyProgress.ICE_PER_FOOT # 10 × 195 = 1950
97
- cost_gold_dragons = ice_cubic_yards * DailyProgress.COST_PER_CUBIC_YARD # 1950 × 1900 = 3,705,000
98
 
99
  progress = DailyProgress.objects.create(
100
  wall_section=wall_section,
@@ -113,8 +105,6 @@ class TestDailyProgressModel:
113
  wall_section = WallSection.objects.create(
114
  profile=profile,
115
  section_name="Tower 1-2",
116
- start_position=Decimal("0.00"),
117
- target_length_feet=Decimal("500.00"),
118
  )
119
 
120
  DailyProgress.objects.create(
@@ -140,8 +130,6 @@ class TestDailyProgressModel:
140
  wall_section = WallSection.objects.create(
141
  profile=profile,
142
  section_name="Tower 1-2",
143
- start_position=Decimal("0.00"),
144
- target_length_feet=Decimal("500.00"),
145
  )
146
 
147
  progress = DailyProgress.objects.create(
@@ -153,229 +141,3 @@ class TestDailyProgressModel:
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
 
8
  import pytest
9
  from django.db import IntegrityError
10
 
11
+ from apps.profiles.constants import COST_PER_CUBIC_YARD, ICE_PER_FOOT
12
  from apps.profiles.models import DailyProgress, Profile, WallSection
 
13
 
14
 
15
  @pytest.mark.django_db
 
55
  wall_section = WallSection.objects.create(
56
  profile=profile,
57
  section_name="Tower 1-2",
 
 
58
  )
59
 
60
  assert wall_section.id is not None
61
  assert wall_section.profile == profile
62
  assert wall_section.section_name == "Tower 1-2"
 
 
63
 
64
  def test_wall_section_str_representation(self) -> None:
65
  """Test wall section string representation."""
 
67
  wall_section = WallSection.objects.create(
68
  profile=profile,
69
  section_name="Tower 1-2",
 
 
70
  )
71
 
72
  assert str(wall_section) == "Tower 1-2 (Northern Watch)"
 
82
  wall_section = WallSection.objects.create(
83
  profile=profile,
84
  section_name="Tower 1-2",
 
 
85
  )
86
 
87
  feet_built = Decimal("10.00")
88
+ ice_cubic_yards = feet_built * ICE_PER_FOOT # 10 × 195 = 1950
89
+ cost_gold_dragons = ice_cubic_yards * COST_PER_CUBIC_YARD # 1950 × 1900 = 3,705,000
90
 
91
  progress = DailyProgress.objects.create(
92
  wall_section=wall_section,
 
105
  wall_section = WallSection.objects.create(
106
  profile=profile,
107
  section_name="Tower 1-2",
 
 
108
  )
109
 
110
  DailyProgress.objects.create(
 
130
  wall_section = WallSection.objects.create(
131
  profile=profile,
132
  section_name="Tower 1-2",
 
 
133
  )
134
 
135
  progress = DailyProgress.objects.create(
 
141
  )
142
 
143
  assert str(progress) == "Tower 1-2: 10.00 ft on 2025-10-20"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/unit/test_serializers.py CHANGED
@@ -22,8 +22,6 @@ class TestDailyProgressSerializer:
22
  wall_section = WallSection.objects.create(
23
  profile=profile,
24
  section_name="Tower 1-2",
25
- start_position=Decimal("0.00"),
26
- target_length_feet=Decimal("500.00"),
27
  )
28
 
29
  serializer = DailyProgressSerializer(
@@ -51,8 +49,6 @@ class TestDailyProgressSerializer:
51
  wall_section = WallSection.objects.create(
52
  profile=profile,
53
  section_name="Tower 1-2",
54
- start_position=Decimal("0.00"),
55
- target_length_feet=Decimal("500.00"),
56
  )
57
 
58
  serializer = DailyProgressSerializer(
@@ -82,8 +78,6 @@ class TestDailyProgressSerializer:
82
  wall_section = WallSection.objects.create(
83
  profile=profile,
84
  section_name="Tower 1-2",
85
- start_position=Decimal("0.00"),
86
- target_length_feet=Decimal("500.00"),
87
  )
88
 
89
  serializer = DailyProgressSerializer(
@@ -107,8 +101,6 @@ class TestDailyProgressSerializer:
107
  wall_section = WallSection.objects.create(
108
  profile=profile,
109
  section_name="Tower 1-2",
110
- start_position=Decimal("0.00"),
111
- target_length_feet=Decimal("500.00"),
112
  )
113
 
114
  serializer = DailyProgressSerializer(
@@ -131,8 +123,6 @@ class TestDailyProgressSerializer:
131
  wall_section = WallSection.objects.create(
132
  profile=profile,
133
  section_name="Tower 1-2",
134
- start_position=Decimal("0.00"),
135
- target_length_feet=Decimal("500.00"),
136
  )
137
 
138
  # Try to override calculated fields
@@ -159,8 +149,6 @@ class TestDailyProgressSerializer:
159
  wall_section = WallSection.objects.create(
160
  profile=profile,
161
  section_name="Tower 1-2",
162
- start_position=Decimal("0.00"),
163
- target_length_feet=Decimal("500.00"),
164
  )
165
 
166
  # Create first progress entry
 
22
  wall_section = WallSection.objects.create(
23
  profile=profile,
24
  section_name="Tower 1-2",
 
 
25
  )
26
 
27
  serializer = DailyProgressSerializer(
 
49
  wall_section = WallSection.objects.create(
50
  profile=profile,
51
  section_name="Tower 1-2",
 
 
52
  )
53
 
54
  serializer = DailyProgressSerializer(
 
78
  wall_section = WallSection.objects.create(
79
  profile=profile,
80
  section_name="Tower 1-2",
 
 
81
  )
82
 
83
  serializer = DailyProgressSerializer(
 
101
  wall_section = WallSection.objects.create(
102
  profile=profile,
103
  section_name="Tower 1-2",
 
 
104
  )
105
 
106
  serializer = DailyProgressSerializer(
 
123
  wall_section = WallSection.objects.create(
124
  profile=profile,
125
  section_name="Tower 1-2",
 
 
126
  )
127
 
128
  # Try to override calculated fields
 
149
  wall_section = WallSection.objects.create(
150
  profile=profile,
151
  section_name="Tower 1-2",
 
 
152
  )
153
 
154
  # Create first progress entry