From d479b4f6217e686e0a4ffea11c62d69e341806f2 Mon Sep 17 00:00:00 2001 From: Aerex Date: Tue, 25 Jun 2019 00:52:17 -0500 Subject: [PATCH] feat: Added ability to edit multiple products - refactor: Changed templates to use yml extension --- grocy/cli.py | 348 +++++++++++------- grocy/conf.py | 9 +- grocy/entity.py | 53 ++- grocy/models/stock.py | 2 +- grocy/request.py | 15 +- grocy/table.py | 34 ++ grocy/util.py | 31 ++ .../{ingredient_add => ingredient_add.yml} | 0 templates/product_edit.yml | 21 ++ templates/{recipe_add => recipe_add.yml} | 0 templates/{recipe_edit => recipe_edit.yml} | 0 .../{single_product => single_product.yml} | 0 12 files changed, 365 insertions(+), 148 deletions(-) create mode 100644 grocy/util.py rename templates/{ingredient_add => ingredient_add.yml} (100%) create mode 100644 templates/product_edit.yml rename templates/{recipe_add => recipe_add.yml} (100%) rename templates/{recipe_edit => recipe_edit.yml} (100%) rename templates/{single_product => single_product.yml} (100%) diff --git a/grocy/cli.py b/grocy/cli.py index d4fbbcf..7d25dde 100644 --- a/grocy/cli.py +++ b/grocy/cli.py @@ -2,6 +2,7 @@ import click from markdown import markdown from jinja2 import Environment, FileSystemLoader from grocy.conf import Configuration +from grocy.util import Util from grocy.recipe import Recipe from grocy.table import Table from grocy.entity import Entity @@ -20,6 +21,13 @@ CONFIG_DIR = click.get_app_dir(APP_NAME) PROJ_DIR = path.join(path.dirname(path.realpath(__file__))) TEMPLATE_LOADER = Environment(loader=FileSystemLoader('templates'), trim_blocks=True, lstrip_blocks=True) +class GrocyGroup(click.Group): + def parse_args(self, ctx, args): + is_subcommand = True if args[0] in self.commands else False + if is_subcommand: + ctx.forward(self.commands[args[0]], args[1:]) + super(GrocyGroup, self).parse_args(ctx, args) + @click.group() def main(): @@ -65,16 +73,20 @@ def stock(ctx): raise e -@main.group(invoke_without_command=True) +@main.group() +def product(): + pass + + +@product.command() @click.pass_context @click.argument('product_id', required=False) -def product(ctx, product_id): +def view(ctx, product_id): logger = logging.getLogger('cli.product') try: if product_id: stock = Stock() product = stock.get_product(product_id) - # Need to get quantity_unit table = Table(entry=product) click.echo(table.product) else: @@ -83,148 +95,203 @@ def product(ctx, product_id): logger.error(e) raise e -#@product.command('list') -#@click.argument('query', required=False) -#def view(query, product_id): -# logger = logging.getLogger('cli.product.view') +@product.command() +@click.option('--name', '-n', 'name') +@click.argument('product_id', required=False) +def edit(product_id, name): + logger = logging.getLogger('cli.product.edit') + try: + cfg = Configuration() + util = Util(cfg=cfg) + cfg.load() + loaded_template = cfg.templates('product_edit') + entity = Entity(name='products') + if product_id: + product = entity.get(id=product_id) + edited_product = click.edit(loaded_template.render(product)) + edited_product.update() + elif name: + # Convert name args to a single string + string_name_arg = ' '.join(name) if type(name) == list else name + products = entity.find({'name': string_name_arg}) + if products is None: + click.echo('Could not find product') + return + edited_products = click.edit(loaded_template.render(products=products), extension='.yml') + if edited_products is None: + return + + parsed_edited_products = Util.load_yaml(edited_products) + schema = entity.schema + + for index, edited_product in enumerate(parsed_edited_products): + edited_product['id'] = products[index]['id'] + Util.verify_integrity(edited_product, schema) + entity.update(edited_product, id=products[index]['id']) + else: + raise click.BadParameter('Missing PRODUCT_ID or QUERY') + except Exception as e: + logger.error(e) + raise e + + +@product.command() +@click.option('--name', '-n', 'name') +def list(name): + logger = logging.getLogger('cli.product.list') + cfg = Configuration() + cfg.load() + product_entity = Entity(name='products') + qu_entity = Entity(name='quantity_units') + location_entity = Entity(name='locations') + product_group_entity = Entity(name='product_groups') + try: + if name: + # Convert name args to a single string + string_name_arg = ' '.join(name) if type(name) == list else name + products = product_entity.find({'name': string_name_arg}) + else: + products = product_entity.get() + + entries = {} + entries['quantity_units'] = qu_entity.get() + entries['product_groups'] = product_group_entity.get() + entries['locations'] = location_entity.get() + entries['products'] = products + table = Table(entries=entries) + click.echo(table.products) + except Exception as e: + logger.error(e) + raise e + + +#@main.command() +#def shopping(): +# logger = logging.getLogger('cli.shopping') # try: -# if product_id: -# entity = Entity(name='product') -# product = entity.get(id=product_id) -# table = Table(product=product) -# click.echo(table.product) -# else: +# entity = Entity(name='shopping_list') +# shopping_list = entity.get() +# table = Table(shopping_list=shopping_list) +# click.echo(table.shopping_list) # except Exception as e: # logger.error(e) # raise e - - -@main.command() -def shopping(): - logger = logging.getLogger('cli.shopping') - try: - entity = Entity(name='shopping_list') - shopping_list = entity.get() - table = Table(shopping_list=shopping_list) - click.echo(table.shopping_list) - except Exception as e: - logger.error(e) - raise e - - -@main.group() -def ingredient(): - pass - - -@ingredient.command('add') -@click.argument('query') -@click.argument('recipe_id') -@click.option('--amount', '-a', 'amount', default=1, type=int) -@click.option('--group', '-g', type=str, default='') -@click.option('--variable-amount', '--va', 'variable_amount', default=None, type=float) -@click.option('--in-stock', '--is', 'in_stock', default=False) -@click.option('--disable-fulfillment', '--df', 'disable_fulfillment', default=False) -@click.option('--note', '-n', 'note', multiple=True, default='', type=str) -@click.option('--no-edit', '--ne', 'no_edit', default=False) -def add(query, recipe_id, amount, group, variable_amount, in_stock, disable_fulfillment, note, no_edit): - logger = logging.getLogger('cli.ingredient.add') - - try: - loaded_template = TEMPLATE_LOADER.get_template('ingredient_add.yml') - new_ingredient = {} - - entity = Entity(name='recipes') - recipe = entity.get(id=recipe_id) - - if not recipe: - raise click.BadParameter(message='recipe {id} does not exist', param='recipe_id', - param_hint='Use `grocy recipes ls` to get a list of recipes') - - entity = Entity(name='products') - product = entity.findOne(query) - new_ingredient['product_id'] = product['id'] - - new_ingredient['amount'] = amount - new_ingredient['group'] = group - new_ingredient['variable_amount'] = variable_amount - new_ingredient['only_check_single_unit_in_stock'] = "1" if in_stock else "0" - new_ingredient['not_check_stock_fulfillment'] = "1" if disable_fulfillment else "0" - new_ingredient['note'] = note - - if not no_edit: - new_ingredient = click.edit(loaded_template.render(new_ingredient)) - - parsed_new_ingredient = yaml.safe_load(new_ingredient) - entity = Entity(name='recipes_pos') - #entity.create(parsed_new_ingredient) - - except Exception as e: - logger.error(e) - raise e - - -@main.group() -@click.pass_context -def recipe(ctx): - pass - - -@recipe.command('ls') -def ls(): - logger = logging.getLogger('cli.recipe') - try: - entity = Entity(name='recipes') - recipes = entity.get() - recipe = Recipe() - recipes_reqs = recipe.get_requirements() - table = Table(recipes=recipes, recipes_reqs=recipes_reqs) - click.echo(table.recipe) - except Exception as e: - logger.error(e) - raise e - - -@recipe.command('edit') -@click.argument('recipe_id') -def edit(recipe_id): - logger = logging.getLogger('cli.recipe.edit') - try: - if recipe_id: - entity = Entity(name='recipes') - recipe = entity.get(id=recipe_id) - loaded_template = TEMPLATE_LOADER.get_template('recipe_edit.yml') - edited_recipe = click.edit(loaded_template.render(recipe)) - if edited_recipe is not None: - parsed_edited_recipe = yaml.safe_load(edited_recipe) - parsed_edited_recipe['description'] = markdown(parsed_edited_recipe['description']) - recipe.__dict__.update(parsed_edited_recipe) - recipe.update() - except Exception as e: - logger.error(e) - logger.error('Could not edit recipe {}'.format(recipe_id)) - raise e - - -@recipe.command('create') -@click.pass_context -def create(ctx): - logger = logging.getLogger('cli.recipe.create') - - try: - recipe = Entity(name='recipes') - loaded_template = TEMPLATE_LOADER.get_template('recipe_add.yml') - new_recipe = click.edit(loaded_template.render()) - if new_recipe is not None: - parsed_new_recipe = yaml.safe_load(new_recipe) - parsed_new_recipe['description'] = markdown(parsed_new_recipe['description']) - recipe.__dict__.update(parsed_new_recipe) - recipe.create() - except Exception as e: - logger.error(e) - raise e - +# +# +#@main.command() +#@main.group() +#def ingredient(): +# pass +# +# +#@ingredient.command('add') +#@click.argument('query') +#@click.argument('recipe_id') +#@click.option('--amount', '-a', 'amount', default=1, type=int) +#@click.option('--group', '-g', type=str, default='') +#@click.option('--variable-amount', '--va', 'variable_amount', default=None, type=float) +#@click.option('--in-stock', '--is', 'in_stock', default=False) +#@click.option('--disable-fulfillment', '--df', 'disable_fulfillment', default=False) +#@click.option('--note', '-n', 'note', multiple=True, default='', type=str) +#@click.option('--no-edit', '--ne', 'no_edit', default=False) +#def add(query, recipe_id, amount, group, variable_amount, in_stock, disable_fulfillment, note, no_edit): +# logger = logging.getLogger('cli.ingredient.add') +# +# try: +# loaded_template = TEMPLATE_LOADER.get_template('ingredient_add.yml') +# new_ingredient = {} +# +# entity = Entity(name='recipes') +# recipe = entity.get(id=recipe_id) +# +# if not recipe: +# raise click.BadParameter(message='recipe {id} does not exist', param='recipe_id', +# param_hint='Use `grocy recipes ls` to get a list of recipes') +# +# entity = Entity(name='products') +# product = entity.findOne(query) +# new_ingredient['product_id'] = product['id'] +# +# new_ingredient['amount'] = amount +# new_ingredient['group'] = group +# new_ingredient['variable_amount'] = variable_amount +# new_ingredient['only_check_single_unit_in_stock'] = "1" if in_stock else "0" +# new_ingredient['not_check_stock_fulfillment'] = "1" if disable_fulfillment else "0" +# new_ingredient['note'] = note +# +# if not no_edit: +# new_ingredient = click.edit(loaded_template.render(new_ingredient)) +# +# parsed_new_ingredient = yaml.safe_load(new_ingredient) +# entity = Entity(name='recipes_pos') +# #entity.create(parsed_new_ingredient) +# +# except Exception as e: +# logger.error(e) +# raise e +# +# +#@main.group() +#@click.pass_context +#def recipe(ctx): +# pass +# +# +#@recipe.command('ls') +#def ls(): +# logger = logging.getLogger('cli.recipe') +# try: +# entity = Entity(name='recipes') +# recipes = entity.get() +# recipe = Recipe() +# recipes_reqs = recipe.get_requirements() +# table = Table(recipes=recipes, recipes_reqs=recipes_reqs) +# click.echo(table.recipe) +# except Exception as e: +# logger.error(e) +# raise e +# +# +#@recipe.command('edit') +#@click.argument('recipe_id') +#def edit(recipe_id): +# logger = logging.getLogger('cli.recipe.edit') +# try: +# if recipe_id: +# entity = Entity(name='recipes') +# recipe = entity.get(id=recipe_id) +# loaded_template = TEMPLATE_LOADER.get_template('recipe_edit.yml') +# edited_recipe = click.edit(loaded_template.render(recipe)) +# if edited_recipe is not None: +# parsed_edited_recipe = yaml.safe_load(edited_recipe) +# parsed_edited_recipe['description'] = markdown(parsed_edited_recipe['description']) +# recipe.__dict__.update(parsed_edited_recipe) +# recipe.update() +# except Exception as e: +# logger.error(e) +# logger.error('Could not edit recipe {}'.format(recipe_id)) +# raise e +# +# +#@recipe.command('create') +#@click.pass_context +#def create(ctx): +# logger = logging.getLogger('cli.recipe.create') +# +# try: +# recipe = Entity(name='recipes') +# loaded_template = TEMPLATE_LOADER.get_template('recipe_add.yml') +# new_recipe = click.edit(loaded_template.render()) +# if new_recipe is not None: +# parsed_new_recipe = yaml.safe_load(new_recipe) +# parsed_new_recipe['description'] = markdown(parsed_new_recipe['description']) +# recipe.__dict__.update(parsed_new_recipe) +# recipe.create() +# except Exception as e: +# logger.error(e) +# raise e +# #@main.command() #@click.pass_context @@ -243,3 +310,4 @@ def create(ctx): # task = Task(**cfg) # tasks = task.get_list() # click.echo(tasks) +# click.echo(tasks) diff --git a/grocy/conf.py b/grocy/conf.py index 3de3150..44c080d 100644 --- a/grocy/conf.py +++ b/grocy/conf.py @@ -1,4 +1,5 @@ -from os import path, chmod, makedirs +from os import path, chmod, makedirs, pardir +from pathlib import Path from shutil import copy from yaml import safe_load, dump from jinja2 import Environment, FileSystemLoader @@ -10,7 +11,8 @@ class Configuration(object): CONFIG_DIR = path.expanduser('~/.config/grocy') USER_TEMPLATE_DIR = path.expanduser('~/.config/grocy/templates') CONFIG_FILE = CONFIG_DIR + '/config.yml' - PROJ_DIR = path.join(path.dirname(path.realpath(__file__))) + TEMPLATE_EXT = 'yml' + PROJ_DIR = Path(__file__).resolve().parent.parent PROJ_TEMPLATE_DIR = '{}/templates'.format(PROJ_DIR) API_KEY_HEADER = 'GROCY-API-KEY' DEFAULT_CFG = { @@ -98,7 +100,8 @@ class Configuration(object): try: TEMPLATE_LOADER = Environment(loader=FileSystemLoader([self.USER_TEMPLATE_DIR, self.PROJ_TEMPLATE_DIR]), trim_blocks=True, lstrip_blocks=True) - return TEMPLATE_LOADER.get_template(name) + + return TEMPLATE_LOADER.get_template('{}.{}'.format(name, self.TEMPLATE_EXT)) except Exception as e: raise e diff --git a/grocy/entity.py b/grocy/entity.py index 3860b07..97b2ae5 100644 --- a/grocy/entity.py +++ b/grocy/entity.py @@ -1,4 +1,5 @@ from grocy.request import Request +import re from grocy.conf import Configuration import logging @@ -6,6 +7,11 @@ import logging class Entity(object): RESOURCE_URL_TEMPLATE = '{api}/objects/{entity}/{objectId}' COLLECTION_URL_TEMPLATE = '{api}/objects/{entity}' + SCHEMA_URL_TEMPLATE = '{api}/openapi/specification' + SCHEMA_MODEL_MAP = { + 'products': 'Product', + 'stock': 'StockEntry' + } def __init__(self, name, **props): self.conf = Configuration() @@ -27,9 +33,28 @@ class Entity(object): logger.error(e) raise e + def find(self, query): + logger = logging.getLogger('entity.find') + found_entities = [] + try: + entities = self.get() + for entity in entities: + for prop, value in query.items(): + regex = re.compile(r'{}'.format(value)) + if regex.search(entity[prop]): + found_entities.append(entity) + + if len(found_entities) == 0: + return None + + return found_entities + except Exception as e: + logger.error(e) + raise e + def create(self, entity): logger = logging.getLogger('entity.add') - url = self.RESOURCE_URL_TEMPLATE + url = self.COLLECTION_URL_TEMPLATE.format(api=self.conf.api, entity=self.name) request = Request('post', url, entity) try: @@ -37,3 +62,29 @@ class Entity(object): except Exception as e: logger.error(e) raise e + + def update(self, entity, id=None): + if id is None: + raise Exception('id property is required to update entity') + logger = logging.getLogger('entity.update') + url = self.RESOURCE_URL_TEMPLATE.format(api=self.conf.api, entity=self.name, objectId=id) + + request = Request('put', url, resource=entity) + try: + return request.send() + except Exception as e: + logger.error(e) + raise e + + @property + def schema(self): + logger = logging.getLogger('entity.schema') + try: + url = self.SCHEMA_URL_TEMPLATE.format(api=self.conf.api) + request = Request('get', url) + response = request.send() + schema_name = self.SCHEMA_MODEL_MAP[self.name] + return response['components']['schemas'][schema_name] + except Exception as e: + logger.error(e) + raise e diff --git a/grocy/models/stock.py b/grocy/models/stock.py index bf0c12e..ed2c4d2 100644 --- a/grocy/models/stock.py +++ b/grocy/models/stock.py @@ -43,7 +43,7 @@ class Stock(object): product_ids = [entry['product_id'] for entry in get_current_stock] table_entries = [] try: - for index in range(len(product_ids)): + for index in range(0, len(product_ids)): product_id = product_ids[index] path = Stock.GET_PRODUCT_BY_ID.format(product_id) product = self.rest_service.get(path) diff --git a/grocy/request.py b/grocy/request.py index f1944f1..9378409 100644 --- a/grocy/request.py +++ b/grocy/request.py @@ -1,13 +1,16 @@ from grocy.conf import Configuration +import json from requests import request +import requests import logging class Request(object): - def __init__(self, method, url): + def __init__(self, method, url, resource=None): self.conf = Configuration() self.conf.load() self.url = url + self.resource = resource self.method = method self.headers = { 'Content-Type': 'application/json', @@ -18,8 +21,14 @@ class Request(object): def send(self): logger = logging.getLogger('request.send') - r = request(method=self.method, url=self.url, headers=self.headers) + if self.resource: + r = request(method=self.method, url=self.url, headers=self.headers, json=self.resource) + else: + r = request(method=self.method, url=self.url, headers=self.headers) + if r.raise_for_status(): logger.error(r.raise_for_status()) raise r.raise_for_status() - return r.json() + if r.status_code != 204: + return r.json() + diff --git a/grocy/table.py b/grocy/table.py index 1b145b9..c5b18f8 100644 --- a/grocy/table.py +++ b/grocy/table.py @@ -23,6 +23,40 @@ class Table(object): loaded_template = self.conf.templates('single_product') return loaded_template.render(self.entry) + @property + def products(self): + if not self.entries: + raise Exception('Missing entries') + product_groups_map = {product_group['id']: product_group['name'] + for product_group in self.entries['product_groups']} + location_map = {location['id']: location['name'] + for location in self.entries['locations']} + quantity_unit_map = {quantity_unit['id']: quantity_unit['name'] + for quantity_unit in self.entries['quantity_units']} + table_entries = [] + try: + for entry in self.entries['products']: + print('{}'.format(entry)) + table_entry = [] + + product_group_name = '' if entry['product_group_id'] == '' else product_groups_map[entry['product_group_id']] + location_name = location_map[entry['location_id']] + quantity_unit_purchase_name = quantity_unit_map[entry['qu_id_purchase']] + quantity_unit_stock_name = quantity_unit_map[entry['qu_id_stock']] + table_entry.append(entry['id']) + table_entry.append(location_name) + table_entry.append(quantity_unit_purchase_name) + table_entry.append(quantity_unit_stock_name) + table_entry.append(product_group_name) + table_entry.append(entry['mini_stock_amount']) + table_entry.append(entry['qu_factor_purchase_to_stock']) + table_entries.append(table_entry) + table_headers = ['ID', 'Name', 'Location', 'Min Stock Amount', + 'QU Purchase', 'QU Stock', 'QU Factor', 'Product Group'] + return tabulate(table_entries, headers=table_headers) + except Exception as e: + raise e + @property def stock(self): logger = logging.getLogger('table.stock') diff --git a/grocy/util.py b/grocy/util.py new file mode 100644 index 0000000..a3a7884 --- /dev/null +++ b/grocy/util.py @@ -0,0 +1,31 @@ +import yaml +import logging + + +def _yaml_constructor(loader, node): + return node.value + + +class Util(object): + def __init__(self, cfg): + self.cfg = cfg + yaml.SafeLoader.add_constructor("tag:yaml.org,2002:python/unicode", _yaml_constructor) + + + def load_yaml(data): + generator = yaml.safe_load_all(data) + data_list = list(generator) + return data_list + + + def verify_integrity(new_data, schema): + logger = logging.getLogger('util.verify_integrity') + try: + # Verify that updated fields exist + schema_keys = schema['properties'].keys() + for prop in new_data.keys(): + if prop not in schema_keys: + raise Exception('{} is not a valid field'.format(prop)) + except Exception as e: + logger.error(e) + raise e diff --git a/templates/ingredient_add b/templates/ingredient_add.yml similarity index 100% rename from templates/ingredient_add rename to templates/ingredient_add.yml diff --git a/templates/product_edit.yml b/templates/product_edit.yml new file mode 100644 index 0000000..da72bc7 --- /dev/null +++ b/templates/product_edit.yml @@ -0,0 +1,21 @@ +{% for product in products %} +name: {{ product.name }} +description: | + {{ product.description }} +location_id: {{ product.location_id }} +qu_id_purchase: {{ product.qu_id_purchase }} +qu_id_stock: {{ product.qu_id_stock }} +qu_factor_purchase_to_stock: {{ product.qu_factor_purchase_to_stock }} +barcode: {{ product.barcode | default("") }} +min_stock_amount: {{ product.min_stock_amount | default("0") }} +default_best_before_days: {{ product.default_best_before_days | default("0") }} +product_group_id: {{ product.product_group_id }} +default_best_before_days_after_open: {{ product.default_best_before_days_after_open | default("0") }} +allow_partial_units_in_stock: {{ product.allow_partial_units_in_stock | default("0") }} +enable_tare_weight_handling: {{ product.enable_tare_weight_handling | default("0") }} +tare_weight: {{ product.tare_weight | default("0.0") }} +not_check_stock_fulfillment_for_recipes: {{ product.not_check_stock_fulfillment_for_recipes | default("0") }} +{% if loop.nextitem %} +--- +{%endif%} +{%endfor %} diff --git a/templates/recipe_add b/templates/recipe_add.yml similarity index 100% rename from templates/recipe_add rename to templates/recipe_add.yml diff --git a/templates/recipe_edit b/templates/recipe_edit.yml similarity index 100% rename from templates/recipe_edit rename to templates/recipe_edit.yml diff --git a/templates/single_product b/templates/single_product.yml similarity index 100% rename from templates/single_product rename to templates/single_product.yml