From 3795832495dcf6c7fc3521d4ef7fe8329e6011f2 Mon Sep 17 00:00:00 2001 From: Norbert Kwizera Date: Thu, 9 Jan 2025 16:12:28 +0200 Subject: [PATCH] Use locations data from Location model --- temba/api/internal/serializers.py | 4 +- temba/api/internal/tests.py | 2 +- temba/api/internal/views.py | 18 ++- temba/api/v2/serializers.py | 8 +- temba/api/v2/tests/test_boundaries.py | 18 +-- temba/api/v2/views.py | 16 +-- temba/channels/android/tests.py | 2 +- temba/channels/types/android/tests.py | 2 +- temba/contacts/models.py | 6 +- temba/contacts/tests/test_contact.py | 16 +-- temba/contacts/tests/test_contactcrudl.py | 6 +- temba/flows/views.py | 2 +- .../management/commands/import_geojson.py | 41 +++---- temba/locations/models.py | 115 ++++++++++++++++++ temba/locations/tests/test_geojson.py | 20 +-- temba/locations/tests/test_location.py | 115 +++++++++--------- temba/locations/views.py | 30 ++--- temba/orgs/models.py | 10 +- temba/orgs/tests/test_org.py | 18 +-- temba/orgs/views/tests/test_orgcrudl.py | 2 +- temba/orgs/views/views.py | 4 +- temba/settings_common.py | 28 ++--- temba/tests/base.py | 32 ++--- temba/tests/mailroom.py | 38 +++--- templates/locations/adminboundary_alias.html | 2 +- templates/orgs/org_country.html | 8 +- 26 files changed, 337 insertions(+), 226 deletions(-) diff --git a/temba/api/internal/serializers.py b/temba/api/internal/serializers.py index b518f46fdf4..5294fb2ee33 100644 --- a/temba/api/internal/serializers.py +++ b/temba/api/internal/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.templates.models import Template, TemplateTranslation from temba.tickets.models import Shortcut @@ -14,7 +14,7 @@ def to_representation(self, instance): class LocationReadSerializer(serializers.ModelSerializer): class Meta: - model = AdminBoundary + model = Location fields = ("osm_id", "name", "path") diff --git a/temba/api/internal/tests.py b/temba/api/internal/tests.py index 564ed9158bf..c1f8f7340ed 100644 --- a/temba/api/internal/tests.py +++ b/temba/api/internal/tests.py @@ -19,7 +19,7 @@ def test_locations(self): self.assertPostNotAllowed(endpoint_url) self.assertDeleteNotAllowed(endpoint_url) - # no country, no results + # no location on org, no results self.assertGet(endpoint_url + "?level=state", [self.agent], results=[]) self.setUpLocations() diff --git a/temba/api/internal/views.py b/temba/api/internal/views.py index 892cd4f6cbe..e7865c04720 100644 --- a/temba/api/internal/views.py +++ b/temba/api/internal/views.py @@ -5,7 +5,7 @@ from django.db.models import Prefetch, Q from temba.channels.models import Channel -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.notifications.models import Notification from temba.templates.models import Template, TemplateTranslation from temba.tickets.models import Shortcut @@ -36,16 +36,16 @@ class LocationsEndpoint(ListAPIMixin, BaseEndpoint): """ LEVELS = { - "state": AdminBoundary.LEVEL_STATE, - "district": AdminBoundary.LEVEL_DISTRICT, - "ward": AdminBoundary.LEVEL_WARD, + "state": Location.LEVEL_STATE, + "district": Location.LEVEL_DISTRICT, + "ward": Location.LEVEL_WARD, } class Pagination(CursorPagination): ordering = ("name", "id") offset_cutoff = 100000 - model = AdminBoundary + model = Location serializer_class = serializers.LocationReadSerializer pagination_class = Pagination @@ -54,12 +54,10 @@ def derive_queryset(self): level = self.LEVELS.get(self.request.query_params.get("level")) query = self.request.query_params.get("query") - if not org.country or not level: - return AdminBoundary.objects.none() + if not org.location or not level: + return Location.objects.none() - qs = AdminBoundary.objects.filter( - path__startswith=f"{org.country.name} {AdminBoundary.PATH_SEPARATOR}", level=level - ) + qs = Location.objects.filter(path__startswith=f"{org.location.name} {Location.PATH_SEPARATOR}", level=level) if query: qs = qs.filter(Q(path__icontains=query)) diff --git a/temba/api/v2/serializers.py b/temba/api/v2/serializers.py index 409bec730bf..919d83b45af 100644 --- a/temba/api/v2/serializers.py +++ b/temba/api/v2/serializers.py @@ -18,7 +18,7 @@ from temba.contacts.models import URN, Contact, ContactField, ContactGroup, ContactNote, ContactURN from temba.flows.models import Flow, FlowRun, FlowStart from temba.globals.models import Global -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.mailroom import modifiers from temba.msgs.models import Broadcast, Label, Media, Msg, OptIn from temba.orgs.models import Org, OrgRole, User @@ -123,7 +123,7 @@ def run_validation(self, data=serializers.empty): # ============================================================ -class AdminBoundaryReadSerializer(ReadSerializer): +class LocationReadSerializer(ReadSerializer): parent = serializers.SerializerMethodField() aliases = serializers.SerializerMethodField() geometry = serializers.SerializerMethodField() @@ -136,12 +136,12 @@ def get_aliases(self, obj): def get_geometry(self, obj): if self.context["include_geometry"] and obj.simplified_geometry: - return json.loads(obj.simplified_geometry.geojson) + return obj.simplified_geometry else: return None class Meta: - model = AdminBoundary + model = Location fields = ("osm_id", "name", "parent", "level", "aliases", "geometry") diff --git a/temba/api/v2/tests/test_boundaries.py b/temba/api/v2/tests/test_boundaries.py index 4dde0e2caa1..adb39901a69 100644 --- a/temba/api/v2/tests/test_boundaries.py +++ b/temba/api/v2/tests/test_boundaries.py @@ -1,7 +1,6 @@ -from django.contrib.gis.geos import GEOSGeometry from django.urls import reverse -from temba.locations.models import BoundaryAlias +from temba.locations.models import LocationAlias from temba.tests import matchers from . import APITest @@ -17,11 +16,14 @@ def test_endpoint(self): self.setUpLocations() - BoundaryAlias.create(self.org, self.admin, self.state1, "Kigali") - BoundaryAlias.create(self.org, self.admin, self.state2, "East Prov") - BoundaryAlias.create(self.org2, self.admin2, self.state1, "Other Org") # shouldn't be returned + LocationAlias.create(self.org, self.admin, self.state1, "Kigali") + LocationAlias.create(self.org, self.admin, self.state2, "East Prov") + LocationAlias.create(self.org2, self.admin2, self.state1, "Other Org") # shouldn't be returned - self.state1.simplified_geometry = GEOSGeometry("MULTIPOLYGON(((1 1, 1 -1, -1 -1, -1 1, 1 1)))") + self.state1.simplified_geometry = { + "type": "MultiPolygon", + "coordinates": [[[[1, 1], [1, -1], [-1, -1], [-1, 1], [1, 1]]]], + } self.state1.save() # test without geometry @@ -136,7 +138,7 @@ def test_endpoint(self): ) # if org doesn't have a country, just return no results - self.org.country = None - self.org.save(update_fields=("country",)) + self.org.location = None + self.org.save(update_fields=("location",)) self.assertGet(endpoint_url, [self.admin], results=[]) diff --git a/temba/api/v2/views.py b/temba/api/v2/views.py index bbeb5f7edec..a58eee7b90c 100644 --- a/temba/api/v2/views.py +++ b/temba/api/v2/views.py @@ -19,7 +19,7 @@ from temba.contacts.models import Contact, ContactField, ContactGroup, ContactNote, ContactURN from temba.flows.models import Flow, FlowRun, FlowStart, FlowStartCount from temba.globals.models import Global -from temba.locations.models import AdminBoundary, BoundaryAlias +from temba.locations.models import Location, LocationAlias from temba.msgs.models import Broadcast, BroadcastMsgCount, Label, LabelCount, Media, Msg, MsgFolder, OptIn from temba.orgs.models import OrgMembership, User from temba.orgs.views.mixins import OrgPermsMixin @@ -43,7 +43,6 @@ ) from ..views import BaseAPIView, BulkWriteAPIMixin, DeleteAPIMixin, ListAPIMixin, WriteAPIMixin from .serializers import ( - AdminBoundaryReadSerializer, ArchiveReadSerializer, BroadcastReadSerializer, BroadcastWriteSerializer, @@ -69,6 +68,7 @@ GlobalWriteSerializer, LabelReadSerializer, LabelWriteSerializer, + LocationReadSerializer, MediaReadSerializer, MediaWriteSerializer, MsgBulkActionSerializer, @@ -453,19 +453,19 @@ class BoundariesEndpoint(ListAPIMixin, BaseEndpoint): class Pagination(CursorPagination): ordering = ("osm_id",) - model = AdminBoundary - serializer_class = AdminBoundaryReadSerializer + model = Location + serializer_class = LocationReadSerializer pagination_class = Pagination def derive_queryset(self): org = self.request.org - if not org.country: - return AdminBoundary.objects.none() + if not org.location: + return Location.objects.none() - queryset = org.country.get_descendants(include_self=True) + queryset = org.location.get_descendants(include_self=True) queryset = queryset.prefetch_related( - Prefetch("aliases", queryset=BoundaryAlias.objects.filter(org=org).order_by("name")) + Prefetch("aliases", queryset=LocationAlias.objects.filter(org=org).order_by("name")) ) return queryset.defer(None).select_related("parent") diff --git a/temba/channels/android/tests.py b/temba/channels/android/tests.py index 2eefd70c1dc..1d82d77ab9f 100644 --- a/temba/channels/android/tests.py +++ b/temba/channels/android/tests.py @@ -11,7 +11,7 @@ class AndroidTest(TembaTest): def test_register_unsupported_android(self): # remove our explicit country so it needs to be derived from channels - self.org.country = None + self.org.location = None self.org.save() Channel.objects.all().delete() diff --git a/temba/channels/types/android/tests.py b/temba/channels/types/android/tests.py index eb28d3b28cb..5302e7536c7 100644 --- a/temba/channels/types/android/tests.py +++ b/temba/channels/types/android/tests.py @@ -16,7 +16,7 @@ class AndroidTypeTest(TembaTest, CRUDLTestMixin): @mock_mailroom def test_claim(self, mr_mocks): # remove our explicit country so it needs to be derived from channels - self.org.country = None + self.org.location = None self.org.timezone = "UTC" self.org.save(update_fields=("country", "timezone")) diff --git a/temba/contacts/models.py b/temba/contacts/models.py index 38ac418e3eb..5410b7b054e 100644 --- a/temba/contacts/models.py +++ b/temba/contacts/models.py @@ -24,7 +24,7 @@ from temba import mailroom from temba.channels.models import Channel -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.mailroom import ContactSpec, modifiers, queue_populate_dynamic_group from temba.orgs.models import DependencyMixin, Export, ExportType, Org, OrgRole, User from temba.utils import format_number, on_transaction_commit @@ -843,7 +843,7 @@ def get_field_serialized(self, field) -> str: def get_field_value(self, field: ContactField): """ - Given the passed in contact field object, returns the value (as a string, decimal, datetime, AdminBoundary) + Given the passed in contact field object, returns the value (as a string, decimal, datetime, Location) for this contact or None. """ @@ -861,7 +861,7 @@ def get_field_value(self, field: ContactField): elif field.value_type == ContactField.TYPE_NUMBER: return Decimal(string_value) elif field.value_type in [ContactField.TYPE_STATE, ContactField.TYPE_DISTRICT, ContactField.TYPE_WARD]: - return AdminBoundary.get_by_path(self.org, string_value) + return Location.get_by_path(self.org, string_value) def get_field_display(self, field: ContactField) -> str: """ diff --git a/temba/contacts/tests/test_contact.py b/temba/contacts/tests/test_contact.py index 821492497f5..9d6d6e0fec7 100644 --- a/temba/contacts/tests/test_contact.py +++ b/temba/contacts/tests/test_contact.py @@ -13,7 +13,7 @@ from temba.channels.models import ChannelEvent from temba.contacts.models import URN, Contact, ContactField, ContactGroup, ContactURN from temba.flows.models import Flow -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.mailroom import modifiers from temba.msgs.models import Msg, SystemLabel from temba.orgs.models import Org @@ -932,10 +932,10 @@ def test_set_location_fields(self): not_state_field = self.create_field("not_state", "Not State", value_type=ContactField.TYPE_TEXT) # add duplicate district in different states - east_province = AdminBoundary.create(osm_id="R005", name="East Province", level=1, parent=self.country) - AdminBoundary.create(osm_id="R004", name="Remera", level=2, parent=east_province) - kigali = AdminBoundary.objects.get(name="Kigali City") - AdminBoundary.create(osm_id="R003", name="Remera", level=2, parent=kigali) + east_province = Location.create(osm_id="R005", name="East Province", level=1, parent=self.country) + Location.create(osm_id="R004", name="Remera", level=2, parent=east_province) + kigali = Location.objects.get(name="Kigali City") + Location.create(osm_id="R003", name="Remera", level=2, parent=kigali) joe = Contact.objects.get(pk=self.joe.pk) self.set_contact_field(joe, "district", "Remera") @@ -961,9 +961,9 @@ def test_set_location_fields(self): def test_set_location_ward_fields(self): self.setUpLocations() - state = AdminBoundary.create(osm_id="3710302", name="Kano", level=1, parent=self.country) - district = AdminBoundary.create(osm_id="3710307", name="Bichi", level=2, parent=state) - AdminBoundary.create(osm_id="3710377", name="Bichi", level=3, parent=district) + state = Location.create(osm_id="3710302", name="Kano", level=1, parent=self.country) + district = Location.create(osm_id="3710307", name="Bichi", level=2, parent=state) + Location.create(osm_id="3710377", name="Bichi", level=3, parent=district) self.create_field("state", "State", value_type=ContactField.TYPE_STATE) self.create_field("district", "District", value_type=ContactField.TYPE_DISTRICT) diff --git a/temba/contacts/tests/test_contactcrudl.py b/temba/contacts/tests/test_contactcrudl.py index 889b4afcc27..fbc027e3341 100644 --- a/temba/contacts/tests/test_contactcrudl.py +++ b/temba/contacts/tests/test_contactcrudl.py @@ -15,7 +15,7 @@ from temba.contacts.models import URN, Contact, ContactExport, ContactField from temba.flows.models import FlowSession, FlowStart from temba.ivr.models import Call -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.msgs.models import Msg from temba.orgs.models import Export, OrgRole from temba.schedules.models import Schedule @@ -32,8 +32,8 @@ class ContactCRUDLTest(CRUDLTestMixin, TembaTest): def setUp(self): super().setUp() - self.country = AdminBoundary.create(osm_id="171496", name="Rwanda", level=0) - AdminBoundary.create(osm_id="1708283", name="Kigali", level=1, parent=self.country) + self.location = Location.create(osm_id="171496", name="Rwanda", level=0) + Location.create(osm_id="1708283", name="Kigali", level=1, parent=self.location) self.create_field("age", "Age", value_type="N", show_in_table=True) self.create_field("home", "Home", value_type="S", show_in_table=True, priority=10) diff --git a/temba/flows/views.py b/temba/flows/views.py index 2bf0e6a58df..0a35bcca016 100644 --- a/temba/flows/views.py +++ b/temba/flows/views.py @@ -807,7 +807,7 @@ def get_features(self, org) -> list: features.append("classifier") if org.get_resthooks(): features.append("resthook") - if org.country_id: + if org.location_id: features.append("locations") return features diff --git a/temba/locations/management/commands/import_geojson.py b/temba/locations/management/commands/import_geojson.py index f5a6bbdd625..17e70af9c29 100644 --- a/temba/locations/management/commands/import_geojson.py +++ b/temba/locations/management/commands/import_geojson.py @@ -4,11 +4,10 @@ import geojson import regex -from django.contrib.gis.geos import MultiPolygon, Polygon from django.core.management.base import BaseCommand from django.db import connection, transaction -from temba.locations.models import AdminBoundary +from temba.locations.models import Location class Command(BaseCommand): @@ -56,23 +55,23 @@ def import_file(self, filename, file): parent_osm_id = str(props.get("parent_id")) # if parent_osm_id is not set and not LEVEL_COUNTRY check for old file format - if parent_osm_id == "None" and level != AdminBoundary.LEVEL_COUNTRY: - if level == AdminBoundary.LEVEL_STATE: + if parent_osm_id == "None" and level != Location.LEVEL_COUNTRY: + if level == Location.LEVEL_STATE: parent_osm_id = str(props["is_in_country"]) - elif level == AdminBoundary.LEVEL_DISTRICT: + elif level == Location.LEVEL_DISTRICT: parent_osm_id = str(props["is_in_state"]) - elif level == AdminBoundary.LEVEL_WARD: + elif level == Location.LEVEL_WARD: parent_osm_id = str(props["is_in_state"]) osm_id = str(props["osm_id"]) name = props.get("name", "") - if not name or name == "None" or level == AdminBoundary.LEVEL_COUNTRY: + if not name or name == "None" or level == Location.LEVEL_COUNTRY: name = props.get("name_en", "") # try to find parent, bail if we can't parent = None if parent_osm_id != "None": - parent = AdminBoundary.objects.filter(osm_id=parent_osm_id).first() + parent = Location.objects.filter(osm_id=parent_osm_id).first() if not parent: self.stdout.write( self.style.SUCCESS(f"Skipping {name} ({osm_id}) as parent {parent_osm_id} not found.") @@ -80,11 +79,11 @@ def import_file(self, filename, file): continue # try to find existing admin level by osm_id - boundary = AdminBoundary.objects.filter(osm_id=osm_id) + boundary = Location.objects.filter(osm_id=osm_id) # didn't find it? what about by name? if not boundary: - boundary = AdminBoundary.objects.filter(parent=parent, name__iexact=name) + boundary = Location.objects.filter(parent=parent, name__iexact=name) # skip over items with no geometry if not feature["geometry"] or not feature["geometry"]["coordinates"]: @@ -92,14 +91,14 @@ def import_file(self, filename, file): polygons = [] if feature["geometry"]["type"] == "Polygon": - polygons.append(Polygon(*feature["geometry"]["coordinates"])) + polygons.append(geojson.Polygon(coordinates=feature["geometry"]["coordinates"])) elif feature["geometry"]["type"] == "MultiPolygon": for polygon in feature["geometry"]["coordinates"]: - polygons.append(Polygon(*polygon)) + polygons.append(geojson.Polygon(coordinates=polygon)) else: raise Exception("Error importing %s, unknown geometry type '%s'" % (name, feature["geometry"]["type"])) - geometry = MultiPolygon(polygons) + geometry = geojson.loads(geojson.dumps(geojson.MultiPolygon(polygons))) kwargs = dict(osm_id=osm_id, name=name, level=level, parent=parent) if is_simplified: @@ -110,7 +109,7 @@ def import_file(self, filename, file): if not parent: kwargs["path"] = name else: - kwargs["path"] = parent.path + AdminBoundary.PADDED_PATH_SEPARATOR + name + kwargs["path"] = parent.path + Location.PADDED_PATH_SEPARATOR + name self.stdout.write(self.style.SUCCESS(f" ** updating {name} ({osm_id})")) boundary = boundary.first() @@ -122,14 +121,14 @@ def import_file(self, filename, file): # otherwise, this is new, so create it else: self.stdout.write(self.style.SUCCESS(f" ** adding {name} ({osm_id})")) - AdminBoundary.create(**kwargs) + Location.create(**kwargs) # keep track of this osm_id seen_osm_ids.add(osm_id) # now remove any unseen boundaries if osm_id: - last_boundary = AdminBoundary.objects.filter(osm_id=osm_id).first() + last_boundary = Location.objects.filter(osm_id=osm_id).first() if last_boundary: self.stdout.write(self.style.SUCCESS(f" ** removing unseen boundaries ({osm_id})")) country = last_boundary.get_root() @@ -198,22 +197,22 @@ def handle(self, *args, **options): with connection.cursor() as cursor: cursor.execute( """ - DELETE FROM locations_adminboundary WHERE id IN ( + DELETE FROM locations_location WHERE id IN ( - with recursive adminboundary_set(id, parent_id, name, depth, path, cycle, osm_id) AS ( + with recursive location_set(id, parent_id, name, depth, path, cycle, osm_id) AS ( SELECT ab.id, ab.parent_id, ab.name, 1, ARRAY[ab.id], false, ab.osm_id - from locations_adminboundary ab + from locations_location ab WHERE id = %s UNION ALL SELECT ab.id, ab.parent_id, ab.name, abs.depth+1, abs.path || ab.id, ab.id = ANY(abs.path), ab.osm_id - from locations_adminboundary ab , adminboundary_set abs + from locations_location ab , location_set abs WHERE not cycle AND ab.parent_id = abs.id ) SELECT abs.id -from adminboundary_set abs +from location_set abs WHERE NOT (abs.osm_id = ANY(%s))) """, (country.id, list(updated_osm_ids)), diff --git a/temba/locations/models.py b/temba/locations/models.py index a75f57565bc..2957b566de4 100644 --- a/temba/locations/models.py +++ b/temba/locations/models.py @@ -207,6 +207,117 @@ class Location(MPTTModel, models.Model): objects = NoGeometryManager() geometries = GeometryManager() + @staticmethod + def get_geojson_dump(name, features): + # build a feature collection + feature_collection = geojson.FeatureCollection(features) + return geojson.dumps({"name": name, "geometry": feature_collection}) + + def as_json(self, org): + result = dict(osm_id=self.osm_id, name=self.name, level=self.level, aliases="", path=self.path) + + if self.parent: + result["parent_osm_id"] = self.parent.osm_id + + aliases = "\n".join(sorted([alias.name for alias in self.aliases.filter(org=org)])) + result["aliases"] = aliases + return result + + def get_geojson_feature(self): + return geojson.Feature( + properties=dict(name=self.name, osm_id=self.osm_id, id=self.pk, level=self.level), + zoomable=True if self.children.all() else False, + geometry=None if not self.simplified_geometry else self.simplified_geometry, + ) + + def get_geojson(self): + return Location.get_geojson_dump(self.name, [self.get_geojson_feature()]) + + def get_children_geojson(self): + children = [] + for child in self.children.all(): + children.append(child.get_geojson_feature()) + return Location.get_geojson_dump(self.name, children) + + def update_aliases(self, org, user, aliases: list): + siblings = self.parent.children.all() + + self.aliases.filter(org=org).delete() # delete any existing aliases for this workspace + + for new_alias in aliases: + assert new_alias and len(new_alias) < Location.MAX_NAME_LEN + + # aliases are only allowed to exist on one boundary with same parent at a time + LocationAlias.objects.filter(name=new_alias, location__in=siblings, org=org).delete() + + LocationAlias.create(org, user, self, new_alias) + + def update(self, **kwargs): + Location.objects.filter(id=self.id).update(**kwargs) + + # update our object values so that self is up to date + for key, value in kwargs.items(): + setattr(self, key, value) + + def update_path(self): + if self.level == 0: + self.path = self.name + self.save(update_fields=("path",)) + + def _update_child_paths(location): + locations = Location.objects.filter(parent=location).only("name", "parent__path") + locations.update(path=Concat(Value(location.path), Value(" %s " % Location.PATH_SEPARATOR), F("name"))) + for location in locations: + _update_child_paths(location) + + _update_child_paths(self) + + def release(self): + for location in Location.objects.filter(parent=self): # pragma: no cover + location.release() + + self.aliases.all().delete() + self.delete() + + @classmethod + def create(cls, osm_id, name, level, parent=None, **kwargs): + """ + Create method that takes care of creating path based on name and parent + """ + path = name + if parent is not None: + path = parent.path + Location.PADDED_PATH_SEPARATOR + name + + return Location.objects.create(osm_id=osm_id, name=name, level=level, parent=parent, path=path, **kwargs) + + @classmethod + def strip_last_path(cls, path): + """ + Strips the last part of the passed in path. Throws if there is no separator + """ + parts = path.split(Location.PADDED_PATH_SEPARATOR) + if len(parts) <= 1: # pragma: no cover + raise Exception("strip_last_path called without a path to strip") + + return Location.PADDED_PATH_SEPARATOR.join(parts[:-1]) + + @classmethod + def get_by_path(cls, org, path): + cache = getattr(org, "_abs", {}) + + if not cache: + setattr(org, "_abs", cache) + + location = cache.get(path) + if not location: + location = Location.objects.filter(path=path).first() + cache[path] = location + + return location + + def __str__(self): + return self.name + class Meta: indexes = [models.Index(Upper("name"), name="locations_by_name")] @@ -220,5 +331,9 @@ class LocationAlias(SmartModel): location = models.ForeignKey(Location, on_delete=models.PROTECT, related_name="aliases") name = models.CharField(max_length=Location.MAX_NAME_LEN, help_text="The name for our alias") + @classmethod + def create(cls, org, user, location, name): + return cls.objects.create(org=org, location=location, name=name, created_by=user, modified_by=user) + class Meta: indexes = [models.Index(Upper("name"), name="locationaliases_by_name")] diff --git a/temba/locations/tests/test_geojson.py b/temba/locations/tests/test_geojson.py index d5e90f0fecd..c2a4107f3e8 100644 --- a/temba/locations/tests/test_geojson.py +++ b/temba/locations/tests/test_geojson.py @@ -8,7 +8,7 @@ from django.core.management import call_command from django.test.utils import captured_stdout -from temba.locations.models import AdminBoundary, BoundaryAlias +from temba.locations.models import Location, LocationAlias from temba.tests import TembaTest from temba.utils import json @@ -165,7 +165,7 @@ def test_wrong_filename(self): captured_output.getvalue(), "=== parsing data.json\nSkipping 'data.json', doesn't match file pattern.\n" ) - self.assertEqual(AdminBoundary.objects.count(), 0) + self.assertEqual(Location.objects.count(), 0) def test_filename_with_no_features(self): with patch("builtins.open", mock_open(read_data=self.data_geojson_no_features)): @@ -174,7 +174,7 @@ def test_filename_with_no_features(self): self.assertEqual(captured_output.getvalue(), "=== parsing R188933admin0_simplified.json\n") - self.assertEqual(AdminBoundary.objects.count(), 0) + self.assertEqual(Location.objects.count(), 0) def test_ok_filename_admin(self): with patch("builtins.open", mock_open(read_data=self.data_geojson_level_0)): @@ -186,7 +186,7 @@ def test_ok_filename_admin(self): "=== parsing R188933admin0_simplified.json\n ** adding Granica (R1000)\n ** removing unseen boundaries (R1000)\nOther unseen boundaries removed: 0\n ** updating paths for all of Granica\n", ) - self.assertEqual(AdminBoundary.objects.count(), 1) + self.assertEqual(Location.objects.count(), 1) def test_ok_filename_admin_level_with_country_prefix(self): with patch("builtins.open", mock_open(read_data=self.data_geojson_level_0)): @@ -198,7 +198,7 @@ def test_ok_filename_admin_level_with_country_prefix(self): "=== parsing R188933admin0_simplified.json\n ** adding Granica (R1000)\n ** removing unseen boundaries (R1000)\nOther unseen boundaries removed: 0\n ** updating paths for all of Granica\n", ) - self.assertEqual(AdminBoundary.objects.count(), 1) + self.assertEqual(Location.objects.count(), 1) def test_ok_filename_admin_level(self): with patch("builtins.open", mock_open(read_data=self.data_geojson_level_0)): @@ -210,7 +210,7 @@ def test_ok_filename_admin_level(self): "=== parsing admin_level_0_simplified.json\n ** adding Granica (R1000)\n ** removing unseen boundaries (R1000)\nOther unseen boundaries removed: 0\n ** updating paths for all of Granica\n", ) - self.assertEqual(AdminBoundary.objects.count(), 1) + self.assertEqual(Location.objects.count(), 1) def test_missing_parent_in_db(self): with patch("builtins.open", mock_open(read_data=self.data_geojson_without_parent)): @@ -222,7 +222,7 @@ def test_missing_parent_in_db(self): "=== parsing admin_level_1_simplified.json\nSkipping Međa (R2000) as parent R0 not found.\n", ) - self.assertEqual(AdminBoundary.objects.count(), 0) + self.assertEqual(Location.objects.count(), 0) def test_feature_without_geometry(self): with patch("builtins.open", mock_open(read_data=self.data_geojson_feature_no_geometry)): @@ -231,7 +231,7 @@ def test_feature_without_geometry(self): self.assertEqual(captured_output.getvalue(), "=== parsing admin_level_0_simplified.json\n") - self.assertEqual(AdminBoundary.objects.count(), 0) + self.assertEqual(Location.objects.count(), 0) def test_feature_multipolygon_geometry(self): with patch("builtins.open", mock_open(read_data=self.data_geojson_multipolygon)): @@ -318,7 +318,7 @@ def test_remove_unseen_boundaries(self): self.assertOSMIDs({"R1000", "R2000"}) - BoundaryAlias.create(self.org, self.admin, AdminBoundary.objects.get(osm_id="R2000"), "My Alias") + LocationAlias.create(self.org, self.admin, Location.objects.get(osm_id="R2000"), "My Alias") # update data, and add a new boundary geojson_data = [self.data_geojson_level_0, self.data_geojson_level_1_new_boundary] @@ -391,7 +391,7 @@ def test_zipfiles_parsing(self): self.assertOSMIDs({"R1000"}) def assertOSMIDs(self, ids): - self.assertEqual(set(ids), set(AdminBoundary.objects.values_list("osm_id", flat=True))) + self.assertEqual(set(ids), set(Location.objects.values_list("osm_id", flat=True))) class DownloadGeoJsonTest(TembaTest): diff --git a/temba/locations/tests/test_location.py b/temba/locations/tests/test_location.py index 142869f61a6..fa05e50427e 100644 --- a/temba/locations/tests/test_location.py +++ b/temba/locations/tests/test_location.py @@ -1,6 +1,6 @@ from django.urls import reverse -from temba.locations.models import AdminBoundary, BoundaryAlias +from temba.locations.models import Location, LocationAlias from temba.tests import TembaTest from temba.utils import json @@ -10,30 +10,30 @@ def test_aliases_update(self): self.setUpLocations() # make other workspace with the same locations - self.org2.country = self.country - self.org2.save(update_fields=("country",)) + self.org2.location = self.country + self.org2.save(update_fields=("location",)) - self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).count(), 1) - self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).get().name, "Kigari") - self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).count(), 1) - self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).get().name, "Chigali") + self.assertEqual(LocationAlias.objects.filter(location=self.state1, org=self.org).count(), 1) + self.assertEqual(LocationAlias.objects.filter(location=self.state1, org=self.org).get().name, "Kigari") + self.assertEqual(LocationAlias.objects.filter(location=self.state1, org=self.org2).count(), 1) + self.assertEqual(LocationAlias.objects.filter(location=self.state1, org=self.org2).get().name, "Chigali") self.state1.update_aliases(self.org, self.admin, ["Kigari", "CapitalCity", "MVK"]) - self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).count(), 3) + self.assertEqual(LocationAlias.objects.filter(location=self.state1, org=self.org).count(), 3) self.assertEqual( - list(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).values_list("name", flat=True)), + list(LocationAlias.objects.filter(location=self.state1, org=self.org).values_list("name", flat=True)), ["Kigari", "CapitalCity", "MVK"], ) - self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).get().name, "Chigali") + self.assertEqual(LocationAlias.objects.filter(location=self.state1, org=self.org2).get().name, "Chigali") self.state1.update_aliases(self.org2, self.admin2, ["Chigali", "CapitalCity", "MVK"]) - self.assertEqual(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).count(), 3) + self.assertEqual(LocationAlias.objects.filter(location=self.state1, org=self.org2).count(), 3) self.assertEqual( - list(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org2).values_list("name", flat=True)), + list(LocationAlias.objects.filter(location=self.state1, org=self.org2).values_list("name", flat=True)), ["Chigali", "CapitalCity", "MVK"], ) self.assertEqual( - list(BoundaryAlias.objects.filter(boundary=self.state1, org=self.org).values_list("name", flat=True)), + list(LocationAlias.objects.filter(location=self.state1, org=self.org).values_list("name", flat=True)), ["Kigari", "CapitalCity", "MVK"], ) @@ -43,31 +43,31 @@ def test_boundaries(self): self.login(self.admin) # clear our country on our org - self.org.country = None + self.org.location = None self.org.save() # try stripping path on our country, will fail with self.assertRaises(Exception): - AdminBoundary.strip_last_path("Rwanda") + Location.strip_last_path("Rwanda") # normal strip - self.assertEqual(AdminBoundary.strip_last_path("Rwanda > Kigali City"), "Rwanda") + self.assertEqual(Location.strip_last_path("Rwanda > Kigali City"), "Rwanda") # get the aliases for our user org - response = self.client.get(reverse("locations.adminboundary_alias")) + response = self.client.get(reverse("locations.location_alias")) # should be a redirect to our org home self.assertRedirect(response, reverse("orgs.org_workspace")) # now set it to rwanda - self.org.country = self.country + self.org.location = self.country self.org.save() # our country is set to rwanda, we should get it as the main object - response = self.client.get(reverse("locations.adminboundary_alias")) + response = self.client.get(reverse("locations.location_alias")) self.assertEqual(self.country, response.context["object"]) # ok, now get the geometry for rwanda - response = self.client.get(reverse("locations.adminboundary_geometry", args=[self.country.osm_id])) + response = self.client.get(reverse("locations.location_geometry", args=[self.country.osm_id])) # should be json response_json = response.json() @@ -79,7 +79,7 @@ def test_boundaries(self): # should have our two top level states self.assertEqual(2, len(geometry["features"])) # now get it for one of the sub areas - response = self.client.get(reverse("locations.adminboundary_geometry", args=[self.district1.osm_id])) + response = self.client.get(reverse("locations.location_geometry", args=[self.district1.osm_id])) response_json = response.json() geometry = response_json["geometry"] @@ -90,7 +90,7 @@ def test_boundaries(self): self.assertEqual(1, len(geometry["features"])) # now grab our aliases - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.country.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.country.osm_id])) response_json = response.json() self.assertEqual( @@ -130,7 +130,7 @@ def test_boundaries(self): # update our alias for east with self.assertNumQueries(13): response = self.client.post( - reverse("locations.adminboundary_boundaries", args=[self.country.osm_id]), + reverse("locations.location_boundaries", args=[self.country.osm_id]), json.dumps(dict(osm_id=self.state2.osm_id, aliases="kigs\n")), content_type="application/json", ) @@ -139,7 +139,7 @@ def test_boundaries(self): # fetch our aliases with self.assertNumQueries(18): - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.country.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.country.osm_id])) response_json = response.json() # now have kigs as an alias @@ -149,7 +149,7 @@ def test_boundaries(self): # update our alias for Nyarugenge response = self.client.post( - reverse("locations.adminboundary_boundaries", args=[self.state1.osm_id]), + reverse("locations.location_boundaries", args=[self.state1.osm_id]), json.dumps(dict(osm_id=self.district3.osm_id, aliases="kigs\n")), content_type="application/json", ) @@ -158,7 +158,7 @@ def test_boundaries(self): # fetch our aliases with self.assertNumQueries(25): - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.state1.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.state1.osm_id])) response_json = response.json() # now have kigs as an alias @@ -168,7 +168,7 @@ def test_boundaries(self): # update our alias for kigali response = self.client.post( - reverse("locations.adminboundary_boundaries", args=[self.country.osm_id]), + reverse("locations.location_boundaries", args=[self.country.osm_id]), json.dumps(dict(osm_id=self.state1.osm_id, aliases="kigs\nkig")), content_type="application/json", ) @@ -176,7 +176,7 @@ def test_boundaries(self): self.assertEqual(200, response.status_code) # fetch our aliases - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.state1.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.state1.osm_id])) response_json = response.json() # now have kigs as an alias @@ -185,7 +185,7 @@ def test_boundaries(self): self.assertEqual("kigs", children[0]["aliases"]) # fetch our aliases again - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.country.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.country.osm_id])) response_json = response.json() # now have kigs as an alias @@ -197,7 +197,7 @@ def test_boundaries(self): ) # kigs alias should have been moved from the eastern province boundary # fetch our aliases - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.state1.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.state1.osm_id])) response_json = response.json() # now have kigs still as an alias on Nyarugenge @@ -207,25 +207,25 @@ def test_boundaries(self): # query for our alias search_result = self.client.get( - f"{reverse('locations.adminboundary_boundaries', args=[self.country.osm_id])}?q=kigs" + f"{reverse('locations.location_boundaries', args=[self.country.osm_id])}?q=kigs" ) self.assertEqual("Kigali City", search_result.json()[0]["name"]) # update our alias for kigali with duplicates response = self.client.post( - reverse("locations.adminboundary_boundaries", args=[self.country.osm_id]), + reverse("locations.location_boundaries", args=[self.country.osm_id]), json.dumps(dict(osm_id=self.state1.osm_id, aliases="kigs\nkig\nkig\nkigs\nkig")), content_type="application/json", ) self.assertEqual(200, response.status_code) - BoundaryAlias.objects.create( - boundary=self.state1, org=self.org2, name="KGL", created_by=self.admin2, modified_by=self.admin2 + LocationAlias.objects.create( + location=self.state1, org=self.org2, name="KGL", created_by=self.admin2, modified_by=self.admin2 ) # fetch our aliases again - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.country.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.country.osm_id])) response_json = response.json() self.assertEqual(response_json[0]["aliases"], "") @@ -249,28 +249,28 @@ def test_boundaries(self): ) response = self.client.post( - reverse("locations.adminboundary_boundaries", args=[self.country.osm_id]), + reverse("locations.location_boundaries", args=[self.country.osm_id]), json.dumps(geo_data), content_type="application/json", ) self.assertEqual(200, response.status_code) - BoundaryAlias.objects.create( - boundary=self.country, org=self.org2, name="SameRwanda", created_by=self.admin2, modified_by=self.admin2 + LocationAlias.objects.create( + location=self.country, org=self.org2, name="SameRwanda", created_by=self.admin2, modified_by=self.admin2 ) - BoundaryAlias.objects.create( - boundary=self.country, org=self.org, name="MyRwanda", created_by=self.admin2, modified_by=self.admin2 + LocationAlias.objects.create( + location=self.country, org=self.org, name="MyRwanda", created_by=self.admin2, modified_by=self.admin2 ) # fetch our aliases again - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.country.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.country.osm_id])) response_json = response.json() self.assertEqual(response_json[0]["aliases"], "MyRwanda") # fetch aliases again - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[self.country.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[self.country.osm_id])) response_json = response.json() children = response_json[0]["children"] self.assertEqual(children[0].get("name"), self.state2.name) @@ -278,7 +278,7 @@ def test_boundaries(self): # trigger wrong request data using bad json response = self.client.post( - reverse("locations.adminboundary_boundaries", args=[self.country.osm_id]), + reverse("locations.location_boundaries", args=[self.country.osm_id]), """{"data":"foo \r\n bar"}""", content_type="application/json", ) @@ -288,43 +288,40 @@ def test_boundaries(self): self.assertEqual(response_json.get("status"), "error") # Get geometry of admin boundary without sub-levels, should return one feature - response = self.client.get(reverse("locations.adminboundary_geometry", args=[self.ward3.osm_id])) + response = self.client.get(reverse("locations.location_geometry", args=[self.ward3.osm_id])) self.assertEqual(200, response.status_code) response_json = response.json() self.assertEqual(len(response_json.get("geometry").get("features")), 1) - long_number_ids = AdminBoundary.create(osm_id="SOME.123.12_12", name="Gatsibo", level=2, parent=self.state2) + long_number_ids = Location.create(osm_id="SOME.123.12_12", name="Gatsibo", level=2, parent=self.state2) - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[long_number_ids.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[long_number_ids.osm_id])) self.assertEqual(200, response.status_code) - word_only_ids = AdminBoundary.create(osm_id="SOME", name="Gatsibo", level=2, parent=self.state2) + word_only_ids = Location.create(osm_id="SOME", name="Gatsibo", level=2, parent=self.state2) - response = self.client.get(reverse("locations.adminboundary_boundaries", args=[word_only_ids.osm_id])) + response = self.client.get(reverse("locations.location_boundaries", args=[word_only_ids.osm_id])) self.assertEqual(200, response.status_code) - def test_adminboundary_create(self): - # create a simple boundary - boundary = AdminBoundary.create(osm_id="-1", name="Null Island", level=0) + def test_location_create(self): + # create a simple location + boundary = Location.create(osm_id="-1", name="Null Island", level=0) self.assertEqual(boundary.path, "Null Island") self.assertIsNone(boundary.simplified_geometry) # create a simple boundary with parent - child_boundary = AdminBoundary.create(osm_id="-2", name="Palm Tree", level=1, parent=boundary) + child_boundary = Location.create(osm_id="-2", name="Palm Tree", level=1, parent=boundary) self.assertEqual(child_boundary.path, "Null Island > Palm Tree") self.assertIsNone(child_boundary.simplified_geometry) - wkb_geometry = ( - "0106000000010000000103000000010000000400000000000000407241C01395356EBA0B304000000000602640C0CDC2B7C4027A27" - "400000000080443DC040848F2D272C304000000000407241C01395356EBA0B3040" - ) + wkb_geometry = {"type": "MultiPolygon", "coordinates": [[[[71.83225, 39.95415], [71.82655, 39.9563]]]]} # create a simple boundary with parent and geometry - geom_boundary = AdminBoundary.create( + geom_boundary = Location.create( osm_id="-3", name="Plum Tree", level=1, parent=boundary, simplified_geometry=wkb_geometry ) self.assertEqual(geom_boundary.path, "Null Island > Plum Tree") self.assertIsNotNone(geom_boundary.simplified_geometry) - # path should not be defined when calling AdminBoundary.create - self.assertRaises(TypeError, AdminBoundary.create, osm_id="-1", name="Null Island", level=0, path="some path") + # path should not be defined when calling Location.create + self.assertRaises(TypeError, Location.create, osm_id="-1", name="Null Island", level=0, path="some path") diff --git a/temba/locations/views.py b/temba/locations/views.py index 5ef64e3fd5e..09eebbfa6c5 100644 --- a/temba/locations/views.py +++ b/temba/locations/views.py @@ -7,7 +7,7 @@ from django.utils.translation import gettext_lazy as _ from django.views.decorators.csrf import csrf_exempt -from temba.locations.models import AdminBoundary, BoundaryAlias +from temba.locations.models import Location, LocationAlias from temba.orgs.views.mixins import OrgPermsMixin from temba.utils import json from temba.utils.views.mixins import ContextMenuMixin, SpaMixin @@ -15,7 +15,7 @@ class BoundaryCRUDL(SmartCRUDL): actions = ("alias", "geometry", "boundaries") - model = AdminBoundary + model = Location class Alias(SpaMixin, OrgPermsMixin, ContextMenuMixin, SmartReadView): menu_path = "/settings/workspace" @@ -32,14 +32,14 @@ def pre_process(self, request, *args, **kwargs): # we didn't shortcut for some other reason, check that they have an # org if not response: - if not request.org.country: + if not request.org.location: messages.warning(request, _("You must select a country for your workspace.")) return HttpResponseRedirect(reverse("orgs.org_workspace")) return None def get_object(self, queryset=None): - return self.request.org.country + return self.request.org.location class Geometry(OrgPermsMixin, SmartReadView): @classmethod @@ -49,7 +49,7 @@ def derive_url_pattern(cls, path, action): return r"^%s/%s/(?P\w+\.?\d+\.?\d?\_?\d?)/$" % (path, action) def get_object(self): - return AdminBoundary.geometries.get(osm_id=self.kwargs["osmId"]) + return Location.geometries.get(osm_id=self.kwargs["osmId"]) def render_to_response(self, context): if self.object.children.all().count() > 0: @@ -68,7 +68,7 @@ def derive_url_pattern(cls, path, action): return r"^%s/%s/(?P[\w\.]+)/$" % (path, action) def get_object(self): - return AdminBoundary.geometries.get(osm_id=self.kwargs["osmId"]) + return Location.geometries.get(osm_id=self.kwargs["osmId"]) def post(self, request, *args, **kwargs): # try to parse our body @@ -80,7 +80,7 @@ def post(self, request, *args, **kwargs): except Exception as e: return JsonResponse(dict(status="error", description="Error parsing JSON: %s" % str(e)), status=400) - boundary = AdminBoundary.objects.filter(osm_id=boundary_update["osm_id"]).first() + boundary = Location.objects.filter(osm_id=boundary_update["osm_id"]).first() aliases = boundary_update.get("aliases", "") if boundary: unique_new_aliases = [a.strip() for a in set(aliases.split("\n")) if a] @@ -100,13 +100,13 @@ def get(self, request, *args, **kwargs): if query: page = int(request.GET.get("page", 0)) matches = set( - AdminBoundary.objects.filter( - path__startswith=f"{boundary.name} {AdminBoundary.PATH_SEPARATOR}" - ).filter(name__icontains=query) + Location.objects.filter(path__startswith=f"{boundary.name} {Location.PATH_SEPARATOR}").filter( + name__icontains=query + ) ) - aliases = BoundaryAlias.objects.filter(name__icontains=query, org=org) + aliases = LocationAlias.objects.filter(name__icontains=query, org=org) for alias in aliases: - matches.add(alias.boundary) + matches.add(alias.location) start = page * page_size end = start + page_size @@ -119,10 +119,10 @@ def get(self, request, *args, **kwargs): path = [] while boundary: children = list( - AdminBoundary.objects.filter(parent__osm_id=boundary.osm_id) + Location.objects.filter(parent__osm_id=boundary.osm_id) .order_by("name") .prefetch_related( - Prefetch("aliases", queryset=BoundaryAlias.objects.filter(org=org).order_by("name")) + Prefetch("aliases", queryset=LocationAlias.objects.filter(org=org).order_by("name")) ) ) @@ -130,7 +130,7 @@ def get(self, request, *args, **kwargs): children_json = [] for child in children: child_json = child.as_json(org) - child_json["has_children"] = AdminBoundary.objects.filter(parent__osm_id=child.osm_id).exists() + child_json["has_children"] = Location.objects.filter(parent__osm_id=child.osm_id).exists() children_json.append(child_json) item["children"] = children_json diff --git a/temba/orgs/models.py b/temba/orgs/models.py index 869b45a3a1e..4677aed08a9 100644 --- a/temba/orgs/models.py +++ b/temba/orgs/models.py @@ -36,7 +36,7 @@ from temba import mailroom from temba.archives.models import Archive -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.utils import json, languages, on_transaction_commit from temba.utils.dates import datetime_to_str from temba.utils.email import EmailSender @@ -968,7 +968,7 @@ def get_resthooks(self): @classmethod def get_possible_countries(cls): - return AdminBoundary.objects.filter(level=0).order_by("name") + return Location.objects.filter(level=0).order_by("name") @property def default_country_code(self) -> str: @@ -984,9 +984,9 @@ def default_country(self): Gets the default country as a pycountry country for this org """ - # first try the country boundary field - if self.country: - country = pycountry.countries.get(name=self.country.name) + # first try the location field + if self.location: + country = pycountry.countries.get(name=self.location.name) if country: return country diff --git a/temba/orgs/tests/test_org.py b/temba/orgs/tests/test_org.py index 2a0f873bcab..c9c8ce9465c 100644 --- a/temba/orgs/tests/test_org.py +++ b/temba/orgs/tests/test_org.py @@ -17,7 +17,7 @@ from temba.contacts.models import ContactExport, ContactField, ContactImport, ContactImportBatch from temba.flows.models import FlowLabel, FlowRun, FlowSession, FlowStart, FlowStartCount, ResultsExport from temba.globals.models import Global -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.msgs.models import MessageExport, Msg from temba.notifications.incidents.builtin import ChannelDisconnectedIncidentType from temba.notifications.types.builtin import ExportFinishedNotificationType @@ -162,7 +162,7 @@ def test_country_view(self): settings_url = reverse("orgs.org_workspace") country_url = reverse("orgs.org_country") - rwanda = AdminBoundary.objects.get(name="Rwanda") + rwanda = Location.objects.get(name="Rwanda") # can't see this page if not logged in self.assertLoginRedirect(self.client.get(country_url)) @@ -173,11 +173,11 @@ def test_country_view(self): self.assertEqual(200, response.status_code) # save with Rwanda as a country - self.client.post(country_url, {"country": rwanda.id}) + self.client.post(country_url, {"location": rwanda.id}) # assert it has changed self.org.refresh_from_db() - self.assertEqual("Rwanda", str(self.org.country)) + self.assertEqual("Rwanda", str(self.org.location)) self.assertEqual("RW", self.org.default_country_code) response = self.client.get(settings_url) @@ -189,8 +189,8 @@ def test_country_view(self): self.assertNotContains(response, "Rwanda") def test_default_country(self): - # if country boundary is set and name is valid country, that has priority - self.org.country = AdminBoundary.create(osm_id="171496", name="Ecuador", level=0) + # if location boundary is set and name is valid country, that has priority + self.org.location = Location.create(osm_id="171496", name="Ecuador", level=0) self.org.timezone = "Africa/Nairobi" self.org.save(update_fields=("country", "timezone")) @@ -198,9 +198,9 @@ def test_default_country(self): del self.org.default_country - # if country name isn't valid, we'll try timezone - self.org.country.name = "Fantasia" - self.org.country.save(update_fields=("name",)) + # if location name isn't valid, we'll try timezone + self.org.location.name = "Fantasia" + self.org.location.save(update_fields=("name",)) self.assertEqual("KE", self.org.default_country.alpha_2) diff --git a/temba/orgs/views/tests/test_orgcrudl.py b/temba/orgs/views/tests/test_orgcrudl.py index 8b4fc13aace..944d4168c02 100644 --- a/temba/orgs/views/tests/test_orgcrudl.py +++ b/temba/orgs/views/tests/test_orgcrudl.py @@ -144,7 +144,7 @@ def test_workspace(self): self.child_org = Org.objects.create( name="Child Org", timezone=ZoneInfo("Africa/Kigali"), - country=self.org.country, + location=self.org.location, created_by=self.user, modified_by=self.user, parent=self.org, diff --git a/temba/orgs/views/views.py b/temba/orgs/views/views.py index 4c8917c1284..12e6763ded6 100644 --- a/temba/orgs/views/views.py +++ b/temba/orgs/views/views.py @@ -2091,7 +2091,7 @@ def derive_exclude(self): class Country(FormaxSectionMixin, InferOrgMixin, OrgPermsMixin, SmartUpdateView): class CountryForm(forms.ModelForm): - country = forms.ModelChoiceField( + location = forms.ModelChoiceField( Org.get_possible_countries(), required=False, label=_("The country used for location values. (optional)"), @@ -2101,7 +2101,7 @@ class CountryForm(forms.ModelForm): class Meta: model = Org - fields = ("country",) + fields = ("location",) form_class = CountryForm diff --git a/temba/settings_common.py b/temba/settings_common.py index 57c64e08169..ec9ec344528 100644 --- a/temba/settings_common.py +++ b/temba/settings_common.py @@ -349,7 +349,7 @@ "flows.flowstart": ("interrupt", "status"), "flows.flowsession": ("json",), "globals.global": ("unused",), - "locations.adminboundary": ("alias", "boundaries", "geometry"), + "locations.location": ("alias", "boundaries", "geometry"), "msgs.broadcast": ("scheduled", "scheduled_delete"), "msgs.msg": ("archive", "export", "label", "menu"), "orgs.export": ("download",), @@ -443,10 +443,10 @@ "flows.flowstart.*", "globals.global.*", "ivr.call.*", - "locations.adminboundary_alias", - "locations.adminboundary_boundaries", - "locations.adminboundary_geometry", - "locations.adminboundary_list", + "locations.location_alias", + "locations.location_boundaries", + "locations.location_geometry", + "locations.location_list", "msgs.broadcast.*", "msgs.label.*", "msgs.media_create", @@ -539,10 +539,10 @@ "flows.flowstart_list", "globals.global.*", "ivr.call_list", - "locations.adminboundary_alias", - "locations.adminboundary_boundaries", - "locations.adminboundary_geometry", - "locations.adminboundary_list", + "locations.location_alias", + "locations.location_boundaries", + "locations.location_geometry", + "locations.location_list", "msgs.broadcast.*", "msgs.label.*", "msgs.media_create", @@ -610,10 +610,10 @@ "globals.global_list", "globals.global_read", "ivr.call_list", - "locations.adminboundary_alias", - "locations.adminboundary_boundaries", - "locations.adminboundary_geometry", - "locations.adminboundary_list", + "locations.location_alias", + "locations.location_boundaries", + "locations.location_geometry", + "locations.location_list", "msgs.broadcast_list", "msgs.broadcast_scheduled", "msgs.label_list", @@ -664,7 +664,7 @@ "contacts.contact_update", "contacts.contactfield_list", "contacts.contactgroup_list", - "locations.adminboundary_list", + "locations.location_list", "msgs.media_create", "msgs.msg_create", "orgs.org_read", diff --git a/temba/tests/base.py b/temba/tests/base.py index 93c7e9a17ad..551ad11f307 100644 --- a/temba/tests/base.py +++ b/temba/tests/base.py @@ -26,7 +26,7 @@ from temba.contacts.models import URN, Contact, ContactField, ContactGroup, ContactImport from temba.flows.models import Flow, FlowRun, FlowSession from temba.ivr.models import Call -from temba.locations.models import AdminBoundary, BoundaryAlias +from temba.locations.models import Location, LocationAlias from temba.msgs.models import Broadcast, Label, Msg, OptIn from temba.orgs.models import Org, OrgRole, User from temba.templates.models import Template @@ -124,24 +124,24 @@ def setUpLocations(self): """ Installs some basic test location data for Rwanda """ - self.country = AdminBoundary.create(osm_id="171496", name="Rwanda", level=0) - self.state1 = AdminBoundary.create(osm_id="1708283", name="Kigali City", level=1, parent=self.country) - self.state2 = AdminBoundary.create(osm_id="171591", name="Eastern Province", level=1, parent=self.country) - self.district1 = AdminBoundary.create(osm_id="R1711131", name="Gatsibo", level=2, parent=self.state2) - self.district2 = AdminBoundary.create(osm_id="1711163", name="Kayônza", level=2, parent=self.state2) - self.district3 = AdminBoundary.create(osm_id="3963734", name="Nyarugenge", level=2, parent=self.state1) - self.district4 = AdminBoundary.create(osm_id="1711142", name="Rwamagana", level=2, parent=self.state2) - self.ward1 = AdminBoundary.create(osm_id="171113181", name="Kageyo", level=3, parent=self.district1) - self.ward2 = AdminBoundary.create(osm_id="171116381", name="Kabare", level=3, parent=self.district2) - self.ward3 = AdminBoundary.create(osm_id="VMN.49.1_1", name="Bukure", level=3, parent=self.district4) - - BoundaryAlias.create(self.org, self.admin, self.state1, "Kigari") - BoundaryAlias.create(self.org2, self.admin2, self.state1, "Chigali") + self.country = Location.create(osm_id="171496", name="Rwanda", level=0) + self.state1 = Location.create(osm_id="1708283", name="Kigali City", level=1, parent=self.country) + self.state2 = Location.create(osm_id="171591", name="Eastern Province", level=1, parent=self.country) + self.district1 = Location.create(osm_id="R1711131", name="Gatsibo", level=2, parent=self.state2) + self.district2 = Location.create(osm_id="1711163", name="Kayônza", level=2, parent=self.state2) + self.district3 = Location.create(osm_id="3963734", name="Nyarugenge", level=2, parent=self.state1) + self.district4 = Location.create(osm_id="1711142", name="Rwamagana", level=2, parent=self.state2) + self.ward1 = Location.create(osm_id="171113181", name="Kageyo", level=3, parent=self.district1) + self.ward2 = Location.create(osm_id="171116381", name="Kabare", level=3, parent=self.district2) + self.ward3 = Location.create(osm_id="VMN.49.1_1", name="Bukure", level=3, parent=self.district4) + + LocationAlias.create(self.org, self.admin, self.state1, "Kigari") + LocationAlias.create(self.org2, self.admin2, self.state1, "Chigali") self.country.update_path() - self.org.country = self.country - self.org.save(update_fields=("country",)) + self.org.location = self.country + self.org.save(update_fields=("location",)) def tearDown(self): super().tearDown() diff --git a/temba/tests/mailroom.py b/temba/tests/mailroom.py index 3df1d3e8fea..fd60f81e221 100644 --- a/temba/tests/mailroom.py +++ b/temba/tests/mailroom.py @@ -16,7 +16,7 @@ from temba.channels.models import ChannelEvent from temba.contacts.models import URN, Contact, ContactField, ContactGroup, ContactURN from temba.flows.models import FlowRun, FlowSession -from temba.locations.models import AdminBoundary +from temba.locations.models import Location from temba.mailroom.client.client import MailroomClient from temba.mailroom.modifiers import Modifier from temba.msgs.models import Broadcast, Msg @@ -739,7 +739,7 @@ def serialize_field_value(contact, field, value): loc_value = None # for locations, if it has a '>' then it is explicit, look it up that way - if AdminBoundary.PATH_SEPARATOR in str_value: + if Location.PATH_SEPARATOR in str_value: loc_value = parse_location_path(contact.org, str_value) # otherwise, try to parse it as a name at the appropriate level @@ -748,17 +748,17 @@ def serialize_field_value(contact, field, value): district_field = org.fields.filter(value_type=ContactField.TYPE_DISTRICT).first() district_value = contact.get_field_value(district_field) if district_value: - loc_value = parse_location(org, str_value, AdminBoundary.LEVEL_WARD, district_value) + loc_value = parse_location(org, str_value, Location.LEVEL_WARD, district_value) elif field.value_type == ContactField.TYPE_DISTRICT: state_field = org.fields.filter(value_type=ContactField.TYPE_STATE).first() if state_field: state_value = contact.get_field_value(state_field) if state_value: - loc_value = parse_location(org, str_value, AdminBoundary.LEVEL_DISTRICT, state_value) + loc_value = parse_location(org, str_value, Location.LEVEL_DISTRICT, state_value) elif field.value_type == ContactField.TYPE_STATE: - loc_value = parse_location(org, str_value, AdminBoundary.LEVEL_STATE) + loc_value = parse_location(org, str_value, Location.LEVEL_STATE) if loc_value is not None and len(loc_value) > 0: loc_value = loc_value[0] @@ -777,15 +777,15 @@ def serialize_field_value(contact, field, value): field_dict["number"] = int(num_as_int) if num_value == num_as_int else num_value if loc_value: - if loc_value.level == AdminBoundary.LEVEL_STATE: + if loc_value.level == Location.LEVEL_STATE: field_dict["state"] = loc_value.path - elif loc_value.level == AdminBoundary.LEVEL_DISTRICT: + elif loc_value.level == Location.LEVEL_DISTRICT: field_dict["district"] = loc_value.path - field_dict["state"] = AdminBoundary.strip_last_path(loc_value.path) - elif loc_value.level == AdminBoundary.LEVEL_WARD: + field_dict["state"] = Location.strip_last_path(loc_value.path) + elif loc_value.level == Location.LEVEL_WARD: field_dict["ward"] = loc_value.path - field_dict["district"] = AdminBoundary.strip_last_path(loc_value.path) - field_dict["state"] = AdminBoundary.strip_last_path(field_dict["district"]) + field_dict["district"] = Location.strip_last_path(loc_value.path) + field_dict["state"] = Location.strip_last_path(field_dict["district"]) return field_dict @@ -807,13 +807,13 @@ def parse_location(org, location_string, level, parent=None): Simplified version of mailroom's location parsing """ # no country? bail - if not org.country_id or not isinstance(location_string, str): + if not org.location_id or not isinstance(location_string, str): return [] boundary = None # try it as a path first if it looks possible - if level == AdminBoundary.LEVEL_COUNTRY or AdminBoundary.PATH_SEPARATOR in location_string: + if level == Location.LEVEL_COUNTRY or Location.PATH_SEPARATOR in location_string: boundary = parse_location_path(org, location_string) if boundary: boundary = [boundary] @@ -835,8 +835,8 @@ def parse_location_path(org, location_string): Parses a location path into a single location, returning None if not found """ return ( - AdminBoundary.objects.filter(path__iexact=location_string.strip()).first() - if org.country_id and isinstance(location_string, str) + Location.objects.filter(path__iexact=location_string.strip()).first() + if org.location_id and isinstance(location_string, str) else None ) @@ -844,13 +844,13 @@ def parse_location_path(org, location_string): def find_boundary_by_name(org, name, level, parent): # first check if we have a direct name match if parent: - boundary = parent.children.filter(name__iexact=name, level=level) + location = parent.children.filter(name__iexact=name, level=level) else: query = dict(name__iexact=name, level=level) - query["__".join(["parent"] * level)] = org.country - boundary = AdminBoundary.objects.filter(**query) + query["__".join(["parent"] * level)] = org.location + location = Location.objects.filter(**query) - return boundary + return location def exit_sessions(session_ids: list, status: str): diff --git a/templates/locations/adminboundary_alias.html b/templates/locations/adminboundary_alias.html index b690a2e28a0..f7a33bf76c7 100644 --- a/templates/locations/adminboundary_alias.html +++ b/templates/locations/adminboundary_alias.html @@ -26,5 +26,5 @@ regions, aliases. {% endblocktrans %} - + {% endblock content %} diff --git a/templates/orgs/org_country.html b/templates/orgs/org_country.html index 52a70e0343c..5d926195894 100644 --- a/templates/orgs/org_country.html +++ b/templates/orgs/org_country.html @@ -2,18 +2,18 @@ {% load smartmin i18n %} {% block fields %} - {% render_field 'country' %} + {% render_field 'location' %} {% endblock fields %} {% block summary %} - {% if object.country %} + {% if object.location %}
- {% blocktrans trimmed with country=object.country %} + {% blocktrans trimmed with country=object.location %} Responses to location questions must be in {{ country }}. {% endblocktrans %}
-
{% trans "Edit Aliases" %}
+
{% trans "Edit Aliases" %}
{% else %}