Published 2021-03-03.
Time to read: 2 minutes.
ancientWarmth
collection.
Private, for my own use only. Do not publish!
Nomenclature
Things
- Mixture
- Specifies only chemical parts/percentages
- Design
- Specifies the combination of:
- mixture
- essential oils (mL/L)
- color
- other non-chemical additives
- skin
- Package
- Design quantity (total grams), placed into a container, with a label affixed, after manufacturing.
People
- User
- someone with an Ancient Warmth login id.
- Bather
- User who obtained a package for their personal use. Users become bathers after their first purchase.
- Designer
- User who customized a design. It is possible to be a designer without being a bather - just don’t order a package.
Mixture
- Has optional description
-
Has a designer’s handle, a name and a version
userHandle:Hot spring:xxx
- Heritage is tracked (copied from
userX:blah blah:xxx
, originated fromuserY:blah blah
) - Classified into a taxonomy? (Ancient Warmth defines taxonomy)
Design
- Mixture customized by adding items that do not affect the chemistry, but do affect the bather’s experience
- Also has an optional description
- Classified into one or more taxonomies (Ancient Warmth defines taxonomy structure)
- Therapy / skin / eczema
- Relaxation / silky
- Relaxation / hot spring
- Relaxation / sleep well
- Heritage is tracked (
copied from xxx, originated as yyy
) - Default skin can be redefined by designer
- Search by word
- Sort by popularity
- Popular near me
- Show geographic recipe popularity
- Share recipe link
- RSS design feed
Users
- Every user has an avatar or photo
- Can order packaged designs
- Can share designs with others
Bathers
- Can rank designs they ordered
- Get one point for ranking a design
Designers
- Display top x% for their recipes
- Grams sold per recipe or for all clones
- Units sold per recipe or for all clones
- Number of clones
- Top x% in city / region / country / world
- Can upload skins, a default skin and a skin for each of their mixtures or designs
- The designer called Original is an alias for Ancient Warmth
- Get one point for each star in a ranking
- Should designers get points for # grams shipped of their designs?
- Have a mission statement
Leaderboards
- Top designers
- Top designs
- Trending designs
- Trending designers
JSON data
Used for proof of concept video demo.
Property names should be camelCase,
this standard had not been established for the video demo.
Any property names that are still snake cased should be renamed ASAP;
currently only mixtures.json
exhibits this issue.
Chemicals
Chemical formula
contains UTF-8 characters, so superscripts and subscripts display well.
In the video demo, fullDescription
were arrays of short text lines.
For the real product, fullDescription
will be HTML fragments.
[ { "altNames": [ "Salt for diabetics", "reduced-sodium replacement salt", "NoSalt", "Nu-Salt", "No Salt Original" ], "formula": "KCl", "fullDescription": [ "European 2009/54/EC Directive classifies mineral waters with more than 200 mg/L of chloride, as ‘water with chloride’." ], "fullName": "Potassium Chloride", "id": "KCl", "shortDescription": "This is a short description." }, { "altNames": [ "Magnesium flake" ], "formula": "MgCl", "fullDescription": [ "Dissolved magnesium makes water harder.", "European 2009/54/EC Directive classifies mineral waters with more than 50 mg/L of magnesium as ‘water with magnesium’,", "and they classify mineral waters with more than 50 mg/L of chloride as 'water with chloride'." ], "fullName": "Magnesium Chloride", "id": "MgCl", "shortDescription": "This is a short description." }, { "altNames": [ "Epsom salt", "magnesium sulphate", "magnesium sulfate heptahydrate" ], "formula": "MgSO₄·7H₂O", "fullDescription": [ "<p>", "Magnesium sulfate is commonly used in bath salts, exfoliants, muscle relaxers and pain relievers.", "It is often used in foot baths to soothe sore feet, and to hasten recovery from muscle pain, soreness, or injury.", "</p>", "<p>", "Does not dissolve as easily as magnesium chloride, and is not absorbed through the skin as easily either.", "This chemical is actually half water by weight, so magnesium chloride costs less to ship.", "</p>", "<p>", "Dissolved magnesium makes water harder.", "</p>", "<p>", "European 2009/54/EC Directive classifies mineral waters with more than 50 mg/L of magnesium as ‘water with magnesium’,", "and they classify mineral waters with more than 200 mg/L of sulfate as 'water with sulphate'.", "This chemical has been discovered on the planet Mars and the dwarf planet Ceres.", "</p>" ], "fullName": "Magnesium Sulfate", "id": "MgSO4", "shortDescription": "This is a short description." }, { "altNames": [ "Baking soda", "bicarbonate of soda", "nahcolite" ], "formula": "NaHCO₃", "fullDescription": [ "Raises pH (makes water more alkaline), which in moderation helps with exfoliation and also makes skin feel smoother.", "Water with pH between 7.5 and 8.1 can provide these benefits.", "European 2009/54/EC Directive classifies mineral waters with more than 200 mg/L of sodium, as ‘water with sodium’." ], "fullName": "Sodium Bicarbonate", "id": "NaHCO3", "shortDescription": "This is a short description." }, { "altNames": [ "Vitamin C", "ascorbic acid", "sodium salt of ascorbic acid", "food additive E301" ], "formula": "C₆H₇O₆Na", "fullDescription": [ "Biofriendly and safe dechlorinator.", "Food additive E301.", "An antioxidant and acidity regulator." ], "fullName": "Sodium Ascorbate", "id": "C6H7NaO6", "shortDescription": "Biofriendly and safe dechlorinator." }, { "altNames": [ "Glauber’s salt", "sodium sulphate", "food additive E514", "decahydrate of sodium sulfate" ], "formula": "Na₂SO₄", "fullDescription": [ "The <a href='https://www.onsen-msrc.com/kenko_f/onsen_english/contents/donoonsen.html'>Onsen Medical Scient Research Center</a> in Japan", "advocates bathing in hot springs (Onsen) containing sodium sulfate for high blood pressure, hardening of the arteries, and external wounds.", "Sodium sulfate is used by the food industry as a diluent for food colors.", "Its largest use is as filler in powdered home laundry detergents.", "It is a neutral salt, which forms aqueous solutions with pH of 7.", "European 2009/54/EC Directive classifies mineral waters with more than 200 mg/L of sulfate as ‘water with sulphate’.", "Pure sodium sulfate dust can cause temporary asthma or eye irritation; do not pour into water from a height." ], "fullName": "Sodium Sulfate", "id": "Na2SO4", "shortDescription": "This is a short description." }, { "altNames": [ "Salt", "table salt", "pool salt" ], "formula": "NaCl", "fullDescription": [ "European 2009/54/EC Directive classifies mineral waters with more than 200 mg/L of chloride, as ‘water with chloride’." ], "fullName": "Sodium Chloride", "id": "NaCl", "shortDescription": "This is a short description." }, ]
Colors
[ { "kidFriendly": False, "id": "natural", "name": "Natural (mostly or completely white)" }, { "kidFriendly": False, "id": "soft_red", "name": "Soft red" }, { "kidFriendly": False, "id": "soft_green", "name": "Soft green" }, { "kidFriendly": False, "id": "soft_blue", "name": "Soft blue" }, { "kidFriendly": False, "id": "soft_orange", "name": "Soft orange" } ]
Essential Oils
For the video demo, there was no photo_toxic
property.
This will change for the real product.
[ { "kidFriendly": False, "id": "bergamot", "longDescription": "Not photo-toxic.", "name": "Bergamot - Bergaptene Free", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "cedarwood", "longDescription": "", "name": "Cedarwood - Atlas", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "frankincense", "longDescription": "", "name": "Frankincense - India", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "ginger_root", "longDescription": "", "name": "Ginger Root", "shortDescription": "This is a short description" }, { "kidFriendly": True, "id": "lavender", "longDescription": "", "name": "Lavender - natural blend", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "lemon", "longDescription": "This is a long description", "name": "Lemon - natural blend", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "lemongrass", "longDescription": "This is a long description", "name": "Lemongrass", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "orange", "longDescription": "This is a long description", "name": "orange - natural blend", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "spearmint", "longDescription": "This is a long description", "name": "Spearmint", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "tea_tree", "longDescription": "This is a long description", "name": "Tea tree - organic", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "turmeric", "longDescription": "This is a long description", "name": "Turmeric - Organic", "shortDescription": "This is a short description" }, { "kidFriendly": False, "id": "ylang_ylang", "longDescription": "This is a long description", "name": "Ylang Ylang", "shortDescription": "This is a short description" } ]
Mixtures
For the video demo, mixtures were called recipes.
[ { "created_on": "2021-01-01", "description": "Enjoy a hot springs experience in the comfort of your own bathtub!", "ingredients": [ { "id": "KCl", "parts": 10 }, { "id": "NaCl", "parts": 10 }, { "id": "NaHCO3", "parts": 2 } ], "essential_oils": [ ], "id": "1", "name": "Hot Spring Mix #1", "user_id": "Original" }, { "created_on": "2021-01-20", "description": "Unwind after a long week.", "ingredients": [ { "id": "KCl", "parts": 10 }, { "id": "NaCl", "parts": 10 }, { "id": "NaHCO3", "parts": 4 } ], "essential_oils": [ { "lemongrass": 5 } ], "id": "2", "name": "Relaxation Mix #1", "user_id": "Original" }, { "created_on": "2021-02-01", "description": "Rejuvenate body and soul.", "ingredients": [ { "id": "C6H7NaO6", "parts": 1 }, { "id": "KCl", "parts": 20 }, { "id": "MgCl", "parts": 20 }, { "id": "NaCl", "parts": 20 }, { "id": "NaHCO3", "parts": 5 }, { "id": "Na2SO4", "parts": 3 } ], "essential_oils": [ { "lemongrass": 5 }, { "lavender": 5 }, { "frankincense": 1 } ], "id": "3", "name": "Rejuvenation Mix #1", "user_id": "Original" } ]
JavaScript
Used for proof of concept video demo. Since then, the term recipe has been superceded by the term mixture.
--- --- class Chemical { constructor({altNames, formula, fullDescription, fullName, id, shortDescription}) { this.altNames = altNames; this.formula = formula; this.fullDescription = fullDescription; this.fullName = fullName; this.id = id; this.shortDescription = shortDescription; } } class Chemicals { constructor(chemicalsJsonArray) { this.chemicals = chemicalsJsonArray.map(chemicalJson => { return new Chemical(chemicalJson); }); } } class EssentialOil { constructor({name, parts}) { this.name = name; this.parts = parts; } } class Ingredient { constructor({id, parts}) { this.id = id; this.parts = parts; } asHtml() { return `<li>${this.id}<span class='recipe_gallery_part'>: ${this.parts} parts</span></li>`; } } class Recipe { constructor({ color = "none", color_material = "none", created_on = "", description = "", essential_oils = [], id, ingredients, name, user_id }) { this.color = color; this.color_material = color_material; this.created_on = created_on; this.description = description; this.essential_oils = essential_oils; this.id = id; this.ingredients = ingredients; this.name = name; this.user_id = user_id; } asHtml() { var description = ""; if (this.description.length>0) description = `<div class='recipe_description'>${ this.description }</div>`; var ingredients = this.ingredients.map(ingredient => { return new Ingredient(ingredient).asHtml(); }).join(""); return `<div class='recipe' data-recipeId="${this.id}"> <div class='recipe_user_id'>${this.user_id}</div> <div class='recipe_title'>${this.name}</div> ${description} <ul class="recipe_ingredients recipe_gallery_ingredient_summary">${ingredients}</ul> </div>`; } } class Recipies { constructor (recipiesJsonArray) { this.recipies = recipiesJsonArray.map(recipeJson => { return new Recipe(recipeJson); }); } nextId() { return (this.recipies.length + 1).toString(); } } const chemicals = new Chemicals({% flexible_include file='order/chemicals.json' do_not_escape='true' %}); const recipies = new Recipies({% flexible_include file='order/mixtures.json' do_not_escape='true' %}); var infoForChemicalId = ""; // Keep track of chemical info displayed in modal dialog var incremented = false; // Has the recipe name/number been incremented yet? var showRecipeGalleryParts = false; var recipeName = ""; // Currently edited recipe name var userId = ""; // Logged in userId /* @return object only containing key/value entries of interest */ function ownProperties(oldObject) { var newObject = {}; Object.getOwnPropertyNames(oldObject).forEach ( key => { // console.log(`key=${key}; value=${oldObject[key]}`) newObject[key] = oldObject[key]; }); return newObject; } /* @return array of string values extracted from innerText of selector results */ function jquerySelectorNodes(selector) { var valueNodes = $(selector); var result = []; for (const name in valueNodes) { i = parseInt(name); if (name.length>0 && i == i) // NaN is never equal to itself result.push(valueNodes[name]); } return result; } function chemicalIdsAvailable() { var result = chemicalIdsDefined().filter(x => { return !chemicalIdsUsed().includes(x) }); // console.log(`chemicalIdsAvailable(): ${result.join(", ")}`); return result; } function chemicalIdsDefined() { var result = []; chemicals.chemicals.forEach(item => { result.push(item.id); }); // console.log(`chemicalIdsDefined(): ${result.join(", ")}`); return result; } /* @return array of string containing chemical ids */ function chemicalIdsUsed() { var result = []; jquerySelectorNodes('#tbody [data-chemicalId]').forEach(trNode => { var item = trNode.getAttribute("data-chemicalId"); result.push(item); }); // console.log(`chemicalIdsUsed(): ${result.join(", ")}`); return result; } /* Compute grams for each ingredient from percentage and set tag text to those values */ function computeGrams(totalWeight, percentArray) { var grams = $('.grams'); for (i=0; i < percentArray.length; i++) { grams[i].textContent = Math.round(percentArray[i] / 100.0 * totalWeight); } } /* Compute percentages for each ingredient from part and set tag text to those values * @return array of floats containing percentages of each ingredient */ function computePercentages(partArray) { var totalParts = partArray.reduce(function(a, b) { return a + b; }, 0); var percentages = $('.percentage'); var percentArray = []; for (i=0; i < partArray.length; i++) { percentage = Math.round(100.0 * partArray[i] / totalParts); percentArray.push(percentage); percentages[i].textContent = `${percentage}%`; } return percentArray; } /* Compute total parts for all chemicals and set tag text to that values * @return array of floats containing parts of each ingredient */ function computeParts() { var partArray = []; var sum = 0.0; jquerySelectorNodes('[contenteditable="true"]').forEach(node => { value = parseFloat(node.innerText); if (value == value) { partArray.push(value); sum += value; } }); $('#total_parts').text(sum); return partArray; } /* Compute parts, percentages and grams. * Side effect: sets tag text for percentages and grams, and totals. */ function computeTotals(totalWeight) { var partArray = computeParts(); var percentArray = computePercentages(partArray); computeGrams(totalWeight, percentArray); } /* Computes total desired weight from user bathtub and reason, sets that value in page, displays weight tag. * @return total weight in grams (float). */ function computeWeight() { var bathtub_gallons = $("#bathtub_gallons").val(); var reason = $("#reason").val(); var factor = 5; if (reason == "light") factor = 10; else if (reason == "medium") factor = 15; else if (reason == "intense") factor = 20; totalWeight = Math.round(bathtub_gallons * factor); $("#total_weight").text(totalWeight); $("#total_grams").text(totalWeight); $("weight_tag").show(); return totalWeight; } /* Computes weights and totals, displays addRow button if any chemicals remain to be chosen. */ function computeEverything() { computeTotals(computeWeight()); if (chemicalIdsAvailable().length>0) $(".addRow").show(); else $(".addRow").hide(); } function createChemicalCheckboxes() { var chemicalsJsonArray = []; chemicalIdsAvailable().forEach( id => { chemicalsJsonArray.push(findChemicalById(id)); }); //console.log(`chemicals: ${chemicals}`); $("#chemicalList") .empty() .append('<tr id="lastrow">'); chemicalsJsonArray.forEach(chemicalJson => { insertChemicalRow(chemicalJson) }); } function dirtyHandler(isDirty) { if (isDirty) { $("#recipe_name").css("border", "dotted thin grey"); $("#recipe_name").css("color", "#286090"); $("#save").css('visibility', 'visible'); } else { $("#recipe_name").css("border", "inherit"); $("#recipe_name").css("color", "inherit"); $("#save").css('visibility', 'hidden'); } } /* @return JSON object for chemical with given id */ function findChemicalById(id) { return chemicals.chemicals.filter(obj => { return obj.id === id; })[0]; } function addChemicalToRecipe(chemicalJson, parts) { var newRow = `<tr data-chemicalId="${chemicalJson.id}"> <td class="pt-3-half noselect">${chemicalJson.fullName}</td> <td class="pt-3-half numeric editable" contenteditable="true">${parts}</td> <td class="pt-3-half numeric noselect percentage"></td> <td class="pt-3-half numeric noselect grams"></td> <td class="minimal"> <button type="button" class="cellButton deleteRow"> <span class="fas fa-minus red" alt="Delete this ingredient from the recipe" title="Delete this ingredient from the recipe"></span> </button> </td> </tr>`; $("#tbody").prepend(newRow); } /** Create single string from array of strings in chemical.fullDescription */ function fullDescription(chemical) { return chemical.fullDescription.join(" "); } /* Delete ingredient row when user clicks on icon */ function handleDeleteRowClick(event) { tr = event.target.parentElement.parentElement.parentElement; tbody = tr.parentElement; tr.remove(); computeEverything(); if (tbody.rows.length == 1) // Remember there is a dummy row at the end $("#total_grams").text(0); } /* Update computations whenever an input or selection changes */ function handleInputChanges(event) { if (isNumeric(event)) { $("#recipe_name").attr('contenteditable', 'true'); incrementSuffix('#recipe_name'); computeEverything(); dirtyHandler(true); } else if (event.target.id == "recipe_name" || event.target.id == "recipe_description") { dirtyHandler(true); } } /* Handle clicks on checkbox info icons in modal dialog */ function handleModalInfoIconClicks(event) { var chemicalId = event.target.getAttribute("data-chemicalId"); var chemical = findChemicalById(chemicalId); $("#chemicalAbout").text(chemical.fullName); $("#chemicalAKA").html(chemical.altNames.join(", ")); $("#chemicalDescription").html(chemical.fullDescription.join(" ")); var chemicalInfoDiv = $("#chemicalInfoDiv"); if (infoForChemicalId==chemicalId) { chemicalInfoDiv.toggle("slow"); } else { chemicalInfoDiv.show("slow"); } infoForChemicalId = chemicalId; event.preventDefault(); } /* Handle change of bathtub capacity */ function handleBathtubResize(event) { computeEverything(); } /* Handle close of chemical modal dialog */ function handleChemicalModalClose(event, modal) { var checkedBoxes = $("#chemicalList :checked"); for (const name in checkedBoxes) { i = parseInt(name); if (name.length>0 && i == i) { // NaN is never equal to itself var chemicalId = checkedBoxes[i].value; var chemical = findChemicalById(chemicalId); addChemicalToRecipe(chemical); computeEverything(); } } } /* Handle setup of chemical add modal dialog */ function handleChemicalModalSetup(event) { createChemicalCheckboxes(); $('#addChemical').modal(); } function handleChooseRecipe(event) { var recipe_gallery = $('#recipe_gallery'); recipe_gallery.empty(); recipies.recipies.forEach(recipe =>{ recipe_gallery.append(recipe.asHtml()); }); if (showRecipeGalleryParts) { $("#show_parts").prop('checked', true); $(".recipe_gallery_part").show(); } $('#recipies').modal(); } function handleLogin(event) { event.preventDefault(); userId = $("#userId").val(); password = $("#password").val(); if (userId.length>2) { // fake a login setUserId(userId); $("#recipe_name").addClass("editable"); handleChemicalModalSetup("ignored"); } } /* Only allow numbers in numeric fields */ function handleNumericKeypress(event) { if (!isNumeric(event)) { event.preventDefault(); event.stopPropagation(); } } /* Only allow numbers in numeric fields */ function handleTextKeypress(event) { if (!isText(event)) { event.preventDefault(); event.stopPropagation(); } } /* Handle change of reason for wanting bath salts */ function handleReasonChange(event) { computeEverything(); } function zipIdParts(ids, parts) { return ids.map((id, i) => { return { id: id, parts: parts[i] }; }); } function handleLoginClose(event) { $("#recipe_author").text(userId); var parts = jquerySelectorNodes('#tbody [contenteditable="true"]') .map(node => { return parseFloat(node.innerText); }); var newRecipe = new Recipe({ "created_on": new Date().toISOString().split('T')[0], "description": $("#recipe_description").text(), "ingredients": zipIdParts(chemicalIdsUsed(), parts), "essential_oils": [ ], "id": recipies.nextId(), "name": recipeName, "user_id": userId }); recipies.recipies.push(newRecipe); } function handleSave(event) { recipeName = $("#recipe_name").text(); if (userId.length==0) { $('#login').modal(); $("#userId").focus(); dirtyHandler(false); } else { dirtyHandler(false); handleLoginClose(); } } function handleSelectedRecipe(event) { var recipeId = event.target.parentElement.getAttribute("data-recipeId"); loadRecipe(recipeId); $.modal.close(); } function handleShowRecipeGalleryParts(event) { showRecipeGalleryParts = !showRecipeGalleryParts; if (showRecipeGalleryParts) $(".recipe_ingredients").removeClass("recipe_gallery_ingredient_summary"); else $(".recipe_ingredients").addClass("recipe_gallery_ingredient_summary"); $(".recipe_gallery_part").toggle(); } function incrementSuffix(selector) { if (incremented) return; var value = $(selector).text(); value = value.replace(/(\d+)$/, function (match, n) { return ++n; }); $(selector).text(value); incremented = true; } function insertChemicalRow(chemicalJson) { $("#chemicalList") .append($("<label>") .text(`${chemicalJson.fullName} (${chemicalJson.formula})`) .css("display", "block") .append($(`<span class='fas fa-info-circle chemicalCheckbox' data-chemicalId='${chemicalJson.id}'></span>`)) .prepend($(`<input type='checkbox'>`) .val(chemicalJson.id) ) ); } function isNumeric(event) { var x = event.charCode || event.keyCode; return !(isNaN(String.fromCharCode(event.which)) && x!=46 || x===32 || x===13 || (x===46 && event.currentTarget.innerText.includes('.'))); } function isText(event) { var key = event.key; var code = event.code; var which = event.which; // console.log(`code=${code}; key=${key}; which=${which}`); valid = which >= 32 && ( key.match(/^[0-9a-zA-Z]+$/) || ' -_\'"()[]{}+-#%'.includes(key) ); return valid; } function loadRecipe(id) { $("#tbody") .empty() .append('<tr id="lastrow">'); var recipe = recipies.recipies.find(x => x.id === id.toString()); $("#recipe_author").text(recipe.user_id); $("#recipe_name").text(recipe.name); $("#recipe_created_on").text(recipe.created_on); $("#recipe_description").text(recipe.description); recipe.ingredients.forEach(ingredientsJson => { var chemicalJson = chemicals.chemicals.find(x => x.id === ingredientsJson.id); addChemicalToRecipe(chemicalJson, ingredientsJson.parts); }); // do the same for essential oils // do the same for color computeEverything(); } function setUserId(newUserId) { if (userId.length == 0) $("#recipe_author").text(newUserId); } window.onload = function() { // Delegated event handlers for generated tags $(document).on('keypress', '.numeric.editable', handleNumericKeypress); $(document).on('keypress', '.text.editable', handleTextKeypress); $(document).on('keyup', '[contenteditable="true"]', handleInputChanges); $(document).on('click', '.deleteRow', handleDeleteRowClick); $(document).on('click', '.chemicalCheckbox', handleModalInfoIconClicks); $(document).on('click', '#login', handleLogin); $(document).on('click', '#show_parts', handleShowRecipeGalleryParts); $(document).on('click', '.recipe', handleSelectedRecipe); // Normal event handlers for static tags $('.addRow').click(handleChemicalModalSetup); $('#addChemical').on($.modal.BEFORE_CLOSE, handleChemicalModalClose); $('#login').on($.modal.BEFORE_CLOSE, handleLoginClose); $('#bathtub_gallons').keyup(handleBathtubResize); $('#chooseRecipe').click(handleChooseRecipe); $('#reason').change(handleReasonChange); $('#save').click(handleSave); // Initialization loadRecipe(1); computeEverything(); };
pricing Django App
Refer to my initial django-oscar
notes.
from datetime import datetime from django.contrib.auth.models import User from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils import timezone from typing import * # noqa from bather.models import Bather from oscar.apps.order.models import Order as OscarOrder class Classification(models.Model): name = models.TextField() parent = models.ForeignKey('self', null=True, on_delete=models.CASCADE) """See https://docs.djangoproject.com/en/3.1/ref/models/fields/#django.db.models.ForeignKey""" def __str__(self) -> str: return self.name class TimeUnits(): YEAR: Final = "year" MONTH: Final = "month" WEEK: Final = "week" DAY: Final = "day" HOUR: Final = "hour" MINUTE: Final = "minute" SECOND: Final = "second" class Chemical(models.Model): alt_names = models.TextField(null=True) """ Contains JSON array of string """ full_description = models.TextField() name = models.TextField() private_comment = models.TextField(null=True) """Only Ancient Warmth users will see this information""" recommended_max_percent = models.PositiveIntegerField(default=100, validators=[MaxValueValidator(100)]) short_description = models.TextField() remaining_inventory = models.PositiveIntegerField(default=0) """grams""" retail_price_per_kg = models.PositiveIntegerField(null=True) """Cents. Recomputed each time we pay for a new shipment. If null, Designs that require this Chemical cannot be sold. System should call dependant_designs() to warn any Ancient Warmth staff user who tries to set this field to null that "xxx Designs rely on this Chemical, and they will become unavailable". """ @property def designs(self) -> List["Design"]: """@return list of Designs that require this Chemical""" pass @property def mixtures(self)-> List["Mixture"]: """@return list of Mixtures that require this Chemical""" pass @property def bathers(self)-> List["Bather"]: """@return list of Bathers that require this Chemical""" pass @property def designers(self)-> List["Designer"]: """@return list of Designers that require this Chemical""" pass @property def usage_rate(self, time_unit=TimeUnits.YEAR, n:int=12) -> int: """@return grams used per unit time, averaged over the most recent n time units""" pass @property def usage_rate_history(self, time_unit=TimeUnits.MONTH, max_length: int = 12) -> List[int]: """ @return list of grams used per unit time """ pass def __str__(self): return self.name class Color(models.Model): kid_friendly = models.BooleanField() name = models.TextField() def __str__(self): return self.name class Mixture(models.Model): """ A Mixture's ingredients cannot be modified or deleted, however all other properties can be modified by Ancient Warmth and the Designer. This ensures that Bathers will be able to trust Designs, which are partially based on Mixtures. If a Designer wants a different formulation they can create a new Mixture version. The components of a Mixture are measured in percentage of the total grams of that component per the total grams. Many fields of a Mixture are immutable. """ readonly_fields=('created', 'designer', 'parent', 'version') classification = models.ForeignKey(Classification, null=True, on_delete=models.CASCADE) created = models.DateTimeField(verbose_name='Date/time this mixture was defined', auto_now_add=True) description = models.TextField(null=True) designer = models.OneToOneField('Designer', null=True, on_delete=models.CASCADE) """ If null, then this is an Original mixture (defined by Ancient Warmth). """ last_modified = models.DateTimeField(verbose_name='Date/time this mixture was modified', auto_now=True) """The chemicals in this mixture can never be changed, only other properties such as classification, description or tags""" name = models.TextField() """Does not include the version number""" parent = models.OneToOneField('self', null=True, on_delete=models.CASCADE) """ Mixture that this mixture was copied from. If null, then the parent is an Original mixture (defined by Ancient Warmth). """ skin = models.ForeignKey('DesignSkin', null=True, on_delete=models.CASCADE) """ Provides default value for Design.skin based on this mixture. TODO decide where/how the default skin is defined. """ version = models.PositiveIntegerField(default=1) @property def bathers(self)-> List[Bather]: """@return list of Bathers that have used this Mixture""" pass @property def designers(self) -> List["Designer"]: """@return list of Designers that have used this Mixture in their Designs""" pass @property def designs(self) -> List["Design"]: """@return list of Designs that use this Mixture""" pass @property def full_heritage(self) -> List['Mixture']: """Return list of parents""" pass @property def full_name(self) -> str: return f'{self.designer.user.handle}/{self.name} #{self.version}' @property def heritage(self) -> str: return f"Copied from {self.parent.full_name}, originated from {self.originator.full_name})" @property def originator(): pass # models.OneToOneField('self', null=True, on_delete=models.CASCADE) """ Mixture that this mixture was originally copied from (top of parent hierarchy). If null, then the original mixture is an Original mixture (defined by Ancient Warmth). """ @property def usage_rate(self, time_unit=TimeUnits.YEAR, n:int=12) -> int: """@return grams used per unit time, averaged over the most recent n time units""" pass def __str__(self): return self.name class Design(models.Model): """ A Design's ingredients cannot be modified or deleted, however all other properties can be modified by Ancient Warmth and the Designer. This ensures that Bathers will be able to trust Designs. If a Designer wants a different formulation they can create a new Design version. Designs scale up and down according to the bathtub capacity, they are independent of the size of the bathtub. Ranking will be computed by another class. A Design can only be defined after its mixture is defined. Many fields are immutable. """ readonly_fields=('color', 'created', 'designer', 'mixture', 'mixture_intensity', 'parent', 'version') color = models.OneToOneField(Color, null=True, on_delete=models.SET_NULL) designer = models.OneToOneField('Designer', null=True, on_delete=models.CASCADE) """ If null, then this is an Original mixture (defined by Ancient Warmth). """ created = models.DateTimeField(verbose_name='date/time created', auto_now_add=True) description = models.TextField(null=True) last_modified = models.DateTimeField(verbose_name='date/time modified', auto_now=True) """ A Design's mixture and mixture_grams cannot be modified or deleted. This ensures that Bathers will be able to trust the Design. If a Designer wants a different formulation they can create a new Design version. Matches by mixture.id, not mixture.name; this allows mixtures to be renamed without breaking the association with Designs. """ mixture = models.OneToOneField(Mixture, null=True, on_delete=models.SET_NULL) mixture_intensity = models.FloatField(default=1.0, validators=[MinValueValidator(0.1), MaxValueValidator(5)]) """ Multiplier for mixture ingredients. Typical values: 1.0 - recreational 2.0 - light therapeutic 3.0 - medium therapeutic 4.0 - intense therapeutic """ name = models.TextField() """Does not include the version number""" parent = models.OneToOneField('self', null=True, on_delete=models.CASCADE) """ Design that this design was copied from. If null, then the parent is an Original design (defined by Ancient Warmth). """ skin = models.ForeignKey('DesignSkin', null=True, on_delete=models.CASCADE) # FIXME should have something like default=mixture.skin) """ There is no restriction on which Designer's skin this Design uses. """ version = models.PositiveIntegerField(default=1) @property def bathers(self)-> List['Bather']: """@return list of Bathers that have used this Design""" pass @property def designers(self)-> List["Designer"]: """@return list of Designers that have cloned this Design, plus the original Designer who created it""" pass @property def full_heritage(self) -> List['Mixture']: """Return list of parents""" pass @property def full_name(self) -> str: return f'{self.designer.user.handle}/{self.name} #{self.version}' @property def heritage(self) -> str: return f"Copied from {self.parent.full_name}, originated from {self.originator.full_name})" @property def mixture_grams_per_liter(self): """ Calibrated for recreational use (200 grams of mixture for 40 US gallons) There are about 150 liters in 40 US gallons """ self.mixture_intensity * 200.0 / 150.0 @property def originator(self): pass # models.OneToOneField('self', null=True, on_delete=models.CASCADE) """ Design that this design was originally copied from (top of parent hierarchy). If null, then the original design is an Original design (defined by Ancient Warmth). """ def usage_rate(self, time_unit=TimeUnits.YEAR, n:int=12) -> int: """@return grams used per unit time, averaged over the most recent n time units""" pass def __str__(self): return self.name def mixture_grams_for(self, bathtub_liters: float) -> int: return int(self.bathtub_liters * self.mixture_grams_per_liter) def oil_ml_for(self, bathtub_liters: float, oil_mg_per_liter: float) -> int: """ Calibrated for recreational use (200 grams of mixture for 40 US gallons). There are about 150 liters in 40 US gallons. """ return int(self.bathtub_liters * self.oil_mg_per_liter) class Designer(models.Model): """ A Design is a Mixture, customized by adding items that do not affect the chemistry, but do affect the bather’s experience. The components of a Design are measured in mg or mL per liter of bathtub water. """ mission_statement = models.TextField(null=True) user = models.ForeignKey(User, null=True, on_delete=models.SET_NULL) def __str__(self): return self.user.name class DesignSkin(models.Model): readonly_fields=('created', 'designer') created = models.DateTimeField(verbose_name='Date/time created', auto_now_add=True) description = models.TextField(null=True) designer = models.ForeignKey('Designer', null=True, on_delete=models.CASCADE) graphics = models.ImageField() last_modified = models.DateTimeField(verbose_name='Date/time last modified', auto_now=True) name = models.TextField() class EssentialOil(models.Model): kid_friendly = models.BooleanField(default=False) full_description = models.TextField() name = models.TextField() private_comment = models.TextField(null=True) """Only Ancient Warmth users will see this information""" recommended_max_ml_per_liter = models.FloatField(default=0.2, validators=[MinValueValidator(0), MaxValueValidator(1)]) """TODO read this from a config file.""" retail_price_per_ml = models.PositiveIntegerField(default=150) """ Cents. Reset each time we pay for a new shipment. TODO read this from a config file. """ remaining_inventory_ml = models.PositiveIntegerField(default=0) short_description = models.TextField() @property def bathers(self)-> List["Bather"]: """@return list of Bathers that have used a package containing this EssentialOil""" pass @property def designs(self) -> List["Design"]: """@return list of Designs that require this EssentialOil""" pass @property def designers(self) -> List["Designer"]: """@return list of Designers that have created or clones Designs that require this EssentialOil""" pass @property def usage_rate(self, time_unit=TimeUnits.YEAR, n:int=12) -> int: """@return grams used per unit time, averaged over the most recent n time units""" pass def __str__(self): return self.name class Scent(EssentialOil): """No changes can be made to a Scent (it is immutable) once it is created""" readonly_fields=('essential_oil', 'design', 'ml_per_liter') design = models.ForeignKey(Design, null=True, on_delete=models.CASCADE) ml_per_liter = models.PositiveIntegerField() def __str__(self): return f"{self.ml_per_liter} of {self.essential_oil}" class Ingredient(Chemical): """No changes can be made to an ingredient (it is immutable) after it is defined""" readonly_fields=('chemical', 'mixture', 'parts') mixture = models.ForeignKey(Mixture, null=True, on_delete=models.CASCADE) parts = models.PositiveIntegerField() def __str__(self): return f"{self.parts} of {self.chemical}" class Inventory(models.Model): """Raw materials inventory""" # TODO Write me pass class Package(Design): """The only Package field that can be modified is price, all other fields are immutable.""" readonly_fields=('bather', 'design', 'grams') bather = models.ForeignKey(Bather, null=True, on_delete=models.CASCADE) grams = models.PositiveIntegerField(default=200, validators=[MinValueValidator(50), MaxValueValidator(10 * 1000)]) @property def price(self): # TODO write me pass @property def name(self): return f"{self.design.name}, {self.grams} grams" def __str__(self): return f"{self.name} by {self.design} for {self.bather.full_name}" # class Meta: # ordering = ['self.design.category', 'self.design.name'] class Rating(models.Model): """The only Rating field that can be modified is value""" readonly_fields=('bather', 'design') bather = models.ForeignKey(Bather, null=True, on_delete=models.CASCADE) design = models.ForeignKey(Design, null=True, on_delete=models.CASCADE) value = models.PositiveIntegerField(default=3, validators=[MinValueValidator(0), MaxValueValidator(5)]) def __str__(self): return f"{self.value} stars for {self.design} by {self.bather}" class Tub(models.Model): """Some bathers have a more than one bathtub, plus maybe a hot tub""" readonly_fields=('bather') bather = models.ForeignKey(Bather, null=True, on_delete=models.CASCADE) capacity_us_gallons = models.PositiveIntegerField(default=40) name = models.TextField(default="bathtub") @property def capacity_liters(self) -> float: return self.capacity_us_gallons * 3.78541 def __str__(self): return f"{self.name}, {self.capacity_us_gallons} US gallons ({self.capacity_liters} liters)" class Subscription(models.Model): """Does Oscar have this?""" created = models.DateTimeField(default=datetime.now) end_date = models.DateField(null=True) frequency = models.PositiveIntegerField(default=1) frequency_units = models.CharField(max_length=10) # 'day', 'week', 'month', 'year' gift_wrap = models.BooleanField(default=False) gift_wrap_message = models.TextField(null=True) last_modified = models.DateTimeField(default=datetime.now) paused = models.BooleanField(default=False) start_date = models.DateField(null=False, default=timezone.now) def __str__(self): return f"Every {self.frequency} {self.frequency_units}s, from {self.start_date} to {self.end_date}; {'not ' if not self.paused else '' }paused." class Order(models.Model): """Derived from Oscar's order.""" gift_wrap = models.BooleanField(default=False) gift_wrap_message = models.TextField(null=True) order = models.ForeignKey(OscarOrder, null=True, on_delete=models.SET_NULL) subscription = models.ForeignKey(Subscription, null=True, on_delete=models.SET_NULL) def __str__(self): return f"{self.gift_wrap}" if __name__ == '__main__': import json # Store the list in the database chemical = Chemical() chemical.name = "KCl" alt_names = ["Salt for diabetics", "reduced-sodium replacement salt", "NoSalt", "Nu-Salt", "No Salt Original"] chemical.alt_names = json.dumps(alt_names) # chemical.save() # Retrieve the list from the database jsonDecoder = json.decoder.JSONDecoder() alt_names = jsonDecoder.decode(chemical.alt_names)
$ ./manage.py makemigrations pricing Migrations for 'pricing': pricing/migrations/0001_initial.py - Create model Bather - Create model Category - Create model Chemical - Create model Color - Create model Design - Create model Designer - Create model EssentialOil - Create model Scent - Create model Rating - Create model Package - Create model Mixture - Create model Ingredient - Add field designer to design - Add field mixture to design
$ ./manage.py sqlmigrate pricing 0001 BEGIN; -- -- Create model Bather -- CREATE TABLE "aw_pricing_bather" ("user_id" integer NOT NULL PRIMARY KEY); -- -- Create model Category -- CREATE TABLE "aw_pricing_category" ("id" serial NOT NULL PRIMARY KEY, "name" text NOT NULL); -- -- Create model Chemical -- CREATE TABLE "aw_pricing_chemical" ("id" serial NOT NULL PRIMARY KEY, "alt_names" text NULL, "full_description" text NOT NULL, "name" text NOT NULL, "short_description" text NOT NULL); -- -- Create model Color -- CREATE TABLE "aw_pricing_color" ("id" serial NOT NULL PRIMARY KEY, "kid_friendly" boolean NOT NULL, "name" text NOT NULL); -- -- Create model Design -- CREATE TABLE "aw_pricing_design" ("id" serial NOT NULL PRIMARY KEY, "created" timestamp with time zone NOT NULL, "graphics" varchar(100) NOT NULL, "name" text NOT NULL, "category_id" integer NOT NULL UNIQUE, "color_id" integer NOT NULL UNIQUE); -- -- Create model Designer -- CREATE TABLE "aw_pricing_designer" ("user_id" integer NOT NULL PRIMARY KEY); -- -- Create model EssentialOil -- CREATE TABLE "aw_pricing_essentialoil" ("id" serial NOT NULL PRIMARY KEY, "kid_friendly" boolean NOT NULL, "full_description" text NOT NULL, "name" text NOT NULL, "short_description" text NOT NULL); -- -- Create model Scent -- CREATE TABLE "aw_pricing_scent" ("id" serial NOT NULL PRIMARY KEY, "ml_per_liter" integer NOT NULL CHECK ("ml_per_liter" >= 0), "design_id" integer NOT NULL, "essential_oil_id" integer NOT NULL UNIQUE); -- -- Create model Rating -- CREATE TABLE "aw_pricing_rating" ("id" serial NOT NULL PRIMARY KEY, "value" integer NOT NULL CHECK ("value" >= 0), "bather_id" integer NOT NULL, "design_id" integer NOT NULL); -- -- Create model Package -- CREATE TABLE "aw_pricing_package" ("id" serial NOT NULL PRIMARY KEY, "grams" integer NOT NULL CHECK ("grams" >= 0), "bather_id" integer NOT NULL, "design_id" integer NOT NULL UNIQUE); -- -- Create model Mixture -- CREATE TABLE "aw_pricing_mixture" ("id" serial NOT NULL PRIMARY KEY, "created" timestamp with time zone NOT NULL, "name" text NOT NULL, "designer_id" integer NOT NULL UNIQUE); -- -- Create model Ingredient -- CREATE TABLE "aw_pricing_ingredient" ("id" serial NOT NULL PRIMARY KEY, "parts" integer NOT NULL CHECK ("parts" >= 0), "chemical_id" integer NOT NULL UNIQUE, "mixture_id" integer NOT NULL); -- -- Add field designer to design -- ALTER TABLE "aw_pricing_design" ADD COLUMN "designer_id" integer NOT NULL CONSTRAINT "aw_pricing_design_designer_id_0be65681_fk_aw_pricin" REFERENCES "aw_pricing_designer"("user_id") DEFERRABLE INITIALLY DEFERRED; SET CONSTRAINTS "aw_pricing_design_designer_id_0be65681_fk_aw_pricin" IMMEDIATE; -- -- Add field mixture to design -- ALTER TABLE "aw_pricing_design" ADD COLUMN "mixture_id" integer NOT NULL UNIQUE CONSTRAINT "aw_pricing_design_mixture_id_93091820_fk_aw_pricing_mixture_id" REFERENCES "aw_pricing_mixture"("id") DEFERRABLE INITIALLY DEFERRED; SET CONSTRAINTS "aw_pricing_design_mixture_id_93091820_fk_aw_pricing_mixture_id" IMMEDIATE; ALTER TABLE "aw_pricing_bather" ADD CONSTRAINT "aw_pricing_bather_user_id_6390db20_fk_auth_user_id" FOREIGN KEY ("user_id") REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_design" ADD CONSTRAINT "aw_pricing_design_category_id_c794e2d1_fk_aw_pricin" FOREIGN KEY ("category_id") REFERENCES "aw_pricing_category" ("id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_design" ADD CONSTRAINT "aw_pricing_design_color_id_a8f9e074_fk_aw_pricing_color_id" FOREIGN KEY ("color_id") REFERENCES "aw_pricing_color" ("id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_designer" ADD CONSTRAINT "aw_pricing_designer_user_id_8b9aabdf_fk_auth_user_id" FOREIGN KEY ("user_id") REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_scent" ADD CONSTRAINT "aw_pricing_scent_design_id_2ab15a89_fk_aw_pricing_design_id" FOREIGN KEY ("design_id") REFERENCES "aw_pricing_design" ("id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_scent" ADD CONSTRAINT "aw_pricing_scent_essential_oil_id_700d88e4_fk_aw_pricin" FOREIGN KEY ("essential_oil_id") REFERENCES "aw_pricing_essentialoil" ("id") DEFERRABLE INITIALLY DEFERRED; CREATE INDEX "aw_pricing_scent_design_id_2ab15a89" ON "aw_pricing_scent" ("design_id"); ALTER TABLE "aw_pricing_rating" ADD CONSTRAINT "aw_pricing_rating_bather_id_c8957e24_fk_aw_pricin" FOREIGN KEY ("bather_id") REFERENCES "aw_pricing_bather" ("user_id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_rating" ADD CONSTRAINT "aw_pricing_rating_design_id_17651e3b_fk_aw_pricing_design_id" FOREIGN KEY ("design_id") REFERENCES "aw_pricing_design" ("id") DEFERRABLE INITIALLY DEFERRED; CREATE INDEX "aw_pricing_rating_bather_id_c8957e24" ON "aw_pricing_rating" ("bather_id"); CREATE INDEX "aw_pricing_rating_design_id_17651e3b" ON "aw_pricing_rating" ("design_id"); ALTER TABLE "aw_pricing_package" ADD CONSTRAINT "aw_pricing_package_bather_id_d0441e4f_fk_aw_pricin" FOREIGN KEY ("bather_id") REFERENCES "aw_pricing_bather" ("user_id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_package" ADD CONSTRAINT "aw_pricing_package_design_id_90fc01b7_fk_aw_pricing_design_id" FOREIGN KEY ("design_id") REFERENCES "aw_pricing_design" ("id") DEFERRABLE INITIALLY DEFERRED; CREATE INDEX "aw_pricing_package_bather_id_d0441e4f" ON "aw_pricing_package" ("bather_id"); ALTER TABLE "aw_pricing_mixture" ADD CONSTRAINT "aw_pricing_mixture_designer_id_be94523d_fk_aw_pricin" FOREIGN KEY ("designer_id") REFERENCES "aw_pricing_designer" ("user_id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_ingredient" ADD CONSTRAINT "aw_pricing_ingredien_chemical_id_2a2fd6ae_fk_aw_pricin" FOREIGN KEY ("chemical_id") REFERENCES "aw_pricing_chemical" ("id") DEFERRABLE INITIALLY DEFERRED; ALTER TABLE "aw_pricing_ingredient" ADD CONSTRAINT "aw_pricing_ingredien_mixture_id_cd5b0b60_fk_aw_pricin" FOREIGN KEY ("mixture_id") REFERENCES "aw_pricing_mixture" ("id") DEFERRABLE INITIALLY DEFERRED; CREATE INDEX "aw_pricing_ingredient_mixture_id_cd5b0b60" ON "aw_pricing_ingredient" ("mixture_id"); CREATE INDEX "aw_pricing_design_designer_id_0be65681" ON "aw_pricing_design" ("designer_id"); COMMIT;