diff options
-rw-r--r-- | fietsboek/static/fietsboek.js | 15 | ||||
-rw-r--r-- | fietsboek/templates/browse.jinja2 | 79 | ||||
-rw-r--r-- | fietsboek/views/browse.py | 93 |
3 files changed, 186 insertions, 1 deletions
diff --git a/fietsboek/static/fietsboek.js b/fietsboek/static/fietsboek.js index 265032e..bf402a8 100644 --- a/fietsboek/static/fietsboek.js +++ b/fietsboek/static/fietsboek.js @@ -290,6 +290,21 @@ addHandler(".archive-checkbox", "change", () => { document.querySelector("#archiveDownloadButton").disabled = (checked.length == 0); }); +/** + * Handler to clear the input when a .button-clear-input is pressed. + * + * The button must be in an input-group with the input. + * + * @param event - The triggering event. + */ +function clearInputButtonClicked(event) { + const input = event.target.closest(".input-group").querySelector("input"); + input.value = ""; +} + +addHandler(".button-clear-input", "click", clearInputButtonClicked); + + document.addEventListener('DOMContentLoaded', function() { window.fietsboekImageIndex = 0; diff --git a/fietsboek/templates/browse.jinja2 b/fietsboek/templates/browse.jinja2 index 2732984..b152b1e 100644 --- a/fietsboek/templates/browse.jinja2 +++ b/fietsboek/templates/browse.jinja2 @@ -2,6 +2,85 @@ {% block content %} <div class="container"> <h1>{{ _("page.browse.title") }}</h1> + <div class="mb-3"> + <form id="browseFilter"> + <div class="row g-3 mb-3"> + <div class="col-12"> + <div class="input-group"> + <button type="button" class="btn btn-outline-secondary button-clear-input"><i class="bi bi-eraser-fill"></i></button> + <input name="search-terms" type="text" class="form-control" placeholder="{{ _("page.browse.filter.search_terms") }}" value="{{ request.params.get('search-terms', '') }}"> + </div> + </div> + </div> + + <div class="collapse row g-3 mb-3" id="advancedSearch"> + <div class="col-md-6"> + <div class="input-group"> + <button type="button" class="btn btn-outline-secondary button-clear-input"><i class="bi bi-eraser-fill"></i></button> + <input name="tags" type="text" class="form-control" placeholder="{{ _("page.browse.filter.tags") }}" value="{{ request.params.get('tags', '') }}"> + </div> + </div> + + <div class="col-md-6"> + <div class="input-group"> + <button type="button" class="btn btn-outline-secondary button-clear-input"><i class="bi bi-eraser-fill"></i></button> + <input name="tagged-person" type="text" class="form-control" placeholder="{{ _("page.browse.filter.tagged_person") }}" value="{{ request.params.get('tagged-person', '') }}"> + </div> + </div> + + <div class="col-md-4"> + <div class="input-group"> + <button type="button" class="btn btn-outline-secondary button-clear-input"><i class="bi bi-eraser-fill"></i></button> + <input name="min-length" type="number" class="form-control" placeholder="{{ _("page.browse.filter.length_minimum") }}" value="{{ request.params.get('min-length', '') }}"> + <span class="input-group-text">km</span> + </div> + </div> + + <div class="col-md-4 fs-5 d-flex align-items-center justify-content-center"> + < {{ _("page.browse.filter.length") }} < + </div> + + <div class="col-md-4"> + <div class="input-group"> + <button type="button" class="btn btn-outline-secondary button-clear-input"><i class="bi bi-eraser-fill"></i></button> + <input name="max-length" type="number" class="form-control" placeholder="{{ _("page.browse.filter.length_maximum") }}" value="{{ request.params.get('max-length', '') }}"> + <span class="input-group-text">km</span> + </div> + </div> + + <div class="col-md-4"> + <div class="input-group"> + <button type="button" class="btn btn-outline-secondary button-clear-input"><i class="bi bi-eraser-fill"></i></button> + <input name="min-date" type="date" class="form-control" value="{{ request.params.get('min-date', '') }}"> + </div> + </div> + + <div class="col-md-4 fs-5 d-flex align-items-center justify-content-center"> + < {{ _("page.browse.filter.date") }} < + </div> + + <div class="col-md-4"> + <div class="input-group"> + <button type="button" class="btn btn-outline-secondary button-clear-input"><i class="bi bi-eraser-fill"></i></button> + <input name="max-date" type="date" class="form-control" value="{{ request.params.get('max-date', '') }}"> + </div> + </div> + </div> + + <div class="row g-3 mb-3"> + <div class="col-md-6"> + <button type="submit" class="btn btn-primary"> + {{ _("page.browse.filters.apply") }} + </button> + </div> + <div class="col-md-6"> + <button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#advancedSearch" aria-expanded="false" aria-controls="advancedSearch"> + {{ _("page.browse.filters.expand_advanced") }} + </button> + </div> + </div> + </form> + </div> {% if tracks %} {% for track in tracks %} <div class="card mb-3"> diff --git a/fietsboek/views/browse.py b/fietsboek/views/browse.py index 17dd45a..83a58b3 100644 --- a/fietsboek/views/browse.py +++ b/fietsboek/views/browse.py @@ -1,9 +1,10 @@ """Views for browsing all tracks.""" +import datetime from io import RawIOBase from zipfile import ZipFile, ZIP_DEFLATED from pyramid.view import view_config -from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound +from pyramid.httpexceptions import HTTPForbidden, HTTPNotFound, HTTPBadRequest from pyramid.response import Response from sqlalchemy import select @@ -32,6 +33,94 @@ class Stream(RawIOBase): return b"".join(buf) +def _get_int(request, name): + try: + return int(request.params.get(name)) + except ValueError: + raise HTTPBadRequest(f'Invalid integer in {name!r}') + +def _get_date(request, name): + try: + return datetime.date.fromisoformat(request.params.get(name)) + except ValueError: + raise HTTPBadRequest(f'Invalid date in {name!r}') + + +class TrackFilters: + """A filter that applies user-given filters to a track.""" + # TODO: We should also do some of those in SQL, if possible. + + def __init__(self, filters): + self._filters = filters + + def apply(self, track): + """Apply the filters to the track. + + :param track: The track. + :type track: fietsboek.models.track.Track + :return: Whether the track matches the filters. + :rtype: bool + """ + return all(f(track) for f in self._filters) + + @classmethod + def parse(cls, request): + """Parse the filters from the given request. + + :raises HTTPBadRequest: If the filters are malformed. + :param request: The request. + :type request: pyramid.request.Request + :return: The parsed filter. + :rtype: TrackFilters + """ + filters = [] + if request.params.get('search-terms'): + term = request.params.get('search-terms').strip() + filters.append(lambda track: term.lower() in track.title.lower()) + + if request.params.get('tags'): + tags = [tag.strip() for tag in request.params.get('tags').split('&&')] + tags = list(filter(bool, tags)) + + def has_tags(track): + lower_tags = {tag.lower() for tag in track.text_tags()} + return all(tag.lower() in lower_tags for tag in tags) + + filters.append(has_tags) + + if request.params.get('tagged-person'): + names = [name.strip() for name in request.params.get('tagged-person').split('&&')] + names = list(filter(bool, names)) + + def has_people(track): + peoples_names = [person.name for person in track.tagged_people] + peoples_names.append(track.owner.name) + peoples_names = set(map(str.lower, peoples_names)) + print(peoples_names) + return all(name.lower() in peoples_names for name in names) + + filters.append(has_people) + + if request.params.get('min-length'): + # Value is given in km, so convert it to m + min_length = _get_int(request, "min-length") * 1000 + filters.append(lambda track: track.length >= min_length) + + if request.params.get('max-length'): + max_length = _get_int(request, "max-length") * 1000 + filters.append(lambda track: track.length <= max_length) + + if request.params.get('min-date'): + min_date = _get_date(request, "min-date") + filters.append(lambda track: track.date.date() >= min_date) + + if request.params.get('max-date'): + max_date = _get_date(request, "max-date") + filters.append(lambda track: track.date.date() <= max_date) + + return TrackFilters(filters) + + def visible_tracks(dbsession, user): """Returns all visible tracks for the given user. @@ -64,7 +153,9 @@ def browse(request): :return: The HTTP response. :rtype: pyramid.response.Response """ + filters = TrackFilters.parse(request) tracks = visible_tracks(request.dbsession, request.identity) + tracks = [track for track in tracks if filters.apply(track)] return { 'tracks': tracks, 'mps_to_kph': util.mps_to_kph, |