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
This commit is contained in:
@@ -20,6 +20,14 @@ from .payments import (
|
|||||||
)
|
)
|
||||||
from .admin import AdminCodeValidateView
|
from .admin import AdminCodeValidateView
|
||||||
from .store_settings import StoreSettingsView
|
from .store_settings import StoreSettingsView
|
||||||
|
from .provenance import (
|
||||||
|
OrganizationView,
|
||||||
|
SupplierView,
|
||||||
|
CountryView,
|
||||||
|
DepartmentView,
|
||||||
|
MunicipalityView,
|
||||||
|
SeedGeographyView,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Catalogue Images
|
# Catalogue Images
|
||||||
@@ -49,4 +57,11 @@ __all__ = [
|
|||||||
"AdminCodeValidateView",
|
"AdminCodeValidateView",
|
||||||
# Store Settings
|
# Store Settings
|
||||||
"StoreSettingsView",
|
"StoreSettingsView",
|
||||||
|
# Provenance
|
||||||
|
"OrganizationView",
|
||||||
|
"SupplierView",
|
||||||
|
"CountryView",
|
||||||
|
"DepartmentView",
|
||||||
|
"MunicipalityView",
|
||||||
|
"SeedGeographyView",
|
||||||
]
|
]
|
||||||
|
|||||||
112
tienda_ilusion/don_confiao/api/provenance.py
Normal file
112
tienda_ilusion/don_confiao/api/provenance.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,3 +1,12 @@
|
|||||||
from .store_settings import StoreSettings
|
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",
|
||||||
|
]
|
||||||
|
|||||||
54
tienda_ilusion/don_confiao/models/geography.py
Normal file
54
tienda_ilusion/don_confiao/models/geography.py
Normal 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
|
||||||
@@ -26,6 +26,9 @@ class Product(models.Model):
|
|||||||
max_length=100, null=True, blank=True
|
max_length=100, null=True, blank=True
|
||||||
)
|
)
|
||||||
categories = models.ManyToManyField(ProductCategory)
|
categories = models.ManyToManyField(ProductCategory)
|
||||||
|
suppliers = models.ManyToManyField(
|
||||||
|
"Supplier", blank=True, related_name="products"
|
||||||
|
)
|
||||||
external_id = models.CharField(max_length=100, null=True, blank=True)
|
external_id = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
53
tienda_ilusion/don_confiao/models/provenance.py
Normal file
53
tienda_ilusion/don_confiao/models/provenance.py
Normal 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
|
||||||
@@ -17,6 +17,8 @@ from .payments import (
|
|||||||
PaymentMethodSerializer,
|
PaymentMethodSerializer,
|
||||||
)
|
)
|
||||||
from .store_settings import StoreSettingsSerializer
|
from .store_settings import StoreSettingsSerializer
|
||||||
|
from .geography import CountrySerializer, DepartmentSerializer, MunicipalitySerializer
|
||||||
|
from .provenance import OrganizationSerializer, SupplierSerializer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Catalogue Images
|
# Catalogue Images
|
||||||
@@ -42,4 +44,11 @@ __all__ = [
|
|||||||
"PaymentMethodSerializer",
|
"PaymentMethodSerializer",
|
||||||
# Store Settings
|
# Store Settings
|
||||||
"StoreSettingsSerializer",
|
"StoreSettingsSerializer",
|
||||||
|
# Geography
|
||||||
|
"CountrySerializer",
|
||||||
|
"DepartmentSerializer",
|
||||||
|
"MunicipalitySerializer",
|
||||||
|
# Provenance
|
||||||
|
"OrganizationSerializer",
|
||||||
|
"SupplierSerializer",
|
||||||
]
|
]
|
||||||
|
|||||||
21
tienda_ilusion/don_confiao/serializers/geography.py
Normal file
21
tienda_ilusion/don_confiao/serializers/geography.py
Normal 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"]
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from ..models.products import Product, ProductCategory
|
from ..models.products import Product, ProductCategory
|
||||||
|
from ..models.provenance import Supplier
|
||||||
|
|
||||||
|
|
||||||
class ProductSerializer(serializers.ModelSerializer):
|
class ProductSerializer(serializers.ModelSerializer):
|
||||||
catalogue_images = serializers.SerializerMethodField()
|
catalogue_images = serializers.SerializerMethodField()
|
||||||
|
suppliers = serializers.PrimaryKeyRelatedField(
|
||||||
|
many=True, queryset=Supplier.objects.all(), required=False
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Product
|
model = Product
|
||||||
@@ -15,6 +19,7 @@ class ProductSerializer(serializers.ModelSerializer):
|
|||||||
"price",
|
"price",
|
||||||
"measuring_unit",
|
"measuring_unit",
|
||||||
"categories",
|
"categories",
|
||||||
|
"suppliers",
|
||||||
"external_id",
|
"external_id",
|
||||||
"catalogue_images",
|
"catalogue_images",
|
||||||
]
|
]
|
||||||
|
|||||||
93
tienda_ilusion/don_confiao/serializers/provenance.py
Normal file
93
tienda_ilusion/don_confiao/serializers/provenance.py
Normal 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",
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from ..models.products import Product
|
||||||
from ..models.sales import (
|
from ..models.sales import (
|
||||||
Sale,
|
Sale,
|
||||||
SaleLine,
|
SaleLine,
|
||||||
@@ -10,6 +11,7 @@ from ..models.sales import (
|
|||||||
)
|
)
|
||||||
from .products import ListProductSerializer
|
from .products import ListProductSerializer
|
||||||
from .customers import ListCustomerSerializer
|
from .customers import ListCustomerSerializer
|
||||||
|
from ..services.provenance import build_product_provenance
|
||||||
|
|
||||||
|
|
||||||
class PublicSummaryLinkMixin:
|
class PublicSummaryLinkMixin:
|
||||||
@@ -23,6 +25,16 @@ class PublicSummaryLinkMixin:
|
|||||||
return path
|
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 SaleLineSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SaleLine
|
model = SaleLine
|
||||||
@@ -101,14 +113,26 @@ class SummarySaleLineSerializer(serializers.ModelSerializer):
|
|||||||
fields = ["product", "quantity", "unit_price", "description"]
|
fields = ["product", "quantity", "unit_price", "description"]
|
||||||
|
|
||||||
|
|
||||||
class SaleSummarySerializer(PublicSummaryLinkMixin, serializers.ModelSerializer):
|
class SaleSummarySerializer(
|
||||||
|
PublicSummaryLinkMixin, ProvenanceSummaryMixin, serializers.ModelSerializer
|
||||||
|
):
|
||||||
customer = ListCustomerSerializer()
|
customer = ListCustomerSerializer()
|
||||||
lines = SummarySaleLineSerializer(many=True, source="saleline_set")
|
lines = SummarySaleLineSerializer(many=True, source="saleline_set")
|
||||||
link = serializers.SerializerMethodField()
|
link = serializers.SerializerMethodField()
|
||||||
|
product_provenance = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Sale
|
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):
|
class CatalogSummarySaleLineSerializer(serializers.ModelSerializer):
|
||||||
@@ -119,18 +143,31 @@ class CatalogSummarySaleLineSerializer(serializers.ModelSerializer):
|
|||||||
fields = ["product", "quantity", "unit_price", "description"]
|
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(
|
class CatalogSaleSummarySerializer(
|
||||||
PublicSummaryLinkMixin, serializers.ModelSerializer
|
PublicSummaryLinkMixin,
|
||||||
|
CatalogProvenanceSummaryMixin,
|
||||||
|
serializers.ModelSerializer,
|
||||||
):
|
):
|
||||||
customer = ListCustomerSerializer()
|
customer = ListCustomerSerializer()
|
||||||
lines = CatalogSummarySaleLineSerializer(
|
lines = CatalogSummarySaleLineSerializer(
|
||||||
many=True, source="catalogsaleline_set"
|
many=True, source="catalogsaleline_set"
|
||||||
)
|
)
|
||||||
link = serializers.SerializerMethodField()
|
link = serializers.SerializerMethodField()
|
||||||
|
product_provenance = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CatalogSale
|
model = CatalogSale
|
||||||
fields = ["id", "code", "date", "customer", "lines", "link"]
|
fields = ["id", "code", "date", "customer", "lines", "link", "product_provenance"]
|
||||||
|
|
||||||
|
|
||||||
class SaleForRenconciliationSerializer(serializers.Serializer):
|
class SaleForRenconciliationSerializer(serializers.Serializer):
|
||||||
|
|||||||
64
tienda_ilusion/don_confiao/services/provenance.py
Normal file
64
tienda_ilusion/don_confiao/services/provenance.py
Normal 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
|
||||||
109
tienda_ilusion/don_confiao/tests/test_geography_models.py
Normal file
109
tienda_ilusion/don_confiao/tests/test_geography_models.py
Normal 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)
|
||||||
325
tienda_ilusion/don_confiao/tests/test_provenance_api.py
Normal file
325
tienda_ilusion/don_confiao/tests/test_provenance_api.py
Normal 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
|
||||||
|
)
|
||||||
163
tienda_ilusion/don_confiao/tests/test_provenance_graphs.py
Normal file
163
tienda_ilusion/don_confiao/tests/test_provenance_graphs.py
Normal 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"])
|
||||||
126
tienda_ilusion/don_confiao/tests/test_provenance_models.py
Normal file
126
tienda_ilusion/don_confiao/tests/test_provenance_models.py
Normal 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})
|
||||||
@@ -29,6 +29,13 @@ from .api import (
|
|||||||
AdminCodeValidateView,
|
AdminCodeValidateView,
|
||||||
# Store Settings
|
# Store Settings
|
||||||
StoreSettingsView,
|
StoreSettingsView,
|
||||||
|
# Provenance
|
||||||
|
OrganizationView,
|
||||||
|
SupplierView,
|
||||||
|
CountryView,
|
||||||
|
DepartmentView,
|
||||||
|
MunicipalityView,
|
||||||
|
SeedGeographyView,
|
||||||
)
|
)
|
||||||
|
|
||||||
app_name = "don_confiao"
|
app_name = "don_confiao"
|
||||||
@@ -48,6 +55,11 @@ router.register(
|
|||||||
ReconciliateJarModelView,
|
ReconciliateJarModelView,
|
||||||
basename="reconciliate_jar",
|
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 = [
|
urlpatterns = [
|
||||||
path("productos", views.products, name="products"),
|
path("productos", views.products, name="products"),
|
||||||
@@ -109,4 +121,9 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
path("api/sales/for_tryton", SalesForTrytonView.as_view()),
|
path("api/sales/for_tryton", SalesForTrytonView.as_view()),
|
||||||
path("api/store_settings", StoreSettingsView.as_view()),
|
path("api/store_settings", StoreSettingsView.as_view()),
|
||||||
|
path(
|
||||||
|
"api/seed_geography",
|
||||||
|
SeedGeographyView.as_view(),
|
||||||
|
name="seed_geography",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user