From 364935709467c654cc1c079212ec939438935d6f Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Thu, 4 Aug 2011 15:39:18 -0400 Subject: [PATCH] Rewrite to Django from scratch. Now it's much more user-friendly. Bump to version 0.2 (retroactively tagging the previous version as 0.1). The flask icon is from DesignKode From their website [1]: DesignKode is releasing this set of 40 free high quality icons for your web site and application GUI designs. All icons in this set are 32 x 32 pixel PNG image files. You may freely use these icons in your commercial or personal projects without attribution. [1] http://www.designkode.com/blog/free-developer-icons --- .gitignore | 6 +- .htaccess | 1 - README | 67 +- bin/cook.py | 149 ----- cookbook/__init__.py | 12 +- cookbook/admin.py | 55 ++ cookbook/cookbook.py | 578 ------------------ cookbook/models.py | 111 ++++ cookbook/mom.py | 136 ----- cookbook/server.py | 440 ------------- cookbook/template/base.html | 33 - cookbook/template/edit-recipe.html | 13 - cookbook/template/recipe.html | 82 --- cookbook/template/recipes.html | 24 - cookbook/tests.py | 16 + cookbook/urls.py | 36 ++ cookbook/views.py | 14 + doc/abbreviations | 25 - example/__init__.py | 0 example/data/static/cookbook.ico | Bin 0 -> 4286 bytes example/data/static/style.css | 109 ++++ .../template/admin/edit_inline/tabular.html | 129 ++++ example/data/template/cookbook/base.html | 29 + example/data/template/cookbook/recipe.html | 58 ++ example/data/template/cookbook/recipes.html | 18 + example/data/template/cookbook/tags.html | 18 + example/manage.py | 14 + example/settings.py | 175 ++++++ example/urls.py | 17 + setup.py | 33 + 30 files changed, 894 insertions(+), 1504 deletions(-) delete mode 100644 .htaccess delete mode 100755 bin/cook.py create mode 100644 cookbook/admin.py delete mode 100644 cookbook/cookbook.py create mode 100644 cookbook/models.py delete mode 100644 cookbook/mom.py delete mode 100644 cookbook/server.py delete mode 100644 cookbook/template/base.html delete mode 100644 cookbook/template/edit-recipe.html delete mode 100644 cookbook/template/recipe.html delete mode 100644 cookbook/template/recipes.html create mode 100644 cookbook/tests.py create mode 100644 cookbook/urls.py create mode 100644 cookbook/views.py delete mode 100644 doc/abbreviations create mode 100644 example/__init__.py create mode 100644 example/data/static/cookbook.ico create mode 100644 example/data/static/style.css create mode 100644 example/data/template/admin/edit_inline/tabular.html create mode 100644 example/data/template/cookbook/base.html create mode 100644 example/data/template/cookbook/recipe.html create mode 100644 example/data/template/cookbook/recipes.html create mode 100644 example/data/template/cookbook/tags.html create mode 100644 example/manage.py create mode 100644 example/settings.py create mode 100644 example/urls.py create mode 100755 setup.py diff --git a/.gitignore b/.gitignore index 58e2130..f5d4d3d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +MANIFEST +sqlite3.db +dist +build *.pyc -recipe -mom* diff --git a/.htaccess b/.htaccess deleted file mode 100644 index 50c2d6b..0000000 --- a/.htaccess +++ /dev/null @@ -1 +0,0 @@ -guest:cookbook:8eb3d4841608f011af3dd4bdc4c8b340 diff --git a/README b/README index 152440d..e751563 100644 --- a/README +++ b/README @@ -1,13 +1,60 @@ -Cookbook provides a Python interface to your recipes so you can throw -out all those index cards. +Cookbook is a recipe manager written in Python_ using the Django_ +framework. -The entry script: - ./bin/cook.py -fires up the server, binding it to a local interface/port. It also -translates cookbook formats, e.g. from my mom's rough text file to the -more robust directory of YAML files that Cookbook uses internally. +Install +======= -The recipes themselves will generally be stored in per-recipe YAML -files under 'recipe'. +Download +-------- -Send any bug reports to W. Trevor King . +Cookbook is published as a Git_ repository. See `cookbook's web +interface`__ for more information. + +__ cookbook_ + +Dependencies +------------ + +Outside of Django_ and the Python_ standard libraries, the only +required dependency is `django-taggit`_ (docs__). + +__ dt2-docs_ + +Quick-start +=========== + +If you don't have a Django project and you just want to run cookbook as +a stand-alone service, you can use the example project written up in +`example`. Set up the project (once):: + + $ python example/manage.py syncdb + +See the `Django documentation`_ for more details. + +Run +=== + +Run the app on your local host (as many times as you like):: + + $ python example/manage.py runserver + +You may need to add the current directory to `PYTHONPATH` so `python` +can find the `cookbook` package. If you're running `bash`, that will +look like + + $ PYTHONPATH=".:$PYTHONPATH" python example/manage.py runserver + +Hacking +======= + +This project was largely build following the `Django tutorial`_. +That's a good place to start if you're new to Django. + +.. _Python: http://www.python.org/ +.. _Django: https://www.djangoproject.com/ +.. _Git: http://git-scm.com/ +.. _cookbook: http://physics.drexel.edu/~wking/code/git/gitweb.cgi?p=cookbook.git +.. _django-taggit: https://github.com/alex/django-taggit +.. _dt2-docs: http://django-taggit.readthedocs.org/en/latest/ +.. _Django documentation: https://docs.djangoproject.com/ +.. _Django tutorial: https://docs.djangoproject.com/en/1.3/intro/tutorial01/ diff --git a/bin/cook.py b/bin/cook.py deleted file mode 100755 index a832e33..0000000 --- a/bin/cook.py +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/python - -# Copyright (C) 2010 W. Trevor King -# -# This file is part of Cookbook. -# -# Cookbook is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# Cookbook is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Cookbook. If not, see . - -from __future__ import with_statement - -import os -import os.path -import uuid - -import cookbook -from cookbook import Cookbook -from cookbook.mom import MomParser -from cookbook.cookbook import test as test_cookbook - -try: - import jinja2 - import cherrypy - from cookbook.server import Server - from cookbook.server import test as test_server -except ImportError, e: - cherrypy = e - def test_server(): - pass - - -if __name__ == '__main__': - import optparse - import sys - - p = optparse.OptionParser() - p.add_option('-t', '--test', dest='test', default=False, - action='store_true', help='run internal tests and exit') - p.add_option('-m', '--mom', dest='mom', metavar='PATH', - help="load Mom's cookbook") - p.add_option('-s', '--serve', dest='serve', default=False, - action='store_true', help='serve cookbook') - p.add_option('-a', '--address', dest='address', default='127.0.0.1', - metavar='ADDR', - help='address that the server will bind to (%default)') - p.add_option('-p', '--port', dest='port', default='8080', - metavar='PORT', - help='port that the server will listen on (%default)') - - options,arguments = p.parse_args() - - if options.test == True: - test_cookbook() - test_server() - sys.exit(0) - - if isinstance(cherrypy, ImportError): - raise cherrypy - - sys.stderr.write('Loading cookbook\n') - if options.mom == None: - c = Cookbook() - c.load() - else: - p = MomParser() - c = p.parse(options.mom) - - if options.serve == False: - sys.stderr.write('Saving cookbook\n') - c.save('new-recipe') - else: - # HACK! to ensure we *always* get utf-8 output - #reload(sys) - #sys.setdefaultencoding('utf-8') - sys.stderr.write('Serving cookbook\n') - module_dir = os.path.dirname(os.path.abspath(cookbook.__file__)) - static_dir = os.path.join(module_dir, 'static') - if not os.path.exists(static_dir): - os.mkdir(static_dir) - template_dir = os.path.join(module_dir, 'template') - config = os.path.join(module_dir, 'config') - s = Server(c, template_dir) - if cherrypy.__version__.startswith('3.'): - cherrypy.config.update({ # http://www.cherrypy.org/wiki/ConfigAPI - 'server.socket_host': options.address, - 'server.socket_port': int(options.port), - 'tools.decode.on': True, - 'tools.encode.on': True, - 'tools.encode.encoding': 'utf8', - 'tools.staticdir.root': static_dir, - }) - if cherrypy.__version__.startswith('3.2'): - get_ha1 = cherrypy.lib.auth_digest.get_ha1_file_htdigest( - '.htaccess') - digest_auth = { - 'tools.auth_digest.on': True, - 'tools.auth_digest.realm': 'cookbook', - 'tools.auth_digest.get_ha1': get_ha1, - 'tools.auth_digest.key': str(uuid.uuid4()), - } - else: - passwds = {} - with open('.htaccess', 'r') as f: - for line in f: - user,realm,ha1 = line.strip().split(':') - passwds[user] = ha1 # use the ha1 as the password - digest_auth = { - 'tools.digest_auth.on': True, - 'tools.digest_auth.realm': 'cookbook', - 'tools.digest_auth.users': passwds, - } - print passwds - del(passwds) - app_config = { - '/static': { - 'tools.staticdir.on': True, - 'tools.staticdir.dir': '', - }, - '/edit': digest_auth, - '/add_tag': digest_auth, - '/remove_tag': digest_auth, - } - cherrypy.quickstart(root=s, config=app_config) - elif cherrypy.__version__.startswith('2.'): - cherrypy.root = s - cherrypy.config.update({ - 'server.environment': 'production', - 'server.socket_host': options.address, - 'server.socket_port': int(options.port), - 'decoding_filter.on': True, - 'encoding_filter.on': True, - 'encodinf_filter.encoding': 'utf8', - 'static_filter.on': True, - 'static_filter.dir': static_dir, - }) - import pprint - pprint.pprint(cherrypy.config.configs) - cherrypy.server.start() - s.cleanup() diff --git a/cookbook/__init__.py b/cookbook/__init__.py index 920f373..b650ceb 100644 --- a/cookbook/__init__.py +++ b/cookbook/__init__.py @@ -1,11 +1 @@ -"""Cookbook package, for manipulating and serving cookbooks. - -Submodules: -* cookbook, basic cookbook classes -* server, for serving a cookbook over HTTP -* mom, for parsing my mom's cookbook format -""" - -from .cookbook import Cookbook - -__all__ = {'Cookbook':Cookbook} +__version__ = '0.2' diff --git a/cookbook/admin.py b/cookbook/admin.py new file mode 100644 index 0000000..cb6a1b1 --- /dev/null +++ b/cookbook/admin.py @@ -0,0 +1,55 @@ +from django import forms +from django.contrib import admin +from django.db import models as django_models + +import models + + +class IngredientInline (admin.TabularInline): + model = models.Ingredient + extra = 0 + + +class IngredientBlockAdmin (admin.ModelAdmin): + fieldsets = [ + (None, {'fields': ['name']}), + ('Directions', {'fields': ['directions_markdown'], + 'classes': ['collapse']}), + ] + inlines = [IngredientInline] + + list_display = ['name', 'recipe'] + extra = 0 + + +class IngredientBlockInline (admin.TabularInline): + model = models.IngredientBlock + fieldsets = [ + (None, {'fields': ['name']}), + ] + inlines = [IngredientInline] + + list_display = ['name'] + extra = 0 + show_edit_link = True # https://code.djangoproject.com/ticket/13163 + # work around 13163 + #template = 'admin/edit_inline/tabular-13163.html' + +class RecipeAdmin (admin.ModelAdmin): + fieldsets = [ + (None, {'fields': ['name']}), + ('Metadata', {'fields': ['author', 'source', 'url', 'x_yield', 'tags'], + 'classes': ['collapse']}), + ('Directions', {'fields': ['directions_markdown']}), + ] + inlines = [IngredientBlockInline] + + list_display = ['name'] + + +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/cookbook.py b/cookbook/cookbook.py deleted file mode 100644 index 24a70fe..0000000 --- a/cookbook/cookbook.py +++ /dev/null @@ -1,578 +0,0 @@ -#!/usr/bin/python -# -*- encoding: utf-8 -*- -# -# Copyright (C) 2010 W. Trevor King -# -# This file is part of Cookbook. -# -# Cookbook is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# Cookbook is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Cookbook. If not, see . - -"""Represent a cookbook and recipes with Python classes. -""" - -from __future__ import with_statement - -import os -import os.path -import textwrap -import types -from urllib import quote_plus - -import yaml - - -def string_for_yaml(unicode_): - """ - >>> string_for_yaml(None) - >>> string_for_yaml('all ascii') - 'all ascii' - >>> string_for_yaml(u'all ascii') - 'all ascii' - >>> string_for_yaml(u'½ ascii') - u'\\xc2\\xbd ascii' - """ - if unicode_ == None: - return unicode_ - try: - string = unicode_.encode('ascii') - return string - except UnicodeEncodeError: - return unicode_ - -def to_yaml_object(obj): - """ - >>> to_yaml_object(None) - >>> to_yaml_object('all ascii') - 'all ascii' - >>> to_yaml_object(u'all ascii') - 'all ascii' - >>> to_yaml_object('all ascii') - 'all ascii' - >>> to_yaml_object(u'½ ascii') - u'\\xc2\\xbd ascii' - >>> class x (object): - ... def to_yaml(self): - ... return 'to_yaml return value' - >>> to_yaml_object(x()) - 'to_yaml return value' - >>> to_yaml_object([u'all ascii', u'½ ascii']) - ['all ascii', u'\\xc2\\xbd ascii'] - """ - if obj == None: - return obj - if hasattr(obj, 'to_yaml'): - return obj.to_yaml() - if type(obj) in types.StringTypes: - return string_for_yaml(obj) - if hasattr(obj, '__len__'): - ret = [] - for item in obj: - ret.append(to_yaml_object(item)) - return ret - raise NotImplementedError( - 'cannot convert %s to YAMLable dict:\n%s' % (type(obj), unicode(obj))) - - -class Amount (object): - """ - >>> import pprint - >>> a = Amount(value='1', units='T.') - >>> str(a) - '1 T.' - >>> pprint.pprint(a.to_yaml()) - {'alternatives': [], 'range_': None, 'units': 'T.', 'value': '1'} - >>> x = Amount() - >>> x.from_yaml(a.to_yaml()) - >>> str(x) - '1 T.' - >>> b = Amount(value='1') - >>> str(b) - '1' - >>> pprint.pprint(b.to_yaml()) - {'alternatives': [], 'range_': None, 'units': None, 'value': '1'} - >>> x.from_yaml(b.to_yaml()) - >>> str(x) - '1' - >>> c = Amount(value='15', units='mL', alternatives=[a]) - >>> str(c) - '15 mL (1 T.)' - >>> pprint.pprint(c.to_yaml()) - {'alternatives': [{'alternatives': [], - 'range_': None, - 'units': 'T.', - 'value': '1'}], - 'range_': None, - 'units': 'mL', - 'value': '15'} - >>> x.from_yaml(c.to_yaml()) - >>> str(x) - '15 mL (1 T.)' - >>> d = Amount(units='T.', range_=['1','2']) - >>> str(d) - '1-2 T.' - >>> pprint.pprint(d.to_yaml()) - {'alternatives': [], 'range_': ['1', '2'], 'units': 'T.', 'value': None} - >>> x.from_yaml(d.to_yaml()) - >>> str(x) - '1-2 T.' - """ - def __init__(self, value=None, units=None, range_=None, alternatives=None): - self.value = value - self.units = units - self.range_ = range_ - if alternatives == None: - alternatives = [] - self.alternatives = alternatives - - def __str__(self): - return str(self.__unicode__()) - - def __unicode__(self): - if self.range_ == None: - value = self.value - else: - value = '-'.join(self.range_) - ret = [value] - if self.units != None: - ret.append(self.units) - if len(self.alternatives) > 0: - ret.append('(%s)' % ', '.join( - [unicode(a) for a in self.alternatives])) - return ' '.join([x for x in ret if x != None]) - - def to_yaml(self): - d = {} - for key in ['value', 'range_', 'units', 'alternatives']: - d[key] = to_yaml_object(getattr(self, key)) - return d - - def from_yaml(self, dict): - for key in ['value', 'range_', 'units']: - setattr(self, key, dict.get(key, None)) - self.alternatives = [] - for a in dict.get('alternatives', []): - amount = Amount() - amount.from_yaml(a) - self.alternatives.append(amount) - - -class Ingredient (object): - """ - >>> import pprint - >>> i = Ingredient('eye of newt', Amount('1')) - >>> str(i) - '1 eye of newt' - >>> pprint.pprint(i.to_yaml()) - {'amount': {'alternatives': [], 'range_': None, 'units': None, 'value': '1'}, - 'name': 'eye of newt', - 'note': None} - >>> x = Ingredient() - >>> x.from_yaml(i.to_yaml()) - >>> str(x) - '1 eye of newt' - >>> j = Ingredient('salamanders', Amount('2'), 'diced fine') - >>> str(j) - '2 salamanders, diced fine' - >>> pprint.pprint(j.to_yaml()) - {'amount': {'alternatives': [], 'range_': None, 'units': None, 'value': '2'}, - 'name': 'salamanders', - 'note': 'diced fine'} - >>> x.from_yaml(j.to_yaml()) - >>> str(x) - '2 salamanders, diced fine' - """ - def __init__(self, name=None, amount=None, note=None): - self.name = name - self.amount = amount - self.note = note - - def __str__(self): - return str(self.__unicode__()) - - def __unicode__(self): - if self.note == None: - return '%s %s' % (unicode(self.amount or ''), self.name) - return '%s %s, %s' % (unicode(self.amount or ''), self.name, self.note) - - def to_yaml(self): - d = {} - for key in ['name', 'amount', 'note']: - d[key] = to_yaml_object(getattr(self, key)) - return d - - def from_yaml(self, dict): - for key in ['name', 'note']: - setattr(self, key, dict.get(key, None)) - self.amount = Amount() - self.amount.from_yaml(dict.get('amount', {})) - - -class IngredientBlock (list): - """ - >>> import pprint - >>> ib = IngredientBlock(None, [ - ... Ingredient('eye of newt', Amount('1')), - ... Ingredient('salamanders', Amount('2'), 'diced fine')]) - >>> print str(ib) - Ingredients: - 1 eye of newt - 2 salamanders, diced fine - >>> pprint.pprint(ib.to_yaml()) - {'ingredients': [{'amount': {'alternatives': [], - 'range_': None, - 'units': None, - 'value': '1'}, - 'name': 'eye of newt', - 'note': None}, - {'amount': {'alternatives': [], - 'range_': None, - 'units': None, - 'value': '2'}, - 'name': 'salamanders', - 'note': 'diced fine'}], - 'name': None} - >>> x = IngredientBlock() - >>> x.from_yaml(ib.to_yaml()) - >>> print str(x) - Ingredients: - 1 eye of newt - 2 salamanders, diced fine - >>> ib.name = 'Dressing' - >>> print str(ib) - Dressing: - 1 eye of newt - 2 salamanders, diced fine - >>> pprint.pprint(ib.to_yaml()) - {'ingredients': [{'amount': {'alternatives': [], - 'range_': None, - 'units': None, - 'value': '1'}, - 'name': 'eye of newt', - 'note': None}, - {'amount': {'alternatives': [], - 'range_': None, - 'units': None, - 'value': '2'}, - 'name': 'salamanders', - 'note': 'diced fine'}], - 'name': 'Dressing'} - >>> x = IngredientBlock() - >>> x.from_yaml(ib.to_yaml()) - >>> print str(x) - Dressing: - 1 eye of newt - 2 salamanders, diced fine - """ - def __init__(self, name=None, *args, **kwargs): - self.name = name - super(IngredientBlock, self).__init__(*args, **kwargs) - - def heading(self): - if self.name == None: - return 'Ingredients' - return self.name - - def __str__(self): - return str(self.__unicode__()) - - def __unicode__(self): - ret = [unicode(i) for i in self] - ret.insert(0, '%s:' % self.heading()) - return '\n '.join(ret) - - def to_yaml(self): - d = {} - for key in ['name']: - d[key] = to_yaml_object(getattr(self, key)) - d['ingredients'] = to_yaml_object(list(self)) - return d - - def from_yaml(self, dict): - self.name = dict.get('name', None) - while len(self) > 0: - self.pop() - for i in dict.get('ingredients', []): - ingredient = Ingredient() - ingredient.from_yaml(i) - self.append(ingredient) - - -class Directions (list): - """ - >>> import pprint - >>> d = Directions(['paragraph 1', 'paragraph 2', 'paragraph 3']) - >>> print str(d) - paragraph 1 - - paragraph 2 - - paragraph 3 - >>> pprint.pprint(d.to_yaml()) - 'paragraph 1\\n\\nparagraph 2\\n\\nparagraph 3' - >>> x = Directions() - >>> x.from_yaml(d.to_yaml()) - >>> print str(x) - paragraph 1 - - paragraph 2 - - paragraph 3 - >>> pprint.pprint(x.to_yaml()) - 'paragraph 1\\n\\nparagraph 2\\n\\nparagraph 3' - """ - def __str__(self): - return str(self.__unicode__()) - - def __unicode__(self): - return '\n\n'.join(self) - - def wrapped_paragraphs(self, *args, **kwargs): - return ['\n'.join( - textwrap.wrap( - paragraph, - *args, - **kwargs)) - for paragraph in self] - - def wrap(self, *args, **kwargs): - return '\n\n'.join(self.wrapped_paragraphs(*args, **kwargs)) - - def to_yaml(self): - return string_for_yaml('\n\n'.join([paragraph.rstrip('\n') - for paragraph in self])) - - def from_yaml(self, string): - if string == None: - return - while len(self) > 0: - self.pop() - for paragraph in string.split('\n\n'): - self.append(paragraph) - - -class Recipe (object): - """ - >>> import pprint - >>> r = Recipe('Hot Newt Dressing', - ... [IngredientBlock(None, [ - ... Ingredient('eye of newt', Amount('1')), - ... Ingredient('salamanders', Amount('2'), 'diced fine')])], - ... Directions(['Mix ingredients until salamander starts to smoke', - ... 'Serve warm over rice']), - ... yield_='enough for one apprentice', - ... author='Merlin', - ... source='CENSORED', - ... url='http://merlin.uk/recipes/1234/', - ... tags=['dinner', 'easy', 'apprentice', 'eye', 'newt', 'salamander']) - >>> print str(r) - Recipe: Hot Newt Dressing - Yield: enough for one apprentice - From: Merlin - Source: CENSORED - URL: http://merlin.uk/recipes/1234/ - Ingredients: - 1 eye of newt - 2 salamanders, diced fine - Mix ingredients until salamander starts to smoke - - Serve warm over rice - >>> r.matches_tags(None) - True - >>> r.matches_tags([]) - True - >>> r.matches_tags(['dinner', 'apprentice']) - True - >>> r.matches_tags(['dinner', 'dragon']) - False - >>> pprint.pprint(r.to_yaml()) - {'author': 'Merlin', - 'directions': 'Mix ingredients until salamander starts to smoke\\n\\nServe warm over rice', - 'ingredient_blocks': [{'ingredients': [{'amount': {'alternatives': [], - 'range_': None, - 'units': None, - 'value': '1'}, - 'name': 'eye of newt', - 'note': None}, - {'amount': {'alternatives': [], - 'range_': None, - 'units': None, - 'value': '2'}, - 'name': 'salamanders', - 'note': 'diced fine'}], - 'name': None}], - 'name': 'Hot Newt Dressing', - 'source': 'CENSORED', - 'tags': ['dinner', 'easy', 'apprentice', 'eye', 'newt', 'salamander'], - 'url': 'http://merlin.uk/recipes/1234/', - 'yield_': 'enough for one apprentice'} - >>> x = Recipe() - >>> x.from_yaml(r.to_yaml()) - >>> print str(x) - Recipe: Hot Newt Dressing - Yield: enough for one apprentice - From: Merlin - Source: CENSORED - URL: http://merlin.uk/recipes/1234/ - Ingredients: - 1 eye of newt - 2 salamanders, diced fine - Mix ingredients until salamander starts to smoke - - Serve warm over rice - """ - def __init__(self, name=None, ingredient_blocks=None, directions=None, - yield_=None, author=None, source=None, url=None, tags=None): - self.name = name - self.ingredient_blocks = ingredient_blocks - self.directions = directions - self.yield_ = yield_ - self.author = author - self.source = source - self.url = url - self.tags = tags - self.path = None - - def clean_name(self, ascii=False): - name = self.name - for from_,to in [(' ','_'), ('/', '_'), (',', ''), ('&', 'and')]: - name = name.replace(from_, to) - if ascii == True: - return quote_plus(name.encode('utf-8')) - return name - - def matches_tags(self, tags): - """Return True if this recipe is tagges with each of the tags in tags. - """ - if tags in [None, []]: - return True - if self.tags == None: - return False - for t in tags: - if t not in self.tags: - return False - return True - - def __str__(self): - return str(self.__unicode__()) - - def __unicode__(self): - return '\n'.join([ - 'Recipe: %s' % self.name, - 'Yield: %s' % self.yield_, - 'From: %s' % self.author, - 'Source: %s' % self.source, - 'URL: %s' % self.url, - '\n'.join([unicode(ib) for ib in self.ingredient_blocks]), - unicode(self.directions), - ]) - - def to_yaml(self): - d = {} - for key in ['name', 'ingredient_blocks', 'directions', 'yield_', - 'author', 'source', 'url', 'tags']: - d[key] = to_yaml_object(getattr(self, key)) - return d - - def from_yaml(self, dict): - for key in ['name', 'yield_', 'author', 'source', 'url', 'tags']: - setattr(self, key, dict.get(key, None)) - self.ingredient_blocks = [] - for ib in dict.get('ingredient_blocks', []): - ingredient_block = IngredientBlock() - ingredient_block.from_yaml(ib) - self.ingredient_blocks.append(ingredient_block) - self.directions = Directions() - self.directions.from_yaml(dict.get('directions', None)) - - def save(self, stream, path=None): - yaml.dump(self.to_yaml(), stream, - default_flow_style=False, allow_unicode=True, width=78) - if path != None: - self.path = path - - def load(self, stream, path=None): - dict = yaml.load(stream) - self.from_yaml(dict) - if path != None: - self.path = path - - -class Cookbook (list): - """ - TODO: doctests - """ - def __init__(self, name="Mom's cookbook", *args, **kwargs): - self.name = name - self.save_dir = u'recipe' - super(Cookbook, self).__init__(*args, **kwargs) - - def save(self): - if not os.path.isdir(self.save_dir): - os.mkdir(self.save_dir) - paths = [] - for recipe in self: - p = self._free_path(recipe, paths) - paths.append(p) - with open(p, 'w') as f: - recipe.save(f, p) - - def load(self): - for path in sorted(os.listdir(self.save_dir)): - r = Recipe() - p = os.path.join(self.save_dir, path) - with open(p, 'r') as f: - r.load(f, p) - self.append(r) - - def make_index(self): - self.index = {} - paths = [recipe.path for recipe in self] - for recipe in self: - self.index[recipe.name] = recipe - self.index[recipe.clean_name()] = recipe - self.index[recipe.clean_name(ascii=True)] = recipe - if recipe.path == None: - p = self._free_path(recipe, paths) - paths.append(p) - recipe.path = p - - def _free_path(self, recipe, paths): - base_path = os.path.join(self.save_dir, recipe.clean_name()) - path = base_path - i = 2 - while path in paths: - path = '%s_%d' % (base_path, i) - i += 1 - return path - - def tags(self): - """List all tags used in this cookbook. - """ - tags = set() - for recipe in self: - if recipe.tags != None: - tags = tags.union(set(recipe.tags)) - return sorted(tags) - - def tagged(self, tags=None): - """Iterate through all recipes matching the given list of tags. - """ - for recipe in self: - if recipe.matches_tags(tags): - yield recipe - - -def test(): - import doctest - doctest.testmod() diff --git a/cookbook/models.py b/cookbook/models.py new file mode 100644 index 0000000..3d75d57 --- /dev/null +++ b/cookbook/models.py @@ -0,0 +1,111 @@ +from django.db import models +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) + +class Unit (models.Model): + "Kilograms, pounds, liters, gallons, etc." + abbrev = models.CharField('abbreviation', max_length=6) + 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) + + def __unicode__(self): + return u'{0.abbrev}'.format(self) + +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) + 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) + + 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) + else: + value = '{0.value} ({0.min_value}-{0.max_value})'.format(self) + return u'{0} {1.unit}'.format(value, self) + +class Recipe (models.Model): + name = models.CharField(max_length=200) + directions_markdown = models.TextField( + 'directions', help_text='Markdown syntax') + 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) + 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) + tags = TaggableManager(blank=True) + + class Meta: + ordering = ['name'] + + 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): + 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: + ordering = ['recipe', 'name'] + + 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): + "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 + if self.note: + fmt += u', {0.note}' + return fmt.format(self) diff --git a/cookbook/mom.py b/cookbook/mom.py deleted file mode 100644 index 54336cf..0000000 --- a/cookbook/mom.py +++ /dev/null @@ -1,136 +0,0 @@ -# Copyright (C) 2010 W. Trevor King -# -# This file is part of Cookbook. -# -# Cookbook is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# Cookbook is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Cookbook. If not, see . - -"""Hack parser for standardizing my Mom's cookbook text. -""" - -from .cookbook import ( - Cookbook, Recipe, IngredientBlock, Ingredient, Amount, Directions) - - -class MomParser (object): - def parse(self, filename): - c = Cookbook() - consecutive_blanks = 100 - recipe_lines = None - for line in file(filename, 'r'): - line = line.strip().decode('utf-8') - if line == '': - if recipe_lines != None and consecutive_blanks == 0: - recipe_lines.append('') - consecutive_blanks += 1 - continue - if consecutive_blanks >= 2: - if recipe_lines != None: - c.append(self._parse_recipe(recipe_lines)) - recipe_lines = [line] - else: - recipe_lines.append(line) - consecutive_blanks = 0 - return c - - def _parse_recipe(self, lines): - name = lines.pop(0) - yield_,author,source,url,lines = self._parse_yield_line(lines) - ingredient_blocks,lines = self._parse_ingredient_blocks(lines) - directions,lines = self._parse_directions(lines) - assert len(lines) == 0, lines - return Recipe( - name=name, - ingredient_blocks=ingredient_blocks, - directions=directions, - yield_=yield_, - author=author, - source=source, - url=url) - - def _parse_yield_line(self, lines): - while len(lines) > 0 and lines[0] == '': - lines.pop(0) - fields = ['yield', 'from', 'source', 'url'] - yield_ = author = source = url = None - matching_line = False - for field in fields: - if field in lines[0].lower(): - matching_line = True - break - if matching_line == True: - bits = lines.pop(0).split('\t') - for bit in bits: - for field in fields: - if bit.lower().startswith(field+':'): - value = bit[len(field+':'):].strip() - if field == 'yield': - yield_ = value.replace('Serving', 'serving') - elif field == 'from': - author = value - elif field == 'source': - source = value - elif field == 'url': - url = value - break - return (yield_, author, source, url, lines) - - def _parse_ingredient_blocks(self, lines): - ingredient_blocks = [] - first_block = True - while True: - while len(lines) > 0 and lines[0] == '': # scroll past blanks - lines.pop(0) - if (len(lines) == 0 - or not (first_block == True - or lines[0].endswith(':'))): - break - if lines[0].endswith(':'): - line = lines.pop(0) - name = line[:-1].strip() - else: - name = None - block = IngredientBlock(name) - while len(lines) > 0 and lines[0] != '': - block.append(self._parse_ingredient_line(lines.pop(0))) - ingredient_blocks.append(block) - first_block = False - return (ingredient_blocks, lines) - - def _parse_ingredient_line(self, line): - if line.lower().startswith('1 red'): - line = '1 # red'+line[len('1 red'):] - try: - value,units,name = line.split(' ', 2) - except ValueError: - print line, - raise - if units == '#': - units = None - elif units == 'Large': - units = 'large' - elif units == 'Cloves': - units = 'cloves' - return Ingredient(name, Amount(value, units)) - - def _parse_directions(self, lines): - directions = Directions() - paragraph = [] - for line in lines: - if line == '': - if len(paragraph) > 0: - directions.append('\n'.join(paragraph)) - paragraph = [] - else: - paragraph.append(line) - return (directions, []) diff --git a/cookbook/server.py b/cookbook/server.py deleted file mode 100644 index e94e769..0000000 --- a/cookbook/server.py +++ /dev/null @@ -1,440 +0,0 @@ -# Copyright (C) 2010 W. Trevor King -# -# This file is part of Cookbook. -# -# Cookbook is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the -# Free Software Foundation, either version 3 of the License, or (at your -# option) any later version. -# -# Cookbook is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with Cookbook. If not, see . - -"""Serve cookbooks over HTTP. -""" - -from __future__ import absolute_import -from __future__ import with_statement - -import os -import random -import re -import types -from urllib import urlencode -import uuid -from xml.sax import saxutils - -import cherrypy -from jinja2 import Environment, FileSystemLoader - -from .cookbook import Recipe, Directions, IngredientBlock, Ingredient, Amount - - -class Server (object): - """Cookbook web interface.""" - - def __init__(self, cookbook, template_root): - self.cookbook = cookbook - self.cookbook.make_index() - self.env = Environment(loader=FileSystemLoader(template_root)) - # name regular expressions - self._action_ingredient_block_regexp = re.compile( - 'ingredient block ([0-9]*) ([a-z ]*)') - self._action_ingredient_regexp = re.compile( - 'ingredient ([0-9]*) ([0-9]*) ([a-z ]*)') - # value regular expressions - self._tag_regexp = re.compile('[a-zA-Z./ ].*') # allowed characters - self._action_add_ingredient_block_regexp = re.compile( - 'add ingredient block') - self._action_remove_ingredient_block_regexp = re.compile( - 'remove ingredient block ([0-9]*)') - self._action_add_ingredient_regexp = re.compile( - 'add ingredient ([0-9]*)') - self._action_remove_ingredient_regexp = re.compile( - 'remove ingredient ([0-9]*) ([0-9]*)') - - def cleanup(self): - #self.cookbook.save('new-recipe') - pass - - @cherrypy.expose - def index(self, tag=None): - """Recipe index page. - - Recipes can be filtered by tag. - """ - if isinstance(tag, types.StringTypes): - tag = [tag] - template = self.env.get_template('recipes.html') - return template.render(cookbook=self.cookbook, - recipes=list(self.cookbook.tagged(tag)), - selected_tags=tag) - - @cherrypy.expose - def recipe(self, name=None): - """Single recipe page. - """ - if name == None: - recipe = random.choice(self.cookbook) - else: - recipe = self.cookbook.index[name] - tag_links = [ - '%s' % (urlencode({'tag':t}), t) - for t in (recipe.tags or [])] - template = self.env.get_template('recipe.html') - return template.render(cookbook=self.cookbook, recipe=recipe, - tag_links=tag_links) - - @cherrypy.expose - def add_tag(self, name, tag): - """Add a tag to a single recipe.""" - recipe = self.cookbook.index[name] - if recipe.tags == None: - recipe.tags = [] - tag = self._clean_tag(tag) - if tag != None and tag not in recipe.tags: - recipe.tags.append(tag) - with open(recipe.path, 'w') as f: - recipe.save(f) - raise cherrypy.HTTPRedirect( - u'recipe?name=%s' % recipe.clean_name(ascii=True), status=302) - - @cherrypy.expose - def remove_tag(self, name, tag): - """Remove a tag from a single recipe.""" - recipe = self.cookbook.index[name] - if recipe.tags == None: - return - tag = self._clean_tag(tag) - if tag != None and tag in recipe.tags: - recipe.tags.remove(tag) - with open(recipe.path, 'w') as f: - recipe.save(f) - raise cherrypy.HTTPRedirect( - u'recipe?name=%s' % recipe.clean_name(ascii=True), status=302) - - def _clean_tag(self, tag): - """Sanitize tag.""" - if not isinstance(tag, types.StringTypes): - if len(tag) == 2 and '' in tag: - # User used either dropdown or textbox - tag.remove('') - tag = tag[0] - else: - # User used both dropdown and textbox - return None - m = self._tag_regexp.match(tag) - if m != None: - return m.group() - return None - - @cherrypy.expose - def edit(self, name=None, **kwargs): - """Remove a tag from a single recipe.""" - name,recipe,action = self._normalize_edit_params(name, **kwargs) - if action == 'remove': - recipe = self.cookbook.index[name] - os.remove(recipe.path) - self.cookbook.remove(recipe) - self.cookbook.make_index() - raise cherrypy.HTTPRedirect(u'.', status=302) - elif action.startswith('edit'): - self._update_recipe(name, recipe) - if action == 'edit and redirect': # done editing this recipe - raise cherrypy.HTTPRedirect( - u'recipe?name=%s' % recipe.clean_name(ascii=True), - status=302) - elif recipe.name != name: - raise cherrypy.HTTPRedirect( - u'edit?name=%s' % recipe.clean_name(ascii=True), - status=302) - template = self.env.get_template('edit-recipe.html') - return template.render(cookbook=self.cookbook, - recipe=recipe, - form='\n'.join(self._recipe_form(recipe))) - - def _update_recipe(self, name, recipe): - if len(recipe.ingredient_blocks) == 0: - recipe.ingredient_blocks = None - if name not in self.cookbook.index: # new recipe - print 'new' - self.cookbook.append(recipe) - self.cookbook.make_index() - self.cookbook.sort(key=lambda r: r.path) - elif name != recipe.name: # renamed recipe - print 'rename' - os.remove(recipe.path) - recipe.path = None - self.cookbook.make_index() - with open(recipe.path, 'w') as f: - recipe.save(f) - - def _normalize_edit_params(self, name, **kwargs): - action = 'view form' - if name in self.cookbook.index: - recipe = self.cookbook.index[name] - name = recipe.name # get the canonical name for this recipe - else: - # Use uuid4() to generate a random name if name is None - recipe = Recipe(name=(name or str(uuid.uuid4()))) - recipe.path = None - # non-ingredient text updates - for attr in ['name','directions','yield_','author','source','url']: - n = self._recipe_attr_to_name(attr) - if n not in kwargs: - continue - v = kwargs.get(n).strip() - if v == '': - v = None - elif attr == 'directions': - d = Directions() - v = v.replace('\r\n', '\n').replace('\r', '\n') - for p in v.split('\n\n'): - p = p.strip() - if len(p) > 0: - d.append(p) - v = d - if v != getattr(recipe, attr): - setattr(recipe, attr, v) - action = 'edit' - if recipe.ingredient_blocks == None: - recipe.ingredient_blocks = [] - # protect against index-changing operations - ingredient_block_map = dict( - [(i,i) for i in range(len(recipe.ingredient_blocks))]) - ingredient_map = dict( - [(i, dict([(j,j) for j in range(len(ib))])) - for i,ib in enumerate(recipe.ingredient_blocks)]) - # ingredient text updates - for key,value in kwargs.items(): - for k in ['ingredient_block', 'ingredient']: - regexp = getattr(self, '_action_%s_regexp' % k) - m = regexp.match(key) - if m != None: - handler = getattr(self, '_action_%s' % k) - kws = {'ingredient_block_map': ingredient_block_map, - 'ingredient_map': ingredient_map} - action = handler(recipe, action, value, *m.groups(), **kws) - break - # button updates - action = kwargs.get('action', action) - if action == 'submit': - action = 'edit and redirect' - for a in ['add_ingredient_block', 'remove_ingredient_block', - 'add_ingredient', 'remove_ingredient']: - regexp = getattr(self, '_action_%s_regexp' % a) - m = regexp.match(action) - if m != None: - handler = getattr(self, '_action_%s' % a) - kws = {'ingredient_block_map': ingredient_block_map, - 'ingredient_map': ingredient_map} - action = handler(recipe, action, *m.groups(), **kws) - break - return (name, recipe, action) - - def _action_ingredient_block(self, recipe, action, value, - block_index, block_action, - ingredient_block_map, **kwargs): - block_index = ingredient_block_map[int(block_index)] - ib = recipe.ingredient_blocks[block_index] - if value == '': - value = None - if block_action == 'position' and value not in [None, '']: - value = int(value) - if value != block_index: - ibs = recipe.ingredient_blocks - ibs.insert(value, ibs.pop(block_index)) - for i in range(min(block_index, value), - max(block_index, value)+1): - ingredient_block_map[i] = i+1 - ingredient_block_map[block_index] = value - return 'edit' - elif block_action == 'name': - if value != ib.name: - ib.name = value - return 'edit' - return action - - def _action_ingredient(self, recipe, action, value, - block_index, index, ingredient_action, - ingredient_block_map, ingredient_map, **kwargs): - block_index = ingredient_block_map[int(block_index)] - index = ingredient_map[block_index][int(index)] - ingredient = recipe.ingredient_blocks[block_index][index] - if value == '': - value = None - if ingredient_action == 'position' and value != None: - value = int(value) - if value != block_index: - ibs = recipe.ingredient_blocks - ibs.insert(value, ibs.pop(block_index)) - for i in range(min(block_index, value), - max(block_index, value)+1): - ingredient_block_map[i] = i+1 - ingredient_block_map[block_index] = value - return 'edit' - elif ingredient_action in ['name', 'note']: - if value != getattr(ingredient, ingredient_action): - setattr(ingredient, ingredient_action, value) - return 'edit' - elif ingredient_action in ['value', 'units']: - if value != getattr(ingredient.amount, ingredient_action): - setattr(ingredient.amount, ingredient_action, value) - return 'edit' - return action - - def _action_add_ingredient_block(self, recipe, action, - ingredient_block_map, - ingredient_map, **kwargs): - recipe.ingredient_blocks.append(IngredientBlock()) - block_index = len(recipe.ingredient_blocks)-1 - ingredient_block_map[block_index] = block_index - ingredient_map[block_index] = {} - return 'edit' - - def _action_remove_ingredient_block(self, recipe, action, block_index, - ingredient_block_map, **kwargs): - block_index = ingredient_block_map[int(block_index)] - recipe.ingredient_blocks.pop(block_index) - for k,v in ingredient_block_map.items(): - if v >= block_index: - ingredient_block_map[k] = v-1 - return 'edit' - - def _action_add_ingredient(self, recipe, action, block_index, - ingredient_block_map, - ingredient_map, **kwargs): - block_index = ingredient_block_map[int(block_index)] - recipe.ingredient_blocks[block_index].append(Ingredient( - amount=Amount())) - index = len(recipe.ingredient_blocks[block_index])-1 - ingredient_map[block_index][index] = index - return 'edit' - - def _action_remove_ingredient(self, recipe, action, block_index, index, - ingredient_block_map, - ingredient_map, **kwargs): - block_index = ingredient_block_map[int(block_index)] - index = ingredient_map[block_index][int(index)] - recipe.ingredient_blocks[block_index].pop(index) - for k,v in ingredient_map[block_index].items(): - if v >= index: - ingredient_map[block_index][k] = v-1 - return 'edit' - - def _recipe_form(self, recipe): - lines = [ - '
', - '', - ] - for attr in ['name', 'yield_', 'author', 'source', 'url']: - lines.extend(self._text_form( - self._recipe_attr_to_name(attr), - self._recipe_attr_to_user(attr), - getattr(recipe, attr))) - for i,ib in enumerate(recipe.ingredient_blocks): - lines.extend(self._ingredient_block_form(ib, i)) - lines.extend(self._submit_form('add ingredient block')) - lines.extend(self._textarea_form( - self._recipe_attr_to_name('directions'), - self._recipe_attr_to_user('directions'), - '\n\n'.join(recipe.directions or Directions()))) - lines.extend(self._submit_form('submit')) - lines.extend([ - '
', - '
', - ]) - return lines - - def _ingredient_block_form(self, ingredient_block, index): - lines = self._submit_form( - 'remove ingredient block %d' % index, - note='Ingredient block %d' % index) - for name,user,value in [ - ('ingredient block %d position' % index, - 'Position (%d)' % index, - None), - ('ingredient block %d name' % index, - 'Name', - ingredient_block.name), - ]: - lines.extend(self._text_form(name, user, value)) - for i,ingredient in enumerate(ingredient_block): - lines.extend(self._ingredient_form(ingredient, index, i)) - lines.extend(self._submit_form('add ingredient %d' % index)) - return lines - - def _ingredient_form(self, ingredient, block_index, index): - lines = self._submit_form( - 'remove ingredient %d %d' % (block_index, index), - note='Ingredient %d %d' % (block_index, index)) - for name,user,value in [ - ('ingredient %d %d position' % (block_index, index), - 'Position (%d)' % index, - ''), - ('ingredient %d %d value' % (block_index, index), - 'Value', - ingredient.amount.value), - ('ingredient %d %d units' % (block_index, index), - 'Units', - ingredient.amount.units), - ('ingredient %d %d name' % (block_index, index), - 'Name', - ingredient.name), - ('ingredient %d %d note' % (block_index, index), - 'Note', - ingredient.note), - ]: - lines.extend(self._text_form(name, user, value)) - # TODO: Amount: range_, alternatives - return lines - - def _submit_form(self, value, note=''): - return [ - ' ', - ' %s' % note, - ' ' - % value, - ' ', - ] - - def _text_form(self, name, user, value): - return [ - ' ', - ' ' % (name, user), - ' ' - % (name, value or ''), - ' ', - ] - - def _textarea_form(self, name, user, value): - return [ - ' ', - ' ' % (name, user), - ' ' - ' ', - ] - - def _recipe_attr_to_name(self, attr): - if attr == 'yield_': - return 'yield' - elif attr == 'name': - return 'new_name' - return attr - - def _recipe_attr_to_user(self, attr): - if attr == 'yield_': - attr = 'yield' - return attr.capitalize() - - -def test(): - import doctest - doctest.testmod() diff --git a/cookbook/template/base.html b/cookbook/template/base.html deleted file mode 100644 index cfd1b71..0000000 --- a/cookbook/template/base.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - {{ cookbook.name }} - - - - -
- -
-

{% block page_title %}{% endblock %}

- {% block content %}{% endblock %} -
- -
- - diff --git a/cookbook/template/edit-recipe.html b/cookbook/template/edit-recipe.html deleted file mode 100644 index fcd2a01..0000000 --- a/cookbook/template/edit-recipe.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "base.html" %} - -{% block page_title %} - {% if recipe.url %} - {{ recipe.name }} - {% else %} - {{ recipe.name }} - {% endif %} -{% endblock %} - -{% block content %} - {{ form }} -{% endblock %} diff --git a/cookbook/template/recipe.html b/cookbook/template/recipe.html deleted file mode 100644 index ac7a90a..0000000 --- a/cookbook/template/recipe.html +++ /dev/null @@ -1,82 +0,0 @@ -{% extends "base.html" %} - -{% block page_title %} - {% if recipe.url %} - {{ recipe.name }} - {% else %} - {{ recipe.name }} - {% endif %} -{% endblock %} - -{% block content %} -

- {% if recipe.yield_ %} - Yield: - {{ recipe.yield_ }}
- {% endif %} - {% if recipe.author %} - Author: - {{ recipe.author }}
- {% endif %} - {% if recipe.source %} - Source: - {{ recipe.source }}
- {% endif %} - {% if recipe.tags %} - Tags: - {{ ', '.join(tag_links) }} -
- {% endif %} -

-
- - - - -
- {% if recipe.tags %} -
- - - -
- {% endif %} - - {% if recipe.ingredient_blocks %} -
- {% for ingredient_block in recipe.ingredient_blocks %} -

{{ ingredient_block.heading() }}

- - {% for ingredient in ingredient_block %} - - {% endfor %} -
{{ ingredient.__unicode__() }}
- {% endfor %} -
- {% endif %} - - {% if recipe.directions %} -
-

Directions

- {% for paragraph in recipe.directions.wrapped_paragraphs() %} -

{{ paragraph }}

- {% endfor %} -
- {% endif %} - - -{% endblock %} diff --git a/cookbook/template/recipes.html b/cookbook/template/recipes.html deleted file mode 100644 index 06e320e..0000000 --- a/cookbook/template/recipes.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "base.html" %} - -{% block page_title %} - Index {% if selected_tags %}({{ ', '.join(selected_tags) }}){% endif %} -{% endblock %} - -{% block content %} -
-
- -
- - -

add new receipe

-{% endblock %} diff --git a/cookbook/tests.py b/cookbook/tests.py new file mode 100644 index 0000000..501deb7 --- /dev/null +++ b/cookbook/tests.py @@ -0,0 +1,16 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". + +Replace this with more appropriate tests for your application. +""" + +from django.test import TestCase + + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.assertEqual(1 + 1, 2) diff --git a/cookbook/urls.py b/cookbook/urls.py new file mode 100644 index 0000000..146dbcc --- /dev/null +++ b/cookbook/urls.py @@ -0,0 +1,36 @@ +from django.conf import settings +from django.conf.urls.defaults import patterns, include, url +from django.views.generic import DetailView, ListView +import taggit.models + +import models + +# Uncomment the next two lines to enable the admin: +from django.contrib import admin +admin.autodiscover() + +urlpatterns = patterns('', + url(r'^$', ListView.as_view( + queryset=models.Recipe.objects.all().order_by('name'), + context_object_name='recipes', + template_name='cookbook/recipes.html'), + name='recipes'), + url(r'^recipe/(?P\d+)/$', DetailView.as_view( + model=models.Recipe, template_name='cookbook/recipe.html'), + name='recipe'), + url(r'^tags/$', ListView.as_view( + queryset=taggit.models.Tag.objects.all(), + context_object_name='tags', + template_name='cookbook/tags.html'), + name='tags'), + url(r'^tag/(?P\d+)/$', 'cookbook.views.tag', name='tag'), + + # Uncomment the admin/doc line below to enable admin documentation: + # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + url(r'^admin/', include(admin.site.urls), name='admin'), + + url(r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', + kwargs={'url': settings.STATIC_URL + 'cookbook.ico'}), +) diff --git a/cookbook/views.py b/cookbook/views.py new file mode 100644 index 0000000..dbc1bd2 --- /dev/null +++ b/cookbook/views.py @@ -0,0 +1,14 @@ +from django.http import HttpResponse +from django.shortcuts import render_to_response, get_object_or_404 +from django.template import RequestContext + +from taggit.models import Tag +from .models import Recipe + + +def tag(request, pk): + tag = get_object_or_404(Tag, pk=pk) + recipes = Recipe.objects.filter(tags__name__in=[tag.slug]) + return render_to_response( + 'cookbook/recipes.html', {'recipes': recipes, 'title': tag.name}, + RequestContext(request)) diff --git a/doc/abbreviations b/doc/abbreviations deleted file mode 100644 index a4e25de..0000000 --- a/doc/abbreviations +++ /dev/null @@ -1,25 +0,0 @@ -Volumes -======= - -t. teaspoon -T. tablespoon -C. cup -pt. pint -qt. quart -gal. gallon - -Weights -======= - -oz. ounce -lb. pound - -Lengths -======= - -” inch - -Counts -====== - -doz. dozen (i.e. 12) diff --git a/example/__init__.py b/example/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/data/static/cookbook.ico b/example/data/static/cookbook.ico new file mode 100644 index 0000000000000000000000000000000000000000..f321568eca2ceef65d57aeb505b47273795b57e3 GIT binary patch literal 4286 zcmd5H!M^QheDC#8wGDSra;C$#s)}H_2Lp+?IC;<4s zfq36a>T&pYt@BA-K=31i=LvpJ@WK@Qgj_#M@H7E(=iC>On73llbI)#%SQK`6{gUWY z8-I1^!j@&}nOk4U&l9|M7m+J?tpnqgsFw@VHZRLK@!`_cFW3JvMiBnu8^3*VA=zeq#_zpHEfCRW;Ces$Q`UFRGbR(kwS6U_y@+<)NVe0E*H zrS0|T+N+$3lFP$)PQ*wHJ`Zb5k9zf3^1Dlxz5K$$8STf}Ex%ciEeJbamawjIpz4~& z?=S>{P6J~Ts~K&MmeZUvaH=E=r%AAKn$2!dMLmFl1OwN%*T5-H4RY#h{j8&_m!X|H z#!qS@8rsr?bxqdTi-Vs2p_KX9v z{J4m#=rLUlxpAvM=@cI7qTO~jeRnZ)IJ^vUBf6kzts1&NFhJkOHW(zn#+Wg%#reP) zKOTxk!5Eo;Kx(j%8it^L4XGhQ3Kzrb;LFvyAUv7H&?7dMXiLyzI{)Im?_L`-s>Xz= z#a!I$xp3@_8t6$g!TqlsApfHs)KP9Q{KX6QU4C%yqrsENgrY6p4|-CAD#`^te=tMq zds?WDRDdvC3i^38e}DszD$ zeH8jPyI^RW2h2Oi!MTTq5#l|zp9w|Bo&cD3_+ap}QBaUQ>RhjbhPRcFy{ZMi3NHe| zipy~J`~`A9=zZuho&VKc>y~JmZ>bdG4(5y1`EWY20UBd8FxpOoI@%4oXfIge{NNz| z?!+LtlNfN3=-5qzDQ+CJ+en?8oT0N4uN{EvtJ@%T|}9@F_Z|9u-( zo+=OqN34NM3Aed3zrO<|YZYKG^MO_91vTDpf>AKWdB74+YT4xlbG!%i+ebkwaD(y_ z8}xi+hK}`G(tkhPh-`(_h+@b*lEb1VYNN+={y2Yi??;Q}WkRW4qh!+FuZ7!dr6B*S z9^9Qi=!qTz=_W16KOF`|6!|V}9R_*S5XiO+f@Gr#+BWn<<2pIiua!ad8VTvY3C?_2 zz>Vtr8Pr7WCwWhn>zyUr+gLLGAgZ zV%lvAG#_r@Zf@v;#*`s2H+!IcrwMxY*r7l10jT%8K%3%*p=5F{iRAZS?*kZ)cfe4r z71}@Ug_fgL3~CUqvh;mho|)92Nz6GQST4DB<*whVWy|-8x$I5dpsH|yjO@W3!63B7 z>Y$tS+Y@gBIjN~{hZ&UH&2T@)2r7XQdXM*TL8q3zcRQma`#^N~OgyJ@TCW_WM9023 zqL(#kX`__U-YyBKD%v^QfQfHS6LSrx8~BE_M!w}j3*UMEE-y`Q<7C(CIAwl4J6Ks7 z2pH}$n2Q>yHC212*O&LlJX3x?InS-{cLj%}=KH(Z<8Ph$+-&;8TSP%|!F$Z%| zV=m9)oTIycU7vNbD&R7V)2dpUR@c$2rHA#)3j+S$DZsJ(W`I`J(8$Fc%tdc}&9vu7 zd2Khop$bkX$KEZ@Y@Qf3v9z*;4)hk%w6c`cQo{r_^$f<)c%Xvj#!PJcEn(BmQ_1m| z`>5V*#$WG_o?mh?HAB%2id}5`R{a zd0fPe=r~$YNc`)VkiYdi;@?I5>lo5IhaBWir!$xN%B!bEywA!975NM^Si?-1I@k$o z4~G%Q{J4Wh&Rjg6lv|a3PQ-gHe6aTh!)hwn31b^KVUchcam@SdJaV3-F`KoAcQ8d& zUB5v5%{(p7VHkBe%NkmVe>aB_$Gp$RBWIF7m-~Kdos)XidFdhlpgfCV)urr&p_%x1 zaTsxo{>Yi+|F8Wk^D;z1x1RTOXE0uci1n)KIIp&a^J(fiUtbY<#u|C#d|&>>!n6{n zPQe=*&N8~zELPW9#Od#qbNcRL&UiPQb?TKoa=s6L;ko0>Yl?2PIQ93zt(Ea!>mV?` z5tyKT5OA4cR43 */ + +body { + background: #eee; +} + +.fullclear { + width:100%; + height:1px; + margin:0; + padding:0; + clear:both; +} + +/* */ + +/* */ + +/*
*/ + +#content { + width: 100%; + margin: 0; + padding: 0; + padding-top: 1em; + padding-bottom: 1em; + background: #fff; +} + +table { + border-collapse: collapse; +} + +table.centered { + margin: auto; +} + +td, th { + padding-right: 1em; + text-align: left; +} + +table.wide tr:nth-child(odd) { + background: #eee; +} + +table.wide thead tr:nth-child(odd) { + background: #ccc; +} + +span.number { + float: right; /* so decimal points match up */ + font-family: monospace; /* so that n-place digits line up */ + text-align: '.'; /* should work by itself, but browser support is bad */ +} + +span.positive { + color: green; +} + +span.negative { + color: red; +} + +/*
*/ diff --git a/example/data/template/admin/edit_inline/tabular.html b/example/data/template/admin/edit_inline/tabular.html new file mode 100644 index 0000000..b0a2c65 --- /dev/null +++ b/example/data/template/admin/edit_inline/tabular.html @@ -0,0 +1,129 @@ +{% load i18n adminmedia admin_modify %} +{% load url from future %} +
+ +
+ + diff --git a/example/data/template/cookbook/base.html b/example/data/template/cookbook/base.html new file mode 100644 index 0000000..5e0b6f8 --- /dev/null +++ b/example/data/template/cookbook/base.html @@ -0,0 +1,29 @@ + + + {% block title %}Cookbook{% endblock %} + + + + + + +
+ +
+ {% block content %}{% endblock %} +
+
+ + + + diff --git a/example/data/template/cookbook/recipe.html b/example/data/template/cookbook/recipe.html new file mode 100644 index 0000000..eb0f5e4 --- /dev/null +++ b/example/data/template/cookbook/recipe.html @@ -0,0 +1,58 @@ +{% extends "cookbook/base.html" %} + +{% block content %} +

{{ recipe.name }}

+ +{% block metadata %} + + +{% if recipe.source or recipe.url %} + +{% endif %} +{% if recipe.x_yield %} + +{% endif %} +{% if recipe.tags.all %} + + + +{% endif %} + +{% endblock %} + +{% block ingredients %} +

Ingredients

+ +{% for ingredient_block in recipe.ingredientblock_set.all %} +
+

{{ ingredient_block.name }}

+
    +{% for ingredient in ingredient_block.ingredient_set.all %} +
  • {{ ingredient }}
  • +{% endfor %} +
+{% if ingredient_block.directions %} +

Directions

+ +
+{{ ingredient_block.directions|safe }} +
+{% endif %} + +
+{% endfor %} +{% endblock %} + +{% block directions %} +

Directions

+ +
+{{ recipe.directions|safe }} +
+{% endblock %} + +{% endblock %} diff --git a/example/data/template/cookbook/recipes.html b/example/data/template/cookbook/recipes.html new file mode 100644 index 0000000..4fd2828 --- /dev/null +++ b/example/data/template/cookbook/recipes.html @@ -0,0 +1,18 @@ +{% extends "cookbook/base.html" %} + +{% block title %} +{{ title }} +{% endblock %} + +{% block content %} +

{{ title }}

+{% if recipes %} + +{% else %} +

No recipes are available.

+{% endif %} +{% endblock %} diff --git a/example/data/template/cookbook/tags.html b/example/data/template/cookbook/tags.html new file mode 100644 index 0000000..4a4d97a --- /dev/null +++ b/example/data/template/cookbook/tags.html @@ -0,0 +1,18 @@ +{% extends "cookbook/base.html" %} + +{% block title %} +{{ title }} +{% endblock %} + +{% block content %} +

{{ title }}

+{% if tags %} + +{% else %} +

No tags are available.

+{% endif %} +{% endblock %} diff --git a/example/manage.py b/example/manage.py new file mode 100644 index 0000000..3e4eedc --- /dev/null +++ b/example/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +from django.core.management import execute_manager +import imp +try: + imp.find_module('settings') # Assumed to be in the same directory. +except ImportError: + import sys + sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n" % __file__) + sys.exit(1) + +import settings + +if __name__ == "__main__": + execute_manager(settings) diff --git a/example/settings.py b/example/settings.py new file mode 100644 index 0000000..b253e12 --- /dev/null +++ b/example/settings.py @@ -0,0 +1,175 @@ +# Django settings for web project. + +import os +import os.path + + +# This is where we'll put the non-Python portions of the app +# (e.g. templates) and store state information (e.g. the SQLite +# database). This should be an absolute path. +DEFAULT_DATA_DIRECTORY = os.path.abspath(os.path.join( + os.path.dirname(__file__), 'data')) +DATA_DIRECTORY = os.environ.get('DJANGO_COOKBOOK_DATA', DEFAULT_DATA_DIRECTORY) + + +DEBUG = True +TEMPLATE_DEBUG = DEBUG + +ADMINS = ( + ('W. Trevor King', 'wking@drexel.edu'), +) + +MANAGERS = ADMINS + +DATABASES = { + 'default': { + #'ENGINE': 'django.db.backends.', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'. + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(DATA_DIRECTORY, 'sqlite3.db'), # Or path to database file if using sqlite3. + 'USER': '', # Not used with sqlite3. + 'PASSWORD': '', # Not used with sqlite3. + 'HOST': '', # Set to empty string for localhost. Not used with sqlite3. + 'PORT': '', # Set to empty string for default. Not used with sqlite3. + } +} + +# Local time zone for this installation. Choices can be found here: +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# although not all choices may be available on all operating systems. +# On Unix systems, a value of None will cause Django to use the same +# timezone as the operating system. +# If running in a Windows environment this must be set to the same as your +# system time zone. +TIME_ZONE = None # 'America/New_York' + +# Language code for this installation. All choices can be found here: +# http://www.i18nguy.com/unicode/language-identifiers.html +LANGUAGE_CODE = 'en-us' + +SITE_ID = 1 + +# If you set this to False, Django will make some optimizations so as not +# to load the internationalization machinery. +USE_I18N = True + +# If you set this to False, Django will not format dates, numbers and +# calendars according to the current locale +USE_L10N = True + +# Absolute filesystem path to the directory that will hold user-uploaded files. +# Example: "/home/media/media.lawrence.com/media/" +MEDIA_ROOT = os.path.join(DATA_DIRECTORY, 'media') + +# URL that handles the media served from MEDIA_ROOT. Make sure to use a +# trailing slash. +# Examples: "http://media.lawrence.com/media/", "http://example.com/media/" +MEDIA_URL = '/media/' + +# Absolute path to the directory static files should be collected to. +# Don't put anything in this directory yourself; store your static files +# in apps' "static/" subdirectories and in STATICFILES_DIRS. +# Example: "/home/media/media.lawrence.com/static/" +STATIC_ROOT = '' + +# URL prefix for static files. +# Example: "http://media.lawrence.com/static/" +STATIC_URL = '/static/' + +# URL prefix for admin static files -- CSS, JavaScript and images. +# Make sure to use a trailing slash. +# Examples: "http://foo.com/static/admin/", "/static/admin/". +ADMIN_MEDIA_PREFIX = '/static/admin/' + +# Additional locations of static files +STATICFILES_DIRS = ( + # Put strings here, like "/home/html/static" or "C:/www/django/static". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. + os.path.join(DATA_DIRECTORY, 'media'), + os.path.join(DATA_DIRECTORY, 'static'), +) + +# List of finder classes that know how to find static files in +# various locations. +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', +# 'django.contrib.staticfiles.finders.DefaultStorageFinder', +) + +# Make this unique, and don't share it with anybody. +SECRET_KEY = 'wq-)fb$0kv*_q%=ufhaqm7$qw(w84=izd7a!nn)5i@@l)!%3x=' + +# List of callables that know how to import templates from various sources. +TEMPLATE_LOADERS = ( + 'django.template.loaders.filesystem.Loader', + 'django.template.loaders.app_directories.Loader', +# 'django.template.loaders.eggs.Loader', +) + +MIDDLEWARE_CLASSES = ( + 'django.middleware.common.CommonMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', +) + +ROOT_URLCONF = 'cookbook.urls' + +TEMPLATE_DIRS = ( + # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". + # Always use forward slashes, even on Windows. + # Don't forget to use absolute paths, not relative paths. + os.path.join(DATA_DIRECTORY, 'template'), +) + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.sites', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # Uncomment the next line to enable the admin: + 'django.contrib.admin', + # Uncomment the next line to enable admin documentation: + # 'django.contrib.admindocs', + 'cookbook', + 'taggit', +) + +# Required for render_table +# http://django-tables2.readthedocs.org/en/latest/#render-table +TEMPLATE_CONTEXT_PROCESSORS = ( + "django.contrib.auth.context_processors.auth", + "django.core.context_processors.debug", + "django.core.context_processors.i18n", + "django.core.context_processors.media", + "django.core.context_processors.static", + "django.contrib.messages.context_processors.messages", + "django.core.context_processors.request", +) + +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins on every HTTP 500 error. +# See http://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'mail_admins': { + 'level': 'ERROR', + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': True, + }, + } +} diff --git a/example/urls.py b/example/urls.py new file mode 100644 index 0000000..7742d01 --- /dev/null +++ b/example/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls.defaults import patterns, include, url + +# Uncomment the next two lines to enable the admin: +# from django.contrib import admin +# admin.autodiscover() + +urlpatterns = patterns('', + # Examples: + # url(r'^$', 'web.views.home', name='home'), + # url(r'^web/', include('web.foo.urls')), + + # Uncomment the admin/doc line below to enable admin documentation: + # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), + + # Uncomment the next line to enable the admin: + # url(r'^admin/', include(admin.site.urls)), +) diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..d72c5be --- /dev/null +++ b/setup.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python + +'Recipe (as in food) managing application for Django' + +from distutils.core import setup +import os.path + +from cookbook import __version__ + + +_this_dir = os.path.dirname(__file__) + +setup( + name='cookbook', + version=__version__, + description=__doc__, + long_description=open(os.path.join(_this_dir, 'README'), 'r').read(), + author='W. Trevor King', + author_email='wking@drexel.edu', + license='GPL', + url='http://physics.drexel.edu/~wking/unfolding-disasters/posts/cookbook/', + packages=['cookbook'], + classifiers=[ + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Software Development :: Libraries', + ], +) -- 2.26.2