Consolidate unit handling and use proxy classes.
authorW. Trevor King <wking@drexel.edu>
Fri, 5 Aug 2011 13:43:13 +0000 (09:43 -0400)
committerW. Trevor King <wking@drexel.edu>
Fri, 5 Aug 2011 13:43:13 +0000 (09:43 -0400)
cookbook/admin.py
cookbook/models.py
cookbook/tests.py
util/populate_units.py [new file with mode: 0644]

index cb6a1b138958e7f3d3bbc3c0d3abea1565b155eb..fa29c02d4f4cbc75f797337eefd9c533bab6ec33 100644 (file)
@@ -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)
index 3d75d57a5acecbe360364d40cf908a369cadf7fa..072647602421caa84674676c1bc2a4af11fc49c8 100644 (file)
 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)
index 501deb776c16733b19f3509d86e125df78958261..4dd06f5ffd3d38ce10f04f8109b5bbddf6966558 100644 (file)
@@ -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 (file)
index 0000000..af4daa8
--- /dev/null
@@ -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()