Repository: SebRut/pygrocy (python)

Issue:
complete abstraction on top of server data classes
Currently, some methods in `Grocy` return response objects and some methods return objects independent of the server representation. In this issue, all methods in `Grocy` should be changed to return a representation of an entity which is independent of the representation on the server side. It may be needed to create additional classes to achieve this.

Pull request:
finish abstraction from api
fixes #95 

Patch:
diff --git a/pygrocy/grocy.py b/pygrocy/grocy.py
index d494a0f..7dd9575 100644
--- a/pygrocy/grocy.py
+++ b/pygrocy/grocy.py
@@ -3,33 +3,48 @@ from typing import List
 
 from .grocy_api_client import (DEFAULT_PORT_NUMBER, ChoreDetailsResponse,
                                CurrentChoreResponse, CurrentStockResponse,
-                               CurrentVolatilStockResponse, GrocyApiClient,
+                               GrocyApiClient,
                                LocationData, MissingProductResponse,
-                               ProductData, ProductDetailsResponse,
+                               ProductDetailsResponse,
                                ShoppingListItem, TransactionType, UserDto)
 
 
 class Product(object):
     def __init__(self, response):
         if isinstance(response, CurrentStockResponse):
-            self._id = response.product_id
-            self._available_amount = response.amount
-            self._best_before_date = response.best_before_date
-            self._amount_missing = None
-            self._is_partly_in_stock = None
-            if response.product:
-                self._name = response.product.name
-                self._barcodes = response.product.barcodes
-                self._product_group_id = response.product.product_group_id
+            self._init_from_CurrentStockResponse(response)
         elif isinstance(response, MissingProductResponse):
-            self._id = response.product_id
-            self._name = response.name
-            self._available_amount = None
-            self._best_before_date = None
-            self._amount_missing = response.amount_missing
-            self._is_partly_in_stock = response.is_partly_in_stock
-            self._barcodes = None
-            self._product_group_id = None
+            self._init_from_MissingProductResponse(response)
+        elif isinstance(response, ProductDetailsResponse):
+            self._init_from_ProductDetailsResponse(response)
+
+    def _init_from_CurrentStockResponse(self, response: CurrentStockResponse):
+        self._id = response.product_id
+        self._available_amount = response.amount
+        self._best_before_date = response.best_before_date
+        self._amount_missing = None
+        self._is_partly_in_stock = None
+        if response.product:
+            self._name = response.product.name
+            self._barcodes = response.product.barcodes
+            self._product_group_id = response.product.product_group_id
+
+    def _init_from_MissingProductResponse(self, response: MissingProductResponse):
+        self._id = response.product_id
+        self._name = response.name
+        self._available_amount = None
+        self._best_before_date = None
+        self._amount_missing = response.amount_missing
+        self._is_partly_in_stock = response.is_partly_in_stock
+        self._barcodes = None
+        self._product_group_id = None
+
+    def _init_from_ProductDetailsResponse(self, response: ProductDetailsResponse):
+        self._id = response.product.id
+        self._available_amount = response.stock_amount
+        self._best_before_date = response.next_best_before_date
+        self._name = response.product.name
+        self._barcodes = response.product.barcodes
 
     def get_details(self, api_client: GrocyApiClient):
         details = api_client.get_product(self.id)
@@ -124,14 +139,26 @@ class ShoppingListProduct(object):
 
 
 class Chore(object):
-    def __init__(self, raw_chore: CurrentChoreResponse):
-        self._id = raw_chore.chore_id
-        self._last_tracked_time = raw_chore.last_tracked_time
-        self._next_estimated_execution_time = raw_chore.next_estimated_execution_time
-
+    def __init__(self, response):
+        if isinstance(response, CurrentChoreResponse):
+            self._init_from_CurrentChoreResponse(response)
+        elif isinstance(response, ChoreDetailsResponse):
+            self._init_from_ChoreDetailsResponse(response)
+
+    def _init_from_CurrentChoreResponse(self, response: CurrentChoreResponse):
+        self._id = response.chore_id
+        self._last_tracked_time = response.last_tracked_time
+        self._next_estimated_execution_time = response.next_estimated_execution_time
         self._name = None
         self._last_done_by = None
 
+    def _init_from_ChoreDetailsResponse(self, response):
+        self._id = response.chore.id
+        self._last_tracked_time = response.last_tracked
+        self._next_estimated_execution_time = response.next_estimated_execution_time
+        self._name = response.chore.name
+        self._last_done_by = response.last_done_by
+
     def get_details(self, api_client: GrocyApiClient):
         details = api_client.get_chore(self.id)
         self._name = details.chore.name
@@ -168,11 +195,8 @@ class Grocy(object):
         stock = [Product(resp) for resp in raw_stock]
         return stock
 
-    def volatile_stock(self) -> CurrentVolatilStockResponse:
-        return self._api_client.get_volatile_stock()
-
     def expiring_products(self, get_details: bool = False) -> List[Product]:
-        raw_expiring_product = self.volatile_stock().expiring_products
+        raw_expiring_product = self._api_client.get_volatile_stock().expiring_products
         expiring_product = [Product(resp) for resp in raw_expiring_product]
 
         if get_details:
@@ -181,7 +205,7 @@ class Grocy(object):
         return expiring_product
 
     def expired_products(self, get_details: bool = False) -> List[Product]:
-        raw_expired_product = self.volatile_stock().expired_products
+        raw_expired_product = self._api_client.get_volatile_stock().expired_products
         expired_product = [Product(resp) for resp in raw_expired_product]
 
         if get_details:
@@ -190,7 +214,7 @@ class Grocy(object):
         return expired_product
 
     def missing_products(self, get_details: bool = False) -> List[Product]:
-        raw_missing_product = self.volatile_stock().missing_products
+        raw_missing_product = self._api_client.get_volatile_stock().missing_products
         missing_product = [Product(resp) for resp in raw_missing_product]
 
         if get_details:
@@ -198,8 +222,10 @@ class Grocy(object):
                 item.get_details(self._api_client)
         return missing_product
 
-    def product(self, product_id: int) -> ProductDetailsResponse:
-        return self._api_client.get_product(product_id)
+    def product(self, product_id: int) -> Product:
+        resp = self._api_client.get_product(product_id)
+        if resp:
+            return Product(resp)
 
     def chores(self, get_details: bool = False) -> List[Chore]:
         raw_chores = self._api_client.get_chores()
@@ -213,8 +239,9 @@ class Grocy(object):
     def execute_chore(self, chore_id: int, done_by: int = None, tracked_time: datetime = datetime.now()):
         return self._api_client.execute_chore(chore_id, done_by, tracked_time)
 
-    def chore(self, chore_id: int) -> ChoreDetailsResponse:
-        return self._api_client.get_chore(chore_id)
+    def chore(self, chore_id: int) -> Chore:
+        resp = self._api_client.get_chore(chore_id)
+        return Chore(resp)
 
     def add_product(self, product_id, amount: float, price: float, best_before_date: datetime = None,
                     transaction_type: TransactionType = TransactionType.PURCHASE):
diff --git a/pygrocy/grocy_api_client.py b/pygrocy/grocy_api_client.py
index f24252f..f22cff1 100644
--- a/pygrocy/grocy_api_client.py
+++ b/pygrocy/grocy_api_client.py
@@ -11,23 +11,23 @@ import requests
 from pygrocy.utils import parse_date, parse_float, parse_int
 from tzlocal import get_localzone
 
-DEFAULT_PORT_NUMBER=9192
+DEFAULT_PORT_NUMBER = 9192
+
 
 class ShoppingListItem(object):
     def __init__(self, parsed_json):
         self._id = parse_int(parsed_json.get('id'))
         self._product_id = parse_int(parsed_json.get('product_id', None))
-        self._note = parsed_json.get('note',None)
-        self._amount = parse_float(parsed_json.get('amount'),0)
+        self._note = parsed_json.get('note', None)
+        self._amount = parse_float(parsed_json.get('amount'), 0)
         self._row_created_timestamp = parse_date(parsed_json.get('row_created_timestamp', None))
         self._shopping_list_id = parse_int(parsed_json.get('shopping_list_id'))
         self._done = parse_int(parsed_json.get('done'))
 
-
     @property
     def id(self) -> int:
         return self._id
-    
+
     @property
     def product_id(self) -> int:
         return self._product_id
@@ -40,6 +40,7 @@ class ShoppingListItem(object):
     def amount(self) -> float:
         return self._amount
 
+
 class QuantityUnitData(object):
     def __init__(self, parsed_json):
         self._id = parse_int(parsed_json.get('id'))
@@ -55,15 +56,15 @@ class LocationData(object):
         self._name = parsed_json.get('name')
         self._description = parsed_json.get('description')
         self._row_created_timestamp = parse_date(parsed_json.get('row_created_timestamp'))
-        
+
     @property
     def id(self) -> int:
         return self._id
-        
+
     @property
     def name(self) -> str:
         return self._name
-        
+
     @property
     def description(self) -> str:
         return self._description
@@ -91,11 +92,10 @@ class ProductData(object):
         else:
             self._barcodes = barcodes_raw.split(",")
 
-
     @property
     def id(self) -> int:
         return self._id
-        
+
     @property
     def product_group_id(self) -> int:
         return self._product_group_id
@@ -172,7 +172,6 @@ class CurrentChoreResponse(object):
         return self._next_estimated_execution_time
 
 
-
 class CurrentStockResponse(object):
     def __init__(self, parsed_json):
         self._product_id = parse_int(parsed_json.get('product_id'))
@@ -180,7 +179,7 @@ class CurrentStockResponse(object):
         self._best_before_date = parse_date(parsed_json.get('best_before_date'))
         self._amount_opened = parse_float(parsed_json.get('amount_opened'))
         self._product = ProductData(parsed_json.get('product'))
-        
+
     @property
     def product_id(self) -> int:
         return self._product_id
@@ -225,6 +224,7 @@ class MissingProductResponse(object):
     def is_partly_in_stock(self) -> bool:
         return self._is_partly_in_stock
 
+
 class CurrentVolatilStockResponse(object):
     def __init__(self, parsed_json):
         self._expiring_products = [CurrentStockResponse(product) for product in parsed_json.get('expiring_products')]
@@ -244,7 +244,6 @@ class CurrentVolatilStockResponse(object):
         return self._missing_products
 
 
-
 class ProductDetailsResponse(object):
     def __init__(self, parsed_json):
         self._last_purchased = parse_date(parsed_json.get('last_purchased'))
@@ -294,6 +293,7 @@ class ChoreDetailsResponse(object):
     def __init__(self, parsed_json):
         self._chore = ChoreData(parsed_json.get('chore'))
         self._last_tracked = parse_date(parsed_json.get('last_tracked'))
+        self._next_estimated_execution_time = parse_date(parsed_json.get('next_estimated_execution_time'))
 
         if self._last_tracked is None:
             self._last_done_by = None
@@ -312,6 +312,10 @@ class ChoreDetailsResponse(object):
     def last_tracked(self) -> datetime:
         return self._last_tracked
 
+    @property
+    def next_estimated_execution_time(self) -> datetime:
+        return self._next_estimated_execution_time
+
 
 class TransactionType(Enum):
     PURCHASE = "purchase"
@@ -321,12 +325,12 @@ class TransactionType(Enum):
 
 
 class GrocyApiClient(object):
-    def __init__(self, base_url, api_key, port: int = DEFAULT_PORT_NUMBER, verify_ssl = True):
+    def __init__(self, base_url, api_key, port: int = DEFAULT_PORT_NUMBER, verify_ssl=True):
         self._base_url = '{}:{}/api/'.format(base_url, port)
         self._api_key = api_key
         self._verify_ssl = verify_ssl
         if self._api_key == "demo_mode":
-            self._headers = { "accept": "application/json" }
+            self._headers = {"accept": "application/json"}
         else:
             self._headers = {
                 "accept": "application/json",
@@ -429,7 +433,7 @@ class GrocyApiClient(object):
         }
 
         self._do_post_request(f"stock/products/{product_id}/consume", data)
-        
+
     def get_shopping_list(self) -> List[ShoppingListItem]:
         parsed_json = self._do_get_request("objects/shopping_list")
         return [ShoppingListItem(response) for response in parsed_json]
@@ -442,7 +446,7 @@ class GrocyApiClient(object):
             }
 
         self._do_post_request("stock/shoppinglist/add-missing-products", data)
-    
+
     def add_product_to_shopping_list(self, product_id: int, shopping_list_id: int = 1, amount: int = 1):
         data = {
             "product_id": product_id,
@@ -450,14 +454,14 @@ class GrocyApiClient(object):
             "product_amount": amount
         }
         self._do_post_request("stock/shoppinglist/add-product", data)
-    
+
     def clear_shopping_list(self, shopping_list_id: int = 1):
         data = {
             "list_id": shopping_list_id
         }
 
         self._do_post_request("stock/shoppinglist/clear", data)
-            
+
     def remove_product_in_shopping_list(self, product_id: int, shopping_list_id: int = 1, amount: int = 1):
         data = {
             "product_id": product_id,
@@ -465,7 +469,7 @@ class GrocyApiClient(object):
             "product_amount": amount
         }
         self._do_post_request("stock/shoppinglist/remove-product", data)
-        
+
     def get_product_groups(self) -> List[LocationData]:
         parsed_json = self._do_get_request("objects/product_groups")
         return [LocationData(response) for response in parsed_json]
@@ -473,18 +477,18 @@ class GrocyApiClient(object):
     def upload_product_picture(self, product_id: int, pic_path: str):
         b64fn = base64.b64encode('{}.jpg'.format(product_id).encode('ascii'))
         req_url = "files/productpictures/" + str(b64fn, "utf-8")
-        with open(pic_path,'rb') as pic:
+        with open(pic_path, 'rb') as pic:
             self._do_put_request(req_url, pic)
-            
+
     def update_product_pic(self, product_id: int):
         pic_name = f"{product_id}.jpg"
-        data = { "picture_file_name":  pic_name }
+        data = {"picture_file_name": pic_name}
         self._do_put_request(f"objects/products/{product_id}", data)
-            
+
     def get_userfields(self, entity: str, object_id: int):
         url = f"userfields/{entity}/{object_id}"
         return self._do_get_request(url)
-        
+
     def set_userfields(self, entity: str, object_id: int, key: str, value):
         data = {
             key: value


Test patch:
diff --git a/test/test_grocy.py b/test/test_grocy.py
index c6ce810..a9135e8 100644
--- a/test/test_grocy.py
+++ b/test/test_grocy.py
@@ -76,7 +76,7 @@ class TestGrocy(TestCase):
         details_json = json.loads(details_json)
         responses.add(responses.GET, f"{self.base_url}/chores/1", json=details_json, status=200)
         chore_details = self.grocy.chore(1)
-        self.assertIsInstance(chore_details, ChoreDetailsResponse)
+        self.assertIsInstance(chore_details, Chore)
 
     @unittest.skip("no stock_current table in current demo data")
     def test_product_get_details_valid(self):