flatisfy/flatisfy/web/routes/api.py

521 lines
15 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 os
import bottle
import vobject
import flatisfy.data
from flatisfy.models import flat as flat_model
from flatisfy.models.postal_code import PostalCode
from flatisfy import cmds
FILTER_RE = re.compile(r"filter\[([A-z0-9_]+)\]")
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
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",
}
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
"""
if bottle.request.method == "OPTIONS":
# CORS
return {}
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))
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))
def import_v1(config):
"""
API v1 import new flats.
Example::
GET /api/v1/import
:return: The new flats.
"""
if bottle.request.method == "OPTIONS":
# CORS
return {}
try:
flats_id = cmds.import_and_filter(config, False, True)
return {"flats": flats_id}
except Exception as exc: # pylint: disable= broad-except
return JSONError(500, str(exc))