From 29d154e140a79dbf8d68167a7748272ea26afc1e Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 16:32:20 -0500 Subject: [PATCH] feat(#49): add product provenance (suppliers, organizations, geography) - Country, Department, Municipality models (municipality unique per department) - Organization and Supplier models, Supplier links to organization and municipality - Product.suppliers M2M to link products to one or more suppliers - Authenticated CRUD APIs for organizations, suppliers, countries, departments and municipalities (write only for administrators) - product_provenance domain data embedded in sale/catalog summaries (public and authenticated) - seed_geography endpoint (admin, idempotent) and scripts/seed_geography.py using pycountry + DANE municipalities CSV - TDD tests for models, API permissions/CRUD and summary provenance data --- tienda_ilusion/don_confiao/api/__init__.py | 15 + tienda_ilusion/don_confiao/api/provenance.py | 112 ++++++ ...zation_department_municipality_and_more.py | 101 ++++++ tienda_ilusion/don_confiao/models/__init__.py | 11 +- .../don_confiao/models/geography.py | 54 +++ tienda_ilusion/don_confiao/models/products.py | 3 + .../don_confiao/models/provenance.py | 53 +++ .../don_confiao/serializers/__init__.py | 9 + .../don_confiao/serializers/geography.py | 21 ++ .../don_confiao/serializers/products.py | 5 + .../don_confiao/serializers/provenance.py | 93 +++++ .../don_confiao/serializers/sales.py | 45 ++- .../don_confiao/services/provenance.py | 64 ++++ .../tests/test_geography_models.py | 109 ++++++ .../don_confiao/tests/test_provenance_api.py | 325 ++++++++++++++++++ .../tests/test_provenance_graphs.py | 163 +++++++++ .../tests/test_provenance_models.py | 126 +++++++ tienda_ilusion/don_confiao/urls.py | 17 + 18 files changed, 1321 insertions(+), 5 deletions(-) create mode 100644 tienda_ilusion/don_confiao/api/provenance.py create mode 100644 tienda_ilusion/don_confiao/migrations/0053_country_organization_department_municipality_and_more.py create mode 100644 tienda_ilusion/don_confiao/models/geography.py create mode 100644 tienda_ilusion/don_confiao/models/provenance.py create mode 100644 tienda_ilusion/don_confiao/serializers/geography.py create mode 100644 tienda_ilusion/don_confiao/serializers/provenance.py create mode 100644 tienda_ilusion/don_confiao/services/provenance.py create mode 100644 tienda_ilusion/don_confiao/tests/test_geography_models.py create mode 100644 tienda_ilusion/don_confiao/tests/test_provenance_api.py create mode 100644 tienda_ilusion/don_confiao/tests/test_provenance_graphs.py create mode 100644 tienda_ilusion/don_confiao/tests/test_provenance_models.py diff --git a/tienda_ilusion/don_confiao/api/__init__.py b/tienda_ilusion/don_confiao/api/__init__.py index af7fd12..3c00bdb 100644 --- a/tienda_ilusion/don_confiao/api/__init__.py +++ b/tienda_ilusion/don_confiao/api/__init__.py @@ -20,6 +20,14 @@ from .payments import ( ) from .admin import AdminCodeValidateView from .store_settings import StoreSettingsView +from .provenance import ( + OrganizationView, + SupplierView, + CountryView, + DepartmentView, + MunicipalityView, + SeedGeographyView, +) __all__ = [ # Catalogue Images @@ -49,4 +57,11 @@ __all__ = [ "AdminCodeValidateView", # Store Settings "StoreSettingsView", + # Provenance + "OrganizationView", + "SupplierView", + "CountryView", + "DepartmentView", + "MunicipalityView", + "SeedGeographyView", ] diff --git a/tienda_ilusion/don_confiao/api/provenance.py b/tienda_ilusion/don_confiao/api/provenance.py new file mode 100644 index 0000000..f497580 --- /dev/null +++ b/tienda_ilusion/don_confiao/api/provenance.py @@ -0,0 +1,112 @@ +from rest_framework import viewsets +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier +from ..serializers import ( + CountrySerializer, + DepartmentSerializer, + MunicipalitySerializer, + OrganizationSerializer, + SupplierSerializer, +) +from ..permissions import IsAdministrator, IsNotPublico + + +class _AdminWriteMixin: + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [IsNotPublico()] + return [IsAuthenticated(), IsAdministrator()] + + +class OrganizationView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + + +class SupplierView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Supplier.objects.select_related( + "organization", "municipality" + ) + serializer_class = SupplierSerializer + + +class CountryView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Country.objects.all() + serializer_class = CountrySerializer + + +class DepartmentView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Department.objects.select_related("country") + serializer_class = DepartmentSerializer + + +class MunicipalityView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Municipality.objects.select_related( + "department", "country" + ) + serializer_class = MunicipalitySerializer + + +class SeedGeographyView(APIView): + permission_classes = [IsAuthenticated, IsAdministrator] + + def post(self, request): + country_data = request.data.get("country") or {} + country_name = country_data.get("name") + if not country_name: + return Response( + {"detail": "country.name is required"}, status=400 + ) + + country, _ = Country.objects.get_or_create( + name=country_name, + defaults={"code": country_data.get("code", "")}, + ) + + departments_created = 0 + departments = {} + for department_data in request.data.get("departments", []): + department, created = Department.objects.get_or_create( + name=department_data["name"], + defaults={"country": country}, + ) + departments[department.name] = department + if created: + departments_created += 1 + + municipalities_created = 0 + for municipality_data in request.data.get( + "municipalities", [] + ): + department_name = municipality_data["department"] + department = departments.get(department_name) + if department is None: + department, created = Department.objects.get_or_create( + name=department_name, defaults={"country": country} + ) + departments[department.name] = department + if created: + departments_created += 1 + + _, created = Municipality.objects.get_or_create( + name=municipality_data["name"], + department=department, + defaults={"country": country}, + ) + if created: + municipalities_created += 1 + + return Response( + { + "country": country.name, + "departments_created": departments_created, + "municipalities_created": municipalities_created, + "total_departments": Department.objects.count(), + "total_municipalities": Municipality.objects.count(), + }, + status=200, + ) diff --git a/tienda_ilusion/don_confiao/migrations/0053_country_organization_department_municipality_and_more.py b/tienda_ilusion/don_confiao/migrations/0053_country_organization_department_municipality_and_more.py new file mode 100644 index 0000000..83629ca --- /dev/null +++ b/tienda_ilusion/don_confiao/migrations/0053_country_organization_department_municipality_and_more.py @@ -0,0 +1,101 @@ +# Generated by Django 5.0.6 on 2026-08-15 21:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('don_confiao', '0052_merge_20260810_0351'), + ] + + operations = [ + migrations.CreateModel( + name='Country', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('code', models.CharField(blank=True, max_length=3, null=True, unique=True)), + ], + options={ + 'verbose_name': 'Country', + 'verbose_name_plural': 'Countries', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Organization', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('website', models.CharField(blank=True, max_length=255, null=True)), + ('contact_email', models.CharField(blank=True, max_length=255, null=True)), + ('contact_phone', models.CharField(blank=True, max_length=100, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Organization', + 'verbose_name_plural': 'Organizations', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Department', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('country', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='departments', to='don_confiao.country')), + ], + options={ + 'verbose_name': 'Department', + 'verbose_name_plural': 'Departments', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Municipality', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('country', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='municipalities', to='don_confiao.country')), + ('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='municipalities', to='don_confiao.department')), + ], + options={ + 'verbose_name': 'Municipality', + 'verbose_name_plural': 'Municipalities', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Supplier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('website', models.CharField(blank=True, max_length=255, null=True)), + ('contact_email', models.CharField(blank=True, max_length=255, null=True)), + ('contact_phone', models.CharField(blank=True, max_length=100, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('municipality', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='suppliers', to='don_confiao.municipality')), + ('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='suppliers', to='don_confiao.organization')), + ], + options={ + 'verbose_name': 'Supplier', + 'verbose_name_plural': 'Suppliers', + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='product', + name='suppliers', + field=models.ManyToManyField(blank=True, related_name='products', to='don_confiao.supplier'), + ), + migrations.AddConstraint( + model_name='municipality', + constraint=models.UniqueConstraint(fields=('name', 'department'), name='unique_municipality_name_per_department'), + ), + ] diff --git a/tienda_ilusion/don_confiao/models/__init__.py b/tienda_ilusion/don_confiao/models/__init__.py index 147edfd..ab1ebd9 100644 --- a/tienda_ilusion/don_confiao/models/__init__.py +++ b/tienda_ilusion/don_confiao/models/__init__.py @@ -1,3 +1,12 @@ from .store_settings import StoreSettings +from .geography import Country, Department, Municipality +from .provenance import Organization, Supplier -__all__ = ["StoreSettings"] +__all__ = [ + "StoreSettings", + "Country", + "Department", + "Municipality", + "Organization", + "Supplier", +] diff --git a/tienda_ilusion/don_confiao/models/geography.py b/tienda_ilusion/don_confiao/models/geography.py new file mode 100644 index 0000000..277676c --- /dev/null +++ b/tienda_ilusion/don_confiao/models/geography.py @@ -0,0 +1,54 @@ +from django.db import models + + +class Country(models.Model): + name = models.CharField(max_length=100, unique=True) + code = models.CharField( + max_length=3, unique=True, null=True, blank=True + ) + + class Meta: + verbose_name = "Country" + verbose_name_plural = "Countries" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Department(models.Model): + name = models.CharField(max_length=100, unique=True) + country = models.ForeignKey( + Country, on_delete=models.PROTECT, related_name="departments" + ) + + class Meta: + verbose_name = "Department" + verbose_name_plural = "Departments" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Municipality(models.Model): + name = models.CharField(max_length=100) + department = models.ForeignKey( + Department, on_delete=models.PROTECT, related_name="municipalities" + ) + country = models.ForeignKey( + Country, on_delete=models.PROTECT, related_name="municipalities" + ) + + class Meta: + verbose_name = "Municipality" + verbose_name_plural = "Municipalities" + ordering = ["name"] + constraints = [ + models.UniqueConstraint( + fields=["name", "department"], name="unique_municipality_name_per_department" + ) + ] + + def __str__(self): + return self.name diff --git a/tienda_ilusion/don_confiao/models/products.py b/tienda_ilusion/don_confiao/models/products.py index 9cc63f4..7260f6a 100644 --- a/tienda_ilusion/don_confiao/models/products.py +++ b/tienda_ilusion/don_confiao/models/products.py @@ -26,6 +26,9 @@ class Product(models.Model): max_length=100, null=True, blank=True ) categories = models.ManyToManyField(ProductCategory) + suppliers = models.ManyToManyField( + "Supplier", blank=True, related_name="products" + ) external_id = models.CharField(max_length=100, null=True, blank=True) def __str__(self): diff --git a/tienda_ilusion/don_confiao/models/provenance.py b/tienda_ilusion/don_confiao/models/provenance.py new file mode 100644 index 0000000..221e44c --- /dev/null +++ b/tienda_ilusion/don_confiao/models/provenance.py @@ -0,0 +1,53 @@ +from django.db import models + +from .geography import Municipality + + +class Organization(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(null=True, blank=True) + website = models.CharField(max_length=255, null=True, blank=True) + contact_email = models.CharField(max_length=255, null=True, blank=True) + contact_phone = models.CharField(max_length=100, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Organization" + verbose_name_plural = "Organizations" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Supplier(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(null=True, blank=True) + organization = models.ForeignKey( + Organization, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="suppliers", + ) + municipality = models.ForeignKey( + Municipality, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="suppliers", + ) + website = models.CharField(max_length=255, null=True, blank=True) + contact_email = models.CharField(max_length=255, null=True, blank=True) + contact_phone = models.CharField(max_length=100, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Supplier" + verbose_name_plural = "Suppliers" + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/tienda_ilusion/don_confiao/serializers/__init__.py b/tienda_ilusion/don_confiao/serializers/__init__.py index 83f093b..46480a9 100644 --- a/tienda_ilusion/don_confiao/serializers/__init__.py +++ b/tienda_ilusion/don_confiao/serializers/__init__.py @@ -17,6 +17,8 @@ from .payments import ( PaymentMethodSerializer, ) from .store_settings import StoreSettingsSerializer +from .geography import CountrySerializer, DepartmentSerializer, MunicipalitySerializer +from .provenance import OrganizationSerializer, SupplierSerializer __all__ = [ # Catalogue Images @@ -42,4 +44,11 @@ __all__ = [ "PaymentMethodSerializer", # Store Settings "StoreSettingsSerializer", + # Geography + "CountrySerializer", + "DepartmentSerializer", + "MunicipalitySerializer", + # Provenance + "OrganizationSerializer", + "SupplierSerializer", ] diff --git a/tienda_ilusion/don_confiao/serializers/geography.py b/tienda_ilusion/don_confiao/serializers/geography.py new file mode 100644 index 0000000..5d93de0 --- /dev/null +++ b/tienda_ilusion/don_confiao/serializers/geography.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from ..models.geography import Country, Department, Municipality + + +class CountrySerializer(serializers.ModelSerializer): + class Meta: + model = Country + fields = ["id", "name", "code"] + + +class DepartmentSerializer(serializers.ModelSerializer): + class Meta: + model = Department + fields = ["id", "name", "country"] + + +class MunicipalitySerializer(serializers.ModelSerializer): + class Meta: + model = Municipality + fields = ["id", "name", "department", "country"] diff --git a/tienda_ilusion/don_confiao/serializers/products.py b/tienda_ilusion/don_confiao/serializers/products.py index e026cb4..d43c07c 100644 --- a/tienda_ilusion/don_confiao/serializers/products.py +++ b/tienda_ilusion/don_confiao/serializers/products.py @@ -1,10 +1,14 @@ from rest_framework import serializers from ..models.products import Product, ProductCategory +from ..models.provenance import Supplier class ProductSerializer(serializers.ModelSerializer): catalogue_images = serializers.SerializerMethodField() + suppliers = serializers.PrimaryKeyRelatedField( + many=True, queryset=Supplier.objects.all(), required=False + ) class Meta: model = Product @@ -15,6 +19,7 @@ class ProductSerializer(serializers.ModelSerializer): "price", "measuring_unit", "categories", + "suppliers", "external_id", "catalogue_images", ] diff --git a/tienda_ilusion/don_confiao/serializers/provenance.py b/tienda_ilusion/don_confiao/serializers/provenance.py new file mode 100644 index 0000000..f1685da --- /dev/null +++ b/tienda_ilusion/don_confiao/serializers/provenance.py @@ -0,0 +1,93 @@ +from rest_framework import serializers + +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier + + +class CountryBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Country + fields = ["id", "name", "code"] + + +class DepartmentBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Department + fields = ["id", "name"] + + +class MunicipalityBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Municipality + fields = ["id", "name"] + + +class OrganizationSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = [ + "id", + "name", + "description", + "website", + "contact_email", + "contact_phone", + "created_at", + "updated_at", + ] + + +class OrganizationBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = [ + "id", + "name", + "description", + "website", + "contact_email", + "contact_phone", + ] + + +class SupplierSerializer(serializers.ModelSerializer): + organization_detail = OrganizationBriefSerializer( + source="organization", read_only=True + ) + municipality_detail = MunicipalityBriefSerializer( + source="municipality", read_only=True + ) + products = serializers.PrimaryKeyRelatedField( + many=True, read_only=True + ) + + class Meta: + model = Supplier + fields = [ + "id", + "name", + "description", + "organization", + "organization_detail", + "municipality", + "municipality_detail", + "website", + "contact_email", + "contact_phone", + "products", + "created_at", + "updated_at", + ] + + +class SupplierBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Supplier + fields = [ + "id", + "name", + "description", + "website", + "contact_email", + "contact_phone", + ] diff --git a/tienda_ilusion/don_confiao/serializers/sales.py b/tienda_ilusion/don_confiao/serializers/sales.py index c244bbb..75113e6 100644 --- a/tienda_ilusion/don_confiao/serializers/sales.py +++ b/tienda_ilusion/don_confiao/serializers/sales.py @@ -1,6 +1,7 @@ from rest_framework import serializers from django.urls import reverse +from ..models.products import Product from ..models.sales import ( Sale, SaleLine, @@ -10,6 +11,7 @@ from ..models.sales import ( ) from .products import ListProductSerializer from .customers import ListCustomerSerializer +from ..services.provenance import build_product_provenance class PublicSummaryLinkMixin: @@ -23,6 +25,16 @@ class PublicSummaryLinkMixin: return path +class ProvenanceSummaryMixin: + def get_product_provenance(self, obj): + products = Product.objects.filter( + saleline__sale=obj + ).distinct() + return build_product_provenance( + products, self.context.get("request") + ) + + class SaleLineSerializer(serializers.ModelSerializer): class Meta: model = SaleLine @@ -101,14 +113,26 @@ class SummarySaleLineSerializer(serializers.ModelSerializer): fields = ["product", "quantity", "unit_price", "description"] -class SaleSummarySerializer(PublicSummaryLinkMixin, serializers.ModelSerializer): +class SaleSummarySerializer( + PublicSummaryLinkMixin, ProvenanceSummaryMixin, serializers.ModelSerializer +): customer = ListCustomerSerializer() lines = SummarySaleLineSerializer(many=True, source="saleline_set") link = serializers.SerializerMethodField() + product_provenance = serializers.SerializerMethodField() class Meta: model = Sale - fields = ["id", "code", "date", "customer", "payment_method", "lines", "link"] + fields = [ + "id", + "code", + "date", + "customer", + "payment_method", + "lines", + "link", + "product_provenance", + ] class CatalogSummarySaleLineSerializer(serializers.ModelSerializer): @@ -119,18 +143,31 @@ class CatalogSummarySaleLineSerializer(serializers.ModelSerializer): fields = ["product", "quantity", "unit_price", "description"] +class CatalogProvenanceSummaryMixin(ProvenanceSummaryMixin): + def get_product_provenance(self, obj): + products = Product.objects.filter( + catalogsaleline__catalog_sale=obj + ).distinct() + return build_product_provenance( + products, self.context.get("request") + ) + + class CatalogSaleSummarySerializer( - PublicSummaryLinkMixin, serializers.ModelSerializer + PublicSummaryLinkMixin, + CatalogProvenanceSummaryMixin, + serializers.ModelSerializer, ): customer = ListCustomerSerializer() lines = CatalogSummarySaleLineSerializer( many=True, source="catalogsaleline_set" ) link = serializers.SerializerMethodField() + product_provenance = serializers.SerializerMethodField() class Meta: model = CatalogSale - fields = ["id", "code", "date", "customer", "lines", "link"] + fields = ["id", "code", "date", "customer", "lines", "link", "product_provenance"] class SaleForRenconciliationSerializer(serializers.Serializer): diff --git a/tienda_ilusion/don_confiao/services/provenance.py b/tienda_ilusion/don_confiao/services/provenance.py new file mode 100644 index 0000000..ab37c1a --- /dev/null +++ b/tienda_ilusion/don_confiao/services/provenance.py @@ -0,0 +1,64 @@ +from ..serializers.provenance import ( + CountryBriefSerializer, + DepartmentBriefSerializer, + MunicipalityBriefSerializer, + OrganizationBriefSerializer, + SupplierBriefSerializer, +) +from ..serializers.products import ListProductSerializer + + +def build_product_provenance(products, request=None): + """Domain data for provenance charts. + + Returns a list of products, each with its suppliers. Each supplier + entry includes the linked organization and the territorial hierarchy + (municipality -> department -> country) derived from the supplier's + municipality. + """ + result = [] + for product in products: + suppliers = [] + supplier_qs = product.suppliers.all().select_related( + "organization", "municipality__department__country" + ) + for supplier in supplier_qs: + municipality = supplier.municipality + entry = { + "supplier": SupplierBriefSerializer(supplier).data, + "organization": ( + OrganizationBriefSerializer(supplier.organization).data + if supplier.organization + else None + ), + "municipality": ( + MunicipalityBriefSerializer(municipality).data + if municipality + else None + ), + "department": ( + DepartmentBriefSerializer( + municipality.department + ).data + if municipality + else None + ), + "country": ( + CountryBriefSerializer( + municipality.department.country + ).data + if municipality + else None + ), + } + suppliers.append(entry) + + result.append( + { + "product": ListProductSerializer( + product, context={"request": request} + ).data, + "suppliers": suppliers, + } + ) + return result diff --git a/tienda_ilusion/don_confiao/tests/test_geography_models.py b/tienda_ilusion/don_confiao/tests/test_geography_models.py new file mode 100644 index 0000000..eb5c77f --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_geography_models.py @@ -0,0 +1,109 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase + +from ..models.geography import Country, Department, Municipality + + +class TestCountryModel(TestCase): + def test_create_country(self): + country = Country.objects.create(name="Colombia", code="CO") + self.assertEqual(country.name, "Colombia") + self.assertEqual(country.code, "CO") + self.assertEqual(str(country), "Colombia") + + def test_country_name_is_unique(self): + Country.objects.create(name="Colombia", code="CO") + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Country.objects.create(name="Colombia", code="CO2") + + def test_country_code_is_unique(self): + Country.objects.create(name="Colombia", code="CO") + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Country.objects.create(name="Colombia2", code="CO") + + +class TestDepartmentModel(TestCase): + def setUp(self): + self.country = Country.objects.create(name="Colombia", code="CO") + + def test_create_department(self): + department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + self.assertEqual(department.country, self.country) + + def test_department_requires_country(self): + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Department.objects.create(name="Antioquia") + + def test_department_name_is_unique(self): + Department.objects.create( + name="Cundinamarca", country=self.country + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Department.objects.create( + name="Cundinamarca", country=self.country + ) + + +class TestMunicipalityModel(TestCase): + def setUp(self): + self.country = Country.objects.create(name="Colombia", code="CO") + self.department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + + def test_create_municipality(self): + municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.assertEqual(municipality.department, self.department) + self.assertEqual(municipality.country, self.country) + self.assertEqual(municipality.department.country.name, "Colombia") + self.assertEqual(str(municipality), "La Mesa") + + def test_municipality_requires_department_and_country(self): + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Municipality.objects.create(name="La Mesa") + + def test_municipality_name_is_unique_per_department(self): + Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + + def test_municipality_same_name_allowed_in_different_department( + self, + ): + department2 = Department.objects.create( + name="Antioquia", country=self.country + ) + Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + municipality2 = Municipality.objects.create( + name="La Mesa", + department=department2, + country=self.country, + ) + self.assertEqual( + Municipality.objects.filter(name="La Mesa").count(), 2 + ) + self.assertEqual(municipality2.department, department2) diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_api.py b/tienda_ilusion/don_confiao/tests/test_provenance_api.py new file mode 100644 index 0000000..0982d2d --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_provenance_api.py @@ -0,0 +1,325 @@ +from django.contrib.auth.models import User +from rest_framework import status +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import RefreshToken + +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier +from ..models.products import Product +from .Mixins import LoginMixin + + +def _create_user(username, user_type): + user = User.objects.create_user( + username=username, password="password123" + ) + user.profile.user_type = user_type + user.profile.save() + return user + + +def _client_for(user): + refresh = RefreshToken.for_user(user) + client = APIClient() + client.credentials( + HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}" + ) + return client + + +class TestProvenanceAPIPermissions(APITestCase, LoginMixin): + resources = [ + "/don_confiao/api/organizations/", + "/don_confiao/api/suppliers/", + "/don_confiao/api/countries/", + "/don_confiao/api/departments/", + "/don_confiao/api/municipalities/", + ] + + def test_anonymous_get_is_forbidden(self): + for url in self.resources: + response = self.client.get(url) + self.assertEqual( + response.status_code, + status.HTTP_401_UNAUTHORIZED, + url, + ) + + def test_anonymous_write_is_forbidden(self): + for url in self.resources: + response = self.client.post( + url, {"name": "x"}, format="json" + ) + self.assertEqual( + response.status_code, + status.HTTP_401_UNAUTHORIZED, + url, + ) + + def test_normal_user_can_read(self): + user = _create_user("normal", "user") + client = _client_for(user) + for url in self.resources: + response = client.get(url) + self.assertEqual( + response.status_code, status.HTTP_200_OK, url + ) + + def test_publico_user_cannot_read(self): + user = _create_user("publico", "publico") + client = _client_for(user) + for url in self.resources: + response = client.get(url) + self.assertEqual( + response.status_code, status.HTTP_403_FORBIDDEN, url + ) + + def test_normal_user_cannot_write(self): + user = _create_user("normal", "user") + client = _client_for(user) + for url in self.resources: + response = client.post( + url, {"name": "x"}, format="json" + ) + self.assertEqual( + response.status_code, status.HTTP_403_FORBIDDEN, url + ) + + def test_publico_user_cannot_write(self): + user = _create_user("publico", "publico") + client = _client_for(user) + for url in self.resources: + response = client.post( + url, {"name": "x"}, format="json" + ) + self.assertEqual( + response.status_code, status.HTTP_403_FORBIDDEN, url + ) + + +class TestProvenanceCRUD(APITestCase, LoginMixin): + def setUp(self): + self.login() + self.country = Country.objects.create(name="Colombia", code="CO") + self.department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + self.municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.organization = Organization.objects.create(name="Asociación") + self.supplier = Supplier.objects.create( + name="Proveedor 1", + organization=self.organization, + municipality=self.municipality, + ) + + def test_admin_can_create_organization(self): + response = self.client.post( + "/don_confiao/api/organizations/", + {"name": "Org 2"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + Organization.objects.filter(name="Org 2").count(), 1 + ) + + def test_admin_can_update_organization(self): + response = self.client.patch( + f"/don_confiao/api/organizations/{self.organization.id}/", + {"description": "Nueva descripción"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.organization.refresh_from_db() + self.assertEqual( + self.organization.description, "Nueva descripción" + ) + + def test_admin_can_delete_organization(self): + response = self.client.delete( + f"/don_confiao/api/organizations/{self.organization.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse( + Organization.objects.filter(pk=self.organization.id).exists() + ) + + def test_admin_can_create_supplier(self): + response = self.client.post( + "/don_confiao/api/suppliers/", + { + "name": "Proveedor 2", + "organization": self.organization.id, + "municipality": self.municipality.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + supplier = Supplier.objects.get(name="Proveedor 2") + self.assertEqual(supplier.organization, self.organization) + self.assertEqual(supplier.municipality, self.municipality) + + def test_admin_can_unlink_supplier_organization(self): + response = self.client.patch( + f"/don_confiao/api/suppliers/{self.supplier.id}/", + {"organization": None}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.supplier.refresh_from_db() + self.assertIsNone(self.supplier.organization) + + def test_admin_can_unlink_supplier_municipality(self): + response = self.client.patch( + f"/don_confiao/api/suppliers/{self.supplier.id}/", + {"municipality": None}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.supplier.refresh_from_db() + self.assertIsNone(self.supplier.municipality) + + def test_admin_can_create_department(self): + response = self.client.post( + "/don_confiao/api/departments/", + {"name": "Antioquia", "country": self.country.id}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + Department.objects.filter(name="Antioquia").count(), 1 + ) + + def test_admin_can_create_municipality(self): + response = self.client.post( + "/don_confiao/api/municipalities/", + { + "name": "El Colegio", + "department": self.department.id, + "country": self.country.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + Municipality.objects.filter(name="El Colegio").count(), 1 + ) + + def test_supplier_serializer_includes_details(self): + response = self.client.get( + f"/don_confiao/api/suppliers/{self.supplier.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["organization_detail"]["name"], "Asociación") + self.assertEqual(data["municipality_detail"]["name"], "La Mesa") + self.assertIn("products", data) + + def test_link_product_to_suppliers_via_product_api(self): + product = Product.objects.create(name="Panela", price=5000) + supplier2 = Supplier.objects.create(name="Proveedor 2") + response = self.client.patch( + f"/don_confiao/api/products/{product.id}/", + {"suppliers": [self.supplier.id, supplier2.id]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + product.refresh_from_db() + self.assertEqual( + set(product.suppliers.all()), {self.supplier, supplier2} + ) + + def test_unlink_product_supplier_via_product_api(self): + product = Product.objects.create(name="Panela", price=5000) + product.suppliers.add(self.supplier) + response = self.client.patch( + f"/don_confiao/api/products/{product.id}/", + {"suppliers": []}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + product.refresh_from_db() + self.assertEqual(product.suppliers.count(), 0) + + +class TestSeedGeography(APITestCase, LoginMixin): + url = "/don_confiao/api/seed_geography" + + def _payload(self): + return { + "country": {"name": "Colombia", "code": "CO"}, + "departments": [{"name": "Cundinamarca", "code": "CO-CUN"}], + "municipalities": [ + {"name": "La Mesa", "department": "Cundinamarca"}, + {"name": "El Colegio", "department": "Cundinamarca"}, + ], + } + + def test_seed_requires_authentication(self): + response = self.client.post( + self.url, self._payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_seed_requires_admin(self): + user = _create_user("normal", "user") + client = _client_for(user) + response = client.post(self.url, self._payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_seed_creates_geography(self): + self.login() + response = self.client.post( + self.url, self._payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["country"], "Colombia") + self.assertEqual(data["total_departments"], 1) + self.assertEqual(data["total_municipalities"], 2) + + def test_seed_is_idempotent(self): + self.login() + self.client.post(self.url, self._payload(), format="json") + response = self.client.post( + self.url, self._payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Country.objects.count(), 1) + self.assertEqual(Department.objects.count(), 1) + self.assertEqual(Municipality.objects.count(), 2) + + def test_seed_requires_country_name(self): + self.login() + response = self.client.post( + self.url, {"departments": []}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_seed_creates_duplicate_municipality_names_in_different_departments( + self, + ): + self.login() + payload = { + "country": {"name": "Colombia", "code": "CO"}, + "departments": [ + {"name": "Cundinamarca", "code": "CO-CUN"}, + {"name": "Antioquia", "code": "CO-ANT"}, + ], + "municipalities": [ + {"name": "Bolivar", "department": "Cundinamarca"}, + {"name": "Bolivar", "department": "Antioquia"}, + ], + } + response = self.client.post(self.url, payload, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["total_departments"], 2) + self.assertEqual(data["total_municipalities"], 2) + self.assertEqual( + Municipality.objects.filter(name="Bolivar").count(), 2 + ) diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py b/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py new file mode 100644 index 0000000..dd6c1de --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py @@ -0,0 +1,163 @@ +from datetime import datetime, timezone + +from rest_framework import status +from rest_framework.test import APITestCase + +from ..models.customers import Customer +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier +from ..models.products import Product +from ..models.sales import CatalogSale, Sale +from .Mixins import LoginMixin + + +class TestProvenanceInSummaries(APITestCase, LoginMixin): + def setUp(self): + self.country = Country.objects.create(name="Colombia", code="CO") + self.department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + self.municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.organization = Organization.objects.create(name="Asociación") + self.supplier = Supplier.objects.create( + name="Proveedor 1", + organization=self.organization, + municipality=self.municipality, + ) + self.product = Product.objects.create(name="Panela", price=5000) + self.product.suppliers.add(self.supplier) + self.customer = Customer.objects.create( + name="Camilo", external_id="18" + ) + + def _create_sale(self): + sale = Sale.objects.create( + customer=self.customer, + date=datetime(2024, 9, 2, tzinfo=timezone.utc), + payment_method="CASH", + ) + sale.saleline_set.create( + product=self.product, quantity=2, unit_price=3000 + ) + return sale + + def _create_catalog_sale(self): + catalog_sale = CatalogSale.objects.create( + customer=self.customer, + date=datetime(2024, 9, 2, tzinfo=timezone.utc), + customer_name="Camilo", + ) + catalog_sale.catalogsaleline_set.create( + product=self.product, quantity=2, unit_price=3000 + ) + return catalog_sale + + def test_public_sale_summary_includes_product_provenance(self): + sale = self._create_sale() + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIn("product_provenance", data) + self.assertEqual(len(data["product_provenance"]), 1) + entry = data["product_provenance"][0] + self.assertEqual(entry["product"]["name"], "Panela") + self.assertEqual(len(entry["suppliers"]), 1) + supplier = entry["suppliers"][0] + self.assertEqual(supplier["supplier"]["name"], "Proveedor 1") + self.assertEqual(supplier["organization"]["name"], "Asociación") + self.assertEqual(supplier["municipality"]["name"], "La Mesa") + self.assertEqual(supplier["department"]["name"], "Cundinamarca") + self.assertEqual(supplier["country"]["name"], "Colombia") + + def test_public_catalog_summary_includes_product_provenance(self): + catalog_sale = self._create_catalog_sale() + response = self.client.get( + f"/don_confiao/resumen_publico/{catalog_sale.code}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIn("product_provenance", data) + self.assertEqual( + len(data["product_provenance"][0]["suppliers"]), 1 + ) + + def test_authenticated_summaries_include_product_provenance(self): + self.login() + sale = self._create_sale() + response = self.client.get( + f"/don_confiao/resumen_compra_json/{sale.id}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("product_provenance", response.json()) + + catalog_sale = self._create_catalog_sale() + response = self.client.get( + f"/don_confiao/resumen_compra_catalogo_json/{catalog_sale.id}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("product_provenance", response.json()) + + def test_summary_existing_fields_are_preserved(self): + sale = self._create_sale() + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + data = response.json() + for field in [ + "id", + "code", + "date", + "customer", + "payment_method", + "lines", + "link", + "type", + ]: + self.assertIn(field, data) + + def test_product_without_suppliers_returns_empty_list(self): + product2 = Product.objects.create(name="Café", price=6000) + sale = self._create_sale() + sale.saleline_set.create( + product=product2, quantity=1, unit_price=6000 + ) + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + data = response.json() + entry = next( + e + for e in data["product_provenance"] + if e["product"]["name"] == "Café" + ) + self.assertEqual(entry["suppliers"], []) + + def test_supplier_without_municipality_returns_null_territory(self): + supplier2 = Supplier.objects.create( + name="Proveedor sin ubicación" + ) + product2 = Product.objects.create(name="Café", price=6000) + product2.suppliers.add(supplier2) + sale = self._create_sale() + sale.saleline_set.create( + product=product2, quantity=1, unit_price=6000 + ) + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + data = response.json() + entry = next( + e + for e in data["product_provenance"] + if e["product"]["name"] == "Café" + ) + supplier = entry["suppliers"][0] + self.assertIsNone(supplier["municipality"]) + self.assertIsNone(supplier["department"]) + self.assertIsNone(supplier["country"]) diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_models.py b/tienda_ilusion/don_confiao/tests/test_provenance_models.py new file mode 100644 index 0000000..2c5c7d9 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_provenance_models.py @@ -0,0 +1,126 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase + +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier +from ..models.products import Product + + +class TestOrganizationModel(TestCase): + def test_create_organization(self): + organization = Organization.objects.create( + name="Asociación La Mesa", + description="Cooperativa de campesinos", + ) + self.assertEqual(organization.name, "Asociación La Mesa") + self.assertEqual( + organization.description, "Cooperativa de campesinos" + ) + self.assertEqual(str(organization), "Asociación La Mesa") + + def test_update_organization(self): + organization = Organization.objects.create(name="Asociación") + organization.description = "Nueva descripción" + organization.save() + organization.refresh_from_db() + self.assertEqual(organization.description, "Nueva descripción") + + def test_delete_organization(self): + organization = Organization.objects.create(name="Asociación") + organization.delete() + self.assertFalse( + Organization.objects.filter(pk=organization.pk).exists() + ) + + def test_organization_name_is_unique(self): + Organization.objects.create(name="Asociación") + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Organization.objects.create(name="Asociación") + + +class TestSupplierModel(TestCase): + def setUp(self): + self.country = Country.objects.create(name="Colombia", code="CO") + self.department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + self.municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.organization = Organization.objects.create(name="Asociación") + self.product = Product.objects.create(name="Panela", price=5000) + + def test_create_supplier(self): + supplier = Supplier.objects.create( + name="Proveedor 1", municipality=self.municipality + ) + self.assertEqual(supplier.municipality, self.municipality) + self.assertEqual( + supplier.municipality.department.country.name, "Colombia" + ) + + def test_supplier_organization_can_be_linked_and_unlinked(self): + supplier = Supplier.objects.create(name="Proveedor 1") + supplier.organization = self.organization + supplier.save() + supplier.refresh_from_db() + self.assertEqual(supplier.organization, self.organization) + + supplier.organization = None + supplier.save() + supplier.refresh_from_db() + self.assertIsNone(supplier.organization) + + def test_supplier_municipality_can_be_linked_and_unlinked(self): + supplier = Supplier.objects.create(name="Proveedor 1") + supplier.municipality = self.municipality + supplier.save() + supplier.refresh_from_db() + self.assertEqual(supplier.municipality, self.municipality) + + supplier.municipality = None + supplier.save() + supplier.refresh_from_db() + self.assertIsNone(supplier.municipality) + + def test_supplier_without_organization_or_municipality(self): + supplier = Supplier.objects.create(name="Proveedor 1") + self.assertIsNone(supplier.organization) + self.assertIsNone(supplier.municipality) + + def test_supplier_name_is_unique(self): + Supplier.objects.create(name="Proveedor 1") + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Supplier.objects.create(name="Proveedor 1") + + def test_delete_organization_unlinks_supplier(self): + supplier = Supplier.objects.create( + name="Proveedor 1", organization=self.organization + ) + self.organization.delete() + supplier.refresh_from_db() + self.assertIsNone(supplier.organization) + + def test_delete_municipality_unlinks_supplier(self): + supplier = Supplier.objects.create( + name="Proveedor 1", municipality=self.municipality + ) + self.municipality.delete() + supplier.refresh_from_db() + self.assertIsNone(supplier.municipality) + + def test_product_can_be_linked_to_multiple_suppliers(self): + supplier1 = Supplier.objects.create(name="Proveedor 1") + supplier2 = Supplier.objects.create(name="Proveedor 2") + + self.product.suppliers.add(supplier1, supplier2) + self.assertEqual(self.product.suppliers.count(), 2) + + self.product.suppliers.remove(supplier1) + self.assertEqual(list(self.product.suppliers.all()), [supplier2]) + + self.assertEqual(set(supplier2.products.all()), {self.product}) diff --git a/tienda_ilusion/don_confiao/urls.py b/tienda_ilusion/don_confiao/urls.py index 8fcd7f6..3547445 100644 --- a/tienda_ilusion/don_confiao/urls.py +++ b/tienda_ilusion/don_confiao/urls.py @@ -29,6 +29,13 @@ from .api import ( AdminCodeValidateView, # Store Settings StoreSettingsView, + # Provenance + OrganizationView, + SupplierView, + CountryView, + DepartmentView, + MunicipalityView, + SeedGeographyView, ) app_name = "don_confiao" @@ -48,6 +55,11 @@ router.register( ReconciliateJarModelView, basename="reconciliate_jar", ) +router.register(r"organizations", OrganizationView, basename="organization") +router.register(r"suppliers", SupplierView, basename="supplier") +router.register(r"countries", CountryView, basename="country") +router.register(r"departments", DepartmentView, basename="department") +router.register(r"municipalities", MunicipalityView, basename="municipality") urlpatterns = [ path("productos", views.products, name="products"), @@ -109,4 +121,9 @@ urlpatterns = [ ), path("api/sales/for_tryton", SalesForTrytonView.as_view()), path("api/store_settings", StoreSettingsView.as_view()), + path( + "api/seed_geography", + SeedGeographyView.as_view(), + name="seed_geography", + ), ]