Feature: Download nutritional information and store description and allergen information in products

This commit is contained in:
Amritanshu Agrawal 2023-12-28 13:52:35 +05:30
parent cd6a5e129f
commit 88f0c35b4d
11 changed files with 434 additions and 3 deletions

View File

@ -0,0 +1,27 @@
"""allergen
Revision ID: 66abfc21db73
Revises: 2438cd581f00
Create Date: 2023-12-28 12:45:01.275322
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "66abfc21db73"
down_revision = "2438cd581f00"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("products", sa.Column("allergen", sa.Text(), server_default="", nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("products", "allergen")
# ### end Alembic commands ###

View File

@ -39,6 +39,8 @@ class Product:
product_group: Mapped["ProductGroup"] = relationship("ProductGroup", back_populates="products")
account: Mapped["Account"] = relationship("Account", back_populates="products")
allergen: Mapped[str] = mapped_column(Text, nullable=False)
protein: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False)
carbohydrate: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False)
total_sugar: Mapped[Decimal] = mapped_column(Numeric(precision=15, scale=5), nullable=False)
@ -56,12 +58,14 @@ class Product:
def __init__(
self,
name: str,
description: str | None,
fraction_units: str,
product_group_id: uuid.UUID,
account_id: uuid.UUID,
is_active: bool,
is_purchased: bool,
is_sold: bool,
allergen: str = "",
protein: Decimal = 0,
carbohydrate: Decimal = 0,
total_sugar: Decimal = 0,
@ -82,6 +86,7 @@ class Product:
if code is not None:
self.code = code
self.name = name
self.description = description
self.fraction_units = fraction_units
self.product_group_id = product_group_id
self.account_id = account_id
@ -89,6 +94,7 @@ class Product:
self.is_purchased = is_purchased
self.is_sold = is_sold
self.allergen = allergen
self.protein = protein
self.carbohydrate = carbohydrate
self.total_sugar = total_sugar

View File

@ -0,0 +1,249 @@
import uuid
from ..schemas.nutritional_information import NutritionalInformation
from ..models.product_group import ProductGroup
from fastapi import HTTPException, status
from sqlalchemy import distinct, or_, select, update
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from ..models.product import Product
from ..models.recipe import Recipe
from ..models.recipe_item import RecipeItem
from ..models.stock_keeping_unit import StockKeepingUnit
def calculate_nutrition(db: Session):
try:
# Get all recipes that have nutritional values
products = set(
db.execute(
select(distinct(StockKeepingUnit.product_id))
.join(StockKeepingUnit.recipes)
.join(StockKeepingUnit.product)
.join(Product.product_group)
.where(or_(ProductGroup.nutritional == True, ProductGroup.ice_cream == True)) # noqa: E712
)
.scalars()
.all()
)
db.flush()
while len(products) > 0:
calculate(products, db)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
def calculate(products: set[uuid.UUID], db: Session) -> None:
sq = select(RecipeItem.recipe_id).where(RecipeItem.product_id.in_(products))
recipes = (
db.execute(
select(Recipe).join(Recipe.sku).where(StockKeepingUnit.product_id.in_(products), Recipe.id.notin_(sq))
)
.scalars()
.all()
)
for recipe in recipes:
protein = sum(i.quantity * i.product.protein for i in recipe.items) / recipe.sku.fraction
carbohydrate = sum(i.quantity * i.product.carbohydrate for i in recipe.items) / recipe.sku.fraction
total_sugar = sum(i.quantity * i.product.total_sugar for i in recipe.items) / recipe.sku.fraction
added_sugar = sum(i.quantity * i.product.added_sugar for i in recipe.items) / recipe.sku.fraction
total_fat = sum(i.quantity * i.product.total_fat for i in recipe.items) / recipe.sku.fraction
saturated_fat = sum(i.quantity * i.product.saturated_fat for i in recipe.items) / recipe.sku.fraction
trans_fat = sum(i.quantity * i.product.trans_fat for i in recipe.items) / recipe.sku.fraction
cholestrol = sum(i.quantity * i.product.cholestrol for i in recipe.items) / recipe.sku.fraction
sodium = sum(i.quantity * i.product.sodium for i in recipe.items) / recipe.sku.fraction
msnf = sum(i.quantity * i.product.msnf for i in recipe.items) / recipe.sku.fraction
other_solids = sum(i.quantity * i.product.other_solids for i in recipe.items) / recipe.sku.fraction
total_solids = sum(i.quantity * i.product.total_solids for i in recipe.items) / recipe.sku.fraction
water = sum(i.quantity * i.product.water for i in recipe.items) / recipe.sku.fraction
db.execute(
update(Product)
.where(Product.id == recipe.sku.product_id)
.values(
protein=protein,
carbohydrate=carbohydrate,
total_sugar=total_sugar,
added_sugar=added_sugar,
total_fat=total_fat,
saturated_fat=saturated_fat,
trans_fat=trans_fat,
cholestrol=cholestrol,
sodium=sodium,
msnf=msnf,
other_solids=other_solids,
total_solids=total_solids,
water=water,
)
)
products.remove(recipe.sku.product_id)
db.flush()
def report_nutrition(db: Session) -> list[NutritionalInformation]:
try:
# Get all recipes that have nutritional values
products = set(
db.execute(
select(distinct(StockKeepingUnit.product_id))
.join(StockKeepingUnit.recipes)
.join(StockKeepingUnit.product)
.join(Product.product_group)
)
.scalars()
.all()
)
final, semi, ingredients = nut_final(db), nut_semi(db), nut_ingredients(db)
return report(products, final, semi, ingredients, db)
except SQLAlchemyError as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=str(e),
)
def report(
products: set[uuid.UUID], final: set[uuid.UUID], semi: set[uuid.UUID], ingredients: set[uuid.UUID], db: Session
) -> list[NutritionalInformation]:
_list: list[NutritionalInformation] = []
ingredient_dict: dict[str, set[str]] = {}
allergen_dict: dict[str, set[str]] = {}
ings = db.execute(select(Product).where(Product.id.in_(ingredients))).scalars().all()
for ingredient in ings:
if ingredient.id in products:
raise ValueError("Ingredient cannot be in a recipe")
ingredients.remove(ingredient.id)
ingredient_dict[ingredient.name] = set([ingredient.name])
allergen_dict[ingredient.name] = set(a.strip() for a in ingredient.allergen.split(",")) - set([""])
_list.append(
NutritionalInformation(
name=ingredient.name,
units=ingredient.fraction_units,
product_group="Ingredients",
description=ingredient.description,
ingredients=[ingredient.name],
allergen=list(allergen_dict[ingredient.name]),
protein=ingredient.protein,
carbohydrate=ingredient.carbohydrate,
total_sugar=ingredient.total_sugar,
added_sugar=ingredient.added_sugar,
total_fat=ingredient.total_fat,
saturated_fat=ingredient.saturated_fat,
trans_fat=ingredient.trans_fat,
cholestrol=ingredient.cholestrol,
sodium=ingredient.sodium,
msnf=ingredient.msnf,
other_solids=ingredient.other_solids,
total_solids=ingredient.total_solids,
water=ingredient.water,
)
)
while len(semi) > 0:
semi_products = (
db.execute(select(Recipe).join(Recipe.sku).where(StockKeepingUnit.product_id.in_(semi))).scalars().all()
)
for semi_recipe in semi_products:
semi_product = semi_recipe.sku.product
products.remove(semi_product.id)
semi.remove(semi_product.id)
ingredient_dict[semi_product.name] = set.union(
*[ingredient_dict[i.product.name] for i in semi_recipe.items]
)
allergen_dict[semi_product.name] = set.union(
*[allergen_dict[i.product.name] for i in semi_recipe.items]
) - set([""])
_list.append(
NutritionalInformation(
name=semi_product.name,
units=semi_product.fraction_units,
product_group="Semi",
description=semi_product.description,
ingredients=list(ingredient_dict[semi_product.name]),
allergen=list(allergen_dict[semi_product.name]),
protein=semi_product.protein,
carbohydrate=semi_product.carbohydrate,
total_sugar=semi_product.total_sugar,
added_sugar=semi_product.added_sugar,
total_fat=semi_product.total_fat,
saturated_fat=semi_product.saturated_fat,
trans_fat=semi_product.trans_fat,
cholestrol=semi_product.cholestrol,
sodium=semi_product.sodium,
msnf=semi_product.msnf,
other_solids=semi_product.other_solids,
total_solids=semi_product.total_solids,
water=semi_product.water,
)
)
recipes = db.execute(select(Recipe).join(Recipe.sku).where(StockKeepingUnit.product_id.in_(final))).scalars().all()
for recipe in recipes:
recipe_product = recipe.sku.product
products.remove(recipe_product.id)
final.remove(recipe_product.id)
ingredient_dict[recipe_product.name] = set.union(*[ingredient_dict[i.product.name] for i in recipe.items])
allergen_dict[recipe_product.name] = set.union(*[allergen_dict[i.product.name] for i in recipe.items]) - set(
[""]
)
_list.append(
NutritionalInformation(
name=recipe_product.name,
units=recipe_product.fraction_units,
product_group=recipe_product.product_group.name,
description=recipe_product.description,
ingredients=list(ingredient_dict[recipe_product.name]),
allergen=list(allergen_dict[recipe_product.name]),
protein=recipe_product.protein,
carbohydrate=recipe_product.carbohydrate,
total_sugar=recipe_product.total_sugar,
added_sugar=recipe_product.added_sugar,
total_fat=recipe_product.total_fat,
saturated_fat=recipe_product.saturated_fat,
trans_fat=recipe_product.trans_fat,
cholestrol=recipe_product.cholestrol,
sodium=recipe_product.sodium,
msnf=recipe_product.msnf,
other_solids=recipe_product.other_solids,
total_solids=recipe_product.total_solids,
water=recipe_product.water,
)
)
if len(products) > 0 or len(final) > 0 or len(semi) > 0 or len(ingredients) > 0:
raise ValueError("They cannot be more than 0")
return _list
def nut_final(db: Session) -> set[uuid.UUID]:
sq = select(StockKeepingUnit.product_id).where(StockKeepingUnit.id.in_(select(Recipe.sku_id)))
ingredients = (
db.execute(select(Product.id).where(Product.id.notin_(select(RecipeItem.product_id)), Product.id.in_(sq)))
.scalars()
.all()
)
return set(ingredients)
def nut_ingredients(db: Session) -> set[uuid.UUID]:
sq = select(StockKeepingUnit.product_id).where(StockKeepingUnit.id.in_(select(Recipe.sku_id)))
ingredients = (
db.execute(select(Product.id).where(Product.id.in_(select(RecipeItem.product_id)), Product.id.notin_(sq)))
.scalars()
.all()
)
return set(ingredients)
def nut_semi(db: Session) -> set[uuid.UUID]:
sq = select(StockKeepingUnit.product_id).where(StockKeepingUnit.id.in_(select(Recipe.sku_id)))
ingredients = (
db.execute(select(Product.id).where(Product.id.in_(select(RecipeItem.product_id)), Product.id.in_(sq)))
.scalars()
.all()
)
return set(ingredients)

View File

@ -34,12 +34,14 @@ def save(
with SessionFuture() as db:
item = Product(
name=data.name,
description=data.description,
fraction_units=data.fraction_units,
product_group_id=data.product_group.id_,
account_id=Account.all_purchases(),
is_active=data.is_active,
is_purchased=data.is_purchased,
is_sold=data.is_sold,
allergen=data.allergen,
protein=data.protein,
carbohydrate=data.carbohydrate,
total_sugar=data.total_sugar,
@ -96,6 +98,7 @@ def update_route(
detail=f"{item.name} is a fixture and cannot be edited or deleted.",
)
item.name = data.name
item.description = data.description
item.fraction_units = data.fraction_units
item.product_group_id = data.product_group.id_
item.account_id = Account.all_purchases()
@ -103,6 +106,7 @@ def update_route(
item.is_purchased = data.is_purchased
item.is_sold = data.is_sold
item.allergen = data.allergen
item.protein = data.protein
item.carbohydrate = data.carbohydrate
item.total_sugar = data.total_sugar
@ -305,6 +309,7 @@ def product_info(product: Product) -> schemas.Product:
id_=product.id,
code=product.code,
name=product.name,
description=product.description,
fraction_units=product.fraction_units,
skus=[
schemas.StockKeepingUnit(
@ -322,6 +327,7 @@ def product_info(product: Product) -> schemas.Product:
is_purchased=product.is_purchased,
is_sold=product.is_sold,
product_group=schemas.ProductGroupLink(id_=product.product_group.id, name=product.product_group.name),
allergen=product.allergen,
protein=product.protein,
carbohydrate=product.carbohydrate,
total_sugar=product.total_sugar,
@ -341,12 +347,14 @@ def product_info(product: Product) -> schemas.Product:
def product_blank() -> schemas.ProductBlank:
return schemas.ProductBlank(
name="",
description="",
fraction_units="",
skus=[],
is_active=True,
is_purchased=True,
is_sold=False,
is_fixture=False,
allergen="",
protein=0,
carbohydrate=0,
total_sugar=0,

View File

@ -8,11 +8,14 @@ from decimal import Decimal
from io import BytesIO
from typing import Sequence
from ..schemas.nutritional_information import NutritionalInformation
from ..routers.calculate_nutrition import calculate_nutrition, report_nutrition
import brewman.schemas.recipe as schemas
import brewman.schemas.recipe_item as rischemas
from brewman.models.price import Price
from brewman.routers.calculate_prices import calculate_prices
from ..models.price import Price
from ..routers.calculate_prices import calculate_prices
from fastapi import APIRouter, Depends, HTTPException, Request, Security, status
from fastapi.responses import FileResponse, StreamingResponse
from openpyxl import Workbook
@ -26,7 +29,6 @@ from ..db.session import SessionFuture
from ..models.product import Product
from ..models.recipe import Recipe
from ..models.recipe_item import RecipeItem
from ..models.recipe_template import RecipeTemplate
from ..models.stock_keeping_unit import StockKeepingUnit
from ..schemas.user import UserToken
@ -352,6 +354,89 @@ def excel(prices: list[tuple[str, str, Decimal, Decimal, Decimal]], recipes: lis
return virtual_workbook
@router.get("/nutrition", response_class=StreamingResponse)
def get_nutrition(
p: uuid.UUID | None = None,
) -> StreamingResponse:
with SessionFuture() as db:
calculate_nutrition(db)
db.commit()
list_: list[NutritionalInformation] = []
with SessionFuture() as db:
list_ = report_nutrition(db)
# q = (
# select(Recipe)
# .join(Recipe.sku)
# .join(StockKeepingUnit.product)
# .join(Product.product_group)
# .where(or_(ProductGroup.nutritional == True, ProductGroup.ice_cream == True)) # noqa: E712
# .options(
# contains_eager(Recipe.sku, StockKeepingUnit.product, Product.product_group),
# )
# )
# list_ = [
# NutritionalInformation(
# name=i.sku.product.name,
# units=i.sku.units,
# product_group=i.sku.product.product_group.name,
# **i.sku.product,
# )
# for i in db.execute(q).unique().scalars().all()
# ]
e = nut(sorted(list_, key=lambda r: r.name))
e.seek(0)
headers = {"Content-Disposition": "attachment; filename = nutritional.xlsx"}
return StreamingResponse(e, media_type="text/xlsx", headers=headers)
def nut(products: list[NutritionalInformation]) -> BytesIO:
wb = Workbook()
wb.active.title = "Ingredients"
pgs = set([x.product_group for x in products])
pgs.remove("Ingredients")
for pg in pgs:
wb.create_sheet(pg)
rows = defaultdict(lambda: 1)
register_styles(wb)
for item in products:
ws = wb[item.product_group]
row = rows[item.product_group]
if row == 1:
ws.cell(row=row, column=1, value="Product").style = "header"
ws.cell(row=row, column=2, value="Description").style = "header"
ws.cell(row=row, column=3, value="Ingredients").style = "header"
ws.cell(row=row, column=4, value="Allergen").style = "header"
ws.cell(row=row, column=5, value="Protein").style = "header"
ws.cell(row=row, column=6, value="Carbohydrate").style = "header"
ws.cell(row=row, column=7, value="Total Sugar").style = "header"
ws.cell(row=row, column=8, value="Added Sugar").style = "header"
ws.cell(row=row, column=9, value="Total Fat").style = "header"
ws.cell(row=row, column=10, value="Saturated Fat").style = "header"
ws.cell(row=row, column=11, value="Trans Fat").style = "header"
ws.cell(row=row, column=12, value="Cholestrol").style = "header"
ws.cell(row=row, column=13, value="Sodium").style = "header"
row += 1
ws.cell(row=row, column=1, value=f"{item.name} ({item.units})").style = "ing"
ws.cell(row=row, column=2, value=item.description).style = "ing"
ws.cell(row=row, column=3, value=", ".join(item.ingredients)).style = "ing"
ws.cell(row=row, column=4, value=", ".join(item.allergen)).style = "ing"
ws.cell(row=row, column=5, value=item.protein).style = "ing"
ws.cell(row=row, column=6, value=item.carbohydrate).style = "ing"
ws.cell(row=row, column=7, value=item.total_sugar).style = "ing"
ws.cell(row=row, column=8, value=item.added_sugar).style = "ing"
ws.cell(row=row, column=9, value=item.total_fat).style = "ing"
ws.cell(row=row, column=10, value=item.saturated_fat).style = "ing"
ws.cell(row=row, column=11, value=item.trans_fat).style = "ing"
ws.cell(row=row, column=12, value=item.cholestrol).style = "ing"
ws.cell(row=row, column=13, value=item.sodium).style = "ing"
row += 1
rows[item.product_group] = row + 1
virtual_workbook = BytesIO()
wb.save(virtual_workbook)
return virtual_workbook
def register_styles(wb: Workbook) -> tuple[NamedStyle, NamedStyle, NamedStyle, NamedStyle, NamedStyle]:
bd = Side(style="thin", color="000000")

View File

@ -0,0 +1,28 @@
from decimal import Decimal
from pydantic import BaseModel
class NutritionalInformation(BaseModel):
name: str
units: str
product_group: str
description: str | None
ingredients: list[str]
allergen: list[str]
protein: Decimal
carbohydrate: Decimal
total_sugar: Decimal
added_sugar: Decimal
total_fat: Decimal
saturated_fat: Decimal
trans_fat: Decimal
cholestrol: Decimal
sodium: Decimal
msnf: Decimal
other_solids: Decimal
total_solids: Decimal
water: Decimal

View File

@ -17,6 +17,7 @@ class ProductLink(BaseModel):
class ProductIn(BaseModel):
name: str = Field(..., min_length=1)
description: str | None
fraction_units: str = Field(..., min_length=1)
skus: list[StockKeepingUnit]
product_group: ProductGroupLink = Field(...)
@ -24,6 +25,7 @@ class ProductIn(BaseModel):
is_purchased: bool
is_sold: bool
allergen: str
protein: Decimal
carbohydrate: Decimal
total_sugar: Decimal

View File

@ -21,6 +21,7 @@ export class Product {
id: string | undefined;
code: number;
name: string;
description: string | undefined;
skus: StockKeepingUnit[];
fractionUnits: string | undefined;
@ -30,6 +31,7 @@ export class Product {
isSold: boolean;
productGroup?: ProductGroup;
allergen: string;
protein: number;
carbohydrate: number;
totalSugar: number;
@ -55,6 +57,7 @@ export class Product {
this.isPurchased = true;
this.isSold = false;
this.allergen = '';
this.protein = 0;
this.carbohydrate = 0;
this.totalSugar = 0;

View File

@ -20,6 +20,12 @@
<input matInput formControlName="fractionUnits" />
</mat-form-field>
</div>
<div class="flex flex-row justify-around content-start items-start">
<mat-form-field class="flex-auto">
<mat-label>Description</mat-label>
<input matInput #nameElement formControlName="description" />
</mat-form-field>
</div>
<div class="flex flex-row justify-around content-start items-start">
<mat-checkbox formControlName="isPurchased" class="flex-auto mr-5">Is Purchased?</mat-checkbox>
<mat-checkbox formControlName="isSold" class="flex-auto mr-5">Is Sold?</mat-checkbox>
@ -35,6 +41,12 @@
</mat-select>
</mat-form-field>
</div>
<div class="flex flex-row justify-around content-start items-start">
<mat-form-field class="flex-auto">
<mat-label>Allergen</mat-label>
<input matInput #nameElement formControlName="allergen" />
</mat-form-field>
</div>
<h2 *ngIf="item.productGroup?.nutritional ?? false">Nutritional Information</h2>
<div
class="flex flex-row justify-around content-start items-start"

View File

@ -23,6 +23,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
form: FormGroup<{
code: FormControl<string | number>;
name: FormControl<string | null>;
description: FormControl<string | null>;
fractionUnits: FormControl<string | null>;
addRow: FormGroup<{
units: FormControl<string | null>;
@ -36,6 +37,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
isActive: FormControl<boolean>;
productGroup: FormControl<string | null>;
allergen: FormControl<string | null>;
protein: FormControl<number>;
carbohydrate: FormControl<number>;
totalSugar: FormControl<number>;
@ -69,6 +71,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
this.form = new FormGroup({
code: new FormControl<string | number>({ value: 0, disabled: true }, { nonNullable: true }),
name: new FormControl<string | null>(null),
description: new FormControl<string | null>(null),
fractionUnits: new FormControl<string | null>(null),
addRow: new FormGroup({
units: new FormControl<string | null>(null),
@ -82,6 +85,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
isActive: new FormControl<boolean>(true, { nonNullable: true }),
productGroup: new FormControl<string | null>(null),
allergen: new FormControl<string | null>(null),
protein: new FormControl<number>(0, { nonNullable: true }),
carbohydrate: new FormControl<number>(0, { nonNullable: true }),
totalSugar: new FormControl<number>(0, { nonNullable: true }),
@ -116,6 +120,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
this.form.setValue({
code: this.item.code || '(Auto)',
name: this.item.name,
description: this.item.description || '',
fractionUnits: this.item.fractionUnits ?? '',
addRow: {
units: '',
@ -129,6 +134,7 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
isActive: this.item.isActive,
productGroup: this.item.productGroup ? this.item.productGroup.id ?? '' : '',
allergen: this.item.allergen ?? '',
protein: this.item.protein,
carbohydrate: this.item.carbohydrate,
totalSugar: this.item.totalSugar,
@ -266,11 +272,13 @@ export class ProductDetailComponent implements OnInit, AfterViewInit {
getItem(): Product {
const formModel = this.form.value;
this.item.name = formModel.name ?? '';
this.item.description = formModel.description ?? '';
this.item.fractionUnits = formModel.fractionUnits ?? '';
this.item.isPurchased = formModel.isPurchased ?? true;
this.item.isSold = formModel.isSold ?? false;
this.item.isActive = formModel.isActive ?? true;
this.item.allergen = formModel.allergen ?? '';
this.item.protein = formModel.protein ?? 0;
this.item.carbohydrate = formModel.carbohydrate ?? 0;
this.item.totalSugar = formModel.totalSugar ?? 0;

View File

@ -5,6 +5,9 @@
<a mat-icon-button [href]="'/api/recipes/xlsx?t=' + period.id">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-icon-button href="/api/recipes/nutrition">
<mat-icon>save_alt</mat-icon>
</a>
<a mat-button [routerLink]="['/recipes', 'new']">
<mat-icon>add_box</mat-icon>
Add