Source code for flatisfy.web.routes.api

# coding: utf-8
"""
This module contains the definition of the web app API routes.
"""
from __future__ import (
    absolute_import, division, print_function, unicode_literals
)

import datetime
import itertools
import json
import re
import os

import bottle
import vobject

import flatisfy.data
from flatisfy.models import flat as flat_model
from flatisfy.models.postal_code import PostalCode

FILTER_RE = re.compile(r"filter\[([A-z0-9_]+)\]")


[docs]def JSONError(error_code, error_str): # pylint: disable=invalid-name """ Return an HTTP error with a JSON payload. :param error_code: HTTP error code to return. :param error_str: Error as a string. :returns: Set correct response parameters and returns JSON-serialized error content. """ bottle.response.status = error_code bottle.response.content_type = "application/json" return json.dumps(dict(error=error_str, status_code=error_code))
def _JSONApiSpec(query, model, default_sorting=None): """ Implementing JSON API spec for filtering, sorting and paginating results. :param query: A Bottle query dict. :param model: Database model used in this query. :param default_sorting: Optional field to sort on if no sort options are passed through parameters. :return: A tuple of filters, page number, page size (items per page) and sorting to apply. """ # Handle filtering according to JSON API spec filters = {} for param in query: filter_match = FILTER_RE.match(param) if not filter_match: continue field = filter_match.group(1) value = query[filter_match.group(0)] filters[field] = value # Handle pagination according to JSON API spec page_number, page_size = 0, None try: if 'page[size]' in query: page_size = int(query['page[size]']) assert page_size > 0 if 'page[number]' in query: page_number = int(query['page[number]']) assert page_number >= 0 except (AssertionError, ValueError): raise ValueError("Invalid pagination provided.") # Handle sorting according to JSON API spec sorting = [] if 'sort' in query: for index in query['sort'].split(','): try: sort_field = getattr(model, index.lstrip('-')) except AttributeError: raise ValueError( "Invalid sorting key provided: {}.".format(index) ) if index.startswith('-'): sort_field = sort_field.desc() sorting.append(sort_field) # Default sorting options if not sorting and default_sorting: try: sorting.append(getattr(model, default_sorting)) except AttributeError: raise ValueError( "Invalid default sorting key provided: {}.".format( default_sorting ) ) return filters, page_number, page_size, sorting def _serialize_flat(flat, config): """ Serialize a flat for JSON API. Converts it to a JSON-representable dict and add postal code metadata. :param flat: An SQLAlchemy Flat object. :param config: A config dict. :returns: A flat dict ready to be serialized. """ flat = flat.json_api_repr() postal_codes = {} for constraint_name, constraint in config["constraints"].items(): postal_codes[constraint_name] = flatisfy.data.load_data( PostalCode, constraint, config ) try: assert flat["flatisfy_postal_code"] postal_code_data = next( x for x in postal_codes.get(flat["flatisfy_constraint"], []) if x.postal_code == flat["flatisfy_postal_code"] ) flat["flatisfy_postal_code"] = { "postal_code": flat["flatisfy_postal_code"], "name": postal_code_data.name, "gps": (postal_code_data.lat, postal_code_data.lng) } except (AssertionError, StopIteration): flat["flatisfy_postal_code"] = {} return flat
[docs]def index_v1(): """ API v1 index route. Example:: GET /api/v1/ """ return { "opendata": "/api/v1/opendata", "flats": "/api/v1/flats", "flat": "/api/v1/flat/:id", "search": "/api/v1/search", "ics": "/api/v1/ics/visits.ics", "time_to_places": "/api/v1/time_to_places", "metadata": "/api/v1/metadata" }
[docs]def flats_v1(config, db): """ API v1 flats route. Example:: GET /api/v1/flats .. note:: Filtering can be done through the ``filter`` GET param, according to JSON API spec (http://jsonapi.org/recommendations/#filtering). .. note:: By default no pagination is done. Pagination can be forced using ``page[size]`` to specify a number of items per page and ``page[number]`` to specify which page to return. Pages are numbered starting from 0. .. note:: Sorting can be handled through the ``sort`` GET param, according to JSON API spec (http://jsonapi.org/format/#fetching-sorting). :return: The available flats objects in a JSON ``data`` dict. """ if bottle.request.method == 'OPTIONS': # CORS return '' try: try: filters, page_number, page_size, sorting = _JSONApiSpec( bottle.request.query, flat_model.Flat, default_sorting='cost' ) except ValueError as exc: return JSONError(400, str(exc)) # Build flat list db_query = ( db.query(flat_model.Flat).filter_by(**filters).order_by(*sorting) ) flats = [ _serialize_flat(flat, config) for flat in itertools.islice( db_query, page_number * page_size if page_size else None, page_number * page_size + page_size if page_size else None ) ] return { "data": flats, "page": page_number, "items_per_page": page_size if page_size else len(flats) } except Exception as exc: # pylint: disable= broad-except return JSONError(500, str(exc))
[docs]def flat_v1(flat_id, config, db): """ API v1 flat route. Example:: GET /api/v1/flats/:flat_id :return: The flat object in a JSON ``data`` dict. """ if bottle.request.method == 'OPTIONS': # CORS return {} try: flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() if not flat: return JSONError(404, "No flat with id {}.".format(flat_id)) return { "data": _serialize_flat(flat, config) } except Exception as exc: # pylint: disable= broad-except return JSONError(500, str(exc))
[docs]def update_flat_v1(flat_id, config, db): """ API v1 route to update flat status. Example:: PATCH /api/v1/flat/:flat_id Data: { "status": "NEW_STATUS", "visit_date": "ISO8601 DATETIME" } .. note:: The keys in the data sent are same keys as in ``Flat`` model. You can provide any subset of them to update part of the flat infos. :return: The new flat object in a JSON ``data`` dict. """ if bottle.request.method == 'OPTIONS': # CORS return {} try: flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() if not flat: return JSONError(404, "No flat with id {}.".format(flat_id)) try: json_body = json.load(bottle.request.body) for key, value in json_body.items(): setattr(flat, key, value) except ValueError as exc: return JSONError( 400, "Invalid payload provided: {}.".format(str(exc)) ) return { "data": _serialize_flat(flat, config) } except Exception as exc: # pylint: disable= broad-except return JSONError(500, str(exc))
[docs]def time_to_places_v1(config): """ API v1 route to fetch the details of the places to compute time to. Example:: GET /api/v1/time_to_places :return: The JSON dump of the places to compute time to (dict of places names mapped to GPS coordinates). """ if bottle.request.method == 'OPTIONS': # CORS return {} try: places = {} for constraint_name, constraint in config["constraints"].items(): places[constraint_name] = { k: v["gps"] for k, v in constraint["time_to"].items() } return { "data": places } except Exception as exc: # pylint: disable= broad-except return JSONError(500, str(exc))
[docs]def search_v1(db, config): """ API v1 route to perform a fulltext search on flats. Example:: POST /api/v1/search Data: { "query": "SOME_QUERY" } .. note:: Filtering can be done through the ``filter`` GET param, according to JSON API spec (http://jsonapi.org/recommendations/#filtering). .. note:: By default no pagination is done. Pagination can be forced using ``page[size]`` to specify a number of items per page and ``page[number]`` to specify which page to return. Pages are numbered starting from 0. .. note:: Sorting can be handled through the ``sort`` GET param, according to JSON API spec (http://jsonapi.org/format/#fetching-sorting). :return: The matching flat objects in a JSON ``data`` dict. """ if bottle.request.method == 'OPTIONS': # CORS return {} try: try: query = json.load(bottle.request.body)["query"] except (ValueError, KeyError): return JSONError(400, "Invalid query provided.") try: filters, page_number, page_size, sorting = _JSONApiSpec( bottle.request.query, flat_model.Flat, default_sorting='cost' ) except ValueError as exc: return JSONError(400, str(exc)) flats_db_query = (flat_model.Flat .search_query(db, query) .filter_by(**filters) .order_by(*sorting)) flats = [ _serialize_flat(flat, config) for flat in itertools.islice( flats_db_query, page_number * page_size if page_size else None, page_number * page_size + page_size if page_size else None ) ] return { "data": flats, "page": page_number, "items_per_page": page_size if page_size else len(flats) } except Exception as exc: # pylint: disable= broad-except return JSONError(500, str(exc))
[docs]def ics_feed_v1(config, db): """ API v1 ICS feed of visits route. Example:: GET /api/v1/ics/visits.ics :return: The ICS feed for the visits. """ if bottle.request.method == 'OPTIONS': # CORS return {} cal = vobject.iCalendar() try: flats_with_visits = db.query(flat_model.Flat).filter( flat_model.Flat.visit_date.isnot(None) ) for flat in flats_with_visits: vevent = cal.add('vevent') vevent.add('dtstart').value = flat.visit_date vevent.add('dtend').value = ( flat.visit_date + datetime.timedelta(hours=1) ) vevent.add('summary').value = 'Visit - {}'.format(flat.title) description = ( '{} (area: {}, cost: {} {})\n{}#/flat/{}\n'.format( flat.title, flat.area, flat.cost, flat.currency, config['website_url'], flat.id ) ) description += '\n{}\n'.format(flat.text) if flat.notes: description += '\n{}\n'.format(flat.notes) vevent.add('description').value = description except Exception: # pylint: disable= broad-except pass return cal.serialize()
[docs]def opendata_index_v1(): """ API v1 data index route. Example:: GET /api/v1/opendata """ if bottle.request.method == 'OPTIONS': # CORS return {} return { "postal_codes": "/api/v1/opendata/postal_codes" }
[docs]def opendata_postal_codes_v1(db): """ API v1 data postal codes route. Example:: GET /api/v1/opendata/postal_codes .. note:: Filtering can be done through the ``filter`` GET param, according to JSON API spec (http://jsonapi.org/recommendations/#filtering). .. note:: By default no pagination is done. Pagination can be forced using ``page[size]`` to specify a number of items per page and ``page[number]`` to specify which page to return. Pages are numbered starting from 0. .. note:: Sorting can be handled through the ``sort`` GET param, according to JSON API spec (http://jsonapi.org/format/#fetching-sorting). :return: The postal codes data from opendata. """ if bottle.request.method == 'OPTIONS': # CORS return {} try: try: filters, page_number, page_size, sorting = _JSONApiSpec( bottle.request.query, PostalCode, default_sorting='postal_code' ) except ValueError as exc: return JSONError(400, str(exc)) db_query = db.query(PostalCode).filter_by(**filters).order_by(*sorting) postal_codes = [ x.json_api_repr() for x in itertools.islice( db_query, page_number * page_size if page_size else None, page_number * page_size + page_size if page_size else None ) ] return { "data": postal_codes, "page": page_number, "items_per_page": page_size if page_size else len(postal_codes) } except Exception as exc: # pylint: disable= broad-except return JSONError(500, str(exc))
[docs]def metadata_v1(config): """ API v1 metadata of the application. Example:: GET /api/v1/metadata :return: The application metadata. """ if bottle.request.method == 'OPTIONS': # CORS return {} try: last_update = None try: ts_file = os.path.join( config['data_directory'], 'timestamp' ) last_update = os.path.getmtime(ts_file) except OSError: pass return { 'data': { 'last_update': last_update } } except Exception as exc: # pylint: disable= broad-except return JSONError(500, str(exc))