refactor: Reanmed templates

- fix: Added browse subcommand for products
This commit is contained in:
Aerex 2019-07-07 13:58:01 -05:00
parent d479b4f621
commit 6af9bef334
21 changed files with 397 additions and 88 deletions

View File

@ -1,6 +1,8 @@
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
@ -104,7 +106,7 @@ def edit(product_id, name):
cfg = Configuration()
util = Util(cfg=cfg)
cfg.load()
loaded_template = cfg.templates('product_edit')
loaded_template = cfg.templates('product/edit')
entity = Entity(name='products')
if product_id:
product = entity.get(id=product_id)
@ -138,7 +140,8 @@ def edit(product_id, name):
@product.command()
@click.option('--name', '-n', 'name')
def list(name):
@click.option('-t', 'template')
def list(name, template):
logger = logging.getLogger('cli.product.list')
cfg = Configuration()
cfg.load()
@ -160,12 +163,114 @@ def list(name):
entries['locations'] = location_entity.get()
entries['products'] = products
table = Table(entries=entries)
click.echo(table.products)
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 add(template):
logger = logging.getLogger('cli.product.add')
try:
cfg = Configuration()
cfg.load()
logger = logging.getLogger('cli.product.add')
meta = Meta()
# Get product_groups
meta.add(type='entities', name='product_groups')
# Get locations
meta.add(type='entities', name='locations')
# Get quantity_units
meta.add(type='entities', name='quantity_units')
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('product/add')
new_product = click.edit(loaded_template.render(grocy=meta.generate()), extension='.yml')
if not new_product:
return
parsed_new_product = yaml.safe_load(new_product)
if template == 'debug':
click.echo(parsed_new_product)
return
entity = Entity(name='products')
entity.create(parsed_new_product)
except Exception as e:
logger.error(e)
raise e
@main.group()
@click.pass_context
def recipe(ctx):
pass
@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')
data['fields'] = entity.get(id=recipe_id)
# 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)
data['fields']['fulfillment'] = recipe.get_fulfillments()
if template:
loaded_template = cfg.templates(template)
else:
loaded_template = cfg.templates('recipe/view')
entity = Entity(name='recipes')
click.echo(loaded_template.render(grocy=data))
else:
click.echo(ctx.get_help())
except Exception as e:
logger.error(e)
raise e
#@recipe.command()
#def list():
# logger = logging.getLogger('cli.recipe')
# try:
# entity = Entity(name='recipes')
# recipes = entity.get()
# recipe = Recipe(
# ingredient_reqs = recipe.get_ingredient_requirements()
# table = Table(recipes=recipes, recipes_reqs=recipes_reqs, ingredient_reqs=ingredient_reqs)
# click.echo(table.recipe)
# except Exception as e:
# logger.error(e)
# raise e
##
#
#@main.command()
#def shopping():
# logger = logging.getLogger('cli.shopping')
@ -232,27 +337,6 @@ def list(name):
# 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):

View File

@ -20,7 +20,7 @@ class Configuration(object):
'level': 'DEBUG',
'file_location': None
},
'api': 'https://demo-en.grocy.info/api',
'domain': 'https://demo-en.grocy.info',
'token': None,
'formats': {
'col': 'center',
@ -56,9 +56,9 @@ class Configuration(object):
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['domain']:
self.domain = user_cfg_options['domain']
cfg_json['domain'] = self.domain
if user_cfg_options['token']:
self.token = user_cfg_options['token']
@ -99,7 +99,7 @@ class Configuration(object):
def templates(self, name):
try:
TEMPLATE_LOADER = Environment(loader=FileSystemLoader([self.USER_TEMPLATE_DIR, self.PROJ_TEMPLATE_DIR]),
trim_blocks=True, lstrip_blocks=True)
trim_blocks=False, lstrip_blocks=True)
return TEMPLATE_LOADER.get_template('{}.{}'.format(name, self.TEMPLATE_EXT))
except Exception as e:

View File

@ -1,16 +1,21 @@
from grocy.request import Request
import re
from copy import deepcopy
from grocy.conf import Configuration
import logging
class Entity(object):
RESOURCE_URL_TEMPLATE = '{api}/objects/{entity}/{objectId}'
COLLECTION_URL_TEMPLATE = '{api}/objects/{entity}'
SCHEMA_URL_TEMPLATE = '{api}/openapi/specification'
RESOURCE_URL_TEMPLATE = '{domain}/api/objects/{entity}/{objectId}'
COLLECTION_URL_TEMPLATE = '{domain}/api/objects/{entity}'
SCHEMA_URL_TEMPLATE = '{domain}/api/openapi/specification'
SCHEMA_MODEL_MAP = {
'products': 'Product',
'stock': 'StockEntry'
'stock': 'StockEntry',
'product_groups':'ProductGroup',
'locations': 'Location',
'quantity_units': 'QuantityUnit'
}
def __init__(self, name, **props):
@ -19,12 +24,13 @@ class Entity(object):
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)
url = self.RESOURCE_URL_TEMPLATE.format(domain=self.conf.domain, entity=self.name, objectId=id)
else:
url = self.COLLECTION_URL_TEMPLATE.format(api=self.conf.api, entity=self.name)
url = self.COLLECTION_URL_TEMPLATE.format(domain=self.conf.domain, entity=self.name)
request = Request('get', url)
try:
@ -54,9 +60,15 @@ class Entity(object):
def create(self, entity):
logger = logging.getLogger('entity.add')
url = self.COLLECTION_URL_TEMPLATE.format(api=self.conf.api, entity=self.name)
url = self.COLLECTION_URL_TEMPLATE.format(domain=self.conf.domain, entity=self.name)
request = Request('post', url, entity)
for key, value in entity.items():
if type(value) == bool:
entity[key] = '1' if value else '0'
print('{}'.format(entity))
request = Request('post', url, resource=entity)
try:
return request.send()
except Exception as e:
@ -67,7 +79,7 @@ class Entity(object):
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)
url = self.RESOURCE_URL_TEMPLATE.format(domain=self.conf.domain, entity=self.name, objectId=id)
request = Request('put', url, resource=entity)
try:
@ -80,7 +92,7 @@ class Entity(object):
def schema(self):
logger = logging.getLogger('entity.schema')
try:
url = self.SCHEMA_URL_TEMPLATE.format(api=self.conf.api)
url = self.SCHEMA_URL_TEMPLATE.format(domain=self.conf.domain)
request = Request('get', url)
response = request.send()
schema_name = self.SCHEMA_MODEL_MAP[self.name]

27
grocy/meta.py Normal file
View File

@ -0,0 +1,27 @@
from grocy.entity import Entity
from grocy.schema import get_schema
from grocy.recipe import Recipe
from fontawesome import icons as fa_icons
class Meta(object):
def __init__(self):
self.meta = {'meta': {'entities': {}, 'fa_icons': fa_icons}}
def add(self, type, name):
if type == 'entities':
entity = Entity(name=name)
resources = entity.get()
schema = get_schema(name)
elif type == 'recipes':
recipe = Recipe()
if name == 'fulfillments':
resources = recipe.get_fulfillments()
schema = get_schema(name='recipe_fulfilments')
self.meta['meta'][type][name] = {}
self.meta['meta'][type][name]['properties'] = schema['properties']
self.meta['meta'][type][name]['valid_values'] = resources
def generate(self):
return self.meta

View File

@ -1,18 +1,30 @@
from grocy.request import Request
from grocy.conf import Configuration
from grocy.entity import Entity
import logging
class Recipe(object):
GET_RECIPE_REQUIRMENTS_URL_TEMPLATE = '{api}/recipes/requirements'
GET_RECIPES_FULFILLMENT_URL_TEMPLATE = '{domain}/api/recipes/fulfillment'
GET_RECIPE_FULFILLMENT_URL_TEMPLATE = '{domain}/api/recipes/{recipeId}/fulfillment'
GET_RECIPES_POS_FULFILLMENT_URL_TEMPLATE = '{domain}/api/recipes/pos/fulfillment'
GET_RECIPE_POS_FULFILLMENT_URL_TEMPLATE = '{domain}/api/recipes/{recipeId}/pos/{recipeId}/fulfillment'
def __init__(self):
def __init__(self, id=None):
self.conf = Configuration()
self.conf.load()
self.id = id
def get_requirements(self):
def get_ingredient_requirements(self, recipe_id=None):
logger = logging.getLogger('recipe.get_ingredient_requirements')
def get_fulfillments(self):
logger = logging.getLogger('recipe.get_requirements')
url = self.GET_RECIPE_REQUIRMENTS_URL_TEMPLATE.format(api=self.conf.api)
if self.id:
url = self.GET_RECIPE_FULFILLMENT_URL_TEMPLATE.format(domain=self.conf.domain, recipeId=self.id)
else:
url = self.GET_RECIPES_FULFILLMENT_URL_TEMPLATE.format(domain=self.conf.domain)
request = Request('get', url)
try:
return request.send()
@ -20,3 +32,23 @@ class Recipe(object):
logger.error(e)
raise e
def get(self):
# Get list of available ingredients
if self.id:
entity = Entity(name='recipes')
recipe = entity.get(id=self.id)
if type(recipe) is list:
pass

View File

@ -1,11 +1,13 @@
from grocy.conf import Configuration
import json
from requests import request
import requests
from requests import session
import logging
import cachecontrol
sess = cachecontrol.CacheControl(session())
class Request(object):
def __init__(self, method, url, resource=None):
self.conf = Configuration()
self.conf.load()
@ -22,13 +24,16 @@ class Request(object):
def send(self):
logger = logging.getLogger('request.send')
if self.resource:
r = request(method=self.method, url=self.url, headers=self.headers, json=self.resource)
r = sess.request(method=self.method, url=self.url, headers=self.headers, json=self.resource)
print(r.text)
else:
r = request(method=self.method, url=self.url, headers=self.headers)
r = sess.request(method=self.method, url=self.url, headers=self.headers)
if r.raise_for_status():
logger.error(r.raise_for_status())
logger.error(r.text)
raise r.raise_for_status()
if r.status_code != 204:
return r.json()

28
grocy/schema.py Normal file
View File

@ -0,0 +1,28 @@
from grocy.request import Request
from grocy.conf import Configuration
import logging
SCHEMA_URL_TEMPLATE = '{domain}/api/openapi/specification'
SCHEMA_MODEL_MAP = {
'products': 'Product',
'stock': 'StockEntry',
'product_groups': 'ProductGroup',
'locations': 'Location',
'quantity_units': 'QuantityUnit',
'recipe_requirements': 'RecipeFulfilmentResponse'
}
def get_schema(name):
logger = logging.getLogger('schema')
try:
cfg = Configuration()
cfg.load()
url = SCHEMA_URL_TEMPLATE.format(domain=cfg.domain)
request = Request('get', url)
response = request.send()
schema_name = SCHEMA_MODEL_MAP[name]
return response['components']['schemas'][schema_name]
except Exception as e:
logger.error(e)
raise e

View File

@ -4,8 +4,8 @@ import logging
class Stock(object):
GET_STOCK_URL_TEMPLATE = '{api}/stock'
GET_STOCK_PRODUCT_DETAIL_TEMPLATE = '{api}/stock/products/{product_id}'
GET_STOCK_URL_TEMPLATE = '{domain}/api/stock'
GET_STOCK_PRODUCT_DETAIL_TEMPLATE = '{domain}/api/stock/products/{product_id}'
def __init__(self):
self.conf = Configuration()
@ -22,10 +22,10 @@ class Stock(object):
@property
def products(self):
logger = logging.getLogger('stock.products')
url = self.GET_STOCK_URL_TEMPLATE.format(api=self.conf.api)
url = self.GET_STOCK_URL_TEMPLATE.format(domain=self.conf.domain)
return self.__get_resources(url, logger)
def get_product(self, product_id):
logger = logging.getLogger('stock.get_product')
url = self.GET_STOCK_PRODUCT_DETAIL_TEMPLATE.format(api=self.conf.api, product_id=product_id)
url = self.GET_STOCK_PRODUCT_DETAIL_TEMPLATE.format(domain=self.conf.domain, product_id=product_id)
return self.__get_resources(url, logger)

View File

@ -20,7 +20,7 @@ class Table(object):
def product(self):
if not self.entry:
raise Exception('Missing product')
loaded_template = self.conf.templates('single_product')
loaded_template = self.conf.templates('product/view')
return loaded_template.render(self.entry)
@property
@ -36,23 +36,26 @@ class Table(object):
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']]
product_group_name = 'N/A' if entry['product_group_id'] == '' else product_groups_map[entry['product_group_id']]
mini_stock_amount = entry['mini_stock_amount'] if 'mini_stock_amount' in entry else 'N/A'
qu_factor_purchase_to_stock = entry['qu_factor_purchase_to_stock'] if 'qu_factor_purchase_to_stock' in entry else 'N/A'
location_name = location_map[entry['location_id']] if 'location_id' in entry else 'N/A'
quantity_unit_purchase_name = quantity_unit_map[entry['qu_id_purchase']] if 'qu_id_purchase' in entry else 'N/A'
quantity_unit_stock_name = quantity_unit_map[entry['qu_id_stock']] if 'qu_id_stock' in entry else 'N/A'
table_entry.append(entry['id'])
table_entry.append(entry['name'])
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_entry.append(mini_stock_amount)
table_entry.append(quantity_unit_stock_name)
table_entries.append(table_entry)
table_headers = ['ID', 'Name', 'Location', 'Min Stock Amount',
'QU Purchase', 'QU Stock', 'QU Factor', 'Product Group']
table_headers = ['ID', 'Name', 'Location', 'Min\nStock Amount',
'QU\nPurchase', 'QU\nStock', 'QU\nFactor', 'Product\nGroup']
return tabulate(table_entries, headers=table_headers)
except Exception as e:
raise e
@ -89,7 +92,49 @@ class Table(object):
checkmark_glyph = ''
times_glyph = ''
exclamation_glyph = ''
logger = logging.getLogger('table.recipes')
logger = logging.getLogger('table.recipe')
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 recipes(self):
checkmark_glyph = ''
times_glyph = ''
exclamation_glyph = ''
logger = logging.getLogger('table.recipe')
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}
@ -160,3 +205,5 @@ class Table(object):
# Generate stock overview table
return tabulate(table_entries, headers=table_headers)

39
models.json Normal file
View File

@ -0,0 +1,39 @@
{
"id": "3",
"name": "Garbonza Beans",
"description": "",
"location": {
"id": "3",
"name": "Pantry",
"description": "",
"row_created_timestamp": "2018-10-21 03:24:25"
},
"quantity_unit": {
"stock": {
"id": "4",
"name": "Ib",
"description": "Pounds",
"row_created_timestamp": "2018-10-21 03:24:11",
"name_plural": "Ibs",
"plural_forms": null
},
"purchase": {
"id": "4",
"name": "Ib",
"description": "Pounds",
"row_created_timestamp": "2018-10-21 03:24:11",
"name_plural": "Ibs"
}
},
"qu_factor_purchase_to_stock": "1.0",
"barcode": "",
"min_stock_amount": "0",
"default_best_before_days": "0",
"row_created_timestamp": "2018-10-21 03:39:42",
"product_group_id": "",
"picture_file_name": null,
"default_best_before_days_after_open": "0",
"allow_partial_units_in_stock": "0",
"enable_tare_weight_handling": "0",
"tare_weight": "0.0",
"not_check_stock_fulfillment_for_recipes": "0"

View File

@ -22,7 +22,7 @@ setup(
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=['Click', 'pyyaml', 'requests', 'taskw', 'lockfile', 'tox', 'html2text', 'markdown'],
install_requires=['Click', 'pyyaml', 'requests', 'taskw', 'lockfile', 'tox', 'html2text', 'markdown', 'cachecontrol', 'fontawesome'],
long_description=read('README.rst'),
tests_require=[
"pytest_mock",

1
templates/debug.yml Normal file
View File

@ -0,0 +1 @@
{{ grocy | tojson(indent=2) | safe }}

20
templates/product/add.yml Normal file
View File

@ -0,0 +1,20 @@
# Legend:
# Location Name (id): {% for valid_value in grocy.meta.entities.locations.valid_values %}{{ valid_value.name }}({{ valid_value.id}}){% if not loop.last %}{{ ', '}}{% endif %}{% endfor %}
# Product Group (id): {% for valid_value in grocy.meta.entities.product_groups.valid_values %}{{ valid_value.name }}({{ valid_value.id}}){% if not loop.last %}{{ ', '}}{% endif %}{% endfor %}
# Quanity Units (id): {% for valid_value in grocy.meta.entities.quantity_units.valid_values %}{{ valid_value.name }}({{ valid_value.id}}){% if not loop.last %}{{ ', '}}{% endif %}{% endfor %}
name:
description: |-
barcode:
location_id:
min_stock_amount: 0
default_best_before_days: 0
product_group_id:
qu_id_purchase:
qu_id_stock:
default_best_before_days_after_open: 0
qu_factor_purchase_to_stock: 0
allow_partial_units_in_stock: False
enable_tare_weight_handling: False
tare_weight: 0
not_check_stock_fulfillment_for_recipes: False

View File

18
templates/recipe/edit.yml Normal file
View File

@ -0,0 +1,18 @@
name: {{ grocy.fields.name }}
description: |
{{ grocy.fields.description }}
base_servings: {{ grocy.fields.base_servings | default("1") }}
desired_servings: {{ grocy.fields.desired_servings | default("1") }}
not_check_shoppinglist: {{ grocy.fields.not_check_shoppinglist | default("1") }}
products: {% for ingredient in grocy.fields.ingredientsp%}
- id: {{ ingredient.id }}
name: {{ ingredient.name }}
description: {{ ingredient.description | default(null) }}
note: {{ ingredient.note }}
amount: {{ ingredient.amount }}
qu_id: {{ ingredient.qu_id }}
only_check_single_unit_in_stock: {{ ingredient.only_check_single_unit_in_stock }}
ingredient_group: {{ ingredient.ingredient_group | default(null) }}
not_check_stock_fulfillment: {{ ingredient.not_check_stock_fulfillment }}{% endfor %}

20
templates/recipe/view.yml Normal file
View File

@ -0,0 +1,20 @@
name: {{ grocy.fields.name }}
servings: {{ grocy.fields.base_servings }}
costs: {{ grocy.fields.fulfillment.costs }}
ingredients: {% for ingredient in grocy.fields.ingredients %}
- product_id: {{ ingredient.id }}
name: {{ ingredient.name}}
description: {{ ingredient.description | default(null) }}
note: {{ ingredient.note }}
amount: {{ ingredient.amount }}
qu_id: {{ ingredient.qu_id }}
only_check_single_unit_in_stock: {{ ingredient.only_check_single_unit_in_stock }}
ingredient_group: {{ ingredient.ingredient_group | default(null) }}
not_check_stock_fulfillment: {{ ingredient.not_check_stock_fulfillment }}{% endfor %}
description: |-
{{ grocy.fields.description }}

View File

@ -1,24 +0,0 @@
name: {{ name }}
description: |
{{ description }}
picture_file_name: {% if picture_file_name is not none %}
{{picture_file_name}}
{% else %}
{% endif %}
base_servings: {{ base_servings | default("1") }}
desired_servings: {{ desired_servings | default("1") }}
not_check_shoppinglist: {{ not_check_shoppinglist | default("1") }}
products: {% for product in products %}
- id: {{ product.id }}
name: {{ product.name }}
description: {{ product.description | default(null) }}
note: {{ recipes_pos[loop.index0].note }}
amount: {{ recipes_pos[loop.index0].amount }}
qu_id: {{ recipes_pos[loop.index0].qu_id }}
only_check_single_unit_in_stock: {{ recipes_pos[loop.index0].only_check_single_unit_in_stock }}
ingredient_group: {{ recipes_pos[loop.index0].ingredient_group | default(null) }}
not_check_stock_fulfillment: {{ recipes_pos[loop.index0].not_check_stock_fulfillment }}{% endfor %}