feat: Added recipe list command

This commit is contained in:
Aerex 2019-06-16 23:54:10 -05:00
parent a1c593b118
commit 0a1ecddf3d
14 changed files with 472 additions and 96 deletions

View File

@ -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()
@ -89,4 +85,4 @@ class RestService(object):
self.headers[type] = value
def addToken(self, value):
self.headers[RestService.API_KEY_HEADER] = value;
self.headers[RestService.API_KEY_HEADER] = value

View File

@ -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'
@ -24,101 +20,134 @@ 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)
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
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

85
grocy/conf.py Normal file
View File

@ -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()

39
grocy/entity.py Normal file
View File

@ -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

23
grocy/models.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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 == '/':

22
grocy/recipe.py Normal file
View File

@ -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

25
grocy/request.py Normal file
View File

@ -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()

22
grocy/stock.py Normal file
View File

@ -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

120
grocy/table.py Normal file
View File

@ -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)

4
setup.cfg Normal file
View File

@ -0,0 +1,4 @@
[pycodestyle]
exclude = .git,*.egg-info
ignore = E241,E128,E226,E722,W504
max-line-length = 120

View File

@ -0,0 +1,8 @@
product_id:
amount:
note:
qu_id:
only_check_single_unit_in_stock:
ingredient_group:
not_check_stock_fulfillment:
variable_amount: