diff --git a/FabLabKasse/shopping/backend/abstract.py b/FabLabKasse/shopping/backend/abstract.py index 7ae88be..b6e1d98 100644 --- a/FabLabKasse/shopping/backend/abstract.py +++ b/FabLabKasse/shopping/backend/abstract.py @@ -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): diff --git a/FabLabKasse/shopping/cart_from_app/cart_gui.py b/FabLabKasse/shopping/cart_from_app/cart_gui.py index 0c7e37f..c5c308f 100644 --- a/FabLabKasse/shopping/cart_from_app/cart_gui.py +++ b/FabLabKasse/shopping/cart_from_app/cart_gui.py @@ -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 @@ -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 diff --git a/FabLabKasse/shopping/cart_from_app/cart_model.py b/FabLabKasse/shopping/cart_from_app/cart_model.py index c654406..0b6698f 100644 --- a/FabLabKasse/shopping/cart_from_app/cart_model.py +++ b/FabLabKasse/shopping/cart_from_app/cart_model.py @@ -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""" @@ -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. @@ -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): @@ -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() diff --git a/FabLabKasse/tools/makeCart.py b/FabLabKasse/tools/makeCart.py new file mode 100755 index 0000000..b0a53dc --- /dev/null +++ b/FabLabKasse/tools/makeCart.py @@ -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 . + + +""" +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 ` +- 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()