From b08879471669ae6d38f282932518e4ffa2af9775 Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 14:56:09 -0500 Subject: [PATCH 1/5] docs: agregar workflow TDD rojo/verde con confirmacion de fallos de tests --- AGENTS.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 4d4f0fa..47146f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,7 +159,44 @@ Nota: El volumen monta `tienda_ilusion/` en `/app/`, por lo que el path correcto ## Tests - Framework: Django unittest - Directorio: don_confiao/tests/ -- Ejecutar: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py test` +- Ejecutar: `docker compose -f docker-compose.dev.yml run --rm django python manage.py test` + +## Workflow TDD (Semáforo Rojo/Verde) + +Todo cambio o nueva funcionalidad debe seguir TDD. **Está prohibido asumir fallos de tests: todo fallo debe ser confirmado ejecutando los tests.** + +### Regla de oro: confirmar antes de modificar +Antes de modificar tests (por cambios en comportamiento o por nuevas funcionalidades), **SIEMPRE ejecutar los tests primero** y confirmar que el fallo esperado realmente ocurre. Nunca asumir que un test falla: verificar el fallo con la ejecución real. + +### Ciclo Rojo → Verde → Refactor +1. **ROJO (escribir el test y confirmar que falla)**: + - Escribir o modificar el test que describe el comportamiento esperado. + - Ejecutar los tests **antes de tocar el código de producción**. + - Confirmar que el test falla por el motivo esperado (assertion fallida por el comportamiento nuevo, no por error de setup o de importación). + - Copiar el output del fallo como evidencia del semáforo rojo. + +2. **VERDE (implementar lo mínimo para que pase)**: + - Implementar el código de producción mínimo necesario para que el test pase. + - Volver a ejecutar los tests y confirmar que el semáforo queda en verde. + +3. **REFACTOR (limpiar con los tests en verde)**: + - Refactorizar y limpiar el código sin cambiar comportamiento. + - Re-ejecutar los tests para confirmar que siguen en verde. + +### Ejecutar tests +Siempre ejecutar dentro del contenedor de desarrollo: +```bash +# Test específico (durante el ciclo, preferible sobre el suite completo para ser rápido) +docker compose -f docker-compose.dev.yml run --rm django python manage.py test don_confiao.tests. + +# Suite completa (al finalizar, para confirmar que nada se rompió) +docker compose -f docker-compose.dev.yml run --rm django python manage.py test +``` + +### Confirmar el fallo, no asumirlo +- Si un test falla o se espera que falle, **ejecutarlo y mostrar el resultado** antes de cambiar cualquier código o test. +- Verificar que el fallo corresponde a la razón esperada (leer el traceback), no a un problema de entorno, migraciones, importaciones o fixtures. +- No modificar tests para "hacerlos pasar" si el fallo no fue previamente confirmado. ## Comandos Útiles (dentro del contenedor) - Migraciones: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py makemigrations && docker-compose -f docker-compose.dev.yml run --rm django python manage.py migrate` From 29d154e140a79dbf8d68167a7748272ea26afc1e Mon Sep 17 00:00:00 2001 From: monomono Date: Sat, 15 Aug 2026 16:32:20 -0500 Subject: [PATCH 2/5] feat(#49): add product provenance (suppliers, organizations, geography) - Country, Department, Municipality models (municipality unique per department) - Organization and Supplier models, Supplier links to organization and municipality - Product.suppliers M2M to link products to one or more suppliers - Authenticated CRUD APIs for organizations, suppliers, countries, departments and municipalities (write only for administrators) - product_provenance domain data embedded in sale/catalog summaries (public and authenticated) - seed_geography endpoint (admin, idempotent) and scripts/seed_geography.py using pycountry + DANE municipalities CSV - TDD tests for models, API permissions/CRUD and summary provenance data --- tienda_ilusion/don_confiao/api/__init__.py | 15 + tienda_ilusion/don_confiao/api/provenance.py | 112 ++++++ ...zation_department_municipality_and_more.py | 101 ++++++ tienda_ilusion/don_confiao/models/__init__.py | 11 +- .../don_confiao/models/geography.py | 54 +++ tienda_ilusion/don_confiao/models/products.py | 3 + .../don_confiao/models/provenance.py | 53 +++ .../don_confiao/serializers/__init__.py | 9 + .../don_confiao/serializers/geography.py | 21 ++ .../don_confiao/serializers/products.py | 5 + .../don_confiao/serializers/provenance.py | 93 +++++ .../don_confiao/serializers/sales.py | 45 ++- .../don_confiao/services/provenance.py | 64 ++++ .../tests/test_geography_models.py | 109 ++++++ .../don_confiao/tests/test_provenance_api.py | 325 ++++++++++++++++++ .../tests/test_provenance_graphs.py | 163 +++++++++ .../tests/test_provenance_models.py | 126 +++++++ tienda_ilusion/don_confiao/urls.py | 17 + 18 files changed, 1321 insertions(+), 5 deletions(-) create mode 100644 tienda_ilusion/don_confiao/api/provenance.py create mode 100644 tienda_ilusion/don_confiao/migrations/0053_country_organization_department_municipality_and_more.py create mode 100644 tienda_ilusion/don_confiao/models/geography.py create mode 100644 tienda_ilusion/don_confiao/models/provenance.py create mode 100644 tienda_ilusion/don_confiao/serializers/geography.py create mode 100644 tienda_ilusion/don_confiao/serializers/provenance.py create mode 100644 tienda_ilusion/don_confiao/services/provenance.py create mode 100644 tienda_ilusion/don_confiao/tests/test_geography_models.py create mode 100644 tienda_ilusion/don_confiao/tests/test_provenance_api.py create mode 100644 tienda_ilusion/don_confiao/tests/test_provenance_graphs.py create mode 100644 tienda_ilusion/don_confiao/tests/test_provenance_models.py diff --git a/tienda_ilusion/don_confiao/api/__init__.py b/tienda_ilusion/don_confiao/api/__init__.py index af7fd12..3c00bdb 100644 --- a/tienda_ilusion/don_confiao/api/__init__.py +++ b/tienda_ilusion/don_confiao/api/__init__.py @@ -20,6 +20,14 @@ from .payments import ( ) from .admin import AdminCodeValidateView from .store_settings import StoreSettingsView +from .provenance import ( + OrganizationView, + SupplierView, + CountryView, + DepartmentView, + MunicipalityView, + SeedGeographyView, +) __all__ = [ # Catalogue Images @@ -49,4 +57,11 @@ __all__ = [ "AdminCodeValidateView", # Store Settings "StoreSettingsView", + # Provenance + "OrganizationView", + "SupplierView", + "CountryView", + "DepartmentView", + "MunicipalityView", + "SeedGeographyView", ] diff --git a/tienda_ilusion/don_confiao/api/provenance.py b/tienda_ilusion/don_confiao/api/provenance.py new file mode 100644 index 0000000..f497580 --- /dev/null +++ b/tienda_ilusion/don_confiao/api/provenance.py @@ -0,0 +1,112 @@ +from rest_framework import viewsets +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier +from ..serializers import ( + CountrySerializer, + DepartmentSerializer, + MunicipalitySerializer, + OrganizationSerializer, + SupplierSerializer, +) +from ..permissions import IsAdministrator, IsNotPublico + + +class _AdminWriteMixin: + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [IsNotPublico()] + return [IsAuthenticated(), IsAdministrator()] + + +class OrganizationView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + + +class SupplierView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Supplier.objects.select_related( + "organization", "municipality" + ) + serializer_class = SupplierSerializer + + +class CountryView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Country.objects.all() + serializer_class = CountrySerializer + + +class DepartmentView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Department.objects.select_related("country") + serializer_class = DepartmentSerializer + + +class MunicipalityView(_AdminWriteMixin, viewsets.ModelViewSet): + queryset = Municipality.objects.select_related( + "department", "country" + ) + serializer_class = MunicipalitySerializer + + +class SeedGeographyView(APIView): + permission_classes = [IsAuthenticated, IsAdministrator] + + def post(self, request): + country_data = request.data.get("country") or {} + country_name = country_data.get("name") + if not country_name: + return Response( + {"detail": "country.name is required"}, status=400 + ) + + country, _ = Country.objects.get_or_create( + name=country_name, + defaults={"code": country_data.get("code", "")}, + ) + + departments_created = 0 + departments = {} + for department_data in request.data.get("departments", []): + department, created = Department.objects.get_or_create( + name=department_data["name"], + defaults={"country": country}, + ) + departments[department.name] = department + if created: + departments_created += 1 + + municipalities_created = 0 + for municipality_data in request.data.get( + "municipalities", [] + ): + department_name = municipality_data["department"] + department = departments.get(department_name) + if department is None: + department, created = Department.objects.get_or_create( + name=department_name, defaults={"country": country} + ) + departments[department.name] = department + if created: + departments_created += 1 + + _, created = Municipality.objects.get_or_create( + name=municipality_data["name"], + department=department, + defaults={"country": country}, + ) + if created: + municipalities_created += 1 + + return Response( + { + "country": country.name, + "departments_created": departments_created, + "municipalities_created": municipalities_created, + "total_departments": Department.objects.count(), + "total_municipalities": Municipality.objects.count(), + }, + status=200, + ) diff --git a/tienda_ilusion/don_confiao/migrations/0053_country_organization_department_municipality_and_more.py b/tienda_ilusion/don_confiao/migrations/0053_country_organization_department_municipality_and_more.py new file mode 100644 index 0000000..83629ca --- /dev/null +++ b/tienda_ilusion/don_confiao/migrations/0053_country_organization_department_municipality_and_more.py @@ -0,0 +1,101 @@ +# Generated by Django 5.0.6 on 2026-08-15 21:17 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('don_confiao', '0052_merge_20260810_0351'), + ] + + operations = [ + migrations.CreateModel( + name='Country', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('code', models.CharField(blank=True, max_length=3, null=True, unique=True)), + ], + options={ + 'verbose_name': 'Country', + 'verbose_name_plural': 'Countries', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Organization', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('website', models.CharField(blank=True, max_length=255, null=True)), + ('contact_email', models.CharField(blank=True, max_length=255, null=True)), + ('contact_phone', models.CharField(blank=True, max_length=100, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'Organization', + 'verbose_name_plural': 'Organizations', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Department', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('country', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='departments', to='don_confiao.country')), + ], + options={ + 'verbose_name': 'Department', + 'verbose_name_plural': 'Departments', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Municipality', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('country', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='municipalities', to='don_confiao.country')), + ('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='municipalities', to='don_confiao.department')), + ], + options={ + 'verbose_name': 'Municipality', + 'verbose_name_plural': 'Municipalities', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Supplier', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('description', models.TextField(blank=True, null=True)), + ('website', models.CharField(blank=True, max_length=255, null=True)), + ('contact_email', models.CharField(blank=True, max_length=255, null=True)), + ('contact_phone', models.CharField(blank=True, max_length=100, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('municipality', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='suppliers', to='don_confiao.municipality')), + ('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='suppliers', to='don_confiao.organization')), + ], + options={ + 'verbose_name': 'Supplier', + 'verbose_name_plural': 'Suppliers', + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='product', + name='suppliers', + field=models.ManyToManyField(blank=True, related_name='products', to='don_confiao.supplier'), + ), + migrations.AddConstraint( + model_name='municipality', + constraint=models.UniqueConstraint(fields=('name', 'department'), name='unique_municipality_name_per_department'), + ), + ] diff --git a/tienda_ilusion/don_confiao/models/__init__.py b/tienda_ilusion/don_confiao/models/__init__.py index 147edfd..ab1ebd9 100644 --- a/tienda_ilusion/don_confiao/models/__init__.py +++ b/tienda_ilusion/don_confiao/models/__init__.py @@ -1,3 +1,12 @@ from .store_settings import StoreSettings +from .geography import Country, Department, Municipality +from .provenance import Organization, Supplier -__all__ = ["StoreSettings"] +__all__ = [ + "StoreSettings", + "Country", + "Department", + "Municipality", + "Organization", + "Supplier", +] diff --git a/tienda_ilusion/don_confiao/models/geography.py b/tienda_ilusion/don_confiao/models/geography.py new file mode 100644 index 0000000..277676c --- /dev/null +++ b/tienda_ilusion/don_confiao/models/geography.py @@ -0,0 +1,54 @@ +from django.db import models + + +class Country(models.Model): + name = models.CharField(max_length=100, unique=True) + code = models.CharField( + max_length=3, unique=True, null=True, blank=True + ) + + class Meta: + verbose_name = "Country" + verbose_name_plural = "Countries" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Department(models.Model): + name = models.CharField(max_length=100, unique=True) + country = models.ForeignKey( + Country, on_delete=models.PROTECT, related_name="departments" + ) + + class Meta: + verbose_name = "Department" + verbose_name_plural = "Departments" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Municipality(models.Model): + name = models.CharField(max_length=100) + department = models.ForeignKey( + Department, on_delete=models.PROTECT, related_name="municipalities" + ) + country = models.ForeignKey( + Country, on_delete=models.PROTECT, related_name="municipalities" + ) + + class Meta: + verbose_name = "Municipality" + verbose_name_plural = "Municipalities" + ordering = ["name"] + constraints = [ + models.UniqueConstraint( + fields=["name", "department"], name="unique_municipality_name_per_department" + ) + ] + + def __str__(self): + return self.name diff --git a/tienda_ilusion/don_confiao/models/products.py b/tienda_ilusion/don_confiao/models/products.py index 9cc63f4..7260f6a 100644 --- a/tienda_ilusion/don_confiao/models/products.py +++ b/tienda_ilusion/don_confiao/models/products.py @@ -26,6 +26,9 @@ class Product(models.Model): max_length=100, null=True, blank=True ) categories = models.ManyToManyField(ProductCategory) + suppliers = models.ManyToManyField( + "Supplier", blank=True, related_name="products" + ) external_id = models.CharField(max_length=100, null=True, blank=True) def __str__(self): diff --git a/tienda_ilusion/don_confiao/models/provenance.py b/tienda_ilusion/don_confiao/models/provenance.py new file mode 100644 index 0000000..221e44c --- /dev/null +++ b/tienda_ilusion/don_confiao/models/provenance.py @@ -0,0 +1,53 @@ +from django.db import models + +from .geography import Municipality + + +class Organization(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(null=True, blank=True) + website = models.CharField(max_length=255, null=True, blank=True) + contact_email = models.CharField(max_length=255, null=True, blank=True) + contact_phone = models.CharField(max_length=100, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Organization" + verbose_name_plural = "Organizations" + ordering = ["name"] + + def __str__(self): + return self.name + + +class Supplier(models.Model): + name = models.CharField(max_length=100, unique=True) + description = models.TextField(null=True, blank=True) + organization = models.ForeignKey( + Organization, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="suppliers", + ) + municipality = models.ForeignKey( + Municipality, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="suppliers", + ) + website = models.CharField(max_length=255, null=True, blank=True) + contact_email = models.CharField(max_length=255, null=True, blank=True) + contact_phone = models.CharField(max_length=100, null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Supplier" + verbose_name_plural = "Suppliers" + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/tienda_ilusion/don_confiao/serializers/__init__.py b/tienda_ilusion/don_confiao/serializers/__init__.py index 83f093b..46480a9 100644 --- a/tienda_ilusion/don_confiao/serializers/__init__.py +++ b/tienda_ilusion/don_confiao/serializers/__init__.py @@ -17,6 +17,8 @@ from .payments import ( PaymentMethodSerializer, ) from .store_settings import StoreSettingsSerializer +from .geography import CountrySerializer, DepartmentSerializer, MunicipalitySerializer +from .provenance import OrganizationSerializer, SupplierSerializer __all__ = [ # Catalogue Images @@ -42,4 +44,11 @@ __all__ = [ "PaymentMethodSerializer", # Store Settings "StoreSettingsSerializer", + # Geography + "CountrySerializer", + "DepartmentSerializer", + "MunicipalitySerializer", + # Provenance + "OrganizationSerializer", + "SupplierSerializer", ] diff --git a/tienda_ilusion/don_confiao/serializers/geography.py b/tienda_ilusion/don_confiao/serializers/geography.py new file mode 100644 index 0000000..5d93de0 --- /dev/null +++ b/tienda_ilusion/don_confiao/serializers/geography.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from ..models.geography import Country, Department, Municipality + + +class CountrySerializer(serializers.ModelSerializer): + class Meta: + model = Country + fields = ["id", "name", "code"] + + +class DepartmentSerializer(serializers.ModelSerializer): + class Meta: + model = Department + fields = ["id", "name", "country"] + + +class MunicipalitySerializer(serializers.ModelSerializer): + class Meta: + model = Municipality + fields = ["id", "name", "department", "country"] diff --git a/tienda_ilusion/don_confiao/serializers/products.py b/tienda_ilusion/don_confiao/serializers/products.py index e026cb4..d43c07c 100644 --- a/tienda_ilusion/don_confiao/serializers/products.py +++ b/tienda_ilusion/don_confiao/serializers/products.py @@ -1,10 +1,14 @@ from rest_framework import serializers from ..models.products import Product, ProductCategory +from ..models.provenance import Supplier class ProductSerializer(serializers.ModelSerializer): catalogue_images = serializers.SerializerMethodField() + suppliers = serializers.PrimaryKeyRelatedField( + many=True, queryset=Supplier.objects.all(), required=False + ) class Meta: model = Product @@ -15,6 +19,7 @@ class ProductSerializer(serializers.ModelSerializer): "price", "measuring_unit", "categories", + "suppliers", "external_id", "catalogue_images", ] diff --git a/tienda_ilusion/don_confiao/serializers/provenance.py b/tienda_ilusion/don_confiao/serializers/provenance.py new file mode 100644 index 0000000..f1685da --- /dev/null +++ b/tienda_ilusion/don_confiao/serializers/provenance.py @@ -0,0 +1,93 @@ +from rest_framework import serializers + +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier + + +class CountryBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Country + fields = ["id", "name", "code"] + + +class DepartmentBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Department + fields = ["id", "name"] + + +class MunicipalityBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Municipality + fields = ["id", "name"] + + +class OrganizationSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = [ + "id", + "name", + "description", + "website", + "contact_email", + "contact_phone", + "created_at", + "updated_at", + ] + + +class OrganizationBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Organization + fields = [ + "id", + "name", + "description", + "website", + "contact_email", + "contact_phone", + ] + + +class SupplierSerializer(serializers.ModelSerializer): + organization_detail = OrganizationBriefSerializer( + source="organization", read_only=True + ) + municipality_detail = MunicipalityBriefSerializer( + source="municipality", read_only=True + ) + products = serializers.PrimaryKeyRelatedField( + many=True, read_only=True + ) + + class Meta: + model = Supplier + fields = [ + "id", + "name", + "description", + "organization", + "organization_detail", + "municipality", + "municipality_detail", + "website", + "contact_email", + "contact_phone", + "products", + "created_at", + "updated_at", + ] + + +class SupplierBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Supplier + fields = [ + "id", + "name", + "description", + "website", + "contact_email", + "contact_phone", + ] diff --git a/tienda_ilusion/don_confiao/serializers/sales.py b/tienda_ilusion/don_confiao/serializers/sales.py index c244bbb..75113e6 100644 --- a/tienda_ilusion/don_confiao/serializers/sales.py +++ b/tienda_ilusion/don_confiao/serializers/sales.py @@ -1,6 +1,7 @@ from rest_framework import serializers from django.urls import reverse +from ..models.products import Product from ..models.sales import ( Sale, SaleLine, @@ -10,6 +11,7 @@ from ..models.sales import ( ) from .products import ListProductSerializer from .customers import ListCustomerSerializer +from ..services.provenance import build_product_provenance class PublicSummaryLinkMixin: @@ -23,6 +25,16 @@ class PublicSummaryLinkMixin: return path +class ProvenanceSummaryMixin: + def get_product_provenance(self, obj): + products = Product.objects.filter( + saleline__sale=obj + ).distinct() + return build_product_provenance( + products, self.context.get("request") + ) + + class SaleLineSerializer(serializers.ModelSerializer): class Meta: model = SaleLine @@ -101,14 +113,26 @@ class SummarySaleLineSerializer(serializers.ModelSerializer): fields = ["product", "quantity", "unit_price", "description"] -class SaleSummarySerializer(PublicSummaryLinkMixin, serializers.ModelSerializer): +class SaleSummarySerializer( + PublicSummaryLinkMixin, ProvenanceSummaryMixin, serializers.ModelSerializer +): customer = ListCustomerSerializer() lines = SummarySaleLineSerializer(many=True, source="saleline_set") link = serializers.SerializerMethodField() + product_provenance = serializers.SerializerMethodField() class Meta: model = Sale - fields = ["id", "code", "date", "customer", "payment_method", "lines", "link"] + fields = [ + "id", + "code", + "date", + "customer", + "payment_method", + "lines", + "link", + "product_provenance", + ] class CatalogSummarySaleLineSerializer(serializers.ModelSerializer): @@ -119,18 +143,31 @@ class CatalogSummarySaleLineSerializer(serializers.ModelSerializer): fields = ["product", "quantity", "unit_price", "description"] +class CatalogProvenanceSummaryMixin(ProvenanceSummaryMixin): + def get_product_provenance(self, obj): + products = Product.objects.filter( + catalogsaleline__catalog_sale=obj + ).distinct() + return build_product_provenance( + products, self.context.get("request") + ) + + class CatalogSaleSummarySerializer( - PublicSummaryLinkMixin, serializers.ModelSerializer + PublicSummaryLinkMixin, + CatalogProvenanceSummaryMixin, + serializers.ModelSerializer, ): customer = ListCustomerSerializer() lines = CatalogSummarySaleLineSerializer( many=True, source="catalogsaleline_set" ) link = serializers.SerializerMethodField() + product_provenance = serializers.SerializerMethodField() class Meta: model = CatalogSale - fields = ["id", "code", "date", "customer", "lines", "link"] + fields = ["id", "code", "date", "customer", "lines", "link", "product_provenance"] class SaleForRenconciliationSerializer(serializers.Serializer): diff --git a/tienda_ilusion/don_confiao/services/provenance.py b/tienda_ilusion/don_confiao/services/provenance.py new file mode 100644 index 0000000..ab37c1a --- /dev/null +++ b/tienda_ilusion/don_confiao/services/provenance.py @@ -0,0 +1,64 @@ +from ..serializers.provenance import ( + CountryBriefSerializer, + DepartmentBriefSerializer, + MunicipalityBriefSerializer, + OrganizationBriefSerializer, + SupplierBriefSerializer, +) +from ..serializers.products import ListProductSerializer + + +def build_product_provenance(products, request=None): + """Domain data for provenance charts. + + Returns a list of products, each with its suppliers. Each supplier + entry includes the linked organization and the territorial hierarchy + (municipality -> department -> country) derived from the supplier's + municipality. + """ + result = [] + for product in products: + suppliers = [] + supplier_qs = product.suppliers.all().select_related( + "organization", "municipality__department__country" + ) + for supplier in supplier_qs: + municipality = supplier.municipality + entry = { + "supplier": SupplierBriefSerializer(supplier).data, + "organization": ( + OrganizationBriefSerializer(supplier.organization).data + if supplier.organization + else None + ), + "municipality": ( + MunicipalityBriefSerializer(municipality).data + if municipality + else None + ), + "department": ( + DepartmentBriefSerializer( + municipality.department + ).data + if municipality + else None + ), + "country": ( + CountryBriefSerializer( + municipality.department.country + ).data + if municipality + else None + ), + } + suppliers.append(entry) + + result.append( + { + "product": ListProductSerializer( + product, context={"request": request} + ).data, + "suppliers": suppliers, + } + ) + return result diff --git a/tienda_ilusion/don_confiao/tests/test_geography_models.py b/tienda_ilusion/don_confiao/tests/test_geography_models.py new file mode 100644 index 0000000..eb5c77f --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_geography_models.py @@ -0,0 +1,109 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase + +from ..models.geography import Country, Department, Municipality + + +class TestCountryModel(TestCase): + def test_create_country(self): + country = Country.objects.create(name="Colombia", code="CO") + self.assertEqual(country.name, "Colombia") + self.assertEqual(country.code, "CO") + self.assertEqual(str(country), "Colombia") + + def test_country_name_is_unique(self): + Country.objects.create(name="Colombia", code="CO") + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Country.objects.create(name="Colombia", code="CO2") + + def test_country_code_is_unique(self): + Country.objects.create(name="Colombia", code="CO") + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Country.objects.create(name="Colombia2", code="CO") + + +class TestDepartmentModel(TestCase): + def setUp(self): + self.country = Country.objects.create(name="Colombia", code="CO") + + def test_create_department(self): + department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + self.assertEqual(department.country, self.country) + + def test_department_requires_country(self): + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Department.objects.create(name="Antioquia") + + def test_department_name_is_unique(self): + Department.objects.create( + name="Cundinamarca", country=self.country + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Department.objects.create( + name="Cundinamarca", country=self.country + ) + + +class TestMunicipalityModel(TestCase): + def setUp(self): + self.country = Country.objects.create(name="Colombia", code="CO") + self.department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + + def test_create_municipality(self): + municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.assertEqual(municipality.department, self.department) + self.assertEqual(municipality.country, self.country) + self.assertEqual(municipality.department.country.name, "Colombia") + self.assertEqual(str(municipality), "La Mesa") + + def test_municipality_requires_department_and_country(self): + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Municipality.objects.create(name="La Mesa") + + def test_municipality_name_is_unique_per_department(self): + Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + + def test_municipality_same_name_allowed_in_different_department( + self, + ): + department2 = Department.objects.create( + name="Antioquia", country=self.country + ) + Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + municipality2 = Municipality.objects.create( + name="La Mesa", + department=department2, + country=self.country, + ) + self.assertEqual( + Municipality.objects.filter(name="La Mesa").count(), 2 + ) + self.assertEqual(municipality2.department, department2) diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_api.py b/tienda_ilusion/don_confiao/tests/test_provenance_api.py new file mode 100644 index 0000000..0982d2d --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_provenance_api.py @@ -0,0 +1,325 @@ +from django.contrib.auth.models import User +from rest_framework import status +from rest_framework.test import APIClient, APITestCase +from rest_framework_simplejwt.tokens import RefreshToken + +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier +from ..models.products import Product +from .Mixins import LoginMixin + + +def _create_user(username, user_type): + user = User.objects.create_user( + username=username, password="password123" + ) + user.profile.user_type = user_type + user.profile.save() + return user + + +def _client_for(user): + refresh = RefreshToken.for_user(user) + client = APIClient() + client.credentials( + HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}" + ) + return client + + +class TestProvenanceAPIPermissions(APITestCase, LoginMixin): + resources = [ + "/don_confiao/api/organizations/", + "/don_confiao/api/suppliers/", + "/don_confiao/api/countries/", + "/don_confiao/api/departments/", + "/don_confiao/api/municipalities/", + ] + + def test_anonymous_get_is_forbidden(self): + for url in self.resources: + response = self.client.get(url) + self.assertEqual( + response.status_code, + status.HTTP_401_UNAUTHORIZED, + url, + ) + + def test_anonymous_write_is_forbidden(self): + for url in self.resources: + response = self.client.post( + url, {"name": "x"}, format="json" + ) + self.assertEqual( + response.status_code, + status.HTTP_401_UNAUTHORIZED, + url, + ) + + def test_normal_user_can_read(self): + user = _create_user("normal", "user") + client = _client_for(user) + for url in self.resources: + response = client.get(url) + self.assertEqual( + response.status_code, status.HTTP_200_OK, url + ) + + def test_publico_user_cannot_read(self): + user = _create_user("publico", "publico") + client = _client_for(user) + for url in self.resources: + response = client.get(url) + self.assertEqual( + response.status_code, status.HTTP_403_FORBIDDEN, url + ) + + def test_normal_user_cannot_write(self): + user = _create_user("normal", "user") + client = _client_for(user) + for url in self.resources: + response = client.post( + url, {"name": "x"}, format="json" + ) + self.assertEqual( + response.status_code, status.HTTP_403_FORBIDDEN, url + ) + + def test_publico_user_cannot_write(self): + user = _create_user("publico", "publico") + client = _client_for(user) + for url in self.resources: + response = client.post( + url, {"name": "x"}, format="json" + ) + self.assertEqual( + response.status_code, status.HTTP_403_FORBIDDEN, url + ) + + +class TestProvenanceCRUD(APITestCase, LoginMixin): + def setUp(self): + self.login() + self.country = Country.objects.create(name="Colombia", code="CO") + self.department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + self.municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.organization = Organization.objects.create(name="Asociación") + self.supplier = Supplier.objects.create( + name="Proveedor 1", + organization=self.organization, + municipality=self.municipality, + ) + + def test_admin_can_create_organization(self): + response = self.client.post( + "/don_confiao/api/organizations/", + {"name": "Org 2"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + Organization.objects.filter(name="Org 2").count(), 1 + ) + + def test_admin_can_update_organization(self): + response = self.client.patch( + f"/don_confiao/api/organizations/{self.organization.id}/", + {"description": "Nueva descripción"}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.organization.refresh_from_db() + self.assertEqual( + self.organization.description, "Nueva descripción" + ) + + def test_admin_can_delete_organization(self): + response = self.client.delete( + f"/don_confiao/api/organizations/{self.organization.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse( + Organization.objects.filter(pk=self.organization.id).exists() + ) + + def test_admin_can_create_supplier(self): + response = self.client.post( + "/don_confiao/api/suppliers/", + { + "name": "Proveedor 2", + "organization": self.organization.id, + "municipality": self.municipality.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + supplier = Supplier.objects.get(name="Proveedor 2") + self.assertEqual(supplier.organization, self.organization) + self.assertEqual(supplier.municipality, self.municipality) + + def test_admin_can_unlink_supplier_organization(self): + response = self.client.patch( + f"/don_confiao/api/suppliers/{self.supplier.id}/", + {"organization": None}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.supplier.refresh_from_db() + self.assertIsNone(self.supplier.organization) + + def test_admin_can_unlink_supplier_municipality(self): + response = self.client.patch( + f"/don_confiao/api/suppliers/{self.supplier.id}/", + {"municipality": None}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.supplier.refresh_from_db() + self.assertIsNone(self.supplier.municipality) + + def test_admin_can_create_department(self): + response = self.client.post( + "/don_confiao/api/departments/", + {"name": "Antioquia", "country": self.country.id}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + Department.objects.filter(name="Antioquia").count(), 1 + ) + + def test_admin_can_create_municipality(self): + response = self.client.post( + "/don_confiao/api/municipalities/", + { + "name": "El Colegio", + "department": self.department.id, + "country": self.country.id, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + Municipality.objects.filter(name="El Colegio").count(), 1 + ) + + def test_supplier_serializer_includes_details(self): + response = self.client.get( + f"/don_confiao/api/suppliers/{self.supplier.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["organization_detail"]["name"], "Asociación") + self.assertEqual(data["municipality_detail"]["name"], "La Mesa") + self.assertIn("products", data) + + def test_link_product_to_suppliers_via_product_api(self): + product = Product.objects.create(name="Panela", price=5000) + supplier2 = Supplier.objects.create(name="Proveedor 2") + response = self.client.patch( + f"/don_confiao/api/products/{product.id}/", + {"suppliers": [self.supplier.id, supplier2.id]}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + product.refresh_from_db() + self.assertEqual( + set(product.suppliers.all()), {self.supplier, supplier2} + ) + + def test_unlink_product_supplier_via_product_api(self): + product = Product.objects.create(name="Panela", price=5000) + product.suppliers.add(self.supplier) + response = self.client.patch( + f"/don_confiao/api/products/{product.id}/", + {"suppliers": []}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + product.refresh_from_db() + self.assertEqual(product.suppliers.count(), 0) + + +class TestSeedGeography(APITestCase, LoginMixin): + url = "/don_confiao/api/seed_geography" + + def _payload(self): + return { + "country": {"name": "Colombia", "code": "CO"}, + "departments": [{"name": "Cundinamarca", "code": "CO-CUN"}], + "municipalities": [ + {"name": "La Mesa", "department": "Cundinamarca"}, + {"name": "El Colegio", "department": "Cundinamarca"}, + ], + } + + def test_seed_requires_authentication(self): + response = self.client.post( + self.url, self._payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_seed_requires_admin(self): + user = _create_user("normal", "user") + client = _client_for(user) + response = client.post(self.url, self._payload(), format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_seed_creates_geography(self): + self.login() + response = self.client.post( + self.url, self._payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["country"], "Colombia") + self.assertEqual(data["total_departments"], 1) + self.assertEqual(data["total_municipalities"], 2) + + def test_seed_is_idempotent(self): + self.login() + self.client.post(self.url, self._payload(), format="json") + response = self.client.post( + self.url, self._payload(), format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Country.objects.count(), 1) + self.assertEqual(Department.objects.count(), 1) + self.assertEqual(Municipality.objects.count(), 2) + + def test_seed_requires_country_name(self): + self.login() + response = self.client.post( + self.url, {"departments": []}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_seed_creates_duplicate_municipality_names_in_different_departments( + self, + ): + self.login() + payload = { + "country": {"name": "Colombia", "code": "CO"}, + "departments": [ + {"name": "Cundinamarca", "code": "CO-CUN"}, + {"name": "Antioquia", "code": "CO-ANT"}, + ], + "municipalities": [ + {"name": "Bolivar", "department": "Cundinamarca"}, + {"name": "Bolivar", "department": "Antioquia"}, + ], + } + response = self.client.post(self.url, payload, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["total_departments"], 2) + self.assertEqual(data["total_municipalities"], 2) + self.assertEqual( + Municipality.objects.filter(name="Bolivar").count(), 2 + ) diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py b/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py new file mode 100644 index 0000000..dd6c1de --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py @@ -0,0 +1,163 @@ +from datetime import datetime, timezone + +from rest_framework import status +from rest_framework.test import APITestCase + +from ..models.customers import Customer +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier +from ..models.products import Product +from ..models.sales import CatalogSale, Sale +from .Mixins import LoginMixin + + +class TestProvenanceInSummaries(APITestCase, LoginMixin): + def setUp(self): + self.country = Country.objects.create(name="Colombia", code="CO") + self.department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + self.municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.organization = Organization.objects.create(name="Asociación") + self.supplier = Supplier.objects.create( + name="Proveedor 1", + organization=self.organization, + municipality=self.municipality, + ) + self.product = Product.objects.create(name="Panela", price=5000) + self.product.suppliers.add(self.supplier) + self.customer = Customer.objects.create( + name="Camilo", external_id="18" + ) + + def _create_sale(self): + sale = Sale.objects.create( + customer=self.customer, + date=datetime(2024, 9, 2, tzinfo=timezone.utc), + payment_method="CASH", + ) + sale.saleline_set.create( + product=self.product, quantity=2, unit_price=3000 + ) + return sale + + def _create_catalog_sale(self): + catalog_sale = CatalogSale.objects.create( + customer=self.customer, + date=datetime(2024, 9, 2, tzinfo=timezone.utc), + customer_name="Camilo", + ) + catalog_sale.catalogsaleline_set.create( + product=self.product, quantity=2, unit_price=3000 + ) + return catalog_sale + + def test_public_sale_summary_includes_product_provenance(self): + sale = self._create_sale() + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIn("product_provenance", data) + self.assertEqual(len(data["product_provenance"]), 1) + entry = data["product_provenance"][0] + self.assertEqual(entry["product"]["name"], "Panela") + self.assertEqual(len(entry["suppliers"]), 1) + supplier = entry["suppliers"][0] + self.assertEqual(supplier["supplier"]["name"], "Proveedor 1") + self.assertEqual(supplier["organization"]["name"], "Asociación") + self.assertEqual(supplier["municipality"]["name"], "La Mesa") + self.assertEqual(supplier["department"]["name"], "Cundinamarca") + self.assertEqual(supplier["country"]["name"], "Colombia") + + def test_public_catalog_summary_includes_product_provenance(self): + catalog_sale = self._create_catalog_sale() + response = self.client.get( + f"/don_confiao/resumen_publico/{catalog_sale.code}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIn("product_provenance", data) + self.assertEqual( + len(data["product_provenance"][0]["suppliers"]), 1 + ) + + def test_authenticated_summaries_include_product_provenance(self): + self.login() + sale = self._create_sale() + response = self.client.get( + f"/don_confiao/resumen_compra_json/{sale.id}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("product_provenance", response.json()) + + catalog_sale = self._create_catalog_sale() + response = self.client.get( + f"/don_confiao/resumen_compra_catalogo_json/{catalog_sale.id}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn("product_provenance", response.json()) + + def test_summary_existing_fields_are_preserved(self): + sale = self._create_sale() + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + data = response.json() + for field in [ + "id", + "code", + "date", + "customer", + "payment_method", + "lines", + "link", + "type", + ]: + self.assertIn(field, data) + + def test_product_without_suppliers_returns_empty_list(self): + product2 = Product.objects.create(name="Café", price=6000) + sale = self._create_sale() + sale.saleline_set.create( + product=product2, quantity=1, unit_price=6000 + ) + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + data = response.json() + entry = next( + e + for e in data["product_provenance"] + if e["product"]["name"] == "Café" + ) + self.assertEqual(entry["suppliers"], []) + + def test_supplier_without_municipality_returns_null_territory(self): + supplier2 = Supplier.objects.create( + name="Proveedor sin ubicación" + ) + product2 = Product.objects.create(name="Café", price=6000) + product2.suppliers.add(supplier2) + sale = self._create_sale() + sale.saleline_set.create( + product=product2, quantity=1, unit_price=6000 + ) + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + data = response.json() + entry = next( + e + for e in data["product_provenance"] + if e["product"]["name"] == "Café" + ) + supplier = entry["suppliers"][0] + self.assertIsNone(supplier["municipality"]) + self.assertIsNone(supplier["department"]) + self.assertIsNone(supplier["country"]) diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_models.py b/tienda_ilusion/don_confiao/tests/test_provenance_models.py new file mode 100644 index 0000000..2c5c7d9 --- /dev/null +++ b/tienda_ilusion/don_confiao/tests/test_provenance_models.py @@ -0,0 +1,126 @@ +from django.db import IntegrityError, transaction +from django.test import TestCase + +from ..models.geography import Country, Department, Municipality +from ..models.provenance import Organization, Supplier +from ..models.products import Product + + +class TestOrganizationModel(TestCase): + def test_create_organization(self): + organization = Organization.objects.create( + name="Asociación La Mesa", + description="Cooperativa de campesinos", + ) + self.assertEqual(organization.name, "Asociación La Mesa") + self.assertEqual( + organization.description, "Cooperativa de campesinos" + ) + self.assertEqual(str(organization), "Asociación La Mesa") + + def test_update_organization(self): + organization = Organization.objects.create(name="Asociación") + organization.description = "Nueva descripción" + organization.save() + organization.refresh_from_db() + self.assertEqual(organization.description, "Nueva descripción") + + def test_delete_organization(self): + organization = Organization.objects.create(name="Asociación") + organization.delete() + self.assertFalse( + Organization.objects.filter(pk=organization.pk).exists() + ) + + def test_organization_name_is_unique(self): + Organization.objects.create(name="Asociación") + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Organization.objects.create(name="Asociación") + + +class TestSupplierModel(TestCase): + def setUp(self): + self.country = Country.objects.create(name="Colombia", code="CO") + self.department = Department.objects.create( + name="Cundinamarca", country=self.country + ) + self.municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.organization = Organization.objects.create(name="Asociación") + self.product = Product.objects.create(name="Panela", price=5000) + + def test_create_supplier(self): + supplier = Supplier.objects.create( + name="Proveedor 1", municipality=self.municipality + ) + self.assertEqual(supplier.municipality, self.municipality) + self.assertEqual( + supplier.municipality.department.country.name, "Colombia" + ) + + def test_supplier_organization_can_be_linked_and_unlinked(self): + supplier = Supplier.objects.create(name="Proveedor 1") + supplier.organization = self.organization + supplier.save() + supplier.refresh_from_db() + self.assertEqual(supplier.organization, self.organization) + + supplier.organization = None + supplier.save() + supplier.refresh_from_db() + self.assertIsNone(supplier.organization) + + def test_supplier_municipality_can_be_linked_and_unlinked(self): + supplier = Supplier.objects.create(name="Proveedor 1") + supplier.municipality = self.municipality + supplier.save() + supplier.refresh_from_db() + self.assertEqual(supplier.municipality, self.municipality) + + supplier.municipality = None + supplier.save() + supplier.refresh_from_db() + self.assertIsNone(supplier.municipality) + + def test_supplier_without_organization_or_municipality(self): + supplier = Supplier.objects.create(name="Proveedor 1") + self.assertIsNone(supplier.organization) + self.assertIsNone(supplier.municipality) + + def test_supplier_name_is_unique(self): + Supplier.objects.create(name="Proveedor 1") + with self.assertRaises(IntegrityError): + with transaction.atomic(): + Supplier.objects.create(name="Proveedor 1") + + def test_delete_organization_unlinks_supplier(self): + supplier = Supplier.objects.create( + name="Proveedor 1", organization=self.organization + ) + self.organization.delete() + supplier.refresh_from_db() + self.assertIsNone(supplier.organization) + + def test_delete_municipality_unlinks_supplier(self): + supplier = Supplier.objects.create( + name="Proveedor 1", municipality=self.municipality + ) + self.municipality.delete() + supplier.refresh_from_db() + self.assertIsNone(supplier.municipality) + + def test_product_can_be_linked_to_multiple_suppliers(self): + supplier1 = Supplier.objects.create(name="Proveedor 1") + supplier2 = Supplier.objects.create(name="Proveedor 2") + + self.product.suppliers.add(supplier1, supplier2) + self.assertEqual(self.product.suppliers.count(), 2) + + self.product.suppliers.remove(supplier1) + self.assertEqual(list(self.product.suppliers.all()), [supplier2]) + + self.assertEqual(set(supplier2.products.all()), {self.product}) diff --git a/tienda_ilusion/don_confiao/urls.py b/tienda_ilusion/don_confiao/urls.py index 8fcd7f6..3547445 100644 --- a/tienda_ilusion/don_confiao/urls.py +++ b/tienda_ilusion/don_confiao/urls.py @@ -29,6 +29,13 @@ from .api import ( AdminCodeValidateView, # Store Settings StoreSettingsView, + # Provenance + OrganizationView, + SupplierView, + CountryView, + DepartmentView, + MunicipalityView, + SeedGeographyView, ) app_name = "don_confiao" @@ -48,6 +55,11 @@ router.register( ReconciliateJarModelView, basename="reconciliate_jar", ) +router.register(r"organizations", OrganizationView, basename="organization") +router.register(r"suppliers", SupplierView, basename="supplier") +router.register(r"countries", CountryView, basename="country") +router.register(r"departments", DepartmentView, basename="department") +router.register(r"municipalities", MunicipalityView, basename="municipality") urlpatterns = [ path("productos", views.products, name="products"), @@ -109,4 +121,9 @@ urlpatterns = [ ), path("api/sales/for_tryton", SalesForTrytonView.as_view()), path("api/store_settings", StoreSettingsView.as_view()), + path( + "api/seed_geography", + SeedGeographyView.as_view(), + name="seed_geography", + ), ] From 699983931e6a4b8f3e45177210f0d33833756319 Mon Sep 17 00:00:00 2001 From: monomono Date: Sun, 16 Aug 2026 03:23:56 -0500 Subject: [PATCH 3/5] feat(#49): return FK details in department and municipality serializers - DepartmentSerializer adds country_detail (nested country object) - MunicipalitySerializer adds department_detail and country_detail - Writable FK id fields kept for create/update - Move geography brief serializers to serializers/geography.py --- .../don_confiao/serializers/geography.py | 31 +++++++++++++++++-- .../don_confiao/serializers/provenance.py | 24 +++----------- .../don_confiao/services/provenance.py | 4 ++- .../don_confiao/tests/test_provenance_api.py | 22 +++++++++++++ 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/tienda_ilusion/don_confiao/serializers/geography.py b/tienda_ilusion/don_confiao/serializers/geography.py index 5d93de0..e1a5abb 100644 --- a/tienda_ilusion/don_confiao/serializers/geography.py +++ b/tienda_ilusion/don_confiao/serializers/geography.py @@ -9,13 +9,38 @@ class CountrySerializer(serializers.ModelSerializer): fields = ["id", "name", "code"] -class DepartmentSerializer(serializers.ModelSerializer): +class CountryBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Country + fields = ["id", "name", "code"] + + +class DepartmentBriefSerializer(serializers.ModelSerializer): class Meta: model = Department - fields = ["id", "name", "country"] + fields = ["id", "name"] + + +class MunicipalityBriefSerializer(serializers.ModelSerializer): + class Meta: + model = Municipality + fields = ["id", "name"] + + +class DepartmentSerializer(serializers.ModelSerializer): + country_detail = CountryBriefSerializer(source="country", read_only=True) + + class Meta: + model = Department + fields = ["id", "name", "country", "country_detail"] class MunicipalitySerializer(serializers.ModelSerializer): + department_detail = DepartmentBriefSerializer( + source="department", read_only=True + ) + country_detail = CountryBriefSerializer(source="country", read_only=True) + class Meta: model = Municipality - fields = ["id", "name", "department", "country"] + fields = ["id", "name", "department", "department_detail", "country", "country_detail"] diff --git a/tienda_ilusion/don_confiao/serializers/provenance.py b/tienda_ilusion/don_confiao/serializers/provenance.py index f1685da..473d9e9 100644 --- a/tienda_ilusion/don_confiao/serializers/provenance.py +++ b/tienda_ilusion/don_confiao/serializers/provenance.py @@ -1,25 +1,11 @@ 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"] +from .geography import ( + CountryBriefSerializer, + DepartmentBriefSerializer, + MunicipalityBriefSerializer, +) class OrganizationSerializer(serializers.ModelSerializer): diff --git a/tienda_ilusion/don_confiao/services/provenance.py b/tienda_ilusion/don_confiao/services/provenance.py index ab37c1a..5058ed4 100644 --- a/tienda_ilusion/don_confiao/services/provenance.py +++ b/tienda_ilusion/don_confiao/services/provenance.py @@ -1,7 +1,9 @@ -from ..serializers.provenance import ( +from ..serializers.geography import ( CountryBriefSerializer, DepartmentBriefSerializer, MunicipalityBriefSerializer, +) +from ..serializers.provenance import ( OrganizationBriefSerializer, SupplierBriefSerializer, ) diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_api.py b/tienda_ilusion/don_confiao/tests/test_provenance_api.py index 0982d2d..abcd719 100644 --- a/tienda_ilusion/don_confiao/tests/test_provenance_api.py +++ b/tienda_ilusion/don_confiao/tests/test_provenance_api.py @@ -219,6 +219,28 @@ class TestProvenanceCRUD(APITestCase, LoginMixin): self.assertEqual(data["municipality_detail"]["name"], "La Mesa") self.assertIn("products", data) + def test_department_serializer_includes_country_detail(self): + response = self.client.get( + f"/don_confiao/api/departments/{self.department.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["country"], self.country.id) + self.assertEqual(data["country_detail"]["name"], "Colombia") + self.assertEqual(data["country_detail"]["code"], "CO") + + def test_municipality_serializer_includes_fk_details(self): + response = self.client.get( + f"/don_confiao/api/municipalities/{self.municipality.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(data["department"], self.department.id) + self.assertEqual(data["department_detail"]["name"], "Cundinamarca") + self.assertEqual(data["country"], self.country.id) + self.assertEqual(data["country_detail"]["name"], "Colombia") + self.assertEqual(data["country_detail"]["code"], "CO") + 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") From 3a0c76ce6993bfc35afdd5ada28846a123d95d28 Mon Sep 17 00:00:00 2001 From: monomono Date: Sun, 16 Aug 2026 04:10:45 -0500 Subject: [PATCH 4/5] feat(#49): add geolocation (latitude/longitude) to municipalities - Municipality model with optional latitude/longitude decimal fields - Municipality and brief serializers expose lat/lng (also in product_provenance of public/authenticated summaries for the Leaflet map) - seed_geography endpoint reads lat/lng and updates existing municipalities on reseed (idempotent) - TDD tests for model, serializers, summaries and seed --- tienda_ilusion/don_confiao/api/provenance.py | 12 ++++- ...ipality_latitude_municipality_longitude.py | 23 ++++++++ .../don_confiao/models/geography.py | 6 +++ .../don_confiao/serializers/geography.py | 13 ++++- .../tests/test_geography_models.py | 20 +++++++ .../don_confiao/tests/test_provenance_api.py | 52 +++++++++++++++++++ .../tests/test_provenance_graphs.py | 17 ++++++ 7 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 tienda_ilusion/don_confiao/migrations/0054_municipality_latitude_municipality_longitude.py diff --git a/tienda_ilusion/don_confiao/api/provenance.py b/tienda_ilusion/don_confiao/api/provenance.py index f497580..cef3259 100644 --- a/tienda_ilusion/don_confiao/api/provenance.py +++ b/tienda_ilusion/don_confiao/api/provenance.py @@ -92,7 +92,7 @@ class SeedGeographyView(APIView): if created: departments_created += 1 - _, created = Municipality.objects.get_or_create( + municipality, created = Municipality.objects.get_or_create( name=municipality_data["name"], department=department, defaults={"country": country}, @@ -100,6 +100,16 @@ class SeedGeographyView(APIView): if created: municipalities_created += 1 + latitude = municipality_data.get("latitude") + longitude = municipality_data.get("longitude") + if latitude is not None or longitude is not None: + municipality.country = country + if latitude is not None: + municipality.latitude = latitude + if longitude is not None: + municipality.longitude = longitude + municipality.save() + return Response( { "country": country.name, diff --git a/tienda_ilusion/don_confiao/migrations/0054_municipality_latitude_municipality_longitude.py b/tienda_ilusion/don_confiao/migrations/0054_municipality_latitude_municipality_longitude.py new file mode 100644 index 0000000..fd89a4d --- /dev/null +++ b/tienda_ilusion/don_confiao/migrations/0054_municipality_latitude_municipality_longitude.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.6 on 2026-08-16 08:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('don_confiao', '0053_country_organization_department_municipality_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='municipality', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True), + ), + migrations.AddField( + model_name='municipality', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True), + ), + ] diff --git a/tienda_ilusion/don_confiao/models/geography.py b/tienda_ilusion/don_confiao/models/geography.py index 277676c..6459caf 100644 --- a/tienda_ilusion/don_confiao/models/geography.py +++ b/tienda_ilusion/don_confiao/models/geography.py @@ -39,6 +39,12 @@ class Municipality(models.Model): country = models.ForeignKey( Country, on_delete=models.PROTECT, related_name="municipalities" ) + latitude = models.DecimalField( + max_digits=10, decimal_places=7, null=True, blank=True + ) + longitude = models.DecimalField( + max_digits=10, decimal_places=7, null=True, blank=True + ) class Meta: verbose_name = "Municipality" diff --git a/tienda_ilusion/don_confiao/serializers/geography.py b/tienda_ilusion/don_confiao/serializers/geography.py index e1a5abb..c53559c 100644 --- a/tienda_ilusion/don_confiao/serializers/geography.py +++ b/tienda_ilusion/don_confiao/serializers/geography.py @@ -24,7 +24,7 @@ class DepartmentBriefSerializer(serializers.ModelSerializer): class MunicipalityBriefSerializer(serializers.ModelSerializer): class Meta: model = Municipality - fields = ["id", "name"] + fields = ["id", "name", "latitude", "longitude"] class DepartmentSerializer(serializers.ModelSerializer): @@ -43,4 +43,13 @@ class MunicipalitySerializer(serializers.ModelSerializer): class Meta: model = Municipality - fields = ["id", "name", "department", "department_detail", "country", "country_detail"] + fields = [ + "id", + "name", + "department", + "department_detail", + "country", + "country_detail", + "latitude", + "longitude", + ] diff --git a/tienda_ilusion/don_confiao/tests/test_geography_models.py b/tienda_ilusion/don_confiao/tests/test_geography_models.py index eb5c77f..3712628 100644 --- a/tienda_ilusion/don_confiao/tests/test_geography_models.py +++ b/tienda_ilusion/don_confiao/tests/test_geography_models.py @@ -68,6 +68,26 @@ class TestMunicipalityModel(TestCase): self.assertEqual(municipality.department.country.name, "Colombia") self.assertEqual(str(municipality), "La Mesa") + def test_municipality_geolocation_fields(self): + municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + latitude=4.63092, + longitude=-74.39152, + ) + self.assertEqual(municipality.latitude, 4.63092) + self.assertEqual(municipality.longitude, -74.39152) + + def test_municipality_geolocation_optional(self): + municipality = Municipality.objects.create( + name="La Mesa", + department=self.department, + country=self.country, + ) + self.assertIsNone(municipality.latitude) + self.assertIsNone(municipality.longitude) + def test_municipality_requires_department_and_country(self): with self.assertRaises(IntegrityError): with transaction.atomic(): diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_api.py b/tienda_ilusion/don_confiao/tests/test_provenance_api.py index abcd719..2a7c5cb 100644 --- a/tienda_ilusion/don_confiao/tests/test_provenance_api.py +++ b/tienda_ilusion/don_confiao/tests/test_provenance_api.py @@ -241,6 +241,27 @@ class TestProvenanceCRUD(APITestCase, LoginMixin): self.assertEqual(data["country_detail"]["name"], "Colombia") self.assertEqual(data["country_detail"]["code"], "CO") + def test_municipality_serializer_includes_geolocation(self): + self.municipality.latitude = 4.63092 + self.municipality.longitude = -74.39152 + self.municipality.save() + response = self.client.get( + f"/don_confiao/api/municipalities/{self.municipality.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertEqual(float(data["latitude"]), 4.63092) + self.assertEqual(float(data["longitude"]), -74.39152) + + def test_municipality_serializer_geolocation_optional(self): + response = self.client.get( + f"/don_confiao/api/municipalities/{self.municipality.id}/" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + data = response.json() + self.assertIsNone(data["latitude"]) + self.assertIsNone(data["longitude"]) + 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") @@ -315,6 +336,37 @@ class TestSeedGeography(APITestCase, LoginMixin): self.assertEqual(Department.objects.count(), 1) self.assertEqual(Municipality.objects.count(), 2) + def test_seed_creates_municipality_with_geolocation(self): + self.login() + payload = self._payload() + payload["municipalities"][0] = { + "name": "La Mesa", + "department": "Cundinamarca", + "latitude": 4.63092, + "longitude": -74.39152, + } + response = self.client.post(self.url, payload, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + municipality = Municipality.objects.get(name="La Mesa") + self.assertEqual(float(municipality.latitude), 4.63092) + self.assertEqual(float(municipality.longitude), -74.39152) + + def test_seed_updates_geolocation_on_reseed(self): + self.login() + self.client.post(self.url, self._payload(), format="json") + payload = self._payload() + payload["municipalities"][0] = { + "name": "La Mesa", + "department": "Cundinamarca", + "latitude": 4.63092, + "longitude": -74.39152, + } + response = self.client.post(self.url, payload, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + municipality = Municipality.objects.get(name="La Mesa") + self.assertEqual(float(municipality.latitude), 4.63092) + self.assertEqual(float(municipality.longitude), -74.39152) + def test_seed_requires_country_name(self): self.login() response = self.client.post( diff --git a/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py b/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py index dd6c1de..67184ad 100644 --- a/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py +++ b/tienda_ilusion/don_confiao/tests/test_provenance_graphs.py @@ -75,6 +75,23 @@ class TestProvenanceInSummaries(APITestCase, LoginMixin): self.assertEqual(supplier["department"]["name"], "Cundinamarca") self.assertEqual(supplier["country"]["name"], "Colombia") + def test_public_summary_municipality_includes_geolocation(self): + self.municipality.latitude = 4.63092 + self.municipality.longitude = -74.39152 + self.municipality.save() + sale = self._create_sale() + response = self.client.get( + f"/don_confiao/resumen_publico/{sale.code}" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + supplier = response.json()["product_provenance"][0]["suppliers"][0] + self.assertEqual( + float(supplier["municipality"]["latitude"]), 4.63092 + ) + self.assertEqual( + float(supplier["municipality"]["longitude"]), -74.39152 + ) + def test_public_catalog_summary_includes_product_provenance(self): catalog_sale = self._create_catalog_sale() response = self.client.get( From 792ac4367cd94fa24555bdb2b993faa6151c7884 Mon Sep 17 00:00:00 2001 From: monomono Date: Sun, 16 Aug 2026 21:58:12 -0500 Subject: [PATCH 5/5] chore(#49): track seed script and geography CSV in git Allowlist scripts/ in .gitignore (was caught by [Ss]cripts venv rule) so seed_geography.py and colombia_municipios.csv can be committed. This simplifies deployment since the files no longer need to be copied separately to the server. --- .gitignore | 2 +- scripts/data/colombia_municipios.csv | 1121 ++++++++++++++++++++++++++ scripts/seed_geography.py | 188 +++++ 3 files changed, 1310 insertions(+), 1 deletion(-) create mode 100644 scripts/data/colombia_municipios.csv create mode 100644 scripts/seed_geography.py diff --git a/.gitignore b/.gitignore index 85499f0..d43ad49 100644 --- a/.gitignore +++ b/.gitignore @@ -321,7 +321,7 @@ pyrightconfig.json [Ll]ib [Ll]ib64 [Ll]ocal -[Ss]cripts +Scripts/ pyvenv.cfg pip-selfcheck.json diff --git a/scripts/data/colombia_municipios.csv b/scripts/data/colombia_municipios.csv new file mode 100644 index 0000000..ba4a686 --- /dev/null +++ b/scripts/data/colombia_municipios.csv @@ -0,0 +1,1121 @@ +department,municipality,latitude,longitude +ANTIOQUIA,MEDELLIN,6.250063,-75.578661 +ANTIOQUIA,ABEJORRAL,5.789315,-75.428739 +ANTIOQUIA,ABRIAQUI,6.631806,-76.064703 +ANTIOQUIA,ALEJANDRIA,6.376061,-75.141346 +ANTIOQUIA,AMAGA,6.038817,-75.702041 +ANTIOQUIA,AMALFI,6.912305,-75.082524 +ANTIOQUIA,ANDES,5.657194,-75.878828 +ANTIOQUIA,ANGELOPOLIS,6.109719,-75.711389 +ANTIOQUIA,ANGOSTURA,6.885175,-75.335116 +ANTIOQUIA,ANORI,7.074703,-75.148355 +ANTIOQUIA,ANZA,6.302641,-75.854442 +ANTIOQUIA,APARTADO,7.883257,-76.625135 +ANTIOQUIA,ARBOLETES,8.849317,-76.426708 +ANTIOQUIA,ARGELIA,5.730672,-75.141741 +ANTIOQUIA,ARMENIA,6.155889,-75.787072 +ANTIOQUIA,BARBOSA,6.439195,-75.331627 +ANTIOQUIA,BELMIRA,6.606319,-75.667779 +ANTIOQUIA,BELLO,6.333587,-75.555245 +ANTIOQUIA,BETANIA,5.744735,-75.975655 +ANTIOQUIA,BETULIA,6.115208,-75.984452 +ANTIOQUIA,CIUDAD BOLIVAR,5.850273,-76.021509 +ANTIOQUIA,BRICEÑO,7.114883,-75.54813 +ANTIOQUIA,BURITICA,6.71973,-75.907496 +ANTIOQUIA,CACERES,7.578366,-75.35205 +ANTIOQUIA,CAICEDO,6.405607,-75.98293 +ANTIOQUIA,CALDAS,6.091296,-75.633619 +ANTIOQUIA,CAMPAMENTO,6.979771,-75.298091 +ANTIOQUIA,CAÑASGORDAS,6.759478,-76.03221 +ANTIOQUIA,CARACOLI,6.409829,-74.757421 +ANTIOQUIA,CARAMANTA,5.54853,-75.643868 +ANTIOQUIA,CAREPA,7.755199,-76.652822 +ANTIOQUIA,EL CARMEN DE VIBORAL,6.083185,-75.334146 +ANTIOQUIA,CAROLINA,6.725995,-75.283192 +ANTIOQUIA,CAUCASIA,7.977278,-75.197996 +ANTIOQUIA,CHIGORODO,7.666199,-76.681276 +ANTIOQUIA,CISNEROS,6.537829,-75.087047 +ANTIOQUIA,COCORNA,6.058295,-75.185483 +ANTIOQUIA,CONCEPCION,6.394348,-75.257587 +ANTIOQUIA,CONCORDIA,6.045766,-75.908417 +ANTIOQUIA,COPACABANA,6.348654,-75.509309 +ANTIOQUIA,DABEIBA,7.000322,-76.262977 +ANTIOQUIA,EBEJICO,6.326128,-75.764165 +ANTIOQUIA,EL BAGRE,7.5975,-74.799097 +ANTIOQUIA,ENTRERRIOS,6.566273,-75.517685 +ANTIOQUIA,ENVIGADO,6.166891,-75.582766 +ANTIOQUIA,FREDONIA,5.924363,-75.670453 +ANTIOQUIA,FRONTINO,6.776066,-76.130765 +ANTIOQUIA,GIRALDO,6.680808,-75.952158 +ANTIOQUIA,GIRARDOTA,6.379487,-75.444235 +ANTIOQUIA,GOMEZ PLATA,6.683269,-75.220018 +ANTIOQUIA,GRANADA,6.142892,-75.184446 +ANTIOQUIA,GUADALUPE,6.814263,-75.240019 +ANTIOQUIA,GUARNE,6.27787,-75.441612 +ANTIOQUIA,GUATAPE,6.232669,-75.162293 +ANTIOQUIA,HELICONIA,6.206757,-75.734322 +ANTIOQUIA,HISPANIA,5.799461,-75.906587 +ANTIOQUIA,ITAGUI,6.169598,-75.614359 +ANTIOQUIA,ITUANGO,7.171629,-75.764673 +ANTIOQUIA,JARDIN,5.597542,-75.818982 +ANTIOQUIA,JERICO,5.789748,-75.785499 +ANTIOQUIA,LA CEJA,6.028062,-75.429433 +ANTIOQUIA,LA ESTRELLA,6.123327,-75.631869 +ANTIOQUIA,LA PINTADA,5.743808,-75.60781 +ANTIOQUIA,LA UNION,5.973845,-75.360874 +ANTIOQUIA,LIBORINA,6.677316,-75.812838 +ANTIOQUIA,MACEO,6.552116,-74.78716 +ANTIOQUIA,MARINILLA,6.172156,-75.33889 +ANTIOQUIA,MONTEBELLO,5.946313,-75.523455 +ANTIOQUIA,MURINDO,6.97771,-76.817485 +ANTIOQUIA,MUTATA,7.242875,-76.435875 +ANTIOQUIA,NARIÑO,5.610777,-75.176262 +ANTIOQUIA,NECOCLI,8.426139,-76.784588 +ANTIOQUIA,NECHI,8.094129,-74.77647 +ANTIOQUIA,OLAYA,6.627524,-75.812301 +ANTIOQUIA,PEÐOL,6.219349,-75.242693 +ANTIOQUIA,PEQUE,7.02102,-75.910347 +ANTIOQUIA,PUEBLORRICO,5.792984,-75.83737 +ANTIOQUIA,PUERTO BERRIO,6.48701,-74.409962 +ANTIOQUIA,PUERTO NARE,6.186025,-74.583012 +ANTIOQUIA,PUERTO TRIUNFO,5.871318,-74.64119 +ANTIOQUIA,REMEDIOS,7.026094,-74.695944 +ANTIOQUIA,RETIRO,6.062454,-75.501301 +ANTIOQUIA,RIONEGRO,6.147425,-75.376859 +ANTIOQUIA,SABANALARGA,6.850028,-75.816645 +ANTIOQUIA,SABANETA,6.149903,-75.615479 +ANTIOQUIA,SALGAR,5.965817,-75.982237 +ANTIOQUIA,SAN ANDRES DE CUERQUIA,6.912558,-75.676008 +ANTIOQUIA,SAN CARLOS,6.188449,-74.987831 +ANTIOQUIA,SAN FRANCISCO,5.961785,-75.101467 +ANTIOQUIA,SAN JERONIMO,6.44809,-75.726975 +ANTIOQUIA,SAN JOSE DE LA MONTAÑA,6.85009,-75.683352 +ANTIOQUIA,SAN JUAN DE URABA,8.758964,-76.52857 +ANTIOQUIA,SAN LUIS,6.043012,-74.993562 +ANTIOQUIA,SAN PEDRO,6.46012,-75.556743 +ANTIOQUIA,SAN PEDRO DE URABA,8.276884,-76.380567 +ANTIOQUIA,SAN RAFAEL,6.293759,-75.02849 +ANTIOQUIA,SAN ROQUE,6.485939,-75.019109 +ANTIOQUIA,SAN VICENTE,6.282164,-75.332616 +ANTIOQUIA,SANTA BARBARA,5.875527,-75.567351 +ANTIOQUIA,SANTA ROSA DE OSOS,6.643366,-75.460723 +ANTIOQUIA,SANTO DOMINGO,6.475646,-75.167142 +ANTIOQUIA,EL SANTUARIO,6.137058,-75.265851 +ANTIOQUIA,SEGOVIA,7.079648,-74.701596 +ANTIOQUIA,SONSON,5.714851,-75.309596 +ANTIOQUIA,SOPETRAN,6.500745,-75.747378 +ANTIOQUIA,TAMESIS,5.664645,-75.714429 +ANTIOQUIA,TARAZA,7.580127,-75.401407 +ANTIOQUIA,TARSO,5.863296,-75.823214 +ANTIOQUIA,TITIRIBI,6.06098,-75.791549 +ANTIOQUIA,TOLEDO,7.010154,-75.691442 +ANTIOQUIA,TURBO,8.093955,-76.725988 +ANTIOQUIA,URAMITA,6.898393,-76.173284 +ANTIOQUIA,URRAO,6.317343,-76.133951 +ANTIOQUIA,VALDIVIA,7.1652,-75.439274 +ANTIOQUIA,VALPARAISO,5.614555,-75.624452 +ANTIOQUIA,VEGACHI,6.773525,-74.798714 +ANTIOQUIA,VENECIA,5.964693,-75.735544 +ANTIOQUIA,VIGIA DEL FUERTE,6.588164,-76.896004 +ANTIOQUIA,YALI,6.676554,-74.840059 +ANTIOQUIA,YARUMAL,6.963832,-75.418828 +ANTIOQUIA,YOLOMBO,6.594511,-75.013385 +ANTIOQUIA,YONDO,7.00396,-73.912445 +ANTIOQUIA,ZARAGOZA,7.488583,-74.867075 +ATLANTICO,BARRANQUILLA,10.977998,-74.815324 +ATLANTICO,BARANOA,10.794447,-74.916073 +ATLANTICO,CAMPO DE LA CRUZ,10.37924,-74.883152 +ATLANTICO,CANDELARIA,10.461903,-74.879717 +ATLANTICO,GALAPA,10.919641,-74.866938 +ATLANTICO,JUAN DE ACOSTA,10.828909,-75.035031 +ATLANTICO,LURUACO,10.610652,-75.142023 +ATLANTICO,MALAMBO,10.851407,-74.778892 +ATLANTICO,MANATI,10.449089,-74.956867 +ATLANTICO,PALMAR DE VARELA,10.738591,-74.754765 +ATLANTICO,PIOJO,10.749216,-75.107592 +ATLANTICO,POLONUEVO,10.777894,-74.854029 +ATLANTICO,PONEDERA,10.641779,-74.753885 +ATLANTICO,PUERTO COLOMBIA,11.022918,-74.851953 +ATLANTICO,REPELON,10.493357,-75.125534 +ATLANTICO,SABANAGRANDE,10.791063,-74.75847 +ATLANTICO,SABANALARGA,10.632291,-74.92153 +ATLANTICO,SANTA LUCIA,10.323273,-74.958971 +ATLANTICO,SANTO TOMAS,10.756056,-74.754112 +ATLANTICO,SOLEDAD,10.909999,-74.785829 +ATLANTICO,SUAN,10.335432,-74.881687 +ATLANTICO,TUBARA,10.873586,-74.978704 +ATLANTICO,USIACURI,10.74298,-74.976985 +BOLIVAR,CARTAGENA,10.385126,-75.496269 +BOLIVAR,ACHI,8.569113,-74.557065 +BOLIVAR,ALTOS DEL ROSARIO,8.79187,-74.164902 +BOLIVAR,ARENAL,8.458865,-73.941099 +BOLIVAR,ARJONA,10.256633,-75.344501 +BOLIVAR,ARROYOHONDO,10.242798,-75.017193 +BOLIVAR,BARRANCO DE LOBA,8.947931,-74.105236 +BOLIVAR,CALAMAR,10.251963,-74.913629 +BOLIVAR,CANTAGALLO,7.378247,-73.914626 +BOLIVAR,CICUCO,9.274313,-74.646028 +BOLIVAR,CORDOBA,9.586926,-74.827391 +BOLIVAR,CLEMENCIA,10.567541,-75.328517 +BOLIVAR,EL CARMEN DE BOLIVAR,9.718573,-75.121231 +BOLIVAR,EL GUAMO,10.030874,-74.976102 +BOLIVAR,EL PEÑON,8.988271,-73.949274 +BOLIVAR,HATILLO DE LOBA,8.955708,-74.07833 +BOLIVAR,MAGANGUE,9.26854,-74.770175 +BOLIVAR,MAHATES,10.233316,-75.191583 +BOLIVAR,MARGARITA,9.15555,-74.280822 +BOLIVAR,MARIA LA BAJA,9.982441,-75.300528 +BOLIVAR,MONTECRISTO,8.299375,-74.468558 +BOLIVAR,NOROSI,8.526298,-74.038043 +BOLIVAR,MORALES,8.276519,-73.868178 +BOLIVAR,PINILLOS,8.914952,-74.462294 +BOLIVAR,REGIDOR,8.666258,-73.821638 +BOLIVAR,RIO VIEJO,8.58795,-73.840466 +BOLIVAR,SAN CRISTOBAL,10.392836,-75.065076 +BOLIVAR,SAN ESTANISLAO,10.398602,-75.153101 +BOLIVAR,SAN FERNANDO,9.214179,-74.323867 +BOLIVAR,SAN JACINTO,9.830275,-75.12105 +BOLIVAR,SAN JACINTO DEL CAUCA,8.251599,-74.721083 +BOLIVAR,SAN JUAN NEPOMUCENO,9.95338,-75.081402 +BOLIVAR,SAN MARTIN DE LOBA,8.937273,-74.039064 +BOLIVAR,SAN PABLO,7.476747,-73.924602 +BOLIVAR,SANTA CATALINA,10.605178,-75.287786 +BOLIVAR,SANTA ROSA,10.444396,-75.369824 +BOLIVAR,SANTA ROSA DEL SUR,7.963626,-74.052943 +BOLIVAR,SIMITI,7.9546,-73.946715 +BOLIVAR,SOPLAVIENTO,10.38839,-75.136404 +BOLIVAR,TALAIGUA NUEVO,9.304889,-74.567628 +BOLIVAR,TIQUISIO,8.557966,-74.26338 +BOLIVAR,TURBACO,10.33276,-75.424387 +BOLIVAR,TURBANA,10.274585,-75.44265 +BOLIVAR,VILLANUEVA,10.445091,-75.271108 +BOLIVAR,ZAMBRANO,9.746985,-74.813714 +BOYACA,TUNJA,5.539898,-73.355543 +BOYACA,ALMEIDA,4.970857,-73.378933 +BOYACA,AQUITANIA,5.518602,-72.88399 +BOYACA,ARCABUCO,5.755381,-73.438111 +BOYACA,BELEN,5.98923,-72.911641 +BOYACA,BERBEO,5.227451,-73.12721 +BOYACA,BETEITIVA,5.909978,-72.809014 +BOYACA,BOAVITA,6.330703,-72.584905 +BOYACA,BOYACA,5.454578,-73.361945 +BOYACA,BRICEÑO,5.690879,-73.92326 +BOYACA,BUENAVISTA,5.512594,-73.94217 +BOYACA,BUSBANZA,5.831393,-72.884158 +BOYACA,CALDAS,5.55458,-73.865553 +BOYACA,CAMPOHERMOSO,5.031676,-73.104173 +BOYACA,CERINZA,5.955939,-72.947918 +BOYACA,CHINAVITA,5.167486,-73.368476 +BOYACA,CHIQUINQUIRA,5.61379,-73.818745 +BOYACA,CHISCAS,6.553845,-72.501162 +BOYACA,CHITA,6.187083,-72.471892 +BOYACA,CHITARAQUE,6.027425,-73.4471 +BOYACA,CHIVATA,5.558949,-73.282529 +BOYACA,CIENEGA,5.408694,-73.296049 +BOYACA,COMBITA,5.634545,-73.323957 +BOYACA,COPER,5.475074,-74.045636 +BOYACA,CORRALES,5.828064,-72.844795 +BOYACA,COVARACHIA,6.500177,-72.738978 +BOYACA,CUBARA,7.000093,-72.110688 +BOYACA,CUCAITA,5.544452,-73.454338 +BOYACA,CUITIVA,5.580367,-72.965923 +BOYACA,CHIQUIZA,5.639834,-73.449463 +BOYACA,CHIVOR,4.887782,-73.366894 +BOYACA,DUITAMA,5.822964,-73.03063 +BOYACA,EL COCUY,6.407738,-72.444537 +BOYACA,EL ESPINO,6.483027,-72.497007 +BOYACA,FIRAVITOBA,5.668885,-72.993392 +BOYACA,FLORESTA,5.859519,-72.918111 +BOYACA,GACHANTIVA,5.751891,-73.549092 +BOYACA,GAMEZA,5.802333,-72.80553 +BOYACA,GARAGOA,5.083234,-73.364413 +BOYACA,GUACAMAYAS,6.459667,-72.500812 +BOYACA,GUATEQUE,5.007321,-73.471207 +BOYACA,GUAYATA,4.967122,-73.489698 +BOYACA,IZA,5.611696,-72.980176 +BOYACA,JENESANO,5.385813,-73.363738 +BOYACA,JERICO,6.145902,-72.57073 +BOYACA,LABRANZAGRANDE,5.562687,-72.57777 +BOYACA,LA CAPILLA,5.095687,-73.444347 +BOYACA,LA VICTORIA,5.523792,-74.234393 +BOYACA,LA UVITA,6.31616,-72.559982 +BOYACA,VILLA DE LEYVA,5.632455,-73.524948 +BOYACA,MACANAL,4.972464,-73.319593 +BOYACA,MARIPI,5.550091,-74.00405 +BOYACA,MIRAFLORES,5.196515,-73.14563 +BOYACA,MONGUA,5.754242,-72.79809 +BOYACA,MONGUI,5.723486,-72.849292 +BOYACA,MONIQUIRA,5.876331,-73.573374 +BOYACA,MOTAVITA,5.5777,-73.367841 +BOYACA,MUZO,5.532758,-74.10269 +BOYACA,NOBSA,5.771043,-72.939853 +BOYACA,NUEVO COLON,5.355317,-73.456759 +BOYACA,OICATA,5.595235,-73.308399 +BOYACA,OTANCHE,5.657536,-74.180965 +BOYACA,PACHAVITA,5.140065,-73.396953 +BOYACA,PAEZ,5.094621,-73.054496 +BOYACA,PAIPA,5.779949,-73.117918 +BOYACA,PAJARITO,5.293783,-72.703231 +BOYACA,PANQUEBA,6.443416,-72.459424 +BOYACA,PAUNA,5.656323,-73.978449 +BOYACA,PAYA,5.625699,-72.423775 +BOYACA,PAZ DE RIO,5.987645,-72.749137 +BOYACA,PESCA,5.558808,-73.050872 +BOYACA,PISBA,5.72141,-72.486023 +BOYACA,PUERTO BOYACA,5.977935,-74.587999 +BOYACA,QUIPAMA,5.51837,-74.177729 +BOYACA,RAMIRIQUI,5.400303,-73.334839 +BOYACA,RAQUIRA,5.539136,-73.632543 +BOYACA,RONDON,5.357378,-73.208474 +BOYACA,SABOYA,5.697756,-73.764456 +BOYACA,SACHICA,5.584305,-73.542539 +BOYACA,SAMACA,5.492161,-73.485589 +BOYACA,SAN EDUARDO,5.223969,-73.07713 +BOYACA,SAN JOSE DE PARE,6.018924,-73.545397 +BOYACA,SAN LUIS DE GACENO,4.81976,-73.168076 +BOYACA,SAN MATEO,6.401683,-72.555264 +BOYACA,SAN MIGUEL DE SEMA,5.518083,-73.722009 +BOYACA,SAN PABLO DE BORBUR,5.650743,-74.069963 +BOYACA,SANTANA,6.056866,-73.481639 +BOYACA,SANTA MARIA,4.857193,-73.263518 +BOYACA,SANTA ROSA DE VITERBO,5.874547,-72.982461 +BOYACA,SANTA SOFIA,5.713269,-73.602707 +BOYACA,SATIVANORTE,6.131132,-72.708458 +BOYACA,SATIVASUR,6.093183,-72.712435 +BOYACA,SIACHOQUE,5.511811,-73.24466 +BOYACA,SOATA,6.331945,-72.684051 +BOYACA,SOCOTA,6.041162,-72.636653 +BOYACA,SOCHA,5.996717,-72.691963 +BOYACA,SOGAMOSO,5.725677,-72.9231 +BOYACA,SOMONDOCO,4.985726,-73.433393 +BOYACA,SORA,5.56684,-73.450153 +BOYACA,SOTAQUIRA,5.765231,-73.247334 +BOYACA,SORACA,5.500898,-73.332804 +BOYACA,SUSACON,6.230332,-72.690289 +BOYACA,SUTAMARCHAN,5.619781,-73.620536 +BOYACA,SUTATENZA,5.022989,-73.452317 +BOYACA,TASCO,5.909821,-72.781011 +BOYACA,TENZA,5.077074,-73.420589 +BOYACA,TIBANA,5.317251,-73.396457 +BOYACA,TIBASOSA,5.74723,-72.999449 +BOYACA,TINJACA,5.579713,-73.646847 +BOYACA,TIPACOQUE,6.418586,-72.693173 +BOYACA,TOCA,5.566464,-73.184794 +BOYACA,TOPAGA,5.768201,-72.832245 +BOYACA,TOTA,5.560497,-72.985898 +BOYACA,TUNUNGUA,5.730582,-73.933155 +BOYACA,TURMEQUE,5.323261,-73.491825 +BOYACA,TUTA,5.690906,-73.225178 +BOYACA,TUTAZA,6.032608,-72.856035 +BOYACA,UMBITA,5.221176,-73.456917 +BOYACA,VENTAQUEMADA,5.368739,-73.522368 +BOYACA,VIRACACHA,5.436833,-73.296894 +BOYACA,ZETAQUIRA,5.282116,-73.169052 +CALDAS,MANIZALES,5.057655,-75.491018 +CALDAS,AGUADAS,5.610244,-75.45487 +CALDAS,ANSERMA,5.236471,-75.784343 +CALDAS,ARANZAZU,5.271142,-75.491255 +CALDAS,BELALCAZAR,4.993785,-75.811918 +CALDAS,CHINCHINA,4.985227,-75.607529 +CALDAS,FILADELFIA,5.297091,-75.562474 +CALDAS,LA DORADA,5.444501,-74.658488 +CALDAS,LA MERCED,5.394867,-75.547853 +CALDAS,MANZANARES,5.255699,-75.152829 +CALDAS,MARMATO,5.47422,-75.600049 +CALDAS,MARQUETALIA,5.297525,-75.053097 +CALDAS,MARULANDA,5.284304,-75.259721 +CALDAS,NEIRA,5.166799,-75.519886 +CALDAS,NORCASIA,5.574796,-74.889546 +CALDAS,PACORA,5.527172,-75.459621 +CALDAS,PALESTINA,5.020634,-75.62163 +CALDAS,PENSILVANIA,5.383281,-75.160299 +CALDAS,RIOSUCIO,5.423673,-75.702104 +CALDAS,RISARALDA,5.164509,-75.76722 +CALDAS,SALAMINA,5.403025,-75.487223 +CALDAS,SAMANA,5.41308,-74.992263 +CALDAS,SAN JOSE,5.081569,-75.792004 +CALDAS,SUPIA,5.446843,-75.64966 +CALDAS,VICTORIA,5.317437,-74.911239 +CALDAS,VILLAMARIA,5.043614,-75.513761 +CALDAS,VITERBO,5.062664,-75.87061 +CAQUETA,FLORENCIA,1.618234,-75.609796 +CAQUETA,ALBANIA,1.328526,-75.878375 +CAQUETA,BELEN DE LOS ANDAQUIES,1.415812,-75.872405 +CAQUETA,CARTAGENA DEL CHAIRA,1.33621,-74.841591 +CAQUETA,CURILLO,1.033473,-75.919205 +CAQUETA,EL DONCELLO,1.679951,-75.283631 +CAQUETA,EL PAUJIL,1.570226,-75.326093 +CAQUETA,LA MONTAÑITA,1.479173,-75.436408 +CAQUETA,MILAN,1.29021,-75.506926 +CAQUETA,MORELIA,1.487623,-75.724581 +CAQUETA,PUERTO RICO,1.909071,-75.157544 +CAQUETA,SAN JOSE DEL FRAGUA,1.331957,-75.973528 +CAQUETA,SAN VICENTE DEL CAGUAN,2.11965,-74.766451 +CAQUETA,SOLANO,0.699077,-75.253702 +CAQUETA,SOLITA,0.87654,-75.619902 +CAQUETA,VALPARAISO,1.194619,-75.70671 +CAUCA,POPAYAN,2.482561,-76.57422 +CAUCA,ALMAGUER,1.913429,-76.85607 +CAUCA,ARGELIA,2.257427,-77.24905 +CAUCA,BALBOA,2.040998,-77.215773 +CAUCA,BOLIVAR,1.837538,-76.966215 +CAUCA,BUENOS AIRES,3.015382,-76.642238 +CAUCA,CAJIBIO,2.623371,-76.570682 +CAUCA,CALDONO,2.798819,-76.484789 +CAUCA,CALOTO,3.034531,-76.408941 +CAUCA,CORINTO,3.173854,-76.261866 +CAUCA,EL TAMBO,2.451409,-76.810911 +CAUCA,FLORENCIA,1.682535,-77.072547 +CAUCA,GUACHENE,3.134153,-76.392189 +CAUCA,GUAPI,2.572918,-77.888033 +CAUCA,INZA,2.549183,-76.063503 +CAUCA,JAMBALO,2.777834,-76.323877 +CAUCA,LA SIERRA,2.179383,-76.763278 +CAUCA,LA VEGA,2.001109,-76.779032 +CAUCA,LOPEZ,2.846788,-77.247803 +CAUCA,MERCADERES,1.775772,-77.165844 +CAUCA,MIRANDA,3.253161,-76.229046 +CAUCA,MORALES,2.754684,-76.629106 +CAUCA,PADILLA,3.220984,-76.313265 +CAUCA,PAEZ,2.641367,-75.971965 +CAUCA,PATIA,2.115875,-76.981075 +CAUCA,PIAMONTE,1.119414,-76.327892 +CAUCA,PUERTO TEJADA,3.233254,-76.417673 +CAUCA,PURACE,2.342756,-76.49575 +CAUCA,ROSAS,2.260941,-76.740336 +CAUCA,SAN SEBASTIAN,1.838451,-76.769467 +CAUCA,SANTANDER DE QUILICHAO,3.01517,-76.485082 +CAUCA,SANTA ROSA,1.700916,-76.573252 +CAUCA,SILVIA,2.611927,-76.379753 +CAUCA,SUAREZ,2.959785,-76.69357 +CAUCA,SUCRE,2.038237,-76.926279 +CAUCA,TIMBIO,2.349249,-76.676174 +CAUCA,TIMBIQUI,2.777312,-77.667541 +CAUCA,TORIBIO,2.95145,-76.271127 +CAUCA,TOTORO,2.510229,-76.402443 +CAUCA,VILLA RICA,3.17762,-76.458025 +CESAR,VALLEDUPAR,10.460472,-73.259398 +CESAR,AGUACHICA,8.309986,-73.599399 +CESAR,AGUSTIN CODAZZI,10.040454,-73.238389 +CESAR,ASTREA,9.498062,-73.975842 +CESAR,BECERRIL,9.704495,-73.278839 +CESAR,BOSCONIA,9.975098,-73.888761 +CESAR,CHIMICHAGUA,9.25875,-73.813278 +CESAR,CHIRIGUANA,9.361058,-73.599913 +CESAR,CURUMANI,9.201716,-73.540843 +CESAR,EL COPEY,10.149626,-73.962751 +CESAR,EL PASO,9.66847,-73.741788 +CESAR,GAMARRA,8.320449,-73.74376 +CESAR,GONZALEZ,8.389604,-73.38004 +CESAR,LA GLORIA,8.619298,-73.80321 +CESAR,LA JAGUA DE IBIRICO,9.563752,-73.334143 +CESAR,MANAURE,10.390899,-73.02832 +CESAR,PAILITAS,8.956032,-73.625808 +CESAR,PELAYA,8.689006,-73.666451 +CESAR,PUEBLO BELLO,10.415186,-73.588468 +CESAR,RIO DE ORO,8.292292,-73.386393 +CESAR,LA PAZ,10.387552,-73.171365 +CESAR,SAN ALBERTO,7.761109,-73.39389 +CESAR,SAN DIEGO,10.333039,-73.181208 +CESAR,SAN MARTIN,7.999855,-73.510914 +CESAR,TAMALAMEQUE,8.861725,-73.812172 +CORDOBA,MONTERIA,8.74513,-75.875576 +CORDOBA,AYAPEL,8.313838,-75.146048 +CORDOBA,BUENAVISTA,8.221187,-75.480897 +CORDOBA,CANALETE,8.786939,-76.241476 +CORDOBA,CERETE,8.88859,-75.796226 +CORDOBA,CHIMA,9.149698,-75.626886 +CORDOBA,CHINU,9.105473,-75.399633 +CORDOBA,CIENAGA DE ORO,8.875794,-75.620807 +CORDOBA,COTORRA,9.037163,-75.799216 +CORDOBA,LA APARTADA,8.044806,-75.332591 +CORDOBA,LORICA,9.240862,-75.815984 +CORDOBA,LOS CORDOBAS,8.892098,-76.35518 +CORDOBA,MOMIL,9.240707,-75.67796 +CORDOBA,MONTELIBANO,7.973777,-75.416818 +CORDOBA,MOÑITOS,9.245223,-76.1291 +CORDOBA,PLANETA RICA,8.4082,-75.583241 +CORDOBA,PUEBLO NUEVO,8.504099,-75.508035 +CORDOBA,PUERTO ESCONDIDO,9.005372,-76.260411 +CORDOBA,PUERTO LIBERTADOR,7.888859,-75.671761 +CORDOBA,PURISIMA,9.239295,-75.724987 +CORDOBA,SAHAGUN,8.943048,-75.445834 +CORDOBA,SAN ANTERO,9.376434,-75.76112 +CORDOBA,SAN BERNARDO DEL VIENTO,9.352481,-75.955053 +CORDOBA,SAN CARLOS,8.799282,-75.698799 +CORDOBA,SAN PELAYO,8.958011,-75.835125 +CORDOBA,TIERRALTA,8.170612,-76.059797 +CORDOBA,VALENCIA,8.255016,-76.150756 +CUNDINAMARCA,AGUA DE DIOS,4.375309,-74.669221 +CUNDINAMARCA,ALBAN,4.878022,-74.438261 +CUNDINAMARCA,ANAPOIMA,4.562737,-74.528676 +CUNDINAMARCA,ANOLAIMA,4.7617,-74.46384 +CUNDINAMARCA,ARBELAEZ,4.272534,-74.414901 +CUNDINAMARCA,BELTRAN,4.802832,-74.741666 +CUNDINAMARCA,BITUIMA,4.872171,-74.539609 +CUNDINAMARCA,BOJACA,4.741758,-74.348492 +CUNDINAMARCA,CABRERA,3.984615,-74.484144 +CUNDINAMARCA,CACHIPAY,4.730957,-74.435711 +CUNDINAMARCA,CAJICA,4.920009,-74.02298 +CUNDINAMARCA,CAPARRAPI,5.34901,-74.490708 +CUNDINAMARCA,CAQUEZA,4.404112,-73.946473 +CUNDINAMARCA,CARMEN DE CARUPA,5.349119,-73.901357 +CUNDINAMARCA,CHAGUANI,4.948916,-74.593455 +CUNDINAMARCA,CHIA,4.866508,-74.05 +CUNDINAMARCA,CHIPAQUE,4.442671,-74.044876 +CUNDINAMARCA,CHOACHI,4.527048,-73.922894 +CUNDINAMARCA,CHOCONTA,5.145224,-73.683533 +CUNDINAMARCA,COGUA,5.061842,-73.978497 +CUNDINAMARCA,COTA,4.812817,-74.102694 +CUNDINAMARCA,CUCUNUBA,5.249795,-73.766113 +CUNDINAMARCA,EL COLEGIO,4.578071,-74.44221 +CUNDINAMARCA,EL PEÑON,5.248747,-74.290207 +CUNDINAMARCA,EL ROSAL,4.85226,-74.263767 +CUNDINAMARCA,FACATATIVA,4.812987,-74.350151 +CUNDINAMARCA,FOMEQUE,4.485474,-73.892523 +CUNDINAMARCA,FOSCA,4.339093,-73.93902 +CUNDINAMARCA,FUNZA,4.712763,-74.211525 +CUNDINAMARCA,FUQUENE,5.403997,-73.795855 +CUNDINAMARCA,FUSAGASUGA,4.337922,-74.365398 +CUNDINAMARCA,GACHALA,4.693579,-73.520161 +CUNDINAMARCA,GACHANCIPA,4.990947,-73.873464 +CUNDINAMARCA,GACHETA,4.817022,-73.637007 +CUNDINAMARCA,GAMA,4.763325,-73.611037 +CUNDINAMARCA,GIRARDOT,4.313103,-74.797949 +CUNDINAMARCA,GRANADA,4.519763,-74.350766 +CUNDINAMARCA,GUACHETA,5.383378,-73.686972 +CUNDINAMARCA,GUADUAS,5.072026,-74.603368 +CUNDINAMARCA,GUASCA,4.866719,-73.877143 +CUNDINAMARCA,GUATAQUI,4.517517,-74.790058 +CUNDINAMARCA,GUATAVITA,4.934214,-73.832893 +CUNDINAMARCA,GUAYABAL DE SIQUIMA,4.877968,-74.467437 +CUNDINAMARCA,GUAYABETAL,4.215306,-73.815107 +CUNDINAMARCA,GUTIERREZ,4.254679,-74.003042 +CUNDINAMARCA,JERUSALEN,4.562273,-74.695474 +CUNDINAMARCA,JUNIN,4.79057,-73.662961 +CUNDINAMARCA,LA CALERA,4.720345,-73.969745 +CUNDINAMARCA,LA MESA,4.631028,-74.461588 +CUNDINAMARCA,LA PALMA,5.358816,-74.391022 +CUNDINAMARCA,LA PEÑA,5.200211,-74.393089 +CUNDINAMARCA,LA VEGA,4.993077,-74.331138 +CUNDINAMARCA,LENGUAZAQUE,5.306131,-73.711512 +CUNDINAMARCA,MACHETA,5.080295,-73.607581 +CUNDINAMARCA,MADRID,4.732802,-74.265385 +CUNDINAMARCA,MANTA,5.009008,-73.540444 +CUNDINAMARCA,MEDINA,4.506298,-73.348449 +CUNDINAMARCA,MOSQUERA,4.711371,-74.231775 +CUNDINAMARCA,NARIÑO,4.399837,-74.824732 +CUNDINAMARCA,NEMOCON,5.067365,-73.87963 +CUNDINAMARCA,NILO,4.305838,-74.620009 +CUNDINAMARCA,NIMAIMA,5.125992,-74.38604 +CUNDINAMARCA,NOCAIMA,5.068933,-74.377728 +CUNDINAMARCA,VENECIA,4.089056,-74.478301 +CUNDINAMARCA,PACHO,5.136907,-74.156132 +CUNDINAMARCA,PAIME,5.370487,-74.152213 +CUNDINAMARCA,PANDI,4.190393,-74.486641 +CUNDINAMARCA,PARATEBUENO,4.374832,-73.212825 +CUNDINAMARCA,PASCA,4.308979,-74.302276 +CUNDINAMARCA,PUERTO SALGAR,5.46499,-74.65277 +CUNDINAMARCA,PULI,4.682022,-74.71438 +CUNDINAMARCA,QUEBRADANEGRA,5.118076,-74.48014 +CUNDINAMARCA,QUETAME,4.329884,-73.863214 +CUNDINAMARCA,QUIPILE,4.74481,-74.533705 +CUNDINAMARCA,APULO,4.520304,-74.593926 +CUNDINAMARCA,RICAURTE,4.29395,-74.784543 +CUNDINAMARCA,SAN ANTONIO DEL TEQUENDAMA,4.616138,-74.351443 +CUNDINAMARCA,SAN BERNARDO,4.179433,-74.42296 +CUNDINAMARCA,SAN CAYETANO,5.332938,-74.024754 +CUNDINAMARCA,SAN FRANCISCO,4.972917,-74.289672 +CUNDINAMARCA,SASAIMA,4.962167,-74.432628 +CUNDINAMARCA,SESQUILE,5.04476,-73.796099 +CUNDINAMARCA,SIBATE,4.489774,-74.258995 +CUNDINAMARCA,SILVANIA,4.381981,-74.405534 +CUNDINAMARCA,SIMIJACA,5.505231,-73.850703 +CUNDINAMARCA,SOACHA,4.579298,-74.215456 +CUNDINAMARCA,SOPO,4.906331,-73.945775 +CUNDINAMARCA,SUBACHOQUE,4.929118,-74.172773 +CUNDINAMARCA,SUESCA,5.103495,-73.798227 +CUNDINAMARCA,SUPATA,5.06162,-74.235403 +CUNDINAMARCA,SUSA,5.455291,-73.813938 +CUNDINAMARCA,SUTATAUSA,5.247482,-73.853159 +CUNDINAMARCA,TABIO,4.916832,-74.096461 +CUNDINAMARCA,TAUSA,5.196555,-73.886473 +CUNDINAMARCA,TENA,4.655286,-74.389193 +CUNDINAMARCA,TENJO,4.871887,-74.14392 +CUNDINAMARCA,TIBACUY,4.348315,-74.452344 +CUNDINAMARCA,TIBIRITA,5.052278,-73.504514 +CUNDINAMARCA,TOCAIMA,4.459279,-74.636296 +CUNDINAMARCA,TOCANCIPA,4.964786,-73.912091 +CUNDINAMARCA,TOPAIPI,5.335973,-74.302688 +CUNDINAMARCA,UBALA,4.743995,-73.533967 +CUNDINAMARCA,UBAQUE,4.483788,-73.933477 +CUNDINAMARCA,VILLA DE SAN DIEGO DE UBATE,5.307528,-73.814356 +CUNDINAMARCA,UNE,4.40245,-74.025183 +CUNDINAMARCA,UTICA,5.19055,-74.483154 +CUNDINAMARCA,VERGARA,5.117258,-74.346163 +CUNDINAMARCA,VIANI,4.875208,-74.56132 +CUNDINAMARCA,VILLAGOMEZ,5.273024,-74.195145 +CUNDINAMARCA,VILLAPINZON,5.216393,-73.595704 +CUNDINAMARCA,VILLETA,5.012754,-74.469686 +CUNDINAMARCA,VIOTA,4.43935,-74.523131 +CUNDINAMARCA,YACOPI,5.459272,-74.33806 +CUNDINAMARCA,ZIPACON,4.759932,-74.379566 +CUNDINAMARCA,ZIPAQUIRA,5.02544,-73.994434 +CHOCO,QUIBDO,5.690997,-76.626533 +CHOCO,ACANDI,8.512178,-77.279951 +CHOCO,ALTO BAUDO,5.516221,-76.974373 +CHOCO,ATRATO,5.531419,-76.635674 +CHOCO,BAGADO,5.409681,-76.416063 +CHOCO,BAHIA SOLANO,6.22127,-77.401633 +CHOCO,BAJO BAUDO,4.954576,-77.365717 +CHOCO,BOJAYA,6.556303,-76.883465 +CHOCO,EL CANTON DEL SAN PABLO,5.335321,-76.726844 +CHOCO,CARMEN DEL DARIEN,7.158538,-76.970795 +CHOCO,CERTEGUI,5.370117,-76.607483 +CHOCO,CONDOTO,5.091003,-76.650683 +CHOCO,EL CARMEN DE ATRATO,5.899789,-76.142112 +CHOCO,EL LITORAL DEL SAN JUAN,4.256823,-77.35989 +CHOCO,ISTMINA,5.153946,-76.68518 +CHOCO,JURADO,7.103619,-77.762751 +CHOCO,LLORO,5.498161,-76.545238 +CHOCO,MEDIO ATRATO,5.994935,-76.783042 +CHOCO,MEDIO BAUDO,5.192471,-76.950891 +CHOCO,MEDIO SAN JUAN,5.098291,-76.694409 +CHOCO,NOVITA,4.956097,-76.607118 +CHOCO,NUQUI,5.708753,-77.265614 +CHOCO,RIO IRO,5.1863,-76.472925 +CHOCO,RIO QUITO,5.483667,-76.740684 +CHOCO,RIOSUCIO,7.436704,-77.113156 +CHOCO,SAN JOSE DEL PALMAR,4.897593,-76.232849 +CHOCO,SIPI,4.65262,-76.643453 +CHOCO,TADO,5.265367,-76.553896 +CHOCO,UNGUIA,8.042761,-77.091753 +CHOCO,UNION PANAMERICANA,5.281108,-76.630143 +HUILA,NEIVA,2.935971,-75.277285 +HUILA,ACEVEDO,1.805259,-75.888532 +HUILA,AGRADO,2.25987,-75.772022 +HUILA,AIPE,3.223996,-75.239017 +HUILA,ALGECIRAS,2.521674,-75.315389 +HUILA,ALTAMIRA,2.063841,-75.788471 +HUILA,BARAYA,3.152983,-75.055253 +HUILA,CAMPOALEGRE,2.686757,-75.325724 +HUILA,COLOMBIA,3.376745,-74.802815 +HUILA,ELIAS,2.012854,-75.938301 +HUILA,GARZON,2.196994,-75.627694 +HUILA,GIGANTE,2.388236,-75.54122 +HUILA,GUADALUPE,2.025157,-75.755028 +HUILA,HOBO,2.582868,-75.451151 +HUILA,IQUIRA,2.649359,-75.634497 +HUILA,ISNOS,1.929467,-76.217637 +HUILA,LA ARGENTINA,2.198496,-75.979763 +HUILA,LA PLATA,2.389479,-75.89148 +HUILA,NATAGA,2.5451,-75.808756 +HUILA,OPORAPA,2.025088,-75.995165 +HUILA,PAICOL,2.449527,-75.772993 +HUILA,PALERMO,2.888246,-75.434329 +HUILA,PALESTINA,1.723723,-76.133291 +HUILA,PITAL,2.26752,-75.805174 +HUILA,PITALITO,1.852523,-76.048925 +HUILA,RIVERA,2.777638,-75.258643 +HUILA,SALADOBLANCO,1.992495,-76.044141 +HUILA,SAN AGUSTIN,1.881081,-76.27036 +HUILA,SANTA MARIA,2.937628,-75.587213 +HUILA,SUAZA,1.976051,-75.79525 +HUILA,TARQUI,2.111325,-75.823976 +HUILA,TESALIA,2.486364,-75.730271 +HUILA,TELLO,3.067538,-75.138773 +HUILA,TERUEL,2.740968,-75.567034 +HUILA,TIMANA,1.974539,-75.932167 +HUILA,VILLAVIEJA,3.220791,-75.21723 +HUILA,YAGUARA,2.664694,-75.518023 +LA GUAJIRA,RIOHACHA,11.528588,-72.911797 +LA GUAJIRA,ALBANIA,11.157489,-72.598522 +LA GUAJIRA,BARRANCAS,10.958663,-72.793574 +LA GUAJIRA,DIBULLA,11.27155,-73.307598 +LA GUAJIRA,DISTRACCION,10.898414,-72.887405 +LA GUAJIRA,EL MOLINO,10.653505,-72.92673 +LA GUAJIRA,FONSECA,10.884301,-72.846662 +LA GUAJIRA,HATONUEVO,11.068859,-72.759043 +LA GUAJIRA,LA JAGUA DEL PILAR,10.510214,-73.071679 +LA GUAJIRA,MAICAO,11.378319,-72.241861 +LA GUAJIRA,MANAURE,11.773891,-72.439621 +LA GUAJIRA,SAN JUAN DEL CESAR,10.769546,-73.000629 +LA GUAJIRA,URIBIA,11.714596,-72.26471 +LA GUAJIRA,URUMITA,10.560169,-73.012507 +LA GUAJIRA,VILLANUEVA,10.608774,-72.977583 +MAGDALENA,SANTA MARTA,11.204685,-74.200077 +MAGDALENA,ALGARROBO,10.188059,-74.061132 +MAGDALENA,ARACATACA,10.589791,-74.186702 +MAGDALENA,ARIGUANI,9.847048,-74.236515 +MAGDALENA,CIENAGA,11.006654,-74.241286 +MAGDALENA,CONCORDIA,10.257314,-74.83303 +MAGDALENA,EL BANCO,9.008503,-73.97437 +MAGDALENA,EL PIÑON,10.403071,-74.823263 +MAGDALENA,EL RETEN,10.610445,-74.268412 +MAGDALENA,FUNDACION,10.514146,-74.191453 +MAGDALENA,GUAMAL,9.144354,-74.223689 +MAGDALENA,NUEVA GRANADA,9.80186,-74.391841 +MAGDALENA,PEDRAZA,10.188206,-74.915458 +MAGDALENA,PIJIÑO DEL CARMEN,9.334376,-74.459257 +MAGDALENA,PIVIJAY,10.460707,-74.613312 +MAGDALENA,PLATO,9.796769,-74.784679 +MAGDALENA,PUEBLOVIEJO,10.994766,-74.28253 +MAGDALENA,REMOLINO,10.701952,-74.716172 +MAGDALENA,SABANAS DE SAN ANGEL,10.032532,-74.213958 +MAGDALENA,SALAMINA,10.491229,-74.794189 +MAGDALENA,SAN SEBASTIAN DE BUENAVISTA,9.240521,-74.3516 +MAGDALENA,SAN ZENON,9.245061,-74.498992 +MAGDALENA,SANTA ANA,9.321759,-74.567658 +MAGDALENA,SANTA BARBARA DE PINTO,9.432263,-74.704667 +MAGDALENA,SITIONUEVO,10.775285,-74.720021 +MAGDALENA,TENERIFE,9.897759,-74.860757 +MAGDALENA,ZAPAYAN,10.168297,-74.716878 +MAGDALENA,ZONA BANANERA,10.763024,-74.140091 +META,VILLAVICENCIO,4.127404,-73.627486 +META,ACACIAS,3.990024,-73.765909 +META,BARRANCA DE UPIA,4.566225,-72.961083 +META,CABUYARO,4.286705,-72.791768 +META,CASTILLA LA NUEVA,3.826992,-73.687237 +META,CUBARRAL,3.793614,-73.838724 +META,CUMARAL,4.269535,-73.486557 +META,EL CALVARIO,4.352665,-73.713325 +META,EL CASTILLO,3.563907,-73.794225 +META,EL DORADO,3.739984,-73.835264 +META,FUENTE DE ORO,3.462875,-73.618121 +META,GRANADA,3.551865,-73.712572 +META,GUAMAL,3.880572,-73.768324 +META,MAPIRIPAN,2.896617,-72.135509 +META,MESETAS,3.383813,-74.044519 +META,LA MACARENA,2.177143,-73.78661 +META,URIBE,3.239634,-74.351508 +META,LEJANIAS,3.526355,-74.02447 +META,PUERTO CONCORDIA,2.624006,-72.760209 +META,PUERTO GAITAN,4.315025,-72.085275 +META,PUERTO LOPEZ,4.09349,-72.957324 +META,PUERTO LLERAS,3.269732,-73.373743 +META,PUERTO RICO,2.939621,-73.206314 +META,RESTREPO,4.260447,-73.56497 +META,SAN CARLOS DE GUAROA,3.71065,-73.242253 +META,SAN JUAN DE ARAMA,3.373728,-73.875832 +META,SAN JUANITO,4.458181,-73.676699 +META,SAN MARTIN,3.701899,-73.695812 +META,VISTAHERMOSA,3.125577,-73.750911 +NARIÑO,PASTO,1.212022,-77.278597 +NARIÑO,ALBAN,1.474978,-77.080712 +NARIÑO,ALDANA,0.882381,-77.700564 +NARIÑO,ANCUYA,1.263276,-77.514512 +NARIÑO,ARBOLEDA,1.503418,-77.135467 +NARIÑO,BARBACOAS,1.670423,-78.139425 +NARIÑO,BELEN,1.595719,-77.015621 +NARIÑO,BUESACO,1.381453,-77.156463 +NARIÑO,COLON,1.643878,-77.019777 +NARIÑO,CONSACA,1.207635,-77.465103 +NARIÑO,CONTADERO,0.910458,-77.549409 +NARIÑO,CORDOBA,0.854564,-77.517897 +NARIÑO,CUASPUD,0.862978,-77.728947 +NARIÑO,CUMBAL,0.906301,-77.792279 +NARIÑO,CUMBITARA,1.647163,-77.578616 +NARIÑO,EL CHARCO,2.477545,-78.109865 +NARIÑO,EL PEÑOL,1.452893,-77.437859 +NARIÑO,EL ROSARIO,1.742476,-77.334566 +NARIÑO,EL TABLON DE GOMEZ,1.427277,-77.097101 +NARIÑO,EL TAMBO,1.407914,-77.390774 +NARIÑO,FUNES,1.001159,-77.448913 +NARIÑO,GUACHUCAL,0.959744,-77.731589 +NARIÑO,GUAITARILLA,1.129567,-77.549818 +NARIÑO,GUALMATAN,0.919803,-77.566815 +NARIÑO,ILES,0.96952,-77.521227 +NARIÑO,IMUES,1.054815,-77.496063 +NARIÑO,IPIALES,0.827732,-77.646367 +NARIÑO,LA CRUZ,1.601318,-76.970504 +NARIÑO,LA FLORIDA,1.299257,-77.407372 +NARIÑO,LA LLANADA,1.472892,-77.58091 +NARIÑO,LA TOLA,2.399689,-78.189821 +NARIÑO,LA UNION,1.600219,-77.131316 +NARIÑO,LEIVA,1.934453,-77.306135 +NARIÑO,LINARES,1.351193,-77.522039 +NARIÑO,LOS ANDES,1.494587,-77.521303 +NARIÑO,MALLAMA,1.141037,-77.864549 +NARIÑO,MOSQUERA,2.507139,-78.452992 +NARIÑO,NARIÑO,1.288979,-77.357972 +NARIÑO,OLAYA HERRERA,2.347457,-78.325814 +NARIÑO,OSPINA,1.058433,-77.566082 +NARIÑO,FRANCISCO PIZARRO,2.040629,-78.658361 +NARIÑO,POLICARPA,1.628139,-77.459265 +NARIÑO,POTOSI,0.806342,-77.572792 +NARIÑO,PROVIDENCIA,1.237814,-77.596794 +NARIÑO,PUERRES,0.885125,-77.504211 +NARIÑO,PUPIALES,0.86714,-77.633504 +NARIÑO,RICAURTE,1.212492,-77.995153 +NARIÑO,ROBERTO PAYAN,1.697492,-78.245716 +NARIÑO,SAMANIEGO,1.335438,-77.594341 +NARIÑO,SANDONA,1.286137,-77.469571 +NARIÑO,SAN BERNARDO,1.514298,-77.047412 +NARIÑO,SAN LORENZO,1.50336,-77.215231 +NARIÑO,SAN PABLO,1.669429,-77.013984 +NARIÑO,SAN PEDRO DE CARTAGO,1.551758,-77.119189 +NARIÑO,SANTA BARBARA,2.449653,-77.979916 +NARIÑO,SANTACRUZ,1.222275,-77.676917 +NARIÑO,SAPUYES,1.037536,-77.62028 +NARIÑO,TAMINANGO,1.570358,-77.2808 +NARIÑO,TANGUA,1.095178,-77.393244 +NARIÑO,SAN ANDRES DE TUMACO,1.807399,-78.764073 +NARIÑO,TUQUERRES,1.085044,-77.61672 +NARIÑO,YACUANQUER,1.114556,-77.402824 +NORTE DE SANTANDER,CUCUTA,7.905613,-72.508577 +NORTE DE SANTANDER,ABREGO,8.081616,-73.221722 +NORTE DE SANTANDER,ARBOLEDAS,7.642985,-72.798952 +NORTE DE SANTANDER,BOCHALEMA,7.612192,-72.64701 +NORTE DE SANTANDER,BUCARASICA,8.041299,-72.868231 +NORTE DE SANTANDER,CACOTA,7.268705,-72.642059 +NORTE DE SANTANDER,CACHIRA,7.741248,-73.048983 +NORTE DE SANTANDER,CHINACOTA,7.60108,-72.6047 +NORTE DE SANTANDER,CHITAGA,7.138187,-72.665468 +NORTE DE SANTANDER,CONVENCION,8.470374,-73.3372 +NORTE DE SANTANDER,CUCUTILLA,7.539633,-72.772816 +NORTE DE SANTANDER,DURANIA,7.714804,-72.658491 +NORTE DE SANTANDER,EL CARMEN,8.510579,-73.446687 +NORTE DE SANTANDER,EL TARRA,8.574281,-73.09614 +NORTE DE SANTANDER,EL ZULIA,7.938529,-72.604805 +NORTE DE SANTANDER,GRAMALOTE,7.916946,-72.787233 +NORTE DE SANTANDER,HACARI,8.321506,-73.145997 +NORTE DE SANTANDER,HERRAN,7.506541,-72.483519 +NORTE DE SANTANDER,LABATECA,7.298414,-72.495983 +NORTE DE SANTANDER,LA ESPERANZA,7.639839,-73.328126 +NORTE DE SANTANDER,LA PLAYA,8.212854,-73.238417 +NORTE DE SANTANDER,LOS PATIOS,7.833055,-72.505764 +NORTE DE SANTANDER,LOURDES,7.944672,-72.832489 +NORTE DE SANTANDER,MUTISCUA,7.300469,-72.747169 +NORTE DE SANTANDER,OCAÑA,8.247588,-73.355889 +NORTE DE SANTANDER,PAMPLONA,7.372816,-72.647715 +NORTE DE SANTANDER,PAMPLONITA,7.436775,-72.63925 +NORTE DE SANTANDER,PUERTO SANTANDER,8.359993,-72.411363 +NORTE DE SANTANDER,RAGONVALIA,7.577861,-72.476708 +NORTE DE SANTANDER,SALAZAR,7.773683,-72.813064 +NORTE DE SANTANDER,SAN CALIXTO,8.400152,-73.206053 +NORTE DE SANTANDER,SAN CAYETANO,7.875695,-72.625459 +NORTE DE SANTANDER,SANTIAGO,7.865339,-72.716579 +NORTE DE SANTANDER,SARDINATA,8.082105,-72.800577 +NORTE DE SANTANDER,SILOS,7.204736,-72.757128 +NORTE DE SANTANDER,TEORAMA,8.436377,-73.287409 +NORTE DE SANTANDER,TIBU,8.639891,-72.734496 +NORTE DE SANTANDER,TOLEDO,7.310023,-72.485088 +NORTE DE SANTANDER,VILLA CARO,7.915047,-72.971282 +NORTE DE SANTANDER,VILLA DEL ROSARIO,7.847757,-72.470277 +QUINDIO,ARMENIA,4.53598,-75.680787 +QUINDIO,BUENAVISTA,4.360121,-75.739693 +QUINDIO,CALARCA,4.515282,-75.649088 +QUINDIO,CIRCASIA,4.617759,-75.636533 +QUINDIO,CORDOBA,4.392485,-75.687866 +QUINDIO,FILANDIA,4.674213,-75.658675 +QUINDIO,GENOVA,4.207537,-75.789584 +QUINDIO,LA TEBAIDA,4.453755,-75.786887 +QUINDIO,MONTENEGRO,4.56296,-75.751812 +QUINDIO,PIJAO,4.335036,-75.703329 +QUINDIO,QUIMBAYA,4.624387,-75.765074 +QUINDIO,SALENTO,4.637157,-75.570844 +RISARALDA,PEREIRA,4.805492,-75.71715 +RISARALDA,APIA,5.105739,-75.941931 +RISARALDA,BALBOA,4.945848,-75.960134 +RISARALDA,BELEN DE UMBRIA,5.200793,-75.868334 +RISARALDA,DOSQUEBRADAS,4.8272,-75.682469 +RISARALDA,GUATICA,5.316251,-75.799324 +RISARALDA,LA CELIA,5.002787,-76.0032 +RISARALDA,LA VIRGINIA,4.89877,-75.884298 +RISARALDA,MARSELLA,4.935559,-75.738576 +RISARALDA,MISTRATO,5.297039,-75.882886 +RISARALDA,PUEBLO RICO,5.222022,-76.030805 +RISARALDA,QUINCHIA,5.340456,-75.730431 +RISARALDA,SANTA ROSA DE CABAL,4.867454,-75.620171 +RISARALDA,SANTUARIO,5.072762,-75.962196 +SANTANDER,BUCARAMANGA,7.116381,-73.132582 +SANTANDER,AGUADA,6.162157,-73.523187 +SANTANDER,ALBANIA,5.758993,-73.913823 +SANTANDER,ARATOCA,6.694418,-73.01786 +SANTANDER,BARBOSA,5.932699,-73.616016 +SANTANDER,BARICHARA,6.634111,-73.223047 +SANTANDER,BARRANCABERMEJA,7.064855,-73.849313 +SANTANDER,BETULIA,6.899525,-73.283669 +SANTANDER,BOLIVAR,5.988953,-73.771346 +SANTANDER,CABRERA,6.592118,-73.246475 +SANTANDER,CALIFORNIA,7.347829,-72.946295 +SANTANDER,CAPITANEJO,6.53318,-72.6992 +SANTANDER,CARCASI,6.629016,-72.627099 +SANTANDER,CEPITA,6.753518,-72.973536 +SANTANDER,CERRITO,6.844244,-72.692345 +SANTANDER,CHARALA,6.284339,-73.146873 +SANTANDER,CHARTA,7.28082,-72.968798 +SANTANDER,CHIMA,6.344348,-73.373656 +SANTANDER,CHIPATA,6.062521,-73.637111 +SANTANDER,CIMITARRA,6.320886,-73.953011 +SANTANDER,CONCEPCION,6.768918,-72.694583 +SANTANDER,CONFINES,6.357327,-73.240554 +SANTANDER,CONTRATACION,6.290561,-73.474426 +SANTANDER,COROMORO,6.294999,-73.040816 +SANTANDER,CURITI,6.605102,-73.069388 +SANTANDER,EL CARMEN DE CHUCURI,6.697907,-73.511268 +SANTANDER,EL GUACAMAYO,6.245119,-73.496893 +SANTANDER,EL PEÑON,6.054277,-73.816911 +SANTANDER,EL PLAYON,7.470715,-73.20287 +SANTANDER,ENCINO,6.137429,-73.098749 +SANTANDER,ENCISO,6.668034,-72.699647 +SANTANDER,FLORIAN,5.804659,-73.97143 +SANTANDER,FLORIDABLANCA,7.072385,-73.098991 +SANTANDER,GALAN,6.638415,-73.287765 +SANTANDER,GAMBITA,5.945981,-73.344184 +SANTANDER,GIRON,7.072749,-73.168257 +SANTANDER,GUACA,6.876563,-72.856322 +SANTANDER,GUADALUPE,6.245847,-73.419292 +SANTANDER,GUAPOTA,6.308588,-73.320699 +SANTANDER,GUAVATA,5.954348,-73.700906 +SANTANDER,HATO,6.543957,-73.308399 +SANTANDER,JESUS MARIA,5.876497,-73.783396 +SANTANDER,JORDAN,6.732727,-73.096053 +SANTANDER,LA BELLEZA,5.85925,-73.965494 +SANTANDER,LANDAZURI,6.218866,-73.811296 +SANTANDER,LA PAZ,6.178509,-73.58959 +SANTANDER,LEBRIJA,7.113351,-73.219524 +SANTANDER,LOS SANTOS,6.755208,-73.102746 +SANTANDER,MACARAVITA,6.50658,-72.593105 +SANTANDER,MALAGA,6.703081,-72.732089 +SANTANDER,MATANZA,7.323175,-73.015566 +SANTANDER,MOGOTES,6.475246,-72.969807 +SANTANDER,MOLAGAVITA,6.674326,-72.809177 +SANTANDER,OCAMONTE,6.339988,-73.122563 +SANTANDER,OIBA,6.265175,-73.299786 +SANTANDER,ONZAGA,6.344104,-72.816766 +SANTANDER,PALMAR,6.537789,-73.29109 +SANTANDER,PALMAS DEL SOCORRO,6.406139,-73.287764 +SANTANDER,PARAMO,6.416465,-73.170394 +SANTANDER,PIEDECUESTA,6.997272,-73.054493 +SANTANDER,PINCHOTE,6.531745,-73.174699 +SANTANDER,PUENTE NACIONAL,5.87836,-73.677554 +SANTANDER,PUERTO PARRA,6.650785,-74.056129 +SANTANDER,PUERTO WILCHES,7.349039,-73.899068 +SANTANDER,RIONEGRO,7.26501,-73.150147 +SANTANDER,SABANA DE TORRES,7.391919,-73.49906 +SANTANDER,SAN ANDRES,6.812318,-72.848696 +SANTANDER,SAN BENITO,6.126656,-73.50907 +SANTANDER,SAN GIL,6.556081,-73.136284 +SANTANDER,SAN JOAQUIN,6.427548,-72.867638 +SANTANDER,SAN JOSE DE MIRANDA,6.658995,-72.733616 +SANTANDER,SAN MIGUEL,6.575315,-72.644123 +SANTANDER,SAN VICENTE DE CHUCURI,6.880383,-73.411024 +SANTANDER,SANTA BARBARA,6.990996,-72.907445 +SANTANDER,SANTA HELENA DEL OPON,6.339565,-73.616716 +SANTANDER,SIMACOTA,6.443471,-73.337368 +SANTANDER,SOCORRO,6.46387,-73.261198 +SANTANDER,SUAITA,6.103542,-73.438416 +SANTANDER,SUCRE,5.918743,-73.790975 +SANTANDER,SURATA,7.36658,-72.984232 +SANTANDER,TONA,7.202725,-72.966018 +SANTANDER,VALLE DE SAN JOSE,6.448028,-73.143507 +SANTANDER,VELEZ,6.010953,-73.666299 +SANTANDER,VETAS,7.30981,-72.871041 +SANTANDER,VILLANUEVA,6.670078,-73.174307 +SANTANDER,ZAPATOCA,6.814387,-73.268034 +SUCRE,SINCELEJO,9.302322,-75.395445 +SUCRE,BUENAVISTA,9.319794,-74.972827 +SUCRE,CAIMITO,8.789324,-75.117141 +SUCRE,COLOSO,9.494192,-75.353256 +SUCRE,COROZAL,9.31875,-75.293067 +SUCRE,COVEÑAS,9.402463,-75.691867 +SUCRE,CHALAN,9.545352,-75.312697 +SUCRE,EL ROBLE,9.099532,-75.198831 +SUCRE,GALERAS,9.159583,-75.048576 +SUCRE,GUARANDA,8.464847,-74.538345 +SUCRE,LA UNION,8.853975,-75.276056 +SUCRE,LOS PALMITOS,9.380269,-75.268716 +SUCRE,MAJAGUAL,8.541163,-74.628077 +SUCRE,MORROA,9.331395,-75.305949 +SUCRE,OVEJAS,9.527176,-75.229037 +SUCRE,PALMITO,9.333157,-75.541264 +SUCRE,SAMPUES,9.183271,-75.380119 +SUCRE,SAN BENITO ABAD,8.930108,-75.031089 +SUCRE,SAN JUAN DE BETULIA,9.273066,-75.243565 +SUCRE,SAN MARCOS,8.664585,-75.132078 +SUCRE,SAN ONOFRE,9.736955,-75.522398 +SUCRE,SAN PEDRO,9.396284,-75.063652 +SUCRE,SAN LUIS DE SINCE,9.244307,-75.145995 +SUCRE,SUCRE,8.811737,-74.723175 +SUCRE,SANTIAGO DE TOLU,9.525454,-75.581284 +TOLIMA,IBAGUE,4.439609,-75.193715 +TOLIMA,ALPUJARRA,3.391548,-74.9329 +TOLIMA,ALVARADO,4.567356,-74.953418 +TOLIMA,AMBALEMA,4.782682,-74.764429 +TOLIMA,ANZOATEGUI,4.627474,-75.096451 +TOLIMA,ARMERO,5.030744,-74.884438 +TOLIMA,ATACO,3.590591,-75.382545 +TOLIMA,CAJAMARCA,4.436595,-75.435748 +TOLIMA,CARMEN DE APICALA,4.147912,-74.716169 +TOLIMA,CASABIANCA,5.078465,-75.120966 +TOLIMA,CHAPARRAL,3.722918,-75.480765 +TOLIMA,COELLO,4.287276,-74.898464 +TOLIMA,COYAIMA,3.798036,-75.193862 +TOLIMA,CUNDAY,4.059259,-74.692227 +TOLIMA,DOLORES,3.539073,-74.896754 +TOLIMA,ESPINAL,4.151314,-74.885446 +TOLIMA,FALAN,5.123104,-74.953007 +TOLIMA,FLANDES,4.276387,-74.818754 +TOLIMA,FRESNO,5.153576,-75.035722 +TOLIMA,GUAMO,4.030992,-74.968135 +TOLIMA,HERVEO,5.080228,-75.177151 +TOLIMA,HONDA,5.201633,-74.741367 +TOLIMA,ICONONZO,4.176487,-74.531969 +TOLIMA,LERIDA,4.862046,-74.910716 +TOLIMA,LIBANO,4.923442,-75.063421 +TOLIMA,MARIQUITA,5.199708,-74.889276 +TOLIMA,MELGAR,4.208233,-74.630057 +TOLIMA,MURILLO,4.875071,-75.170787 +TOLIMA,NATAGAIMA,3.624324,-75.093182 +TOLIMA,ORTEGA,3.934916,-75.222601 +TOLIMA,PALOCABILDO,5.120972,-75.022198 +TOLIMA,PIEDRAS,4.543908,-74.878074 +TOLIMA,PLANADAS,3.197911,-75.644163 +TOLIMA,PRADO,3.750939,-74.927447 +TOLIMA,PURIFICACION,3.853147,-74.936989 +TOLIMA,RIOBLANCO,3.529842,-75.644718 +TOLIMA,RONCESVALLES,4.012888,-75.606561 +TOLIMA,ROVIRA,4.239019,-75.240648 +TOLIMA,SALDAÑA,3.926909,-75.016153 +TOLIMA,SAN ANTONIO,3.913072,-75.481072 +TOLIMA,SAN LUIS,4.133721,-75.095804 +TOLIMA,SANTA ISABEL,4.713606,-75.097934 +TOLIMA,SUAREZ,4.048891,-74.831885 +TOLIMA,VALLE DE SAN JUAN,4.197494,-75.115669 +TOLIMA,VENADILLO,4.717976,-74.929177 +TOLIMA,VILLAHERMOSA,5.030452,-75.117729 +TOLIMA,VILLARRICA,3.936729,-74.600341 +VALLE DEL CAUCA,CALI,3.414162,-76.521469 +VALLE DEL CAUCA,ALCALA,4.674994,-75.779792 +VALLE DEL CAUCA,ANDALUCIA,4.171713,-76.167925 +VALLE DEL CAUCA,ANSERMANUEVO,4.794984,-75.992003 +VALLE DEL CAUCA,ARGELIA,4.726945,-76.119905 +VALLE DEL CAUCA,BOLIVAR,4.337846,-76.183583 +VALLE DEL CAUCA,BUENAVENTURA,3.875708,-77.01074 +VALLE DEL CAUCA,GUADALAJARA DE BUGA,3.900736,-76.298979 +VALLE DEL CAUCA,BUGALAGRANDE,4.208358,-76.15682 +VALLE DEL CAUCA,CAICEDONIA,4.334808,-75.830594 +VALLE DEL CAUCA,CALIMA,3.933664,-76.484132 +VALLE DEL CAUCA,CANDELARIA,3.407761,-76.346663 +VALLE DEL CAUCA,CARTAGO,4.758755,-75.942264 +VALLE DEL CAUCA,DAGUA,3.657318,-76.68886 +VALLE DEL CAUCA,EL AGUILA,4.909568,-76.041317 +VALLE DEL CAUCA,EL CAIRO,4.760874,-76.221611 +VALLE DEL CAUCA,EL CERRITO,3.684272,-76.311909 +VALLE DEL CAUCA,EL DOVIO,4.510452,-76.237084 +VALLE DEL CAUCA,FLORIDA,3.324118,-76.234199 +VALLE DEL CAUCA,GINEBRA,3.724181,-76.268068 +VALLE DEL CAUCA,GUACARI,3.762678,-76.331168 +VALLE DEL CAUCA,JAMUNDI,3.260449,-76.539785 +VALLE DEL CAUCA,LA CUMBRE,3.649268,-76.56805 +VALLE DEL CAUCA,LA UNION,4.533869,-76.099661 +VALLE DEL CAUCA,LA VICTORIA,4.523603,-76.036529 +VALLE DEL CAUCA,OBANDO,4.575712,-75.974709 +VALLE DEL CAUCA,PALMIRA,3.532355,-76.298568 +VALLE DEL CAUCA,PRADERA,3.419847,-76.241832 +VALLE DEL CAUCA,RESTREPO,3.821351,-76.523329 +VALLE DEL CAUCA,RIOFRIO,4.156908,-76.288313 +VALLE DEL CAUCA,ROLDANILLO,4.413601,-76.152277 +VALLE DEL CAUCA,SAN PEDRO,3.995073,-76.228692 +VALLE DEL CAUCA,SEVILLA,4.267033,-75.930941 +VALLE DEL CAUCA,TORO,4.608085,-76.076859 +VALLE DEL CAUCA,TRUJILLO,4.212037,-76.318818 +VALLE DEL CAUCA,TULUA,4.085335,-76.19761 +VALLE DEL CAUCA,ULLOA,4.702514,-75.737748 +VALLE DEL CAUCA,VERSALLES,4.575019,-76.199203 +VALLE DEL CAUCA,VIJES,3.698686,-76.441804 +VALLE DEL CAUCA,YOTOCO,3.861241,-76.382698 +VALLE DEL CAUCA,YUMBO,3.540097,-76.499893 +VALLE DEL CAUCA,ZARZAL,4.392658,-76.070795 +ARAUCA,ARAUCA,7.077359,-70.74635 +ARAUCA,ARAUQUITA,7.026988,-71.426747 +ARAUCA,CRAVO NORTE,6.303913,-70.204286 +ARAUCA,FORTUL,6.792738,-71.774279 +ARAUCA,PUERTO RONDON,6.283852,-71.097743 +ARAUCA,SARAVENA,6.953926,-71.872812 +ARAUCA,TAME,6.450575,-71.75892 +CASANARE,YOPAL,5.332863,-72.394588 +CASANARE,AGUAZUL,5.172834,-72.547048 +CASANARE,CHAMEZA,5.214527,-72.87016 +CASANARE,HATO COROZAL,6.154097,-71.764184 +CASANARE,LA SALINA,6.127816,-72.334036 +CASANARE,MANI,4.81681,-72.281384 +CASANARE,MONTERREY,4.877024,-72.894068 +CASANARE,NUNCHIA,5.636469,-72.195319 +CASANARE,OROCUE,4.790258,-71.338533 +CASANARE,PAZ DE ARIPORO,5.879827,-71.890348 +CASANARE,PORE,5.72773,-71.99286 +CASANARE,RECETOR,5.229181,-72.760991 +CASANARE,SABANALARGA,4.854787,-73.038696 +CASANARE,SACAMA,6.096738,-72.250157 +CASANARE,SAN LUIS DE PALENQUE,5.421013,-71.731853 +CASANARE,TAMARA,5.82964,-72.16174 +CASANARE,TAURAMENA,5.018977,-72.74662 +CASANARE,TRINIDAD,5.412178,-71.662812 +CASANARE,VILLANUEVA,4.610006,-72.927446 +PUTUMAYO,MOCOA,1.152296,-76.650271 +PUTUMAYO,COLON,1.190133,-76.972566 +PUTUMAYO,ORITO,0.663593,-76.873276 +PUTUMAYO,PUERTO ASIS,0.505627,-76.496887 +PUTUMAYO,PUERTO CAICEDO,0.684854,-76.605088 +PUTUMAYO,PUERTO GUZMAN,0.962684,-76.407274 +PUTUMAYO,LEGUIZAMO,-0.190921,-74.782473 +PUTUMAYO,SIBUNDOY,1.20026,-76.917814 +PUTUMAYO,SAN FRANCISCO,1.174194,-76.879283 +PUTUMAYO,SAN MIGUEL,0.343481,-76.912055 +PUTUMAYO,SANTIAGO,1.147076,-77.002641 +PUTUMAYO,VALLE DEL GUAMUEZ,0.423506,-76.906751 +PUTUMAYO,VILLAGARZON,1.028821,-76.61721 +AMAZONAS,LETICIA,-4.199511,-69.941465 +AMAZONAS,PUERTO NARIÑO,-3.78111,-70.365072 +GUAINIA,INIRIDA,3.866324,-67.918864 +GUAVIARE,SAN JOSE DEL GUAVIARE,2.566394,-72.639024 +GUAVIARE,CALAMAR,1.960982,-72.655197 +GUAVIARE,EL RETORNO,2.330164,-72.627304 +GUAVIARE,MIRAFLORES,1.337539,-71.950416 +VAUPES,MITU,1.253151,-70.232641 +VAUPES,CARURU,1.014598,-71.297793 +VAUPES,TARAIRA,-0.566224,-69.634271 +VICHADA,PUERTO CARREÑO,6.186636,-67.487095 +VICHADA,LA PRIMAVERA,5.486309,-70.410515 +VICHADA,SANTA ROSALIA,5.136393,-70.859499 +VICHADA,CUMARIBO,4.446352,-69.795533 +ANTIOQUIA,SANTAFE DE ANTIOQUIA,6.5564008,-75.8277746 +ANTIOQUIA,DON MATIAS,6.4306701,-75.4397931 +BOGOTA,"BOGOTA, D.C.",4.6533817,-74.0836331 +BOLIVAR,MOMPOS,9.241486,-74.4251549 +BOYACA,GÜICÁN,6.4616779,-72.4119937 +BOYACA,TOGÜÍ,5.9170442,-73.4964349 +CAUCA,PIENDAMO,2.6404294,-76.5325746 +CAUCA,SOTARA,2.2248489,-76.5924607 +CORDOBA,SAN ANDRES SOTAVENTO,9.2239832,-75.5268761 +CUNDINAMARCA,SAN JUAN DE RIO SECO,4.8455155,-74.6225366 +MAGDALENA,CERRO SAN ANTONIO,10.3269546,-74.8690124 +MAGDALENA,CHIBOLO,10.0269357,-74.6208028 +NARIÑO,CHACHAGÜÍ,1.360752,-77.2831744 +NARIÑO,MAGÜÍ,1.7651194,-78.1800866 +SANTANDER,GÜEPSA,6.0357259,-73.5663239 +SUCRE,TOLU VIEJO,9.453506,-75.4392989 +SAN ANDRES,SAN ANDRES,12.5375979,-81.7204155 +SAN ANDRES,PROVIDENCIA,12.5827601,-81.6913289 +AMAZONAS,EL ENCANTO,-1.7444574,-73.2090566 +AMAZONAS,LA CHORRERA,-1.4420148,-72.7889364 +AMAZONAS,LA PEDRERA,-1.1950966,-70.0887773 +AMAZONAS,LA VICTORIA,0.0568157,-71.220554 +AMAZONAS,MIRITI - PARANA,-0.8880777,-70.9883084 +AMAZONAS,PUERTO ALEGRIA,-1.0049233,-74.0136193 +AMAZONAS,PUERTO ARICA,-2.1475772,-71.7540411 +AMAZONAS,PUERTO SANTANDER,-1.0783329,-72.184333 +AMAZONAS,TARAPACA,-2.6092732,-69.9353009 +GUAINIA,BARRANCO MINAS,3.4905446,-69.8094888 +GUAINIA,MAPIRIPANA,2.776245,-70.4473347 +GUAINIA,SAN FELIPE,1.9130285,-67.0683131 +GUAINIA,PUERTO COLOMBIA,2.4924729,-68.2765774 +GUAINIA,LA GUADALUPE,1.4287745,-66.9988075 +GUAINIA,CACAHUAL,3.4452512,-67.5555849 +GUAINIA,PANA PANA,2.0633718,-69.0848469 +GUAINIA,MORICHAL,2.2649829,-69.9189381 +VAUPES,PACOA,0.194202,-70.9074578 +VAUPES,PAPUNAUA,1.7818451,-71.124897 +VAUPES,YAVARATE,0.8314207,-69.6342437 diff --git a/scripts/seed_geography.py b/scripts/seed_geography.py new file mode 100644 index 0000000..e7c691c --- /dev/null +++ b/scripts/seed_geography.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Seed geografía de Colombia (países, departamentos y municipios). + +Requisitos (instalar ad-hoc, NO son dependencia del proyecto): + pip install pycountry requests + +Uso: + python scripts/seed_geography.py http://localhost:7000 + +El script obtiene el país y los departamentos de Colombia desde +`pycountry` (ISO 3166-1/3166-2) y los municipios desde el dataset DANE +incluido en `scripts/data/colombia_municipios.csv`. No consulta internet +en tiempo de ejecución. +""" +import argparse +import csv +import getpass +import os +import sys +import unicodedata + +import requests + +try: + import pycountry +except ModuleNotFoundError: + print( + "pycountry no está instalado. Ejecuta: pip install pycountry", + file=sys.stderr, + ) + sys.exit(1) + +TOKEN_URL = "/api/token/" +SEED_GEOGRAPHY_URL = "/don_confiao/api/seed_geography" + +MUNICIPALITIES_CSV = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "data", + "colombia_municipios.csv", +) + +# Nombres DANE que difieren de los nombres oficiales de pycountry. +DEPARTMENT_ALIASES = { + "BOGOTA": "Distrito Capital de Bogotá", + "SAN ANDRES": "San Andrés, Providencia y Santa Catalina", +} + + +def get_credentials(): + username = input("Usuario: ") + password = getpass.getpass("Contraseña: ") + return username, password + + +def get_token(domain, username, password): + url = domain.rstrip("/") + TOKEN_URL + response = requests.post( + url, json={"username": username, "password": password} + ) + if response.status_code != 200: + print( + f"Error al obtener token: {response.status_code} {response.text}", + file=sys.stderr, + ) + sys.exit(1) + return response.json()["access"] + + +def _normalize(name): + return "".join( + char + for char in unicodedata.normalize("NFD", name) + if unicodedata.category(char) != "Mn" + ).upper() + + +def get_department_mapping(subdivisions): + by_normalized = { + _normalize(subdivision.name): subdivision.name + for subdivision in subdivisions + } + + csv_department_names = set() + with open(MUNICIPALITIES_CSV, newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + csv_department_names.add(row["department"]) + + mapping = dict(DEPARTMENT_ALIASES) + for name in csv_department_names: + if name in mapping: + continue + mapped = by_normalized.get(_normalize(name)) + if mapped is None: + print( + f"Departamento '{name}' no encontrado en pycountry", + file=sys.stderr, + ) + sys.exit(1) + mapping[name] = mapped + return mapping + + +def read_municipalities(mapping): + municipalities = [] + with open(MUNICIPALITIES_CSV, newline="", encoding="utf-8") as f: + for row in csv.DictReader(f): + municipality = { + "name": row["municipality"], + "department": mapping[row["department"]], + } + if row.get("latitude"): + municipality["latitude"] = float(row["latitude"]) + if row.get("longitude"): + municipality["longitude"] = float(row["longitude"]) + municipalities.append(municipality) + return municipalities + + +def build_payload(): + country = pycountry.countries.get(alpha_2="CO") + subdivisions = pycountry.subdivisions.get(country_code="CO") + + departments = [ + {"name": subdivision.name, "code": subdivision.code} + for subdivision in subdivisions + ] + mapping = get_department_mapping(subdivisions) + municipalities = read_municipalities(mapping) + + return { + "country": {"name": country.name, "code": country.alpha_2}, + "departments": departments, + "municipalities": municipalities, + } + + +def seed_geography(domain, token, payload): + headers = {"Authorization": f"Bearer {token}"} + full_url = domain.rstrip("/") + SEED_GEOGRAPHY_URL + response = requests.post(full_url, headers=headers, json=payload) + if response.status_code != 200: + print( + f"Error al sembrar geografía: {response.status_code} {response.text}", + file=sys.stderr, + ) + sys.exit(1) + return response.json() + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Alimenta países, departamentos y municipios de Colombia " + "usando pycountry y el dataset DANE incluido en scripts/data/." + ) + ) + parser.add_argument( + "domain", help="Dominio del backend (ej: http://localhost:7000)" + ) + args = parser.parse_args() + + username, password = get_credentials() + token = get_token(args.domain, username, password) + print("Token obtenido correctamente.") + + payload = build_payload() + print( + "Preparando siembra: 1 país, " + f"{len(payload['departments'])} departamentos, " + f"{len(payload['municipalities'])} municipios." + ) + + print("Enviando geografía...") + result = seed_geography(args.domain, token, payload) + print( + f" [OK] {result['country']}: " + f"{result['total_departments']} departamentos, " + f"{result['total_municipalities']} municipios." + ) + print( + f"\nResumen: {result['departments_created']} departamentos y " + f"{result['municipalities_created']} municipios creados " + f"(re-ejecutar es idempotente)." + ) + + +if __name__ == "__main__": + main()