Skip to content

Commit

Permalink
Merge pull request #34695 from dimagi/cs/SC-3593-geo-max-distance-and…
Browse files Browse the repository at this point in the history
…-travel

Implement max distance and travel criteria for geospatial disbursement algorithm.
  • Loading branch information
Charl1996 authored Jun 19, 2024
2 parents d7ad559 + 503aaa0 commit 600c11b
Show file tree
Hide file tree
Showing 16 changed files with 433 additions and 378 deletions.
5 changes: 5 additions & 0 deletions corehq/apps/geospatial/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
# Max number of cases per geohash
MAX_GEOHASH_DOC_COUNT = 1_000

# Travel modes
TRAVEL_MODE_WALKING = "walking"
TRAVEL_MODE_CYCLING = "cycling"
TRAVEL_MODE_DRIVING = "driving"

# Modified version of https://geojson.org/schema/FeatureCollection.json
# Modification 1 - Added top-level name attribute
# Modification 2 - geometry is limited to a polygon
Expand Down
43 changes: 42 additions & 1 deletion corehq/apps/geospatial/forms.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from corehq.apps.hqwebapp import crispy as hqcrispy
from crispy_forms import layout as crispy
from crispy_forms.bootstrap import StrictButton
from django.forms.widgets import Select

from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from django import forms
from corehq.apps.geospatial.models import GeoConfig
from corehq.apps.geospatial.models import GeoConfig, validate_travel_mode
from corehq import toggles


Expand Down Expand Up @@ -37,6 +38,9 @@ class Meta:
"plaintext_api_token",
"min_cases_per_user",
"max_cases_per_user",
"max_case_distance",
"max_case_travel_time",
"travel_mode",
]

user_location_property_name = forms.CharField(
Expand Down Expand Up @@ -75,6 +79,25 @@ class Meta:
required=False,
min_value=1,
)
max_case_distance = forms.IntegerField(
label=_("Max distance (km) to case"),
help_text=_("The maximum distance (in kilometers) from the user to the case. Leave blank to skip."),
required=False,
min_value=1,
)
max_case_travel_time = forms.IntegerField(
label=_("Max travel time (minutes) to case"),
help_text=_("The maximum travel time (in minutes) from the user to the case. Leave blank to skip."),
required=False,
min_value=0,
)
travel_mode = forms.CharField(
label=_("Select travel mode"),
help_text=_("The travel mode of the users. "
"Consider this when specifying the max travel time to each case."),
widget=Select(choices=GeoConfig.VALID_TRAVEL_MODES),
validators=[validate_travel_mode]
)
selected_disbursement_algorithm = forms.ChoiceField(
label=_("Disbursement algorithm"),
# TODO: Uncomment once linked documentation becomes public (geospatial feature is GA'ed)
Expand Down Expand Up @@ -149,6 +172,24 @@ def __init__(self, *args, **kwargs):
'max_cases_per_user',
data_bind='value: maxCasesPerUser',
),
crispy.Field(
'max_case_distance',
data_bind='value: maxCaseDistance',
),
crispy.Div(
crispy.Field(
'travel_mode',
data_bind='value: travelMode',
),
data_bind='visible: captureApiToken',
),
crispy.Div(
crispy.Field(
'max_case_travel_time',
data_bind='value: maxTravelTime',
),
data_bind='visible: captureApiToken',
),
crispy.Div(
crispy.Field('plaintext_api_token', data_bind="value: plaintext_api_token"),
data_bind="visible: captureApiToken"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 4.2.11 on 2024-05-31 07:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('geospatial', '0006_geoconfig_max_cases_per_user_and_more'),
]

operations = [
migrations.AddField(
model_name='geoconfig',
name='max_case_distance',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='geoconfig',
name='max_case_travel_time',
field=models.IntegerField(null=True),
),
migrations.AddField(
model_name='geoconfig',
name='travel_mode',
field=models.CharField(choices=[('walking', 'Walking'), ('cycling', 'Cycling'), ('driving', 'Driving')], default='driving', max_length=50),
),
]
40 changes: 38 additions & 2 deletions corehq/apps/geospatial/models.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
from django.db import models
from django.utils.translation import gettext as _
from django.forms.models import model_to_dict

from corehq.apps.geospatial.const import GPS_POINT_CASE_PROPERTY, ALGO_AES
from django.core.exceptions import ValidationError

from corehq.apps.geospatial.const import (
GPS_POINT_CASE_PROPERTY,
ALGO_AES,
TRAVEL_MODE_WALKING,
TRAVEL_MODE_CYCLING,
TRAVEL_MODE_DRIVING,
)
from corehq.apps.geospatial.routing_solvers import pulp
from corehq.motech.utils import b64_aes_encrypt, b64_aes_decrypt

Expand All @@ -17,6 +24,19 @@ class GeoPolygon(models.Model):
domain = models.CharField(max_length=256, db_index=True)


def validate_travel_mode(value):
valid_modes = [
TRAVEL_MODE_WALKING,
TRAVEL_MODE_CYCLING,
TRAVEL_MODE_DRIVING
]
if value not in valid_modes:
raise ValidationError(
_("%(value)s is not a valid travel mode"),
params={"value": value},
)


class GeoConfig(models.Model):

CUSTOM_USER_PROPERTY = 'custom_user_property'
Expand All @@ -43,6 +63,11 @@ class GeoConfig(models.Model):
(MIN_MAX_GROUPING, _('Min/Max Grouping')),
(TARGET_SIZE_GROUPING, _('Target Size Grouping')),
]
VALID_TRAVEL_MODES = [
(TRAVEL_MODE_WALKING, _("Walking")),
(TRAVEL_MODE_CYCLING, _("Cycling")),
(TRAVEL_MODE_DRIVING, _("Driving")),
]

domain = models.CharField(max_length=256, db_index=True, primary_key=True)
location_data_source = models.CharField(max_length=126, default=CUSTOM_USER_PROPERTY)
Expand All @@ -60,6 +85,13 @@ class GeoConfig(models.Model):

max_cases_per_user = models.IntegerField(null=True)
min_cases_per_user = models.IntegerField(default=1)
max_case_distance = models.IntegerField(null=True) # km
max_case_travel_time = models.IntegerField(null=True) # minutes
travel_mode = models.CharField(
choices=VALID_TRAVEL_MODES,
default=TRAVEL_MODE_DRIVING,
max_length=50,
)
selected_disbursement_algorithm = models.CharField(
choices=VALID_DISBURSEMENT_ALGORITHMS,
default=RADIAL_ALGORITHM,
Expand All @@ -81,6 +113,10 @@ def _clear_caches(self):
get_geo_case_property.clear(self.domain)
get_geo_user_property.clear(self.domain)

@property
def supports_travel_mode(self):
return self.selected_disbursement_algorithm == self.ROAD_NETWORK_ALGORITHM

@property
def disbursement_solver(self):
return self.VALID_DISBURSEMENT_ALGORITHM_CLASSES[
Expand Down
136 changes: 0 additions & 136 deletions corehq/apps/geospatial/routing_solvers/mapbox_optimize.py

This file was deleted.

43 changes: 43 additions & 0 deletions corehq/apps/geospatial/routing_solvers/mapbox_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import jsonschema


def validate_routing_request(request_json):
schema = {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {
"$ref": "#/definitions/location"
}
},
"cases": {
"type": "array",
"items": {
"$ref": "#/definitions/location"
}
}
},
"definitions": {
"location": {
"type": "object",
"properties": {
"lon": {
"type": ["string", "number"],
"description": "longitude"
},
"lat": {
"type": ["string", "number"],
"description": "latitude"
},
"id": {
"type": "string",
"description": "id or pk"
}
},
"required": ["lon", "lat", "id"]
}
},
"required": ["users", "cases"]
}
jsonschema.validate(request_json, schema)
Loading

0 comments on commit 600c11b

Please sign in to comment.