cleanup: remove obsolete code and unused fields
Browse files- .gitignore +3 -0
- SPEC-DEMO-GUI.md +64 -40
- SPEC-DEMO-TDD.md +0 -1399
- SPEC-DEMO.md +387 -425
- apps/profiles/__pycache__/models.cpython-312.pyc +0 -0
- apps/profiles/__pycache__/serializers.cpython-312.pyc +0 -0
- apps/profiles/constants.py +9 -0
- apps/profiles/migrations/0003_remove_wallsection_start_position_and_more.py +20 -0
- apps/profiles/models.py +0 -7
- apps/profiles/repositories.py +0 -64
- apps/profiles/serializers.py +5 -7
- apps/profiles/services/__pycache__/calculators.cpython-312.pyc +0 -0
- apps/profiles/services/aggregators.py +0 -124
- apps/profiles/services/calculators.py +0 -34
- apps/profiles/services/simulator.py +6 -11
- apps/profiles/views.py +0 -229
- db.sqlite3 +0 -0
- scripts/run_tests.py +24 -12
- tests/integration/__pycache__/test_profile_api.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/integration/__pycache__/test_wallsection_api.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/integration/test_edge_cases.py +40 -0
- tests/integration/test_profile_api.py +0 -388
- tests/integration/test_wallsection_api.py +0 -33
- tests/unit/__pycache__/test_models.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/unit/__pycache__/test_serializers.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/unit/test_aggregators.py +0 -97
- tests/unit/test_calculators.py +0 -63
- tests/unit/test_models.py +3 -241
- tests/unit/test_serializers.py +0 -12
.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 |
-
│ │ ├──
|
|
|
|
| 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
|
| 358 |
|
| 359 |
-
**Data Source**: `GET /api/
|
| 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
|
| 377 |
|
| 378 |
-
**Data Source**: `GET /api/
|
| 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.
|
| 395 |
-
**File**: `src/pages/
|
| 396 |
|
| 397 |
-
**Purpose**:
|
| 398 |
|
| 399 |
-
**Data Source**: `POST /api/
|
| 400 |
|
| 401 |
**Components**:
|
| 402 |
-
-
|
| 403 |
-
-
|
| 404 |
-
-
|
| 405 |
-
-
|
| 406 |
-
-
|
| 407 |
-
-
|
| 408 |
-
- Submit button
|
| 409 |
|
| 410 |
**Key Features**:
|
| 411 |
-
-
|
| 412 |
-
-
|
| 413 |
-
-
|
| 414 |
-
-
|
| 415 |
-
-
|
|
|
|
| 416 |
|
| 417 |
-
### 4.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
###
|
| 438 |
**File**: `src/pages/CostAnalytics.jsx`
|
| 439 |
|
| 440 |
**Purpose**: Multi-chart cost analytics dashboard
|
| 441 |
|
| 442 |
-
**Data Source**: `GET /api/
|
| 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 |
-
//
|
| 519 |
-
|
| 520 |
method: 'POST',
|
| 521 |
-
body: JSON.stringify(
|
| 522 |
}),
|
| 523 |
|
| 524 |
// Analytics
|
| 525 |
-
|
| 526 |
-
|
| 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
|
|
|
|
| 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('
|
| 692 |
-
className={route === '
|
| 693 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 === '
|
|
|
|
| 703 |
</main>
|
| 704 |
</div>
|
| 705 |
)
|
|
@@ -712,8 +736,8 @@ export default App
|
|
| 712 |
```
|
| 713 |
/#dashboard
|
| 714 |
/#profile?id=1
|
| 715 |
-
/#
|
| 716 |
-
/#
|
| 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
|
| 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.
|
| 16 |
-
2.
|
| 17 |
-
3.
|
| 18 |
-
4.
|
| 19 |
-
5.
|
| 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
|
| 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**
|
| 31 |
-
- ViewSets for CRUD
|
| 32 |
- Serializers for data validation
|
| 33 |
-
- Pagination
|
| 34 |
|
| 35 |
### Multi-Threading
|
| 36 |
- **Python concurrent.futures.ThreadPoolExecutor**
|
| 37 |
-
-
|
| 38 |
-
-
|
| 39 |
-
-
|
| 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 │ │
|
| 54 |
-
│ │ ViewSet │ │ ViewSet │ │
|
| 55 |
-
│
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
┌─────────────────────────────────────────────────────────────┐
|
| 60 |
-
│
|
| 61 |
-
│ ┌──────────────┐ ┌──────────────┐
|
| 62 |
-
│ │
|
| 63 |
-
│ │
|
| 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 |
-
│ │
|
| 83 |
-
│ │
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 189 |
```http
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
```
|
| 192 |
|
| 193 |
-
**Response**
|
| 194 |
```json
|
| 195 |
{
|
| 196 |
-
"
|
| 197 |
-
"
|
| 198 |
-
"
|
| 199 |
-
"
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
```http
|
| 213 |
-
|
| 214 |
-
|
| 215 |
|
|
|
|
|
|
|
| 216 |
{
|
| 217 |
-
"
|
| 218 |
-
"
|
| 219 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
}
|
| 221 |
```
|
| 222 |
|
| 223 |
-
### 3.
|
| 224 |
```http
|
| 225 |
-
|
| 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 |
-
"
|
| 240 |
-
"
|
| 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 |
-
|
| 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/{
|
| 257 |
```
|
| 258 |
|
| 259 |
**Response**
|
| 260 |
```json
|
| 261 |
{
|
| 262 |
-
"
|
| 263 |
-
"
|
| 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.
|
| 283 |
```http
|
| 284 |
-
GET /api/profiles/
|
| 285 |
```
|
| 286 |
|
| 287 |
**Response**
|
| 288 |
```json
|
| 289 |
{
|
| 290 |
-
"
|
| 291 |
-
"
|
| 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 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
|
| 323 |
-
###
|
| 324 |
|
| 325 |
```python
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
|
| 332 |
-
class
|
| 333 |
-
"""
|
| 334 |
-
Service for parallel cost calculations across multiple profiles.
|
| 335 |
-
Uses ThreadPoolExecutor for CPU-bound aggregation tasks.
|
| 336 |
-
"""
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
)
|
| 380 |
-
results.append({
|
| 381 |
-
"profile_id": profile_id,
|
| 382 |
-
"error": str(exc)
|
| 383 |
-
})
|
| 384 |
-
|
| 385 |
-
return results
|
| 386 |
|
| 387 |
-
|
| 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 |
-
|
| 406 |
-
"
|
| 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 |
-
|
| 414 |
-
"""Gracefully shutdown the thread pool."""
|
| 415 |
-
self.executor.shutdown(wait=True)
|
| 416 |
```
|
| 417 |
|
| 418 |
-
###
|
| 419 |
|
| 420 |
```python
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
```
|
| 424 |
|
| 425 |
-
|
|
|
|
| 426 |
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
from rest_framework.response import Response
|
| 431 |
|
| 432 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
|
| 434 |
-
|
| 435 |
-
|
| 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 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
profile_ids,
|
| 445 |
-
start_date,
|
| 446 |
-
end_date
|
| 447 |
-
)
|
| 448 |
-
return Response({"results": results})
|
| 449 |
-
finally:
|
| 450 |
-
aggregator.shutdown()
|
| 451 |
-
```
|
| 452 |
|
| 453 |
-
|
|
|
|
|
|
|
| 454 |
|
| 455 |
-
|
|
|
|
| 456 |
|
| 457 |
-
|
| 458 |
-
|
| 459 |
|
| 460 |
-
|
| 461 |
-
|
| 462 |
|
| 463 |
-
|
| 464 |
-
|
| 465 |
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
"""Calculate ice usage in cubic yards."""
|
| 469 |
-
return feet_built * cls.ICE_PER_FOOT
|
| 470 |
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
"""Calculate cost in Gold Dragons."""
|
| 474 |
-
return ice_cubic_yards * cls.COST_PER_CUBIC_YARD
|
| 475 |
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
```
|
| 483 |
|
| 484 |
-
##
|
| 485 |
|
| 486 |
-
###
|
| 487 |
|
| 488 |
```python
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
).select_related('wall_section')
|
| 501 |
|
| 502 |
-
|
| 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 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
"record_count": result["record_count"]
|
| 528 |
-
}
|
| 529 |
```
|
| 530 |
|
| 531 |
-
##
|
| 532 |
|
| 533 |
-
###
|
| 534 |
|
| 535 |
```python
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
```
|
| 540 |
|
| 541 |
-
##
|
| 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 |
-
|
| 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 |
-
###
|
| 573 |
-
|
| 574 |
-
-
|
| 575 |
-
-
|
| 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 |
-
|
| 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`
|
| 622 |
-
- `select_related()` for
|
| 623 |
-
- `aggregate()` for sum
|
| 624 |
-
-
|
| 625 |
|
| 626 |
### Thread Pool Sizing
|
| 627 |
-
- Default:
|
| 628 |
-
-
|
| 629 |
-
-
|
| 630 |
-
- HuggingFace Space
|
|
|
|
|
|
|
| 631 |
|
| 632 |
-
### Query Optimization
|
| 633 |
```python
|
| 634 |
-
#
|
| 635 |
-
|
| 636 |
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
```
|
| 641 |
|
| 642 |
-
##
|
| 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 |
-
#
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
|
| 659 |
-
#
|
| 660 |
-
|
| 661 |
-
|
| 662 |
|
| 663 |
-
#
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 33 |
-
"
|
| 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 =
|
| 62 |
-
cost_gold_dragons =
|
| 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 <
|
| 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 <
|
| 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)) *
|
| 247 |
-
cost = ice *
|
| 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 >=
|
| 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 |
-
"""
|
| 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
|
| 16 |
demo_root = Path(__file__).resolve().parent.parent
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
|
|
|
| 19 |
if not tests_dir.exists():
|
| 20 |
-
|
| 21 |
return 0
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 *
|
| 97 |
-
cost_gold_dragons = ice_cubic_yards *
|
| 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
|