feat/49-provenance Implementa trazabilidad de proveedores #49 #50

Merged
mono merged 6 commits from feat/49-provenance into main 2026-08-16 22:17:35 -05:00
18 changed files with 1321 additions and 5 deletions
Showing only changes of commit 29d154e140 - Show all commits

View File

@@ -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",
]

View File

@@ -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,
)

View File

@@ -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'),
),
]

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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):

View File

@@ -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

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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"])

View File

@@ -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})

View File

@@ -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",
),
]