From: W. Trevor King Date: Fri, 5 Aug 2011 13:43:13 +0000 (-0400) Subject: Consolidate unit handling and use proxy classes. X-Git-Url: http://git.tremily.us/?p=cookbook.git;a=commitdiff_plain;h=b6c65766908b628f4f3dcdcccfee3494df46d85b Consolidate unit handling and use proxy classes. --- diff --git a/cookbook/admin.py b/cookbook/admin.py index cb6a1b1..fa29c02 100644 --- a/cookbook/admin.py +++ b/cookbook/admin.py @@ -38,8 +38,10 @@ class IngredientBlockInline (admin.TabularInline): class RecipeAdmin (admin.ModelAdmin): fieldsets = [ (None, {'fields': ['name']}), - ('Metadata', {'fields': ['author', 'source', 'url', 'x_yield', 'tags'], + ('Metadata', {'fields': ['author', 'source', 'url', 'tags'], 'classes': ['collapse']}), + ('Yield', {'fields': ['unit', 'value', 'min_value', 'max_value'], + 'classes': ['collapse']}), ('Directions', {'fields': ['directions_markdown']}), ] inlines = [IngredientBlockInline] @@ -49,7 +51,4 @@ class RecipeAdmin (admin.ModelAdmin): admin.site.register(models.Recipe, RecipeAdmin) admin.site.register(models.IngredientBlock, IngredientBlockAdmin) -admin.site.register(models.Amount) admin.site.register(models.Unit) -admin.site.register(models.UnitSystem) -admin.site.register(models.UnitType) diff --git a/cookbook/models.py b/cookbook/models.py index 3d75d57..0726476 100644 --- a/cookbook/models.py +++ b/cookbook/models.py @@ -1,64 +1,118 @@ from django.db import models +from django.forms import ValidationError import markdown from taggit.managers import TaggableManager -class UnitType (models.Model): - "Weight, length, count, time, etc." - name = models.CharField(max_length=40) - - def __unicode__(self): - return u'{0.name}'.format(self) - -class UnitSystem (models.Model): - "SI, CGS, British Imperial, US, etc." - name = models.CharField(max_length=40) - - def __unicode__(self): - return u'{0.name}'.format(self) +SI = 'SI (International System of Units)' +US = 'US (United States of America customary units)' class Unit (models.Model): "Kilograms, pounds, liters, gallons, etc." - abbrev = models.CharField('abbreviation', max_length=6) + TYPES = ( + ('c', 'count'), + ('m', 'mass'), + ('v', 'volume'), + ('t', 'time'), + ('T', 'temperature'), + ) + SYSTEMS = ( + ('SI', SI), + ('US', US), + ) name = models.CharField(max_length=40) - type = models.ForeignKey(UnitType) - system = models.ForeignKey(UnitSystem) - si_scale = models.DecimalField(max_digits=30, decimal_places=15) - si_offset = models.DecimalField(max_digits=30, decimal_places=15) + abbrev = models.CharField('abbreviation', max_length=6) + type = models.CharField(max_length=1, choices=TYPES) + system = models.CharField(max_length=2, choices=SYSTEMS) + scale = models.DecimalField( + 'SI/X-equivalent ratio (e.g. 453.6 for lb (g), 3.78 for gal (L))', + max_digits=30, decimal_places=15) + offset = models.DecimalField( + 'X when SI-equivalent is zero (e.g. 32 for degrees Farenheit (C))', + max_digits=30, decimal_places=15) def __unicode__(self): return u'{0.abbrev}'.format(self) + def convert_from_si(self, value): + return value / self.scale + self.offset + + def convert_to_si(self, value): + return (value - self.offset) * self.scale + + class Amount (models.Model): "1 kg, 2-3 lb., 0.5 (0.3-0.6) gal., etc." - unit = models.ForeignKey(Unit) - value = models.DecimalField(max_digits=10, decimal_places=4) + value = models.DecimalField( + max_digits=10, decimal_places=4, null=True, blank=True) min_value = models.DecimalField( 'minimum value', max_digits=10, decimal_places=4, null=True, blank=True) max_value = models.DecimalField( 'maximum value', max_digits=10, decimal_places=4, null=True, blank=True) + unit = models.ForeignKey(Unit, null=True, blank=True) - def __unicode__(self): - if self.min_value is None and self.max_value is None: - value = self.value - elif self.min_value is None: - value = '{0.value}-{0.max_value}'.format(self) - elif self.max_value is None: - value = '{0.min_value}-{0.value}'.format(self) + class Meta: + abstract = True + + def format_amount(self): + if self.unit is None: + for v in [self.value, self.min_value, self.max_value]: + if v is not None: + raise ValidationError(v) + return u'-' + if self.value is None: + if self.min_value is None: + if self.max_value is not None: + raise ValidationError('cannot only set max_value') + fmt = u'- {0.unit}' + else: + if self.max_value is None: + raise ValidationError('cannot only set min_value') + fmt = u'{0.min_value}-{0.max_value} {0.unit}' else: - value = '{0.value} ({0.min_value}-{0.max_value})'.format(self) - return u'{0} {1.unit}'.format(value, self) + if self.min_value is None: + if self.max_value is None: + fmt = u'{0.value} {0.unit}' + else: + fmt = u'{0.value}-{0.max_value} {0.unit}' + else: + if self.max_value is None: + fmt = u'{0.min_value}-{0.value} {0.unit}' + else: + fmt = u'{0.value} ({0.min_value}-{0.max_value}) {0.unit}' + return fmt.format(self) -class Recipe (models.Model): - name = models.CharField(max_length=200) + def validate_amount(self): + if self.value is None: + if self.min_value is None and self.max_value is not None: + raise ValidationError('cannot only set max_value') + elif self.min_value is not None and self.max_value is None: + raise ValidationError('cannot only set min_value') + if self.value is not None and self.unit is None: + raise ValidationError('values must have units') + + +class Directions (models.Model): directions_markdown = models.TextField( - 'directions', help_text='Markdown syntax') + 'directions', help_text='Markdown syntax', blank=True, null=True) directions = models.TextField('directions as HTML', blank=True, null=True) - # yield is a reserved word - x_yield = models.OneToOneField( - Amount, verbose_name='yield', db_column='yield', null=True, blank=True) + + class Meta: + abstract = True + + def save(self): + # https://code.djangoproject.com/wiki/UsingMarkup + if self.directions_markdown is None: + self.directions = None + else: + self.directions = markdown.markdown(self.directions_markdown) + super(Directions, self).save() + + +class Recipe (Amount, Directions): + name = models.CharField(max_length=200) author = models.CharField(max_length=200, null=True, blank=True) source = models.CharField(max_length=200, null=True, blank=True) url = models.URLField('URL', null=True, blank=True) @@ -70,17 +124,9 @@ class Recipe (models.Model): def __unicode__(self): return u'{0.name}'.format(self) - def save(self): - # https://code.djangoproject.com/wiki/UsingMarkup - self.directions = markdown.markdown(self.directions_markdown) - super(Recipe, self).save() - -class IngredientBlock (models.Model): +class IngredientBlock (Directions): name = models.CharField(max_length=200) - directions_markdown = models.TextField( - 'directions', help_text='markdown syntax', blank=True, null=True) - directions = models.TextField('directions as HTML', blank=True, null=True) recipe = models.ForeignKey(Recipe) class Meta: @@ -89,23 +135,19 @@ class IngredientBlock (models.Model): def __unicode__(self): return u'{0.name}'.format(self) - def save(self): - if self.directions_markdown: - self.directions = markdown.markdown(self.directions_markdown) - super(IngredientBlock, self).save() - -class Ingredient (models.Model): +class Ingredient (Amount): "1 kg, 2 lb., 3.4 L, 0.5 gal., etc." - amount = models.OneToOneField(Amount, null=True, blank=True) name = models.CharField(max_length=200) note = models.CharField(max_length=200, null=True, blank=True) block = models.ForeignKey(IngredientBlock) def __unicode__(self): - fmt = '{0.name}' - if self.amount: - fmt = '{0.amount} ' + fmt + fmt = '{i.name}' + args = {'i': self} + if self.unit: + fmt = '{amount} ' + fmt + args['amount'] = self.format_amount() if self.note: - fmt += u', {0.note}' - return fmt.format(self) + fmt += u', {i.note}' + return fmt.format(**args) diff --git a/cookbook/tests.py b/cookbook/tests.py index 501deb7..4dd06f5 100644 --- a/cookbook/tests.py +++ b/cookbook/tests.py @@ -5,12 +5,92 @@ when you run "manage.py test". Replace this with more appropriate tests for your application. """ +from django.forms import ValidationError from django.test import TestCase +from . import models -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) +class UnitTest(TestCase): + def setUp(self): + self.F = models.Unit( + name=u'degree Farenheit', abbrev=u'\u00b0F', type=u'temperature', + system=models.US, scale=1/1.8, offset=32) + self.gal = models.Unit( + name=u'gallon', abbrev=u'gal', type=u'volume', + system=models.US, scale=3.78541, offset=0) + + def test_conversion_from_si(self): + "Test from-SI conversion" + self.assertEqual(self.F.convert_from_si(0), 32) + self.assertEqual(self.F.convert_from_si(100), 212) + self.assertEqual(self.gal.convert_from_si(1), 0.26417217685798894) + + def test_conversion_to_si(self): + "Test to-SI conversion" + self.assertEqual(self.F.convert_to_si(32), 0) + self.assertEqual(self.F.convert_to_si(212), 100) + self.assertEqual(self.gal.convert_to_si(1), 3.78541) + + def test_formatting(self): + "Test amount formatting" + self.assertEqual(unicode(self.gal), u'gal') + self.assertEqual(unicode(self.F), u'\u00b0F') + + +class AmountTest(TestCase): + def setUp(self): + self.unit = models.Unit( + name=u'gallon', abbrev=u'gal', type=u'volume', + system=models.US, scale=3.78541, offset=0) + self.amount = models.Amount() + + def test_formatting(self): + "Test amount formatting" + for v,minv,maxv,unit,result in ( + (None, None, None, None, u'-'), + (None, None, None, self.unit, u'- gal'), + (2, None, None, self.unit, u'2 gal'), + (2, 1.5, None, self.unit, u'1.5-2 gal'), + (2, None, 2.5, self.unit, u'2-2.5 gal'), + (2, 1.5, 2.5, self.unit, u'2 (1.5-2.5) gal'), + (None, 1.5, 2.5, self.unit, u'1.5-2.5 gal'), + ): + self.amount.unit = unit + self.amount.value = v + self.amount.min_value = minv + self.amount.max_value = maxv + self.assertEqual(self.amount.format_amount(), result) + + def test_invalid_formatting(self): + "Test amount formatting which raises errors" + for v,minv,maxv,unit,result in ( + (None, 1.5, None, self.unit, u'2 gal'), + (None, None, 2.5, self.unit, u'2 gal'), + ): + self.amount.unit = unit + self.amount.value = v + self.amount.min_value = minv + self.amount.max_value = maxv + self.assertRaises(ValidationError, self.amount.format_amount) + + def test_validation(self): + "Test amount validation" + for valid,v,minv,maxv,unit in ( + (True, None, None, None, None), + (True, None, None, None, self.unit), + (True, 2, None, None, self.unit), + (True, 2, 1.5, None, self.unit), + (True, 2, None, 2.5, self.unit), + (True, 2, 1.5, 2.5, self.unit), + (True, None, 1.5, 2.5, self.unit), + (False, None, 1.5, None, self.unit), + (False, None, None, 2.5, self.unit), + ): + self.amount.unit = unit + self.amount.value = v + self.amount.min_value = minv + self.amount.max_value = maxv + if valid: + self.amount.validate_amount() + else: + self.assertRaises(ValidationError, self.amount.format_amount) diff --git a/util/populate_units.py b/util/populate_units.py new file mode 100644 index 0000000..af4daa8 --- /dev/null +++ b/util/populate_units.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python + +"""Populate a cookbook database with standard units. + +Example usage:: + + $ export DJANGO_SETTINGS_MODULE=example.settings + $ export PYTHONPATH=. + $ python util/populate_units.py +""" + +from cookbook.models import Unit, SI, US + + +for abbrev,name,type,system,scale,offset in ( + (u'ct', u'count', u'count', SI, 1, 0), + (u'g', u'gram', u'mass', SI, 1, 0), + (u'L', u'liter', u'volume', SI, 1, 0), + (u's', u'second', u'time', SI, 1, 0), + (u'\u00b0C', u'degree Celsius', u'temperature', SI, 1, 0), + + (u'srv', u'serving', u'count', US, 1, 0), # not really a US unit + (u'doz', u'dozen', u'count', US, 12, 0), + (u'gro', u'gross', u'count', US, 144, 0), + + (u'gr', u'grain', u'mass', US, 0.06480, 0), + (u'dr', u'dram', u'mass', US, 1.772, 0), + (u'oz', u'ounce', u'mass', US, 28.35, 0), + (u'lb', u'pound', u'mass', US, 453.6, 0), + + (u'drop', u'drop', u'volume', US, 0.00005, 0), + (u'pinch', u'pinch', u'volume', US, 0.0012325, 0), + (u'dash', u'dash', u'volume', US, 0.002465, 0), + (u't', u'teaspoon', u'volume', US, 0.00493, 0), + (u'T', u'tablespoon', u'volume', US, 0.01479, 0), + (u'fl oz', u'fluid ounce', u'volume', US, 0.02957, 0), + (u'C', u'cup', u'volume', US, 0.23659, 0), + (u'pt', u'pint', u'volume', US, 0.47318, 0), + (u'qt', u'quart', u'volume', US, 0.94635, 0), + (u'gal', u'gallon', u'volume', US, 3.78541, 0), + + # These time units are not really US, but they are certainly not SI. + (u'min', u'minute', u'time', US, 60, 0), + (u'hr', u'hour', u'time', US, 3600, 0), + (u'day', u'day', u'time', US, 86400, 0), # for "rest overnight" + (u'wk', u'', u'time', US, 604800, 0), # beer + (u'month', u'month', u'time', US, 2629800, 0), # cheese + (u'yr', u'year', u'time', US, 31557600, 0), # wine + + (u'\u00b0F', u'degree Farenheit', u'temperature', US, 1/1.8, 32), + ): + u = Unit(name=name, abbrev=abbrev, type=type, scale=scale, offset=offset) + u.save()