Lucian Marin

Location validation on Subreply

Since 2014 when Subreply was launched, I always wanted for users to specify their location. After years of programming in Python I found a way to validate City, Country and Country formats in a simple and intuitive way.

The code

The first part of the problem is to have all data available so you can match the user input with the expected output. I do this by fetching world cities from Simplemaps as a hashmap or Python dictionary. There are some corrections applied to city and country names. I wanted a Django management command so I can run it every time Simplemaps update their public dataset.

import csv
import io
import json
import zipfile
from collections import defaultdict

import requests
from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help = "Fetch world cities."
    url = "https://simplemaps.com/static/data/world-cities/basic/simplemaps_worldcities_basicv1.73.zip"

    def get_csv(self):
        r = requests.get(self.url, stream=True)
        c = io.BytesIO(r.content)
        with zipfile.ZipFile(c) as zip:
            with zip.open('worldcities.csv') as file:
                return io.StringIO(file.read().decode())

    def fix_country(self, value):
        replaces = [
            (", The", ""),
            ("Korea, South", "South Korea"),
            ("Korea, North", "North Korea"),
            ("Curaçao", "Curacao"),
            ("Czechia", "Czech Republic"),
            ("Côte D’Ivoire", "Cote d'Ivoire"),
            ("Congo (Brazzaville)", "Congo-Brazzaville"),
            ("Congo (Kinshasa)", "Congo-Kinshasa"),
            ("Micronesia, Federated States Of", "Micronesia"),
            ("Falkland Islands (Islas Malvinas)", "Falkland Islands"),
            (", And Tristan Da Cunha", " and Tristan da Cunha"),
            (" And ", " and "),
            (" The ", " the "),
            (" Of ", " of ")
        ]
        for before, after in replaces:
            value = value.replace(before, after)
        return value

    def fix_city(self, value):
        replaces = [
            ("Beaubassin East / Beaubassin-est", "Beaubassin East"),
            ("Islamorada, Village of Islands", "Islamorada"),
            ("Dolores Hidalgo Cuna de la Independencia Nacional", "Dolores Hidalgo"),
            ("`", "'")
        ]
        for before, after in replaces:
            value = value.replace(before, after)
        return value.split(" / ")[0]

    def handle(self, *args, **options):
        world = defaultdict(set)
        reader = csv.DictReader(self.get_csv())
        for row in reader:
            country = self.fix_country(row['country'])
            city = self.fix_city(row['city_ascii'])
            name = f"{city}, {country}"
            if name.count(", ") == 1:
                world[country].add(city)
        for country, cities in world.items():
            world[country] = sorted(cities)
        with open('static/worldcities.json', 'w') as file:
            json.dump(world, file, sort_keys=True, indent=4)

The validation

Loading JSON in memory as a Python dictionary is straight forward. Validating user input against an country: [cities] hashmap is simple as well. First you validated the country and then you validated the city is part of that country.

with open('data/worldcities.json') as file:
    WORLD_CITIES = json.load(file)

def valid_location(value, delimiter=", "):
    if value:
        if value.count(delimiter) > 1:
            return "City, Country or just Country"
        elif value.count(delimiter):
            city, country = value.split(delimiter)
            if country not in WORLD_CITIES:
                return "Country is invalid"
            elif city not in WORLD_CITIES[country]:
                return "City is invalid"
        elif value not in WORLD_CITIES:
            return "Country is invalid"

Even if you aren’t a user on Subreply, you can try it out on the register page by filling only the location field. Enjoy sharing your city where you work from!