Search code examples
pythondjangourl-patternurlconf

django - regex for optional url parameters


I have a view in django that can accept a number of different filter parameters, but they are all optional. If I have 6 optional filters, do I really have to write urls for every combination of the 6 or is there a way to define what parts of the url are optional?

To give you an example with just 2 filters, I could have all of these url possibilities:

/<city>/<state>/
/<city>/<state>/radius/<miles>/
/<city>/<state>/company/<company-name>/
/<city>/<state>/radius/<miles>/company/<company-name>/
/<city>/<state>/company/<company-name>/radius/<miles>/

All of these url's are pointing to the same view and the only required params are city and state. With 6 filters, this becomes unmanageable.

What's the best way to go about doing what I want to achieve?


Solution

  • One method would be to make the regular expression read all the given filters as a single string, and then split them up into individual values in the view.

    I came up with the following URL:

    (r'^(?P<city>[^/]+)/(?P<state>[^/]+)(?P<filters>(?:/[^/]+/[^/]+)*)/?$',
     'views.my_view'),
    

    Matching the required city and state is easy. The filters part is a bit more complicated. The inner part - (?:/[^/]+/[^/]+)* - matches filters given in the form /name/value. However, the * quantifier (like all Python regular expression quantifiers) only returns the last match found - so if the url was /radius/80/company/mycompany/ only company/mycompany would be stored. Instead, we tell it not to capture the individual values (the ?: at the start), and put it inside a capturing block which will store all filter values as a single string.

    The view logic is fairly straightforward. Note that the regular expression will only match pairs of filters - so /company/mycompany/radius/ will not be matched. This means we can safely assume we have pairs of values. The view I tested this with is as follows:

    def my_view(request, city, state, filters):
        # Split into a list ['name', 'value', 'name', 'value']. Note we remove the
        # first character of the string as it will be a slash.
        split = filters[1:].split('/')
    
        # Map into a dictionary {'name': 'value', 'name': 'value'}.
        filters = dict(zip(split[::2], split[1::2]))
    
        # Get the values you want - the second parameter is the default if none was
        # given in the URL. Note all entries in the dictionary are strings at this
        # point, so you will have to convert to the appropriate types if desired.
        radius = filters.get('radius', None)
        company = filters.get('company', None)
    
        # Then use the values as desired in your view.
        context = {
            'city': city,
            'state': state,
            'radius': radius,
            'company': company,
        }
        return render_to_response('my_view.html', context)
    

    Two things to note about this. First, it allows unknown filter entries into your view. For example, /fakefilter/somevalue is valid. The view code above ignores these, but you probably want to report an error to the user. If so, alter the code getting the values to

    radius = filters.pop('radius', None)
    company = filters.pop('company', None)
    

    Any entries remaining in the filters dictionary are unknown values about which you can complain.

    Second, if the user repeats a filter, the last value will be used. For example, /radius/80/radius/50 will set the radius to 50. If you want to detect this, you will need to scan the list of values before it is converted to a dictionary:

    given = set()
    for name in split[::2]:
        if name in given:
            # Repeated entry, complain to user or something.
        else:
            given.add(name)