feat: implement TDD for Wall Construction API with full type safety
Browse filesComplete test-driven development implementation:
- Profile model with full CRUD API (13 tests, django-filter integration)
- WallSection model with height calculations (11 tests, ice/crew validation)
- Django REST Framework serializers and viewsets with comprehensive test coverage
- Full type safety with django-stubs==5.2.6 and djangorestframework-stubs==3.16.4
- Fixed all 61 mypy errors: added type parameters to ModelSerializer[T] and ModelViewSet[T]
- Updated test fixtures with proper APIClient typing (was object)
- Upgraded factory-boy==3.3.3 for native type annotations
- Removed legacy mypy config from other projects
- 24 tests passing, 96% coverage, 0 mypy errors
- .coverage +0 -0
- README.md +3 -0
- SPEC-DEMO-GUI.md +1205 -0
- SPEC-DEMO-TDD.md +1399 -0
- SPEC-DEMO.md +667 -0
- __pycache__/__init__.cpython-312.pyc +0 -0
- apps/__init__.py +0 -0
- apps/__pycache__/__init__.cpython-312.pyc +0 -0
- apps/profiles/__init__.py +0 -0
- apps/profiles/__pycache__/__init__.cpython-312.pyc +0 -0
- apps/profiles/__pycache__/models.cpython-312.pyc +0 -0
- apps/profiles/__pycache__/serializers.cpython-312.pyc +0 -0
- apps/profiles/__pycache__/urls.cpython-312.pyc +0 -0
- apps/profiles/__pycache__/views.cpython-312.pyc +0 -0
- apps/profiles/models.py +46 -0
- apps/profiles/serializers.py +32 -0
- apps/profiles/urls.py +16 -0
- apps/profiles/views.py +23 -0
- config/__init__.py +0 -0
- config/__pycache__/__init__.cpython-312.pyc +0 -0
- config/__pycache__/urls.cpython-312.pyc +0 -0
- config/settings/__init__.py +0 -0
- config/settings/__pycache__/__init__.cpython-312.pyc +0 -0
- config/settings/__pycache__/base.cpython-312.pyc +0 -0
- config/settings/__pycache__/test.cpython-312.pyc +0 -0
- config/settings/base.py +82 -0
- config/settings/test.py +57 -0
- config/urls.py +9 -0
- config/wsgi.py +11 -0
- main.py +2 -2
- manage.py +25 -0
- module_setup.py +16 -13
- mypy.ini +31 -0
- pyproject.toml +37 -4
- pytest.ini +15 -11
- ruff.toml +49 -0
- scripts/ruff.toml +45 -0
- scripts/run_tests.py +28 -4
- tests/__pycache__/__init__.cpython-312.pyc +0 -0
- tests/__pycache__/conftest.cpython-312-pytest-8.4.1.pyc +0 -0
- tests/__pycache__/conftest.cpython-312-pytest-8.4.2.pyc +0 -0
- tests/conftest.py +17 -0
- tests/factories.py +14 -0
- tests/integration/__init__.py +0 -0
- tests/integration/__pycache__/__init__.cpython-312.pyc +0 -0
- 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_profile_api.py +194 -0
- tests/integration/test_wallsection_api.py +330 -0
.coverage
ADDED
|
Binary file (53.2 kB). View file
|
|
|
README.md
CHANGED
|
@@ -21,6 +21,9 @@ From the orchestrator root:
|
|
| 21 |
- `python.ver` - Python version requirement (3.12)
|
| 22 |
- `.gitignore` - Git ignore patterns (.venv, uv.lock, build artifacts)
|
| 23 |
- `scripts/run_tests.py` - Test runner script
|
|
|
|
|
|
|
|
|
|
| 24 |
- `README.md` - This file
|
| 25 |
|
| 26 |
## Development
|
|
|
|
| 21 |
- `python.ver` - Python version requirement (3.12)
|
| 22 |
- `.gitignore` - Git ignore patterns (.venv, uv.lock, build artifacts)
|
| 23 |
- `scripts/run_tests.py` - Test runner script
|
| 24 |
+
- `SPEC-DEMO.md` - Wall Construction API technical specification (Django backend)
|
| 25 |
+
- `SPEC-DEMO-GUI.md` - Wall Construction GUI technical specification (React frontend)
|
| 26 |
+
- `SPEC-DEMO-TDD.md` - Test-Driven Development specification (pytest, factories, coverage)
|
| 27 |
- `README.md` - This file
|
| 28 |
|
| 29 |
## Development
|
SPEC-DEMO-GUI.md
ADDED
|
@@ -0,0 +1,1205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wall Construction API - GUI Technical Specification
|
| 2 |
+
|
| 3 |
+
## Philosophy: Minimal Dependencies, Maximum Standards
|
| 4 |
+
|
| 5 |
+
This specification defines a **production-ready React GUI** with an absolute minimal dependency footprint while adhering to 2025 industry best practices.
|
| 6 |
+
|
| 7 |
+
**Core Principle**: Every dependency must justify its existence. No bloat, no convenience libraries that add marginal value.
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Technology Stack
|
| 12 |
+
|
| 13 |
+
### Framework & Build Tools
|
| 14 |
+
- **React 19.2.0** (October 2025 release)
|
| 15 |
+
- Latest stable with React Compiler
|
| 16 |
+
- Actions API for async operations
|
| 17 |
+
- Enhanced form handling
|
| 18 |
+
- Improved hydration and error reporting
|
| 19 |
+
|
| 20 |
+
- **Vite 7.0** (2025 release)
|
| 21 |
+
- Requires Node.js 20.19+ or 22.12+
|
| 22 |
+
- ESM-only distribution
|
| 23 |
+
- Native `require(esm)` support
|
| 24 |
+
- 5x faster builds than Vite 6
|
| 25 |
+
- Instant HMR (Hot Module Replacement)
|
| 26 |
+
|
| 27 |
+
### Styling & UI
|
| 28 |
+
- **Tailwind CSS v4.0** (January 2025)
|
| 29 |
+
- Zero configuration setup
|
| 30 |
+
- Single CSS import: `@import "tailwindcss"`
|
| 31 |
+
- Built-in Vite plugin
|
| 32 |
+
- 5x faster full builds, 100x faster incremental builds
|
| 33 |
+
- Modern CSS features (cascade layers, @property, color-mix)
|
| 34 |
+
- P3 color palette for vibrant displays
|
| 35 |
+
- Container queries support
|
| 36 |
+
|
| 37 |
+
### Data Visualization
|
| 38 |
+
- **Recharts 2.x** (latest)
|
| 39 |
+
- 24.8k GitHub stars
|
| 40 |
+
- React-native component API
|
| 41 |
+
- SVG-based rendering
|
| 42 |
+
- Responsive by default
|
| 43 |
+
- Composable chart primitives
|
| 44 |
+
- Built on D3.js submodules
|
| 45 |
+
|
| 46 |
+
### HTTP & State
|
| 47 |
+
- **Native Fetch API** (no axios, no external HTTP libs)
|
| 48 |
+
- **React useState/useReducer** (no Redux, no Zustand, no external state libs)
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Dependencies
|
| 53 |
+
|
| 54 |
+
### Production Dependencies (3 total)
|
| 55 |
+
```json
|
| 56 |
+
{
|
| 57 |
+
"react": "^19.2.0",
|
| 58 |
+
"react-dom": "^19.2.0",
|
| 59 |
+
"recharts": "^2.15.0"
|
| 60 |
+
}
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### Development Dependencies (2 total)
|
| 64 |
+
```json
|
| 65 |
+
{
|
| 66 |
+
"vite": "^7.0.0",
|
| 67 |
+
"@tailwindcss/vite": "^4.0.0"
|
| 68 |
+
}
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
**Total: 5 dependencies**
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## Project Structure
|
| 76 |
+
|
| 77 |
+
```
|
| 78 |
+
wall-construction-gui/
|
| 79 |
+
├── public/
|
| 80 |
+
│ └── favicon.ico
|
| 81 |
+
├── src/
|
| 82 |
+
│ ├── components/ # Reusable UI components
|
| 83 |
+
│ │ ├── Button.jsx
|
| 84 |
+
│ │ ├── Card.jsx
|
| 85 |
+
│ │ ├── Input.jsx
|
| 86 |
+
│ │ ├── Select.jsx
|
| 87 |
+
│ │ ├── DatePicker.jsx
|
| 88 |
+
│ │ ├── Spinner.jsx
|
| 89 |
+
│ │ ├── ErrorBoundary.jsx
|
| 90 |
+
│ │ └── charts/
|
| 91 |
+
│ │ ├── LineChart.jsx
|
| 92 |
+
│ │ ├── BarChart.jsx
|
| 93 |
+
│ │ └── AreaChart.jsx
|
| 94 |
+
│ ├── pages/ # Page-level components
|
| 95 |
+
│ │ ├── Dashboard.jsx
|
| 96 |
+
│ │ ├── ProfileDetail.jsx
|
| 97 |
+
│ │ ├── ProgressForm.jsx
|
| 98 |
+
│ │ ├── DailyIceUsage.jsx
|
| 99 |
+
│ │ └── CostAnalytics.jsx
|
| 100 |
+
│ ├── hooks/ # Custom React hooks
|
| 101 |
+
│ │ ├── useApi.js
|
| 102 |
+
│ │ ├── useFetch.js
|
| 103 |
+
│ │ └── useDebounce.js
|
| 104 |
+
│ ├── utils/ # Helper functions
|
| 105 |
+
│ │ ├── api.js # Fetch wrapper
|
| 106 |
+
│ │ ├── formatters.js # Number/date formatting
|
| 107 |
+
│ │ └── constants.js # App constants
|
| 108 |
+
│ ├── App.jsx # Root component
|
| 109 |
+
│ ├── main.jsx # Entry point
|
| 110 |
+
│ └── index.css # Global styles
|
| 111 |
+
├── index.html
|
| 112 |
+
├── vite.config.js
|
| 113 |
+
└── package.json
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## Setup Instructions
|
| 119 |
+
|
| 120 |
+
### 1. Initialize Project
|
| 121 |
+
|
| 122 |
+
```bash
|
| 123 |
+
# Create Vite project
|
| 124 |
+
npm create vite@latest wall-construction-gui -- --template react
|
| 125 |
+
|
| 126 |
+
cd wall-construction-gui
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
### 2. Install Dependencies
|
| 130 |
+
|
| 131 |
+
```bash
|
| 132 |
+
# Install production dependencies
|
| 133 |
+
npm install react@19.2.0 react-dom@19.2.0 recharts
|
| 134 |
+
|
| 135 |
+
# Install dev dependencies
|
| 136 |
+
npm install -D vite@7 @tailwindcss/vite@4
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
### 3. Configure Vite
|
| 140 |
+
|
| 141 |
+
**vite.config.js**
|
| 142 |
+
```javascript
|
| 143 |
+
import { defineConfig } from 'vite'
|
| 144 |
+
import react from '@vitejs/plugin-react'
|
| 145 |
+
import tailwindcss from '@tailwindcss/vite'
|
| 146 |
+
|
| 147 |
+
export default defineConfig({
|
| 148 |
+
plugins: [
|
| 149 |
+
react(),
|
| 150 |
+
tailwindcss()
|
| 151 |
+
],
|
| 152 |
+
server: {
|
| 153 |
+
port: 5173,
|
| 154 |
+
proxy: {
|
| 155 |
+
'/api': {
|
| 156 |
+
target: 'http://localhost:8000',
|
| 157 |
+
changeOrigin: true
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
})
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### 4. Setup Tailwind CSS
|
| 165 |
+
|
| 166 |
+
**src/index.css**
|
| 167 |
+
```css
|
| 168 |
+
@import "tailwindcss";
|
| 169 |
+
|
| 170 |
+
/* CSS Custom Properties for Theme */
|
| 171 |
+
:root {
|
| 172 |
+
--color-primary: #3b82f6;
|
| 173 |
+
--color-secondary: #64748b;
|
| 174 |
+
--color-success: #10b981;
|
| 175 |
+
--color-danger: #ef4444;
|
| 176 |
+
--color-warning: #f59e0b;
|
| 177 |
+
--color-ice: #93c5fd;
|
| 178 |
+
--color-gold: #fbbf24;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/* Global Styles */
|
| 182 |
+
body {
|
| 183 |
+
@apply bg-gray-50 text-gray-900;
|
| 184 |
+
}
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
### 5. Run Development Server
|
| 188 |
+
|
| 189 |
+
```bash
|
| 190 |
+
npm run dev
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
Server starts at `http://localhost:5173`
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## Component Architecture
|
| 198 |
+
|
| 199 |
+
### Base Components
|
| 200 |
+
|
| 201 |
+
#### Button Component
|
| 202 |
+
```jsx
|
| 203 |
+
// src/components/Button.jsx
|
| 204 |
+
export default function Button({
|
| 205 |
+
children,
|
| 206 |
+
variant = 'primary',
|
| 207 |
+
onClick,
|
| 208 |
+
disabled = false,
|
| 209 |
+
type = 'button'
|
| 210 |
+
}) {
|
| 211 |
+
const variants = {
|
| 212 |
+
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
|
| 213 |
+
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900',
|
| 214 |
+
danger: 'bg-red-600 hover:bg-red-700 text-white'
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
return (
|
| 218 |
+
<button
|
| 219 |
+
type={type}
|
| 220 |
+
onClick={onClick}
|
| 221 |
+
disabled={disabled}
|
| 222 |
+
className={`
|
| 223 |
+
px-4 py-2 rounded-lg font-medium
|
| 224 |
+
transition-colors duration-200
|
| 225 |
+
disabled:opacity-50 disabled:cursor-not-allowed
|
| 226 |
+
${variants[variant]}
|
| 227 |
+
`}
|
| 228 |
+
>
|
| 229 |
+
{children}
|
| 230 |
+
</button>
|
| 231 |
+
)
|
| 232 |
+
}
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
#### Card Component
|
| 236 |
+
```jsx
|
| 237 |
+
// src/components/Card.jsx
|
| 238 |
+
export default function Card({ children, className = '' }) {
|
| 239 |
+
return (
|
| 240 |
+
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
| 241 |
+
{children}
|
| 242 |
+
</div>
|
| 243 |
+
)
|
| 244 |
+
}
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
#### Input Component
|
| 248 |
+
```jsx
|
| 249 |
+
// src/components/Input.jsx
|
| 250 |
+
export default function Input({
|
| 251 |
+
label,
|
| 252 |
+
type = 'text',
|
| 253 |
+
value,
|
| 254 |
+
onChange,
|
| 255 |
+
placeholder,
|
| 256 |
+
required = false,
|
| 257 |
+
error
|
| 258 |
+
}) {
|
| 259 |
+
return (
|
| 260 |
+
<div className="mb-4">
|
| 261 |
+
{label && (
|
| 262 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 263 |
+
{label}
|
| 264 |
+
{required && <span className="text-red-500 ml-1">*</span>}
|
| 265 |
+
</label>
|
| 266 |
+
)}
|
| 267 |
+
<input
|
| 268 |
+
type={type}
|
| 269 |
+
value={value}
|
| 270 |
+
onChange={(e) => onChange(e.target.value)}
|
| 271 |
+
placeholder={placeholder}
|
| 272 |
+
required={required}
|
| 273 |
+
className={`
|
| 274 |
+
w-full px-4 py-2 border rounded-lg
|
| 275 |
+
focus:outline-none focus:ring-2 focus:ring-blue-500
|
| 276 |
+
${error ? 'border-red-500' : 'border-gray-300'}
|
| 277 |
+
`}
|
| 278 |
+
/>
|
| 279 |
+
{error && (
|
| 280 |
+
<p className="text-red-500 text-sm mt-1">{error}</p>
|
| 281 |
+
)}
|
| 282 |
+
</div>
|
| 283 |
+
)
|
| 284 |
+
}
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
### Chart Components
|
| 288 |
+
|
| 289 |
+
#### LineChart Wrapper
|
| 290 |
+
```jsx
|
| 291 |
+
// src/components/charts/LineChart.jsx
|
| 292 |
+
import {
|
| 293 |
+
LineChart as RechartsLine,
|
| 294 |
+
Line,
|
| 295 |
+
XAxis,
|
| 296 |
+
YAxis,
|
| 297 |
+
CartesianGrid,
|
| 298 |
+
Tooltip,
|
| 299 |
+
ResponsiveContainer
|
| 300 |
+
} from 'recharts'
|
| 301 |
+
|
| 302 |
+
export default function LineChart({ data, dataKey, xKey, color = '#3b82f6' }) {
|
| 303 |
+
return (
|
| 304 |
+
<ResponsiveContainer width="100%" height={300}>
|
| 305 |
+
<RechartsLine data={data}>
|
| 306 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 307 |
+
<XAxis dataKey={xKey} />
|
| 308 |
+
<YAxis />
|
| 309 |
+
<Tooltip />
|
| 310 |
+
<Line
|
| 311 |
+
type="monotone"
|
| 312 |
+
dataKey={dataKey}
|
| 313 |
+
stroke={color}
|
| 314 |
+
strokeWidth={2}
|
| 315 |
+
/>
|
| 316 |
+
</RechartsLine>
|
| 317 |
+
</ResponsiveContainer>
|
| 318 |
+
)
|
| 319 |
+
}
|
| 320 |
+
```
|
| 321 |
+
|
| 322 |
+
#### BarChart Wrapper
|
| 323 |
+
```jsx
|
| 324 |
+
// src/components/charts/BarChart.jsx
|
| 325 |
+
import {
|
| 326 |
+
BarChart as RechartsBar,
|
| 327 |
+
Bar,
|
| 328 |
+
XAxis,
|
| 329 |
+
YAxis,
|
| 330 |
+
CartesianGrid,
|
| 331 |
+
Tooltip,
|
| 332 |
+
ResponsiveContainer
|
| 333 |
+
} from 'recharts'
|
| 334 |
+
|
| 335 |
+
export default function BarChart({ data, dataKey, xKey, color = '#10b981' }) {
|
| 336 |
+
return (
|
| 337 |
+
<ResponsiveContainer width="100%" height={300}>
|
| 338 |
+
<RechartsBar data={data}>
|
| 339 |
+
<CartesianGrid strokeDasharray="3 3" />
|
| 340 |
+
<XAxis dataKey={xKey} />
|
| 341 |
+
<YAxis />
|
| 342 |
+
<Tooltip />
|
| 343 |
+
<Bar dataKey={dataKey} fill={color} />
|
| 344 |
+
</RechartsBar>
|
| 345 |
+
</ResponsiveContainer>
|
| 346 |
+
)
|
| 347 |
+
}
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
---
|
| 351 |
+
|
| 352 |
+
## Pages
|
| 353 |
+
|
| 354 |
+
### 1. Dashboard
|
| 355 |
+
**File**: `src/pages/Dashboard.jsx`
|
| 356 |
+
|
| 357 |
+
**Purpose**: Display all construction profiles as cards with summary statistics
|
| 358 |
+
|
| 359 |
+
**Data Source**: `GET /api/profiles/` + parallel `GET /api/profiles/{id}/cost-overview/`
|
| 360 |
+
|
| 361 |
+
**Components**:
|
| 362 |
+
- Grid of profile cards
|
| 363 |
+
- Summary statistics (total ice, total cost, total feet)
|
| 364 |
+
- "View Details" button per profile
|
| 365 |
+
- "+ New Profile" button
|
| 366 |
+
|
| 367 |
+
**Key Features**:
|
| 368 |
+
- Responsive grid (1 col mobile, 2 col tablet, 3 col desktop)
|
| 369 |
+
- Loading states with skeleton cards
|
| 370 |
+
- Empty state when no profiles exist
|
| 371 |
+
- Filter by active/inactive status
|
| 372 |
+
|
| 373 |
+
### 2. ProfileDetail
|
| 374 |
+
**File**: `src/pages/ProfileDetail.jsx`
|
| 375 |
+
|
| 376 |
+
**Purpose**: Detailed view of a single profile with cost analytics and daily breakdown
|
| 377 |
+
|
| 378 |
+
**Data Source**: `GET /api/profiles/{id}/cost-overview/?start_date=X&end_date=Y`
|
| 379 |
+
|
| 380 |
+
**Components**:
|
| 381 |
+
- Profile header (name, team lead)
|
| 382 |
+
- Summary cards (total cost, total ice, avg/day)
|
| 383 |
+
- Line chart: Daily cost trend
|
| 384 |
+
- Line chart: Daily feet built
|
| 385 |
+
- Area chart: Cumulative cost
|
| 386 |
+
- Data table: Daily breakdown
|
| 387 |
+
|
| 388 |
+
**Key Features**:
|
| 389 |
+
- Date range picker (default: last 30 days)
|
| 390 |
+
- Responsive charts
|
| 391 |
+
- Download CSV button (client-side generation)
|
| 392 |
+
- Print-friendly layout
|
| 393 |
+
|
| 394 |
+
### 3. ProgressForm
|
| 395 |
+
**File**: `src/pages/ProgressForm.jsx`
|
| 396 |
+
|
| 397 |
+
**Purpose**: Record daily construction progress for a wall section
|
| 398 |
+
|
| 399 |
+
**Data Source**: `POST /api/profiles/{id}/progress/`
|
| 400 |
+
|
| 401 |
+
**Components**:
|
| 402 |
+
- Profile selector dropdown
|
| 403 |
+
- Wall section selector
|
| 404 |
+
- Date picker (default: today)
|
| 405 |
+
- Feet built input (number)
|
| 406 |
+
- Notes textarea (optional)
|
| 407 |
+
- Real-time calculation display (ice usage, cost)
|
| 408 |
+
- Submit button
|
| 409 |
+
|
| 410 |
+
**Key Features**:
|
| 411 |
+
- Form validation (required fields, number validation)
|
| 412 |
+
- Real-time calculations using constants (195 yd³/ft, 1,900 GD/yd³)
|
| 413 |
+
- Success toast notification
|
| 414 |
+
- Error handling with user-friendly messages
|
| 415 |
+
- Clear form after submission
|
| 416 |
+
|
| 417 |
+
### 4. DailyIceUsage
|
| 418 |
+
**File**: `src/pages/DailyIceUsage.jsx`
|
| 419 |
+
|
| 420 |
+
**Purpose**: Breakdown of ice usage by wall section for a specific date
|
| 421 |
+
|
| 422 |
+
**Data Source**: `GET /api/profiles/{id}/daily-ice-usage/?date=YYYY-MM-DD`
|
| 423 |
+
|
| 424 |
+
**Components**:
|
| 425 |
+
- Profile selector
|
| 426 |
+
- Date picker
|
| 427 |
+
- Summary card (total feet, total ice)
|
| 428 |
+
- Horizontal bar chart (ice by section)
|
| 429 |
+
- Data table (section breakdown with percentages)
|
| 430 |
+
|
| 431 |
+
**Key Features**:
|
| 432 |
+
- Client-side percentage calculations
|
| 433 |
+
- Color-coded bars
|
| 434 |
+
- Sortable table columns
|
| 435 |
+
- Export to CSV
|
| 436 |
+
|
| 437 |
+
### 5. CostAnalytics
|
| 438 |
+
**File**: `src/pages/CostAnalytics.jsx`
|
| 439 |
+
|
| 440 |
+
**Purpose**: Multi-chart cost analytics dashboard
|
| 441 |
+
|
| 442 |
+
**Data Source**: `GET /api/profiles/{id}/cost-overview/?start_date=X&end_date=Y`
|
| 443 |
+
|
| 444 |
+
**Components**:
|
| 445 |
+
- Date range selector
|
| 446 |
+
- 4 summary cards (total cost, total feet, avg/day, days)
|
| 447 |
+
- Line chart: Daily cost
|
| 448 |
+
- Line chart: Daily feet built
|
| 449 |
+
- Area chart: Cumulative cost
|
| 450 |
+
- Compare profiles (optional enhancement)
|
| 451 |
+
|
| 452 |
+
**Key Features**:
|
| 453 |
+
- Multiple chart views in grid layout
|
| 454 |
+
- Responsive breakpoints
|
| 455 |
+
- Print view
|
| 456 |
+
- Share URL with date filters in query params
|
| 457 |
+
|
| 458 |
+
---
|
| 459 |
+
|
| 460 |
+
## API Integration
|
| 461 |
+
|
| 462 |
+
### API Client
|
| 463 |
+
|
| 464 |
+
**src/utils/api.js**
|
| 465 |
+
```javascript
|
| 466 |
+
const API_BASE = import.meta.env.DEV ? '/api' : 'https://api.example.com/api'
|
| 467 |
+
|
| 468 |
+
class ApiError extends Error {
|
| 469 |
+
constructor(message, status, data) {
|
| 470 |
+
super(message)
|
| 471 |
+
this.status = status
|
| 472 |
+
this.data = data
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
async function request(endpoint, options = {}) {
|
| 477 |
+
const url = `${API_BASE}${endpoint}`
|
| 478 |
+
|
| 479 |
+
const config = {
|
| 480 |
+
headers: {
|
| 481 |
+
'Content-Type': 'application/json',
|
| 482 |
+
...options.headers
|
| 483 |
+
},
|
| 484 |
+
...options
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
try {
|
| 488 |
+
const response = await fetch(url, config)
|
| 489 |
+
|
| 490 |
+
const data = await response.json()
|
| 491 |
+
|
| 492 |
+
if (!response.ok) {
|
| 493 |
+
throw new ApiError(
|
| 494 |
+
data.message || 'Request failed',
|
| 495 |
+
response.status,
|
| 496 |
+
data
|
| 497 |
+
)
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
return data
|
| 501 |
+
} catch (error) {
|
| 502 |
+
if (error instanceof ApiError) {
|
| 503 |
+
throw error
|
| 504 |
+
}
|
| 505 |
+
throw new ApiError('Network error', 0, { originalError: error })
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
export const api = {
|
| 510 |
+
// Profiles
|
| 511 |
+
getProfiles: () => request('/profiles/'),
|
| 512 |
+
getProfile: (id) => request(`/profiles/${id}/`),
|
| 513 |
+
createProfile: (data) => request('/profiles/', {
|
| 514 |
+
method: 'POST',
|
| 515 |
+
body: JSON.stringify(data)
|
| 516 |
+
}),
|
| 517 |
+
|
| 518 |
+
// Progress
|
| 519 |
+
recordProgress: (profileId, data) => request(`/profiles/${profileId}/progress/`, {
|
| 520 |
+
method: 'POST',
|
| 521 |
+
body: JSON.stringify(data)
|
| 522 |
+
}),
|
| 523 |
+
|
| 524 |
+
// Analytics
|
| 525 |
+
getDailyIceUsage: (profileId, date) =>
|
| 526 |
+
request(`/profiles/${profileId}/daily-ice-usage/?date=${date}`),
|
| 527 |
+
|
| 528 |
+
getCostOverview: (profileId, startDate, endDate) =>
|
| 529 |
+
request(`/profiles/${profileId}/cost-overview/?start_date=${startDate}&end_date=${endDate}`)
|
| 530 |
+
}
|
| 531 |
+
```
|
| 532 |
+
|
| 533 |
+
### Custom Hooks
|
| 534 |
+
|
| 535 |
+
**src/hooks/useFetch.js**
|
| 536 |
+
```javascript
|
| 537 |
+
import { useState, useEffect } from 'react'
|
| 538 |
+
|
| 539 |
+
export function useFetch(fetchFn, dependencies = []) {
|
| 540 |
+
const [data, setData] = useState(null)
|
| 541 |
+
const [loading, setLoading] = useState(true)
|
| 542 |
+
const [error, setError] = useState(null)
|
| 543 |
+
|
| 544 |
+
useEffect(() => {
|
| 545 |
+
let cancelled = false
|
| 546 |
+
|
| 547 |
+
async function fetchData() {
|
| 548 |
+
try {
|
| 549 |
+
setLoading(true)
|
| 550 |
+
setError(null)
|
| 551 |
+
const result = await fetchFn()
|
| 552 |
+
if (!cancelled) {
|
| 553 |
+
setData(result)
|
| 554 |
+
}
|
| 555 |
+
} catch (err) {
|
| 556 |
+
if (!cancelled) {
|
| 557 |
+
setError(err)
|
| 558 |
+
}
|
| 559 |
+
} finally {
|
| 560 |
+
if (!cancelled) {
|
| 561 |
+
setLoading(false)
|
| 562 |
+
}
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
fetchData()
|
| 567 |
+
|
| 568 |
+
return () => {
|
| 569 |
+
cancelled = true
|
| 570 |
+
}
|
| 571 |
+
}, dependencies)
|
| 572 |
+
|
| 573 |
+
return { data, loading, error }
|
| 574 |
+
}
|
| 575 |
+
```
|
| 576 |
+
|
| 577 |
+
**Usage Example**:
|
| 578 |
+
```javascript
|
| 579 |
+
import { useFetch } from '../hooks/useFetch'
|
| 580 |
+
import { api } from '../utils/api'
|
| 581 |
+
|
| 582 |
+
function Dashboard() {
|
| 583 |
+
const { data: profiles, loading, error } = useFetch(() => api.getProfiles())
|
| 584 |
+
|
| 585 |
+
if (loading) return <Spinner />
|
| 586 |
+
if (error) return <ErrorMessage error={error} />
|
| 587 |
+
|
| 588 |
+
return (
|
| 589 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
| 590 |
+
{profiles.results.map(profile => (
|
| 591 |
+
<ProfileCard key={profile.id} profile={profile} />
|
| 592 |
+
))}
|
| 593 |
+
</div>
|
| 594 |
+
)
|
| 595 |
+
}
|
| 596 |
+
```
|
| 597 |
+
|
| 598 |
+
---
|
| 599 |
+
|
| 600 |
+
## State Management Strategy
|
| 601 |
+
|
| 602 |
+
### Component-Level State
|
| 603 |
+
Use `useState` for:
|
| 604 |
+
- Form inputs
|
| 605 |
+
- UI toggles (modals, dropdowns)
|
| 606 |
+
- Local loading/error states
|
| 607 |
+
|
| 608 |
+
### Lifted State
|
| 609 |
+
Use props drilling for:
|
| 610 |
+
- Shared data between sibling components
|
| 611 |
+
- Parent-child communication
|
| 612 |
+
|
| 613 |
+
**Example**:
|
| 614 |
+
```javascript
|
| 615 |
+
function App() {
|
| 616 |
+
const [currentView, setCurrentView] = useState('dashboard')
|
| 617 |
+
const [selectedProfile, setSelectedProfile] = useState(null)
|
| 618 |
+
|
| 619 |
+
return (
|
| 620 |
+
<div>
|
| 621 |
+
<Navigation view={currentView} onNavigate={setCurrentView} />
|
| 622 |
+
{currentView === 'dashboard' && (
|
| 623 |
+
<Dashboard onSelectProfile={setSelectedProfile} />
|
| 624 |
+
)}
|
| 625 |
+
{currentView === 'profile' && (
|
| 626 |
+
<ProfileDetail profile={selectedProfile} />
|
| 627 |
+
)}
|
| 628 |
+
</div>
|
| 629 |
+
)
|
| 630 |
+
}
|
| 631 |
+
```
|
| 632 |
+
|
| 633 |
+
### When to Add Context
|
| 634 |
+
Only add React Context if:
|
| 635 |
+
- Props drilling exceeds 3 levels
|
| 636 |
+
- Data is truly global (theme, auth, language)
|
| 637 |
+
- Performance profiling shows re-render issues
|
| 638 |
+
|
| 639 |
+
**Not needed for this app initially**.
|
| 640 |
+
|
| 641 |
+
---
|
| 642 |
+
|
| 643 |
+
## Routing Strategy
|
| 644 |
+
|
| 645 |
+
### Hash-Based Routing (Minimal Approach)
|
| 646 |
+
|
| 647 |
+
**src/App.jsx**
|
| 648 |
+
```javascript
|
| 649 |
+
import { useState, useEffect } from 'react'
|
| 650 |
+
import Dashboard from './pages/Dashboard'
|
| 651 |
+
import ProfileDetail from './pages/ProfileDetail'
|
| 652 |
+
import ProgressForm from './pages/ProgressForm'
|
| 653 |
+
|
| 654 |
+
function App() {
|
| 655 |
+
const [route, setRoute] = useState(window.location.hash.slice(1) || 'dashboard')
|
| 656 |
+
const [params, setParams] = useState({})
|
| 657 |
+
|
| 658 |
+
useEffect(() => {
|
| 659 |
+
const handleHashChange = () => {
|
| 660 |
+
const hash = window.location.hash.slice(1)
|
| 661 |
+
const [path, query] = hash.split('?')
|
| 662 |
+
setRoute(path || 'dashboard')
|
| 663 |
+
|
| 664 |
+
// Parse query params
|
| 665 |
+
const searchParams = new URLSearchParams(query)
|
| 666 |
+
const paramsObj = {}
|
| 667 |
+
searchParams.forEach((value, key) => {
|
| 668 |
+
paramsObj[key] = value
|
| 669 |
+
})
|
| 670 |
+
setParams(paramsObj)
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
window.addEventListener('hashchange', handleHashChange)
|
| 674 |
+
return () => window.removeEventListener('hashchange', handleHashChange)
|
| 675 |
+
}, [])
|
| 676 |
+
|
| 677 |
+
const navigate = (path, queryParams = {}) => {
|
| 678 |
+
const query = new URLSearchParams(queryParams).toString()
|
| 679 |
+
window.location.hash = query ? `${path}?${query}` : path
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
return (
|
| 683 |
+
<div className="min-h-screen bg-gray-50">
|
| 684 |
+
<nav className="bg-white shadow-sm mb-6">
|
| 685 |
+
<div className="max-w-7xl mx-auto px-4 py-4">
|
| 686 |
+
<div className="flex gap-4">
|
| 687 |
+
<button onClick={() => navigate('dashboard')}
|
| 688 |
+
className={route === 'dashboard' ? 'font-bold' : ''}>
|
| 689 |
+
Dashboard
|
| 690 |
+
</button>
|
| 691 |
+
<button onClick={() => navigate('progress')}
|
| 692 |
+
className={route === 'progress' ? 'font-bold' : ''}>
|
| 693 |
+
Record Progress
|
| 694 |
+
</button>
|
| 695 |
+
</div>
|
| 696 |
+
</div>
|
| 697 |
+
</nav>
|
| 698 |
+
|
| 699 |
+
<main className="max-w-7xl mx-auto px-4">
|
| 700 |
+
{route === 'dashboard' && <Dashboard navigate={navigate} />}
|
| 701 |
+
{route === 'profile' && <ProfileDetail profileId={params.id} navigate={navigate} />}
|
| 702 |
+
{route === 'progress' && <ProgressForm navigate={navigate} />}
|
| 703 |
+
</main>
|
| 704 |
+
</div>
|
| 705 |
+
)
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
export default App
|
| 709 |
+
```
|
| 710 |
+
|
| 711 |
+
### URL Structure
|
| 712 |
+
```
|
| 713 |
+
/#dashboard
|
| 714 |
+
/#profile?id=1
|
| 715 |
+
/#profile?id=1&start=2025-10-01&end=2025-10-20
|
| 716 |
+
/#progress
|
| 717 |
+
/#ice-usage?id=1&date=2025-10-15
|
| 718 |
+
```
|
| 719 |
+
|
| 720 |
+
---
|
| 721 |
+
|
| 722 |
+
## Styling Guidelines
|
| 723 |
+
|
| 724 |
+
### Tailwind Utility Classes
|
| 725 |
+
- Use composition for common patterns
|
| 726 |
+
- Avoid inline style objects
|
| 727 |
+
- Keep classes readable (multi-line for complex components)
|
| 728 |
+
|
| 729 |
+
**Good**:
|
| 730 |
+
```jsx
|
| 731 |
+
<div className="
|
| 732 |
+
flex items-center justify-between
|
| 733 |
+
bg-white rounded-lg shadow-md
|
| 734 |
+
p-6 mb-4
|
| 735 |
+
hover:shadow-lg transition-shadow
|
| 736 |
+
">
|
| 737 |
+
```
|
| 738 |
+
|
| 739 |
+
**Bad**:
|
| 740 |
+
```jsx
|
| 741 |
+
<div style={{ display: 'flex', padding: '24px', background: 'white' }}>
|
| 742 |
+
```
|
| 743 |
+
|
| 744 |
+
### Responsive Design
|
| 745 |
+
Use Tailwind breakpoints:
|
| 746 |
+
- `sm:` - 640px
|
| 747 |
+
- `md:` - 768px
|
| 748 |
+
- `lg:` - 1024px
|
| 749 |
+
- `xl:` - 1280px
|
| 750 |
+
|
| 751 |
+
**Example**:
|
| 752 |
+
```jsx
|
| 753 |
+
<div className="
|
| 754 |
+
grid
|
| 755 |
+
grid-cols-1
|
| 756 |
+
md:grid-cols-2
|
| 757 |
+
lg:grid-cols-3
|
| 758 |
+
gap-6
|
| 759 |
+
">
|
| 760 |
+
```
|
| 761 |
+
|
| 762 |
+
### Color Palette
|
| 763 |
+
Use Tailwind's default colors + custom properties:
|
| 764 |
+
```css
|
| 765 |
+
:root {
|
| 766 |
+
--color-ice: #93c5fd; /* Light blue for ice theme */
|
| 767 |
+
--color-gold: #fbbf24; /* Gold for currency */
|
| 768 |
+
}
|
| 769 |
+
```
|
| 770 |
+
|
| 771 |
+
Apply in Tailwind:
|
| 772 |
+
```jsx
|
| 773 |
+
<div className="bg-[var(--color-ice)]">
|
| 774 |
+
```
|
| 775 |
+
|
| 776 |
+
---
|
| 777 |
+
|
| 778 |
+
## Error Handling
|
| 779 |
+
|
| 780 |
+
### Error Boundary Component
|
| 781 |
+
|
| 782 |
+
**src/components/ErrorBoundary.jsx**
|
| 783 |
+
```javascript
|
| 784 |
+
import { Component } from 'react'
|
| 785 |
+
|
| 786 |
+
class ErrorBoundary extends Component {
|
| 787 |
+
constructor(props) {
|
| 788 |
+
super(props)
|
| 789 |
+
this.state = { hasError: false, error: null }
|
| 790 |
+
}
|
| 791 |
+
|
| 792 |
+
static getDerivedStateFromError(error) {
|
| 793 |
+
return { hasError: true, error }
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
componentDidCatch(error, errorInfo) {
|
| 797 |
+
console.error('Error caught by boundary:', error, errorInfo)
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
render() {
|
| 801 |
+
if (this.state.hasError) {
|
| 802 |
+
return (
|
| 803 |
+
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
| 804 |
+
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md">
|
| 805 |
+
<h2 className="text-2xl font-bold text-red-600 mb-4">
|
| 806 |
+
Something went wrong
|
| 807 |
+
</h2>
|
| 808 |
+
<p className="text-gray-600 mb-4">
|
| 809 |
+
{this.state.error?.message || 'An unexpected error occurred'}
|
| 810 |
+
</p>
|
| 811 |
+
<button
|
| 812 |
+
onClick={() => window.location.reload()}
|
| 813 |
+
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
|
| 814 |
+
>
|
| 815 |
+
Reload Page
|
| 816 |
+
</button>
|
| 817 |
+
</div>
|
| 818 |
+
</div>
|
| 819 |
+
)
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
return this.props.children
|
| 823 |
+
}
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
export default ErrorBoundary
|
| 827 |
+
```
|
| 828 |
+
|
| 829 |
+
### API Error Handling
|
| 830 |
+
|
| 831 |
+
Display user-friendly error messages:
|
| 832 |
+
```javascript
|
| 833 |
+
function ErrorMessage({ error }) {
|
| 834 |
+
const getMessage = () => {
|
| 835 |
+
if (error.status === 404) return 'Resource not found'
|
| 836 |
+
if (error.status === 500) return 'Server error. Please try again later.'
|
| 837 |
+
if (error.status === 0) return 'Network error. Check your connection.'
|
| 838 |
+
return error.message || 'An error occurred'
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
return (
|
| 842 |
+
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
| 843 |
+
<p className="text-red-800">{getMessage()}</p>
|
| 844 |
+
</div>
|
| 845 |
+
)
|
| 846 |
+
}
|
| 847 |
+
```
|
| 848 |
+
|
| 849 |
+
---
|
| 850 |
+
|
| 851 |
+
## Performance Optimization
|
| 852 |
+
|
| 853 |
+
### Code Splitting (Future Enhancement)
|
| 854 |
+
When bundle size grows, use React.lazy:
|
| 855 |
+
```javascript
|
| 856 |
+
import { lazy, Suspense } from 'react'
|
| 857 |
+
|
| 858 |
+
const ProfileDetail = lazy(() => import('./pages/ProfileDetail'))
|
| 859 |
+
|
| 860 |
+
function App() {
|
| 861 |
+
return (
|
| 862 |
+
<Suspense fallback={<Spinner />}>
|
| 863 |
+
<ProfileDetail />
|
| 864 |
+
</Suspense>
|
| 865 |
+
)
|
| 866 |
+
}
|
| 867 |
+
```
|
| 868 |
+
|
| 869 |
+
### Memoization
|
| 870 |
+
Use React.memo for expensive list items:
|
| 871 |
+
```javascript
|
| 872 |
+
import { memo } from 'react'
|
| 873 |
+
|
| 874 |
+
const ProfileCard = memo(function ProfileCard({ profile }) {
|
| 875 |
+
return (
|
| 876 |
+
<Card>
|
| 877 |
+
<h3>{profile.name}</h3>
|
| 878 |
+
<p>{profile.team_lead}</p>
|
| 879 |
+
</Card>
|
| 880 |
+
)
|
| 881 |
+
})
|
| 882 |
+
```
|
| 883 |
+
|
| 884 |
+
### Debouncing
|
| 885 |
+
For search/filter inputs:
|
| 886 |
+
```javascript
|
| 887 |
+
// src/hooks/useDebounce.js
|
| 888 |
+
import { useState, useEffect } from 'react'
|
| 889 |
+
|
| 890 |
+
export function useDebounce(value, delay = 300) {
|
| 891 |
+
const [debouncedValue, setDebouncedValue] = useState(value)
|
| 892 |
+
|
| 893 |
+
useEffect(() => {
|
| 894 |
+
const timer = setTimeout(() => {
|
| 895 |
+
setDebouncedValue(value)
|
| 896 |
+
}, delay)
|
| 897 |
+
|
| 898 |
+
return () => clearTimeout(timer)
|
| 899 |
+
}, [value, delay])
|
| 900 |
+
|
| 901 |
+
return debouncedValue
|
| 902 |
+
}
|
| 903 |
+
```
|
| 904 |
+
|
| 905 |
+
---
|
| 906 |
+
|
| 907 |
+
## Build & Deployment
|
| 908 |
+
|
| 909 |
+
### Development Build
|
| 910 |
+
```bash
|
| 911 |
+
npm run dev
|
| 912 |
+
```
|
| 913 |
+
|
| 914 |
+
### Production Build
|
| 915 |
+
```bash
|
| 916 |
+
npm run build
|
| 917 |
+
```
|
| 918 |
+
|
| 919 |
+
Output: `dist/` directory with optimized static files
|
| 920 |
+
|
| 921 |
+
### Preview Production Build
|
| 922 |
+
```bash
|
| 923 |
+
npm run preview
|
| 924 |
+
```
|
| 925 |
+
|
| 926 |
+
### Build Optimizations (Vite 7)
|
| 927 |
+
- Automatic code splitting
|
| 928 |
+
- CSS minification
|
| 929 |
+
- Tree shaking
|
| 930 |
+
- Asset optimization (images, fonts)
|
| 931 |
+
- Source maps (optional)
|
| 932 |
+
|
| 933 |
+
**vite.config.js** (production settings):
|
| 934 |
+
```javascript
|
| 935 |
+
export default defineConfig({
|
| 936 |
+
plugins: [react(), tailwindcss()],
|
| 937 |
+
build: {
|
| 938 |
+
sourcemap: false,
|
| 939 |
+
minify: 'esbuild',
|
| 940 |
+
target: 'es2020',
|
| 941 |
+
rollupOptions: {
|
| 942 |
+
output: {
|
| 943 |
+
manualChunks: {
|
| 944 |
+
'react-vendor': ['react', 'react-dom'],
|
| 945 |
+
'charts': ['recharts']
|
| 946 |
+
}
|
| 947 |
+
}
|
| 948 |
+
}
|
| 949 |
+
}
|
| 950 |
+
})
|
| 951 |
+
```
|
| 952 |
+
|
| 953 |
+
---
|
| 954 |
+
|
| 955 |
+
## HuggingFace Space Deployment
|
| 956 |
+
|
| 957 |
+
### Static Site Setup
|
| 958 |
+
|
| 959 |
+
**Dockerfile**
|
| 960 |
+
```dockerfile
|
| 961 |
+
FROM nginx:alpine
|
| 962 |
+
|
| 963 |
+
# Copy built files
|
| 964 |
+
COPY dist /usr/share/nginx/html
|
| 965 |
+
|
| 966 |
+
# Copy nginx config
|
| 967 |
+
COPY nginx.conf /etc/nginx/nginx.conf
|
| 968 |
+
|
| 969 |
+
EXPOSE 7860
|
| 970 |
+
|
| 971 |
+
CMD ["nginx", "-g", "daemon off;"]
|
| 972 |
+
```
|
| 973 |
+
|
| 974 |
+
**nginx.conf**
|
| 975 |
+
```nginx
|
| 976 |
+
events {
|
| 977 |
+
worker_connections 1024;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
http {
|
| 981 |
+
include /etc/nginx/mime.types;
|
| 982 |
+
default_type application/octet-stream;
|
| 983 |
+
|
| 984 |
+
server {
|
| 985 |
+
listen 7860;
|
| 986 |
+
server_name _;
|
| 987 |
+
|
| 988 |
+
root /usr/share/nginx/html;
|
| 989 |
+
index index.html;
|
| 990 |
+
|
| 991 |
+
# SPA fallback
|
| 992 |
+
location / {
|
| 993 |
+
try_files $uri $uri/ /index.html;
|
| 994 |
+
}
|
| 995 |
+
|
| 996 |
+
# API proxy (if Django backend in same Space)
|
| 997 |
+
location /api {
|
| 998 |
+
proxy_pass http://localhost:8000;
|
| 999 |
+
proxy_set_header Host $host;
|
| 1000 |
+
proxy_set_header X-Real-IP $remote_addr;
|
| 1001 |
+
}
|
| 1002 |
+
}
|
| 1003 |
+
}
|
| 1004 |
+
```
|
| 1005 |
+
|
| 1006 |
+
### Space Configuration
|
| 1007 |
+
|
| 1008 |
+
**README.md** (HuggingFace header):
|
| 1009 |
+
```yaml
|
| 1010 |
+
---
|
| 1011 |
+
title: Wall Construction Tracker
|
| 1012 |
+
emoji: 🏰
|
| 1013 |
+
colorFrom: blue
|
| 1014 |
+
colorTo: gray
|
| 1015 |
+
sdk: docker
|
| 1016 |
+
app_port: 7860
|
| 1017 |
+
---
|
| 1018 |
+
```
|
| 1019 |
+
|
| 1020 |
+
### Environment Variables
|
| 1021 |
+
|
| 1022 |
+
**src/utils/api.js**:
|
| 1023 |
+
```javascript
|
| 1024 |
+
const API_BASE = import.meta.env.VITE_API_BASE || '/api'
|
| 1025 |
+
```
|
| 1026 |
+
|
| 1027 |
+
**.env.production**:
|
| 1028 |
+
```
|
| 1029 |
+
VITE_API_BASE=https://your-api-domain.com/api
|
| 1030 |
+
```
|
| 1031 |
+
|
| 1032 |
+
---
|
| 1033 |
+
|
| 1034 |
+
## Testing Strategy (Future Enhancement)
|
| 1035 |
+
|
| 1036 |
+
When tests become necessary:
|
| 1037 |
+
|
| 1038 |
+
### Unit Tests
|
| 1039 |
+
- Vitest (Vite-native test runner)
|
| 1040 |
+
- React Testing Library
|
| 1041 |
+
- Test utilities, formatters, API client
|
| 1042 |
+
|
| 1043 |
+
### Integration Tests
|
| 1044 |
+
- Test page-level components
|
| 1045 |
+
- Mock API responses
|
| 1046 |
+
- Test user workflows
|
| 1047 |
+
|
| 1048 |
+
### E2E Tests
|
| 1049 |
+
- Playwright or Cypress
|
| 1050 |
+
- Test critical paths (record progress, view analytics)
|
| 1051 |
+
|
| 1052 |
+
**Not included in minimal spec** - add when project matures.
|
| 1053 |
+
|
| 1054 |
+
---
|
| 1055 |
+
|
| 1056 |
+
## Accessibility (a11y)
|
| 1057 |
+
|
| 1058 |
+
### Semantic HTML
|
| 1059 |
+
Use proper elements:
|
| 1060 |
+
```jsx
|
| 1061 |
+
<button> instead of <div onClick>
|
| 1062 |
+
<nav> for navigation
|
| 1063 |
+
<main> for main content
|
| 1064 |
+
<header>, <footer> for sections
|
| 1065 |
+
```
|
| 1066 |
+
|
| 1067 |
+
### ARIA Labels
|
| 1068 |
+
```jsx
|
| 1069 |
+
<button aria-label="Close modal">×</button>
|
| 1070 |
+
<input aria-describedby="error-message" />
|
| 1071 |
+
```
|
| 1072 |
+
|
| 1073 |
+
### Keyboard Navigation
|
| 1074 |
+
- All interactive elements focusable
|
| 1075 |
+
- Visible focus states
|
| 1076 |
+
- Logical tab order
|
| 1077 |
+
|
| 1078 |
+
### Color Contrast
|
| 1079 |
+
- WCAG AA minimum (4.5:1 for text)
|
| 1080 |
+
- Use Tailwind's accessible color combinations
|
| 1081 |
+
|
| 1082 |
+
---
|
| 1083 |
+
|
| 1084 |
+
## Development Workflow
|
| 1085 |
+
|
| 1086 |
+
### 1. Start Backend (Django)
|
| 1087 |
+
```bash
|
| 1088 |
+
cd /path/to/django/backend
|
| 1089 |
+
python manage.py runserver
|
| 1090 |
+
```
|
| 1091 |
+
|
| 1092 |
+
### 2. Start Frontend (Vite)
|
| 1093 |
+
```bash
|
| 1094 |
+
cd /path/to/react/frontend
|
| 1095 |
+
npm run dev
|
| 1096 |
+
```
|
| 1097 |
+
|
| 1098 |
+
### 3. Access Application
|
| 1099 |
+
- Frontend: http://localhost:5173
|
| 1100 |
+
- Backend API: http://localhost:8000/api
|
| 1101 |
+
- Vite proxies `/api` requests to backend
|
| 1102 |
+
|
| 1103 |
+
### 4. Make Changes
|
| 1104 |
+
- Edit React components
|
| 1105 |
+
- Save file
|
| 1106 |
+
- Vite HMR updates browser instantly (no refresh needed)
|
| 1107 |
+
|
| 1108 |
+
---
|
| 1109 |
+
|
| 1110 |
+
## Code Quality Standards
|
| 1111 |
+
|
| 1112 |
+
### Formatting
|
| 1113 |
+
- Consistent indentation (2 spaces)
|
| 1114 |
+
- Trailing commas in multiline arrays/objects
|
| 1115 |
+
- Single quotes for strings
|
| 1116 |
+
- Semicolons optional (be consistent)
|
| 1117 |
+
|
| 1118 |
+
### Naming Conventions
|
| 1119 |
+
- Components: PascalCase (`ProfileCard.jsx`)
|
| 1120 |
+
- Hooks: camelCase with `use` prefix (`useFetch.js`)
|
| 1121 |
+
- Utilities: camelCase (`formatNumber.js`)
|
| 1122 |
+
- Constants: UPPER_SNAKE_CASE (`API_BASE`)
|
| 1123 |
+
|
| 1124 |
+
### File Organization
|
| 1125 |
+
- One component per file
|
| 1126 |
+
- Group related components in folders
|
| 1127 |
+
- Keep files under 200 lines
|
| 1128 |
+
- Extract complex logic to hooks/utils
|
| 1129 |
+
|
| 1130 |
+
### Comments
|
| 1131 |
+
- Use JSDoc for functions
|
| 1132 |
+
- Explain "why", not "what"
|
| 1133 |
+
- Remove commented-out code
|
| 1134 |
+
|
| 1135 |
+
**Example**:
|
| 1136 |
+
```javascript
|
| 1137 |
+
/**
|
| 1138 |
+
* Formats a number as currency (Gold Dragons)
|
| 1139 |
+
* @param {number} value - The value to format
|
| 1140 |
+
* @returns {string} Formatted string like "1,234,567 GD"
|
| 1141 |
+
*/
|
| 1142 |
+
function formatCurrency(value) {
|
| 1143 |
+
return `${value.toLocaleString()} GD`
|
| 1144 |
+
}
|
| 1145 |
+
```
|
| 1146 |
+
|
| 1147 |
+
---
|
| 1148 |
+
|
| 1149 |
+
## Browser Support
|
| 1150 |
+
|
| 1151 |
+
### Target Browsers (Vite 7 defaults)
|
| 1152 |
+
- Chrome 107+
|
| 1153 |
+
- Edge 107+
|
| 1154 |
+
- Firefox 104+
|
| 1155 |
+
- Safari 16.0+
|
| 1156 |
+
|
| 1157 |
+
These align with Vite 7's "baseline-widely-available" target.
|
| 1158 |
+
|
| 1159 |
+
### Polyfills
|
| 1160 |
+
None needed - modern browsers support:
|
| 1161 |
+
- ES2020 syntax
|
| 1162 |
+
- Fetch API
|
| 1163 |
+
- Async/await
|
| 1164 |
+
- CSS Grid/Flexbox
|
| 1165 |
+
- CSS custom properties
|
| 1166 |
+
|
| 1167 |
+
---
|
| 1168 |
+
|
| 1169 |
+
## Future Enhancements (Not in Minimal Spec)
|
| 1170 |
+
|
| 1171 |
+
### When to Add
|
| 1172 |
+
1. **React Router** - When hash-based routing becomes limiting
|
| 1173 |
+
2. **React Context** - When props drilling exceeds 3 levels
|
| 1174 |
+
3. **React Query** - When caching/invalidation becomes complex
|
| 1175 |
+
4. **TypeScript** - When team grows or errors increase
|
| 1176 |
+
5. **Testing** - When regression bugs appear frequently
|
| 1177 |
+
6. **Storybook** - When design system emerges
|
| 1178 |
+
7. **i18n** - When internationalization is required
|
| 1179 |
+
8. **PWA** - When offline support is needed
|
| 1180 |
+
|
| 1181 |
+
### Don't Add Unless Needed
|
| 1182 |
+
- Redux (useState is sufficient)
|
| 1183 |
+
- CSS-in-JS (Tailwind is enough)
|
| 1184 |
+
- Component libraries (build your own)
|
| 1185 |
+
- Lodash (native JS is powerful enough)
|
| 1186 |
+
|
| 1187 |
+
---
|
| 1188 |
+
|
| 1189 |
+
## Summary
|
| 1190 |
+
|
| 1191 |
+
This specification defines a **minimal, production-ready React GUI** with:
|
| 1192 |
+
|
| 1193 |
+
✅ **5 total dependencies** (React, ReactDOM, Recharts, Vite, Tailwind)
|
| 1194 |
+
✅ **Modern 2025 stack** (React 19.2, Vite 7, Tailwind v4)
|
| 1195 |
+
✅ **Zero configuration** (Tailwind v4, Vite auto-discovery)
|
| 1196 |
+
✅ **Fast builds** (5x faster with Vite 7 + Tailwind v4)
|
| 1197 |
+
✅ **Component-based architecture** (reusable, composable)
|
| 1198 |
+
✅ **Hash-based routing** (no external router)
|
| 1199 |
+
✅ **Native fetch** (no HTTP libraries)
|
| 1200 |
+
✅ **Simple state management** (useState/props)
|
| 1201 |
+
✅ **Recharts integration** (SVG-based, responsive charts)
|
| 1202 |
+
✅ **Tailwind styling** (utility-first, no CSS frameworks)
|
| 1203 |
+
✅ **HuggingFace Space ready** (Docker, nginx, static build)
|
| 1204 |
+
|
| 1205 |
+
**Philosophy**: Start minimal, add dependencies only when complexity demands it.
|
SPEC-DEMO-TDD.md
ADDED
|
@@ -0,0 +1,1399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wall Construction API - Technical Specification
|
| 2 |
+
|
| 3 |
+
## Problem Overview
|
| 4 |
+
|
| 5 |
+
The Great Wall of Westeros requires a tracking system for multi-profile wall construction operations. Each construction profile must track daily progress, ice material consumption, and associated costs.
|
| 6 |
+
|
| 7 |
+
### Business Rules
|
| 8 |
+
|
| 9 |
+
- **Ice Consumption**: 195 cubic yards per linear foot of wall
|
| 10 |
+
- **Ice Cost**: 1,900 Gold Dragons per cubic yard
|
| 11 |
+
- **Daily Cost Formula**: `feet_built × 195 yd³/ft × 1,900 GD/yd³ = daily_cost`
|
| 12 |
+
|
| 13 |
+
### Requirements
|
| 14 |
+
|
| 15 |
+
1. Track multiple construction profiles simultaneously
|
| 16 |
+
2. Record daily wall construction progress (feet built per day)
|
| 17 |
+
3. Calculate daily ice usage for each profile
|
| 18 |
+
4. Provide cost overview reports with date range filtering
|
| 19 |
+
5. Support multi-threaded computation for aggregations
|
| 20 |
+
6. Run in HuggingFace Docker Space (file-based, no external services)
|
| 21 |
+
|
| 22 |
+
## Technology Stack
|
| 23 |
+
|
| 24 |
+
### Core Framework
|
| 25 |
+
- **Django 5.2.7 LTS** (released April 2, 2025)
|
| 26 |
+
- Python 3.10-3.14 support
|
| 27 |
+
- SQLite database (file-based persistence)
|
| 28 |
+
- Built-in ORM with transaction support
|
| 29 |
+
|
| 30 |
+
- **Django REST Framework 3.16** (released March 28, 2025)
|
| 31 |
+
- ViewSets for CRUD operations
|
| 32 |
+
- Serializers for data validation
|
| 33 |
+
- Pagination and filtering support
|
| 34 |
+
|
| 35 |
+
### Multi-Threading
|
| 36 |
+
- **Python concurrent.futures.ThreadPoolExecutor**
|
| 37 |
+
- No external broker dependencies (Celery-free)
|
| 38 |
+
- Configurable worker pool size
|
| 39 |
+
- Suitable for CPU-bound aggregations
|
| 40 |
+
|
| 41 |
+
### Deployment
|
| 42 |
+
- **HuggingFace Docker Space Compatible**
|
| 43 |
+
- SQLite database file (`db.sqlite3`)
|
| 44 |
+
- No PostgreSQL, Redis, or RabbitMQ required
|
| 45 |
+
- Single container deployment
|
| 46 |
+
|
| 47 |
+
## Architecture
|
| 48 |
+
|
| 49 |
+
```
|
| 50 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 51 |
+
│ REST API Layer (DRF) │
|
| 52 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
| 53 |
+
│ │ Profile │ │ Progress │ │ Analytics │ │
|
| 54 |
+
│ │ ViewSet │ │ ViewSet │ │ ViewSet │ │
|
| 55 |
+
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
| 56 |
+
└─────────┼──────────────────┼──────────────────┼─────────────┘
|
| 57 |
+
│ │ │
|
| 58 |
+
▼ ▼ ▼
|
| 59 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 60 |
+
│ Service Layer │
|
| 61 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
| 62 |
+
│ │ Profile │ │ Ice Usage │ │ Cost │ │
|
| 63 |
+
│ │ Service │ │ Calculator │ │ Aggregator │ │
|
| 64 |
+
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
| 65 |
+
└─────────┼──────────────────┼──────────────────┼─────────────┘
|
| 66 |
+
│ │ │
|
| 67 |
+
▼ ▼ ▼
|
| 68 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 69 |
+
│ Repository Layer │
|
| 70 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
| 71 |
+
│ │ Profile │ │ Wall │ │ Daily │ │
|
| 72 |
+
│ │ Repository │ │ Section │ │ Progress │ │
|
| 73 |
+
│ │ │ │ Repository │ │ Repository │ │
|
| 74 |
+
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
|
| 75 |
+
└─────────┼──────────────────┼──────────────────┼─────────────┘
|
| 76 |
+
│ │ │
|
| 77 |
+
▼ ▼ ▼
|
| 78 |
+
┌─────────────────────────────────────────────────────────────┐
|
| 79 |
+
│ Django ORM + SQLite Database │
|
| 80 |
+
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
| 81 |
+
│ │ Profile │ │ WallSection │ │ Daily │ │
|
| 82 |
+
│ │ Model │ │ Model │ │ Progress │ │
|
| 83 |
+
│ │ │ │ │ │ Model │ │
|
| 84 |
+
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
| 85 |
+
└─────────────────────────────────────────────────────────────┘
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### ThreadPoolExecutor Integration
|
| 89 |
+
|
| 90 |
+
```python
|
| 91 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 92 |
+
from django.conf import settings
|
| 93 |
+
|
| 94 |
+
# services/cost_aggregator.py
|
| 95 |
+
class CostAggregator:
|
| 96 |
+
def __init__(self):
|
| 97 |
+
self.executor = ThreadPoolExecutor(
|
| 98 |
+
max_workers=settings.WORKER_POOL_SIZE
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
def calculate_parallel_costs(self, profiles, date_range):
|
| 102 |
+
futures = [
|
| 103 |
+
self.executor.submit(self._calculate_cost, profile, date_range)
|
| 104 |
+
for profile in profiles
|
| 105 |
+
]
|
| 106 |
+
return [f.result() for f in futures]
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
## Data Models
|
| 110 |
+
|
| 111 |
+
### Profile Model
|
| 112 |
+
```python
|
| 113 |
+
from django.db import models
|
| 114 |
+
|
| 115 |
+
class Profile(models.Model):
|
| 116 |
+
"""Construction profile for wall building operations."""
|
| 117 |
+
name = models.CharField(max_length=255, unique=True)
|
| 118 |
+
team_lead = models.CharField(max_length=255)
|
| 119 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 120 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 121 |
+
is_active = models.BooleanField(default=True)
|
| 122 |
+
|
| 123 |
+
class Meta:
|
| 124 |
+
db_table = 'profiles'
|
| 125 |
+
ordering = ['-created_at']
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
### WallSection Model
|
| 129 |
+
```python
|
| 130 |
+
class WallSection(models.Model):
|
| 131 |
+
"""Physical wall section assigned to a profile."""
|
| 132 |
+
profile = models.ForeignKey(
|
| 133 |
+
Profile,
|
| 134 |
+
on_delete=models.CASCADE,
|
| 135 |
+
related_name='wall_sections'
|
| 136 |
+
)
|
| 137 |
+
section_name = models.CharField(max_length=255)
|
| 138 |
+
start_position = models.DecimalField(max_digits=10, decimal_places=2)
|
| 139 |
+
target_length_feet = models.DecimalField(max_digits=10, decimal_places=2)
|
| 140 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 141 |
+
|
| 142 |
+
class Meta:
|
| 143 |
+
db_table = 'wall_sections'
|
| 144 |
+
unique_together = [['profile', 'section_name']]
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### DailyProgress Model
|
| 148 |
+
```python
|
| 149 |
+
class DailyProgress(models.Model):
|
| 150 |
+
"""Daily construction progress for a wall section."""
|
| 151 |
+
wall_section = models.ForeignKey(
|
| 152 |
+
WallSection,
|
| 153 |
+
on_delete=models.CASCADE,
|
| 154 |
+
related_name='daily_progress'
|
| 155 |
+
)
|
| 156 |
+
date = models.DateField()
|
| 157 |
+
feet_built = models.DecimalField(max_digits=10, decimal_places=2)
|
| 158 |
+
ice_cubic_yards = models.DecimalField(
|
| 159 |
+
max_digits=10,
|
| 160 |
+
decimal_places=2,
|
| 161 |
+
help_text="195 cubic yards per foot"
|
| 162 |
+
)
|
| 163 |
+
cost_gold_dragons = models.DecimalField(
|
| 164 |
+
max_digits=15,
|
| 165 |
+
decimal_places=2,
|
| 166 |
+
help_text="1900 Gold Dragons per cubic yard"
|
| 167 |
+
)
|
| 168 |
+
notes = models.TextField(blank=True)
|
| 169 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 170 |
+
|
| 171 |
+
class Meta:
|
| 172 |
+
db_table = 'daily_progress'
|
| 173 |
+
unique_together = [['wall_section', 'date']]
|
| 174 |
+
ordering = ['-date']
|
| 175 |
+
indexes = [
|
| 176 |
+
models.Index(fields=['date']),
|
| 177 |
+
models.Index(fields=['wall_section', 'date']),
|
| 178 |
+
]
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
## API Endpoints
|
| 182 |
+
|
| 183 |
+
### Base URL
|
| 184 |
+
```
|
| 185 |
+
http://localhost:8000/api/
|
| 186 |
+
```
|
| 187 |
+
|
| 188 |
+
### 1. List Profiles
|
| 189 |
+
```http
|
| 190 |
+
GET /api/profiles/
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
**Response**
|
| 194 |
+
```json
|
| 195 |
+
{
|
| 196 |
+
"count": 2,
|
| 197 |
+
"next": null,
|
| 198 |
+
"previous": null,
|
| 199 |
+
"results": [
|
| 200 |
+
{
|
| 201 |
+
"id": 1,
|
| 202 |
+
"name": "Northern Watch",
|
| 203 |
+
"team_lead": "Jon Snow",
|
| 204 |
+
"is_active": true,
|
| 205 |
+
"created_at": "2025-10-01T08:00:00Z"
|
| 206 |
+
}
|
| 207 |
+
]
|
| 208 |
+
}
|
| 209 |
+
```
|
| 210 |
+
|
| 211 |
+
### 2. Create Profile
|
| 212 |
+
```http
|
| 213 |
+
POST /api/profiles/
|
| 214 |
+
Content-Type: application/json
|
| 215 |
+
|
| 216 |
+
{
|
| 217 |
+
"name": "Eastern Defense",
|
| 218 |
+
"team_lead": "Tormund Giantsbane",
|
| 219 |
+
"is_active": true
|
| 220 |
+
}
|
| 221 |
+
```
|
| 222 |
+
|
| 223 |
+
### 3. Record Daily Progress
|
| 224 |
+
```http
|
| 225 |
+
POST /api/profiles/{profile_id}/progress/
|
| 226 |
+
Content-Type: application/json
|
| 227 |
+
|
| 228 |
+
{
|
| 229 |
+
"wall_section_id": 5,
|
| 230 |
+
"date": "2025-10-15",
|
| 231 |
+
"feet_built": 12.5,
|
| 232 |
+
"notes": "Clear weather, good progress"
|
| 233 |
+
}
|
| 234 |
+
```
|
| 235 |
+
|
| 236 |
+
**Response**
|
| 237 |
+
```json
|
| 238 |
+
{
|
| 239 |
+
"id": 42,
|
| 240 |
+
"wall_section_id": 5,
|
| 241 |
+
"date": "2025-10-15",
|
| 242 |
+
"feet_built": "12.50",
|
| 243 |
+
"ice_cubic_yards": "2437.50",
|
| 244 |
+
"cost_gold_dragons": "4631250.00",
|
| 245 |
+
"notes": "Clear weather, good progress",
|
| 246 |
+
"created_at": "2025-10-15T14:30:00Z"
|
| 247 |
+
}
|
| 248 |
+
```
|
| 249 |
+
|
| 250 |
+
**Calculation**
|
| 251 |
+
- Ice usage: 12.5 feet × 195 yd³/ft = 2,437.5 yd³
|
| 252 |
+
- Cost: 2,437.5 yd³ × 1,900 GD/yd³ = 4,631,250 GD
|
| 253 |
+
|
| 254 |
+
### 4. Daily Ice Usage by Profile
|
| 255 |
+
```http
|
| 256 |
+
GET /api/profiles/{profile_id}/daily-ice-usage/?date=2025-10-15
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
**Response**
|
| 260 |
+
```json
|
| 261 |
+
{
|
| 262 |
+
"profile_id": 1,
|
| 263 |
+
"profile_name": "Northern Watch",
|
| 264 |
+
"date": "2025-10-15",
|
| 265 |
+
"total_feet_built": "28.75",
|
| 266 |
+
"total_ice_cubic_yards": "5606.25",
|
| 267 |
+
"sections": [
|
| 268 |
+
{
|
| 269 |
+
"section_name": "Tower 1-2",
|
| 270 |
+
"feet_built": "12.50",
|
| 271 |
+
"ice_cubic_yards": "2437.50"
|
| 272 |
+
},
|
| 273 |
+
{
|
| 274 |
+
"section_name": "Tower 2-3",
|
| 275 |
+
"feet_built": "16.25",
|
| 276 |
+
"ice_cubic_yards": "3168.75"
|
| 277 |
+
}
|
| 278 |
+
]
|
| 279 |
+
}
|
| 280 |
+
```
|
| 281 |
+
|
| 282 |
+
### 5. Cost Overview with Date Range
|
| 283 |
+
```http
|
| 284 |
+
GET /api/profiles/{profile_id}/cost-overview/?start_date=2025-10-01&end_date=2025-10-15
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
**Response**
|
| 288 |
+
```json
|
| 289 |
+
{
|
| 290 |
+
"profile_id": 1,
|
| 291 |
+
"profile_name": "Northern Watch",
|
| 292 |
+
"date_range": {
|
| 293 |
+
"start": "2025-10-01",
|
| 294 |
+
"end": "2025-10-15"
|
| 295 |
+
},
|
| 296 |
+
"summary": {
|
| 297 |
+
"total_days": 15,
|
| 298 |
+
"total_feet_built": "425.50",
|
| 299 |
+
"total_ice_cubic_yards": "82972.50",
|
| 300 |
+
"total_cost_gold_dragons": "157647750.00",
|
| 301 |
+
"average_feet_per_day": "28.37",
|
| 302 |
+
"average_cost_per_day": "10509850.00"
|
| 303 |
+
},
|
| 304 |
+
"daily_breakdown": [
|
| 305 |
+
{
|
| 306 |
+
"date": "2025-10-15",
|
| 307 |
+
"feet_built": "28.75",
|
| 308 |
+
"ice_cubic_yards": "5606.25",
|
| 309 |
+
"cost_gold_dragons": "10651875.00"
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"date": "2025-10-14",
|
| 313 |
+
"feet_built": "31.00",
|
| 314 |
+
"ice_cubic_yards": "6045.00",
|
| 315 |
+
"cost_gold_dragons": "11485500.00"
|
| 316 |
+
}
|
| 317 |
+
]
|
| 318 |
+
}
|
| 319 |
+
```
|
| 320 |
+
|
| 321 |
+
## Multi-Threading Implementation
|
| 322 |
+
|
| 323 |
+
### Cost Aggregation Service
|
| 324 |
+
|
| 325 |
+
```python
|
| 326 |
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
| 327 |
+
from decimal import Decimal
|
| 328 |
+
from django.conf import settings
|
| 329 |
+
from django.db import transaction
|
| 330 |
+
from django.db.models import Sum
|
| 331 |
+
|
| 332 |
+
class CostAggregatorService:
|
| 333 |
+
"""
|
| 334 |
+
Service for parallel cost calculations across multiple profiles.
|
| 335 |
+
Uses ThreadPoolExecutor for CPU-bound aggregation tasks.
|
| 336 |
+
"""
|
| 337 |
+
|
| 338 |
+
def __init__(self, max_workers: int | None = None):
|
| 339 |
+
self.max_workers = max_workers or settings.WORKER_POOL_SIZE
|
| 340 |
+
self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
|
| 341 |
+
|
| 342 |
+
def calculate_multi_profile_costs(
|
| 343 |
+
self,
|
| 344 |
+
profile_ids: list[int],
|
| 345 |
+
start_date: str,
|
| 346 |
+
end_date: str
|
| 347 |
+
) -> list[dict]:
|
| 348 |
+
"""
|
| 349 |
+
Calculate costs for multiple profiles in parallel.
|
| 350 |
+
|
| 351 |
+
Args:
|
| 352 |
+
profile_ids: List of profile IDs to process
|
| 353 |
+
start_date: Start date (YYYY-MM-DD)
|
| 354 |
+
end_date: End date (YYYY-MM-DD)
|
| 355 |
+
|
| 356 |
+
Returns:
|
| 357 |
+
List of cost summaries per profile
|
| 358 |
+
"""
|
| 359 |
+
futures = {
|
| 360 |
+
self.executor.submit(
|
| 361 |
+
self._calculate_profile_cost,
|
| 362 |
+
profile_id,
|
| 363 |
+
start_date,
|
| 364 |
+
end_date
|
| 365 |
+
): profile_id
|
| 366 |
+
for profile_id in profile_ids
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
results = []
|
| 370 |
+
for future in as_completed(futures):
|
| 371 |
+
profile_id = futures[future]
|
| 372 |
+
try:
|
| 373 |
+
result = future.result()
|
| 374 |
+
results.append(result)
|
| 375 |
+
except Exception as exc:
|
| 376 |
+
# Log error and continue with other profiles
|
| 377 |
+
logger.error(
|
| 378 |
+
f"Profile {profile_id} cost calculation failed: {exc}"
|
| 379 |
+
)
|
| 380 |
+
results.append({
|
| 381 |
+
"profile_id": profile_id,
|
| 382 |
+
"error": str(exc)
|
| 383 |
+
})
|
| 384 |
+
|
| 385 |
+
return results
|
| 386 |
+
|
| 387 |
+
def _calculate_profile_cost(
|
| 388 |
+
self,
|
| 389 |
+
profile_id: int,
|
| 390 |
+
start_date: str,
|
| 391 |
+
end_date: str
|
| 392 |
+
) -> dict:
|
| 393 |
+
"""Calculate cost summary for a single profile."""
|
| 394 |
+
from .repositories import DailyProgressRepository
|
| 395 |
+
|
| 396 |
+
repo = DailyProgressRepository()
|
| 397 |
+
|
| 398 |
+
# Use Django ORM aggregation for efficient DB queries
|
| 399 |
+
aggregates = repo.get_aggregates_by_profile(
|
| 400 |
+
profile_id,
|
| 401 |
+
start_date,
|
| 402 |
+
end_date
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
return {
|
| 406 |
+
"profile_id": profile_id,
|
| 407 |
+
"total_feet_built": str(aggregates["total_feet"]),
|
| 408 |
+
"total_ice_cubic_yards": str(aggregates["total_ice"]),
|
| 409 |
+
"total_cost_gold_dragons": str(aggregates["total_cost"]),
|
| 410 |
+
"calculation_thread": threading.current_thread().name
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
def shutdown(self):
|
| 414 |
+
"""Gracefully shutdown the thread pool."""
|
| 415 |
+
self.executor.shutdown(wait=True)
|
| 416 |
+
```
|
| 417 |
+
|
| 418 |
+
### Configuration
|
| 419 |
+
|
| 420 |
+
```python
|
| 421 |
+
# settings.py
|
| 422 |
+
WORKER_POOL_SIZE = 4 # Configurable based on container resources
|
| 423 |
+
```
|
| 424 |
+
|
| 425 |
+
### Usage in ViewSet
|
| 426 |
+
|
| 427 |
+
```python
|
| 428 |
+
from rest_framework import viewsets
|
| 429 |
+
from rest_framework.decorators import action
|
| 430 |
+
from rest_framework.response import Response
|
| 431 |
+
|
| 432 |
+
class ProfileViewSet(viewsets.ModelViewSet):
|
| 433 |
+
|
| 434 |
+
@action(detail=False, methods=['get'])
|
| 435 |
+
def bulk_cost_overview(self, request):
|
| 436 |
+
"""Calculate costs for multiple profiles in parallel."""
|
| 437 |
+
profile_ids = request.query_params.getlist('profile_ids[]')
|
| 438 |
+
start_date = request.query_params.get('start_date')
|
| 439 |
+
end_date = request.query_params.get('end_date')
|
| 440 |
+
|
| 441 |
+
aggregator = CostAggregatorService()
|
| 442 |
+
try:
|
| 443 |
+
results = aggregator.calculate_multi_profile_costs(
|
| 444 |
+
profile_ids,
|
| 445 |
+
start_date,
|
| 446 |
+
end_date
|
| 447 |
+
)
|
| 448 |
+
return Response({"results": results})
|
| 449 |
+
finally:
|
| 450 |
+
aggregator.shutdown()
|
| 451 |
+
```
|
| 452 |
+
|
| 453 |
+
## Service Layer Design
|
| 454 |
+
|
| 455 |
+
### IceUsageCalculator
|
| 456 |
+
|
| 457 |
+
```python
|
| 458 |
+
from decimal import Decimal
|
| 459 |
+
|
| 460 |
+
class IceUsageCalculator:
|
| 461 |
+
"""Business logic for ice usage calculations."""
|
| 462 |
+
|
| 463 |
+
ICE_PER_FOOT = Decimal("195") # cubic yards per foot
|
| 464 |
+
COST_PER_CUBIC_YARD = Decimal("1900") # Gold Dragons
|
| 465 |
+
|
| 466 |
+
@classmethod
|
| 467 |
+
def calculate_ice_usage(cls, feet_built: Decimal) -> Decimal:
|
| 468 |
+
"""Calculate ice usage in cubic yards."""
|
| 469 |
+
return feet_built * cls.ICE_PER_FOOT
|
| 470 |
+
|
| 471 |
+
@classmethod
|
| 472 |
+
def calculate_cost(cls, ice_cubic_yards: Decimal) -> Decimal:
|
| 473 |
+
"""Calculate cost in Gold Dragons."""
|
| 474 |
+
return ice_cubic_yards * cls.COST_PER_CUBIC_YARD
|
| 475 |
+
|
| 476 |
+
@classmethod
|
| 477 |
+
def calculate_full_cost(cls, feet_built: Decimal) -> tuple[Decimal, Decimal]:
|
| 478 |
+
"""Calculate both ice usage and cost."""
|
| 479 |
+
ice = cls.calculate_ice_usage(feet_built)
|
| 480 |
+
cost = cls.calculate_cost(ice)
|
| 481 |
+
return ice, cost
|
| 482 |
+
```
|
| 483 |
+
|
| 484 |
+
## Repository Layer Design
|
| 485 |
+
|
| 486 |
+
### DailyProgressRepository
|
| 487 |
+
|
| 488 |
+
```python
|
| 489 |
+
from django.db.models import Sum, Avg, Count
|
| 490 |
+
from decimal import Decimal
|
| 491 |
+
|
| 492 |
+
class DailyProgressRepository:
|
| 493 |
+
"""Data access layer for DailyProgress model."""
|
| 494 |
+
|
| 495 |
+
def get_by_date(self, profile_id: int, date: str):
|
| 496 |
+
"""Retrieve all progress records for a profile on a specific date."""
|
| 497 |
+
return DailyProgress.objects.filter(
|
| 498 |
+
wall_section__profile_id=profile_id,
|
| 499 |
+
date=date
|
| 500 |
+
).select_related('wall_section')
|
| 501 |
+
|
| 502 |
+
def get_aggregates_by_profile(
|
| 503 |
+
self,
|
| 504 |
+
profile_id: int,
|
| 505 |
+
start_date: str,
|
| 506 |
+
end_date: str
|
| 507 |
+
) -> dict:
|
| 508 |
+
"""Get aggregated statistics for a profile within date range."""
|
| 509 |
+
result = DailyProgress.objects.filter(
|
| 510 |
+
wall_section__profile_id=profile_id,
|
| 511 |
+
date__gte=start_date,
|
| 512 |
+
date__lte=end_date
|
| 513 |
+
).aggregate(
|
| 514 |
+
total_feet=Sum('feet_built'),
|
| 515 |
+
total_ice=Sum('ice_cubic_yards'),
|
| 516 |
+
total_cost=Sum('cost_gold_dragons'),
|
| 517 |
+
avg_feet=Avg('feet_built'),
|
| 518 |
+
record_count=Count('id')
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
# Handle None values for empty querysets
|
| 522 |
+
return {
|
| 523 |
+
"total_feet": result["total_feet"] or Decimal("0"),
|
| 524 |
+
"total_ice": result["total_ice"] or Decimal("0"),
|
| 525 |
+
"total_cost": result["total_cost"] or Decimal("0"),
|
| 526 |
+
"avg_feet": result["avg_feet"] or Decimal("0"),
|
| 527 |
+
"record_count": result["record_count"]
|
| 528 |
+
}
|
| 529 |
+
```
|
| 530 |
+
|
| 531 |
+
## HuggingFace Space Deployment
|
| 532 |
+
|
| 533 |
+
### Requirements
|
| 534 |
+
|
| 535 |
+
```python
|
| 536 |
+
# requirements.txt
|
| 537 |
+
Django==5.2.7
|
| 538 |
+
djangorestframework==3.16.0
|
| 539 |
+
```
|
| 540 |
+
|
| 541 |
+
### Dockerfile
|
| 542 |
+
|
| 543 |
+
```dockerfile
|
| 544 |
+
FROM python:3.12-slim
|
| 545 |
+
|
| 546 |
+
WORKDIR /app
|
| 547 |
+
|
| 548 |
+
COPY requirements.txt .
|
| 549 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 550 |
+
|
| 551 |
+
COPY . .
|
| 552 |
+
|
| 553 |
+
# Run migrations and start server
|
| 554 |
+
CMD python manage.py migrate && \
|
| 555 |
+
python manage.py runserver 0.0.0.0:7860
|
| 556 |
+
```
|
| 557 |
+
|
| 558 |
+
### Space Configuration
|
| 559 |
+
|
| 560 |
+
```yaml
|
| 561 |
+
# README.md (HuggingFace Space header)
|
| 562 |
+
---
|
| 563 |
+
title: Wall Construction API
|
| 564 |
+
emoji: 🏰
|
| 565 |
+
colorFrom: blue
|
| 566 |
+
colorTo: gray
|
| 567 |
+
sdk: docker
|
| 568 |
+
app_port: 7860
|
| 569 |
+
---
|
| 570 |
+
```
|
| 571 |
+
|
| 572 |
+
### Database Persistence
|
| 573 |
+
|
| 574 |
+
- SQLite database file: `db.sqlite3`
|
| 575 |
+
- Persisted in HuggingFace Space persistent storage
|
| 576 |
+
- Automatic migrations on container startup
|
| 577 |
+
- No external database service required
|
| 578 |
+
|
| 579 |
+
## Error Handling
|
| 580 |
+
|
| 581 |
+
### Standard Error Response
|
| 582 |
+
|
| 583 |
+
```json
|
| 584 |
+
{
|
| 585 |
+
"error": "validation_error",
|
| 586 |
+
"message": "Invalid date format",
|
| 587 |
+
"details": {
|
| 588 |
+
"date": ["Date must be in YYYY-MM-DD format"]
|
| 589 |
+
}
|
| 590 |
+
}
|
| 591 |
+
```
|
| 592 |
+
|
| 593 |
+
### HTTP Status Codes
|
| 594 |
+
|
| 595 |
+
- `200 OK` - Successful GET request
|
| 596 |
+
- `201 Created` - Successful POST request
|
| 597 |
+
- `400 Bad Request` - Validation error
|
| 598 |
+
- `404 Not Found` - Resource not found
|
| 599 |
+
- `500 Internal Server Error` - Server error
|
| 600 |
+
|
| 601 |
+
## Testing Strategy
|
| 602 |
+
|
| 603 |
+
### Unit Tests
|
| 604 |
+
- Service layer: IceUsageCalculator calculations
|
| 605 |
+
- Repository layer: Query correctness
|
| 606 |
+
- Model layer: Validation rules
|
| 607 |
+
|
| 608 |
+
### Integration Tests
|
| 609 |
+
- API endpoints with test database
|
| 610 |
+
- ThreadPoolExecutor parallel execution
|
| 611 |
+
- Full request/response cycle
|
| 612 |
+
|
| 613 |
+
### Test Data
|
| 614 |
+
- Sample profiles with known outputs
|
| 615 |
+
- Edge cases: zero feet built, large numbers
|
| 616 |
+
- Date range boundaries
|
| 617 |
+
|
| 618 |
+
## Performance Considerations
|
| 619 |
+
|
| 620 |
+
### Database Optimization
|
| 621 |
+
- Indexes on `date` and `wall_section_id` fields
|
| 622 |
+
- `select_related()` for FK queries
|
| 623 |
+
- `aggregate()` for sum/avg calculations
|
| 624 |
+
- Database connection pooling (built into Django)
|
| 625 |
+
|
| 626 |
+
### Thread Pool Sizing
|
| 627 |
+
- Default: 4 workers
|
| 628 |
+
- Configurable via `WORKER_POOL_SIZE` setting
|
| 629 |
+
- Balance between parallelism and resource usage
|
| 630 |
+
- HuggingFace Space constraints: 2-4 workers recommended
|
| 631 |
+
|
| 632 |
+
### Query Optimization
|
| 633 |
+
```python
|
| 634 |
+
# Good: Single query with aggregation
|
| 635 |
+
DailyProgress.objects.filter(...).aggregate(Sum('cost_gold_dragons'))
|
| 636 |
+
|
| 637 |
+
# Bad: Multiple queries in loop
|
| 638 |
+
for progress in DailyProgress.objects.filter(...):
|
| 639 |
+
total += progress.cost_gold_dragons
|
| 640 |
+
```
|
| 641 |
+
|
| 642 |
+
## Future Enhancements
|
| 643 |
+
|
| 644 |
+
1. **Caching Layer**: Redis cache for frequently accessed aggregations
|
| 645 |
+
2. **Async Views**: Upgrade to Django 5.x async views when DRF adds native support
|
| 646 |
+
3. **Background Tasks**: True Celery integration for long-running reports
|
| 647 |
+
4. **PostgreSQL**: Upgrade to PostgreSQL for production deployments
|
| 648 |
+
5. **Metrics Dashboard**: Real-time construction progress visualization
|
| 649 |
+
6. **Export Features**: CSV/PDF report generation
|
| 650 |
+
7. **Authentication**: Token-based API authentication
|
| 651 |
+
8. **Rate Limiting**: Throttling for cost-intensive aggregations
|
| 652 |
+
|
| 653 |
+
## Appendix: Constants
|
| 654 |
+
|
| 655 |
+
```python
|
| 656 |
+
# constants.py
|
| 657 |
+
from decimal import Decimal
|
| 658 |
+
|
| 659 |
+
# Wall Construction Constants
|
| 660 |
+
ICE_CUBIC_YARDS_PER_FOOT = Decimal("195")
|
| 661 |
+
GOLD_DRAGONS_PER_CUBIC_YARD = Decimal("1900")
|
| 662 |
+
|
| 663 |
+
# Calculated Constants
|
| 664 |
+
GOLD_DRAGONS_PER_FOOT = (
|
| 665 |
+
ICE_CUBIC_YARDS_PER_FOOT * GOLD_DRAGONS_PER_CUBIC_YARD
|
| 666 |
+
) # 370,500 GD per foot
|
| 667 |
+
```
|
__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (165 Bytes). View file
|
|
|
apps/__init__.py
ADDED
|
File without changes
|
apps/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (170 Bytes). View file
|
|
|
apps/profiles/__init__.py
ADDED
|
File without changes
|
apps/profiles/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (179 Bytes). View file
|
|
|
apps/profiles/__pycache__/models.cpython-312.pyc
ADDED
|
Binary file (2.87 kB). View file
|
|
|
apps/profiles/__pycache__/serializers.cpython-312.pyc
ADDED
|
Binary file (1.52 kB). View file
|
|
|
apps/profiles/__pycache__/urls.cpython-312.pyc
ADDED
|
Binary file (800 Bytes). View file
|
|
|
apps/profiles/__pycache__/views.cpython-312.pyc
ADDED
|
Binary file (1.29 kB). View file
|
|
|
apps/profiles/models.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Profile models for Wall Construction API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from django.db import models
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class Profile(models.Model):
|
| 9 |
+
"""Construction profile for wall building operations."""
|
| 10 |
+
|
| 11 |
+
name = models.CharField(max_length=255, unique=True)
|
| 12 |
+
team_lead = models.CharField(max_length=255)
|
| 13 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 14 |
+
updated_at = models.DateTimeField(auto_now=True)
|
| 15 |
+
is_active = models.BooleanField(default=True)
|
| 16 |
+
|
| 17 |
+
class Meta:
|
| 18 |
+
db_table = "profiles"
|
| 19 |
+
ordering = ["-created_at"]
|
| 20 |
+
|
| 21 |
+
def __str__(self) -> str:
|
| 22 |
+
"""Return string representation of profile."""
|
| 23 |
+
return f"{self.name} (led by {self.team_lead})"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class WallSection(models.Model):
|
| 27 |
+
"""Physical wall section assigned to a profile."""
|
| 28 |
+
|
| 29 |
+
profile = models.ForeignKey(
|
| 30 |
+
Profile,
|
| 31 |
+
on_delete=models.CASCADE,
|
| 32 |
+
related_name="wall_sections",
|
| 33 |
+
)
|
| 34 |
+
section_name = models.CharField(max_length=255)
|
| 35 |
+
start_position = models.DecimalField(max_digits=10, decimal_places=2)
|
| 36 |
+
target_length_feet = models.DecimalField(max_digits=10, decimal_places=2)
|
| 37 |
+
created_at = models.DateTimeField(auto_now_add=True)
|
| 38 |
+
|
| 39 |
+
class Meta:
|
| 40 |
+
db_table = "wall_sections"
|
| 41 |
+
unique_together = [["profile", "section_name"]]
|
| 42 |
+
ordering = ["-created_at"]
|
| 43 |
+
|
| 44 |
+
def __str__(self) -> str:
|
| 45 |
+
"""Return string representation of wall section."""
|
| 46 |
+
return f"{self.section_name} ({self.profile.name})"
|
apps/profiles/serializers.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Serializers for Profile API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from rest_framework import serializers
|
| 6 |
+
|
| 7 |
+
from apps.profiles.models import Profile, WallSection
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class ProfileSerializer(serializers.ModelSerializer[Profile]):
|
| 11 |
+
"""Serializer for Profile model."""
|
| 12 |
+
|
| 13 |
+
class Meta:
|
| 14 |
+
model = Profile
|
| 15 |
+
fields = ["id", "name", "team_lead", "is_active", "created_at", "updated_at"]
|
| 16 |
+
read_only_fields = ["id", "created_at", "updated_at"]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class WallSectionSerializer(serializers.ModelSerializer[WallSection]):
|
| 20 |
+
"""Serializer for WallSection model."""
|
| 21 |
+
|
| 22 |
+
class Meta:
|
| 23 |
+
model = WallSection
|
| 24 |
+
fields = [
|
| 25 |
+
"id",
|
| 26 |
+
"profile",
|
| 27 |
+
"section_name",
|
| 28 |
+
"start_position",
|
| 29 |
+
"target_length_feet",
|
| 30 |
+
"created_at",
|
| 31 |
+
]
|
| 32 |
+
read_only_fields = ["id", "created_at"]
|
apps/profiles/urls.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""URL configuration for profiles app."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from django.urls import include, path
|
| 6 |
+
from rest_framework.routers import DefaultRouter
|
| 7 |
+
|
| 8 |
+
from apps.profiles.views import ProfileViewSet, WallSectionViewSet
|
| 9 |
+
|
| 10 |
+
router = DefaultRouter()
|
| 11 |
+
router.register(r"profiles", ProfileViewSet, basename="profile")
|
| 12 |
+
router.register(r"wallsections", WallSectionViewSet, basename="wallsection")
|
| 13 |
+
|
| 14 |
+
urlpatterns = [
|
| 15 |
+
path("", include(router.urls)),
|
| 16 |
+
]
|
apps/profiles/views.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Views for Profile API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from rest_framework import viewsets
|
| 6 |
+
|
| 7 |
+
from apps.profiles.models import Profile, WallSection
|
| 8 |
+
from apps.profiles.serializers import ProfileSerializer, WallSectionSerializer
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ProfileViewSet(viewsets.ModelViewSet[Profile]):
|
| 12 |
+
"""ViewSet for Profile CRUD operations."""
|
| 13 |
+
|
| 14 |
+
queryset = Profile.objects.all()
|
| 15 |
+
serializer_class = ProfileSerializer
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class WallSectionViewSet(viewsets.ModelViewSet[WallSection]):
|
| 19 |
+
"""ViewSet for WallSection CRUD operations."""
|
| 20 |
+
|
| 21 |
+
queryset = WallSection.objects.all()
|
| 22 |
+
serializer_class = WallSectionSerializer
|
| 23 |
+
filterset_fields = ["profile"]
|
config/__init__.py
ADDED
|
File without changes
|
config/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (172 Bytes). View file
|
|
|
config/__pycache__/urls.cpython-312.pyc
ADDED
|
Binary file (434 Bytes). View file
|
|
|
config/settings/__init__.py
ADDED
|
File without changes
|
config/settings/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (181 Bytes). View file
|
|
|
config/settings/__pycache__/base.cpython-312.pyc
ADDED
|
Binary file (2.12 kB). View file
|
|
|
config/settings/__pycache__/test.cpython-312.pyc
ADDED
|
Binary file (1.04 kB). View file
|
|
|
config/settings/base.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Django settings for Wall Construction API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 8 |
+
|
| 9 |
+
SECRET_KEY = "django-insecure-demo-key-replace-in-production"
|
| 10 |
+
|
| 11 |
+
DEBUG = True
|
| 12 |
+
|
| 13 |
+
ALLOWED_HOSTS: list[str] = []
|
| 14 |
+
|
| 15 |
+
INSTALLED_APPS = [
|
| 16 |
+
"django.contrib.auth",
|
| 17 |
+
"django.contrib.contenttypes",
|
| 18 |
+
"django.contrib.staticfiles",
|
| 19 |
+
"rest_framework",
|
| 20 |
+
"django_filters",
|
| 21 |
+
"apps.profiles",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
MIDDLEWARE = [
|
| 25 |
+
"django.middleware.security.SecurityMiddleware",
|
| 26 |
+
"django.middleware.common.CommonMiddleware",
|
| 27 |
+
"django.middleware.csrf.CsrfViewMiddleware",
|
| 28 |
+
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
| 29 |
+
]
|
| 30 |
+
|
| 31 |
+
ROOT_URLCONF = "config.urls"
|
| 32 |
+
|
| 33 |
+
TEMPLATES = [
|
| 34 |
+
{
|
| 35 |
+
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
| 36 |
+
"DIRS": [],
|
| 37 |
+
"APP_DIRS": True,
|
| 38 |
+
"OPTIONS": {
|
| 39 |
+
"context_processors": [
|
| 40 |
+
"django.template.context_processors.debug",
|
| 41 |
+
"django.template.context_processors.request",
|
| 42 |
+
],
|
| 43 |
+
},
|
| 44 |
+
},
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
WSGI_APPLICATION = "config.wsgi.application"
|
| 48 |
+
|
| 49 |
+
DATABASES = {
|
| 50 |
+
"default": {
|
| 51 |
+
"ENGINE": "django.db.backends.sqlite3",
|
| 52 |
+
"NAME": BASE_DIR / "db.sqlite3",
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
LANGUAGE_CODE = "en-us"
|
| 57 |
+
|
| 58 |
+
TIME_ZONE = "UTC"
|
| 59 |
+
|
| 60 |
+
USE_I18N = True
|
| 61 |
+
|
| 62 |
+
USE_TZ = True
|
| 63 |
+
|
| 64 |
+
STATIC_URL = "static/"
|
| 65 |
+
|
| 66 |
+
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
| 67 |
+
|
| 68 |
+
REST_FRAMEWORK = {
|
| 69 |
+
"DEFAULT_RENDERER_CLASSES": [
|
| 70 |
+
"rest_framework.renderers.JSONRenderer",
|
| 71 |
+
],
|
| 72 |
+
"DEFAULT_PARSER_CLASSES": [
|
| 73 |
+
"rest_framework.parsers.JSONParser",
|
| 74 |
+
],
|
| 75 |
+
"DEFAULT_FILTER_BACKENDS": [
|
| 76 |
+
"django_filters.rest_framework.DjangoFilterBackend",
|
| 77 |
+
],
|
| 78 |
+
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
| 79 |
+
"PAGE_SIZE": 100,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
WORKER_POOL_SIZE = 4
|
config/settings/test.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test settings for Wall Construction API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from config.settings.base import (
|
| 6 |
+
ALLOWED_HOSTS,
|
| 7 |
+
BASE_DIR,
|
| 8 |
+
DEFAULT_AUTO_FIELD,
|
| 9 |
+
INSTALLED_APPS,
|
| 10 |
+
LANGUAGE_CODE,
|
| 11 |
+
MIDDLEWARE,
|
| 12 |
+
REST_FRAMEWORK,
|
| 13 |
+
ROOT_URLCONF,
|
| 14 |
+
SECRET_KEY,
|
| 15 |
+
STATIC_URL,
|
| 16 |
+
TEMPLATES,
|
| 17 |
+
TIME_ZONE,
|
| 18 |
+
USE_I18N,
|
| 19 |
+
USE_TZ,
|
| 20 |
+
WORKER_POOL_SIZE,
|
| 21 |
+
WSGI_APPLICATION,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
__all__ = [
|
| 25 |
+
"ALLOWED_HOSTS",
|
| 26 |
+
"BASE_DIR",
|
| 27 |
+
"DATABASES",
|
| 28 |
+
"DEBUG",
|
| 29 |
+
"DEFAULT_AUTO_FIELD",
|
| 30 |
+
"INSTALLED_APPS",
|
| 31 |
+
"LANGUAGE_CODE",
|
| 32 |
+
"MIDDLEWARE",
|
| 33 |
+
"PASSWORD_HASHERS",
|
| 34 |
+
"REST_FRAMEWORK",
|
| 35 |
+
"ROOT_URLCONF",
|
| 36 |
+
"SECRET_KEY",
|
| 37 |
+
"STATIC_URL",
|
| 38 |
+
"TEMPLATES",
|
| 39 |
+
"TIME_ZONE",
|
| 40 |
+
"USE_I18N",
|
| 41 |
+
"USE_TZ",
|
| 42 |
+
"WORKER_POOL_SIZE",
|
| 43 |
+
"WSGI_APPLICATION",
|
| 44 |
+
]
|
| 45 |
+
|
| 46 |
+
DEBUG = False
|
| 47 |
+
|
| 48 |
+
DATABASES = {
|
| 49 |
+
"default": {
|
| 50 |
+
"ENGINE": "django.db.backends.sqlite3",
|
| 51 |
+
"NAME": ":memory:",
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
PASSWORD_HASHERS = [
|
| 56 |
+
"django.contrib.auth.hashers.MD5PasswordHasher",
|
| 57 |
+
]
|
config/urls.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""URL configuration for Wall Construction API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from django.urls import include, path
|
| 6 |
+
|
| 7 |
+
urlpatterns = [
|
| 8 |
+
path("api/", include("apps.profiles.urls")),
|
| 9 |
+
]
|
config/wsgi.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""WSGI configuration for Wall Construction API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
|
| 7 |
+
from django.core.wsgi import get_wsgi_application
|
| 8 |
+
|
| 9 |
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base")
|
| 10 |
+
|
| 11 |
+
application = get_wsgi_application()
|
main.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Minimal demo script for
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
@@ -8,7 +8,7 @@ from loguru import logger
|
|
| 8 |
|
| 9 |
def main() -> int:
|
| 10 |
"""Main entry point for demo."""
|
| 11 |
-
logger.info("
|
| 12 |
logger.info("Module initialized successfully")
|
| 13 |
logger.info("Demo complete")
|
| 14 |
return 0
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Minimal demo script for irisai module."""
|
| 3 |
|
| 4 |
from __future__ import annotations
|
| 5 |
|
|
|
|
| 8 |
|
| 9 |
def main() -> int:
|
| 10 |
"""Main entry point for demo."""
|
| 11 |
+
logger.info("Irisai demo starting...")
|
| 12 |
logger.info("Module initialized successfully")
|
| 13 |
logger.info("Demo complete")
|
| 14 |
return 0
|
manage.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""Django management utility for Wall Construction API."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def main() -> None:
|
| 11 |
+
"""Run administrative tasks."""
|
| 12 |
+
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base")
|
| 13 |
+
try:
|
| 14 |
+
from django.core.management import execute_from_command_line
|
| 15 |
+
except ImportError as exc:
|
| 16 |
+
raise ImportError(
|
| 17 |
+
"Couldn't import Django. Are you sure it's installed and "
|
| 18 |
+
"available on your PYTHONPATH environment variable? Did you "
|
| 19 |
+
"forget to activate a virtual environment?"
|
| 20 |
+
) from exc
|
| 21 |
+
execute_from_command_line(sys.argv)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
if __name__ == "__main__":
|
| 25 |
+
main()
|
module_setup.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""Module setup script for demo project."""
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 5 |
import subprocess
|
| 6 |
-
import sys
|
| 7 |
from pathlib import Path
|
| 8 |
|
|
|
|
|
|
|
| 9 |
|
| 10 |
def check_uv() -> None:
|
| 11 |
"""Check if uv is installed and accessible."""
|
|
@@ -26,18 +29,18 @@ def ensure_python_version(version: str) -> None:
|
|
| 26 |
check=True,
|
| 27 |
)
|
| 28 |
if not result.stdout.strip():
|
| 29 |
-
|
| 30 |
subprocess.run(["uv", "python", "install", version], check=True)
|
| 31 |
-
except subprocess.CalledProcessError
|
| 32 |
-
|
| 33 |
subprocess.run(["uv", "python", "install", version], check=True)
|
| 34 |
|
| 35 |
|
| 36 |
def sync_dependencies(module_root: Path) -> None:
|
| 37 |
"""Sync pyproject.toml dependencies with uv."""
|
| 38 |
-
|
| 39 |
subprocess.run(
|
| 40 |
-
["uv", "sync", "--
|
| 41 |
cwd=module_root,
|
| 42 |
check=True,
|
| 43 |
)
|
|
@@ -49,11 +52,11 @@ def main() -> int:
|
|
| 49 |
python_ver_file = module_root / "python.ver"
|
| 50 |
|
| 51 |
if not python_ver_file.exists():
|
| 52 |
-
|
| 53 |
return 1
|
| 54 |
|
| 55 |
required_version = python_ver_file.read_text().strip()
|
| 56 |
-
|
| 57 |
|
| 58 |
try:
|
| 59 |
check_uv()
|
|
@@ -61,19 +64,19 @@ def main() -> int:
|
|
| 61 |
|
| 62 |
pyproject = module_root / "pyproject.toml"
|
| 63 |
if not pyproject.exists():
|
| 64 |
-
|
| 65 |
return 1
|
| 66 |
|
| 67 |
sync_dependencies(module_root)
|
| 68 |
|
| 69 |
-
|
| 70 |
venv_dir = module_root / ".venv"
|
| 71 |
-
|
| 72 |
-
|
| 73 |
return 0
|
| 74 |
|
| 75 |
except (subprocess.CalledProcessError, RuntimeError) as e:
|
| 76 |
-
|
| 77 |
return 1
|
| 78 |
|
| 79 |
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""Module setup script for demo project."""
|
| 3 |
+
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
+
import logging
|
| 7 |
import subprocess
|
|
|
|
| 8 |
from pathlib import Path
|
| 9 |
|
| 10 |
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
| 11 |
+
|
| 12 |
|
| 13 |
def check_uv() -> None:
|
| 14 |
"""Check if uv is installed and accessible."""
|
|
|
|
| 29 |
check=True,
|
| 30 |
)
|
| 31 |
if not result.stdout.strip():
|
| 32 |
+
logging.info(f"Installing Python {version} via uv...")
|
| 33 |
subprocess.run(["uv", "python", "install", version], check=True)
|
| 34 |
+
except subprocess.CalledProcessError:
|
| 35 |
+
logging.info(f"Installing Python {version} via uv...")
|
| 36 |
subprocess.run(["uv", "python", "install", version], check=True)
|
| 37 |
|
| 38 |
|
| 39 |
def sync_dependencies(module_root: Path) -> None:
|
| 40 |
"""Sync pyproject.toml dependencies with uv."""
|
| 41 |
+
logging.info("Syncing dependencies with uv (all extras)...")
|
| 42 |
subprocess.run(
|
| 43 |
+
["uv", "sync", "--all-extras"],
|
| 44 |
cwd=module_root,
|
| 45 |
check=True,
|
| 46 |
)
|
|
|
|
| 52 |
python_ver_file = module_root / "python.ver"
|
| 53 |
|
| 54 |
if not python_ver_file.exists():
|
| 55 |
+
logging.error(f"Error: {python_ver_file} not found")
|
| 56 |
return 1
|
| 57 |
|
| 58 |
required_version = python_ver_file.read_text().strip()
|
| 59 |
+
logging.info(f"Setting up demo project with Python {required_version}...")
|
| 60 |
|
| 61 |
try:
|
| 62 |
check_uv()
|
|
|
|
| 64 |
|
| 65 |
pyproject = module_root / "pyproject.toml"
|
| 66 |
if not pyproject.exists():
|
| 67 |
+
logging.error(f"Error: {pyproject} not found")
|
| 68 |
return 1
|
| 69 |
|
| 70 |
sync_dependencies(module_root)
|
| 71 |
|
| 72 |
+
logging.info("\n✓ Setup complete for demo project")
|
| 73 |
venv_dir = module_root / ".venv"
|
| 74 |
+
logging.info(f" Activate: source {venv_dir}/bin/activate")
|
| 75 |
+
logging.info(f" Run demo: ./scripts/ami-run.sh {module_root}/main.py")
|
| 76 |
return 0
|
| 77 |
|
| 78 |
except (subprocess.CalledProcessError, RuntimeError) as e:
|
| 79 |
+
logging.error(f"\n✗ Setup failed: {e}")
|
| 80 |
return 1
|
| 81 |
|
| 82 |
|
mypy.ini
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[mypy]
|
| 2 |
+
# IMPORTANT: This version must match the version in python.ver file
|
| 3 |
+
# mypy does not support reading from external files, so this must be manually kept in sync
|
| 4 |
+
python_version = 3.12
|
| 5 |
+
files = .
|
| 6 |
+
exclude = ^(\.venv|venv)/.*
|
| 7 |
+
# Enable proper package resolution with MYPYPATH set to parent
|
| 8 |
+
namespace_packages = True
|
| 9 |
+
explicit_package_bases = True
|
| 10 |
+
follow_imports = silent
|
| 11 |
+
ignore_missing_imports = False
|
| 12 |
+
strict = True
|
| 13 |
+
warn_return_any = True
|
| 14 |
+
warn_unused_configs = True
|
| 15 |
+
disallow_untyped_defs = True
|
| 16 |
+
check_untyped_defs = True
|
| 17 |
+
no_implicit_optional = True
|
| 18 |
+
warn_redundant_casts = True
|
| 19 |
+
warn_unused_ignores = True
|
| 20 |
+
warn_unreachable = True
|
| 21 |
+
strict_equality = True
|
| 22 |
+
plugins = mypy_django_plugin.main
|
| 23 |
+
|
| 24 |
+
[mypy.plugins.django-stubs]
|
| 25 |
+
django_settings_module = config.settings.base
|
| 26 |
+
|
| 27 |
+
[mypy-loguru.*]
|
| 28 |
+
ignore_missing_imports = True
|
| 29 |
+
|
| 30 |
+
[mypy-factory.*]
|
| 31 |
+
ignore_missing_imports = True
|
pyproject.toml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
[project]
|
| 2 |
name = "demo"
|
| 3 |
version = "0.1.0"
|
| 4 |
-
description = "
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12"
|
| 7 |
authors = [
|
|
@@ -9,21 +9,41 @@ authors = [
|
|
| 9 |
]
|
| 10 |
dependencies = [
|
| 11 |
"loguru==0.7.3",
|
|
|
|
|
|
|
|
|
|
| 12 |
]
|
| 13 |
|
| 14 |
[project.optional-dependencies]
|
| 15 |
dev = [
|
| 16 |
-
"pytest==8.4.2",
|
| 17 |
-
"pytest-asyncio==1.2.0",
|
| 18 |
-
"pytest-timeout==2.4.0",
|
| 19 |
"mypy==1.18.2",
|
| 20 |
"ruff==0.13.2",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
]
|
| 22 |
|
| 23 |
[build-system]
|
| 24 |
requires = ["setuptools>=69", "wheel"]
|
| 25 |
build-backend = "setuptools.build_meta"
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
[tool.mypy]
|
| 28 |
python_version = "3.12"
|
| 29 |
warn_unused_ignores = true
|
|
@@ -32,3 +52,16 @@ warn_return_any = true
|
|
| 32 |
|
| 33 |
[tool.ruff]
|
| 34 |
target-version = "py312"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "demo"
|
| 3 |
version = "0.1.0"
|
| 4 |
+
description = "Wall Construction API - Django demo for irisai integration"
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12"
|
| 7 |
authors = [
|
|
|
|
| 9 |
]
|
| 10 |
dependencies = [
|
| 11 |
"loguru==0.7.3",
|
| 12 |
+
"Django==5.2.7",
|
| 13 |
+
"djangorestframework==3.16.0",
|
| 14 |
+
"django-filter>=24.3",
|
| 15 |
]
|
| 16 |
|
| 17 |
[project.optional-dependencies]
|
| 18 |
dev = [
|
|
|
|
|
|
|
|
|
|
| 19 |
"mypy==1.18.2",
|
| 20 |
"ruff==0.13.2",
|
| 21 |
+
"django-stubs==5.2.6",
|
| 22 |
+
"djangorestframework-stubs==3.16.4",
|
| 23 |
+
]
|
| 24 |
+
test = [
|
| 25 |
+
"pytest==8.4.2",
|
| 26 |
+
"pytest-django==4.9.0",
|
| 27 |
+
"pytest-xdist==3.6.1",
|
| 28 |
+
"factory-boy==3.3.3",
|
| 29 |
+
"Faker==33.3.0",
|
| 30 |
+
"pytest-cov==6.0.0",
|
| 31 |
+
"coverage[toml]==7.6.0",
|
| 32 |
+
"pytest-timeout==2.4.0",
|
| 33 |
]
|
| 34 |
|
| 35 |
[build-system]
|
| 36 |
requires = ["setuptools>=69", "wheel"]
|
| 37 |
build-backend = "setuptools.build_meta"
|
| 38 |
|
| 39 |
+
[tool.setuptools]
|
| 40 |
+
py-modules = []
|
| 41 |
+
|
| 42 |
+
[tool.setuptools.packages.find]
|
| 43 |
+
where = ["."]
|
| 44 |
+
include = ["apps*", "config*", "tests*"]
|
| 45 |
+
exclude = ["*.tests", "*.tests.*"]
|
| 46 |
+
|
| 47 |
[tool.mypy]
|
| 48 |
python_version = "3.12"
|
| 49 |
warn_unused_ignores = true
|
|
|
|
| 52 |
|
| 53 |
[tool.ruff]
|
| 54 |
target-version = "py312"
|
| 55 |
+
|
| 56 |
+
[tool.coverage.run]
|
| 57 |
+
omit = [
|
| 58 |
+
"*/migrations/*",
|
| 59 |
+
"*/tests/*",
|
| 60 |
+
"manage.py",
|
| 61 |
+
"module_setup.py",
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
[tool.coverage.report]
|
| 65 |
+
exclude_lines = [
|
| 66 |
+
"if TYPE_CHECKING:",
|
| 67 |
+
]
|
pytest.ini
CHANGED
|
@@ -1,24 +1,28 @@
|
|
| 1 |
[pytest]
|
|
|
|
| 2 |
python_files = test_*.py
|
| 3 |
python_classes = Test*
|
| 4 |
python_functions = test_*
|
| 5 |
-
asyncio_mode = auto
|
| 6 |
|
| 7 |
-
#
|
| 8 |
-
log_cli = true
|
| 9 |
-
log_cli_level = INFO
|
| 10 |
-
log_cli_format = %(asctime)s [%(levelname)8s] %(message)s
|
| 11 |
-
log_cli_date_format = %Y-%m-%d %H:%M:%S
|
| 12 |
-
|
| 13 |
-
# Coverage
|
| 14 |
addopts =
|
| 15 |
--strict-markers
|
| 16 |
--tb=short
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
markers =
|
| 19 |
-
|
| 20 |
-
integration:
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
# Timeout
|
| 24 |
timeout = 300
|
|
|
|
| 1 |
[pytest]
|
| 2 |
+
DJANGO_SETTINGS_MODULE = config.settings.test
|
| 3 |
python_files = test_*.py
|
| 4 |
python_classes = Test*
|
| 5 |
python_functions = test_*
|
|
|
|
| 6 |
|
| 7 |
+
# Django settings
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
addopts =
|
| 9 |
--strict-markers
|
| 10 |
--tb=short
|
| 11 |
+
--nomigrations
|
| 12 |
+
-v
|
| 13 |
+
--cov=apps
|
| 14 |
+
--cov-report=term-missing:skip-covered
|
| 15 |
+
--cov-report=html
|
| 16 |
+
--cov-fail-under=90
|
| 17 |
+
-n auto
|
| 18 |
|
| 19 |
markers =
|
| 20 |
+
unit: Unit tests (fast, isolated)
|
| 21 |
+
integration: Integration tests (API + DB)
|
| 22 |
+
e2e: End-to-end tests (full workflows)
|
| 23 |
+
slow: Slow tests (run separately)
|
| 24 |
+
|
| 25 |
+
testpaths = tests
|
| 26 |
|
| 27 |
# Timeout
|
| 28 |
timeout = 300
|
ruff.toml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
line-length = 160
|
| 2 |
+
target-version = "py312"
|
| 3 |
+
|
| 4 |
+
[lint]
|
| 5 |
+
# Comprehensive rule set replacing pylint + bandit
|
| 6 |
+
select = [
|
| 7 |
+
"E", # pycodestyle errors
|
| 8 |
+
"W", # pycodestyle warnings
|
| 9 |
+
"F", # pyflakes
|
| 10 |
+
"I", # isort (import sorting)
|
| 11 |
+
"B", # flake8-bugbear
|
| 12 |
+
"S", # flake8-bandit (security)
|
| 13 |
+
"C4", # flake8-comprehensions
|
| 14 |
+
"UP", # pyupgrade
|
| 15 |
+
"PL", # pylint
|
| 16 |
+
"A", # flake8-builtins
|
| 17 |
+
"C90", # mccabe complexity
|
| 18 |
+
"N", # pep8-naming
|
| 19 |
+
"SIM", # flake8-simplify
|
| 20 |
+
"RET", # flake8-return
|
| 21 |
+
"ARG", # flake8-unused-arguments
|
| 22 |
+
"PTH", # flake8-use-pathlib
|
| 23 |
+
"ERA", # eradicate (remove commented code)
|
| 24 |
+
"PIE", # flake8-pie
|
| 25 |
+
"T20", # flake8-print
|
| 26 |
+
"Q", # flake8-quotes
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
[lint.mccabe]
|
| 30 |
+
max-complexity = 10
|
| 31 |
+
|
| 32 |
+
[lint.pylint]
|
| 33 |
+
max-args = 16
|
| 34 |
+
max-locals = 64
|
| 35 |
+
max-statements = 96
|
| 36 |
+
max-branches = 16
|
| 37 |
+
|
| 38 |
+
[lint.per-file-ignores]
|
| 39 |
+
"**/test_*.py" = ["S101", "PLR2004"] # Asserts and magic values required for tests
|
| 40 |
+
"tests/**/*.py" = ["S101", "PLR2004"] # Asserts and magic values required for tests
|
| 41 |
+
"backend/dataops/acquisition/download_youtube_transcript.py" = ["T201"] # CLI tool needs print()
|
| 42 |
+
"config/settings/base.py" = ["S105"] # Demo key with explicit warning comment
|
| 43 |
+
"manage.py" = ["PLC0415"] # Django standard pattern for imports
|
| 44 |
+
"module_setup.py" = ["S607", "S603"] # Setup script needs subprocess
|
| 45 |
+
"scripts/run_tests.py" = ["S607", "S603"] # Test runner needs subprocess
|
| 46 |
+
|
| 47 |
+
[format]
|
| 48 |
+
line-ending = "auto"
|
| 49 |
+
quote-style = "double"
|
scripts/ruff.toml
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
line-length = 160
|
| 2 |
+
target-version = "py311"
|
| 3 |
+
|
| 4 |
+
[lint]
|
| 5 |
+
# Comprehensive rule set replacing pylint + bandit
|
| 6 |
+
select = [
|
| 7 |
+
"E", # pycodestyle errors
|
| 8 |
+
"W", # pycodestyle warnings
|
| 9 |
+
"F", # pyflakes
|
| 10 |
+
"I", # isort (import sorting)
|
| 11 |
+
"B", # flake8-bugbear
|
| 12 |
+
"S", # flake8-bandit (security)
|
| 13 |
+
"C4", # flake8-comprehensions
|
| 14 |
+
"UP", # pyupgrade
|
| 15 |
+
"PL", # pylint
|
| 16 |
+
"A", # flake8-builtins
|
| 17 |
+
"C90", # mccabe complexity
|
| 18 |
+
"N", # pep8-naming
|
| 19 |
+
"SIM", # flake8-simplify
|
| 20 |
+
"RET", # flake8-return
|
| 21 |
+
"ARG", # flake8-unused-arguments
|
| 22 |
+
"PTH", # flake8-use-pathlib
|
| 23 |
+
"ERA", # eradicate (remove commented code)
|
| 24 |
+
"PIE", # flake8-pie
|
| 25 |
+
"T20", # flake8-print
|
| 26 |
+
"Q", # flake8-quotes
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
[lint.mccabe]
|
| 30 |
+
max-complexity = 10
|
| 31 |
+
|
| 32 |
+
[lint.pylint]
|
| 33 |
+
max-args = 16
|
| 34 |
+
max-locals = 64
|
| 35 |
+
max-statements = 96
|
| 36 |
+
max-branches = 16
|
| 37 |
+
|
| 38 |
+
[lint.per-file-ignores]
|
| 39 |
+
"**/test_*.py" = ["S101", "PLR2004"] # Asserts and magic values required for tests
|
| 40 |
+
"tests/**/*.py" = ["S101", "PLR2004"] # Asserts and magic values required for tests
|
| 41 |
+
"backend/dataops/acquisition/download_youtube_transcript.py" = ["T201"] # CLI tool needs print()
|
| 42 |
+
|
| 43 |
+
[format]
|
| 44 |
+
line-ending = "auto"
|
| 45 |
+
quote-style = "double"
|
scripts/run_tests.py
CHANGED
|
@@ -1,12 +1,36 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""Test runner for demo project
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def main() -> int:
|
| 7 |
-
"""Run
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
if __name__ == "__main__":
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""Test runner for demo project."""
|
| 3 |
+
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
+
import logging
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
| 12 |
+
|
| 13 |
|
| 14 |
def main() -> int:
|
| 15 |
+
"""Run pytest with all command line arguments passed through."""
|
| 16 |
+
demo_root = Path(__file__).resolve().parent.parent
|
| 17 |
+
tests_dir = demo_root / "tests"
|
| 18 |
+
|
| 19 |
+
if not tests_dir.exists():
|
| 20 |
+
logging.info(f"No tests directory found at {tests_dir}. Nothing to test.")
|
| 21 |
+
return 0
|
| 22 |
+
|
| 23 |
+
test_files = list(tests_dir.rglob("test_*.py")) + list(tests_dir.rglob("*_test.py"))
|
| 24 |
+
if not test_files:
|
| 25 |
+
logging.info("No test files found. Nothing to test.")
|
| 26 |
+
return 0
|
| 27 |
+
|
| 28 |
+
cmd = [sys.executable, "-m", "pytest"] + sys.argv[1:]
|
| 29 |
+
try:
|
| 30 |
+
subprocess.run(cmd, cwd=str(demo_root), check=True)
|
| 31 |
+
return 0
|
| 32 |
+
except subprocess.CalledProcessError as exc:
|
| 33 |
+
return exc.returncode
|
| 34 |
|
| 35 |
|
| 36 |
if __name__ == "__main__":
|
tests/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (171 Bytes). View file
|
|
|
tests/__pycache__/conftest.cpython-312-pytest-8.4.1.pyc
ADDED
|
Binary file (725 Bytes). View file
|
|
|
tests/__pycache__/conftest.cpython-312-pytest-8.4.2.pyc
ADDED
|
Binary file (1.04 kB). View file
|
|
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pytest configuration and fixtures for Wall Construction API."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
from rest_framework.test import APIClient
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@pytest.fixture(autouse=True)
|
| 10 |
+
def enable_db_access_for_all_tests(db: object) -> None:
|
| 11 |
+
"""Enable database access for all tests with proper transaction isolation."""
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.fixture
|
| 15 |
+
def api_client() -> APIClient:
|
| 16 |
+
"""Provide DRF API client for testing."""
|
| 17 |
+
return APIClient()
|
tests/factories.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Factory definitions for test data generation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from factory.django import DjangoModelFactory
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class BaseFactory(DjangoModelFactory[Any]):
|
| 11 |
+
"""Base factory with common configuration."""
|
| 12 |
+
|
| 13 |
+
class Meta:
|
| 14 |
+
abstract = True
|
tests/integration/__init__.py
ADDED
|
File without changes
|
tests/integration/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (183 Bytes). View file
|
|
|
tests/integration/__pycache__/test_profile_api.cpython-312-pytest-8.4.2.pyc
ADDED
|
Binary file (29.2 kB). View file
|
|
|
tests/integration/__pycache__/test_wallsection_api.cpython-312-pytest-8.4.2.pyc
ADDED
|
Binary file (28.4 kB). View file
|
|
|
tests/integration/test_profile_api.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Integration tests for Profile API endpoints."""
|
| 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
|
| 13 |
+
class TestProfileAPI:
|
| 14 |
+
"""Test Profile CRUD operations via REST API."""
|
| 15 |
+
|
| 16 |
+
def test_create_profile_success(self, api_client: APIClient) -> None:
|
| 17 |
+
"""Test creating a new profile returns 201 and correct data."""
|
| 18 |
+
url = reverse("profile-list")
|
| 19 |
+
payload = {
|
| 20 |
+
"name": "Northern Watch",
|
| 21 |
+
"team_lead": "Jon Snow",
|
| 22 |
+
"is_active": True,
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
response = api_client.post(url, payload, format="json")
|
| 26 |
+
|
| 27 |
+
assert response.status_code == status.HTTP_201_CREATED
|
| 28 |
+
assert response.data["name"] == "Northern Watch"
|
| 29 |
+
assert response.data["team_lead"] == "Jon Snow"
|
| 30 |
+
assert response.data["is_active"] is True
|
| 31 |
+
assert "id" in response.data
|
| 32 |
+
assert "created_at" in response.data
|
| 33 |
+
assert "updated_at" in response.data
|
| 34 |
+
|
| 35 |
+
def test_create_profile_duplicate_name_fails(self, api_client: APIClient) -> None:
|
| 36 |
+
"""Test creating profile with duplicate name returns 400."""
|
| 37 |
+
url = reverse("profile-list")
|
| 38 |
+
payload = {"name": "Northern Watch", "team_lead": "Jon Snow"}
|
| 39 |
+
|
| 40 |
+
api_client.post(url, payload, format="json")
|
| 41 |
+
response = api_client.post(url, payload, format="json")
|
| 42 |
+
|
| 43 |
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
| 44 |
+
|
| 45 |
+
def test_list_profiles_empty(self, api_client: APIClient) -> None:
|
| 46 |
+
"""Test listing profiles when none exist returns empty results."""
|
| 47 |
+
url = reverse("profile-list")
|
| 48 |
+
|
| 49 |
+
response = api_client.get(url)
|
| 50 |
+
|
| 51 |
+
assert response.status_code == status.HTTP_200_OK
|
| 52 |
+
assert response.data["count"] == 0
|
| 53 |
+
assert response.data["results"] == []
|
| 54 |
+
|
| 55 |
+
def test_list_profiles_with_data(self, api_client: APIClient) -> None:
|
| 56 |
+
"""Test listing profiles returns all profiles ordered by created_at."""
|
| 57 |
+
url = reverse("profile-list")
|
| 58 |
+
api_client.post(
|
| 59 |
+
url,
|
| 60 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 61 |
+
format="json",
|
| 62 |
+
)
|
| 63 |
+
api_client.post(
|
| 64 |
+
url,
|
| 65 |
+
{"name": "Eastern Defense", "team_lead": "Tormund Giantsbane"},
|
| 66 |
+
format="json",
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
response = api_client.get(url)
|
| 70 |
+
|
| 71 |
+
assert response.status_code == status.HTTP_200_OK
|
| 72 |
+
assert response.data["count"] == 2
|
| 73 |
+
assert len(response.data["results"]) == 2
|
| 74 |
+
assert response.data["results"][0]["name"] == "Eastern Defense"
|
| 75 |
+
assert response.data["results"][1]["name"] == "Northern Watch"
|
| 76 |
+
|
| 77 |
+
def test_retrieve_profile_success(self, api_client: APIClient) -> None:
|
| 78 |
+
"""Test retrieving a profile by ID returns 200 and correct data."""
|
| 79 |
+
create_url = reverse("profile-list")
|
| 80 |
+
create_response = api_client.post(
|
| 81 |
+
create_url,
|
| 82 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 83 |
+
format="json",
|
| 84 |
+
)
|
| 85 |
+
profile_id = create_response.data["id"]
|
| 86 |
+
|
| 87 |
+
retrieve_url = reverse("profile-detail", kwargs={"pk": profile_id})
|
| 88 |
+
response = api_client.get(retrieve_url)
|
| 89 |
+
|
| 90 |
+
assert response.status_code == status.HTTP_200_OK
|
| 91 |
+
assert response.data["id"] == profile_id
|
| 92 |
+
assert response.data["name"] == "Northern Watch"
|
| 93 |
+
assert response.data["team_lead"] == "Jon Snow"
|
| 94 |
+
|
| 95 |
+
def test_retrieve_profile_not_found(self, api_client: APIClient) -> None:
|
| 96 |
+
"""Test retrieving non-existent profile returns 404."""
|
| 97 |
+
url = reverse("profile-detail", kwargs={"pk": 99999})
|
| 98 |
+
|
| 99 |
+
response = api_client.get(url)
|
| 100 |
+
|
| 101 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 102 |
+
|
| 103 |
+
def test_update_profile_success(self, api_client: APIClient) -> None:
|
| 104 |
+
"""Test updating a profile via PUT returns 200 and updated data."""
|
| 105 |
+
create_url = reverse("profile-list")
|
| 106 |
+
create_response = api_client.post(
|
| 107 |
+
create_url,
|
| 108 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 109 |
+
format="json",
|
| 110 |
+
)
|
| 111 |
+
profile_id = create_response.data["id"]
|
| 112 |
+
|
| 113 |
+
update_url = reverse("profile-detail", kwargs={"pk": profile_id})
|
| 114 |
+
updated_payload = {
|
| 115 |
+
"name": "Northern Watch Updated",
|
| 116 |
+
"team_lead": "Samwell Tarly",
|
| 117 |
+
"is_active": False,
|
| 118 |
+
}
|
| 119 |
+
response = api_client.put(update_url, updated_payload, format="json")
|
| 120 |
+
|
| 121 |
+
assert response.status_code == status.HTTP_200_OK
|
| 122 |
+
assert response.data["name"] == "Northern Watch Updated"
|
| 123 |
+
assert response.data["team_lead"] == "Samwell Tarly"
|
| 124 |
+
assert response.data["is_active"] is False
|
| 125 |
+
|
| 126 |
+
def test_partial_update_profile_success(self, api_client: APIClient) -> None:
|
| 127 |
+
"""Test partially updating profile via PATCH returns 200."""
|
| 128 |
+
create_url = reverse("profile-list")
|
| 129 |
+
create_response = api_client.post(
|
| 130 |
+
create_url,
|
| 131 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 132 |
+
format="json",
|
| 133 |
+
)
|
| 134 |
+
profile_id = create_response.data["id"]
|
| 135 |
+
|
| 136 |
+
update_url = reverse("profile-detail", kwargs={"pk": profile_id})
|
| 137 |
+
response = api_client.patch(
|
| 138 |
+
update_url,
|
| 139 |
+
{"is_active": False},
|
| 140 |
+
format="json",
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
assert response.status_code == status.HTTP_200_OK
|
| 144 |
+
assert response.data["is_active"] is False
|
| 145 |
+
assert response.data["name"] == "Northern Watch"
|
| 146 |
+
assert response.data["team_lead"] == "Jon Snow"
|
| 147 |
+
|
| 148 |
+
def test_delete_profile_success(self, api_client: APIClient) -> None:
|
| 149 |
+
"""Test deleting a profile returns 204."""
|
| 150 |
+
create_url = reverse("profile-list")
|
| 151 |
+
create_response = api_client.post(
|
| 152 |
+
create_url,
|
| 153 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 154 |
+
format="json",
|
| 155 |
+
)
|
| 156 |
+
profile_id = create_response.data["id"]
|
| 157 |
+
|
| 158 |
+
delete_url = reverse("profile-detail", kwargs={"pk": profile_id})
|
| 159 |
+
response = api_client.delete(delete_url)
|
| 160 |
+
|
| 161 |
+
assert response.status_code == status.HTTP_204_NO_CONTENT
|
| 162 |
+
|
| 163 |
+
retrieve_response = api_client.get(delete_url)
|
| 164 |
+
assert retrieve_response.status_code == status.HTTP_404_NOT_FOUND
|
| 165 |
+
|
| 166 |
+
def test_profile_name_required(self, api_client: APIClient) -> None:
|
| 167 |
+
"""Test creating profile without name returns 400."""
|
| 168 |
+
url = reverse("profile-list")
|
| 169 |
+
payload = {"team_lead": "Jon Snow"}
|
| 170 |
+
|
| 171 |
+
response = api_client.post(url, payload, format="json")
|
| 172 |
+
|
| 173 |
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
| 174 |
+
assert "name" in response.data
|
| 175 |
+
|
| 176 |
+
def test_profile_team_lead_required(self, api_client: APIClient) -> None:
|
| 177 |
+
"""Test creating profile without team_lead returns 400."""
|
| 178 |
+
url = reverse("profile-list")
|
| 179 |
+
payload = {"name": "Northern Watch"}
|
| 180 |
+
|
| 181 |
+
response = api_client.post(url, payload, format="json")
|
| 182 |
+
|
| 183 |
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
| 184 |
+
assert "team_lead" in response.data
|
| 185 |
+
|
| 186 |
+
def test_profile_defaults(self, api_client: APIClient) -> None:
|
| 187 |
+
"""Test profile creation with default values."""
|
| 188 |
+
url = reverse("profile-list")
|
| 189 |
+
payload = {"name": "Northern Watch", "team_lead": "Jon Snow"}
|
| 190 |
+
|
| 191 |
+
response = api_client.post(url, payload, format="json")
|
| 192 |
+
|
| 193 |
+
assert response.status_code == status.HTTP_201_CREATED
|
| 194 |
+
assert response.data["is_active"] is True
|
tests/integration/test_wallsection_api.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Integration tests for WallSection API endpoints."""
|
| 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
|
| 13 |
+
class TestWallSectionAPI:
|
| 14 |
+
"""Test WallSection CRUD operations via REST API."""
|
| 15 |
+
|
| 16 |
+
def test_create_wall_section_success(self, api_client: APIClient) -> None:
|
| 17 |
+
"""Test creating a wall section for a profile returns 201."""
|
| 18 |
+
profile_url = reverse("profile-list")
|
| 19 |
+
profile = api_client.post(
|
| 20 |
+
profile_url,
|
| 21 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 22 |
+
format="json",
|
| 23 |
+
).data
|
| 24 |
+
|
| 25 |
+
url = reverse("wallsection-list")
|
| 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
|
| 42 |
+
|
| 43 |
+
def test_create_wall_section_duplicate_name_for_profile_fails(self, api_client: APIClient) -> None:
|
| 44 |
+
"""Test duplicate section name for same profile returns 400."""
|
| 45 |
+
profile_url = reverse("profile-list")
|
| 46 |
+
profile = api_client.post(
|
| 47 |
+
profile_url,
|
| 48 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 49 |
+
format="json",
|
| 50 |
+
).data
|
| 51 |
+
|
| 52 |
+
url = reverse("wallsection-list")
|
| 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")
|
| 61 |
+
response = api_client.post(url, payload, format="json")
|
| 62 |
+
|
| 63 |
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
| 64 |
+
|
| 65 |
+
def test_create_wall_section_same_name_different_profiles_succeeds(self, api_client: APIClient) -> None:
|
| 66 |
+
"""Test same section name for different profiles is allowed."""
|
| 67 |
+
profile_url = reverse("profile-list")
|
| 68 |
+
profile1 = api_client.post(
|
| 69 |
+
profile_url,
|
| 70 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 71 |
+
format="json",
|
| 72 |
+
).data
|
| 73 |
+
profile2 = api_client.post(
|
| 74 |
+
profile_url,
|
| 75 |
+
{"name": "Eastern Defense", "team_lead": "Tormund"},
|
| 76 |
+
format="json",
|
| 77 |
+
).data
|
| 78 |
+
|
| 79 |
+
url = reverse("wallsection-list")
|
| 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")
|
| 94 |
+
response2 = api_client.post(url, payload2, format="json")
|
| 95 |
+
|
| 96 |
+
assert response1.status_code == status.HTTP_201_CREATED
|
| 97 |
+
assert response2.status_code == status.HTTP_201_CREATED
|
| 98 |
+
|
| 99 |
+
def test_list_wall_sections(self, api_client: APIClient) -> None:
|
| 100 |
+
"""Test listing wall sections returns all sections."""
|
| 101 |
+
profile_url = reverse("profile-list")
|
| 102 |
+
profile = api_client.post(
|
| 103 |
+
profile_url,
|
| 104 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 105 |
+
format="json",
|
| 106 |
+
).data
|
| 107 |
+
|
| 108 |
+
section_url = reverse("wallsection-list")
|
| 109 |
+
api_client.post(
|
| 110 |
+
section_url,
|
| 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 |
+
)
|
| 119 |
+
api_client.post(
|
| 120 |
+
section_url,
|
| 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 |
+
)
|
| 129 |
+
|
| 130 |
+
response = api_client.get(section_url)
|
| 131 |
+
|
| 132 |
+
assert response.status_code == status.HTTP_200_OK
|
| 133 |
+
assert response.data["count"] == 2
|
| 134 |
+
assert len(response.data["results"]) == 2
|
| 135 |
+
|
| 136 |
+
def test_filter_wall_sections_by_profile(self, api_client: APIClient) -> None:
|
| 137 |
+
"""Test filtering sections by profile ID."""
|
| 138 |
+
profile_url = reverse("profile-list")
|
| 139 |
+
profile1 = api_client.post(
|
| 140 |
+
profile_url,
|
| 141 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 142 |
+
format="json",
|
| 143 |
+
).data
|
| 144 |
+
profile2 = api_client.post(
|
| 145 |
+
profile_url,
|
| 146 |
+
{"name": "Eastern Defense", "team_lead": "Tormund"},
|
| 147 |
+
format="json",
|
| 148 |
+
).data
|
| 149 |
+
|
| 150 |
+
section_url = reverse("wallsection-list")
|
| 151 |
+
api_client.post(
|
| 152 |
+
section_url,
|
| 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 |
+
)
|
| 161 |
+
api_client.post(
|
| 162 |
+
section_url,
|
| 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 |
+
)
|
| 171 |
+
|
| 172 |
+
response = api_client.get(section_url, {"profile": profile1["id"]})
|
| 173 |
+
|
| 174 |
+
assert response.status_code == status.HTTP_200_OK
|
| 175 |
+
assert response.data["count"] == 1
|
| 176 |
+
assert response.data["results"][0]["section_name"] == "Tower 1-2"
|
| 177 |
+
|
| 178 |
+
def test_retrieve_wall_section(self, api_client: APIClient) -> None:
|
| 179 |
+
"""Test retrieving a specific wall section."""
|
| 180 |
+
profile_url = reverse("profile-list")
|
| 181 |
+
profile = api_client.post(
|
| 182 |
+
profile_url,
|
| 183 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 184 |
+
format="json",
|
| 185 |
+
).data
|
| 186 |
+
|
| 187 |
+
section_url = reverse("wallsection-list")
|
| 188 |
+
section = api_client.post(
|
| 189 |
+
section_url,
|
| 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
|
| 198 |
+
|
| 199 |
+
detail_url = reverse("wallsection-detail", kwargs={"pk": section["id"]})
|
| 200 |
+
response = api_client.get(detail_url)
|
| 201 |
+
|
| 202 |
+
assert response.status_code == status.HTTP_200_OK
|
| 203 |
+
assert response.data["id"] == section["id"]
|
| 204 |
+
assert response.data["section_name"] == "Tower 1-2"
|
| 205 |
+
|
| 206 |
+
def test_update_wall_section(self, api_client: APIClient) -> None:
|
| 207 |
+
"""Test updating a wall section."""
|
| 208 |
+
profile_url = reverse("profile-list")
|
| 209 |
+
profile = api_client.post(
|
| 210 |
+
profile_url,
|
| 211 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 212 |
+
format="json",
|
| 213 |
+
).data
|
| 214 |
+
|
| 215 |
+
section_url = reverse("wallsection-list")
|
| 216 |
+
section = api_client.post(
|
| 217 |
+
section_url,
|
| 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
|
| 226 |
+
|
| 227 |
+
detail_url = reverse("wallsection-detail", kwargs={"pk": section["id"]})
|
| 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."""
|
| 242 |
+
profile_url = reverse("profile-list")
|
| 243 |
+
profile = api_client.post(
|
| 244 |
+
profile_url,
|
| 245 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 246 |
+
format="json",
|
| 247 |
+
).data
|
| 248 |
+
|
| 249 |
+
section_url = reverse("wallsection-list")
|
| 250 |
+
section = api_client.post(
|
| 251 |
+
section_url,
|
| 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
|
| 260 |
+
|
| 261 |
+
detail_url = reverse("wallsection-detail", kwargs={"pk": section["id"]})
|
| 262 |
+
response = api_client.delete(detail_url)
|
| 263 |
+
|
| 264 |
+
assert response.status_code == status.HTTP_204_NO_CONTENT
|
| 265 |
+
|
| 266 |
+
retrieve_response = api_client.get(detail_url)
|
| 267 |
+
assert retrieve_response.status_code == status.HTTP_404_NOT_FOUND
|
| 268 |
+
|
| 269 |
+
def test_delete_profile_cascades_to_sections(self, api_client: APIClient) -> None:
|
| 270 |
+
"""Test deleting a profile also deletes associated wall sections."""
|
| 271 |
+
profile_url = reverse("profile-list")
|
| 272 |
+
profile = api_client.post(
|
| 273 |
+
profile_url,
|
| 274 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 275 |
+
format="json",
|
| 276 |
+
).data
|
| 277 |
+
|
| 278 |
+
section_url = reverse("wallsection-list")
|
| 279 |
+
section = api_client.post(
|
| 280 |
+
section_url,
|
| 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
|
| 289 |
+
|
| 290 |
+
profile_detail_url = reverse("profile-detail", kwargs={"pk": profile["id"]})
|
| 291 |
+
api_client.delete(profile_detail_url)
|
| 292 |
+
|
| 293 |
+
section_detail_url = reverse("wallsection-detail", kwargs={"pk": section["id"]})
|
| 294 |
+
response = api_client.get(section_detail_url)
|
| 295 |
+
assert response.status_code == status.HTTP_404_NOT_FOUND
|
| 296 |
+
|
| 297 |
+
def test_wall_section_requires_profile(self, api_client: APIClient) -> None:
|
| 298 |
+
"""Test creating section without profile returns 400."""
|
| 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")
|
| 307 |
+
|
| 308 |
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
| 309 |
+
assert "profile" in response.data
|
| 310 |
+
|
| 311 |
+
def test_wall_section_requires_section_name(self, api_client: APIClient) -> None:
|
| 312 |
+
"""Test creating section without section_name returns 400."""
|
| 313 |
+
profile_url = reverse("profile-list")
|
| 314 |
+
profile = api_client.post(
|
| 315 |
+
profile_url,
|
| 316 |
+
{"name": "Northern Watch", "team_lead": "Jon Snow"},
|
| 317 |
+
format="json",
|
| 318 |
+
).data
|
| 319 |
+
|
| 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")
|
| 328 |
+
|
| 329 |
+
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
| 330 |
+
assert "section_name" in response.data
|