333 lines
12 KiB
Markdown
333 lines
12 KiB
Markdown
# FunkyJuice Recipes
|
||
A python application for managing e-juice recipes
|
||
* Uses SQLite3 as database
|
||
* Uses Toga as GUI
|
||
* Uses Peewee ORM
|
||
* Separate files per model
|
||
* Separate files per migration
|
||
* Migrations for database schema changes using `playhouse.migrate`
|
||
* Migrations are versioned via the date they were created (YYYY-MM-DD_migration_name.py)
|
||
* Migrations state is stored in a Migrations table in the database
|
||
* Migrations table does not use a migration file of its own
|
||
* Migrations are run automatically on startup (check migration files vs database entries in migrations table)
|
||
* At startup
|
||
* The app creates (if missing) the per‑user data directory at `app.paths.data`
|
||
* The app stores the SQLite DB at `<app.paths.data>/funkyjuicerecipes.data`.
|
||
* The connection enables pragmas: `{'foreign_keys': 1, 'journal_mode': 'wal', 'synchronous': 1}`.
|
||
* Tests use a temporary DB path.
|
||
* Uses class wrappers for Toga widgets
|
||
* FunkyJuiceRecipesApp(toga.App)
|
||
* MainWindow(toga.MainWindow)
|
||
* RecipesTable(toga.Table)
|
||
* RecipeForm(toga.Box -> with form elements as needed)
|
||
* AddRecipeButton(toga.Button)
|
||
* EditRecipeButton(toga.Button)
|
||
* DeleteRecipeButton(toga.Button -> with confirmation dialog)
|
||
* FlavorsTable(toga.Table)
|
||
* AddFlavorButton(toga.Button)
|
||
* EditFlavorButton(toga.Button)
|
||
* DeleteFlavorButton(toga.Button -> with confirmation dialog)
|
||
* FlavorForm(toga.Box -> with form elements as needed)
|
||
* Uses briefcase to package app for distribution
|
||
* Code follows PEP8
|
||
* Code follows SOLID principles
|
||
* SRP at method level
|
||
|
||
## How to Run
|
||
* `pip install briefcase`
|
||
* or `uv add briefcase`
|
||
* `briefcase dev`
|
||
* or `python -m funkyjuicerecipes.app`
|
||
|
||
## Flavors Soft Delete Policy
|
||
* When a flavor is deleted
|
||
* Flavors are Soft Deleted if they are in a recipe.
|
||
* Flavors are Hard Deleted if they are not in a recipe.
|
||
* Flavors can be Un-Deleted when viewing the Soft Deleted Flavors list.
|
||
* Flavors can be Hard Deleted when viewing the Soft Deleted List and they are no longer in a recipe.
|
||
* UI Queries default to `WHERE is_deleted = 0`
|
||
* Hard Delete is only allowed when `NOT EXISTS (SELECT 1 FROM recipe_flavor WHERE flavor_id=flavor.id)`
|
||
|
||
## Directory/File Structure
|
||
* project-root/
|
||
* pyproject.toml
|
||
* README.md
|
||
* src/
|
||
* funkyjuicerecipes/
|
||
* __init__.py
|
||
* app.py # Toga App subclass and `main()`
|
||
* ui/
|
||
* __init__.py
|
||
* buttons/
|
||
* __init__.py
|
||
* add_recipe_button.py
|
||
* edit_recipe_button.py
|
||
* delete_recipe_button.py
|
||
* add_flavor_button.py
|
||
* edit_flavor_button.py
|
||
* delete_flavor_button.py
|
||
* forms/
|
||
* __init__.py
|
||
* recipe_form.py
|
||
* flavor_form.py
|
||
* tables/
|
||
* __init__.py
|
||
* recipes_table.py
|
||
* flavors_table.py
|
||
* dialogs/
|
||
* __init__.py
|
||
* delete_recipe_confirmation.py
|
||
* delete_flavor_confirmation.py
|
||
* data/
|
||
* __init__.py
|
||
* db.py # database init (Peewee SqliteDatabase, pragmas)
|
||
* models/
|
||
* __init__.py
|
||
* flavor.py
|
||
* recipe.py
|
||
* recipe_flavor.py
|
||
* migrations/
|
||
* __init__.py
|
||
* 2025-12-18_flavor_table_created.py
|
||
* 2025-12-18_recipe_table_created.py
|
||
* 2025-12-18_recipe_flavor_table_created.py
|
||
* logic/
|
||
* __init__.py
|
||
* calculations.py # pure functions for PG/VG/nic/flavor math
|
||
* resources/ # icons, etc.
|
||
* tests/
|
||
* test_calculations.py
|
||
|
||
## Requirements
|
||
* Python 3.8+
|
||
* Toga
|
||
* Peewee
|
||
* playhouse.migrate
|
||
* Briefcase
|
||
|
||
## DB Schema
|
||
* Enable FKs on connection: `SqliteDatabase(path, pragmas={'foreign_keys': 1})`
|
||
* Flavor Table
|
||
```
|
||
flavor(
|
||
id INTEGER PRIMARY KEY,
|
||
name TEXT COLLATE NOCASE NOT NULL,
|
||
company TEXT COLLATE NOCASE NOT NULL,
|
||
base TEXT CHECK(base IN ('PG','VG')) NOT NULL,
|
||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||
deleted_at DATETIME NULL,
|
||
UNIQUE(name, company)
|
||
)
|
||
CREATE INDEX IF NOT EXISTS idx_flavor_name_company ON flavor(name, company);
|
||
```
|
||
* Recipe Table
|
||
```
|
||
recipe(
|
||
id INTEGER PRIMARY KEY,
|
||
name TEXT NOT NULL UNIQUE,
|
||
size_ml INTEGER NOT NULL,
|
||
base_pg_pct REAL NOT NULL,
|
||
base_vg_pct REAL NOT NULL,
|
||
nic_pct REAL CHECK(nic_pct >= 0 AND nic_pct <= 100) NOT NULL,
|
||
nic_base TEXT CHECK(nic_base IN ('PG','VG')) NOT NULL,
|
||
CHECK (
|
||
base_pg_pct >= 0 AND
|
||
base_vg_pct >= 0 AND
|
||
base_pg_pct + base_vg_pct = 100
|
||
)
|
||
)
|
||
```
|
||
* RecipeFlavor XREF Table
|
||
```
|
||
recipe_flavor(
|
||
id INTEGER PRIMARY KEY,
|
||
recipe_id INTEGER NOT NULL,
|
||
flavor_id INTEGER NOT NULL,
|
||
pct REAL CHECK(pct >= 0 AND pct <= 100) NOT NULL,
|
||
FOREIGN KEY(recipe_id)
|
||
REFERENCES recipe(id) ON DELETE CASCADE,
|
||
FOREIGN KEY(flavor_id) REFERENCES flavor(id) ON DELETE RESTRICT,
|
||
UNIQUE(recipe_id, flavor_id)
|
||
)
|
||
CREATE INDEX IF NOT EXISTS idx_recipe_flavor_recipe ON recipe_flavor(recipe_id);
|
||
CREATE INDEX IF NOT EXISTS idx_recipe_flavor_flavor ON recipe_flavor(flavor_id);
|
||
```
|
||
* Migrations Table
|
||
```
|
||
migrations(
|
||
id INTEGER PRIMARY KEY,
|
||
batch INTEGER NOT NULL,
|
||
filename TEXT NOT NULL,
|
||
date_run DATETIME NOT NULL,
|
||
UNIQUE(filename)
|
||
)
|
||
```
|
||
|
||
## Recipes
|
||
* Recipes require
|
||
* Name,
|
||
* Size (ml),
|
||
* PG Base %,
|
||
* VG base %,
|
||
* Nic Base (PG/VG)
|
||
* Nic %,
|
||
* Flavor List
|
||
* Recipe equations are:
|
||
* flavor_pg_pct = sum(pct for flavor in flavors if flavor.base == 'PG')
|
||
* flavor_vg_pct = sum(pct for flavor in flavors if flavor.base == 'VG')
|
||
* pg_pct = base_pg_pct - flavor_pg_pct - (nic_pct if nic_base == 'PG' else 0)
|
||
* vg_pct = base_vg_pct - flavor_vg_pct - (nic_pct if nic_base == 'VG' else 0)
|
||
* PG_ml = pg_pct / 100 * size_ml
|
||
* VG_ml = vg_pct / 100 * size_ml
|
||
* Nic_ml = nic_pct / 100 * size_ml
|
||
* Flavor_i_ml = flavor_i.pct / 100 * size_ml
|
||
* Recipe output is:
|
||
* Recipe Name
|
||
* Size (ml)
|
||
* PG: PG_ml
|
||
* VG: VG_ml
|
||
* Nic: Nic_ml
|
||
* Flavors
|
||
* If viewing individual flavors:
|
||
* Flavor 1 Name: Flavor_1_ml
|
||
* Flavor 2 Name: Flavor_2_ml
|
||
* if viewing Flavor Prep:
|
||
* "Flavor Prep": Flavor_Prep_ml (sum(pct for flavor in flavors)/100 * size_ml)
|
||
|
||
## Main Screen
|
||
* Menu Bar with File, Recipes, and Help
|
||
* File Menu
|
||
* New Recipe
|
||
* Exit
|
||
* Recipes Menu
|
||
* Shows a list of saved recipes as menu items
|
||
* Selecting menu item opens the recipe screen
|
||
* Help Menu
|
||
* About screen
|
||
* Shows Saved Recipes List
|
||
* Recipe Name
|
||
* Button to open the recipe screen (View only)
|
||
* Button to edit the recipe (Edit form)
|
||
* Button to delete the recipe (Confirmation dialog)
|
||
* Has button on top to open Inventory Screen.
|
||
|
||
## About Screen
|
||
* Shows
|
||
* Application Name (FunkyJuice Recipes)
|
||
* Version Number
|
||
* Author (Funky Waddle)
|
||
|
||
## View Recipe Screen
|
||
* Needs access to soft deleted flavors for recipes that currently use deleted flavors.
|
||
* Show components rounded to 1 decimal point
|
||
* Just convert percentage to decimal number and display, no need to validate sum(amounts) = Size (ml)
|
||
* We store the percent in the database, not the actual amount, so there is no check that the amount adds to size (ml), only that sum(percentages) = 100
|
||
* Shows recipe name,
|
||
* Amount in ml,
|
||
* PG: PG_ml, (rounded to 1 decimal point)
|
||
* VG: VG_ml, (rounded to 1 decimal point)
|
||
* Nic: Nic_ml, (rounded to 1 decimal point)
|
||
* Flavors
|
||
* If viewing individual flavors:
|
||
* Flavor 1 Name: Flavor_1_ml (rounded to 1 decimal point)
|
||
* Flavor 2 Name: Flavor_2_ml (rounded to 1 decimal point)
|
||
* if viewing Flavor Prep:
|
||
* Flavor Prep Amount
|
||
* Has button to open Edit Recipe Screen
|
||
* Has button to open Delete Recipe
|
||
* Has button to open Inventory Screen
|
||
* Has Drop Down for "View Recipe Using"
|
||
* Options are "Individual Flavors" or "Flavor Prep"
|
||
* When Flavor Prep is chosen, it replaces the list of flavors with a total ml of the sum of all the flavors in the recipe.
|
||
* When Individual Flavors is chosen (default), it replaces the Flavor Prep with the list of individual flavors.
|
||
* Has button for "Mix Flavor Prep"
|
||
* Flavor Prep is not stored in database. It's calculated on the fly based on flavors in the recipe.
|
||
* Shows recipe of Flavors only, no PG, VG, or Nicotine
|
||
* Flavor Prep is a batch of flavors
|
||
* Take ratio of each flavor to get total amount of each flavor (12% flavor 1 + 2% flavor 2 = 6:1 ratio)
|
||
* Flavor Prep batches of different sizes (10ml, 30ml, 120ml)
|
||
* Flavor Prep is just pre-mixing flavors, much like premixing flour and sugar for use in cookie batter.
|
||
* You can make 10ml or 1000ml of Sugar Flour, and it won't change the amount used in the cookie recipe.
|
||
* You can pre-mix 1000ml of flavors, and use 10ml per recipe, giving you 100 batches of Flavor Prep.
|
||
|
||
## Edit Recipe Screen
|
||
* Needs access to soft deleted flavors for recipes that currently use deleted flavors.
|
||
* Has a form for editing the recipe
|
||
* Name
|
||
* Size (mL) (10ml, 30ml, 120ml)
|
||
* PG Base %
|
||
* VG Base %
|
||
* Nic Base (PG or VG)
|
||
* Nic %
|
||
* Flavors
|
||
* Name (with company)
|
||
* Amount %
|
||
* If recipe contains a deleted flavor,
|
||
* Recipe can be saved with the deleted flavor still in the list, as long as it was not changed.
|
||
* Cannot actively change a flavor to a deleted flavor.
|
||
* Has button to save changes (Save form, then return to View Recipe screen)
|
||
* Has button to cancel changes (Discard form changes, return to View Recipe screen)
|
||
* Has button to delete recipe (Confirmation dialog)
|
||
* On Save, if recipe contains duplicate flavors,
|
||
* If duplicate flavor is found:
|
||
* Show question dialog to ask if user wants to "Combine the duplicate flavors" or "Cancel Save" and return to Edit Recipe screen.
|
||
* If user chooses to combine duplicate flavors,
|
||
* Combine flavors with same name and company into one flavor with combined percentage.
|
||
* If user chooses to cancel save,
|
||
* DO NOT SAVE and close dialog box, returning user to Edit Recipe screen.
|
||
* Validate that (PG Base + VG Base) === 100
|
||
* Validate that neither PG Base nor VG Base is < 0
|
||
* Validate sum(flavor.pct) + nic.pct + pg.pct + vg.pct ~= 100
|
||
* On App Exit, if on Edit Recipe screen and data has changed, ask to save changes before exiting.
|
||
* On Screen Change, if on Edit Recipe screen and data has changed, ask to save changes before changing screens.
|
||
|
||
## Add Recipe Screen
|
||
* Has a form for adding a recipe
|
||
* Name
|
||
* Size (mL) (10ml, 30ml, 120ml)
|
||
* PG Base %
|
||
* VG Base %
|
||
* Nic Base (PG or VG)
|
||
* Nic %
|
||
* Flavors
|
||
* Name
|
||
* Amount %
|
||
* Has button to save changes (Save form, then return to View Recipe screen)
|
||
* Has button to cancel changes (Discard form changes, return to View Recipe screen)
|
||
* On Save, if recipe contains duplicate flavors,
|
||
* If duplicate flavor is found:
|
||
* Show question dialog to ask if user wants to "Combine the duplicate flavors" or "Cancel Save" and return to Edit Recipe screen.
|
||
* If user chooses to combine duplicate flavors,
|
||
* Combine flavors with same name and company into one flavor with combined percentage.
|
||
* If user chooses to cancel save,
|
||
* DO NOT SAVE and close dialog box, returning user to Edit Recipe screen.
|
||
* Validate that (PG Base + VG Base) === 100
|
||
* Validate that neither PG Base nor VG Base is < 0
|
||
* Validate sum(flavor.pct) + nic.pct + pg.pct + vg.pct ~= 100
|
||
* On App Exit, if on Edit Recipe screen and data has changed, ask to save changes before exiting.
|
||
* On Screen Change, if on Edit Recipe screen and data has changed, ask to save changes before changing screens.
|
||
|
||
## Recipe Form Screens (Add/Edit)
|
||
* When adding/editing a recipe, you enter the PG, VG, Flavors, and Nic as %
|
||
* PG and VG are entered as % of total recipe size
|
||
* When changing PG or VG, the other is updated automatically so that PG + VG is always 100%
|
||
* Neither PG or VG can be negative
|
||
|
||
## Inventory Screen
|
||
* List of all Non-Deleted Flavors saved in the database
|
||
* Flavor Name
|
||
* Company
|
||
* PG or VG Base
|
||
* Has button to view Soft Deleted Flavors, and how many recipes they are in
|
||
* Can Hard Delete Soft Deleted Flavors if they are no longer in a recipe
|
||
* Can Un-Delete a Soft Deleted Flavor
|
||
* Each Flavor has a button to edit the flavor (Name, Company, PG or VG Base)
|
||
* Each Flavor has a button to delete the flavor
|
||
* When deleting a flavor,
|
||
* If flavor is in a recipe,
|
||
* Soft Delete the flavor
|
||
* Allow flavor data to be viewed in recipe, but with a strikethrough
|
||
* If flavor is not in a recipe,
|
||
* Hard Delete the flavor
|
||
* NO DUPLICATES ALLOWED
|