Repository: SebRut/pygrocy
Pull request #247 (2022-07-24 16:32:21)

Add system endpoints
## Description

- Add the Grocy API system/info, system/time and system/config endpoints.

## Checklist
  - [x] The code change is tested and works locally.
  - [x] tests pass. **Your PR won't be merged unless tests pass**
  - [x] There is no commented out code in this PR



Patch:
diff --git a/pygrocy/data_models/system.py b/pygrocy/data_models/system.py
new file mode 100644
index 0000000..1471b1b
--- /dev/null
+++ b/pygrocy/data_models/system.py
@@ -0,0 +1,116 @@
+from datetime import date, datetime
+from typing import List
+
+from pygrocy.base import DataModel
+from pygrocy.grocy_api_client import SystemConfigDto, SystemInfoDto, SystemTimeDto
+
+
+class SystemInfo(DataModel):
+    def __init__(self, system_info_dto: SystemInfoDto):
+        self._grocy_version = system_info_dto.grocy_version_info.version
+        self._grocy_release_date = system_info_dto.grocy_version_info.release_date
+        self._php_version = system_info_dto.php_version
+        self._sqlite_version = system_info_dto.sqlite_version
+        self._os = system_info_dto.os
+        self._client = system_info_dto.client
+
+    @property
+    def grocy_version(self) -> str:
+        return self._grocy_version
+
+    @property
+    def grocy_release_date(self) -> date:
+        return self._grocy_release_date
+
+    @property
+    def php_version(self) -> str:
+        return self._php_version
+
+    @property
+    def sqlite_version(self) -> str:
+        return self._sqlite_version
+
+    @property
+    def os(self) -> str:
+        return self._os
+
+    @property
+    def client(self) -> str:
+        return self._client
+
+
+class SystemTime(DataModel):
+    def __init__(self, system_time_dto: SystemTimeDto):
+        self._timezone = system_time_dto.timezone
+        self._time_local = system_time_dto.time_local
+        self._time_local_sqlite3 = system_time_dto.time_local_sqlite3
+        self._time_utc = system_time_dto.time_utc
+        self._timestamp = system_time_dto.timestamp
+
+    @property
+    def timezone(self) -> str:
+        return self._timezone
+
+    @property
+    def time_local(self) -> datetime:
+        return self._time_local
+
+    @property
+    def time_local_sqlite3(self) -> datetime:
+        return self._time_local_sqlite3
+
+    @property
+    def time_utc(self) -> datetime:
+        return self._time_utc
+
+    @property
+    def timestamp(self) -> int:
+        return self._timestamp
+
+
+class SystemConfig(DataModel):
+    def __init__(self, system_config_dto: SystemConfigDto):
+        self._username = system_config_dto.username
+        self._base_path = system_config_dto.base_path
+        self._base_url = system_config_dto.base_url
+        self._mode = system_config_dto.mode
+        self._default_locale = system_config_dto.default_locale
+        self._locale = system_config_dto.locale
+        self._currency = system_config_dto.currency
+
+        self._enabled_features = []
+        for feature, value in system_config_dto.feature_flags.items():
+            if bool(value):
+                self._enabled_features.append(feature)
+
+    @property
+    def username(self) -> str:
+        return self._username
+
+    @property
+    def base_path(self) -> str:
+        return self._base_path
+
+    @property
+    def base_url(self) -> str:
+        return self._base_url
+
+    @property
+    def mode(self) -> str:
+        return self._mode
+
+    @property
+    def default_locale(self) -> str:
+        return self._default_locale
+
+    @property
+    def locale(self) -> str:
+        return self._locale
+
+    @property
+    def currency(self) -> str:
+        return self._currency
+
+    @property
+    def enabled_features(self) -> List[str]:
+        return self._enabled_features
diff --git a/pygrocy/grocy.py b/pygrocy/grocy.py
index 9c134a0..b305b51 100644
--- a/pygrocy/grocy.py
+++ b/pygrocy/grocy.py
@@ -10,6 +10,7 @@ from .data_models.chore import Chore
 from .data_models.generic import EntityType
 from .data_models.meal_items import MealPlanItem, MealPlanSection, RecipeItem
 from .data_models.product import Group, Product, ShoppingListProduct
+from .data_models.system import SystemConfig, SystemInfo, SystemTime
 from .data_models.task import Task
 from .data_models.user import User  # noqa: F401
 from .errors import GrocyError  # noqa: F401
@@ -302,6 +303,21 @@ class Grocy(object):
     def get_last_db_changed(self):
         return self._api_client.get_last_db_changed()
 
+    def get_system_info(self) -> SystemInfo:
+        raw_system_info = self._api_client.get_system_info()
+        if raw_system_info:
+            return SystemInfo(raw_system_info)
+
+    def get_system_time(self) -> SystemTime:
+        raw_system_time = self._api_client.get_system_time()
+        if raw_system_time:
+            return SystemTime(raw_system_time)
+
+    def get_system_config(self) -> SystemConfig:
+        raw_system_config = self._api_client.get_system_config()
+        if raw_system_config:
+            return SystemConfig(raw_system_config)
+
     def tasks(self, query_filters: List[str] = None) -> List[Task]:
         raw_tasks = self._api_client.get_tasks(query_filters)
         return [Task(task) for task in raw_tasks]
diff --git a/pygrocy/grocy_api_client.py b/pygrocy/grocy_api_client.py
index d236718..6f140b3 100644
--- a/pygrocy/grocy_api_client.py
+++ b/pygrocy/grocy_api_client.py
@@ -7,7 +7,7 @@ from typing import Any, Dict, List, Optional
 from urllib.parse import urljoin
 
 import requests
-from pydantic import BaseModel, Field, validator
+from pydantic import BaseModel, Extra, Field, root_validator, validator
 from pydantic.schema import date
 
 from pygrocy import EntityType
@@ -265,6 +265,53 @@ class StockLogResponse(BaseModel):
     transaction_type: TransactionType
 
 
+class GrocyVersionDto(BaseModel):
+    version: str = Field(alias="Version")
+    release_date: date = Field(alias="ReleaseDate")
+
+
+class SystemInfoDto(BaseModel):
+    grocy_version_info: GrocyVersionDto = Field(alias="grocy_version")
+    php_version: str
+    sqlite_version: str
+    os: str
+    client: str
+
+
+class SystemTimeDto(BaseModel):
+    timezone: str
+    time_local: datetime
+    time_local_sqlite3: datetime
+    time_utc: datetime
+    timestamp: int
+
+
+class SystemConfigDto(BaseModel, extra=Extra.allow):
+    username: str = Field(alias="USER_USERNAME")
+    base_path: str = Field(alias="BASE_PATH")
+    base_url: str = Field(alias="BASE_URL")
+    mode: str = Field(alias="MODE")
+    default_locale: str = Field(alias="DEFAULT_LOCALE")
+    locale: str = Field(alias="LOCALE")
+    currency: str = Field(alias="CURRENCY")
+    feature_flags: Dict[str, Any]
+
+    @root_validator(pre=True)
+    def feature_flags_root_validator(cls, fields: Dict[str, Any]) -> Dict[str, Any]:
+        """Pydantic root validator to add all "FEATURE_FLAG_" settings to Dict."""
+        features: Dict[str, Any] = {}
+
+        class_defined_fields = cls.__fields__.values()
+
+        for field, value in fields.items():
+            if field.startswith("FEATURE_FLAG_") and field not in class_defined_fields:
+                features[field] = value
+
+        fields["feature_flags"] = features
+
+        return fields
+
+
 def _enable_debug_mode():
     _LOGGER.setLevel(logging.DEBUG)
 
@@ -660,6 +707,21 @@ class GrocyApiClient(object):
         last_change_timestamp = parse_date(resp.get("changed_time"))
         return last_change_timestamp
 
+    def get_system_info(self) -> SystemInfoDto:
+        parsed_json = self._do_get_request("system/info")
+        if parsed_json:
+            return SystemInfoDto(**parsed_json)
+
+    def get_system_time(self) -> SystemTimeDto:
+        parsed_json = self._do_get_request("system/time")
+        if parsed_json:
+            return SystemTimeDto(**parsed_json)
+
+    def get_system_config(self) -> SystemConfigDto:
+        parsed_json = self._do_get_request("system/config")
+        if parsed_json:
+            return SystemConfigDto(**parsed_json)
+
     def get_tasks(self, query_filters: List[str] = None) -> List[TaskResponse]:
         parsed_json = self._do_get_request("tasks", query_filters)
         if parsed_json:


Test patch:
diff --git a/test/cassettes/test_system/TestSystem.test_get_system_config_valid.yaml b/test/cassettes/test_system/TestSystem.test_get_system_config_valid.yaml
new file mode 100644
index 0000000..0645a21
--- /dev/null
+++ b/test/cassettes/test_system/TestSystem.test_get_system_config_valid.yaml
@@ -0,0 +1,44 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.0
+      accept:
+      - application/json
+    method: GET
+    uri: https://localhost/api/system/config
+  response:
+    body:
+      string: '{"MODE":"demo","DEFAULT_LOCALE":"en","CALENDAR_FIRST_DAY_OF_WEEK":"","CALENDAR_SHOW_WEEK_OF_YEAR":true,"MEAL_PLAN_FIRST_DAY_OF_WEEK":"","CURRENCY":"USD","BASE_PATH":"","BASE_URL":"\/","STOCK_BARCODE_LOOKUP_PLUGIN":"DemoBarcodeLookupPlugin","DISABLE_URL_REWRITING":false,"ENTRY_PAGE":"stock","DISABLE_AUTH":false,"AUTH_CLASS":"Grocy\\Middleware\\DefaultAuthMiddleware","REVERSE_PROXY_AUTH_HEADER":"REMOTE_USER","REVERSE_PROXY_AUTH_USE_ENV":false,"LDAP_ADDRESS":"","LDAP_BASE_DN":"","LDAP_BIND_DN":"","LDAP_BIND_PW":"","LDAP_USER_FILTER":"","LDAP_UID_ATTR":"","DEFAULT_PERMISSIONS":["ADMIN"],"GROCYCODE_TYPE":"1D","LABEL_PRINTER_WEBHOOK":"","LABEL_PRINTER_RUN_SERVER":true,"LABEL_PRINTER_PARAMS":{"font_family":"Source
+        Sans Pro (Regular)"},"LABEL_PRINTER_HOOK_JSON":false,"TPRINTER_IS_NETWORK_PRINTER":false,"TPRINTER_PRINT_QUANTITY_NAME":true,"TPRINTER_PRINT_NOTES":true,"TPRINTER_IP":"127.0.0.1","TPRINTER_PORT":9100,"TPRINTER_CONNECTOR":"\/dev\/usb\/lp0","FEATURE_FLAG_STOCK":true,"FEATURE_FLAG_SHOPPINGLIST":true,"FEATURE_FLAG_RECIPES":true,"FEATURE_FLAG_CHORES":true,"FEATURE_FLAG_TASKS":true,"FEATURE_FLAG_BATTERIES":true,"FEATURE_FLAG_EQUIPMENT":true,"FEATURE_FLAG_CALENDAR":true,"FEATURE_FLAG_LABEL_PRINTER":false,"FEATURE_FLAG_STOCK_PRICE_TRACKING":true,"FEATURE_FLAG_STOCK_LOCATION_TRACKING":true,"FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING":true,"FEATURE_FLAG_STOCK_PRODUCT_OPENED_TRACKING":true,"FEATURE_FLAG_STOCK_PRODUCT_FREEZING":true,"FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_FIELD_NUMBER_PAD":true,"FEATURE_FLAG_SHOPPINGLIST_MULTIPLE_LISTS":true,"FEATURE_FLAG_CHORES_ASSIGNMENTS":true,"FEATURE_FLAG_THERMAL_PRINTER":false,"FEATURE_FLAG_DISABLE_BROWSER_BARCODE_CAMERA_SCANNING":false,"FEATURE_FLAG_AUTO_TORCH_ON_WITH_CAMERA":true,"LOCALE":"en","USER_USERNAME":"Demo
+        User","USER_PICTURE_FILE_NAME":null}'
+    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:
+      - Sun, 24 Jul 2022 16:18:25 GMT
+      Server:
+      - nginx/1.22.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/8.0.20
+      content-length:
+      - '1813'
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/test/cassettes/test_system/TestSystem.test_get_system_info_valid.yaml b/test/cassettes/test_system/TestSystem.test_get_system_info_valid.yaml
new file mode 100644
index 0000000..5a9d44c
--- /dev/null
+++ b/test/cassettes/test_system/TestSystem.test_get_system_info_valid.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.0
+      accept:
+      - application/json
+    method: GET
+    uri: https://localhost/api/system/info
+  response:
+    body:
+      string: '{"grocy_version":{"Version":"3.3.1","ReleaseDate":"2022-06-10"},"php_version":"8.0.20","sqlite_version":"3.38.5","os":"Linux
+        5.10.0-15-amd64 #1 SMP Debian 5.10.120-1 (2022-06-09) x86_64","client":"python-requests\/2.28.0"}'
+    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:
+      - Sun, 24 Jul 2022 16:18:25 GMT
+      Server:
+      - nginx/1.22.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/8.0.20
+      content-length:
+      - '222'
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/test/cassettes/test_system/TestSystem.test_get_system_time_valid.yaml b/test/cassettes/test_system/TestSystem.test_get_system_time_valid.yaml
new file mode 100644
index 0000000..989bd97
--- /dev/null
+++ b/test/cassettes/test_system/TestSystem.test_get_system_time_valid.yaml
@@ -0,0 +1,43 @@
+interactions:
+- request:
+    body: null
+    headers:
+      Accept-Encoding:
+      - gzip, deflate
+      Connection:
+      - keep-alive
+      User-Agent:
+      - python-requests/2.28.0
+      accept:
+      - application/json
+    method: GET
+    uri: https://localhost/api/system/time
+  response:
+    body:
+      string: '{"timezone":"UTC","time_local":"2022-07-24 16:18:25","time_local_sqlite3":"2022-07-24
+        16:18:25","time_utc":"2022-07-24 16:18:25","timestamp":1658679505,"offset":0}'
+    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:
+      - Sun, 24 Jul 2022 16:18:25 GMT
+      Server:
+      - nginx/1.22.0
+      Transfer-Encoding:
+      - chunked
+      X-Powered-By:
+      - PHP/8.0.20
+      content-length:
+      - '163'
+    status:
+      code: 200
+      message: OK
+version: 1
diff --git a/test/test_system.py b/test/test_system.py
index 6102f5c..0879c00 100644
--- a/test/test_system.py
+++ b/test/test_system.py
@@ -1,6 +1,7 @@
-from datetime import datetime
+from datetime import date, datetime
 
 import pytest
+from pygrocy.data_models.system import SystemConfig, SystemInfo, SystemTime
 
 
 class TestSystem:
@@ -12,3 +13,37 @@ class TestSystem:
         assert timestamp.year == 2022
         assert timestamp.month == 4
         assert timestamp.day == 22
+
+    @pytest.mark.vcr
+    def test_get_system_info_valid(self, grocy):
+        system_info = grocy.get_system_info()
+
+        assert isinstance(system_info, SystemInfo)
+        assert isinstance(system_info.grocy_release_date, date)
+        assert system_info.grocy_version == "3.3.1"
+        assert system_info.php_version == "8.0.20"
+        assert system_info.sqlite_version == "3.38.5"
+
+    @pytest.mark.vcr
+    def test_get_system_time_valid(self, grocy):
+        system_time = grocy.get_system_time()
+
+        assert isinstance(system_time, SystemTime)
+        assert isinstance(system_time.time_local, datetime)
+        assert isinstance(system_time.time_local_sqlite3, datetime)
+        assert isinstance(system_time.time_utc, datetime)
+
+        assert system_time.timezone == "UTC"
+        assert system_time.timestamp == 1658679505
+
+    @pytest.mark.vcr
+    def test_get_system_config_valid(self, grocy):
+        system_config = grocy.get_system_config()
+
+        assert isinstance(system_config, SystemConfig)
+
+        assert system_config.username == "Demo User"
+        assert system_config.currency == "USD"
+        assert system_config.locale == "en"
+        assert "FEATURE_FLAG_TASKS" in system_config.enabled_features
+        assert "FEATURE_FLAG_THERMAL_PRINTER" not in system_config.enabled_features