grocy-cli/grocy/cli.py

789 lines
25 KiB
Python

import logging
import click
from markdown import markdown
from html2text import html2text
from jinja2 import Environment, FileSystemLoader
from grocy.meta import Meta
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
from grocy.chore import Chore
from grocy.stock import Stock
from grocy.schema import get_schema
from grocy.shoppinglist import ShoppingList
import yaml
from sys import exit
from os import path
APP_NAME = 'grocy-cli'
SAMPLE_CONFIG_FILE = 'sample.config.yml'
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)
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():
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:
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)
else:
cfg.load()
logging.basicConfig(level=cfg.logger_level, filename=cfg.logger_file_location)
@main.command()
@click.pass_context
def stock(ctx):
logger = logging.getLogger('cli.stock')
if ctx.invoked_subcommand is None:
try:
stock = Stock()
table = Table(stocks=stock.products)
click.echo(table.stock)
except Exception as e:
logger.error(e)
raise e
@main.group()
def product():
pass
@product.command()
@click.pass_context
@click.argument('product_id', required=False)
def view(ctx, product_id):
logger = logging.getLogger('cli.product')
try:
if product_id:
stock = Stock()
product = stock.get_product(product_id)
table = Table(entry=product)
click.echo(table.product)
else:
click.echo(ctx.get_help())
except Exception as e:
logger.error(e)
raise e
@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')
@click.option('-t', 'template')
def list(name, template):
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})
if len(products) == 0:
return
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_via_pager(table.products)
except Exception as e:
logger.error(e)
raise e
@product.command()
@click.argument('product_id')
def browse(product_id):
logger = logging.getLogger('cli.product.browse')
try:
cfg = Configuration()
cfg.load()
url = '{domain}/product/{product_id}'.format(domain=cfg.domain, product_id=product_id)
click.launch(url, wait=False)
except Exception as e:
logger.error(e)
raise e
@product.command()
@click.option('-t', 'template')
def create(template):
logger = logging.getLogger('cli.product.create')
try:
cfg = Configuration()
cfg.load()
meta = Meta()
# Get product_groups
entity = Entity(name='product_groups')
product_groups = entity.get()
meta.add(type='entities', name='product_groups', valid_values=product_groups)
# Get locations
entity = Entity(name='locations')
locations = entity.get()
meta.add(type='entities', name='locations', valid_values=locations)
# Get quantity_units
entity = Entity(name='quantity_units')
quantity_units = entity.get()
meta.add(type='entities', name='quantity_units', valid_values=quantity_units)
data = { 'meta': meta.generate() }
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('product/create')
created_product = click.edit(loaded_template.render(grocy=data), extension='.yml')
if not created_product:
return
parsed_created_product = yaml.safe_load(created_product)
if template == 'debug':
click.echo(parsed_created_product)
return
entity = Entity(name='products')
entity.create(parsed_created_product)
except Exception as e:
logger.error(e)
raise e
@main.group()
@click.pass_context
def recipe(ctx):
pass
@recipe.command()
def create(template):
logger = logging.getLogger('cli.recipe.create')
try:
cfg = Configuration()
recipe = Entity(name='recipes')
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('recipe/create')
created_recipe = click.edit(loaded_template.render(),
extension='.yml')
if created_recipe is None:
return
parsed_created_recipe = util.load_yaml(created_recipe)[0]
if template == 'debug':
click.echo(parsed_created_recipe)
return
shopping_lists.create(parsed_created_recipe)
except Exception as e:
raise e
@recipe.command()
@click.pass_context
@click.argument('recipe_id', required=False)
@click.option('-t', 'template')
def view(ctx, recipe_id, template):
logger = logging.getLogger('cli.recipes.view')
try:
cfg = Configuration()
cfg.load()
data = {'fields': {}}
if recipe_id:
entity = Entity(name='recipes')
meta = Meta()
data['fields'] = entity.get(id=recipe_id)
recipe = Recipe(id=recipe_id)
data['fields']['fulfillment'] = recipe.fulfillment
# Change html markup to plain text
html_markup_description = data['fields']['description']
plain_text_description = html2text(html_markup_description)
data['fields']['description'] = plain_text_description
recipe = Recipe(id=recipe_id)
meta.add(type='recipes', name='ingredients', valid_values=recipe.ingredients)
data['meta'] = meta.generate()
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('recipe/view')
click.echo(loaded_template.render(grocy=data))
else:
click.echo(ctx.get_help())
except Exception as e:
logger.error(e)
raise e
@recipe.command()
@click.option('--name', '-n', 'name')
@click.option('-t', 'template')
def list(name, template):
logger = logging.getLogger('cli.recipe.list')
cfg = Configuration()
cfg.load()
try:
entity = Entity(name='recipes')
if name:
# Convert name args to a single string
string_name_arg = ' '.join(name) if type(name) == list else name
recipe_entities = entity.find({'name': string_name_arg})
if len(recipe_entities) == 0:
return 0
else:
recipe_entities = entity.get()
data = {'recipes': []}
for recipe_entity in recipe_entities:
entry = {'fields': recipe_entity, 'meta': []}
meta = Meta(include_fa_icons=False)
recipe = Recipe(id=recipe_entity['id'])
entry['fields']['fulfillment'] = recipe.fulfillment
entry['fields']['description'] = recipe.generate_plain_text_description(recipe_entity['description'])
meta.add(type='recipes', name='ingredients', valid_values=recipe.ingredients)
entry['meta'].append(meta.generate())
data['recipes'].append(entry)
meta = Meta()
data['meta'] = meta.generate()
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('recipe/list')
click.echo(loaded_template.render(grocy=data))
except Exception as e:
logger.error(e)
raise e
@recipe.command()
@click.option('--fullscreen', '-f', 'fullscreen', is_flag=True)
@click.argument('recipe_id')
def browse(fullscreen, recipe_id):
logger = logging.getLogger('cli.recipe.browse')
try:
cfg = Configuration()
cfg.load()
url = '{domain}/recipes?recipe={recipe_id}'.format(domain=cfg.domain, recipe_id=recipe_id)
if fullscreen:
url += '#fullscreen'
click.launch(url, wait=False)
except Exception as e:
logger.error(e)
# TODO: revist this command
@recipe.command()
@click.option('--name', '-n', 'name')
@click.argument('recipe_id', required=False)
@click.option('-t', 'template')
def edit(recipe_id, name, template):
logger = logging.getLogger('cli.recipe.edit')
try:
cfg = Configuration()
util = Util(cfg=cfg)
cfg.load()
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('recipe/edit')
entity = Entity(name='recipes')
if recipe_id:
recipe_entity = entity.get(id=recipe_id)
recipe = Recipe(id=recipe_id)
entry = {'fields': recipe_entity}
meta = Meta()
entry['fields']['fulfillment'] = recipe.fulfillment
entry['fields']['description'] = recipe.generate_plain_text_description(entity['description'])
entry['meta'] = meta.generate()
edited_recipe = click.edit(loaded_template.render(grocy=entry))
edited_recipe.update()
return
elif name:
# Convert name args to a single string
string_name_arg = ' '.join(name) if type(name) == list else name
recipe_entities = entity.find({'name': string_name_arg})
if len(recipe_entities) == 0:
click.echo('Could not find recipe')
return
# Build hydrated recipe entity object for editing
data = {'recipes': []}
for recipe_entity in recipe_entities:
entry = {'fields': recipe_entity, 'meta': []}
meta = Meta(include_fa_icons=False)
recipe = Recipe(id=recipe_entity['id'])
entry['fields']['fulfillment'] = recipe.fulfillment
entry['fields']['description'] = recipe.generate_plain_text_description(recipe_entity['description'])
meta.add(type='recipes', name='ingredients', valid_values=recipe.ingredients)
entry['meta'].append(meta.generate())
data['recipes'].append(entry)
meta = Meta()
data['meta'] = meta.generate()
edited_recipes = click.edit(loaded_template.render(grocy=data))
if edited_recipes is None:
return
schema = []
schema.append(Entity(name='products').schema)
schema.append(Entity(name='locations').schema)
schema.append(Entity(name='quantity_units').schema)
schema.append(Entity(name='recipes').schema)
parsed_edited_recipes = util.load_yaml(edited_recipes)
for index, edited_recipe in enumerate(parsed_edited_recipes):
schema = entity.schema
edited_recipe['id'] = recipe_entities[index]['id']
util.verify_integrity(edited_recipe, schema)
entity.update(edited_recipe, id=edited_recipe['id'])
else:
raise click.BadParameter('Missing RECIPE_ID or QUERY')
except Exception as e:
logger.error(e)
raise e
@recipe.command()
@click.argument('recipe_id', required=True)
@click.option('-t', 'template')
def add_ingredient(recipe_id, template):
logger = logging.getLogger('cli.recipe.add_ingredient')
try:
cfg = Configuration()
util = Util(cfg=cfg)
cfg.load()
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('recipe/add-ingredient')
# Verify that recipe exist
try:
entity = Entity(name='recipes')
entity.get(id=recipe_id)
except Exception:
raise Exception('Could not find recipe {}'.format(recipe_id))
# TODO: Meta has to be added to validate against know fields/properties
# Add quantity_units to meta
ingredient = click.edit(loaded_template.render(), extension='.yml')
if ingredient is None:
return
parsed_new_ingredient = util.load_yaml(ingredient)[0]
parsed_new_ingredient['recipe_id'] = recipe_id
if template == 'debug':
click.echo(parsed_new_ingredient)
return
entity = Entity(name='recipes_pos')
entity.create(parsed_new_ingredient)
except Exception as e:
logger.error(e)
raise e
@recipe.command()
@click.argument('recipe_id', required=True)
@click.argument('ingredient_id', required=True)
def remove_ingredient(recipe_id, ingredient_id):
logger = logging.getLogger('cli.recipe.remove_ingredient')
try:
entity = Entity(name='recipes_pos')
entity.delete(ingredient_id)
except Exception as e:
logger.error(e)
raise e
@main.group()
@click.pass_context
def shopping(ctx):
pass
@shopping.command()
@click.pass_context
@click.argument('shopping_id', required=False)
@click.option('-t', 'template')
def view(ctx, shopping_id, template):
logger = logging.getLogger('cli.shopping.view')
try:
cfg = Configuration()
cfg.load()
data = {'fields': {'shopping_list_items': []}}
if shopping_id:
entity = Entity(name='shopping_list')
all_shopping_list_items = entity.get()
shopping_list_items = [s for s in all_shopping_list_items if s['shopping_list_id'] == shopping_id]
if shopping_list_items is None:
return
entity = Entity(name='products')
products = entity.get()
products_map = {product['id']: product for product in products}
for shopping_list_item in shopping_list_items:
shopping_product_item = products_map[shopping_list_item['product_id']]
shopping_list_item['product'] = shopping_product_item
data['fields']['shopping_list_items'].append(shopping_list_item)
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('shopping/view')
click.echo(loaded_template.render(grocy=data))
else:
click.echo(ctx.get_help())
except Exception as e:
logger.error(e)
raise e
@shopping.command()
@click.option('--name', '-n', 'name')
@click.option('-t', 'template')
def list(name, template):
logger = logging.getLogger('cli.shopping.list')
cfg = Configuration()
cfg.load()
try:
entity = Entity(name='shopping_lists')
if name:
# Convert name args to a single string
string_name_arg = ' '.join(name) if type(name) == list else name
shopping_list_entities = entity.find({'name': string_name_arg})
if len(shopping_list_entities) == 0:
return
else:
shopping_list_entities = entity.get()
data = {'fields': {'shopping_lists': shopping_list_entities}}
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('shopping/list')
click.echo(loaded_template.render(grocy=data))
except Exception as e:
logger.error(e)
raise e
@shopping.command()
@click.argument('shopping_id')
def browse(shopping_id):
logger = logging.getLogger('cli.shopping.browse')
try:
cfg = Configuration()
cfg.load()
url = '{domain}/shoppinglist?list={shopping_id}'.format(domain=cfg.domain, shopping_id=shopping_id)
click.launch(url, wait=False)
except Exception as e:
logger.error(e)
@shopping.command()
@click.option('-t', 'template')
def create(template):
logger = logging.getLogger('cli.shopping.create')
try:
cfg = Configuration()
cfg.load()
util = Util(cfg=cfg)
shopping_lists = Entity(name='shopping_lists')
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('shopping/create')
created_shopping_list = click.edit(loaded_template.render(),
extension='.yml')
if created_shopping_list is None:
return
parsed_created_shopping_list = util.load_yaml(created_shopping_list)[0]
if template == 'debug':
click.echo(parsed_created_shopping_list)
return
shopping_lists.create(parsed_created_shopping_list)
except Exception as e:
raise e
@shopping.command()
@click.argument('shopping_id')
def delete(shopping_id):
logger = logging.getLogger('cli.shopping.delete')
try:
cfg = Configuration()
cfg.load()
util = Util(cfg=cfg)
shopping_lists = Entity(name='shopping_lists')
shopping_lists.delete(shopping_id)
except Exception as e:
raise e
@shopping.command()
@click.argument('shopping_id')
def clear(shopping_id):
logger = logging.getLogger('cli.shopping.clear')
try:
cfg = Configuration()
cfg.load()
util = Util(cfg=cfg)
# Validate that shopping list exists
entity = Entity(name='shopping_lists')
found_shopping_list = entity.get(id=shopping_id)
if found_shopping_list is None:
raise Exception('Shopping list does not exist')
shopping_list = ShoppingList(id=shopping_id)
shopping_list.clear()
except Exception as e:
raise e
@shopping.command()
@click.argument('shopping_id')
@click.option('-t', 'template')
def add_product(shopping_id, template):
logger = logging.getLogger('cli.shopping.add_product')
try:
cfg = Configuration()
cfg.load()
util = Util(cfg=cfg)
# Validate that shopping list exists
entity = Entity(name='shopping_lists')
found_shopping_list = entity.get(id=shopping_id)
if found_shopping_list is None:
raise Exception('Shopping list does not exist')
shopping_list = ShoppingList(id=shopping_id)
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('shopping/add_product')
added_product = click.edit(loaded_template.render(), extension='.yml')
if added_product is None:
return
parsed_added_product = util.load_yaml(added_product)[0]
if template == 'debug':
# TODO: Reserve for meta
return
shopping_list.add_product(parsed_added_product)
except Exception as e:
raise e
@shopping.command()
@click.argument('shopping_id')
@click.option('-t', 'template')
def remove_product(shopping_id, template):
logger = logging.getLogger('cli.shopping.remove_product')
try:
cfg = Configuration()
cfg.load()
util = Util(cfg=cfg)
shopping_list = ShoppingList(id=shopping_id)
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('shopping/remove_product')
remove_product = click.edit(loaded_template.render(), extension='.yml')
if remove_product is None:
return
parsed_removed_product = util.load_yaml(remove_product)[0]
if template == 'debug':
# TODO: Reserve for meta
return
shopping_list.remove_product(parsed_removed_product)
except Exception as e:
raise e
# TODO: Figure out why this command is not working for new shopping lists
#@shopping.command()
#@click.argument('shopping_id')
#@click.option('-t', 'template')
#@click.option('recipe_id', 'r')
#def add_missing_products(shopping_id, recipe_id, template):
# logger = logging.getLogger('cli.shopping.add_missing_products')
# try:
# cfg = Configuration()
# cfg.load()
# util = Util(cfg=cfg)
#
# # Validate that shopping list exists
# entity = Entity(name='shopping_lists')
# found_shopping_list = entity.get(id=shopping_id)
# if found_shopping_list is None:
# raise Exception('Shopping list does not exist')
#
# shopping_list = ShoppingList(id=shopping_id)
# except Exception as e:
# raise e
@main.group()
def chore():
pass
@chore.command()
@click.option('--name', '-n', 'name')
@click.option('-t', 'template')
def list(name, template):
logger = logging.getLogger('cli.shopping.list')
cfg = Configuration()
cfg.load()
try:
entity = Entity(name='chores')
if name:
chores = entity.find_by_name(name)
if len(chores) == 0:
return
else:
chores = entity.get()
data = {'chores': []}
for chore in chores:
chore_details = Chore(chore['id'])
chore.update(chore_details.execution_times)
data['chores'].append({'fields': chore})
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('chore/list')
click.echo(loaded_template.render(grocy=data))
except Exception as e:
logger.error(e)
raise e
@chore.command()
@click.option('-t', 'template')
def create(template):
logger = logging.getLogger('cli.chore.create')
try:
cfg = Configuration()
cfg.load()
entity = Entity(name='chores')
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('chore/create')
created_chore = click.edit(loaded_template.render(), extension='.yml')
if not created_chore:
return
parsed_created_chore = yaml.safe_load(created_chore)
if template == 'debug':
click.echo(parsed_created_chore)
return
entity.create(parsed_created_chore)
except Exception as e:
logger.error(e)
raise e