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
|