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
This commit is contained in:
2026-08-16 04:10:45 -05:00
parent 699983931e
commit 3a0c76ce69
7 changed files with 140 additions and 3 deletions

View File

@@ -92,7 +92,7 @@ class SeedGeographyView(APIView):
if created: if created:
departments_created += 1 departments_created += 1
_, created = Municipality.objects.get_or_create( municipality, created = Municipality.objects.get_or_create(
name=municipality_data["name"], name=municipality_data["name"],
department=department, department=department,
defaults={"country": country}, defaults={"country": country},
@@ -100,6 +100,16 @@ class SeedGeographyView(APIView):
if created: if created:
municipalities_created += 1 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( return Response(
{ {
"country": country.name, "country": country.name,

View File

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

View File

@@ -39,6 +39,12 @@ class Municipality(models.Model):
country = models.ForeignKey( country = models.ForeignKey(
Country, on_delete=models.PROTECT, related_name="municipalities" 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: class Meta:
verbose_name = "Municipality" verbose_name = "Municipality"

View File

@@ -24,7 +24,7 @@ class DepartmentBriefSerializer(serializers.ModelSerializer):
class MunicipalityBriefSerializer(serializers.ModelSerializer): class MunicipalityBriefSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Municipality model = Municipality
fields = ["id", "name"] fields = ["id", "name", "latitude", "longitude"]
class DepartmentSerializer(serializers.ModelSerializer): class DepartmentSerializer(serializers.ModelSerializer):
@@ -43,4 +43,13 @@ class MunicipalitySerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Municipality model = Municipality
fields = ["id", "name", "department", "department_detail", "country", "country_detail"] fields = [
"id",
"name",
"department",
"department_detail",
"country",
"country_detail",
"latitude",
"longitude",
]

View File

@@ -68,6 +68,26 @@ class TestMunicipalityModel(TestCase):
self.assertEqual(municipality.department.country.name, "Colombia") self.assertEqual(municipality.department.country.name, "Colombia")
self.assertEqual(str(municipality), "La Mesa") 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): def test_municipality_requires_department_and_country(self):
with self.assertRaises(IntegrityError): with self.assertRaises(IntegrityError):
with transaction.atomic(): with transaction.atomic():

View File

@@ -241,6 +241,27 @@ class TestProvenanceCRUD(APITestCase, LoginMixin):
self.assertEqual(data["country_detail"]["name"], "Colombia") self.assertEqual(data["country_detail"]["name"], "Colombia")
self.assertEqual(data["country_detail"]["code"], "CO") 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): def test_link_product_to_suppliers_via_product_api(self):
product = Product.objects.create(name="Panela", price=5000) product = Product.objects.create(name="Panela", price=5000)
supplier2 = Supplier.objects.create(name="Proveedor 2") supplier2 = Supplier.objects.create(name="Proveedor 2")
@@ -315,6 +336,37 @@ class TestSeedGeography(APITestCase, LoginMixin):
self.assertEqual(Department.objects.count(), 1) self.assertEqual(Department.objects.count(), 1)
self.assertEqual(Municipality.objects.count(), 2) 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): def test_seed_requires_country_name(self):
self.login() self.login()
response = self.client.post( response = self.client.post(

View File

@@ -75,6 +75,23 @@ class TestProvenanceInSummaries(APITestCase, LoginMixin):
self.assertEqual(supplier["department"]["name"], "Cundinamarca") self.assertEqual(supplier["department"]["name"], "Cundinamarca")
self.assertEqual(supplier["country"]["name"], "Colombia") 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): def test_public_catalog_summary_includes_product_provenance(self):
catalog_sale = self._create_catalog_sale() catalog_sale = self._create_catalog_sale()
response = self.client.get( response = self.client.get(