AIVLAD commited on
Commit
5422df1
·
1 Parent(s): f75bff1

fix: add database migrations at container startup and deployment tests

Browse files

- Add startup script that runs migrations before gunicorn
- Switch Dockerfile to Alpine Python for nginx compatibility
- Add comprehensive deployment tests (12 passing tests)
- Expand ALLOWED_HOSTS for Docker networking
- Add .cache/ to .gitignore
- Add docker-compose.yml for local testing
- Remove tracked __pycache__ file

.gitignore CHANGED
@@ -8,6 +8,7 @@ frontend/dist/
8
  .coverage
9
  __pycache__/
10
  *.pyc
 
11
 
12
  # Database
13
  db.sqlite3
 
8
  .coverage
9
  __pycache__/
10
  *.pyc
11
+ .cache/
12
 
13
  # Database
14
  db.sqlite3
Dockerfile CHANGED
@@ -16,10 +16,13 @@ COPY frontend/ ./
16
  RUN npm run build
17
 
18
  # Stage 2: Setup Python/Django backend
19
- FROM python:3.12-slim AS backend
20
 
21
  WORKDIR /app
22
 
 
 
 
23
  # Install uv package manager
24
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
25
 
@@ -32,15 +35,11 @@ COPY pyproject.toml ./
32
  COPY uv.lock ./
33
 
34
  # Install dependencies using uv
35
- RUN --mount=type=cache,target=/root/.cache/uv \
36
- uv sync --frozen
37
 
38
  # Copy application code
39
  COPY . .
40
 
41
- # Run migrations
42
- RUN /app/.venv/bin/python manage.py migrate
43
-
44
  # Stage 3: Final image with nginx
45
  FROM nginx:alpine
46
 
@@ -57,15 +56,23 @@ COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html
57
  # Copy nginx configuration
58
  COPY nginx.conf /etc/nginx/nginx.conf
59
 
60
- # Create supervisor config
61
- RUN echo '[supervisord]' > /etc/supervisor/conf.d/supervisord.conf && \
 
 
 
 
62
  echo 'nodaemon=true' >> /etc/supervisor/conf.d/supervisord.conf && \
63
  echo '' >> /etc/supervisor/conf.d/supervisord.conf && \
64
  echo '[program:django]' >> /etc/supervisor/conf.d/supervisord.conf && \
65
- echo 'command=/app/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 2' >> /etc/supervisor/conf.d/supervisord.conf && \
66
  echo 'directory=/app' >> /etc/supervisor/conf.d/supervisord.conf && \
67
  echo 'autostart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
68
  echo 'autorestart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
 
 
 
 
69
  echo '' >> /etc/supervisor/conf.d/supervisord.conf && \
70
  echo '[program:nginx]' >> /etc/supervisor/conf.d/supervisord.conf && \
71
  echo 'command=nginx -g "daemon off;"' >> /etc/supervisor/conf.d/supervisord.conf && \
 
16
  RUN npm run build
17
 
18
  # Stage 2: Setup Python/Django backend
19
+ FROM python:3.12-alpine AS backend
20
 
21
  WORKDIR /app
22
 
23
+ # Install build dependencies for Python packages
24
+ RUN apk add --no-cache gcc musl-dev linux-headers
25
+
26
  # Install uv package manager
27
  COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
28
 
 
35
  COPY uv.lock ./
36
 
37
  # Install dependencies using uv
38
+ RUN uv sync --frozen
 
39
 
40
  # Copy application code
41
  COPY . .
42
 
 
 
 
43
  # Stage 3: Final image with nginx
44
  FROM nginx:alpine
45
 
 
56
  # Copy nginx configuration
57
  COPY nginx.conf /etc/nginx/nginx.conf
58
 
59
+ # Make startup script executable
60
+ RUN chmod +x /app/start-django.sh
61
+
62
+ # Create supervisor config directory and file
63
+ RUN mkdir -p /etc/supervisor/conf.d && \
64
+ echo '[supervisord]' > /etc/supervisor/conf.d/supervisord.conf && \
65
  echo 'nodaemon=true' >> /etc/supervisor/conf.d/supervisord.conf && \
66
  echo '' >> /etc/supervisor/conf.d/supervisord.conf && \
67
  echo '[program:django]' >> /etc/supervisor/conf.d/supervisord.conf && \
68
+ echo 'command=/app/start-django.sh' >> /etc/supervisor/conf.d/supervisord.conf && \
69
  echo 'directory=/app' >> /etc/supervisor/conf.d/supervisord.conf && \
70
  echo 'autostart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
71
  echo 'autorestart=true' >> /etc/supervisor/conf.d/supervisord.conf && \
72
+ echo 'stdout_logfile=/dev/stdout' >> /etc/supervisor/conf.d/supervisord.conf && \
73
+ echo 'stdout_logfile_maxbytes=0' >> /etc/supervisor/conf.d/supervisord.conf && \
74
+ echo 'stderr_logfile=/dev/stderr' >> /etc/supervisor/conf.d/supervisord.conf && \
75
+ echo 'stderr_logfile_maxbytes=0' >> /etc/supervisor/conf.d/supervisord.conf && \
76
  echo '' >> /etc/supervisor/conf.d/supervisord.conf && \
77
  echo '[program:nginx]' >> /etc/supervisor/conf.d/supervisord.conf && \
78
  echo 'command=nginx -g "daemon off;"' >> /etc/supervisor/conf.d/supervisord.conf && \
config/settings/__pycache__/base.cpython-312.pyc DELETED
Binary file (2.38 kB)
 
config/settings/base.py CHANGED
@@ -11,7 +11,13 @@ SECRET_KEY = "django-insecure-demo-key-replace-in-production"
11
 
12
  DEBUG = True
13
 
14
- ALLOWED_HOSTS: list[str] = ["aivlad-demo.hf.space", "localhost", "127.0.0.1"]
 
 
 
 
 
 
15
 
16
  INSTALLED_APPS = [
17
  "django.contrib.auth",
 
11
 
12
  DEBUG = True
13
 
14
+ ALLOWED_HOSTS: list[str] = [
15
+ "aivlad-demo.hf.space",
16
+ "localhost",
17
+ "127.0.0.1",
18
+ "192.168.50.66",
19
+ "wall-api-demo",
20
+ ]
21
 
22
  INSTALLED_APPS = [
23
  "django.contrib.auth",
docker-compose.yml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ services:
2
+ demo:
3
+ image: wall-api:latest
4
+ ports:
5
+ - "7860:7860"
6
+ container_name: wall-api-demo
pytest.ini CHANGED
@@ -20,6 +20,7 @@ markers =
20
  integration: Integration tests (API + DB)
21
  e2e: End-to-end tests (full workflows)
22
  slow: Slow tests (run separately)
 
23
 
24
  testpaths = tests
25
 
 
20
  integration: Integration tests (API + DB)
21
  e2e: End-to-end tests (full workflows)
22
  slow: Slow tests (run separately)
23
+ deployment: Deployment verification tests
24
 
25
  testpaths = tests
26
 
start-django.sh ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ cd /app
5
+
6
+ echo "Running database migrations..."
7
+ /app/.venv/bin/python manage.py migrate --noinput
8
+
9
+ echo "Starting Gunicorn..."
10
+ exec /app/.venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 2
tests/api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """API deployment and integration tests."""
tests/api/test_deployment.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deployment tests for Wall Construction API.
2
+
3
+ Tests that verify the full stack is properly deployed and functional:
4
+ - Database migrations have run successfully
5
+ - API endpoints are accessible and return correct status codes
6
+ - CRUD operations work end-to-end
7
+ - Data persistence works correctly
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+ from django.urls import reverse
14
+ from rest_framework import status
15
+ from rest_framework.test import APIClient
16
+
17
+
18
+ @pytest.mark.django_db
19
+ @pytest.mark.deployment
20
+ class TestDeploymentHealth:
21
+ """Test deployment health and basic API functionality."""
22
+
23
+ def test_api_root_accessible(self, api_client: APIClient) -> None:
24
+ """Test API root endpoint is accessible."""
25
+ url = "/"
26
+
27
+ response = api_client.get(url)
28
+
29
+ assert response.status_code in (
30
+ status.HTTP_200_OK,
31
+ status.HTTP_301_MOVED_PERMANENTLY,
32
+ status.HTTP_302_FOUND,
33
+ )
34
+
35
+ def test_profiles_endpoint_accessible(self, api_client: APIClient) -> None:
36
+ """Test profiles list endpoint returns 200 with empty database."""
37
+ url = reverse("profile-list")
38
+
39
+ response = api_client.get(url)
40
+
41
+ assert response.status_code == status.HTTP_200_OK
42
+ assert "results" in response.data
43
+ assert "count" in response.data
44
+ assert isinstance(response.data["results"], list)
45
+
46
+ def test_wallsections_endpoint_accessible(self, api_client: APIClient) -> None:
47
+ """Test wallsections list endpoint returns 200 with empty database."""
48
+ url = reverse("wallsection-list")
49
+
50
+ response = api_client.get(url)
51
+
52
+ assert response.status_code == status.HTTP_200_OK
53
+ assert "results" in response.data
54
+ assert "count" in response.data
55
+ assert isinstance(response.data["results"], list)
56
+
57
+
58
+ @pytest.mark.django_db
59
+ @pytest.mark.deployment
60
+ class TestDeploymentEndToEnd:
61
+ """Test end-to-end workflows to verify deployment integrity."""
62
+
63
+ def test_create_profile_and_retrieve(self, api_client: APIClient) -> None:
64
+ """Test creating a profile and retrieving it verifies database works."""
65
+ list_url = reverse("profile-list")
66
+
67
+ # Create profile
68
+ create_payload = {
69
+ "name": "Deployment Test Profile",
70
+ "team_lead": "Test Lead",
71
+ "is_active": True,
72
+ }
73
+ create_response = api_client.post(list_url, create_payload, format="json")
74
+
75
+ assert create_response.status_code == status.HTTP_201_CREATED
76
+ assert create_response.data["name"] == "Deployment Test Profile"
77
+ assert "id" in create_response.data
78
+
79
+ profile_id = create_response.data["id"]
80
+
81
+ # Retrieve profile
82
+ detail_url = reverse("profile-detail", kwargs={"pk": profile_id})
83
+ retrieve_response = api_client.get(detail_url)
84
+
85
+ assert retrieve_response.status_code == status.HTTP_200_OK
86
+ assert retrieve_response.data["id"] == profile_id
87
+ assert retrieve_response.data["name"] == "Deployment Test Profile"
88
+ assert retrieve_response.data["team_lead"] == "Test Lead"
89
+
90
+ def test_create_wallsection_with_profile(self, api_client: APIClient) -> None:
91
+ """Test creating wallsection with profile verifies foreign keys work."""
92
+ # Create profile first
93
+ profile_url = reverse("profile-list")
94
+ profile_payload = {"name": "Wall Builder", "team_lead": "Builder Lead"}
95
+ profile_response = api_client.post(
96
+ profile_url,
97
+ profile_payload,
98
+ format="json",
99
+ )
100
+ assert profile_response.status_code == status.HTTP_201_CREATED
101
+ profile_id = profile_response.data["id"]
102
+
103
+ # Create wallsection
104
+ wallsection_url = reverse("wallsection-list")
105
+ wallsection_payload = {
106
+ "profile": profile_id,
107
+ "section_name": "Section 1",
108
+ }
109
+ wallsection_response = api_client.post(
110
+ wallsection_url,
111
+ wallsection_payload,
112
+ format="json",
113
+ )
114
+
115
+ assert wallsection_response.status_code == status.HTTP_201_CREATED
116
+ assert wallsection_response.data["profile"] == profile_id
117
+ assert wallsection_response.data["section_name"] == "Section 1"
118
+ assert "id" in wallsection_response.data
119
+
120
+ def test_list_profiles_after_creation(self, api_client: APIClient) -> None:
121
+ """Test listing profiles returns created profiles."""
122
+ url = reverse("profile-list")
123
+
124
+ # Create multiple profiles
125
+ for i in range(3):
126
+ payload = {
127
+ "name": f"Profile {i}",
128
+ "team_lead": f"Lead {i}",
129
+ }
130
+ create_response = api_client.post(url, payload, format="json")
131
+ assert create_response.status_code == status.HTTP_201_CREATED
132
+
133
+ # List all profiles
134
+ list_response = api_client.get(url)
135
+
136
+ assert list_response.status_code == status.HTTP_200_OK
137
+ assert list_response.data["count"] == 3
138
+ assert len(list_response.data["results"]) == 3
139
+
140
+ def test_update_profile(self, api_client: APIClient) -> None:
141
+ """Test updating a profile verifies write operations work."""
142
+ # Create profile
143
+ list_url = reverse("profile-list")
144
+ create_payload = {"name": "Original Name", "team_lead": "Original Lead"}
145
+ create_response = api_client.post(list_url, create_payload, format="json")
146
+ assert create_response.status_code == status.HTTP_201_CREATED
147
+ profile_id = create_response.data["id"]
148
+
149
+ # Update profile
150
+ detail_url = reverse("profile-detail", kwargs={"pk": profile_id})
151
+ update_payload = {
152
+ "name": "Updated Name",
153
+ "team_lead": "Updated Lead",
154
+ "is_active": False,
155
+ }
156
+ update_response = api_client.put(detail_url, update_payload, format="json")
157
+
158
+ assert update_response.status_code == status.HTTP_200_OK
159
+ assert update_response.data["name"] == "Updated Name"
160
+ assert update_response.data["team_lead"] == "Updated Lead"
161
+ assert update_response.data["is_active"] is False
162
+
163
+ def test_delete_profile(self, api_client: APIClient) -> None:
164
+ """Test deleting a profile verifies delete operations work."""
165
+ # Create profile
166
+ list_url = reverse("profile-list")
167
+ create_payload = {"name": "To Delete", "team_lead": "Delete Lead"}
168
+ create_response = api_client.post(list_url, create_payload, format="json")
169
+ assert create_response.status_code == status.HTTP_201_CREATED
170
+ profile_id = create_response.data["id"]
171
+
172
+ # Delete profile
173
+ detail_url = reverse("profile-detail", kwargs={"pk": profile_id})
174
+ delete_response = api_client.delete(detail_url)
175
+
176
+ assert delete_response.status_code == status.HTTP_204_NO_CONTENT
177
+
178
+ # Verify deletion
179
+ retrieve_response = api_client.get(detail_url)
180
+ assert retrieve_response.status_code == status.HTTP_404_NOT_FOUND
181
+
182
+
183
+ @pytest.mark.django_db
184
+ @pytest.mark.deployment
185
+ class TestDatabaseMigrations:
186
+ """Test that database migrations have been applied correctly."""
187
+
188
+ def test_profile_model_exists(self, api_client: APIClient) -> None:
189
+ """Test Profile model table exists and is accessible."""
190
+ url = reverse("profile-list")
191
+
192
+ response = api_client.get(url)
193
+
194
+ assert response.status_code == status.HTTP_200_OK
195
+
196
+ def test_wallsection_model_exists(self, api_client: APIClient) -> None:
197
+ """Test WallSection model table exists and is accessible."""
198
+ url = reverse("wallsection-list")
199
+
200
+ response = api_client.get(url)
201
+
202
+ assert response.status_code == status.HTTP_200_OK
203
+
204
+ def test_profile_fields_exist(self, api_client: APIClient) -> None:
205
+ """Test Profile model has all required fields."""
206
+ url = reverse("profile-list")
207
+ payload = {
208
+ "name": "Field Test",
209
+ "team_lead": "Test Lead",
210
+ "is_active": True,
211
+ }
212
+
213
+ response = api_client.post(url, payload, format="json")
214
+
215
+ assert response.status_code == status.HTTP_201_CREATED
216
+ assert "id" in response.data
217
+ assert "name" in response.data
218
+ assert "team_lead" in response.data
219
+ assert "is_active" in response.data
220
+ assert "created_at" in response.data
221
+ assert "updated_at" in response.data
222
+
223
+ def test_wallsection_fields_exist(self, api_client: APIClient) -> None:
224
+ """Test WallSection model has all required fields."""
225
+ # Create profile first
226
+ profile_url = reverse("profile-list")
227
+ profile_response = api_client.post(
228
+ profile_url,
229
+ {"name": "Test", "team_lead": "Lead"},
230
+ format="json",
231
+ )
232
+ profile_id = profile_response.data["id"]
233
+
234
+ # Create wallsection
235
+ url = reverse("wallsection-list")
236
+ payload = {
237
+ "profile": profile_id,
238
+ "section_name": "Test Section",
239
+ }
240
+
241
+ response = api_client.post(url, payload, format="json")
242
+
243
+ assert response.status_code == status.HTTP_201_CREATED
244
+ assert "id" in response.data
245
+ assert "profile" in response.data
246
+ assert "section_name" in response.data
247
+ assert "created_at" in response.data