diff --git a/flatisfy/models/flat.py b/flatisfy/models/flat.py index 733791c..d683429 100644 --- a/flatisfy/models/flat.py +++ b/flatisfy/models/flat.py @@ -13,6 +13,7 @@ import arrow from sqlalchemy import ( Column, DateTime, Enum, Float, SmallInteger, String, Text ) +from sqlalchemy.orm import validates from flatisfy.database.base import BASE from flatisfy.database.types import MagicJSON @@ -96,6 +97,62 @@ class Flat(BASE): # Date for visit visit_date = Column(DateTime) + @validates('utilities') + def validate_utilities(self, _, utilities): + """ + Utilities validation method + """ + if isinstance(utilities, FlatUtilities): + return utilities + + if utilities == "C.C.": + return FlatUtilities.included + elif utilities == "H.C.": + return FlatUtilities.excluded + else: + return FlatUtilities.unknown + + @validates("status") + def validate_status(self, _, status): + """ + Status validation method + """ + if isinstance(status, FlatStatus): + return status + + try: + return getattr(FlatStatus, status) + except (AttributeError, TypeError): + LOGGER.warn("Unkown flat status %s, ignoring it.", + status) + return self.status.default.arg + + @validates("notation") + def validate_status(self, _, notation): + """ + Notation validation method + """ + try: + notation = int(notation) + assert notation >= 0 and notation <= 5 + except (ValueError, AssertionError): + raise ValueError('notation should be an integer between 0 and 5') + return notation + + @validates("date") + def validate_date(self, _, date): + """ + Date validation method + """ + return arrow.get(date).naive + + @validates("visit_date") + def validate_visit_date(self, _, visit_date): + """ + Visit date validation method + """ + return arrow.get(visit_date).naive + @staticmethod def from_dict(flat_dict): """ @@ -119,29 +176,6 @@ class Flat(BASE): ) del flat_dict["flatisfy"] - # Handle utilities field - if not isinstance(flat_dict["utilities"], FlatUtilities): - if flat_dict["utilities"] == "C.C.": - flat_dict["utilities"] = FlatUtilities.included - elif flat_dict["utilities"] == "H.C.": - flat_dict["utilities"] = FlatUtilities.excluded - else: - flat_dict["utilities"] = FlatUtilities.unknown - - # Handle status field - flat_status = flat_dict.get("status", "new") - if not isinstance(flat_status, FlatStatus): - try: - flat_dict["status"] = getattr(FlatStatus, flat_status) - except AttributeError: - if "status" in flat_dict: - del flat_dict["status"] - LOGGER.warn("Unkown flat status %s, ignoring it.", - flat_status) - - # Handle date field - flat_dict["date"] = arrow.get(flat_dict["date"]).naive - flat_object = Flat() # Using a __dict__.update() call to make it work even if there are # extra keys in flat_dict which are not valid kwargs for Flat model. @@ -151,7 +185,6 @@ class Flat(BASE): def __repr__(self): return "" % (self.id, self.urls) - def json_api_repr(self): """ Return a dict representation of this flat object that is JSON diff --git a/flatisfy/web/app.py b/flatisfy/web/app.py index 2915e74..545a32c 100644 --- a/flatisfy/web/app.py +++ b/flatisfy/web/app.py @@ -73,20 +73,11 @@ def get_app(config): api_routes.time_to_places_v1) app.route("/api/v1/flats", "GET", api_routes.flats_v1) - app.route("/api/v1/flats/status/:status", "GET", - api_routes.flats_by_status_v1) + app.route("/api/v1/flats/:flat_id", "GET", api_routes.flat_v1) + app.route("/api/v1/flats/:flat_id", "PATCH", + api_routes.update_flat_v1) - app.route("/api/v1/flat/:flat_id", "GET", api_routes.flat_v1) - app.route("/api/v1/flat/:flat_id/status", "POST", - api_routes.update_flat_status_v1) - app.route("/api/v1/flat/:flat_id/notes", "POST", - api_routes.update_flat_notes_v1) - app.route("/api/v1/flat/:flat_id/notation", "POST", - api_routes.update_flat_notation_v1) - app.route("/api/v1/flat/:flat_id/visit_date", "POST", - api_routes.update_flat_visit_date_v1) - - app.route("/api/v1/visits.ics", "GET", + app.route("/api/v1/ics/visits.ics", "GET", api_routes.ics_feed_v1) app.route("/api/v1/search", "POST", api_routes.search_v1) diff --git a/flatisfy/web/js_src/api/index.js b/flatisfy/web/js_src/api/index.js index 27fca9d..59be30e 100644 --- a/flatisfy/web/js_src/api/index.js +++ b/flatisfy/web/js_src/api/index.js @@ -46,7 +46,7 @@ export const getFlats = function (callback) { export const getFlat = function (flatId, callback) { fetch( - '/api/v1/flat/' + encodeURIComponent(flatId), + '/api/v1/flats/' + encodeURIComponent(flatId), { credentials: 'same-origin' } ) .then(function (response) { @@ -61,10 +61,10 @@ export const getFlat = function (flatId, callback) { export const updateFlatStatus = function (flatId, newStatus, callback) { fetch( - '/api/v1/flat/' + encodeURIComponent(flatId) + '/status', + '/api/v1/flats/' + encodeURIComponent(flatId), { credentials: 'same-origin', - method: 'POST', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -79,10 +79,10 @@ export const updateFlatStatus = function (flatId, newStatus, callback) { export const updateFlatNotes = function (flatId, newNotes, callback) { fetch( - '/api/v1/flat/' + encodeURIComponent(flatId) + '/notes', + '/api/v1/flats/' + encodeURIComponent(flatId), { credentials: 'same-origin', - method: 'POST', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -97,10 +97,10 @@ export const updateFlatNotes = function (flatId, newNotes, callback) { export const updateFlatNotation = function (flatId, newNotation, callback) { fetch( - '/api/v1/flat/' + encodeURIComponent(flatId) + '/notation', + '/api/v1/flats/' + encodeURIComponent(flatId), { credentials: 'same-origin', - method: 'POST', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, @@ -115,10 +115,10 @@ export const updateFlatNotation = function (flatId, newNotation, callback) { export const updateFlatVisitDate = function (flatId, newVisitDate, callback) { fetch( - '/api/v1/flat/' + encodeURIComponent(flatId) + '/visit_date', + '/api/v1/flats/' + encodeURIComponent(flatId), { credentials: 'same-origin', - method: 'POST', + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, diff --git a/flatisfy/web/routes/api.py b/flatisfy/web/routes/api.py index e211b44..8d9a45a 100644 --- a/flatisfy/web/routes/api.py +++ b/flatisfy/web/routes/api.py @@ -8,8 +8,8 @@ from __future__ import ( import datetime import json +import re -import arrow import bottle import vobject @@ -18,6 +18,12 @@ from flatisfy.models import flat as flat_model from flatisfy.models.postal_code import PostalCode +def JSONError(error_code, error_str): + bottle.response.status = error_code + bottle.response.content_type = "application/json" + return json.dumps(dict(error=error_str, status_code=error_code)) + + def _serialize_flat(flat, config): """ Serialize a flat for JSON API. @@ -65,7 +71,8 @@ def index_v1(): "flats": "/api/v1/flats", "flat": "/api/v1/flat/:id", "search": "/api/v1/search", - "time_to_places": "/api/v1/time_to/places" + "ics": "/api/v1/ics/visits.ics", + "time_to_places": "/api/v1/time_to_places" } @@ -75,57 +82,43 @@ def flats_v1(config, db): GET /api/v1/flats + .. note:: Filtering can be done through the ``filter`` GET param, according + to JSON API spec (http://jsonapi.org/recommendations/#filtering). + :return: The available flats objects in a JSON ``data`` dict. """ try: + db_query = db.query(flat_model.Flat) + + # Handle filtering according to JSON API spec + FILTER_RE = re.compile(r"filter\[([A-z0-9_]+)\]") + filters = {} + for param in bottle.request.query: + filter_match = FILTER_RE.match(param) + if not filter: + continue + field = filter_match.group(1) + value = bottle.request.query[filter_match.group(0)] + filters[field] = value + db_query = db_query.filter_by(**filters) + + # Build flat list flats = [ _serialize_flat(flat, config) - for flat in db.query(flat_model.Flat).all() + for flat in db_query ] - return { "data": flats } - except Exception as e: - return { - "error": str(e) - } - - -def flats_by_status_v1(status, config, db): - """ - API v1 flats route with a specific status: - - GET /api/v1/flats/status/:status - - :return: The matching flats objects in a JSON ``data`` dict. - """ - try: - flats = [ - _serialize_flat(flat, config) - for flat in ( - db.query(flat_model.Flat) - .filter_by(status=getattr(flat_model.FlatStatus, status)) - .all() - ) - ] - - return { - "data": flats - } - except AttributeError: - return bottle.HTTPError(400, "Invalid status provided.") - except Exception as e: - return { - "error": str(e) - } + except Exception as exc: + return JSONError(500, str(exc)) def flat_v1(flat_id, config, db): """ API v1 flat route: - GET /api/v1/flat/:flat_id + GET /api/v1/flats/:flat_id :return: The flat object in a JSON ``data`` dict. """ @@ -133,141 +126,50 @@ def flat_v1(flat_id, config, db): flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() if not flat: - return bottle.HTTPError(404, "No flat with id {}.".format(flat_id)) + return JSONError(404, "No flat with id {}.".format(flat_id)) return { "data": _serialize_flat(flat, config) } - except Exception as e: - return { - "error": str(e) - } + except Exception as exc: + return JSONError(500, str(exc)) -def update_flat_status_v1(flat_id, config, db): +def update_flat_v1(flat_id, config, db): """ API v1 route to update flat status: - POST /api/v1/flat/:flat_id/status - Data: { - "status": "NEW_STATUS" - } - - :return: The new flat object in a JSON ``data`` dict. - """ - try: - flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() - if not flat: - return bottle.HTTPError(404, "No flat with id {}.".format(flat_id)) - - try: - flat.status = getattr( - flat_model.FlatStatus, json.load(bottle.request.body)["status"] - ) - except (AttributeError, ValueError, KeyError): - return bottle.HTTPError(400, "Invalid status provided.") - - return { - "data": _serialize_flat(flat, config) - } - except Exception as e: - return { - "error": str(e) - } - - -def update_flat_notes_v1(flat_id, config, db): - """ - API v1 route to update flat notes: - - POST /api/v1/flat/:flat_id/notes - Data: { - "notes": "NEW_NOTES" - } - - :return: The new flat object in a JSON ``data`` dict. - """ - try: - flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() - if not flat: - return bottle.HTTPError(404, "No flat with id {}.".format(flat_id)) - - try: - flat.notes = json.load(bottle.request.body)["notes"] - except (ValueError, KeyError): - return bottle.HTTPError(400, "Invalid notes provided.") - - return { - "data": _serialize_flat(flat, config) - } - except Exception as e: - return { - "error": str(e) - } - - -def update_flat_notation_v1(flat_id, config, db): - """ - API v1 route to update flat notation: - - POST /api/v1/flat/:flat_id/notation - Data: { - "notation": "NEW_NOTATION" - } - - :return: The new flat object in a JSON ``data`` dict. - """ - try: - flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() - if not flat: - return bottle.HTTPError(404, "No flat with id {}.".format(flat_id)) - - try: - flat.notation = json.load(bottle.request.body)["notation"] - assert flat.notation >= 0 and flat.notation <= 5 - except (AssertionError, ValueError, KeyError): - return bottle.HTTPError(400, "Invalid notation provided.") - - return { - "data": _serialize_flat(flat, config) - } - except Exception as e: - return { - "error": str(e) - } - - -def update_flat_visit_date_v1(flat_id, config, db): - """ - API v1 route to update flat date of visit: - - POST /api/v1/flat/:flat_id/visit_date + 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. """ try: flat = db.query(flat_model.Flat).filter_by(id=flat_id).first() if not flat: - return bottle.HTTPError(404, "No flat with id {}.".format(flat_id)) + return JSONError(404, "No flat with id {}.".format(flat_id)) try: - visit_date = json.load(bottle.request.body)["visit_date"] - if visit_date: - visit_date = arrow.get(visit_date).naive - flat.visit_date = visit_date - except (arrow.parser.ParserError, ValueError, KeyError): - return bottle.HTTPError(400, "Invalid visit date provided.") + json_body = json.load(bottle.request.body) + for k, v in json_body.items(): + setattr(flat, k, v) + except ValueError as exc: + return JSONError( + 400, + "Invalid payload provided: {}.".format(str(exc)) + ) return { "data": _serialize_flat(flat, config) } - except Exception as e: - return { - "error": str(e) - } + except Exception as exc: + return JSONError(500, str(exc)) def time_to_places_v1(config): @@ -289,10 +191,8 @@ def time_to_places_v1(config): return { "data": places } - except Exception as e: - return { - "error": str(e) - } + except Exception as exc: + return JSONError(500, str(exc)) def search_v1(db, config): @@ -310,7 +210,7 @@ def search_v1(db, config): try: query = json.load(bottle.request.body)["query"] except (ValueError, KeyError): - return bottle.HTTPError(400, "Invalid query provided.") + return JSONError(400, "Invalid query provided.") flats_db_query = flat_model.Flat.search_query(db, query) flats = [ @@ -321,26 +221,24 @@ def search_v1(db, config): return { "data": flats } - except Exception as e: - return { - "error": str(e) - } + except Exception as exc: + return JSONError(500, str(exc)) def ics_feed_v1(config, db): """ API v1 ICS feed of visits route: - GET /api/v1/visits.ics + GET /api/v1/ics/visits.ics :return: The ICS feed for the visits. """ + cal = vobject.iCalendar() try: flats_with_visits = db.query(flat_model.Flat).filter( flat_model.Flat.visit_date.isnot(None) - ).all() + ) - cal = vobject.iCalendar() for flat in flats_with_visits: vevent = cal.add('vevent') vevent.add('dtstart').value = flat.visit_date @@ -360,7 +258,7 @@ def ics_feed_v1(config, db): description += '\n{}\n'.format(flat.notes) vevent.add('description').value = description - - return cal.serialize() except: - return '' + pass + + return cal.serialize()