diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bda8eb3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: hsdb_test + POSTGRES_USER: hsuser + POSTGRES_PASSWORD: hspass + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U hsuser -d hsdb_test" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + SECRET_KEY: ci-secret-key-not-used-in-prod + DEBUG: "True" + ALLOWED_HOSTS: localhost,127.0.0.1 + DATABASE_URL: postgres://hsuser:hspass@localhost:5432/hsdb_test + DJANGO_SETTINGS_MODULE: core.settings.development + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install dependencies + run: pip install -r requirements.txt + + - name: Enable pg_trgm extension + run: psql postgres://hsuser:hspass@localhost:5432/hsdb_test -c "CREATE EXTENSION IF NOT EXISTS pg_trgm;" + + - name: Run migrations + run: python manage.py migrate --noinput + + - name: Run tests + run: python manage.py test app --verbosity=2 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b79bcb9..3ff9f55 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,55 +1,50 @@ name: Deploy Production on: -workflow_run: -workflows: ["CI"] -branches: [main] -types: -- completed + workflow_run: + workflows: ["CI"] + branches: [main] + types: + - completed jobs: -deploy: -if: ${{ github.event.workflow_run.conclusion == 'success' }} - -runs-on: ubuntu-latest - -steps: - - name: Deploy to VPS - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.VPS_HOST }} - username: ${{ secrets.VPS_USER }} - key: ${{ secrets.VPS_SSH_KEY }} - port: ${{ secrets.VPS_PORT }} - - script: | - set -e - - cd /Hs_codes_api - - echo "Pulling latest images..." - docker compose pull - - echo "Starting updated containers..." - docker compose up -d - - echo "Waiting for application..." - sleep 15 - - echo "Running migrations..." - docker compose exec -T web python manage.py migrate --noinput - - echo "Checking health endpoint..." - - for i in $(seq 1 20); do - if curl -fsS http://localhost:8001/api/v1/health/ > /dev/null; then - echo "Health check passed" - exit 0 - fi - - echo "Waiting for healthy container..." - sleep 5 - done - - echo "Health check failed" - exit 1 + deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + + steps: + - name: Deploy to VPS + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + port: ${{ secrets.VPS_PORT }} + script: | + set -e + cd /Hs_codes_api + + echo "Pulling latest code..." + git pull origin main + + echo "Building and starting containers..." + docker compose up -d --build + + echo "Waiting for application to start..." + sleep 15 + + echo "Running migrations..." + docker compose exec -T web python manage.py migrate --noinput + + echo "Checking health endpoint..." + for i in $(seq 1 20); do + if curl -fsS http://localhost:8001/api/v1/health/ > /dev/null; then + echo "Health check passed" + exit 0 + fi + echo "Attempt $i/20 — waiting..." + sleep 5 + done + + echo "Health check failed after 100s" + exit 1 diff --git a/.gitignore b/.gitignore index 5f8f5e1..24be7b2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ build/ *.egg-info/ celerybeat-schedule +core/media/ +logfile diff --git a/Dockerfile b/Dockerfile index d6810c1..b6adad6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,7 +24,8 @@ WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 -RUN groupadd -r appuser && useradd -r -g appuser appuser +RUN groupadd -r appuser && \ + useradd -r -g appuser -m -d /home/appuser appuser COPY --from=builder /install /usr/local diff --git a/app/migrations/0001_initial.py b/app/migrations/0001_initial.py index 86c1703..a9d9f2b 100644 --- a/app/migrations/0001_initial.py +++ b/app/migrations/0001_initial.py @@ -19,7 +19,6 @@ class Migration(migrations.Migration): operations = [ TrigramExtension(), - migrations.CreateModel( name="Category", fields=[ @@ -65,7 +64,9 @@ class Migration(migrations.Migration): ("password", models.CharField(max_length=128, verbose_name="password")), ( "last_login", - models.DateTimeField(blank=True, null=True, verbose_name="last login"), + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), ), ( "is_superuser", @@ -92,15 +93,21 @@ class Migration(migrations.Migration): ), ( "first_name", - models.CharField(blank=True, max_length=150, verbose_name="first name"), + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), ), ( "last_name", - models.CharField(blank=True, max_length=150, verbose_name="last name"), + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), ), ( "email", - models.EmailField(blank=True, max_length=254, verbose_name="email address"), + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), ), ( "is_staff", @@ -120,7 +127,9 @@ class Migration(migrations.Migration): ), ( "date_joined", - models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"), + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), ), ( "role", @@ -210,4 +219,4 @@ class Migration(migrations.Migration): ], }, ), - ] \ No newline at end of file + ] diff --git a/app/services/category_service.py b/app/services/category_service.py index ce685a3..80a6a22 100644 --- a/app/services/category_service.py +++ b/app/services/category_service.py @@ -103,7 +103,7 @@ def get_or_create_category_for_hs_code(hs_code: str) -> "Category": """Derive category from HS code chapter prefix and get or create it.""" - from .models import Category + from app.models import Category chapter = hs_code[:2] name = HS_CHAPTER_CATEGORIES.get(chapter, f"Chapter {chapter}") diff --git a/app/services/file_upload_service.py b/app/services/file_upload_service.py index f7fb833..cd3b17a 100644 --- a/app/services/file_upload_service.py +++ b/app/services/file_upload_service.py @@ -18,17 +18,20 @@ class UploadResult: def process_hs_code_csv(uploaded_file) -> UploadResult: text = _decode_file(uploaded_file) rows = _parse_csv(text) - - print(text) hs_code_file = HsCodeFile.objects.create(hs_code_file=uploaded_file) objects, skipped_blank = _build_objects(rows, hs_code_file) - created_objects = HsCode.objects.bulk_create(objects, ignore_conflicts=True) + incoming_codes = [obj.hs_code for obj in objects] + existing_count = HsCode.objects.filter(hs_code__in=incoming_codes).count() + + HsCode.objects.bulk_create(objects, ignore_conflicts=True) + + created = len(objects) - existing_count return UploadResult( - created=len(created_objects), - skipped_duplicates=len(objects) - len(created_objects), + created=created, + skipped_duplicates=existing_count, skipped_blank_rows=skipped_blank, total_rows=len(rows), hs_code_file_id=hs_code_file.pk, @@ -83,4 +86,4 @@ def _build_objects(rows, hs_code_file): ) ) - return objects, skipped_blank \ No newline at end of file + return objects, skipped_blank diff --git a/app/tests.py b/app/tests.py index 7ce503c..061e02e 100644 --- a/app/tests.py +++ b/app/tests.py @@ -1,3 +1,419 @@ +import csv +import io + from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from app.models import HsCode, HsCodeFile, User +from app.services.file_upload_service import ( + UploadResult, + _build_objects, + _decode_file, + _parse_csv, + process_hs_code_csv, +) + + +def _make_csv_file(rows: list[dict], filename="hs_codes.csv") -> io.BytesIO: + """Build an in-memory CSV file with the correct headers.""" + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=["HS CODE", "GOODS DESCRIPTION"]) + writer.writeheader() + writer.writerows(rows) + encoded = buf.getvalue().encode("utf-8") + f = io.BytesIO(encoded) + f.name = filename + return f + + +def _make_uploaded_file(rows: list[dict]): + """Return a Django-compatible InMemoryUploadedFile-like object.""" + from django.core.files.uploadedfile import InMemoryUploadedFile + + raw = _make_csv_file(rows) + return InMemoryUploadedFile( + file=raw, + field_name="file", + name="hs_codes.csv", + content_type="text/csv", + size=raw.getbuffer().nbytes, + charset="utf-8", + ) + + +SAMPLE_ROWS = [ + {"HS CODE": "0101.21.00", "GOODS DESCRIPTION": "Live pure-bred breeding horses"}, + {"HS CODE": "0101.29.00", "GOODS DESCRIPTION": "Other live horses"}, + { + "HS CODE": "0201.10.00", + "GOODS DESCRIPTION": "Carcasses and half-carcasses of bovine", + }, +] + + +class DecodeFileTest(TestCase): + def test_utf8_file_decoded(self): + f = io.BytesIO(b"hello world") + f.name = "test.csv" + # Attach a read method compatible with _decode_file + result = _decode_file(f) + self.assertEqual(result, "hello world") + + def test_utf8_sig_bom_stripped(self): + bom = b"\xef\xbb\xbfHS CODE,GOODS DESCRIPTION\n" + f = io.BytesIO(bom) + f.name = "bom.csv" + result = _decode_file(f) + self.assertFalse(result.startswith("\ufeff")) + self.assertTrue(result.startswith("HS CODE")) + + def test_bad_encoding_raises_value_error(self): + bad = io.BytesIO(b"\xff\xfe invalid latin sequence \x80\x81") + bad.name = "bad.csv" + with self.assertRaises(ValueError) as ctx: + _decode_file(bad) + self.assertIn("encoding", str(ctx.exception).lower()) + + +class ParseCsvTest(TestCase): + def _csv_text(self, rows, headers=None): + headers = headers or ["HS CODE", "GOODS DESCRIPTION"] + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=headers) + writer.writeheader() + writer.writerows(rows) + return buf.getvalue() + + def test_valid_csv_returns_rows(self): + text = self._csv_text(SAMPLE_ROWS) + rows = _parse_csv(text) + self.assertEqual(len(rows), 3) + + def test_missing_hs_code_column_raises(self): + text = self._csv_text( + [{"GOODS DESCRIPTION": "something"}], + headers=["GOODS DESCRIPTION"], + ) + with self.assertRaises(ValueError) as ctx: + _parse_csv(text) + self.assertIn("HS CODE", str(ctx.exception)) + + def test_missing_description_column_raises(self): + text = self._csv_text( + [{"HS CODE": "0101.21.00"}], + headers=["HS CODE"], + ) + with self.assertRaises(ValueError): + _parse_csv(text) + + def test_empty_csv_raises(self): + buf = io.StringIO() + writer = csv.DictWriter(buf, fieldnames=["HS CODE", "GOODS DESCRIPTION"]) + writer.writeheader() + with self.assertRaises(ValueError) as ctx: + _parse_csv(buf.getvalue()) + self.assertIn("no data", str(ctx.exception).lower()) + + +class BuildObjectsTest(TestCase): + def setUp(self): + self.hs_file = HsCodeFile.objects.create(hs_code_file="hs_code/test.csv") + + def test_builds_correct_count(self): + rows = [ + {"HS CODE": "0101.21.00", "GOODS DESCRIPTION": "Horses"}, + {"HS CODE": "0201.10.00", "GOODS DESCRIPTION": "Beef carcasses"}, + ] + objects, skipped = _build_objects(rows, self.hs_file) + self.assertEqual(len(objects), 2) + self.assertEqual(skipped, 0) + + def test_skips_blank_hs_code(self): + rows = [ + {"HS CODE": "", "GOODS DESCRIPTION": "Missing code"}, + {"HS CODE": "0101.21.00", "GOODS DESCRIPTION": "Horses"}, + ] + objects, skipped = _build_objects(rows, self.hs_file) + self.assertEqual(len(objects), 1) + self.assertEqual(skipped, 1) + + def test_skips_blank_description(self): + rows = [ + {"HS CODE": "0101.21.00", "GOODS DESCRIPTION": ""}, + ] + objects, skipped = _build_objects(rows, self.hs_file) + self.assertEqual(len(objects), 0) + self.assertEqual(skipped, 1) + + def test_skips_whitespace_only_fields(self): + rows = [ + {"HS CODE": " ", "GOODS DESCRIPTION": " "}, + ] + objects, skipped = _build_objects(rows, self.hs_file) + self.assertEqual(skipped, 1) + + def test_objects_have_correct_hs_code_file(self): + rows = [{"HS CODE": "0101.21.00", "GOODS DESCRIPTION": "Horses"}] + objects, _ = _build_objects(rows, self.hs_file) + self.assertEqual(objects[0].hs_code_file, self.hs_file) + + +class ProcessHsCodeCsvTest(TestCase): + def test_creates_records_and_returns_result(self): + uploaded = _make_uploaded_file(SAMPLE_ROWS) + result = process_hs_code_csv(uploaded_file=uploaded) + + self.assertIsInstance(result, UploadResult) + self.assertEqual(result.created, 3) + self.assertEqual(result.skipped_duplicates, 0) + self.assertEqual(result.skipped_blank_rows, 0) + self.assertEqual(result.total_rows, 3) + self.assertEqual(HsCode.objects.count(), 3) + + def test_duplicate_codes_are_skipped(self): + # First upload + uploaded = _make_uploaded_file(SAMPLE_ROWS) + process_hs_code_csv(uploaded_file=uploaded) + + # Second upload with same codes + uploaded2 = _make_uploaded_file(SAMPLE_ROWS) + result = process_hs_code_csv(uploaded_file=uploaded2) + + self.assertEqual(result.skipped_duplicates, 3) + self.assertEqual(HsCode.objects.count(), 3) # no new records + + def test_partial_duplicates_counted_correctly(self): + uploaded = _make_uploaded_file(SAMPLE_ROWS[:2]) + process_hs_code_csv(uploaded_file=uploaded) + + # Upload all 3; 2 are duplicates + uploaded2 = _make_uploaded_file(SAMPLE_ROWS) + result = process_hs_code_csv(uploaded_file=uploaded2) + + self.assertEqual(result.created, 1) + self.assertEqual(result.skipped_duplicates, 2) + + def test_creates_hs_code_file_record(self): + uploaded = _make_uploaded_file(SAMPLE_ROWS) + process_hs_code_csv(uploaded_file=uploaded) + self.assertEqual(HsCodeFile.objects.count(), 1) + + def test_invalid_csv_raises_value_error(self): + f = io.BytesIO(b"WRONG_COL,ANOTHER_COL\nfoo,bar") + f.name = "bad.csv" + from django.core.files.uploadedfile import InMemoryUploadedFile + + bad_file = InMemoryUploadedFile(f, "file", "bad.csv", "text/csv", 20, "utf-8") + with self.assertRaises(ValueError): + process_hs_code_csv(uploaded_file=bad_file) + + +class HsCodeUploadViewTest(APITestCase): + url = "/api/v1/hs-codes/upload/" + + def _post_csv(self, rows): + csv_file = _make_csv_file(rows) + return self.client.post( + self.url, + {"file": csv_file}, + format="multipart", + ) + + def test_upload_valid_csv_returns_201(self): + response = self._post_csv(SAMPLE_ROWS) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_upload_response_contains_expected_keys(self): + response = self._post_csv(SAMPLE_ROWS) + data = response.json() + self.assertIn("created", data) + self.assertIn("skipped_duplicates", data) + self.assertIn("skipped_blank_rows", data) + self.assertIn("total_rows", data) + self.assertIn("hs_code_file_id", data) + + def test_upload_creates_correct_count(self): + response = self._post_csv(SAMPLE_ROWS) + self.assertEqual(response.json()["created"], 3) + self.assertEqual(HsCode.objects.count(), 3) + + def test_upload_no_file_returns_400(self): + response = self.client.post(self.url, {}, format="multipart") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_upload_wrong_columns_returns_400(self): + buf = io.BytesIO(b"WRONG,COLS\nfoo,bar") + buf.name = "bad.csv" + response = self.client.post(self.url, {"file": buf}, format="multipart") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn("error", response.json()) + + def test_upload_empty_csv_body_returns_400(self): + buf = io.BytesIO(b"HS CODE,GOODS DESCRIPTION\n") + buf.name = "empty.csv" + response = self.client.post(self.url, {"file": buf}, format="multipart") + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_duplicate_upload_returns_201_with_skipped(self): + self._post_csv(SAMPLE_ROWS) + response = self._post_csv(SAMPLE_ROWS) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json()["skipped_duplicates"], 3) + self.assertEqual(response.json()["created"], 0) + + +class HsCodeSearchViewTest(APITestCase): + url = "/api/v1/hs-codes/" + + @classmethod + def setUpTestData(cls): + hs_file = HsCodeFile.objects.create(hs_code_file="hs_code/test.csv") + HsCode.objects.bulk_create( + [ + HsCode( + hs_code="0101.21.00", + description="Live pure-bred breeding horses", + hs_code_file=hs_file, + ), + HsCode( + hs_code="0101.29.00", + description="Other live horses", + hs_code_file=hs_file, + ), + HsCode( + hs_code="0201.10.00", + description="Carcasses and half-carcasses of bovine animals", + hs_code_file=hs_file, + ), + HsCode( + hs_code="8471.30.00", + description="Portable automatic data processing machines", + hs_code_file=hs_file, + ), + ] + ) + + def test_search_missing_q_returns_400(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_search_empty_q_returns_400(self): + response = self.client.get(self.url, {"q": ""}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_search_returns_200(self): + response = self.client.get(self.url, {"q": "horses"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_search_results_are_list(self): + response = self.client.get(self.url, {"q": "horses"}) + self.assertIsInstance(response.json(), list) + + def test_search_hs_code_direct_match(self): + response = self.client.get(self.url, {"q": "0101.21.00"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + codes = [r["hs_code"] for r in response.json()] + self.assertIn("0101.21.00", codes) + + def test_search_no_results_returns_empty_list(self): + response = self.client.get(self.url, {"q": "xyznonexistentterm12345"}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.json(), []) + + def test_search_result_has_expected_fields(self): + response = self.client.get(self.url, {"q": "horses"}) + results = response.json() + if results: + self.assertIn("hs_code", results[0]) + self.assertIn("description", results[0]) + + +class HealthCheckViewTest(APITestCase): + url = "/api/v1/health/" + + def test_health_returns_200(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_health_returns_correct_body(self): + response = self.client.get(self.url) + self.assertEqual(response.json(), {"status": "healthy"}) + + def test_health_requires_no_auth(self): + # Even if session auth is default, health should be open + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + +class HsCodeModelTest(TestCase): + def setUp(self): + self.hs_file = HsCodeFile.objects.create(hs_code_file="hs_code/test.csv") + + def test_unique_constraint_on_hs_code(self): + from django.db import IntegrityError + + HsCode.objects.create( + hs_code="0101.21.00", + description="Horses", + hs_code_file=self.hs_file, + ) + with self.assertRaises(IntegrityError): + HsCode.objects.create( + hs_code="0101.21.00", + description="Different description", + hs_code_file=self.hs_file, + ) + + def test_cascade_delete_removes_hs_codes(self): + HsCode.objects.create( + hs_code="0101.21.00", + description="Horses", + hs_code_file=self.hs_file, + ) + self.assertEqual(HsCode.objects.count(), 1) + self.hs_file.delete() + self.assertEqual(HsCode.objects.count(), 0) + + +class IsAdminOrStaffPermissionTest(TestCase): + def test_admin_role_allowed(self): + from app.permissions import IsAdminOrStaff + from unittest.mock import MagicMock + + perm = IsAdminOrStaff() + request = MagicMock() + request.user.is_authenticated = True + request.user.role = "Admin" + self.assertTrue(perm.has_permission(request, MagicMock())) + + def test_staff_role_allowed(self): + from app.permissions import IsAdminOrStaff + from unittest.mock import MagicMock + + perm = IsAdminOrStaff() + request = MagicMock() + request.user.is_authenticated = True + request.user.role = "Staff" + self.assertTrue(perm.has_permission(request, MagicMock())) + + def test_unknown_role_denied(self): + from app.permissions import IsAdminOrStaff + from unittest.mock import MagicMock + + perm = IsAdminOrStaff() + request = MagicMock() + request.user.is_authenticated = True + request.user.role = "Guest" + self.assertFalse(perm.has_permission(request, MagicMock())) + + def test_unauthenticated_denied(self): + from app.permissions import IsAdminOrStaff + from unittest.mock import MagicMock -# Create your tests here. + perm = IsAdminOrStaff() + request = MagicMock() + request.user.is_authenticated = False + self.assertFalse(perm.has_permission(request, MagicMock())) diff --git a/app/urls.py b/app/urls.py index f8c75e1..ac43330 100644 --- a/app/urls.py +++ b/app/urls.py @@ -8,7 +8,5 @@ name="hs-code-search", ), path("hs-codes/upload/", HsCodeUploadView.as_view(), name="hscode-upload"), - path("health/", - HealthCheckView.as_view(), - name="health") + path("health/", HealthCheckView.as_view(), name="health"), ] diff --git a/app/views.py b/app/views.py index 1f02cb7..46f5f01 100644 --- a/app/views.py +++ b/app/views.py @@ -21,7 +21,7 @@ class HsCodeUploadView(APIView): parser_classes = [MultiPartParser] - # permission_classes = [IsAdminOrStaff] + permission_classes = [IsAdminOrStaff] def post(self, request): serializer = HsCodeUploadSerializer(data=request.data) @@ -75,15 +75,6 @@ def get_queryset(self): .filter(similarity__gte=threshold) .order_by("-similarity") ) - - logger.info( - "HS search completed | query={q} | results={count}", - q=q, - count=queryset.count(), - ) - - logger.info(queryset) - return queryset except Exception as e: diff --git a/docker-compose.yml b/docker-compose.yml index a3e5ad7..1eb9310 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,65 +1,54 @@ -version: "3.9" - services: -db: -image: postgres:16-alpine -container_name: hs_postgres - -restart: unless-stopped - -environment: - POSTGRES_DB: ${DB_NAME} - POSTGRES_USER: ${DB_USER} - POSTGRES_PASSWORD: ${DB_PASSWORD} - -volumes: - - postgres_data:/var/lib/postgresql/data - -healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] - interval: 10s - timeout: 5s - retries: 5 - start_period: 10s - -networks: - - backend - -web: -image: ghcr.io/casymoyo-spec/hs-api:latest -container_name: hs_api - -restart: unless-stopped - -env_file: - - .env - -depends_on: db: - condition: service_healthy - -ports: - - "8001:8001" - -healthcheck: - test: - [ - "CMD", - "python", - "-c", - "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health/')" - ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 30s - -networks: - - backend + image: postgres:16-alpine + container_name: hs_postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - backend + + web: + build: + context: . + dockerfile: Dockerfile + container_name: hs_api + restart: unless-stopped + env_file: + - .env + depends_on: + db: + condition: service_healthy + ports: + - "8001:8001" + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health/')" + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - backend volumes: -postgres_data: + postgres_data: networks: -backend: -driver: bridge + backend: + driver: bridge diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 index 8cd024b..25cfea1 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,5 +6,5 @@ echo "Running migrations..." python manage.py migrate --noinput echo "Starting Gunicorn..." -exec gunicorn config.wsgi:application \ +exec gunicorn core.wsgi:application \ --config gunicorn.conf.py diff --git a/gunicorn.conf.py b/gunicorn.conf.py index 59b7a37..ee9fd67 100644 --- a/gunicorn.conf.py +++ b/gunicorn.conf.py @@ -1,9 +1,8 @@ import os -from decouple import config bind = "0.0.0.0:8001" -workers = int(config("GUNICORN_WORKERS", "3")) +workers = int(os.getenv("GUNICORN_WORKERS", "3")) timeout = 120 diff --git a/requirements.txt b/requirements.txt index f3dfa8c..a512000 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,8 +13,8 @@ inflection==0.5.1 jsonschema==4.26.0 jsonschema-specifications==2025.9.1 packaging==26.2 -#psycopg==3.3.4 -#psycopg-binary==3.3.4 +psycopg==3.3.4 +psycopg-binary==3.3.4 PyJWT==2.13.0 python-decouple==3.8 python-dotenv==1.2.2 @@ -26,3 +26,4 @@ sqlparse==0.5.5 typing_extensions==4.15.0 uritemplate==4.2.0 whitenoise==6.12.0 +loguru