Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

IMPROVE cart_from_app: JSON error handling #52

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions FabLabKasse/shopping/backend/abstract.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,29 @@


def float_to_decimal(number, digits):
"""
convert float to decimal with rounding and strict error tolerances

If the given number cannot be represented as decimal with an error
within 1/1000 of the last digit, :exc:`ValueError` is raised.

:param number: a float that is nearly equal to a decimal number
:type number: float | Decimal
:param digits: number of decimal places of the resulting value (max. 9)
:type digits: int

:raise: ValueError
"""

# conversion is guaranteed to be accurate at 1e12 for 0 digits
# larger values are maybe not correctly represented in a float, so we are careful here
assert isinstance(digits, int)
assert 0 <= digits < 10, "invalid number of digits"
result = Decimal(int(round(number * (10 ** digits)))) / (10 ** digits)
assert abs(number) < 10 ** (10 + digits), "cannot precisely convert such a large float to Decimal"
assert abs(float(result) - float(number)) < (10 ** -(digits + 3)), "attempted inaccurate conversion from {} to {}".format(repr(number), repr(result))
if not abs(number) < 10 ** (10 + digits):
raise ValueError("cannot precisely convert such a large float to Decimal")
if not abs(float(result) - float(number)) < (10 ** -(digits + 3)):
raise ValueError("attempted inaccurate conversion from {} to {}".format(repr(number), repr(result)))
return result

def format_qty(qty):
Expand Down
10 changes: 8 additions & 2 deletions FabLabKasse/shopping/cart_from_app/cart_gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

from PyQt4 import Qt, QtGui
from FabLabKasse.UI.LoadFromMobileAppDialogCode import LoadFromMobileAppDialog
from FabLabKasse.shopping.cart_from_app.cart_model import MobileAppCartModel
from FabLabKasse.shopping.cart_from_app.cart_model import MobileAppCartModel, InvalidCartJSONError
from FabLabKasse.shopping.backend.abstract import ProductNotFound
import logging

Expand Down Expand Up @@ -123,7 +123,13 @@ def poll(self):
# this should not happen, maybe a race-condition
return
logging.debug(u"polling for cart {}".format(self.cart.cart_id))
response = self.cart.load()
try:
response = self.cart.load()
except InvalidCartJSONError:
QtGui.QMessageBox.warning(self.parent, "Warenkorb", u"Entschuldigung, beim Import ist leider ein Fehler aufgetreten.\n (Fehlerhafte Warenkorbdaten)")
self.diag.reject()
return

if not response:
self.poll_timer.start()
return
Expand Down
174 changes: 136 additions & 38 deletions FabLabKasse/shopping/cart_from_app/cart_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,32 @@
import random
import os
from PyQt4.QtCore import pyqtSignal, QObject

import unittest
from decimal import Decimal
# TODO self-signed ssl , we need HTTPS :(


class InvalidCartJSONError(Exception):
"""Cart JSON object received was wrong."""
def __init__(self, text=None, property_name=None, value=None):
"""
Cart JSON object received was wrong.

automatically logs a warning (TODO is this okay for an exception initialisation?)

:param text: reason
:param property_name: use property_name and value if an unexpected value for a property occurs. The infotext is then filled automatically.
:param value: see property_name
"""
if not text:
text = u""
text = u"Invalid Cart: " + text
if property_name:
text += u"Property {} has unexpected value: {}".format(property_name, repr(value))
logging.warn(text)
Exception.__init__(self, text)


class MobileAppCartModel(QObject):
"""loads a cart from a mobile application"""

Expand Down Expand Up @@ -98,7 +120,8 @@ def load(self):
:return: list of tuples (product_code, quantity) or False
:rtype: list[(int, Decimal)] | bool

:raise: None (hopefully) - just returns False in normal cases of error
:raise: InvalidCartJSONError
if an invalid cart response was received from the server, (otherwise just returns False in normal cases of error)

If the cart id seems already used, the random cart id is updated. please connect to the cart_id_changed() signal
and update the shown QR code.
Expand All @@ -120,46 +143,41 @@ def load(self):
# logging.debug("app-checkout: empty response from server")
# no logging here since this is a standard use-case
return False
return self._decode_json(req)

def _decode_json(self, req):
"""decode JSON data containing the cart

:param req: response object with a .json() function for decoding
:type req: requests.Response
:raise: InvalidCartJSONError"""
try:
data = req.json()
except simplejson.JSONDecodeError:
logging.debug("app-checkout: JSONDecodeError")
return False
raise InvalidCartJSONError("app-checkout: JSONDecodeError")
logging.debug(u"received cart: {}".format(repr(data)))
# check, if json was ok
# TODO notify user of import error and aboard polling
# check whether json is ok
# TODO notify user of import error and abort polling
try:
error_msg = "rejecting cart with '{property}'='{value}'. Regenerating random id."
if data["status"] != "PENDING":
logging.info(error_msg.format(property="status", value=data["status"]))
self.generate_random_id()
return False
elif str(data["cartCode"]) != self.cart_id:
logging.info(error_msg.format(property="cartCode", value=data["cartCode"]))
self.generate_random_id()
return False
except KeyError:
logging.info("rejecting cart as a required key is missing in json. Regenerating random id.")
self.generate_random_id()
return False
cart = []
try:
for entry in data["items"]:
try:
item = (int(entry["productId"]), float_to_decimal(float(entry["amount"]), 3))
if item[1] <= 0:
logging.info(error_msg.format(property="item.amount", value=item[1]))
self.generate_random_id()
return False
cart.append(item)
except ValueError:
logging.info("rejecting cart with invalid values. Regenerating random id.")
self.generate_random_id()
return False
raise InvalidCartJSONError(property_name="status", value=data["status"])
if unicode(data["cartCode"]) != self.cart_id:
raise InvalidCartJSONError(property_name="cartCode", value=data["cartCode"])
# access data["items"] here so that a possible KeyError is raised
# and caught now and not later
cart_items = data["items"]
cart = []
for entry in cart_items:
if not isinstance(entry, dict):
raise InvalidCartJSONError(property_name="items", value=data["items"])
item = (int(entry["productId"]), float_to_decimal(float(entry["amount"]), 3))
if item[1] < 0:
raise InvalidCartJSONError(property_name="item.amount", value=item[1])
cart.append(item)
except KeyError:
logging.info("rejecting cart as a required key is missing in json. Regenerating random id.")
self.generate_random_id()
return False
raise InvalidCartJSONError("a required key is missing in JSON")
except ValueError:
raise InvalidCartJSONError("invalid field value in JSON (probably amount or productId)")
return cart

def send_status_feedback(self, success):
Expand All @@ -178,11 +196,91 @@ def send_status_feedback(self, success):
try:
req = requests.post(self.server_url + status + "/" + self.cart_id, timeout=self.timeout) # , HTTPAdapter(max_retries=5))
logging.debug("response: {}".format(repr(req.text)))
req.raise_for_status()
except IOError:
logging.warn("sending cart feedback failed")
# TODO what do we do on failure?


# if __name__ == "__main__":
# print getCart(str(1234))
# print setCartFeedback(str(1234),False)
class MobileAppCartModelTest(unittest.TestCase):
""" Test MobileAppCartModel """
class FakeResponse(object):
"fake requests.Response object, only simulating the .json() method"
def __init__(self, data):
":param data: JSON encoded string, simulated response body"
self.data = data

def json(self):
return simplejson.loads(self.data)

def test_decode_json(self):
FakeResponse = self.FakeResponse
def prepare():
model = MobileAppCartModel(None)
model.generate_random_id()
valid_data = {}
valid_data["cartCode"] = model.cart_id
valid_data["items"] = []

product = {}
product["id"] = 44
product["productId"] = "9011"
product["amount"] = "5."

valid_data["items"].append(product)
valid_data["status"] = "PENDING"
valid_data["pushId"] = "000"
valid_data["sendToServer"] = 12398234781237

valid_cart = [(int(product["productId"]), Decimal(5))]

return [model, valid_data, valid_cart]

# test valid cart
[model, data, valid_cart] = prepare()
self.assertEqual(model._decode_json(FakeResponse(simplejson.dumps(data))), valid_cart)

# test deleted fields and wrong datatype/value
# (pushID and sendToServer are unused and therefore ignored)
for field in ["status", "items", "cartCode"]:
[model, data, _] = prepare()
with self.assertRaises(InvalidCartJSONError):
del data[field]
model._decode_json(FakeResponse(simplejson.dumps(data)))

[model, data, _] = prepare()
with self.assertRaises(InvalidCartJSONError):
data[field] = "fooo"
model._decode_json(FakeResponse(simplejson.dumps(data)))

# wrong datatype inside items list
[model, data, _] = prepare()
with self.assertRaises(InvalidCartJSONError):
data["items"][0] = "fooo"
model._decode_json(FakeResponse(simplejson.dumps(data)))

# test missing fields (productId, amount) in item, or wrong datatype
# (id is ignored)
for field in ["amount", "productId"]:
[model, data, _] = prepare()
with self.assertRaises(InvalidCartJSONError):
del data["items"][0][field]
model._decode_json(FakeResponse(simplejson.dumps(data)))

# invalid values for amount
for invalid_amount_value in ["-5", "1.241234234232343242342234", "1e20"]:
[model, data, _] = prepare()
data["items"][0]["amount"] = invalid_amount_value
with self.assertRaises(InvalidCartJSONError):
model._decode_json(FakeResponse(simplejson.dumps(data)))

# invalid values for product id
for invalid_id_value in ["1.5", ""]:
[model, data, _] = prepare()
data["items"][0]["productId"] = invalid_id_value
with self.assertRaises(InvalidCartJSONError):
model._decode_json(FakeResponse(simplejson.dumps(data)))


if __name__ == "__main__":
unittest.main()
56 changes: 56 additions & 0 deletions FabLabKasse/tools/makeCart.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env python2.7
# -*- coding: utf-8 -*-
#
# FabLabKasse, a Point-of-Sale Software for FabLabs and other public and trust-based workshops.
# Copyright (C) 2015 FAU FabLab team members and others
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU
# General Public License as published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
# even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with this program. If not,
# see <http://www.gnu.org/licenses/>.


"""
Generate an example cart JSON for testing.

usage:

- set up a HTTP server, configure it config.ini
- go the webroot, you can now create carts with `makeCart.py <cart_id>`
- the script will generate a file with some JSON content for testing.
Adjust the script to your needs.
- submitting the status (cancel/paid) of a cart cannot be simulated

"""


import json
from sys import argv
import time

if __name__ == "__main__":
id = int(argv[1])

data = {}
data["cartCode"] = id
data["items"] = []

product = {}
product["id"] = 44
product["productId"] = "9011"
#product["amount"] = "5."

data["items"].append(product)
data["status"] = "PENDING"
data["pushId"] = "000"
data["sendToServer"] = int(time.time())

f = open(str(id), "w")
f.write(json.dumps(data))
f.close()