Skip to content

popoto.fields.geo_field

popoto.fields.geo_field

Geospatial field implementation for Popoto Redis ORM.

This module provides GeoField, which leverages Redis's native geospatial indexing capabilities (GEOADD, GEORADIUS, GEORADIUSBYMEMBER) to enable location-based queries on Popoto models.

Design Philosophy

Redis stores geospatial data in sorted sets using geohash encoding, making radius queries extremely efficient (O(N+log(M)) where N is the number of elements in the radius and M is total elements). By exposing this through a Django-like field interface, Popoto enables powerful location-based applications with minimal boilerplate.

Key Design Decisions
  1. Separate geo index: Coordinates are stored in a dedicated Redis sorted set (not in the model's hash), enabling efficient spatial queries without loading full model data.

  2. Latitude-first convention: The Coordinates namedtuple uses (latitude, longitude) order, matching common geographic convention (e.g., Google Maps), even though Redis GEOADD expects (longitude, latitude) internally.

  3. Null-safe design: Both coordinates must be present or both must be None. Partial coordinates (only lat or only lng) are explicitly rejected to prevent data integrity issues.

Example

class Restaurant(Model): name = KeyField() location = GeoField()

restaurant = Restaurant.create( name="Pizza Place", location=GeoField.Coordinates(latitude=40.7128, longitude=-74.0060) )

Find all restaurants within 5km

nearby = Restaurant.query.filter( location=(40.7128, -74.0060), location_radius=5, location_radius_unit='km' )

GeoField

Bases: Field

A field that stores geospatial coordinates and enables radius-based queries.

GeoField provides a bridge between Python's (latitude, longitude) representation and Redis's powerful GEOSPATIAL commands. Each GeoField on a model creates a separate Redis sorted set that acts as a spatial index, enabling efficient "find all objects within X distance" queries.

Values are GeoField.Coordinates(latitude, longitude) namedtuples (plain tuples also accepted). Backed by a Redis GEO set.

The field stores coordinates both in the model's Redis hash (for retrieval) and in a dedicated geo index (for spatial queries). This dual-storage approach optimizes for both direct access and radius searches.

Filter lookups: coordinates, _latitude, _longitude, _radius, _radius_unit (m/km/ft/mi), _member, _with_distances.

Attributes:

Name Type Description
Coordinates

A namedtuple for type-safe coordinate representation. Provides self-documenting code and prevents lat/lng order confusion.

type type

Always tuple; coordinates are stored as (latitude, longitude).

latitude float

Current latitude value (float or None).

longitude float

Current longitude value (float or None).

null bool

Defaults to True, allowing models without location data.

Example

class City(Model): name = KeyField() coordinates = GeoField()

rome = City.create( name="Rome", coordinates=GeoField.Coordinates(latitude=41.9028, longitude=12.4964) )

Query by coordinates and radius

nearby_cities = City.query.filter( coordinates=rome.coordinates, coordinates_radius=100, coordinates_radius_unit='km' )

Source code in src/popoto/fields/geo_field.py
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
class GeoField(Field):
    """
    A field that stores geospatial coordinates and enables radius-based queries.

    GeoField provides a bridge between Python's (latitude, longitude) representation
    and Redis's powerful GEOSPATIAL commands. Each GeoField on a model creates a
    separate Redis sorted set that acts as a spatial index, enabling efficient
    "find all objects within X distance" queries.

    Values are ``GeoField.Coordinates(latitude, longitude)`` namedtuples
    (plain tuples also accepted). Backed by a Redis GEO set.

    The field stores coordinates both in the model's Redis hash (for retrieval)
    and in a dedicated geo index (for spatial queries). This dual-storage approach
    optimizes for both direct access and radius searches.

    Filter lookups: coordinates, ``_latitude``, ``_longitude``, ``_radius``,
    ``_radius_unit`` (m/km/ft/mi), ``_member``, ``_with_distances``.

    Attributes:
        Coordinates: A namedtuple for type-safe coordinate representation.
            Provides self-documenting code and prevents lat/lng order confusion.
        type: Always tuple; coordinates are stored as (latitude, longitude).
        latitude: Current latitude value (float or None).
        longitude: Current longitude value (float or None).
        null: Defaults to True, allowing models without location data.

    Example:
        class City(Model):
            name = KeyField()
            coordinates = GeoField()

        rome = City.create(
            name="Rome",
            coordinates=GeoField.Coordinates(latitude=41.9028, longitude=12.4964)
        )

        # Query by coordinates and radius
        nearby_cities = City.query.filter(
            coordinates=rome.coordinates,
            coordinates_radius=100,
            coordinates_radius_unit='km'
        )
    """

    Coordinates = namedtuple("Coordinates", "latitude longitude")

    type: type = tuple
    latitude: float = None
    longitude: float = None
    default: tuple = Coordinates(None, None)
    null: bool = True

    def __init__(self, **kwargs):
        """
        Initialize a GeoField with optional configuration overrides.

        GeoField defaults to nullable (null=True) since many models may have
        optional location data. The default value is Coordinates(None, None)
        rather than None itself, providing a consistent type for coordinate
        access even when no location is set.

        Args:
            **kwargs: Field configuration options. Common options include:
                - null (bool): Whether the field accepts None. Defaults to True.
                - default: Default value if none provided. Defaults to
                  Coordinates(None, None).

        Note:
            Unlike other fields, GeoField's type is always tuple and cannot
            be overridden, as geospatial operations require coordinate pairs.
        """
        super().__init__(**kwargs)
        geofield_defaults = {
            "type": tuple,
            "latitude": None,
            "longitude": None,
            "null": True,
            "default": GeoField.Coordinates(None, None),
        }
        self.field_defaults.update(geofield_defaults)
        # set field options, let kwargs override
        for k, v in geofield_defaults.items():
            setattr(self, k, kwargs.get(k, v))

    def get_filter_query_params(self, field_name) -> set:
        """
        Return the set of valid query parameter names for filtering on this field.

        GeoField extends the base query parameters with geospatial-specific
        options that enable radius-based searches. The parameter naming follows
        a consistent pattern using the field name as prefix.

        Args:
            field_name: The name of this field on the model (e.g., 'location').

        Returns:
            A set of valid parameter names for Query.filter(). For a field
            named 'location', this includes:
            - location: Coordinates tuple or (lat, lng) tuple
            - location__isnull: Boolean to filter by presence of coordinates
            - location_latitude: Latitude for radius search center
            - location_longitude: Longitude for radius search center
            - location_radius: Search radius (default: 1)
            - location_radius_unit: Unit of measure ('m', 'km', 'ft', 'mi')
        """
        return (
            super()
            .get_filter_query_params(field_name)
            .union(
                {
                    f"{field_name}",
                    f"{field_name}__isnull",
                    f"{field_name}_latitude",
                    f"{field_name}_longitude",
                    f"{field_name}_radius",
                    f"{field_name}_radius_unit",
                    f"{field_name}_member",
                    f"{field_name}_with_distances",
                }
            )
        )

    @classmethod
    def is_valid(cls, field, value, null_check=True, **kwargs) -> bool:
        """
        Validate that a value is acceptable for this GeoField.

        Enforces the "all-or-nothing" coordinate rule: either both latitude
        and longitude must be provided, or both must be None. This prevents
        partial coordinate data that would be meaningless for geospatial
        operations.

        Args:
            field: The GeoField instance being validated against.
            value: The value to validate. May be a Coordinates namedtuple,
                a plain (lat, lng) tuple, or None.
            null_check: If True, requires non-null values when field.null
                is False. Defaults to True.
            **kwargs: Additional arguments (unused, for API compatibility).

        Returns:
            True if the value is valid for this field, False otherwise.
            Logs specific error messages when validation fails.

        Validation Rules:
            1. None is valid if field.null is True
            2. Must be a tuple or Coordinates namedtuple
            3. Both lat and lng must be present, or both must be absent
            4. Both values must be convertible to float
        """
        if not super().is_valid(field, value, null_check):
            return False
        if value is None:
            return True
        if isinstance(value, GeoField.Coordinates):
            pass
        elif isinstance(value, tuple):
            value = GeoField.Coordinates(value[0], value[1])
        else:
            logger.error(
                f"GeoField MUST be type GeoField.Coordinates or tuple, NOT {type(value)}"
            )
            return False
        if field.null and not any([value.latitude, value.longitude]):
            return True
        elif bool(value.latitude) != bool(value.longitude):
            logger.error(
                f"latitude is {value.latitude} and longitude is {value.longitude}"
            )
            logger.error("BOTH latitude AND longitude MUST have a value or be None")
            return False
        elif null_check and not all([value.latitude, value.longitude]):
            logger.error(
                f"latitude is {value.latitude} and longitude is {value.longitude}"
            )
            logger.error("BOTH latitude AND longitude MUST have a value")
            return False
        try:
            float(value.latitude), float(value.longitude)
        except ValueError as e:
            logger.error(e)
            return False
        except TypeError as e:
            logger.error(e)
            return False
        return True

    def format_value_pre_save(self, field_value, **kwargs):
        """
        Normalize coordinate values to the Coordinates namedtuple before saving.

        This method ensures consistent storage format regardless of how
        coordinates were provided (plain tuple vs Coordinates namedtuple).
        This normalization simplifies downstream code that can always expect
        the Coordinates type with named .latitude and .longitude attributes.

        Args:
            field_value: The value being saved. May be a Coordinates namedtuple,
                a plain (lat, lng) tuple, or None/invalid.
            **kwargs: Additional keyword arguments for forward compatibility
                (e.g., skip_auto_now used by other field types).

        Returns:
            A GeoField.Coordinates namedtuple. Returns Coordinates(None, None)
            for nullable fields with missing data.

        Note:
            Called automatically during model.save(). Assumes validation has
            already passed via is_valid().
        """
        if isinstance(field_value, GeoField.Coordinates):
            return field_value
        if isinstance(field_value, tuple):
            return GeoField.Coordinates(field_value[0], field_value[1])
        if self.null:
            return GeoField.Coordinates(None, None)
        return field_value

    @classmethod
    def get_geo_db_key(cls, model, field_name: str) -> DB_key:
        """
        Generate the Redis key for this field's geospatial index.

        Each GeoField maintains a separate Redis sorted set (used internally
        by Redis's GEO commands) that maps model instance keys to their
        geohash-encoded coordinates. This method returns the key for that
        sorted set.

        Args:
            model: The Model class or instance this field belongs to.
            field_name: The name of the GeoField on the model.

        Returns:
            A DB_key pointing to the Redis sorted set storing the geo index.
            Format: "$GeoF:{ModelClassName}:{field_name}"

        Example:
            For a model `Restaurant` with field `location`, returns a key like:
            "$GeoF:Restaurant:location"
        """
        return cls.get_special_use_field_db_key(model, field_name)

    @classmethod
    def on_save(
        cls,
        model_instance: "Model",
        field_name: str,
        field_value: "GeoField.Coordinates",
        pipeline=None,
        **kwargs,
    ):
        """
        Update the geospatial index when a model instance is saved.

        This hook maintains the Redis geo index in sync with model data.
        When coordinates are present, adds/updates the instance's position
        in the geo sorted set. When coordinates are null, removes the
        instance from the index to prevent stale location data in queries.

        Args:
            model_instance: The Model instance being saved.
            field_name: The name of the GeoField being processed.
            field_value: The Coordinates being saved (may be null).
            pipeline: Optional Redis pipeline for batching commands. When
                provided, commands are queued rather than executed immediately.
            **kwargs: Additional arguments (unused, for API compatibility).

        Returns:
            The pipeline (if provided) or the Redis command result.

        Note:
            Redis GEOADD expects (longitude, latitude) order, but Popoto's
            Coordinates uses (latitude, longitude). This method handles the
            conversion internally.
        """
        geo_db_key = cls.get_geo_db_key(model_instance, field_name)
        geo_member = model_instance.db_key.redis_key
        if not field_value or not (field_value.longitude and field_value.latitude):
            if pipeline:
                return pipeline.zrem(geo_db_key.redis_key, geo_member)
            else:
                return POPOTO_REDIS_DB.zrem(geo_db_key.redis_key, geo_member)
        if pipeline:
            return pipeline.geoadd(
                name=geo_db_key.redis_key,
                values=[field_value.longitude, field_value.latitude, geo_member],
            )
        else:
            return POPOTO_REDIS_DB.geoadd(
                name=geo_db_key.redis_key,
                values=[field_value.longitude, field_value.latitude, geo_member],
            )

    @classmethod
    def on_delete(
        cls,
        model_instance: "Model",
        field_name: str,
        field_value,
        pipeline: redis.client.Pipeline = None,
        **kwargs,
    ):
        """
        Remove a model instance from the geospatial index when deleted.

        This hook ensures the geo index stays clean when models are deleted.
        Without this cleanup, deleted instances would remain in radius query
        results, returning keys that no longer exist in Redis.

        Args:
            model_instance: The Model instance being deleted.
            field_name: The name of the GeoField being processed.
            field_value: The current coordinates (unused, removal is unconditional).
            pipeline: Optional Redis pipeline for batching commands.
            **kwargs: Additional arguments (unused, for API compatibility).

        Returns:
            The pipeline (if provided) or the Redis command result.

        Note:
            Always removes the instance from the geo index, regardless of
            whether coordinates were set. This is a safe no-op if the
            instance wasn't in the index.
        """
        geo_db_key = cls.get_geo_db_key(model_instance, field_name)
        # Use saved_redis_key if provided, otherwise fall back to current db_key
        geo_member = kwargs.get("saved_redis_key", model_instance.db_key.redis_key)
        if pipeline:
            return pipeline.zrem(geo_db_key.redis_key, geo_member)
        else:
            return POPOTO_REDIS_DB.zrem(geo_db_key.redis_key, geo_member)

    @classmethod
    def filter_query(cls, model: "Model", field_name: str, **query_params):
        """
        Execute a geospatial radius query and return matching model keys.

        This is the core query method that translates Popoto's Django-like
        filter syntax into Redis GEORADIUS or GEORADIUSBYMEMBER commands.
        It supports two query modes:

        1. Coordinate-based: Search around a specific (lat, lng) point
        2. Member-based: Search around an existing model instance's location

        Args:
            model: The Model class to query.
            field_name: The name of the GeoField to filter on.
            **query_params: Filter parameters including:
                - {field_name}: Coordinates tuple for search center
                - {field_name}_latitude: Latitude of search center
                - {field_name}_longitude: Longitude of search center
                - {field_name}_member: Model instance to search around
                - {field_name}_radius: Search radius (default: 1)
                - {field_name}_radius_unit: 'm', 'km', 'ft', or 'mi' (default: 'm')

        Returns:
            set{db_key, db_key, ..} or tuple(set, distances_dict, unit) if with_distances=True.
            A set of Redis keys (bytes) for model instances within the
            specified radius. These keys can be used to fetch full model
            objects via Query.get_many_objects().

        Raises:
            QueryException: If required coordinate parameters are missing,
                if radius_unit is invalid, or if query_params are malformed.

        Example:
            # Find restaurants within 5km of Times Square
            keys = GeoField.filter_query(
                Restaurant,
                'location',
                location=(40.7580, -73.9855),
                location_radius=5,
                location_radius_unit='km'
            )
        """
        field = model._meta.fields[field_name]
        geo_db_key = cls.get_geo_db_key(model, field_name)
        coordinates = GeoField.Coordinates(None, None)
        member, radius, unit = None, 1, "m"
        with_distances = False
        for query_param, query_value in query_params.items():
            if query_param == f"{field_name}":
                if isinstance(query_value, GeoField.Coordinates):
                    coordinates = query_value
                elif not isinstance(query_value, tuple) or not len(query_value) == 2:
                    from ..models.query import QueryException

                    raise QueryException(
                        f"{query_param} must be assigned a tuple = (latitude, longitude)"
                    )
                else:
                    coordinates = GeoField.Coordinates(query_value[0], query_value[1])

            elif query_param.endswith("_latitude"):
                coordinates = GeoField.Coordinates(query_value, coordinates.longitude)
            elif query_param.endswith("_longitude"):
                coordinates = GeoField.Coordinates(coordinates.latitude, query_value)

            elif "_member" in query_param:
                if not isinstance(query_value, model):
                    from ..models.query import QueryException

                    raise QueryException(
                        f"{query_param} must be assigned a tuple = (latitude, longitude)"
                    )
                member = query_value

            elif query_param.endswith("_radius"):
                radius = query_value

            elif query_param.endswith("_radius_unit"):
                if query_value not in ["m", "km", "ft", "mi"]:
                    from ..models.query import QueryException

                    raise QueryException(f"{query_param} must be one of m|km|ft|mi ")
                unit = query_value

            elif query_param.endswith("_with_distances"):
                with_distances = bool(query_value)

        if member:
            redis_db_keys_list = POPOTO_REDIS_DB.georadiusbymember(
                geo_db_key.redis_key,
                member=member.db_key.redis_key,
                radius=radius,
                unit=unit,
                withdist=with_distances,
                sort="ASC" if with_distances else None,
            )

        elif coordinates.latitude is not None and coordinates.longitude is not None:
            redis_db_keys_list = POPOTO_REDIS_DB.georadius(
                geo_db_key.redis_key,
                longitude=coordinates.longitude,
                latitude=coordinates.latitude,
                radius=radius,
                unit=unit,
                withdist=with_distances,
                sort="ASC" if with_distances else None,
            )
        else:
            from ..models.query import QueryException

            raise QueryException(
                "missing one or more required parameters. "
                "geofilter requires either coordinates or instance of the same model"
            )

        if with_distances:
            # Result is [(key, distance), ...] when withdist=True
            distances = {item[0]: item[1] for item in redis_db_keys_list}
            return set(distances.keys()), distances, unit
        else:
            return set(redis_db_keys_list)

get_filter_query_params(field_name)

Return the set of valid query parameter names for filtering on this field.

GeoField extends the base query parameters with geospatial-specific options that enable radius-based searches. The parameter naming follows a consistent pattern using the field name as prefix.

Parameters:

Name Type Description Default
field_name

The name of this field on the model (e.g., 'location').

required

Returns:

Type Description
set

A set of valid parameter names for Query.filter(). For a field

set

named 'location', this includes:

set
  • location: Coordinates tuple or (lat, lng) tuple
set
  • location__isnull: Boolean to filter by presence of coordinates
set
  • location_latitude: Latitude for radius search center
set
  • location_longitude: Longitude for radius search center
set
  • location_radius: Search radius (default: 1)
set
  • location_radius_unit: Unit of measure ('m', 'km', 'ft', 'mi')
Source code in src/popoto/fields/geo_field.py
def get_filter_query_params(self, field_name) -> set:
    """
    Return the set of valid query parameter names for filtering on this field.

    GeoField extends the base query parameters with geospatial-specific
    options that enable radius-based searches. The parameter naming follows
    a consistent pattern using the field name as prefix.

    Args:
        field_name: The name of this field on the model (e.g., 'location').

    Returns:
        A set of valid parameter names for Query.filter(). For a field
        named 'location', this includes:
        - location: Coordinates tuple or (lat, lng) tuple
        - location__isnull: Boolean to filter by presence of coordinates
        - location_latitude: Latitude for radius search center
        - location_longitude: Longitude for radius search center
        - location_radius: Search radius (default: 1)
        - location_radius_unit: Unit of measure ('m', 'km', 'ft', 'mi')
    """
    return (
        super()
        .get_filter_query_params(field_name)
        .union(
            {
                f"{field_name}",
                f"{field_name}__isnull",
                f"{field_name}_latitude",
                f"{field_name}_longitude",
                f"{field_name}_radius",
                f"{field_name}_radius_unit",
                f"{field_name}_member",
                f"{field_name}_with_distances",
            }
        )
    )

is_valid(field, value, null_check=True, **kwargs) classmethod

Validate that a value is acceptable for this GeoField.

Enforces the "all-or-nothing" coordinate rule: either both latitude and longitude must be provided, or both must be None. This prevents partial coordinate data that would be meaningless for geospatial operations.

Parameters:

Name Type Description Default
field

The GeoField instance being validated against.

required
value

The value to validate. May be a Coordinates namedtuple, a plain (lat, lng) tuple, or None.

required
null_check

If True, requires non-null values when field.null is False. Defaults to True.

True
**kwargs

Additional arguments (unused, for API compatibility).

{}

Returns:

Type Description
bool

True if the value is valid for this field, False otherwise.

bool

Logs specific error messages when validation fails.

Validation Rules
  1. None is valid if field.null is True
  2. Must be a tuple or Coordinates namedtuple
  3. Both lat and lng must be present, or both must be absent
  4. Both values must be convertible to float
Source code in src/popoto/fields/geo_field.py
@classmethod
def is_valid(cls, field, value, null_check=True, **kwargs) -> bool:
    """
    Validate that a value is acceptable for this GeoField.

    Enforces the "all-or-nothing" coordinate rule: either both latitude
    and longitude must be provided, or both must be None. This prevents
    partial coordinate data that would be meaningless for geospatial
    operations.

    Args:
        field: The GeoField instance being validated against.
        value: The value to validate. May be a Coordinates namedtuple,
            a plain (lat, lng) tuple, or None.
        null_check: If True, requires non-null values when field.null
            is False. Defaults to True.
        **kwargs: Additional arguments (unused, for API compatibility).

    Returns:
        True if the value is valid for this field, False otherwise.
        Logs specific error messages when validation fails.

    Validation Rules:
        1. None is valid if field.null is True
        2. Must be a tuple or Coordinates namedtuple
        3. Both lat and lng must be present, or both must be absent
        4. Both values must be convertible to float
    """
    if not super().is_valid(field, value, null_check):
        return False
    if value is None:
        return True
    if isinstance(value, GeoField.Coordinates):
        pass
    elif isinstance(value, tuple):
        value = GeoField.Coordinates(value[0], value[1])
    else:
        logger.error(
            f"GeoField MUST be type GeoField.Coordinates or tuple, NOT {type(value)}"
        )
        return False
    if field.null and not any([value.latitude, value.longitude]):
        return True
    elif bool(value.latitude) != bool(value.longitude):
        logger.error(
            f"latitude is {value.latitude} and longitude is {value.longitude}"
        )
        logger.error("BOTH latitude AND longitude MUST have a value or be None")
        return False
    elif null_check and not all([value.latitude, value.longitude]):
        logger.error(
            f"latitude is {value.latitude} and longitude is {value.longitude}"
        )
        logger.error("BOTH latitude AND longitude MUST have a value")
        return False
    try:
        float(value.latitude), float(value.longitude)
    except ValueError as e:
        logger.error(e)
        return False
    except TypeError as e:
        logger.error(e)
        return False
    return True

format_value_pre_save(field_value, **kwargs)

Normalize coordinate values to the Coordinates namedtuple before saving.

This method ensures consistent storage format regardless of how coordinates were provided (plain tuple vs Coordinates namedtuple). This normalization simplifies downstream code that can always expect the Coordinates type with named .latitude and .longitude attributes.

Parameters:

Name Type Description Default
field_value

The value being saved. May be a Coordinates namedtuple, a plain (lat, lng) tuple, or None/invalid.

required
**kwargs

Additional keyword arguments for forward compatibility (e.g., skip_auto_now used by other field types).

{}

Returns:

Type Description

A GeoField.Coordinates namedtuple. Returns Coordinates(None, None)

for nullable fields with missing data.

Note

Called automatically during model.save(). Assumes validation has already passed via is_valid().

Source code in src/popoto/fields/geo_field.py
def format_value_pre_save(self, field_value, **kwargs):
    """
    Normalize coordinate values to the Coordinates namedtuple before saving.

    This method ensures consistent storage format regardless of how
    coordinates were provided (plain tuple vs Coordinates namedtuple).
    This normalization simplifies downstream code that can always expect
    the Coordinates type with named .latitude and .longitude attributes.

    Args:
        field_value: The value being saved. May be a Coordinates namedtuple,
            a plain (lat, lng) tuple, or None/invalid.
        **kwargs: Additional keyword arguments for forward compatibility
            (e.g., skip_auto_now used by other field types).

    Returns:
        A GeoField.Coordinates namedtuple. Returns Coordinates(None, None)
        for nullable fields with missing data.

    Note:
        Called automatically during model.save(). Assumes validation has
        already passed via is_valid().
    """
    if isinstance(field_value, GeoField.Coordinates):
        return field_value
    if isinstance(field_value, tuple):
        return GeoField.Coordinates(field_value[0], field_value[1])
    if self.null:
        return GeoField.Coordinates(None, None)
    return field_value

get_geo_db_key(model, field_name) classmethod

Generate the Redis key for this field's geospatial index.

Each GeoField maintains a separate Redis sorted set (used internally by Redis's GEO commands) that maps model instance keys to their geohash-encoded coordinates. This method returns the key for that sorted set.

Parameters:

Name Type Description Default
model

The Model class or instance this field belongs to.

required
field_name str

The name of the GeoField on the model.

required

Returns:

Name Type Description
DB_key

A DB_key pointing to the Redis sorted set storing the geo index.

Format DB_key

"$GeoF:{ModelClassName}:{field_name}"

Example

For a model Restaurant with field location, returns a key like: "$GeoF:Restaurant:location"

Source code in src/popoto/fields/geo_field.py
@classmethod
def get_geo_db_key(cls, model, field_name: str) -> DB_key:
    """
    Generate the Redis key for this field's geospatial index.

    Each GeoField maintains a separate Redis sorted set (used internally
    by Redis's GEO commands) that maps model instance keys to their
    geohash-encoded coordinates. This method returns the key for that
    sorted set.

    Args:
        model: The Model class or instance this field belongs to.
        field_name: The name of the GeoField on the model.

    Returns:
        A DB_key pointing to the Redis sorted set storing the geo index.
        Format: "$GeoF:{ModelClassName}:{field_name}"

    Example:
        For a model `Restaurant` with field `location`, returns a key like:
        "$GeoF:Restaurant:location"
    """
    return cls.get_special_use_field_db_key(model, field_name)

on_save(model_instance, field_name, field_value, pipeline=None, **kwargs) classmethod

Update the geospatial index when a model instance is saved.

This hook maintains the Redis geo index in sync with model data. When coordinates are present, adds/updates the instance's position in the geo sorted set. When coordinates are null, removes the instance from the index to prevent stale location data in queries.

Parameters:

Name Type Description Default
model_instance Model

The Model instance being saved.

required
field_name str

The name of the GeoField being processed.

required
field_value Coordinates

The Coordinates being saved (may be null).

required
pipeline

Optional Redis pipeline for batching commands. When provided, commands are queued rather than executed immediately.

None
**kwargs

Additional arguments (unused, for API compatibility).

{}

Returns:

Type Description

The pipeline (if provided) or the Redis command result.

Note

Redis GEOADD expects (longitude, latitude) order, but Popoto's Coordinates uses (latitude, longitude). This method handles the conversion internally.

Source code in src/popoto/fields/geo_field.py
@classmethod
def on_save(
    cls,
    model_instance: "Model",
    field_name: str,
    field_value: "GeoField.Coordinates",
    pipeline=None,
    **kwargs,
):
    """
    Update the geospatial index when a model instance is saved.

    This hook maintains the Redis geo index in sync with model data.
    When coordinates are present, adds/updates the instance's position
    in the geo sorted set. When coordinates are null, removes the
    instance from the index to prevent stale location data in queries.

    Args:
        model_instance: The Model instance being saved.
        field_name: The name of the GeoField being processed.
        field_value: The Coordinates being saved (may be null).
        pipeline: Optional Redis pipeline for batching commands. When
            provided, commands are queued rather than executed immediately.
        **kwargs: Additional arguments (unused, for API compatibility).

    Returns:
        The pipeline (if provided) or the Redis command result.

    Note:
        Redis GEOADD expects (longitude, latitude) order, but Popoto's
        Coordinates uses (latitude, longitude). This method handles the
        conversion internally.
    """
    geo_db_key = cls.get_geo_db_key(model_instance, field_name)
    geo_member = model_instance.db_key.redis_key
    if not field_value or not (field_value.longitude and field_value.latitude):
        if pipeline:
            return pipeline.zrem(geo_db_key.redis_key, geo_member)
        else:
            return POPOTO_REDIS_DB.zrem(geo_db_key.redis_key, geo_member)
    if pipeline:
        return pipeline.geoadd(
            name=geo_db_key.redis_key,
            values=[field_value.longitude, field_value.latitude, geo_member],
        )
    else:
        return POPOTO_REDIS_DB.geoadd(
            name=geo_db_key.redis_key,
            values=[field_value.longitude, field_value.latitude, geo_member],
        )

on_delete(model_instance, field_name, field_value, pipeline=None, **kwargs) classmethod

Remove a model instance from the geospatial index when deleted.

This hook ensures the geo index stays clean when models are deleted. Without this cleanup, deleted instances would remain in radius query results, returning keys that no longer exist in Redis.

Parameters:

Name Type Description Default
model_instance Model

The Model instance being deleted.

required
field_name str

The name of the GeoField being processed.

required
field_value

The current coordinates (unused, removal is unconditional).

required
pipeline Pipeline

Optional Redis pipeline for batching commands.

None
**kwargs

Additional arguments (unused, for API compatibility).

{}

Returns:

Type Description

The pipeline (if provided) or the Redis command result.

Note

Always removes the instance from the geo index, regardless of whether coordinates were set. This is a safe no-op if the instance wasn't in the index.

Source code in src/popoto/fields/geo_field.py
@classmethod
def on_delete(
    cls,
    model_instance: "Model",
    field_name: str,
    field_value,
    pipeline: redis.client.Pipeline = None,
    **kwargs,
):
    """
    Remove a model instance from the geospatial index when deleted.

    This hook ensures the geo index stays clean when models are deleted.
    Without this cleanup, deleted instances would remain in radius query
    results, returning keys that no longer exist in Redis.

    Args:
        model_instance: The Model instance being deleted.
        field_name: The name of the GeoField being processed.
        field_value: The current coordinates (unused, removal is unconditional).
        pipeline: Optional Redis pipeline for batching commands.
        **kwargs: Additional arguments (unused, for API compatibility).

    Returns:
        The pipeline (if provided) or the Redis command result.

    Note:
        Always removes the instance from the geo index, regardless of
        whether coordinates were set. This is a safe no-op if the
        instance wasn't in the index.
    """
    geo_db_key = cls.get_geo_db_key(model_instance, field_name)
    # Use saved_redis_key if provided, otherwise fall back to current db_key
    geo_member = kwargs.get("saved_redis_key", model_instance.db_key.redis_key)
    if pipeline:
        return pipeline.zrem(geo_db_key.redis_key, geo_member)
    else:
        return POPOTO_REDIS_DB.zrem(geo_db_key.redis_key, geo_member)

filter_query(model, field_name, **query_params) classmethod

Execute a geospatial radius query and return matching model keys.

This is the core query method that translates Popoto's Django-like filter syntax into Redis GEORADIUS or GEORADIUSBYMEMBER commands. It supports two query modes:

  1. Coordinate-based: Search around a specific (lat, lng) point
  2. Member-based: Search around an existing model instance's location

Parameters:

Name Type Description Default
model Model

The Model class to query.

required
field_name str

The name of the GeoField to filter on.

required
**query_params

Filter parameters including: - {field_name}: Coordinates tuple for search center - {field_name}_latitude: Latitude of search center - {field_name}_longitude: Longitude of search center - {field_name}_member: Model instance to search around - {field_name}_radius: Search radius (default: 1) - {field_name}_radius_unit: 'm', 'km', 'ft', or 'mi' (default: 'm')

{}

Returns:

Type Description

set{db_key, db_key, ..} or tuple(set, distances_dict, unit) if with_distances=True.

A set of Redis keys (bytes) for model instances within the

specified radius. These keys can be used to fetch full model

objects via Query.get_many_objects().

Raises:

Type Description
QueryException

If required coordinate parameters are missing, if radius_unit is invalid, or if query_params are malformed.

Example
Find restaurants within 5km of Times Square

keys = GeoField.filter_query( Restaurant, 'location', location=(40.7580, -73.9855), location_radius=5, location_radius_unit='km' )

Source code in src/popoto/fields/geo_field.py
@classmethod
def filter_query(cls, model: "Model", field_name: str, **query_params):
    """
    Execute a geospatial radius query and return matching model keys.

    This is the core query method that translates Popoto's Django-like
    filter syntax into Redis GEORADIUS or GEORADIUSBYMEMBER commands.
    It supports two query modes:

    1. Coordinate-based: Search around a specific (lat, lng) point
    2. Member-based: Search around an existing model instance's location

    Args:
        model: The Model class to query.
        field_name: The name of the GeoField to filter on.
        **query_params: Filter parameters including:
            - {field_name}: Coordinates tuple for search center
            - {field_name}_latitude: Latitude of search center
            - {field_name}_longitude: Longitude of search center
            - {field_name}_member: Model instance to search around
            - {field_name}_radius: Search radius (default: 1)
            - {field_name}_radius_unit: 'm', 'km', 'ft', or 'mi' (default: 'm')

    Returns:
        set{db_key, db_key, ..} or tuple(set, distances_dict, unit) if with_distances=True.
        A set of Redis keys (bytes) for model instances within the
        specified radius. These keys can be used to fetch full model
        objects via Query.get_many_objects().

    Raises:
        QueryException: If required coordinate parameters are missing,
            if radius_unit is invalid, or if query_params are malformed.

    Example:
        # Find restaurants within 5km of Times Square
        keys = GeoField.filter_query(
            Restaurant,
            'location',
            location=(40.7580, -73.9855),
            location_radius=5,
            location_radius_unit='km'
        )
    """
    field = model._meta.fields[field_name]
    geo_db_key = cls.get_geo_db_key(model, field_name)
    coordinates = GeoField.Coordinates(None, None)
    member, radius, unit = None, 1, "m"
    with_distances = False
    for query_param, query_value in query_params.items():
        if query_param == f"{field_name}":
            if isinstance(query_value, GeoField.Coordinates):
                coordinates = query_value
            elif not isinstance(query_value, tuple) or not len(query_value) == 2:
                from ..models.query import QueryException

                raise QueryException(
                    f"{query_param} must be assigned a tuple = (latitude, longitude)"
                )
            else:
                coordinates = GeoField.Coordinates(query_value[0], query_value[1])

        elif query_param.endswith("_latitude"):
            coordinates = GeoField.Coordinates(query_value, coordinates.longitude)
        elif query_param.endswith("_longitude"):
            coordinates = GeoField.Coordinates(coordinates.latitude, query_value)

        elif "_member" in query_param:
            if not isinstance(query_value, model):
                from ..models.query import QueryException

                raise QueryException(
                    f"{query_param} must be assigned a tuple = (latitude, longitude)"
                )
            member = query_value

        elif query_param.endswith("_radius"):
            radius = query_value

        elif query_param.endswith("_radius_unit"):
            if query_value not in ["m", "km", "ft", "mi"]:
                from ..models.query import QueryException

                raise QueryException(f"{query_param} must be one of m|km|ft|mi ")
            unit = query_value

        elif query_param.endswith("_with_distances"):
            with_distances = bool(query_value)

    if member:
        redis_db_keys_list = POPOTO_REDIS_DB.georadiusbymember(
            geo_db_key.redis_key,
            member=member.db_key.redis_key,
            radius=radius,
            unit=unit,
            withdist=with_distances,
            sort="ASC" if with_distances else None,
        )

    elif coordinates.latitude is not None and coordinates.longitude is not None:
        redis_db_keys_list = POPOTO_REDIS_DB.georadius(
            geo_db_key.redis_key,
            longitude=coordinates.longitude,
            latitude=coordinates.latitude,
            radius=radius,
            unit=unit,
            withdist=with_distances,
            sort="ASC" if with_distances else None,
        )
    else:
        from ..models.query import QueryException

        raise QueryException(
            "missing one or more required parameters. "
            "geofilter requires either coordinates or instance of the same model"
        )

    if with_distances:
        # Result is [(key, distance), ...] when withdist=True
        distances = {item[0]: item[1] for item in redis_db_keys_list}
        return set(distances.keys()), distances, unit
    else:
        return set(redis_db_keys_list)