Repository: SebRut/pygrocy
Pull request #156 (2021-03-03 13:13:34)

add support for generic objcts
fixes #115 


Patch:
diff --git a/README.md b/README.md
index aa358bd..6defed5 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,7 @@
 [![Development Build Status](https://api.travis-ci.com/SebRut/pygrocy.svg?branch=develop)](https://travis-ci.com/SebRut/pygrocy)
 [![PyPI](https://img.shields.io/pypi/v/pygrocy.svg)](https://pypi.org/project/pygrocy/)
 ![Python Version](https://img.shields.io/badge/python-3.6%20%7C%203.8%20%7C%203.9-blue)
+![Grocy Version](https://img.shields.io/badge/grocy-3.0.1-yellow)
 [![Coverage Status](https://coveralls.io/repos/github/SebRut/pygrocy/badge.svg?branch=master)](https://coveralls.io/github/SebRut/pygrocy?branch=master)
 [![CodeFactor](https://www.codefactor.io/repository/github/sebrut/pygrocy/badge)](https://www.codefactor.io/repository/github/sebrut/pygrocy)
 [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit)
diff --git a/pygrocy/__init__.py b/pygrocy/__init__.py
index 9a787d3..392c4c8 100644
--- a/pygrocy/__init__.py
+++ b/pygrocy/__init__.py
@@ -1,6 +1,7 @@
 """
 The pygrocy module
 """
+from .data_models.generic import EntityType  # noqa: F401
 from .grocy import Grocy  # noqa: F401
 from .grocy_api_client import TransactionType  # noqa: F401
 
diff --git a/pygrocy/data_models/generic.py b/pygrocy/data_models/generic.py
new file mode 100644
index 0000000..d85af48
--- /dev/null
+++ b/pygrocy/data_models/generic.py
@@ -0,0 +1,25 @@
+from enum import Enum
+
+
+class EntityType(str, Enum):
+    PRODUCTS = "products"
+    CHORES = "chores"
+    PRODUCT_BARCODES = "product_barcodes"
+    BATTERIES = "batteries"
+    LOCATIONS = "locations"
+    QUANTITY_UNITS = "quantity_units"
+    QUANTITY_UNIT_CONVERSIONS = "quantity_unit_conversions"
+    SHOPPING_LIST = "shopping_list"
+    SHOPPING_LISTS = "shopping_lists"
+    SHOPPING_LOCATIONS = "shopping_locations"
+    RECIPES = "recipes"
+    RECIPES_POS = "recipes_pos"
+    RECIPES_NESTINGS = "recipes_nestings"
+    TASKS = "tasks"
+    TASK_CATEGORIES = "task_categories"
+    PRODUCT_GROUPS = "product_groups"
+    EQUIPMENT = "equipment"
+    USER_FIELDS = "userfields"
+    USER_ENTITIES = "userentities"
+    USER_OBJECTS = "userobjects"
+    MEAL_PLAN = "meal_plan"
diff --git a/pygrocy/grocy.py b/pygrocy/grocy.py
index cab71af..a2658cf 100644
--- a/pygrocy/grocy.py
+++ b/pygrocy/grocy.py
@@ -6,6 +6,7 @@ import deprecation
 from .base import DataModel  # noqa: F401
 from .data_models.battery import Battery
 from .data_models.chore import Chore
+from .data_models.generic import EntityType
 from .data_models.meal_items import MealPlanItem, RecipeItem
 from .data_models.product import Group, Product, ShoppingListProduct
 from .data_models.task import Task
@@ -192,9 +193,6 @@ class Grocy(object):
         if recipe:
             return RecipeItem(recipe)
 
-    def add_generic(self, entity_type, data):
-        return self._api_client.add_generic(entity_type, data)
-
     def batteries(self) -> List[Battery]:
         raw_batteries = self._api_client.get_batteries()
         return [Battery(bat) for bat in raw_batteries]
@@ -206,3 +204,14 @@ class Grocy(object):
 
     def charge_battery(self, battery_id: int, tracked_time: datetime = datetime.now()):
         return self._api_client.charge_battery(battery_id, tracked_time)
+
+    def add_generic(self, entity_type: EntityType, data):
+        return self._api_client.add_generic(entity_type.value, data)
+
+    def update_generic(self, entity_type: EntityType, object_id: int, updated_data):
+        return self._api_client.update_generic(
+            entity_type.value, object_id, updated_data
+        )
+
+    def delete_generic(self, entity_type: EntityType, object_id: int):
+        return self._api_client.delete_generic(entity_type, object_id)
diff --git a/pygrocy/grocy_api_client.py b/pygrocy/grocy_api_client.py
index 09824d0..2e95dd8 100644
--- a/pygrocy/grocy_api_client.py
+++ b/pygrocy/grocy_api_client.py
@@ -584,6 +584,15 @@ class GrocyApiClient(object):
         if len(resp.content) > 0:
             return resp.json()
 
+    def _do_delete_request(self, end_url: str):
+        req_url = urljoin(self._base_url, end_url)
+        resp = requests.get(req_url, verify=self._verify_ssl, headers=self._headers)
+        if resp.status_code >= 400:
+            raise GrocyError(resp)
+
+        if len(resp.content) > 0:
+            return resp.json()
+
     def get_stock(self) -> List[CurrentStockResponse]:
         parsed_json = self._do_get_request("stock")
         return [CurrentStockResponse(response) for response in parsed_json]
@@ -742,9 +751,6 @@ class GrocyApiClient(object):
         if parsed_json:
             return RecipeDetailsResponse(parsed_json)
 
-    def add_generic(self, entity_type: str, data: object):
-        self._do_post_request(f"objects/{entity_type}", data)
-
     def get_batteries(self) -> List[CurrentBatteryResponse]:
         parsed_json = self._do_get_request(f"batteries")
         if parsed_json:
@@ -760,3 +766,12 @@ class GrocyApiClient(object):
         data = {"tracked_time": localized_tracked_time.isoformat()}
 
         return self._do_post_request(f"batteries/{battery_id}/charge", data)
+
+    def add_generic(self, entity_type: str, data):
+        return self._do_post_request(f"objects/{entity_type}", data)
+
+    def update_generic(self, entity_type: str, object_id: int, data):
+        return self._do_put_request(f"objects/{entity_type}/{object_id}", data)
+
+    def delete_generic(self, entity_type: str, object_id: int):
+        return self._do_delete_request(f"objects/{entity_type}/{object_id}")


Test patch:
diff --git a/test/cassettes/test_generic/TestGeneric.test_delete_generic_error.yaml b/test/cassettes/test_generic/TestGeneric.test_delete_generic_error.yaml
new file mode 100644
index 0000000..c56a6ae
--- /dev/null
+++ b/test/cassettes/test_generic/TestGeneric.test_delete_generic_error.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - application/json
+    method: GET
+    uri: https://localhost/api/objects/tasks/30000
+  response:
+    body:
+      string: !!binary |
+        H4sIAAAAAAAEA6tWSi0qyi+Kz00tLk5MT1WyUvJPykpNLlHIyy9RSMsvzUtRqgUAcMoJfiQAAAA=
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Encoding:
+      - gzip
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 03 Mar 2021 13:12:37 GMT
+      Server:
+      - nginx/1.18.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 404
+      message: Not Found
+version: 1
diff --git a/test/cassettes/test_generic/TestGeneric.test_delete_generic_success.yaml b/test/cassettes/test_generic/TestGeneric.test_delete_generic_success.yaml
new file mode 100644
index 0000000..da02ff5
--- /dev/null
+++ b/test/cassettes/test_generic/TestGeneric.test_delete_generic_success.yaml
@@ -0,0 +1,45 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - application/json
+    method: GET
+    uri: https://localhost/api/objects/tasks/3
+  response:
+    body:
+      string: !!binary |
+        H4sIAAAAAAAEA0WNSwoCMRBEryJZO5CPCOYc7kNI2iGYz5DOICLe3W5HmV11Vb2ul0hRWGHEUVRf
+        gOTV413RGQFDT8tIrQpb15zJWsFFP7ilpVaT1JPSXG2VPfmTbqQCOHxZ/mAgaG796b5jjHjENFeI
+        bjS3IvQt4d3eHi50IILC/dG+aA7qYk/KmjO1mb0lyBG3rfcHvUz3sNEAAAA=
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Encoding:
+      - gzip
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 03 Mar 2021 13:12:37 GMT
+      Server:
+      - nginx/1.18.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/test/cassettes/test_generic/TestGeneric.test_generic_add_invalid.yaml b/test/cassettes/test_generic/TestGeneric.test_generic_add_invalid.yaml
new file mode 100644
index 0000000..781ed43
--- /dev/null
+++ b/test/cassettes/test_generic/TestGeneric.test_generic_add_invalid.yaml
@@ -0,0 +1,45 @@
+interactions:
+- request:
+    body: '{"eman": "Testbattery"}'
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '23'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - application/json
+    method: POST
+    uri: https://localhost/api/objects/batteries
+  response:
+    body:
+      string: '{"error_message":"SQLSTATE[HY000]: General error: 1 table batteries
+        has no column named eman"}'
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 03 Mar 2021 13:07:14 GMT
+      Server:
+      - nginx/1.18.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 400
+      message: Bad Request
+version: 1
diff --git a/test/cassettes/test_generic/TestGeneric.test_generic_add_valid.yaml b/test/cassettes/test_generic/TestGeneric.test_generic_add_valid.yaml
new file mode 100644
index 0000000..87097cb
--- /dev/null
+++ b/test/cassettes/test_generic/TestGeneric.test_generic_add_valid.yaml
@@ -0,0 +1,47 @@
+interactions:
+- request:
+    body: '{"name": "Testbattery"}'
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '23'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - application/json
+    method: POST
+    uri: https://localhost/api/objects/batteries
+  response:
+    body:
+      string: !!binary |
+        H4sIAAAAAAAEA6tWSi5KTSxJTYnPT8pKTS6Jz0xRslIyVaoFAFvm3pQZAAAA
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Encoding:
+      - gzip
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 03 Mar 2021 13:06:48 GMT
+      Server:
+      - nginx/1.18.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/test/cassettes/test_generic/TestGeneric.test_generic_update_invalid_data.yaml b/test/cassettes/test_generic/TestGeneric.test_generic_update_invalid_data.yaml
new file mode 100644
index 0000000..36c6279
--- /dev/null
+++ b/test/cassettes/test_generic/TestGeneric.test_generic_update_invalid_data.yaml
@@ -0,0 +1,45 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '0'
+      Content-Type:
+      - application/octet-stream
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - '*/*'
+    method: PUT
+    uri: https://localhost/api/objects/batteries/1
+  response:
+    body:
+      string: '{"error_message":"Request body could not be parsed (probably invalid
+        JSON format or missing\/wrong Content-Type header)"}'
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 03 Mar 2021 11:40:26 GMT
+      Server:
+      - nginx/1.18.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 400
+      message: Bad Request
+version: 1
diff --git a/test/cassettes/test_generic/TestGeneric.test_generic_update_invalid_id.yaml b/test/cassettes/test_generic/TestGeneric.test_generic_update_invalid_id.yaml
new file mode 100644
index 0000000..8051a70
--- /dev/null
+++ b/test/cassettes/test_generic/TestGeneric.test_generic_update_invalid_id.yaml
@@ -0,0 +1,65 @@
+interactions:
+- request:
+    body: '{"name": "Le new battery"}'
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '26'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - '*/*'
+    method: PUT
+    uri: https://localhost/api/objects/batteries/1000
+  response:
+    body:
+      string: '{"error_message":"Call to a member function update() on null","error_details":{"stack_trace":"#0
+        \/var\/www\/vendor\/slim\/slim\/Slim\/Handlers\/Strategies\/RequestResponse.php(43):
+        Grocy\\Controllers\\GenericEntityApiController->EditObject()\n#1 \/var\/www\/vendor\/slim\/slim\/Slim\/Routing\/Route.php(384):
+        Slim\\Handlers\\Strategies\\RequestResponse->__invoke()\n#2 \/var\/www\/vendor\/slim\/slim\/Slim\/MiddlewareDispatcher.php(81):
+        Slim\\Routing\\Route->handle()\n#3 \/var\/www\/middleware\/JsonMiddleware.php(13):
+        Slim\\MiddlewareDispatcher->handle()\n#4 \/var\/www\/vendor\/slim\/slim\/Slim\/MiddlewareDispatcher.php(209):
+        Grocy\\Middleware\\JsonMiddleware->__invoke()\n#5 \/var\/www\/vendor\/slim\/slim\/Slim\/MiddlewareDispatcher.php(81):
+        class@anonymous->handle()\n#6 \/var\/www\/vendor\/slim\/slim\/Slim\/Routing\/Route.php(341):
+        Slim\\MiddlewareDispatcher->handle()\n#7 \/var\/www\/vendor\/slim\/slim\/Slim\/Routing\/RouteRunner.php(84):
+        Slim\\Routing\\Route->run()\n#8 \/var\/www\/middleware\/AuthMiddleware.php(48):
+        Slim\\Routing\\RouteRunner->handle()\n#9 \/var\/www\/vendor\/slim\/slim\/Slim\/MiddlewareDispatcher.php(313):
+        Grocy\\Middleware\\AuthMiddleware->__invoke()\n#10 \/var\/www\/vendor\/slim\/slim\/Slim\/Middleware\/RoutingMiddleware.php(59):
+        class@anonymous->handle()\n#11 \/var\/www\/vendor\/slim\/slim\/Slim\/MiddlewareDispatcher.php(147):
+        Slim\\Middleware\\RoutingMiddleware->process()\n#12 \/var\/www\/vendor\/slim\/slim\/Slim\/Middleware\/ErrorMiddleware.php(107):
+        class@anonymous->handle()\n#13 \/var\/www\/vendor\/slim\/slim\/Slim\/MiddlewareDispatcher.php(147):
+        Slim\\Middleware\\ErrorMiddleware->process()\n#14 \/var\/www\/middleware\/CorsMiddleware.php(30):
+        class@anonymous->handle()\n#15 \/var\/www\/vendor\/slim\/slim\/Slim\/MiddlewareDispatcher.php(313):
+        Grocy\\Middleware\\CorsMiddleware->__invoke()\n#16 \/var\/www\/vendor\/slim\/slim\/Slim\/MiddlewareDispatcher.php(81):
+        class@anonymous->handle()\n#17 \/var\/www\/vendor\/slim\/slim\/Slim\/App.php(215):
+        Slim\\MiddlewareDispatcher->handle()\n#18 \/var\/www\/vendor\/slim\/slim\/Slim\/App.php(199):
+        Slim\\App->handle()\n#19 \/var\/www\/app.php(94): Slim\\App->run()\n#20 \/var\/www\/public\/index.php(45):
+        require_once(''\/var\/www\/app.ph...'')\n#21 {main}","file":"\/var\/www\/controllers\/GenericEntityApiController.php","line":93}}'
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 03 Mar 2021 11:35:43 GMT
+      Server:
+      - nginx/1.18.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 500
+      message: Internal Server Error
+version: 1
diff --git a/test/cassettes/test_generic/TestGeneric.test_generic_update_valid.yaml b/test/cassettes/test_generic/TestGeneric.test_generic_update_valid.yaml
new file mode 100644
index 0000000..52629d9
--- /dev/null
+++ b/test/cassettes/test_generic/TestGeneric.test_generic_update_valid.yaml
@@ -0,0 +1,42 @@
+interactions:
+- request:
+    body: '{"name": "Le new battery"}'
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      Content-Length:
+      - '26'
+      Content-Type:
+      - application/json
+      User-Agent:
+      - python-requests/2.25.1
+      accept:
+      - '*/*'
+    method: PUT
+    uri: https://localhost/api/objects/batteries/1
+  response:
+    body:
+      string: ''
+    headers:
+      Access-Control-Allow-Headers:
+      - '*'
+      Access-Control-Allow-Methods:
+      - GET, POST, PUT, DELETE, OPTIONS
+      Access-Control-Allow-Origin:
+      - '*'
+      Connection:
+      - keep-alive
+      Content-Type:
+      - application/json
+      Date:
+      - Wed, 03 Mar 2021 11:25:56 GMT
+      Server:
+      - nginx/1.18.0
+      X-Powered-By:
+      - PHP/7.4.14
+    status:
+      code: 204
+      message: No Content
+version: 1
diff --git a/test/test_generic.py b/test/test_generic.py
new file mode 100644
index 0000000..18706ed
--- /dev/null
+++ b/test/test_generic.py
@@ -0,0 +1,60 @@
+import pytest
+
+from pygrocy.data_models.generic import EntityType
+from pygrocy.errors.grocy_error import GrocyError
+
+
+class TestGeneric:
+    @pytest.mark.vcr
+    def test_generic_add_valid(self, grocy):
+        data = {"name": "Testbattery"}
+
+        grocy.add_generic(EntityType.BATTERIES, data)
+
+    @pytest.mark.vcr
+    def test_generic_add_invalid(self, grocy):
+        data = {"eman": "Testbattery"}
+
+        with pytest.raises(GrocyError) as exc_info:
+            grocy.add_generic(EntityType.BATTERIES, data)
+
+        error = exc_info.value
+        assert error.status_code == 400
+
+    @pytest.mark.vcr
+    def test_generic_update_valid(self, grocy):
+        updated_data = {"name": "Le new battery"}
+
+        grocy.update_generic(EntityType.BATTERIES, 1, updated_data)
+
+    @pytest.mark.vcr
+    def test_generic_update_invalid_id(self, grocy):
+        updated_data = {"name": "Le new battery"}
+
+        with pytest.raises(GrocyError) as exc_info:
+            grocy.update_generic(EntityType.BATTERIES, 1000, updated_data)
+
+        error = exc_info.value
+        assert error.status_code == 500
+        assert error.message[:7] == "Call to"
+
+    @pytest.mark.vcr
+    def test_generic_update_invalid_data(self, grocy):
+        with pytest.raises(GrocyError) as exc_info:
+            grocy.update_generic(EntityType.BATTERIES, 1, None)
+
+        error = exc_info.value
+        assert error.status_code == 400
+        assert error.message[:7] == "Request"
+
+    @pytest.mark.vcr
+    def test_delete_generic_success(self, grocy):
+        grocy.delete_generic(EntityType.TASKS, 3)
+
+    @pytest.mark.vcr
+    def test_delete_generic_error(self, grocy):
+        with pytest.raises(GrocyError) as exc_info:
+            grocy.delete_generic(EntityType.TASKS, 30000)
+
+        error = exc_info.value
+        assert error.status_code == 404
diff --git a/test/test_grocy.py b/test/test_grocy.py
index 4696fd7..d1bec24 100644
--- a/test/test_grocy.py
+++ b/test/test_grocy.py
@@ -202,18 +202,6 @@ class TestGrocy(TestCase):
             GrocyError, self.grocy.add_product, 1, 1.3, 2.44, self.date_test
         )
 
-    @responses.activate
-    def test_add_generic_valid(self):
-        responses.add(responses.POST, f"{self.base_url}/objects/tasks", status=200)
-        self.assertIsNone(self.grocy.add_generic("tasks", self.add_generic_data))
-
-    @responses.activate
-    def test_add_generic_error(self):
-        responses.add(responses.POST, f"{self.base_url}/objects/tasks", status=400)
-        self.assertRaises(
-            GrocyError, self.grocy.add_generic, "tasks", self.add_generic_data
-        )
-
     @responses.activate
     def test_consume_product_valid(self):
         responses.add(