From 0a1ecddf3d86ab09e3cebc2f3e95fd99f77551b7 Mon Sep 17 00:00:00 2001 From: Aerex Date: Sun, 16 Jun 2019 23:54:10 -0500 Subject: [PATCH] feat: Added recipe list command --- grocy/__init__.py | 8 +- grocy/cli.py | 187 ++++++++++++++++++++--------------- grocy/conf.py | 85 ++++++++++++++++ grocy/entity.py | 39 ++++++++ grocy/models.py | 23 +++++ grocy/models/product.py | 11 --- grocy/models/recipe.py | 12 +++ grocy/models/schema.py | 2 +- grocy/recipe.py | 22 +++++ grocy/request.py | 25 +++++ grocy/stock.py | 22 +++++ grocy/table.py | 120 ++++++++++++++++++++++ setup.cfg | 4 + templates/ingredient_add.yml | 8 ++ 14 files changed, 472 insertions(+), 96 deletions(-) create mode 100644 grocy/conf.py create mode 100644 grocy/entity.py create mode 100644 grocy/models.py create mode 100644 grocy/recipe.py create mode 100644 grocy/request.py create mode 100644 grocy/stock.py create mode 100644 grocy/table.py create mode 100644 setup.cfg create mode 100644 templates/ingredient_add.yml diff --git a/grocy/__init__.py b/grocy/__init__.py index cd4897f..122e33b 100644 --- a/grocy/__init__.py +++ b/grocy/__init__.py @@ -76,10 +76,6 @@ class RestService(object): r = requests.post(url, json=json.dumps(json_payload), headers=self.headers) - #if r.raise_for_status(): - # logger.error(r.raise_for_status()) - # raise r.raise_for_status() - if self.json: return r.json() @@ -87,6 +83,6 @@ class RestService(object): def addHeader(self, type, value): self.headers[type] = value - + def addToken(self, value): - self.headers[RestService.API_KEY_HEADER] = value; + self.headers[RestService.API_KEY_HEADER] = value diff --git a/grocy/cli.py b/grocy/cli.py index f0790ef..f941c01 100644 --- a/grocy/cli.py +++ b/grocy/cli.py @@ -1,20 +1,16 @@ import click from markdown import markdown -from dataclasses import asdict, replace from jinja2 import Environment, FileSystemLoader -from uuid import uuid4 -from grocy import RestService -from grocy.models import (Stock, -Battery, Shopping, Recipe) -from pkg_resources import iter_entry_points +from grocy.conf import Configuration +from grocy.recipe import Recipe +from grocy.table import Table +from grocy.entity import Entity +from grocy.stock import Stock import yaml from sys import exit -from shutil import copy -from os import path, chmod, makedirs -from taskw import TaskWarriorShellout +from os import path import logging -logger = logging.getLogger(__name__) APP_NAME = 'grocy-cli' SAMPLE_CONFIG_FILE = 'sample.config.yml' @@ -22,103 +18,136 @@ TMP_DIR = '/tmp/grocy' CONFIG_FILE = 'config.yml' 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) +TEMPLATE_LOADER = Environment(loader=FileSystemLoader('templates'), trim_blocks=True, lstrip_blocks=True) + def __validate_token(cfg): # Validate token if hasattr(cfg, 'token') or cfg['token'] is None: click.echo('No token was found. Please add your token to the config file', err=True) - logger.error('No token was found. Please add your token') + #logger.error('No token was found. Please add your token') exit(1) -def __create_config_file(): - user_cfg_file = path.join(CONFIG_DIR, CONFIG_FILE) - sample_cfg_file = path.join(PROJ_DIR, '..', SAMPLE_CONFIG_FILE) - if not path.exists(CONFIG_DIR): - click.echo('Config {} director does not exist, create...'.format(CONFIG_DIR)) - makedirs(CONFIG_DIR) - - copy(sample_cfg_file, user_cfg_file) - click.echo('Copying sample config to {}'.format(sample_cfg_file, user_cfg_file)) - chmod(user_cfg_file, 0o664) - return user_cfg_file @click.group() -@click.pass_context -def main(ctx): - cfg = get_config_file() - # Get logger - if 'logger' in cfg: - log_cfg = cfg['logger'] - else: - log_cfg = path.join(click.get_app_dir(APP_NAME), 'grocy.log') - log_level = 'DEBUG' if 'level' not in log_cfg else log_cfg['level'] - log_filename = 'log' if 'file_location' not in log_cfg else log_cfg['file_location'] - logging.basicConfig(level=log_level, filename=log_filename) - cfg['logger'] = logger - - __validate_token(cfg) - ctx.ensure_object(dict) - ctx.obj['cfg'] = cfg - - -def get_config_file(): - no_config_msg = 'A config file was not found' - no_config_msg+= ' and will be created under {}. Is that Ok?' - no_config_msg = no_config_msg.format(click.get_app_dir(APP_NAME)) - cfg_file = path.join(click.get_app_dir(APP_NAME), 'config.yml') - if not path.exists(cfg_file): +def main(): + cfg = Configuration() + if not cfg.exists: + no_config_msg = 'A config file was not found. A sample configuration file' + no_config_msg += ' will be created under {}. Is that Ok?'.format(cfg.CONFIG_DIR) create_config_app_dir = click.confirm(no_config_msg) + user_cfg_options = {} if create_config_app_dir: - cfg_file = __create_config_file() + user_cfg_options['logger_level'] = click.prompt('Enter logger level', + default='DEBUG') + user_cfg_options['logger_file_location'] = click.prompt('Enter location for logger', + default=path.expanduser('~/.config/grocy/log')) + user_cfg_options['api'] = click.prompt('Enter the grocy api url', + default='https://demo-en.grocy.info/api') + user_cfg_options['token'] = click.prompt('Enter the grocy token ', + default='') + user_cfg_options['col_format'] = click.prompt('Enter the col position for rendering tables', + default='col') + user_cfg_options['table_format'] = click.prompt('Enter the table format', + default='simple') + cfg.create(user_cfg_options) + else: exit(0) - fd = open(cfg_file) - parse_cfg_file = yaml.safe_load(fd) + else: + cfg.load() - return parse_cfg_file + logging.basicConfig(level=cfg.logger_level, filename=cfg.logger_file_location) @main.command() -@click.pass_context +@click.pass_context def stock(ctx): - cfg = ctx.obj['cfg'] + logger = logging.getLogger('cli.stock') + if ctx.invoked_subcommand is None: + try: + entity = Stock() + stocks = entity.get() + table = Table(stocks=stocks) + click.echo(table.stock) + except Exception as e: + logger.error(e) + raise e - stock = Stock(**cfg) - stock_entries = stock.get_entries() - click.echo(stock_entries) @main.command() -@click.pass_context -def shopping(ctx): - cfg = ctx.obj['cfg'] +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(ctx): + pass + + +@ingredient.command('add') +@click.option('-r', '--recipe_id') +def add(ctx, recipe_id): + logger = logging.getLogger('cli.ingredient.add') + + try: + loaded_template = TEMPLATE_LOADER.get_template('ingredient_add.yml') + + if recipe_id: + entity = Entity(name='recipes') + recipe = entity.get(id=recipe_id) + new_ingredient = click.edit(loaded_template.render(recipe)) + parsed_new_ingredient = yaml.safe_load(new_ingredient) + else: + new_ingredient = click.edit(loaded_template.render()) + parsed_new_ingredient = yaml.safe_load(new_ingredient) + if parsed_new_ingredient['recipe_id']: + raise Exception('Recipe id is not defined') + + if new_ingredient: + entity = Entity(name='ingredients', **parsed_new_ingredient) + entity.create() + except Exception as e: + logger.error(e) - shopping = Shopping(**cfg) - shopping_list = shopping.get_list() - click.echo(shopping_list) @main.group() @click.pass_context def recipe(ctx): - if ctx.invoked_subcommand is None: - cfg = ctx.obj['cfg'] + pass - receipe = Recipe(id=None, **cfg) - recipes = receipe.get_list() - click.echo(recipes) + +@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') -@click.pass_context -def edit(ctx, recipe_id): - cfg = ctx.obj['cfg'] - logger = cfg['logger'] - +def edit(recipe_id): + logger = logging.getLogger('cli.recipe.edit') try: if recipe_id: - recipe = Recipe(id=recipe_id, **cfg) - recipe.get(include_products=True) + 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.toJSON())) + 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']) @@ -127,16 +156,16 @@ def edit(ctx, recipe_id): 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): - cfg = ctx.obj['cfg'] - logger = cfg['logger'] + logger = logging.getLogger('cli.recipe.create') try: - recipe = Recipe(**cfg) + 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: @@ -146,6 +175,8 @@ def create(ctx): recipe.create() except Exception as e: logger.error(e) + raise e + #@main.command() #@click.pass_context diff --git a/grocy/conf.py b/grocy/conf.py new file mode 100644 index 0000000..8d606fa --- /dev/null +++ b/grocy/conf.py @@ -0,0 +1,85 @@ +from os import path, chmod, makedirs +from yaml import safe_load, dump +from copy import deepcopy + + +class Configuration(object): + # TODO: Figure out how to handle windows config + CONFIG_DIR = path.expanduser('~/.config/grocy') + CONFIG_FILE = CONFIG_DIR + '/config.yml' + API_KEY_HEADER = 'GROCY-API-KEY' + DEFAULT_CFG = { + 'logger': { + 'level': 'DEBUG', + 'file_location': None + }, + 'api': 'https://demo-en.grocy.info/api', + 'token': None, + 'formats': { + 'col': 'center', + 'table': 'simple' + } + } + + def update(self, props): + # Set nested fields (e.g. logger, formats) + if props['logger']: + for prop in props['logger']: + prop_attr = 'logger_{prop}'.format(prop=prop) + prop_value = props['logger'][prop] + setattr(self, prop_attr, prop_value) + if props['formats']: + for prop in props['formats']: + prop_attr = '{prop}_format'.format(prop=prop) + prop_value = props['formats'][prop] + setattr(self, prop_attr, prop_value) + + for prop, value in props.items(): + if not prop == 'logger' and prop in props: + setattr(self, prop, value) + + def create(self, user_cfg_options={}): + cfg_json = deepcopy(self.DEFAULT_CFG) + + if user_cfg_options['logger_level']: + self.logger_level = user_cfg_options['logger_level'] + cfg_json['logger']['level'] = self.logger_level + + if user_cfg_options['logger_file_location']: + self.logger_file_location = user_cfg_options['logger_file_location'] + cfg_json['logger']['file_location'] = self.logger_file_location + + if user_cfg_options['api']: + self.api = user_cfg_options['api'] + cfg_json['api'] = self.api + + if user_cfg_options['token']: + self.token = user_cfg_options['token'] + cfg_json['token'] = self.token + + if user_cfg_options['col_format']: + self.col_format = user_cfg_options['col_format'] + cfg_json['formats']['col'] = self.col_format + + if user_cfg_options['table_format']: + self.table_format = user_cfg_options['table_format'] + cfg_json['formats']['table'] = self.table_format + + # Create new configuration + makedirs(self.CONFIG_DIR) + with open(self.CONFIG_FILE, 'x') as fd: + dump_cfg = dump(cfg_json, default_flow_style=False, allow_unicode=True, encoding=None) + fd.write(dump_cfg) + chmod(self.CONFIG_DIR, 0o755) + + @property + def exists(self): + return path.exists(self.CONFIG_FILE) + + def load(self): + if self.exists: + with open(self.CONFIG_FILE) as fd: + data = safe_load(fd) + self.update(data) + else: + self.create() diff --git a/grocy/entity.py b/grocy/entity.py new file mode 100644 index 0000000..624a50a --- /dev/null +++ b/grocy/entity.py @@ -0,0 +1,39 @@ +from grocy.request import Request +from grocy.conf import Configuration +import logging + + +class Entity(object): + RESOURCE_URL_TEMPLATE = '{api}/objects/{entity}/{objectId}' + COLLECTION_URL_TEMPLATE = '{api}/objects/{entity}' + + def __init__(self, name, **props): + self.conf = Configuration() + self.conf.load() + self.name = name + self.__dict__.update(**props) + + def get(self, id=None): + logger = logging.getLogger('entity.get') + if id: + url = self.RESOURCE_URL_TEMPLATE.format(api=self.conf.api, entity=self.name, objectId=id) + else: + url = self.COLLECTION_URL_TEMPLATE.format(api=self.conf.api, entity=self.name) + + request = Request('get', url) + try: + return request.send() + except Exception as e: + logger.error(e) + raise e + + def create(self, entity): + logger = logging.getLogger('entity.add') + url = self.RESOURCE_URL_TEMPLATE + + request = Request('post', url) + try: + return request.send() + except Exception as e: + logger.error(e) + raise e diff --git a/grocy/models.py b/grocy/models.py new file mode 100644 index 0000000..d5c4b95 --- /dev/null +++ b/grocy/models.py @@ -0,0 +1,23 @@ +class Product(dict): + def __init__(self, **entries): + self.fields = [ + 'location_id', + 'name', + 'description', + 'qu_id_purchase', + 'qu_id_stock', + 'qu_factor_purchase_to_stock', + 'barcode', + 'min_stock_amount', + 'default_best_before_days', + 'default_best_before_days_after_open', + 'tare_weight', 'enable_tare_weight_handling', 'picture_file_name','product_group_id', + 'allow_partial_units_in_stock'] + self.__dict__.update(entries) + def toJSON(self): + obj = {} + for attr, value in self.__dict__.items(): + if attr in self.fields and value is not None: + obj[attr] = value + return obj + diff --git a/grocy/models/product.py b/grocy/models/product.py index ed618c8..6b16402 100644 --- a/grocy/models/product.py +++ b/grocy/models/product.py @@ -17,14 +17,3 @@ class Product(Schema): obj[attr] = value return obj - #id:str = '' - #location_id:str = '' - #name:str = '' - #description:str = '' - #qu_id_purchase:str = '' - #qu_id_stock:str = '' - #qu_factor_purchase_to_stock:float = 0 - #barcode:str = '' - #min_stock_amount:int = 0 - #default_best_before_days:int = 0 - #default_best_before_days_after_open:int = 0 diff --git a/grocy/models/recipe.py b/grocy/models/recipe.py index 76cc6f5..9def1a4 100644 --- a/grocy/models/recipe.py +++ b/grocy/models/recipe.py @@ -101,6 +101,17 @@ class Recipe(Schema): except Exception as e: raise e + def addIngredient(self, ingredient): + ingredient['recipe_id'] = self.id + hasProduct = False + if 'product_id' in ingredient and ingredient['product_id'] is not None: + hasProduct = Product.exists(ingredient['product_id']) + if hasProduct: + self.rest_service.post('recipes_pos', ingredient) + else + raise Exception('No product was given') + + def create(self): created_recipe = { 'description': self.description, @@ -110,6 +121,7 @@ class Recipe(Schema): 'not_check_shoppinglist': self.not_check_shoppinglist } self.rest_service.post('recipes', created_recipe) + def get(self, include_products=False): try: recipe = self.rest_service.get('recipes', id=self.id) diff --git a/grocy/models/schema.py b/grocy/models/schema.py index e41fa25..966aa7e 100644 --- a/grocy/models/schema.py +++ b/grocy/models/schema.py @@ -1,8 +1,8 @@ from grocy import RestService import logging -class Schema(object): +class Schema(object): def _init_rest_service(self): if hasattr(self, 'api'): if self.api.startswith == '/': diff --git a/grocy/recipe.py b/grocy/recipe.py new file mode 100644 index 0000000..c7b3e07 --- /dev/null +++ b/grocy/recipe.py @@ -0,0 +1,22 @@ +from grocy.request import Request +from grocy.conf import Configuration +import logging + + +class Recipe(object): + GET_RECIPE_REQUIRMENTS_URL_TEMPLATE = '{api}/recipes/requirements' + + def __init__(self): + self.conf = Configuration() + self.conf.load() + + def get_requirements(self): + logger = logging.getLogger('recipe.get_requirements') + url = self.GET_RECIPE_REQUIRMENTS_URL_TEMPLATE.format(api=self.conf.api) + request = Request('get', url) + try: + return request.send() + except Exception as e: + logger.error(e) + raise e + diff --git a/grocy/request.py b/grocy/request.py new file mode 100644 index 0000000..f1944f1 --- /dev/null +++ b/grocy/request.py @@ -0,0 +1,25 @@ +from grocy.conf import Configuration +from requests import request +import logging + + +class Request(object): + def __init__(self, method, url): + self.conf = Configuration() + self.conf.load() + self.url = url + self.method = method + self.headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + if self.conf.token: + self.headers[self.conf.API_KEY_HEADER] = self.conf.token + + def send(self): + logger = logging.getLogger('request.send') + 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() diff --git a/grocy/stock.py b/grocy/stock.py new file mode 100644 index 0000000..779c662 --- /dev/null +++ b/grocy/stock.py @@ -0,0 +1,22 @@ +from grocy.request import Request +from grocy.conf import Configuration +import logging + + +class Stock(object): + GET_STOCK_URL_TEMPLATE = '{api}/stock' + + def __init__(self): + self.conf = Configuration() + self.conf.load() + + def get(self): + logger = logging.getLogger('stock.get') + url = self.GET_STOCK_URL_TEMPLATE.format(api=self.conf.api) + request = Request('get', url) + try: + return request.send() + except Exception as e: + logger.error(e) + raise e + diff --git a/grocy/table.py b/grocy/table.py new file mode 100644 index 0000000..3019129 --- /dev/null +++ b/grocy/table.py @@ -0,0 +1,120 @@ +from grocy.entity import Entity +import re +from grocy.conf import Configuration +import logging +from tabulate import tabulate + + +class Table(object): + NOT_ENOUGH_IN_STOCK_MSG = '{glyph} Not enough in stock, {missing_amount} ingredient{s} missing' + ENOUGH_IN_STOCK_MSG = '{glyph} Enough in stock' + NOT_ENOUGH_BUT_IN_SHOPPING_LIST_MSG = '{not_enough}, but already on list'.format(not_enough=NOT_ENOUGH_IN_STOCK_MSG) + + def __init__(self, **entries): + self.__dict__.update(entries) + self.conf = Configuration() + self.conf.load() + + @property + def stock(self): + logger = logging.getLogger('table.stock') + entity = Entity(name='products') + products = entity.get() + products_map = {product['id']: product for product in products} + try: + # Get product names from ids and replace + table_entries = [] + try: + for item in self.stocks: + product = products_map[item['product_id']] + item['product_id'] = product['name'] + table_entry = list(dict.values(item)) + table_entries.append(table_entry) + + except Exception as e: + logger.error(e) + raise e + + # Generate stock overview table + table_headers = ['Product', 'Amount', 'Best Before Date', 'Amount Opened'] + return tabulate(table_entries, headers=table_headers) + except Exception as e: + logger.error(e) + raise e + + @property + def recipe(self): + checkmark_glyph = '' + times_glyph = '' + exclamation_glyph = '' + logger = logging.getLogger('table.recipes') + normal_recipes = [recipe for recipe in self.recipes if re.search(r'^normal', recipe['type'])] + recipes_req_map = {recipe_reqs['recipe_id']: recipe_reqs for recipe_reqs in self.recipes_reqs} + + try: + table_entries = [] + try: + for item in normal_recipes: + table_entry = [] + table_entry.append(item['id']) + table_entry.append(item['name']) + table_entry.append(item['base_servings']) + + recipe_reqs = recipes_req_map[item['id']] + missing_amount = int(recipe_reqs['missing_products_count']) + number_of_ingredients = 's' if missing_amount > 1 else '' + if recipe_reqs['need_fulfilled'] == '1': + table_entry.append(self.ENOUGH_IN_STOCK_MSG.format(glyph=checkmark_glyph)) + elif recipe_reqs['need_fulfilled_with_shopping_list'] == '1': + missing_amount = recipe_reqs['missing_products_count'] + table_entry.append(self.NOT_ENOUGH_BUT_IN_SHOPPING_LIST_MSG.format(glyph=exclamation_glyph, + missing_amount=missing_amount, s=number_of_ingredients)) + else: + table_entry.append(self.NOT_ENOUGH_IN_STOCK_MSG.format(glyph=times_glyph, + missing_amount=missing_amount, s=number_of_ingredients)) + table_entries.append(table_entry) + + except Exception as e: + logger.error(e) + raise e + + # Generate recipes overview table + table_headers = ['Id', 'Name', 'Servings', 'Requirements Fulfilled'] + return tabulate(table_entries, headers=table_headers) + except Exception as e: + logger.error(e) + raise e + + @property + def shopping_list(self): + logger = logging.getLogger('table.shopping_list') + try: + table_headers = ['Group', 'Product', 'Amount'] + # Get product names and location from ids and replace + product_ids = [entry['product_id'] for entry in self.shopping_list] + products = [] + location_ids = [] + table_entries = [] + for index in range(len(product_ids)): + product_id = product_ids[index] + entity = Entity(name='products') + product = entity.get(id=product_id) + + entity = Entity(name='quantity_units') + quantity_unit = entity.get(product['qu_id_purchase']) + + min_amount = '{} {}'.format(product['min_stock_amount'], quantity_unit['name']) + if product['product_group_id'] == '': + product_group_name = 'Uncategorized' + else: + entity = Entity(name='product_groups') + product_group = product.get(id=product['product_group_id']) + product_group_name = product_group['name'] + shopping_item = [product_group_name, product['name'], min_amount] + table_entries.append(shopping_item) + except Exception as e: + logger.error(e) + raise e + + # Generate stock overview table + return tabulate(table_entries, headers=table_headers) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..83b656c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,4 @@ +[pycodestyle] +exclude = .git,*.egg-info +ignore = E241,E128,E226,E722,W504 +max-line-length = 120 diff --git a/templates/ingredient_add.yml b/templates/ingredient_add.yml new file mode 100644 index 0000000..b7e639e --- /dev/null +++ b/templates/ingredient_add.yml @@ -0,0 +1,8 @@ +product_id: +amount: +note: +qu_id: +only_check_single_unit_in_stock: +ingredient_group: +not_check_stock_fulfillment: +variable_amount: