From a9a1358b08000b7a0d4105d3e650c740c3a38999 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Sun, 22 Apr 2018 19:47:46 +0200 Subject: [PATCH] Added a plugin system for looking up products against external services by barcode (references #6) --- README.md | 5 ++ config-dist.php | 5 ++ controllers/StockApiController.php | 18 ++++++ data/.gitignore | 1 + data/plugins/.gitignore | 3 + data/plugins/DemoBarcodeLookupPlugin.php | 78 +++++++++++++++++++++++ grocy.openapi.json | 80 ++++++++++++++++++++++++ helpers/BaseBarcodeLookupPlugin.php | 80 ++++++++++++++++++++++++ helpers/extensions.php | 6 ++ routes.php | 1 + services/StockService.php | 40 ++++++++++++ version.txt | 2 +- 12 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 data/plugins/.gitignore create mode 100644 data/plugins/DemoBarcodeLookupPlugin.php create mode 100644 helpers/BaseBarcodeLookupPlugin.php diff --git a/README.md b/README.md index e7f6babd..56d72dca 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ The following shorthands are available: Wherever a button contains a bold highlighted letter, this is a shortcut key. Example: Button "Add as new **p**roduct" can be "pressed" by using the `P` key on your keyboard. +### Barcode lookup via external services +Products can be directly added to the database via looking them up against external services by a barcode. +This is currently only possible through the REST API. +There is no plugin included for any service, see the reference implementation in `data/plugins/DemoBarcodeLookupPlugin.php`. + ### Database migrations Database schema migration is automatically done when visiting the root (`/`) route (click on the logo in the left upper edge). diff --git a/config-dist.php b/config-dist.php index 1f9dd849..40a97c3b 100644 --- a/config-dist.php +++ b/config-dist.php @@ -15,3 +15,8 @@ define('CULTURE', 'en'); # should be just "/" when running directly under the root of a (sub)domain # or for example "https:/example.com/grocy" when using a subdirectory define('BASE_URL', '/'); + +# The plugin to use for external barcode lookups, +# must be the filename without .php extension and must be located in /data/plugins, +# see /data/plugins/DemoBarcodeLookupPlugin.php for an example implementation +define('STOCK_BARCODE_LOOKUP_PLUGIN', 'DemoBarcodeLookupPlugin'); diff --git a/controllers/StockApiController.php b/controllers/StockApiController.php index c8c9b3cb..91d6519c 100644 --- a/controllers/StockApiController.php +++ b/controllers/StockApiController.php @@ -105,4 +105,22 @@ class StockApiController extends BaseApiController $this->StockService->AddMissingProductsToShoppingList(); return $this->VoidApiActionResponse($response); } + + public function ExternalBarcodeLookup(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) + { + try + { + $addFoundProduct = false; + if (isset($request->getQueryParams()['add']) && ($request->getQueryParams()['add'] === 'true' || $request->getQueryParams()['add'] === 1)) + { + $addFoundProduct = true; + } + + return $this->ApiResponse($this->StockService->ExternalBarcodeLookup($args['barcode'], $addFoundProduct)); + } + catch (\Exception $ex) + { + return $this->VoidApiActionResponse($response, false, 400, $ex->getMessage()); + } + } } diff --git a/data/.gitignore b/data/.gitignore index 71e5a288..109996bb 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,3 +1,4 @@ * !.gitignore !viewcache +!plugins diff --git a/data/plugins/.gitignore b/data/plugins/.gitignore new file mode 100644 index 00000000..a1b771e5 --- /dev/null +++ b/data/plugins/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!DemoBarcodeLookupPlugin.php diff --git a/data/plugins/DemoBarcodeLookupPlugin.php b/data/plugins/DemoBarcodeLookupPlugin.php new file mode 100644 index 00000000..91ac26bc --- /dev/null +++ b/data/plugins/DemoBarcodeLookupPlugin.php @@ -0,0 +1,78 @@ +Locations contains all locations + $this->QuantityUnits contains all quantity units + */ + + /* + Useful hints: + + Get a quantity unit by name: + $quantityUnit = FindObjectInArrayByPropertyValue($this->QuantityUnits, 'name', 'Piece'); + + Get a location by name: + $location = FindObjectInArrayByPropertyValue($this->Locations, 'name', 'Fridge'); + */ + + /* + This class must implement the protected abstract function ExecuteLookup($barcode), + which is called with the barcode that needs to be looked up and must return an + associative array of the product model or null, when nothing was found for the barcode. + + The returned array must contain at least these properties: + array( + 'name' => '', + 'location_id' => 1, // A valid id of a location object, check against $this->Locations + 'qu_id_purchase' => 1, // A valid id of quantity unit object, check against $this->QuantityUnits + 'qu_id_stock' => 1, // A valid id of quantity unit object, check against $this->QuantityUnits + 'qu_factor_purchase_to_stock' => 1, // Normally 1 when quantity unit stock and purchase is the same + 'barcode' => $barcode // The barcode of the product, maybe just pass through $barcode or manipulate it if necessary + ) + */ + protected function ExecuteLookup($barcode) + { + if ($barcode === 'x') // Demonstration when nothing is found + { + return null; + } + elseif ($barcode === 'e') // Demonstration when an error occurred + { + throw new \Exception('This is the error message from the plugin...'); + } + else + { + return array( + 'name' => 'LookedUpProduct_' . RandomString(5), + 'location_id' => $this->Locations[0]->id, + 'qu_id_purchase' => $this->QuantityUnits[0]->id, + 'qu_id_stock' => $this->QuantityUnits[0]->id, + 'qu_factor_purchase_to_stock' => 1, + 'barcode' => $barcode + ); + } + } +} diff --git a/grocy.openapi.json b/grocy.openapi.json index 3ba7da56..a0ea5011 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -650,6 +650,57 @@ } } }, + "/stock/external-barcode-lookup/{barcode}": { + "get": { + "description": "Executes an external barcode lookoup via the configured plugin with the given barcode", + "tags": [ + "Stock" + ], + "parameters": [ + { + "in": "path", + "name": "barcode", + "required": true, + "description": "The barcode to lookup up", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "add", + "required": false, + "description": "When true, the product is added to the database on a successful lookup and the new product id is in included in output", + "schema": { + "type": "boolean", + "default": false + } + } + ], + "responses": { + "200": { + "description": "An ExternalBarcodeLookupResponse object or null, when nothing was found for the given barcode", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalBarcodeLookupResponse" + } + } + } + }, + "400": { + "description": "A VoidApiActionResponse object (possible errors are: Plugin error)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorExampleVoidApiActionResponse" + } + } + } + } + } + } + }, "/habits/track-habit-execution/{habitId}": { "get": { "description": "Tracks an execution of the given habit", @@ -992,6 +1043,35 @@ } } }, + "ExternalBarcodeLookupResponse": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "location_id": { + "type": "integer" + }, + "qu_id_purchase": { + "type": "integer" + }, + "qu_id_stock": { + "type": "integer" + }, + "qu_factor_purchase_to_stock": { + "type": "number", + "format": "double" + }, + "barcode": { + "type": "string", + "description": "Can contain multiple barcodes separated by comma" + }, + "id": { + "type": "integer", + "description": "The id of the added product, only included when the producted was added to the database" + } + } + }, "HabitDetailsResponse": { "type": "object", "properties": { diff --git a/helpers/BaseBarcodeLookupPlugin.php b/helpers/BaseBarcodeLookupPlugin.php new file mode 100644 index 00000000..a8a4101b --- /dev/null +++ b/helpers/BaseBarcodeLookupPlugin.php @@ -0,0 +1,80 @@ +Locations = $locations; + $this->QuantityUnits = $quantityUnits; + } + + protected $Locations; + protected $QuantityUnits; + + abstract protected function ExecuteLookup($barcode); + + final public function Lookup($barcode) + { + $pluginOutput = $this->ExecuteLookup($barcode); + + if ($pluginOutput === null) + { + return $pluginOutput; + } + + // Plugin must return an associative array + if (!is_array($pluginOutput)) + { + throw new \Exception('Plugin output must be an associative array'); + } + if (!IsAssociativeArray($pluginOutput)) // $pluginOutput is at least an indexed array here + { + throw new \Exception('Plugin output must be an associative array'); + } + + // Check for minimum needed properties + $minimunNeededProperties = array( + 'name', + 'location_id', + 'qu_id_purchase', + 'qu_id_stock', + 'qu_factor_purchase_to_stock', + 'barcode' + ); + foreach ($minimunNeededProperties as $prop) + { + if (!array_key_exists($prop, $pluginOutput)) + { + throw new \Exception("Plugin output does not provide needed property $prop"); + } + } + + // $pluginOutput contains all needed properties here + + // Check referenced entity ids are valid + $locationId = $pluginOutput['location_id']; + if (FindObjectInArrayByPropertyValue($this->Locations, 'id', $locationId) === null) + { + throw new \Exception("Location $locationId is not a valid location id"); + } + $quIdPurchase = $pluginOutput['qu_id_purchase']; + if (FindObjectInArrayByPropertyValue($this->QuantityUnits, 'id', $quIdPurchase) === null) + { + throw new \Exception("Location $quIdPurchase is not a valid quantity unit id"); + } + $quIdStock = $pluginOutput['qu_id_stock']; + if (FindObjectInArrayByPropertyValue($this->QuantityUnits, 'id', $quIdStock) === null) + { + throw new \Exception("Location $quIdStock is not a valid quantity unit id"); + } + $quFactor = $pluginOutput['qu_factor_purchase_to_stock']; + if (empty($quFactor) || !is_numeric($quFactor)) + { + throw new \Exception('Quantity unit factor is empty or not a number'); + } + + return $pluginOutput; + } +} diff --git a/helpers/extensions.php b/helpers/extensions.php index 411d8079..7e40e3cd 100644 --- a/helpers/extensions.php +++ b/helpers/extensions.php @@ -72,3 +72,9 @@ function RandomString($length, $allowedChars = '0123456789abcdefghijklmnopqrstuv return $randomString; } + +function IsAssociativeArray(array $array) +{ + $keys = array_keys($array); + return array_keys($keys) !== $keys; +} diff --git a/routes.php b/routes.php index b7855ca5..58c7f6ce 100644 --- a/routes.php +++ b/routes.php @@ -70,6 +70,7 @@ $app->group('/api', function() $this->get('/stock/get-product-details/{productId}', 'Grocy\Controllers\StockApiController:ProductDetails'); $this->get('/stock/get-current-stock', 'Grocy\Controllers\StockApiController:CurrentStock'); $this->get('/stock/add-missing-products-to-shoppinglist', 'Grocy\Controllers\StockApiController:AddMissingProductsToShoppingList'); + $this->get('/stock/external-barcode-lookup/{barcode}', 'Grocy\Controllers\StockApiController:ExternalBarcodeLookup'); $this->get('/habits/track-habit-execution/{habitId}', 'Grocy\Controllers\HabitsApiController:TrackHabitExecution'); $this->get('/habits/get-habit-details/{habitId}', 'Grocy\Controllers\HabitsApiController:HabitDetails'); diff --git a/services/StockService.php b/services/StockService.php index 2a697f3b..84b8fe15 100644 --- a/services/StockService.php +++ b/services/StockService.php @@ -208,4 +208,44 @@ class StockService extends BaseService $productRow = $this->Database->products()->where('id = :1', $productId)->fetch(); return $productRow !== null; } + + private function LoadBarcodeLookupPlugin() + { + $pluginName = defined('STOCK_BARCODE_LOOKUP_PLUGIN') ? STOCK_BARCODE_LOOKUP_PLUGIN : ''; + if (empty($pluginName)) + { + throw new \Exception('No barcode lookup plugin defined'); + } + + $path = __DIR__ . "/../data/plugins/$pluginName.php"; + if (file_exists($path)) + { + require_once $path; + return new $pluginName($this->Database->locations()->fetchAll(), $this->Database->quantity_units()->fetchAll()); + } + else + { + throw new \Exception("Plugin $pluginName was not found"); + } + } + + public function ExternalBarcodeLookup($barcode, $addFoundProduct) + { + $plugin = $this->LoadBarcodeLookupPlugin(); + $pluginOutput = $plugin->Lookup($barcode); + + if ($pluginOutput !== null) // Lookup was successful + { + if ($addFoundProduct === true) + { + // Add product to database and include new product id in output + $newRow = $this->Database->products()->createRow($pluginOutput); + $newRow->save(); + + $pluginOutput['id'] = $newRow->id; + } + } + + return $pluginOutput; + } } diff --git a/version.txt b/version.txt index 9ab8337f..8fdcf386 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.1 +1.9.2