Repository: SebRut/pygrocy
Pull request #108 (2020-08-12 04:15:17)

Update coveralls requirement from ~=2.1.1 to ~=2.1.2
Updates the requirements on [coveralls](https://github.com/coveralls-clients/coveralls-python) to permit the latest version.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/coveralls-clients/coveralls-python/releases">coveralls's releases</a>.</em></p>
<blockquote>
<h2>2.1.2</h2>
<p><!-- raw HTML omitted --><!-- raw HTML omitted --></p>
<h2>2.1.2 (2020-08-12)</h2>
<h4>Features</h4>
<ul>
<li><strong>circleci:</strong>  support parallel builds (<a href="https://github-redirect.dependabot.com/coveralls-clients/coveralls-python/issues/233">#233</a>) (<a href="https://github.com/coveralls-clients/coveralls-python/blob/HEAD/5e05654c">https://github.com/coveralls-clients/coveralls-python/blob/HEAD/5e05654c</a>)
Note: this is partially a fix for the <code>--finish</code> command
introduced in v2.1.0, which did not seem to work for some CircleCI
users.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a href="https://github.com/coveralls-clients/coveralls-python/blob/master/CHANGELOG.md">coveralls's changelog</a>.</em></p>
<blockquote>
<h2>2.1.2 (2020-08-12)</h2>
<h4>Features</h4>
<ul>
<li><strong>circleci:</strong>  support parallel builds (<a href="https://github-redirect.dependabot.com/coveralls-clients/coveralls-python/issues/233">#233</a>) (<a href="https://github.com/coveralls-clients/coveralls-python/blob/master/5e05654c">https://github.com/coveralls-clients/coveralls-python/blob/master/5e05654c</a>)
Note: this is partially a fix for the <code>--finish</code> command
introduced in v2.1.0, which did not seem to work for some CircleCI
users.</li>
</ul>
<p><!-- raw HTML omitted --><!-- raw HTML omitted --></p>
<h2>2.1.1 (2020-07-08)</h2>
<h4>Bug Fixes</h4>
<ul>
<li>fix unhashable CoverallsException (<a href="https://github-redirect.dependabot.com/coveralls-clients/coveralls-python/issues/230">#230</a>) (<a href="https://github.com/coveralls-clients/coveralls-python/blob/master/aa55335d">https://github.com/coveralls-clients/coveralls-python/blob/master/aa55335d</a>)
This fixes a regression introduced in v2.1.0 which affected (at least) any
Python 3.5 installations.</li>
</ul>
<p><!-- raw HTML omitted --><!-- raw HTML omitted --></p>
<h2>2.1.0 (2020-07-07)</h2>
<h4>Features</h4>
<ul>
<li><strong>cli</strong>:  add new <code>--finish</code> flag for finalizing parallel builds (<a href="https://github-redirect.dependabot.com/coveralls-clients/coveralls-python/issues/277">#277</a>) (<a href="https://github.com/coveralls-clients/coveralls-python/blob/master/f597109b">https://github.com/coveralls-clients/coveralls-python/blob/master/f597109b</a>)</li>
</ul>
<h4>Bug Fixes</h4>
<ul>
<li><strong>github:</strong>  fix Github Actions support (<a href="https://github-redirect.dependabot.com/coveralls-clients/coveralls-python/issues/227">#227</a>) (<a href="https://github.com/coveralls-clients/coveralls-python/blob/master/f597109b">https://github.com/coveralls-clients/coveralls-python/blob/master/f597109b</a>)</li>
</ul>
<p><!-- raw HTML omitted --><!-- raw HTML omitted --></p>
<h2>2.0.0 (2020-04-07)</h2>
<h4>Compatiblity (BREAKING CHANGES)</h4>
<ul>
<li>We have now dropped support for End-Of-Life'd versions of Python and
particularly old versions of the <code>coverage</code> library; if you are still using
Python v2.7 or v3.4, or you are using <code>coverage&lt;4.1</code>, this library will no
longer be compatible starting from this release -- please pin to
<code>coveralls&lt;2.0.0</code>.</li>
</ul>
<p><!-- raw HTML omitted --><!-- raw HTML omitted --></p>
<h2>1.11.1 (2020-02-15)</h2>
<h4>Bug Fixes</h4>
<ul>
<li><strong>github:</strong>  rename to github-actions (<a href="https://github.com/coveralls-clients/coveralls-python/blob/master/9e65a059">https://github.com/coveralls-clients/coveralls-python/blob/master/9e65a059</a>)
This fixes a regression introduced with v1.11.0, which may have prevented
usage of this library on Github Actions.</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/coveralls-clients/coveralls-python/commit/e31c265e2c9e4231d346d28dba6fc98177b5d2f2"><code>e31c265</code></a> chore(release): bump version</li>
<li><a href="https://github.com/coveralls-clients/coveralls-python/commit/5e05654c4e315b3915b860ecc1540a5fae9cf059"><code>5e05654</code></a> feat(circleci): support parallel builds (<a href="https://github-redirect.dependabot.com/coveralls-clients/coveralls-python/issues/233">#233</a>)</li>
<li><a href="https://github.com/coveralls-clients/coveralls-python/commit/30e4815169b3db2616981939d55d2f4495816821"><code>30e4815</code></a> docs: fix broken link (<a href="https://github-redirect.dependabot.com/coveralls-clients/coveralls-python/issues/231">#231</a>)</li>
<li><a href="https://github.com/coveralls-clients/coveralls-python/commit/2d6bab0010f9ff3bfd96462f9cba0a9d45999c84"><code>2d6bab0</code></a> docs(release): update conda process</li>
<li>See full diff in <a href="https://github.com/coveralls-clients/coveralls-python/compare/2.1.1...2.1.2">compare view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
- `@dependabot use these labels` will set the current labels as the default for future PRs for this repo and language
- `@dependabot use these reviewers` will set the current reviewers as the default for future PRs for this repo and language
- `@dependabot use these assignees` will set the current assignees as the default for future PRs for this repo and language
- `@dependabot use this milestone` will set the current milestone as the default for future PRs for this repo and language
- `@dependabot badge me` will comment on this PR with code to add a "Dependabot enabled" badge to your readme

Additionally, you can set the following in your Dependabot [dashboard](https://app.dependabot.com):
- Update frequency (including time of day and day of week)
- Pull request limits (per update run and/or open at any time)
- Automerge options (never/patch/minor, and dev/runtime dependencies)
- Out-of-range updates (receive only lockfile updates, if desired)
- Security updates (receive only security updates, if desired)



</details>


Patch:
diff --git a/.flake8 b/.flake8
new file mode 100644
index 0000000..e44b810
--- /dev/null
+++ b/.flake8
@@ -0,0 +1,2 @@
+[flake8]
+ignore = E501
diff --git a/.github_changelog_generator b/.github_changelog_generator
new file mode 100644
index 0000000..0fdac50
--- /dev/null
+++ b/.github_changelog_generator
@@ -0,0 +1,1 @@
+since-tag=v0.14.0
\ No newline at end of file
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..c59fd2f
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,9 @@
+-   repo: https://github.com/ambv/black
+    rev: stable
+    hooks:
+    - id: black
+      language_version: python3.8
+-   repo: https://gitlab.com/pycqa/flake8
+    rev: 3.7.9
+    hooks:
+    - id: flake8
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
index 126d9d0..7b822de 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -29,7 +29,7 @@ script:
 
 after_success:
   - coveralls
-  - cd grocy-docker && docker-compose down
+  - cd grocy-docker && docker-compose down && cd ..
 
 deploy:
   - provider: pypi
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..69c608f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,93 @@
+# Changelog
+
+## [v0.20.0](https://github.com/SebRut/pygrocy/tree/v0.20.0) (2020-08-16)
+
+[Full Changelog](https://github.com/SebRut/pygrocy/compare/v0.19.0...v0.20.0)
+
+**Closed issues:**
+
+- Use Product instead of ProductDate in ShoppingListProduct [\#116](https://github.com/SebRut/pygrocy/issues/116)
+
+**Merged pull requests:**
+
+- only localize datetimes not already containing tz info [\#118](https://github.com/SebRut/pygrocy/pull/118) ([SebRut](https://github.com/SebRut))
+- Use Product instead of ProductData [\#117](https://github.com/SebRut/pygrocy/pull/117) ([SebRut](https://github.com/SebRut))
+
+## [v0.19.0](https://github.com/SebRut/pygrocy/tree/v0.19.0) (2020-08-14)
+
+[Full Changelog](https://github.com/SebRut/pygrocy/compare/v0.18.0...v0.19.0)
+
+**Merged pull requests:**
+
+- Add endpoint for adding generic entity [\#115](https://github.com/SebRut/pygrocy/pull/115) ([isabellaalstrom](https://github.com/isabellaalstrom))
+
+## [v0.18.0](https://github.com/SebRut/pygrocy/tree/v0.18.0) (2020-08-14)
+
+[Full Changelog](https://github.com/SebRut/pygrocy/compare/v0.17.0...v0.18.0)
+
+**Merged pull requests:**
+
+- Add details for tasks [\#114](https://github.com/SebRut/pygrocy/pull/114) ([isabellaalstrom](https://github.com/isabellaalstrom))
+
+## [v0.17.0](https://github.com/SebRut/pygrocy/tree/v0.17.0) (2020-08-14)
+
+[Full Changelog](https://github.com/SebRut/pygrocy/compare/v0.16.0...v0.17.0)
+
+**Closed issues:**
+
+- Misnaming in enum [\#112](https://github.com/SebRut/pygrocy/issues/112)
+
+**Merged pull requests:**
+
+- fix issue \#112 [\#113](https://github.com/SebRut/pygrocy/pull/113) ([SebRut](https://github.com/SebRut))
+
+## [v0.16.0](https://github.com/SebRut/pygrocy/tree/v0.16.0) (2020-08-13)
+
+[Full Changelog](https://github.com/SebRut/pygrocy/compare/v0.15.0...v0.16.0)
+
+**Implemented enhancements:**
+
+- implement meal plan and recipes interface [\#90](https://github.com/SebRut/pygrocy/issues/90)
+
+**Merged pull requests:**
+
+- Bugfix/bug smashing [\#111](https://github.com/SebRut/pygrocy/pull/111) ([SebRut](https://github.com/SebRut))
+-  Add meal plan / recipe interfaces. \#90  [\#109](https://github.com/SebRut/pygrocy/pull/109) ([nervetattoo](https://github.com/nervetattoo))
+- Update pdoc3 requirement from ~=0.8.3 to ~=0.8.4 [\#105](https://github.com/SebRut/pygrocy/pull/105) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview))
+- Update coveralls requirement from ~=2.0.0 to ~=2.1.1 [\#104](https://github.com/SebRut/pygrocy/pull/104) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview))
+- Update pdoc3 requirement from ~=0.8.1 to ~=0.8.3 [\#102](https://github.com/SebRut/pygrocy/pull/102) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview))
+- Update responses requirement from ~=0.10.14 to ~=0.10.15 [\#101](https://github.com/SebRut/pygrocy/pull/101) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview))
+
+## [v0.15.0](https://github.com/SebRut/pygrocy/tree/v0.15.0) (2020-05-25)
+
+[Full Changelog](https://github.com/SebRut/pygrocy/compare/v0.14.0...v0.15.0)
+
+**Implemented enhancements:**
+
+- implement task API interface [\#91](https://github.com/SebRut/pygrocy/issues/91)
+- implement chore details API interface [\#87](https://github.com/SebRut/pygrocy/issues/87)
+
+**Fixed bugs:**
+
+- fix failing travis build because of changed docker-grocy setup [\#92](https://github.com/SebRut/pygrocy/issues/92)
+
+**Closed issues:**
+
+- complete abstraction on top of server data classes [\#95](https://github.com/SebRut/pygrocy/issues/95)
+- CurrentStockResponse contains Product data [\#81](https://github.com/SebRut/pygrocy/issues/81)
+
+**Merged pull requests:**
+
+- upgrade used grocy version [\#100](https://github.com/SebRut/pygrocy/pull/100) ([SebRut](https://github.com/SebRut))
+- Feature/91 task api [\#99](https://github.com/SebRut/pygrocy/pull/99) ([SebRut](https://github.com/SebRut))
+- add all fields from chore details api call [\#98](https://github.com/SebRut/pygrocy/pull/98) ([SebRut](https://github.com/SebRut))
+- finish abstraction from api [\#96](https://github.com/SebRut/pygrocy/pull/96) ([SebRut](https://github.com/SebRut))
+- fix travis build [\#93](https://github.com/SebRut/pygrocy/pull/93) ([SebRut](https://github.com/SebRut))
+- Update responses requirement from ~=0.10.12 to ~=0.10.14 [\#89](https://github.com/SebRut/pygrocy/pull/89) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview))
+- Update pdoc3 requirement from ~=0.6.4 to ~=0.8.1 [\#88](https://github.com/SebRut/pygrocy/pull/88) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview))
+- Update coveralls requirement from ~=1.11.1 to ~=2.0.0 [\#86](https://github.com/SebRut/pygrocy/pull/86) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview))
+- Fix \#81 [\#82](https://github.com/SebRut/pygrocy/pull/82) ([BlueBlueBlob](https://github.com/BlueBlueBlob))
+
+
+
+\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
diff --git a/RELEASE_GUIDE.md b/RELEASE_GUIDE.md
index 950f8fc..7b52ee1 100644
--- a/RELEASE_GUIDE.md
+++ b/RELEASE_GUIDE.md
@@ -9,4 +9,5 @@
 
 ## Prepare the new release
 1. Create a new branch using `git flow release start {NEW_VERSION}` (insert new version number)
-2. change the packages version to new version in `setup.py`
\ No newline at end of file
+2. change the packages version to new version in `setup.py`
+3. generate changelog with `github_changelog_generator -u SebRut -p pygrocy --future-release v{{NEW_VERSION}}`
\ No newline at end of file
diff --git a/pygrocy/base.py b/pygrocy/base.py
new file mode 100644
index 0000000..fe714d8
--- /dev/null
+++ b/pygrocy/base.py
@@ -0,0 +1,10 @@
+def get_val(obj):
+    if hasattr(obj, 'as_dict'):
+        as_attr = getattr(obj, 'as_dict')
+        return as_attr()
+    return obj
+
+
+class DataModel(object):
+    def as_dict(self):
+        return {k: get_val(getattr(self, k)) for k, v in self.__class__.__dict__.items() if isinstance(v, property)}
diff --git a/pygrocy/grocy.py b/pygrocy/grocy.py
index a20c557..4543d0b 100644
--- a/pygrocy/grocy.py
+++ b/pygrocy/grocy.py
@@ -1,16 +1,28 @@
+import base64
 from datetime import datetime
 from enum import Enum
 from typing import List, Dict
 
-from .grocy_api_client import (DEFAULT_PORT_NUMBER, ChoreDetailsResponse,
-                               CurrentChoreResponse, CurrentStockResponse,
-                               GrocyApiClient,
-                               LocationData, MissingProductResponse,
-                               ProductDetailsResponse,
-                               ShoppingListItem, TransactionType, UserDto, TaskResponse)
-
-
-class Product(object):
+from .base import DataModel
+from .grocy_api_client import (
+    DEFAULT_PORT_NUMBER,
+    ChoreDetailsResponse,
+    CurrentChoreResponse,
+    CurrentStockResponse,
+    GrocyApiClient,
+    LocationData,
+    MissingProductResponse,
+    ProductDetailsResponse,
+    MealPlanResponse,
+    RecipeDetailsResponse,
+    ShoppingListItem,
+    TransactionType,
+    UserDto,
+    TaskResponse,
+)
+
+
+class Product(DataModel):
     def __init__(self, response):
         if isinstance(response, CurrentStockResponse):
             self._init_from_CurrentStockResponse(response)
@@ -87,7 +99,7 @@ class Product(object):
         return self._is_partly_in_stock
 
 
-class Group(object):
+class Group(DataModel):
     def __init__(self, raw_product_group: LocationData):
         self._id = raw_product_group.id
         self._name = raw_product_group.name
@@ -106,7 +118,7 @@ class Group(object):
         return self._description
 
 
-class ShoppingListProduct(object):
+class ShoppingListProduct(DataModel):
     def __init__(self, raw_shopping_list: ShoppingListItem):
         self._id = raw_shopping_list.id
         self._product_id = raw_shopping_list.product_id
@@ -116,7 +128,7 @@ class ShoppingListProduct(object):
 
     def get_details(self, api_client: GrocyApiClient):
         if self._product_id:
-            self._product = api_client.get_product(self._product_id).product
+            self._product = Product(api_client.get_product(self._product_id))
 
     @property
     def id(self) -> int:
@@ -139,7 +151,7 @@ class ShoppingListProduct(object):
         return self._product
 
 
-class User(object):
+class User(DataModel):
     def __init__(self, user_dto: UserDto):
         self._id = user_dto.id
         self._username = user_dto.username
@@ -168,20 +180,22 @@ class User(object):
         return self._display_name
 
 
-class Chore(object):
-    class PeriodType(Enum):
-        MANUALLY = 'manually'
-        DYNAMIC_REGULAR = 'dynamic-regular'
-        DAILY = 'daily'
-        WEEKLY = 'weekly'
-        MONTHLY = 'monthly'
+class PeriodType(str, Enum):
+    MANUALLY = "manually"
+    DYNAMIC_REGULAR = "dynamic-regular"
+    DAILY = "daily"
+    WEEKLY = "weekly"
+    MONTHLY = "monthly"
+
 
-    class AssignmentType(Enum):
-        NO_ASSIGNMENT = 'no-assignment'
-        WHO_DID_LEAST_DID_FIRST = 'who-did-least-did-first'
-        RANDOM = 'random'
-        IN_ALPHABETICAL_ORDER = 'in-alphabetical-order'
+class AssignmentType(str, Enum):
+    NO_ASSIGNMENT = "no-assignment"
+    WHO_LEAST_DID_FIRST = "who-least-did-first"
+    RANDOM = "random"
+    IN_ALPHABETICAL_ORDER = "in-alphabetical-order"
 
+
+class Chore(DataModel):
     def __init__(self, response):
         if isinstance(response, CurrentChoreResponse):
             self._init_from_CurrentChoreResponse(response)
@@ -204,7 +218,7 @@ class Chore(object):
         self._description = chore_data.description
 
         if chore_data.period_type is not None:
-            self._period_type = Chore.PeriodType(chore_data.period_type)
+            self._period_type = PeriodType(chore_data.period_type)
         else:
             self._period_type = None
 
@@ -214,20 +228,27 @@ class Chore(object):
         self._rollover = chore_data.rollover
 
         if chore_data.assignment_type is not None:
-            self._assignment_type = Chore.AssignmentType(chore_data.assignment_type)
+            self._assignment_type = AssignmentType(chore_data.assignment_type)
         else:
             self._assignment_type = None
 
         self._assignment_config = chore_data.assignment_config
-        self._next_execution_assigned_to_user_id = chore_data.next_execution_assigned_to_user_id
+        self._next_execution_assigned_to_user_id = (
+            chore_data.next_execution_assigned_to_user_id
+        )
         self._userfields = chore_data.userfields
 
         self._last_tracked_time = response.last_tracked
         self._next_estimated_execution_time = response.next_estimated_execution_time
-        self._last_done_by = User(response.last_done_by)
+        if response.last_done_by is not None:
+            self._last_done_by = User(response.last_done_by)
+        else:
+            self._last_done_by = None
         self._track_count = response.track_count
         if response.next_execution_assigned_user is not None:
-            self._next_execution_assigned_user = User(response.next_execution_assigned_user)
+            self._next_execution_assigned_user = User(
+                response.next_execution_assigned_user
+            )
         else:
             self._next_execution_assigned_user = None
 
@@ -278,9 +299,9 @@ class Chore(object):
     @property
     def next_execution_assigned_to_user_id(self) -> int:
         return self._next_execution_assigned_to_user_id
-    
+
     @property
-    def userfields(self) -> Dict[str,str]:
+    def userfields(self) -> Dict[str, str]:
         return self._userfields
 
     @property
@@ -304,8 +325,9 @@ class Chore(object):
         return self._next_execution_assigned_user
 
 
-class Task(object):
+class Task(DataModel):
     def __init__(self, response: TaskResponse):
+
         self._id = response.id
         self._name = response.name
         self._description = response.description
@@ -324,9 +346,118 @@ class Task(object):
     def name(self) -> str:
         return self._name
 
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @property
+    def due_date(self) -> datetime:
+        return self._due_date
+
+    @property
+    def done(self) -> int:
+        return self._done
+
+    @property
+    def done_timestamp(self) -> datetime:
+        return self._done_timestamp
+
+    @property
+    def category_id(self) -> int:
+        return self._category_id
+
+    @property
+    def assigned_to_user_id(self) -> int:
+        return self._assigned_to_user_id
+
+    @property
+    def userfields(self) -> Dict[str, str]:
+        return self._userfields
+
+
+class RecipeItem(DataModel):
+    def __init__(self, response: RecipeDetailsResponse):
+        self._id = response.id
+        self._name = response.name
+        self._description = response.description
+        self._base_servings = response.base_servings
+        self._desired_servings = response.desired_servings
+        self._picture_file_name = response.picture_file_name
+
+    @property
+    def id(self) -> int:
+        return self._id
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @property
+    def base_servings(self) -> int:
+        return self._base_servings
+
+    @property
+    def desired_servings(self) -> int:
+        return self._desired_servings
+
+    @property
+    def picture_file_name(self) -> str:
+        return self._picture_file_name
+
+    def get_picture_url_path(self, width: int = 400):
+        if self.picture_file_name:
+            b64name = base64.b64encode(self.picture_file_name.encode("ascii"))
+            path = "files/recipepictures/" + str(b64name, "utf-8")
+
+            return f"{path}?force_serve_as=picture&best_fit_width={width}"
+
+
+class MealPlanItem(DataModel):
+    def __init__(self, response: MealPlanResponse):
+        self._id = response.id
+        self._day = response.day
+        self._recipe_id = response.recipe_id
+        self._recipe_servings = response.recipe_servings
+        self._note = response.note
+
+    @property
+    def id(self) -> int:
+        return self._id
+
+    @property
+    def day(self) -> datetime:
+        return self._day
+
+    @property
+    def recipe_id(self) -> int:
+        return self._recipe_id
+
+    @property
+    def recipe_servings(self) -> int:
+        return self._recipe_servings
+
+    @property
+    def note(self) -> str:
+        return self._note
+
+    @property
+    def recipe(self) -> RecipeItem:
+        return self._recipe
+
+    def get_details(self, api_client: GrocyApiClient):
+        recipe = api_client.get_recipe(self.recipe_id)
+        if recipe:
+            self._recipe = RecipeItem(recipe)
+
 
 class Grocy(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._api_client = GrocyApiClient(base_url, api_key, port, verify_ssl)
 
     def stock(self) -> List[Product]:
@@ -375,20 +506,40 @@ class Grocy(object):
                 chore.get_details(self._api_client)
         return chores
 
-    def execute_chore(self, chore_id: int, done_by: int = None, tracked_time: datetime = datetime.now()):
+    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) -> 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):
-        return self._api_client.add_product(product_id, amount, price, best_before_date, transaction_type)
-
-    def consume_product(self, product_id: int, amount: float = 1, spoiled: bool = False,
-                        transaction_type: TransactionType = TransactionType.CONSUME):
-        return self._api_client.consume_product(product_id, amount, spoiled, transaction_type)
+    def add_product(
+        self,
+        product_id,
+        amount: float,
+        price: float,
+        best_before_date: datetime = None,
+        transaction_type: TransactionType = TransactionType.PURCHASE,
+    ):
+        return self._api_client.add_product(
+            product_id, amount, price, best_before_date, transaction_type
+        )
+
+    def consume_product(
+        self,
+        product_id: int,
+        amount: float = 1,
+        spoiled: bool = False,
+        transaction_type: TransactionType = TransactionType.CONSUME,
+    ):
+        return self._api_client.consume_product(
+            product_id, amount, spoiled, transaction_type
+        )
 
     def shopping_list(self, get_details: bool = False) -> List[ShoppingListProduct]:
         raw_shoppinglist = self._api_client.get_shopping_list()
@@ -402,14 +553,22 @@ class Grocy(object):
     def add_missing_product_to_shopping_list(self, shopping_list_id: int = 1):
         return self._api_client.add_missing_product_to_shopping_list(shopping_list_id)
 
-    def add_product_to_shopping_list(self, product_id: int, shopping_list_id: int = None, amount: int = None):
-        return self._api_client.add_product_to_shopping_list(product_id, shopping_list_id, amount)
+    def add_product_to_shopping_list(
+        self, product_id: int, shopping_list_id: int = None, amount: int = None
+    ):
+        return self._api_client.add_product_to_shopping_list(
+            product_id, shopping_list_id, amount
+        )
 
     def clear_shopping_list(self, shopping_list_id: int = 1):
         return self._api_client.clear_shopping_list(shopping_list_id)
 
-    def remove_product_in_shopping_list(self, product_id: int, shopping_list_id: int = 1, amount: int = 1):
-        return self._api_client.remove_product_in_shopping_list(product_id, shopping_list_id, amount)
+    def remove_product_in_shopping_list(
+        self, product_id: int, shopping_list_id: int = 1, amount: int = 1
+    ):
+        return self._api_client.remove_product_in_shopping_list(
+            product_id, shopping_list_id, amount
+        )
 
     def product_groups(self) -> List[Group]:
         raw_groups = self._api_client.get_product_groups()
@@ -432,5 +591,22 @@ class Grocy(object):
         raw_tasks = self._api_client.get_tasks()
         return [Task(task) for task in raw_tasks]
 
-    def complete_task(self, task_id):
-        return self._api_client.complete_task(task_id)
+    def complete_task(self, task_id, done_time):
+        return self._api_client.complete_task(task_id, done_time)
+
+    def meal_plan(self, get_details: bool = False) -> List[MealPlanItem]:
+        raw_meal_plan = self._api_client.get_meal_plan()
+        meal_plan = [MealPlanItem(data) for data in raw_meal_plan]
+
+        if get_details:
+            for item in meal_plan:
+                item.get_details(self._api_client)
+        return meal_plan
+
+    def recipe(self, recipe_id: int) -> RecipeItem:
+        recipe = self._api_client.get_recipe(recipe_id)
+        if recipe:
+            return RecipeItem(recipe)
+
+    def add_generic(self, entity_type, data):
+        return self._api_client.add_generic(entity_type, data)
diff --git a/pygrocy/grocy_api_client.py b/pygrocy/grocy_api_client.py
index 537015c..5201489 100644
--- a/pygrocy/grocy_api_client.py
+++ b/pygrocy/grocy_api_client.py
@@ -1,28 +1,27 @@
 import base64
 import json
-import os
 from datetime import datetime
 from enum import Enum
 from typing import List
 from urllib.parse import urljoin
 
-import pytz
 import requests
-from pygrocy.utils import parse_date, parse_float, parse_int
-from tzlocal import get_localzone
+from pygrocy.utils import parse_date, parse_float, parse_int, localize_datetime
 
 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._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'))
+        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._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:
@@ -41,21 +40,100 @@ class ShoppingListItem(object):
         return self._amount
 
 
+class MealPlanResponse(object):
+    def __init__(self, parsed_json):
+        self._id = parse_int(parsed_json.get("id"))
+        self._day = parse_date(parsed_json.get("day"))
+        self._type = parsed_json.get("type")
+        self._recipe_id = parse_int(parsed_json.get("recipe_id"))
+        self._recipe_servings = parse_int(parsed_json.get("recipe_servings"))
+        self._note = parsed_json.get("note", None)
+        self._product_id = parsed_json.get("product_id")
+        self._product_amount = parse_float(parsed_json.get("product_amount"), 0)
+        self._product_qu_id = parsed_json.get("product_qu_id")
+        self._row_created_timestamp = parse_date(
+            parsed_json.get("row_created_timestamp")
+        )
+        self._userfields = parsed_json.get("userfields")
+
+    @property
+    def id(self) -> int:
+        return self._id
+
+    @property
+    def day(self) -> datetime:
+        return self._day
+
+    @property
+    def recipe_id(self) -> int:
+        return self._recipe_id
+
+    @property
+    def recipe_servings(self) -> int:
+        return self._recipe_servings
+
+    @property
+    def note(self) -> str:
+        return self._note
+
+
+class RecipeDetailsResponse(object):
+    def __init__(self, parsed_json):
+        self._id = parse_int(parsed_json.get("id"))
+        self._name = parsed_json.get("name")
+        self._description = parsed_json.get("description")
+        self._base_servings = parse_int(parsed_json.get("base_servings"))
+        self._desired_servings = parse_int(parsed_json.get("desired_servings"))
+        self._picture_file_name = parsed_json.get("picture_file_name")
+        self._row_created_timestamp = parse_date(
+            parsed_json.get("row_created_timestamp")
+        )
+        self._userfields = parsed_json.get("userfields")
+
+    @property
+    def id(self) -> int:
+        return self._id
+
+    @property
+    def name(self) -> str:
+        return self._name
+
+    @property
+    def description(self) -> str:
+        return self._description
+
+    @property
+    def base_servings(self) -> int:
+        return self._base_servings
+
+    @property
+    def desired_servings(self) -> int:
+        return self._desired_servings
+
+    @property
+    def picture_file_name(self) -> str:
+        return self._picture_file_name
+
+
 class QuantityUnitData(object):
     def __init__(self, parsed_json):
-        self._id = parse_int(parsed_json.get('id'))
-        self._name = parsed_json.get('name')
-        self._name_plural = parsed_json.get('name_plural')
-        self._description = parsed_json.get('description')
-        self._row_created_timestamp = parse_date(parsed_json.get('row_created_timestamp'))
+        self._id = parse_int(parsed_json.get("id"))
+        self._name = parsed_json.get("name")
+        self._name_plural = parsed_json.get("name_plural")
+        self._description = parsed_json.get("description")
+        self._row_created_timestamp = parse_date(
+            parsed_json.get("row_created_timestamp")
+        )
 
 
 class LocationData(object):
     def __init__(self, parsed_json):
-        self._id = parse_int(parsed_json.get('id'))
-        self._name = parsed_json.get('name')
-        self._description = parsed_json.get('description')
-        self._row_created_timestamp = parse_date(parsed_json.get('row_created_timestamp'))
+        self._id = parse_int(parsed_json.get("id"))
+        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:
@@ -72,21 +150,29 @@ class LocationData(object):
 
 class ProductData(object):
     def __init__(self, parsed_json):
-        self._id = parse_int(parsed_json.get('id'))
-        self._name = parsed_json.get('name')
-        self._description = parsed_json.get('description', None)
-        self._location_id = parse_int(parsed_json.get('location_id', None))
-        self._product_group_id = parse_int(parsed_json.get('product_group_id', None))
-        self._qu_id_stock = parse_int(parsed_json.get('qu_id_stock', None))
-        self._qu_id_purchase = parse_int(parsed_json.get('qu_id_purchsase', None))
-        self._qu_factor_purchase_to_stock = parse_float(parsed_json.get('qu_factor_purchase_to_stock', None))
-        self._picture_file_name = parsed_json.get('picture_file_name', None)
-        self._allow_partial_units_in_stock = bool(parsed_json.get('allow_partial_units_in_stock', None) == "true")
-        self._row_created_timestamp = parse_date(parsed_json.get('row_created_timestamp', None))
-        self._min_stock_amount = parse_int(parsed_json.get('min_stock_amount', None), 0)
-        self._default_best_before_days = parse_int(parsed_json.get('default_best_before_days', None))
-
-        barcodes_raw = parsed_json.get('barcode', "")
+        self._id = parse_int(parsed_json.get("id"))
+        self._name = parsed_json.get("name")
+        self._description = parsed_json.get("description", None)
+        self._location_id = parse_int(parsed_json.get("location_id", None))
+        self._product_group_id = parse_int(parsed_json.get("product_group_id", None))
+        self._qu_id_stock = parse_int(parsed_json.get("qu_id_stock", None))
+        self._qu_id_purchase = parse_int(parsed_json.get("qu_id_purchsase", None))
+        self._qu_factor_purchase_to_stock = parse_float(
+            parsed_json.get("qu_factor_purchase_to_stock", None)
+        )
+        self._picture_file_name = parsed_json.get("picture_file_name", None)
+        self._allow_partial_units_in_stock = bool(
+            parsed_json.get("allow_partial_units_in_stock", None) == "true"
+        )
+        self._row_created_timestamp = parse_date(
+            parsed_json.get("row_created_timestamp", None)
+        )
+        self._min_stock_amount = parse_int(parsed_json.get("min_stock_amount", None), 0)
+        self._default_best_before_days = parse_int(
+            parsed_json.get("default_best_before_days", None)
+        )
+
+        barcodes_raw = parsed_json.get("barcode", "")
         if barcodes_raw is None:
             self._barcodes = None
         else:
@@ -111,28 +197,30 @@ class ProductData(object):
 
 class ChoreData(object):
     def __init__(self, parsed_json):
-        self.id = parse_int(parsed_json.get('id'))
-        self.name = parsed_json.get('name')
-        self.description = parsed_json.get('description')
-        self.period_type = parsed_json.get('period_type')
-        self.period_config = parsed_json.get('period_config')
-        self.period_days = parse_int(parsed_json.get('period_days'))
-        self.track_date_only = parsed_json.get('track_date_only')
-        self.rollover = parsed_json.get('rollover')
-        self.assignment_type = parsed_json.get('assignment_type')
-        self.assignment_config = parsed_json.get('assignment_config')
-        self.next_execution_assigned_to_user_id = parse_int('next_execution_assigned_to_user_id')
-        self.userfields = parsed_json.get('userfields')
+        self.id = parse_int(parsed_json.get("id"))
+        self.name = parsed_json.get("name")
+        self.description = parsed_json.get("description")
+        self.period_type = parsed_json.get("period_type")
+        self.period_config = parsed_json.get("period_config")
+        self.period_days = parse_int(parsed_json.get("period_days"))
+        self.track_date_only = parsed_json.get("track_date_only")
+        self.rollover = parsed_json.get("rollover")
+        self.assignment_type = parsed_json.get("assignment_type")
+        self.assignment_config = parsed_json.get("assignment_config")
+        self.next_execution_assigned_to_user_id = parse_int(
+            "next_execution_assigned_to_user_id"
+        )
+        self.userfields = parsed_json.get("userfields")
 
 
 class UserDto(object):
     def __init__(self, parsed_json):
-        self._id = parse_int(parsed_json.get('id'))
+        self._id = parse_int(parsed_json.get("id"))
 
-        self._username = parsed_json.get('username')
-        self._first_name = parsed_json.get('first_name')
-        self._last_name = parsed_json.get('last_name')
-        self._display_name = parsed_json.get('display_name')
+        self._username = parsed_json.get("username")
+        self._first_name = parsed_json.get("first_name")
+        self._last_name = parsed_json.get("last_name")
+        self._display_name = parsed_json.get("display_name")
 
     @property
     def id(self) -> int:
@@ -157,9 +245,11 @@ class UserDto(object):
 
 class CurrentChoreResponse(object):
     def __init__(self, parsed_json):
-        self._chore_id = parse_int(parsed_json.get('chore_id'), None)
-        self._last_tracked_time = parse_date(parsed_json.get('last_tracked_time'))
-        self._next_estimated_execution_time = parse_date(parsed_json.get('next_estimated_execution_time'))
+        self._chore_id = parse_int(parsed_json.get("chore_id"), None)
+        self._last_tracked_time = parse_date(parsed_json.get("last_tracked_time"))
+        self._next_estimated_execution_time = parse_date(
+            parsed_json.get("next_estimated_execution_time")
+        )
 
     @property
     def chore_id(self) -> int:
@@ -176,11 +266,11 @@ class CurrentChoreResponse(object):
 
 class CurrentStockResponse(object):
     def __init__(self, parsed_json):
-        self._product_id = parse_int(parsed_json.get('product_id'))
-        self._amount = parse_float(parsed_json.get('amount'))
-        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'))
+        self._product_id = parse_int(parsed_json.get("product_id"))
+        self._amount = parse_float(parsed_json.get("amount"))
+        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:
@@ -205,10 +295,12 @@ class CurrentStockResponse(object):
 
 class MissingProductResponse(object):
     def __init__(self, parsed_json):
-        self._product_id = parse_int(parsed_json.get('id'))
-        self._name = parsed_json.get('name')
-        self._amount_missing = parse_float(parsed_json.get('amount_missing'))
-        self._is_partly_in_stock = bool(parse_int(parsed_json.get('is_partly_in_stock')))
+        self._product_id = parse_int(parsed_json.get("id"))
+        self._name = parsed_json.get("name")
+        self._amount_missing = parse_float(parsed_json.get("amount_missing"))
+        self._is_partly_in_stock = bool(
+            parse_int(parsed_json.get("is_partly_in_stock"))
+        )
 
     @property
     def product_id(self) -> int:
@@ -229,9 +321,18 @@ class MissingProductResponse(object):
 
 class CurrentVolatilStockResponse(object):
     def __init__(self, parsed_json):
-        self._expiring_products = [CurrentStockResponse(product) for product in parsed_json.get('expiring_products')]
-        self._expired_products = [CurrentStockResponse(product) for product in parsed_json.get('expired_products')]
-        self._missing_products = [MissingProductResponse(product) for product in parsed_json.get('missing_products')]
+        self._expiring_products = [
+            CurrentStockResponse(product)
+            for product in parsed_json.get("expiring_products")
+        ]
+        self._expired_products = [
+            CurrentStockResponse(product)
+            for product in parsed_json.get("expired_products")
+        ]
+        self._missing_products = [
+            MissingProductResponse(product)
+            for product in parsed_json.get("missing_products")
+        ]
 
     @property
     def expiring_products(self) -> List[CurrentStockResponse]:
@@ -248,19 +349,25 @@ class CurrentVolatilStockResponse(object):
 
 class ProductDetailsResponse(object):
     def __init__(self, parsed_json):
-        self._last_purchased = parse_date(parsed_json.get('last_purchased'))
-        self._last_used = parse_date(parsed_json.get('last_used'))
-        self._stock_amount = parse_int(parsed_json.get('stock_amount'))
-        self._stock_amount_opened = parse_int(parsed_json.get('stock_amount_opened'))
-        self._next_best_before_date = parse_date(parsed_json.get('next_best_before_date'))
-        self._last_price = parse_float(parsed_json.get('last_price'))
+        self._last_purchased = parse_date(parsed_json.get("last_purchased"))
+        self._last_used = parse_date(parsed_json.get("last_used"))
+        self._stock_amount = parse_int(parsed_json.get("stock_amount"))
+        self._stock_amount_opened = parse_int(parsed_json.get("stock_amount_opened"))
+        self._next_best_before_date = parse_date(
+            parsed_json.get("next_best_before_date")
+        )
+        self._last_price = parse_float(parsed_json.get("last_price"))
 
-        self._product = ProductData(parsed_json.get('product'))
+        self._product = ProductData(parsed_json.get("product"))
 
-        self._quantity_unit_purchase = QuantityUnitData(parsed_json.get('quantity_unit_purchase'))
-        self._quantity_unit_stock = QuantityUnitData(parsed_json.get('quantity_unit_stock'))
+        self._quantity_unit_purchase = QuantityUnitData(
+            parsed_json.get("quantity_unit_purchase")
+        )
+        self._quantity_unit_stock = QuantityUnitData(
+            parsed_json.get("quantity_unit_stock")
+        )
 
-        self._location = LocationData(parsed_json.get('location'))
+        self._location = LocationData(parsed_json.get("location"))
 
     @property
     def last_purchased(self) -> datetime:
@@ -293,12 +400,14 @@ class ProductDetailsResponse(object):
 
 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'))
-        self._track_count = parse_int(parsed_json.get('track_count'))
-
-        next_user = parsed_json.get('next_execution_assigned_user')
+        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")
+        )
+        self._track_count = parse_int(parsed_json.get("track_count"))
+
+        next_user = parsed_json.get("next_execution_assigned_user")
         if next_user is not None:
             self._next_execution_assigned_user = UserDto(next_user)
         else:
@@ -307,7 +416,7 @@ class ChoreDetailsResponse(object):
         if self._last_tracked is None:
             self._last_done_by = None
         else:
-            self._last_done_by = UserDto(parsed_json.get('last_done_by'))
+            self._last_done_by = UserDto(parsed_json.get("last_done_by"))
 
     @property
     def chore(self) -> ChoreData:
@@ -343,34 +452,32 @@ class TransactionType(Enum):
 
 class TaskResponse(object):
     def __init__(self, parsed_json):
-        self.id = parse_int(parsed_json.get('id'))
-        self.name = parsed_json.get('name')
-        self.description = parsed_json.get('description')
-        self.due_date = parse_date(parsed_json.get('due_date'))
-        self.done = parse_int(parsed_json.get('done'))
-        self.done_timestamp = parse_date(parsed_json.get('done_timestamp'))
-        self.category_id = parse_int(parsed_json.get('category_id'))
-        self.assigned_to_user_id = parse_int(parsed_json.get('assigned_to_user_id'))
-        self.userfields = parsed_json.get('userfields')
+        self.id = parse_int(parsed_json.get("id"))
+        self.name = parsed_json.get("name")
+        self.description = parsed_json.get("description")
+        self.due_date = parse_date(parsed_json.get("due_date"))
+        self.done = parse_int(parsed_json.get("done"))
+        self.done_timestamp = parse_date(parsed_json.get("done_timestamp"))
+        self.category_id = parse_int(parsed_json.get("category_id"))
+        self.assigned_to_user_id = parse_int(parsed_json.get("assigned_to_user_id"))
+        self.userfields = parsed_json.get("userfields")
 
 
 class GrocyApiClient(object):
-    def __init__(self, base_url, api_key, port: int = DEFAULT_PORT_NUMBER, verify_ssl=True):
-        self._base_url = '{}:{}/api/'.format(base_url, port)
+    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"}
         else:
-            self._headers = {
-                "accept": "application/json",
-                "GROCY-API-KEY": api_key
-            }
+            self._headers = {"accept": "application/json", "GROCY-API-KEY": api_key}
 
     def _do_get_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)
+        resp = requests.get(req_url, verify=self._verify_ssl, headers=self._headers)
         resp.raise_for_status()
         if len(resp.content) > 0:
             return resp.json()
@@ -378,9 +485,8 @@ class GrocyApiClient(object):
     def _do_post_request(self, end_url: str, data: dict):
         req_url = urljoin(self._base_url, end_url)
         resp = requests.post(
-            req_url, verify=self._verify_ssl,
-            headers=self._headers,
-            data=data)
+            req_url, verify=self._verify_ssl, headers=self._headers, data=data
+        )
         resp.raise_for_status()
         if len(resp.content) > 0:
             return resp.json()
@@ -388,16 +494,15 @@ class GrocyApiClient(object):
     def _do_put_request(self, end_url: str, data):
         req_url = urljoin(self._base_url, end_url)
         up_header = self._headers.copy()
-        up_header['accept'] = '*/*'
+        up_header["accept"] = "*/*"
         if isinstance(data, dict):
-            up_header['Content-Type'] = 'application/json'
+            up_header["Content-Type"] = "application/json"
             data = json.dumps(data)
         else:
-            up_header['Content-Type'] = 'application/octet-stream'
+            up_header["Content-Type"] = "application/octet-stream"
         resp = requests.put(
-            req_url, verify=self._verify_ssl,
-            headers=up_header,
-            data=data)
+            req_url, verify=self._verify_ssl, headers=up_header, data=data
+        )
         resp.raise_for_status()
         if len(resp.content) > 0:
             return resp.json()
@@ -426,40 +531,51 @@ class GrocyApiClient(object):
         if parsed_json:
             return ChoreDetailsResponse(parsed_json)
 
-    def execute_chore(self, chore_id: int, done_by: int = None, tracked_time: datetime = datetime.now()):
-        # Grocy API expects UTC time; time returned from datetime.now() is local time without timezone
-        # information, so timezone information must be attached.
-        local_tz = get_localzone()
-        localized_tracked_time = local_tz.localize(tracked_time)
+    def execute_chore(
+        self,
+        chore_id: int,
+        done_by: int = None,
+        tracked_time: datetime = datetime.now(),
+    ):
+        localized_tracked_time = localize_datetime(tracked_time)
 
-        data = {
-            "tracked_time": localized_tracked_time.isoformat()
-        }
+        data = {"tracked_time": localized_tracked_time.isoformat()}
 
         if done_by is not None:
             data["done_by"] = done_by
 
         return self._do_post_request(f"chores/{chore_id}/execute", data)
 
-    def add_product(self, product_id, amount: float, price: float, best_before_date: datetime = None,
-                    transaction_type: TransactionType = TransactionType.PURCHASE):
+    def add_product(
+        self,
+        product_id,
+        amount: float,
+        price: float,
+        best_before_date: datetime = None,
+        transaction_type: TransactionType = TransactionType.PURCHASE,
+    ):
         data = {
             "amount": amount,
             "transaction_type": transaction_type.value,
-            "price": price
+            "price": price,
         }
 
         if best_before_date is not None:
-            data["best_before_date"] = best_before_date.strftime('%Y-%m-%d')
+            data["best_before_date"] = best_before_date.strftime("%Y-%m-%d")
 
         return self._do_post_request(f"stock/products/{product_id}/add", data)
 
-    def consume_product(self, product_id: int, amount: float = 1, spoiled: bool = False,
-                        transaction_type: TransactionType = TransactionType.CONSUME):
+    def consume_product(
+        self,
+        product_id: int,
+        amount: float = 1,
+        spoiled: bool = False,
+        transaction_type: TransactionType = TransactionType.CONSUME,
+    ):
         data = {
             "amount": amount,
             "spoiled": spoiled,
-            "transaction_type": transaction_type.value
+            "transaction_type": transaction_type.value,
         }
 
         self._do_post_request(f"stock/products/{product_id}/consume", data)
@@ -471,32 +587,32 @@ class GrocyApiClient(object):
     def add_missing_product_to_shopping_list(self, shopping_list_id: int = None):
         data = None
         if shopping_list_id:
-            data = {
-                "list_id": shopping_list_id
-            }
+            data = {"list_id": shopping_list_id}
 
         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):
+    def add_product_to_shopping_list(
+        self, product_id: int, shopping_list_id: int = 1, amount: int = 1
+    ):
         data = {
             "product_id": product_id,
             "list_id": shopping_list_id,
-            "product_amount": amount
+            "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
-        }
+        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):
+    def remove_product_in_shopping_list(
+        self, product_id: int, shopping_list_id: int = 1, amount: int = 1
+    ):
         data = {
             "product_id": product_id,
             "list_id": shopping_list_id,
-            "product_amount": amount
+            "product_amount": amount,
         }
         self._do_post_request("stock/shoppinglist/remove-product", data)
 
@@ -505,9 +621,9 @@ class GrocyApiClient(object):
         return [LocationData(response) for response in parsed_json]
 
     def upload_product_picture(self, product_id: int, pic_path: str):
-        b64fn = base64.b64encode('{}.jpg'.format(product_id).encode('ascii'))
+        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):
@@ -520,20 +636,34 @@ class GrocyApiClient(object):
         return self._do_get_request(url)
 
     def set_userfields(self, entity: str, object_id: int, key: str, value):
-        data = {
-            key: value
-        }
+        data = {key: value}
         self._do_put_request(f"userfields/{entity}/{object_id}", data)
 
     def get_last_db_changed(self):
         resp = self._do_get_request("system/db-changed-time")
-        last_change_timestamp = parse_date(resp.get('changed_time'))
+        last_change_timestamp = parse_date(resp.get("changed_time"))
         return last_change_timestamp
 
     def get_tasks(self) -> List[TaskResponse]:
         parsed_json = self._do_get_request("tasks")
         return [TaskResponse(data) for data in parsed_json]
 
-    def complete_task(self, task_id: int):
+    def complete_task(self, task_id: int, done_time: datetime = datetime.now()):
         url = f"tasks/{task_id}/complete"
-        self._do_post_request(url)
+
+        localized_done_time = localize_datetime(done_time)
+
+        data = {"done_time": localized_done_time.isoformat()}
+        self._do_post_request(url, data)
+
+    def get_meal_plan(self) -> List[MealPlanResponse]:
+        parsed_json = self._do_get_request("objects/meal_plan")
+        return [MealPlanResponse(data) for data in parsed_json]
+
+    def get_recipe(self, object_id: int) -> RecipeDetailsResponse:
+        parsed_json = self._do_get_request(f"objects/recipes/{object_id}")
+        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)
diff --git a/pygrocy/utils.py b/pygrocy/utils.py
index f81826b..02fa408 100644
--- a/pygrocy/utils.py
+++ b/pygrocy/utils.py
@@ -1,4 +1,7 @@
 import iso8601
+import pytz
+from tzlocal import get_localzone
+from datetime import datetime
 
 
 def parse_date(input_value):
@@ -23,3 +26,11 @@ def parse_float(input_value, default_value=None):
         return float(input_value)
     except ValueError:
         return default_value
+
+
+def localize_datetime(timestamp: datetime) -> datetime:
+    if timestamp.tzinfo is not None:
+        return timestamp
+
+    local_tz = get_localzone()
+    return local_tz.localize(timestamp).astimezone(pytz.utc)
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 39c8989..5f365eb 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,4 +1,4 @@
-coveralls~=2.1.1
+coveralls~=2.1.2
 doctr~=1.8.0
 pdoc3~=0.8.4
 responses~=0.10.15
diff --git a/setup.py b/setup.py
index 5a2bbd6..c9712e1 100644
--- a/setup.py
+++ b/setup.py
@@ -5,21 +5,16 @@ with open("README.md", "r") as fh:
 
 setuptools.setup(
     name="pygrocy",
-    version="0.14.0",
+    version="0.20.0",
     author="Sebastian Rutofski",
     author_email="kontakt@sebastian-rutofski.de",
     description="",
     long_description=long_description,
     long_description_content_type="text/markdown",
     url="https://github.com/sebrut/pygrocy",
-    license='MIT',
+    license="MIT",
     packages=setuptools.find_packages(),
-    install_requires=[
-        "requests",
-        "iso8601",
-        "pytz",
-        "tzlocal"
-    ],
+    install_requires=["requests", "iso8601", "pytz", "tzlocal"],
     classifiers=[
         "Programming Language :: Python :: 3",
         "License :: OSI Approved :: MIT License",


Test patch:
diff --git a/test/test_MealPlanResponse.py b/test/test_MealPlanResponse.py
new file mode 100644
index 0000000..0743045
--- /dev/null
+++ b/test/test_MealPlanResponse.py
@@ -0,0 +1,29 @@
+import json
+from datetime import datetime
+from unittest import TestCase
+
+from pygrocy.grocy_api_client import MealPlanResponse
+
+
+class TestMealPlanResponse(TestCase):
+    def test_parse(self):
+        input_json = """{
+            "id": "7",
+            "day": "2020-08-16",
+            "type": "recipe",
+            "recipe_id": "4",
+            "recipe_servings": "3",
+            "note": null,
+            "product_id": null,
+            "product_amount": "0.0",
+            "product_qu_id": null,
+            "row_created_timestamp": "2020-08-12 14:37:06",
+            "userfields": null
+        }"""
+
+        response = MealPlanResponse(json.loads(input_json))
+
+        assert response.id == 7
+        assert response.recipe_id == 4
+        assert response.recipe_servings == 3
+        self.assertIsInstance(response.day, datetime)
diff --git a/test/test_grocy.py b/test/test_grocy.py
index 13fd447..c89676a 100644
--- a/test/test_grocy.py
+++ b/test/test_grocy.py
@@ -8,19 +8,20 @@ import responses
 from requests.exceptions import HTTPError
 
 from pygrocy import Grocy
-from pygrocy.grocy import Chore, Product, Group, ShoppingListProduct
-from pygrocy.grocy_api_client import GrocyApiClient, UserDto, \
-    ProductData
+from pygrocy.grocy import Chore, Product, Group, ShoppingListProduct, AssignmentType
+from pygrocy.grocy_api_client import GrocyApiClient, UserDto, ProductData
 from test.test_const import CONST_BASE_URL, CONST_PORT, CONST_SSL
 
 
 class TestGrocy(TestCase):
-
     def setUp(self):
         self.grocy_regular = Grocy(CONST_BASE_URL, "api_key")
-        self.grocy = Grocy(CONST_BASE_URL, "demo_mode", verify_ssl=CONST_SSL, port=CONST_PORT)
+        self.grocy = Grocy(
+            CONST_BASE_URL, "demo_mode", verify_ssl=CONST_SSL, port=CONST_PORT
+        )
         self.base_url = f"{CONST_BASE_URL}:{CONST_PORT}/api"
-        self.date_test = datetime.strptime("2019-05-04 11:31:04", '%Y-%m-%d %H:%M:%S')
+        self.date_test = datetime.strptime("2019-05-04 11:31:04", "%Y-%m-%d %H:%M:%S")
+        self.add_generic_data = {"name": "This is a task"}
 
     def test_init(self):
         self.assertIsInstance(self.grocy, Grocy)
@@ -31,7 +32,7 @@ class TestGrocy(TestCase):
 
         assert len(tasks) == 6
         assert tasks[0].id == 1
-        assert tasks[0].name == 'Repair the garage door'
+        assert tasks[0].name == "Repair the garage door"
 
     @unittest.skip("no chores_current table in current demo data")
     def test_get_chores_valid(self):
@@ -60,7 +61,7 @@ class TestGrocy(TestCase):
                 "period_config": null,
                 "track_date_only": "0",
                 "rollover": "0",
-                "assignment_type": null,
+                "assignment_type": "who-least-did-first",
                 "assignment_config": null,
                 "next_execution_assigned_to_user_id": null,
                 "consume_product_on_execution": "0",
@@ -68,23 +69,19 @@ class TestGrocy(TestCase):
                 "product_amount": null,
                 "period_interval": "1"
             },
-            "last_tracked": "2020-03-11 00:50:15",
             "tracked_count": 3,
-            "last_done_by": {
-                "id": "1",
-                "username": "Demo User",
-                "first_name": null,
-                "last_name": null,
-                "row_created_timestamp": "2020-03-16 00:50:13",
-                "display_name": "Demo User"
-            },
             "next_estimated_execution_time": "2999-12-31 23:59:59",
             "next_execution_assigned_user": null
         }"""
         details_json = json.loads(details_json)
-        responses.add(responses.GET, f"{self.base_url}/chores/1", json=details_json, status=200)
+        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, Chore)
+        self.assertEqual(
+            chore_details.assignment_type, AssignmentType.WHO_LEAST_DID_FIRST
+        )
 
     @unittest.skip("no stock_current table in current demo data")
     def test_product_get_details_valid(self):
@@ -92,7 +89,9 @@ class TestGrocy(TestCase):
 
         product = stock[0]
 
-        api_client = GrocyApiClient(CONST_BASE_URL, "demo_mode", port=CONST_PORT, verify_ssl=CONST_SSL)
+        api_client = GrocyApiClient(
+            CONST_BASE_URL, "demo_mode", port=CONST_PORT, verify_ssl=CONST_SSL
+        )
         product.get_details(api_client)
 
         self.assertIsInstance(product.name, str)
@@ -143,13 +142,20 @@ class TestGrocy(TestCase):
 
     @responses.activate
     def test_get_shopping_list_invalid_no_data(self):
-        responses.add(responses.GET, f"{self.base_url}/objects/shopping_list", status=400)
+        responses.add(
+            responses.GET, f"{self.base_url}/objects/shopping_list", status=400
+        )
         self.assertRaises(HTTPError, self.grocy.shopping_list)
 
     @responses.activate
     def test_get_shopping_list_invalid_missing_data(self):
         resp = []
-        responses.add(responses.GET, f"{self.base_url}/objects/shopping_list", json=resp, status=200)
+        responses.add(
+            responses.GET,
+            f"{self.base_url}/objects/shopping_list",
+            json=resp,
+            status=200,
+        )
         self.assertEqual(len(self.grocy.shopping_list()), 0)
 
     @unittest.skip("no shopping list existing in current demo data")
@@ -158,7 +164,11 @@ class TestGrocy(TestCase):
 
     @responses.activate
     def test_add_missing_product_to_shopping_list_error(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/shoppinglist/add-missing-products", status=400)
+        responses.add(
+            responses.POST,
+            f"{self.base_url}/stock/shoppinglist/add-missing-products",
+            status=400,
+        )
         self.assertRaises(HTTPError, self.grocy.add_missing_product_to_shopping_list)
 
     @unittest.skip("no shopping list existing in current demo data")
@@ -171,22 +181,34 @@ class TestGrocy(TestCase):
 
     @responses.activate
     def test_clear_shopping_list_valid(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/shoppinglist/clear", status=204)
+        responses.add(
+            responses.POST, f"{self.base_url}/stock/shoppinglist/clear", status=204
+        )
         self.grocy.clear_shopping_list()
 
     @responses.activate
     def test_clear_shopping_list_error(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/shoppinglist/clear", status=400)
+        responses.add(
+            responses.POST, f"{self.base_url}/stock/shoppinglist/clear", status=400
+        )
         self.assertRaises(HTTPError, self.grocy.clear_shopping_list)
 
     @responses.activate
     def test_remove_product_in_shopping_list_valid(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/shoppinglist/remove-product", status=204)
+        responses.add(
+            responses.POST,
+            f"{self.base_url}/stock/shoppinglist/remove-product",
+            status=204,
+        )
         self.grocy.remove_product_in_shopping_list(1)
 
     @responses.activate
     def test_remove_product_in_shopping_list_error(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/shoppinglist/remove-product", status=400)
+        responses.add(
+            responses.POST,
+            f"{self.base_url}/stock/shoppinglist/remove-product",
+            status=400,
+        )
         self.assertRaises(HTTPError, self.grocy.remove_product_in_shopping_list, 1)
 
     @unittest.skip("no userentities table in current demo data")
@@ -204,22 +226,35 @@ class TestGrocy(TestCase):
 
     @responses.activate
     def test_get_product_groups_invalid_no_data(self):
-        responses.add(responses.GET, f"{self.base_url}/objects/product_groups", status=400)
+        responses.add(
+            responses.GET, f"{self.base_url}/objects/product_groups", status=400
+        )
         self.assertRaises(HTTPError, self.grocy.product_groups)
 
     @responses.activate
     def test_get_product_groups_invalid_missing_data(self):
         resp = []
-        responses.add(responses.GET, f"{self.base_url}/objects/product_groups", json=resp, status=200)
+        responses.add(
+            responses.GET,
+            f"{self.base_url}/objects/product_groups",
+            json=resp,
+            status=200,
+        )
         self.assertEqual(len(self.grocy.product_groups()), 0)
 
     @responses.activate
     def test_add_product_pic_valid(self):
         with patch("os.path.exists") as m_exist:
-            with patch("builtins.open", mock_open()) as m_open:
+            with patch("builtins.open", mock_open()):
                 m_exist.return_value = True
-                responses.add(responses.PUT, f"{self.base_url}/files/productpictures/MS5qcGc=", status=204)
-                responses.add(responses.PUT, f"{self.base_url}/objects/products/1", status=204)
+                responses.add(
+                    responses.PUT,
+                    f"{self.base_url}/files/productpictures/MS5qcGc=",
+                    status=204,
+                )
+                responses.add(
+                    responses.PUT, f"{self.base_url}/objects/products/1", status=204
+                )
                 resp = self.grocy.add_product_pic(1, "/somepath/pic.jpg")
                 self.assertIsNone(resp)
 
@@ -227,20 +262,32 @@ class TestGrocy(TestCase):
     def test_add_product_pic_invalid_missing_data(self):
         with patch("os.path.exists") as m_exist:
             m_exist.return_value = False
-            self.assertRaises(FileNotFoundError, self.grocy.add_product_pic, 1, "/somepath/pic.jpg")
+            self.assertRaises(
+                FileNotFoundError, self.grocy.add_product_pic, 1, "/somepath/pic.jpg"
+            )
 
     @responses.activate
     def test_upload_product_picture_error(self):
         with patch("os.path.exists") as m_exist:
-            with patch("builtins.open", mock_open()) as m_open:
+            with patch("builtins.open", mock_open()):
                 m_exist.return_value = True
-                api_client = GrocyApiClient(CONST_BASE_URL, "demo_mode", port=CONST_PORT, verify_ssl=CONST_SSL)
-                responses.add(responses.PUT, f"{self.base_url}/files/productpictures/MS5qcGc=", status=400)
-                self.assertRaises(HTTPError, api_client.upload_product_picture, 1, "/somepath/pic.jpg")
+                api_client = GrocyApiClient(
+                    CONST_BASE_URL, "demo_mode", port=CONST_PORT, verify_ssl=CONST_SSL
+                )
+                responses.add(
+                    responses.PUT,
+                    f"{self.base_url}/files/productpictures/MS5qcGc=",
+                    status=400,
+                )
+                self.assertRaises(
+                    HTTPError, api_client.upload_product_picture, 1, "/somepath/pic.jpg"
+                )
 
     @responses.activate
     def test_update_product_pic_error(self):
-        api_client = GrocyApiClient(CONST_BASE_URL, "demo_mode", port=CONST_PORT, verify_ssl=CONST_SSL)
+        api_client = GrocyApiClient(
+            CONST_BASE_URL, "demo_mode", port=CONST_PORT, verify_ssl=CONST_SSL
+        )
         responses.add(responses.PUT, f"{self.base_url}/objects/products/1", status=400)
         self.assertRaises(HTTPError, api_client.update_product_pic, 1)
 
@@ -256,19 +303,19 @@ class TestGrocy(TestCase):
 
     @responses.activate
     def test_get_expiring_invalid_no_data(self):
-        resp = {
-            "expiring_products": [],
-            "expired_products": [],
-            "missing_products": []
-        }
-        responses.add(responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200)
+        resp = {"expiring_products": [], "expired_products": [], "missing_products": []}
+        responses.add(
+            responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200
+        )
 
         self.grocy.expiring_products(True)
 
     @responses.activate
     def test_get_expiring_invalid_missing_data(self):
         resp = {}
-        responses.add(responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200)
+        responses.add(
+            responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200
+        )
 
     @unittest.skip("no stock_current table in current demo data")
     def test_get_expired_products_valid(self):
@@ -282,19 +329,19 @@ class TestGrocy(TestCase):
 
     @responses.activate
     def test_get_expired_invalid_no_data(self):
-        resp = {
-            "expiring_products": [],
-            "expired_products": [],
-            "missing_products": []
-        }
-        responses.add(responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200)
+        resp = {"expiring_products": [], "expired_products": [], "missing_products": []}
+        responses.add(
+            responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200
+        )
 
         self.grocy.expired_products(True)
 
     @responses.activate
     def test_get_expired_invalid_missing_data(self):
         resp = {}
-        responses.add(responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200)
+        responses.add(
+            responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200
+        )
 
     @unittest.skip("no stock_current table in current demo data")
     def test_get_missing_products_valid(self):
@@ -310,32 +357,31 @@ class TestGrocy(TestCase):
 
     @responses.activate
     def test_get_missing_invalid_no_data(self):
-        resp = {
-            "expiring_products": [],
-            "expired_products": [],
-            "missing_products": []
-        }
-        responses.add(responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200)
+        resp = {"expiring_products": [], "expired_products": [], "missing_products": []}
+        responses.add(
+            responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200
+        )
 
         self.grocy.missing_products(True)
 
     @responses.activate
     def test_get_missing_invalid_missing_data(self):
         resp = {}
-        responses.add(responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200)
+        responses.add(
+            responses.GET, f"{self.base_url}/stock/volatile", json=resp, status=200
+        )
 
     @responses.activate
     def test_get_userfields_valid(self):
-        resp = {
-            "uf1": 0,
-            "uf2": "string"
-        }
+        resp = {"uf1": 0, "uf2": "string"}
 
-        responses.add(responses.GET, f"{self.base_url}/userfields/chores/1", json=resp, status=200)
+        responses.add(
+            responses.GET, f"{self.base_url}/userfields/chores/1", json=resp, status=200
+        )
 
         a_chore_uf = self.grocy.get_userfields("chores", 1)
 
-        self.assertEqual(a_chore_uf['uf1'], 0)
+        self.assertEqual(a_chore_uf["uf1"], 0)
 
     @unittest.skip("no userentities table in current demo data")
     def test_get_userfields_invalid_no_data(self):
@@ -349,7 +395,9 @@ class TestGrocy(TestCase):
     @responses.activate
     def test_set_userfields_error(self):
         responses.add(responses.PUT, f"{self.base_url}/userfields/chores/1", status=400)
-        self.assertRaises(HTTPError, self.grocy.set_userfields, "chores", 1, "auserfield", "value")
+        self.assertRaises(
+            HTTPError, self.grocy.set_userfields, "chores", 1, "auserfield", "value"
+        )
 
     def test_get_last_db_changed_valid(self):
 
@@ -360,28 +408,59 @@ class TestGrocy(TestCase):
     @responses.activate
     def test_get_last_db_changed_invalid_no_data(self):
         resp = {}
-        responses.add(responses.GET, f"{self.base_url}/system/db-changed-time", json=resp, status=200)
+        responses.add(
+            responses.GET,
+            f"{self.base_url}/system/db-changed-time",
+            json=resp,
+            status=200,
+        )
 
         self.assertIsNone(self.grocy.get_last_db_changed())
 
     @responses.activate
     def test_add_product_valid(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/products/1/add", status=200)
+        responses.add(
+            responses.POST, f"{self.base_url}/stock/products/1/add", status=200
+        )
         self.assertIsNone(self.grocy.add_product(1, 1.3, 2.44, self.date_test))
 
     @responses.activate
     def test_add_product_error(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/products/1/add", status=400)
-        self.assertRaises(HTTPError, self.grocy.add_product, 1, 1.3, 2.44, self.date_test)
+        responses.add(
+            responses.POST, f"{self.base_url}/stock/products/1/add", status=400
+        )
+        self.assertRaises(
+            HTTPError, 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(
+            HTTPError, self.grocy.add_generic, "tasks", self.add_generic_data
+        )
 
     @responses.activate
     def test_consume_product_valid(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/products/1/consume", status=200)
+        responses.add(
+            responses.POST, f"{self.base_url}/stock/products/1/consume", status=200
+        )
         self.assertIsNone(self.grocy.consume_product(1, 1.3, self.date_test))
 
     @responses.activate
     def test_consume_product_error(self):
-        responses.add(responses.POST, f"{self.base_url}/stock/products/1/consume", status=400)
+        responses.add(
+            responses.POST, f"{self.base_url}/stock/products/1/consume", status=400
+        )
         self.assertRaises(HTTPError, self.grocy.consume_product, 1, 1.3, self.date_test)
 
     @responses.activate
@@ -393,3 +472,62 @@ class TestGrocy(TestCase):
     def test_execute_chore_error(self):
         responses.add(responses.POST, f"{self.base_url}/chores/1/execute", status=400)
         self.assertRaises(HTTPError, self.grocy.execute_chore, 1, 1, self.date_test)
+
+    @responses.activate
+    def test_get_meal_plan(self):
+        resp_json = json.loads(
+            """[
+              {
+                "id": "1",
+                "day": "2020-08-10",
+                "type": "recipe",
+                "recipe_id": "1",
+                "recipe_servings": "1",
+                "note": null,
+                "product_id": null,
+                "product_amount": "0.0",
+                "product_qu_id": null,
+                "row_created_timestamp": "2020-08-12 19:59:30",
+                "userfields": null
+              }
+          ]"""
+        )
+        responses.add(
+            responses.GET,
+            f"{self.base_url}/objects/meal_plan",
+            json=resp_json,
+            status=200,
+        )
+        meal_plan = self.grocy.meal_plan()
+        self.assertEqual(len(meal_plan), 1)
+        self.assertEqual(meal_plan[0].id, 1)
+        self.assertEqual(meal_plan[0].recipe_id, 1)
+
+    @responses.activate
+    def test_get_recipe(self):
+        resp_json = json.loads(
+            """{
+          "id": "1",
+          "name": "Pizza",
+          "description": "<p>Mix everything</p>",
+          "row_created_timestamp": "2020-08-12 11:37:34",
+          "picture_file_name": "51si0q0wsiq5imo4f8wbIMG_5709.jpeg",
+          "base_servings": "4",
+          "desired_servings": "4",
+          "not_check_shoppinglist": "0",
+          "type": "normal",
+          "product_id": "",
+          "userfields": null
+        }"""
+        )
+        responses.add(
+            responses.GET,
+            f"{self.base_url}/objects/recipes/1",
+            json=resp_json,
+            status=200,
+        )
+        recipe = self.grocy.recipe(1)
+        self.assertEqual(recipe.id, 1)
+        self.assertEqual(recipe.name, "Pizza")
+        self.assertEqual(recipe.base_servings, 4)
+        self.assertIsInstance(recipe.get_picture_url_path(400), str)