502 lines
14 KiB
Python
502 lines
14 KiB
Python
# 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 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_]+)\]")
|
|
|
|
|
|
def JSONError(error_code, error_str):
|
|
"""
|
|
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
|
|
|
|
|
|
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"
|
|
}
|
|
|
|
|
|
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))
|
|
|
|
|
|
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))
|
|
|
|
|
|
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))
|
|
|
|
|
|
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))
|
|
|
|
|
|
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))
|
|
|
|
|
|
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()
|
|
|
|
|
|
def opendata_index_v1():
|
|
"""
|
|
API v1 data index route.
|
|
|
|
Example::
|
|
|
|
GET /api/v1/opendata
|
|
"""
|
|
return {
|
|
"postal_codes": "/api/v1/opendata/postal_codes"
|
|
}
|
|
|
|
|
|
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))
|