Python 2.5 doesn't allow fn(*args, imap=value)
[cookbook.git] / cookbook / server.py
1 # Copyright (C) 2010 W. Trevor King <wking@drexel.edu>
2 #
3 # This file is part of Cookbook.
4 #
5 # Cookbook is free software: you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by the
7 # Free Software Foundation, either version 3 of the License, or (at your
8 # option) any later version.
9 #
10 # Cookbook is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with Cookbook.  If not, see <http://www.gnu.org/licenses/>.
17
18 """Serve cookbooks over HTTP.
19 """
20
21 from __future__ import absolute_import
22 from __future__ import with_statement
23
24 import os
25 import random
26 import re
27 import types
28 import uuid
29 from xml.sax import saxutils
30
31 import cherrypy
32 from jinja2 import Environment, FileSystemLoader
33
34 from .cookbook import Recipe, Directions, IngredientBlock, Ingredient, Amount
35
36
37 class Server (object):
38     """Cookbook web interface."""
39
40     def __init__(self, cookbook, template_root):
41         self.cookbook = cookbook
42         self.cookbook.make_index()
43         self.env = Environment(loader=FileSystemLoader(template_root))
44         # name regular expressions
45         self._action_ingredient_block_regexp = re.compile(
46             'ingredient block ([0-9]*) ([a-z ]*)')        
47         self._action_ingredient_regexp = re.compile(
48             'ingredient ([0-9]*) ([0-9]*) ([a-z ]*)')        
49         # value regular expressions
50         self._tag_regexp = re.compile('[a-zA-Z./ ].*')  # allowed characters
51         self._action_add_ingredient_block_regexp = re.compile(
52             'add ingredient block')
53         self._action_remove_ingredient_block_regexp = re.compile(
54             'remove ingredient block ([0-9]*)')
55         self._action_add_ingredient_regexp = re.compile(
56             'add ingredient ([0-9]*)')
57         self._action_remove_ingredient_regexp = re.compile(
58             'remove ingredient ([0-9]*) ([0-9]*)')
59
60     def cleanup(self):
61         #self.cookbook.save('new-recipe')
62         pass
63
64     @cherrypy.expose
65     def index(self, tag=None):
66         """Recipe index page.
67
68         Recipes can be filtered by tag.
69         """
70         if isinstance(tag, types.StringTypes):
71             tag = [tag]
72         template = self.env.get_template('recipes.html')
73         return template.render(cookbook=self.cookbook,
74                                recipes=list(self.cookbook.tagged(tag)),
75                                selected_tags=tag)
76
77     @cherrypy.expose
78     def recipe(self, name=None):
79         """Single recipe page.
80         """
81         if name == None:
82             recipe = random.choice(self.cookbook)
83         else:
84             recipe = self.cookbook.index[name]
85         template = self.env.get_template('recipe.html')
86         return template.render(cookbook=self.cookbook, recipe=recipe)
87
88     @cherrypy.expose
89     def add_tag(self, name, tag):
90         """Add a tag to a single recipe."""
91         recipe = self.cookbook.index[name]
92         if recipe.tags == None:
93             recipe.tags = []
94         tag = self._clean_tag(tag)
95         if tag != None and tag not in recipe.tags:
96             recipe.tags.append(tag)
97             with open(recipe.path, 'w') as f:
98                 recipe.save(f)
99         raise cherrypy.HTTPRedirect(
100             u'recipe?name=%s' % recipe.clean_name(ascii=True), status=302)
101
102     @cherrypy.expose
103     def remove_tag(self, name, tag):
104         """Remove a tag from a single recipe."""
105         recipe = self.cookbook.index[name]
106         if recipe.tags == None:
107             return
108         tag = self._clean_tag(tag)
109         if tag != None and tag in recipe.tags:
110             recipe.tags.remove(tag)
111             with open(recipe.path, 'w') as f:
112                 recipe.save(f)
113         raise cherrypy.HTTPRedirect(
114             u'recipe?name=%s' % recipe.clean_name(ascii=True), status=302)
115
116     def _clean_tag(self, tag):
117         """Sanitize tag."""
118         if not isinstance(tag, types.StringTypes):
119             if len(tag) == 2 and '' in tag:
120                 # User used either dropdown or textbox
121                 tag.remove('')
122                 tag = tag[0]
123             else:
124                 # User used both dropdown and textbox
125                 return None
126         m = self._tag_regexp.match(tag)
127         if m != None:
128             return m.group()
129         return None
130
131     @cherrypy.expose
132     def edit(self, name=None, **kwargs):
133         """Remove a tag from a single recipe."""
134         name,recipe,action = self._normalize_edit_params(name, **kwargs)
135         if action == 'remove':
136             recipe = self.cookbook.index[name]
137             os.remove(recipe.path)
138             self.cookbook.remove(recipe)
139             self.cookbook.make_index()
140             raise cherrypy.HTTPRedirect(u'.', status=302)
141         elif action.startswith('edit'):
142             self._update_recipe(name, recipe)
143             if action == 'edit and redirect':  # done editing this recipe
144                 raise cherrypy.HTTPRedirect(
145                     u'recipe?name=%s' % recipe.clean_name(ascii=True),
146                     status=302)
147             elif recipe.name != name:
148                 raise cherrypy.HTTPRedirect(
149                     u'edit?name=%s' % recipe.clean_name(ascii=True),
150                     status=302)
151         template = self.env.get_template('edit-recipe.html')
152         return template.render(cookbook=self.cookbook,
153                                recipe=recipe,
154                                form='\n'.join(self._recipe_form(recipe)))
155
156     def _update_recipe(self, name, recipe):
157         if len(recipe.ingredient_blocks) == 0:
158             recipe.ingredient_blocks = None
159         if name not in self.cookbook.index:  # new recipe
160             print 'new'
161             self.cookbook.append(recipe)
162             self.cookbook.make_index()
163         elif name != recipe.name:  # renamed recipe
164             print 'rename'
165             os.remove(recipe.path)
166             recipe.path = None
167             self.cookbook.make_index()
168         with open(recipe.path, 'w') as f:
169             recipe.save(f)
170
171     def _normalize_edit_params(self, name, **kwargs):
172         action = 'view form'
173         if name in self.cookbook.index:
174             recipe = self.cookbook.index[name]
175             name = recipe.name  # get the canonical name for this recipe
176         else:
177             # Use uuid4() to generate a random name if name is None
178             recipe = Recipe(name=(name or str(uuid.uuid4())))
179             recipe.path = None
180         # non-ingredient text updates
181         for attr in ['name','directions','yield_','author','source','url']:
182             n = self._recipe_attr_to_name(attr)
183             if n not in kwargs:
184                 continue
185             v = kwargs.get(n).strip()
186             if v == '':
187                 v = None
188             elif attr == 'directions':
189                 d = Directions()
190                 v = v.replace('\r\n', '\n').replace('\r', '\n')
191                 for p in v.split('\n\n'):
192                     p = p.strip()
193                     if len(p) > 0:
194                         d.append(p)
195                 v = d
196             if v != getattr(recipe, attr):
197                 setattr(recipe, attr, v)
198                 action = 'edit'
199         if recipe.ingredient_blocks == None:
200             recipe.ingredient_blocks = []
201         # protect against index-changing operations
202         ingredient_block_map = dict(
203             [(i,i) for i in range(len(recipe.ingredient_blocks))])
204         ingredient_map = dict(
205             [(i, dict([(j,j) for j in range(len(ib))]))
206              for i,ib in enumerate(recipe.ingredient_blocks)])
207         # ingredient text updates
208         for key,value in kwargs.items():
209             for k in ['ingredient_block', 'ingredient']:
210                 regexp = getattr(self, '_action_%s_regexp' % k)
211                 m = regexp.match(key)
212                 if m != None:
213                     handler = getattr(self, '_action_%s' % k)
214                     kws = {'ingredient_block_map': ingredient_block_map,
215                            'ingredient_map': ingredient_map}
216                     action = handler(recipe, action, value, *m.groups(), **kws)
217                     break
218         # button updates
219         action = kwargs.get('action', action)
220         if action == 'submit':
221             action = 'edit and redirect'
222         for a in ['add_ingredient_block', 'remove_ingredient_block',
223                   'add_ingredient', 'remove_ingredient']:
224             regexp = getattr(self, '_action_%s_regexp' % a)
225             m = regexp.match(action)
226             if m != None:
227                 handler = getattr(self, '_action_%s' % a)
228                 kws = {'ingredient_block_map': ingredient_block_map,
229                        'ingredient_map': ingredient_map}
230                 action = handler(recipe, action, *m.groups(), **kws)
231                 break
232         return (name, recipe, action)
233
234     def _action_ingredient_block(self, recipe, action, value,
235                                  block_index, block_action,
236                                  ingredient_block_map, **kwargs):
237         block_index = ingredient_block_map[int(block_index)]
238         ib = recipe.ingredient_blocks[block_index]
239         if value == '':
240             value = None        
241         if block_action == 'position' and value not in [None, '']:
242             value = int(value)
243             if value != block_index:
244                 ibs = recipe.ingredient_blocks
245                 ibs.insert(value, ibs.pop(block_index))
246                 for i in range(min(block_index, value),
247                                max(block_index, value)+1):
248                     ingredient_block_map[i] = i+1
249                 ingredient_block_map[block_index] = value
250                 return 'edit'
251         elif block_action == 'name':
252             if value != ib.name:
253                 ib.name = value
254                 return 'edit'
255         return action
256
257     def _action_ingredient(self, recipe, action, value,
258                            block_index, index, ingredient_action,
259                            ingredient_block_map, ingredient_map, **kwargs):
260         block_index = ingredient_block_map[int(block_index)]
261         index = ingredient_map[block_index][int(index)]
262         ingredient = recipe.ingredient_blocks[block_index][index]
263         if value == '':
264             value = None
265         if ingredient_action == 'position' and value != None:
266             value = int(value)
267             if value != block_index:
268                 ibs = recipe.ingredient_blocks
269                 ibs.insert(value, ibs.pop(block_index))
270                 for i in range(min(block_index, value),
271                                max(block_index, value)+1):
272                     ingredient_block_map[i] = i+1
273                 ingredient_block_map[block_index] = value
274                 return 'edit'
275         elif ingredient_action in ['name', 'note']:
276             if value != getattr(ingredient, ingredient_action):
277                 setattr(ingredient, ingredient_action, value)
278                 return 'edit'
279         elif ingredient_action in ['value', 'units']:
280             if value != getattr(ingredient.amount, ingredient_action):
281                 setattr(ingredient.amount, ingredient_action, value)
282                 return 'edit'
283         return action
284
285     def _action_add_ingredient_block(self, recipe, action,
286                                      ingredient_block_map,
287                                      ingredient_map, **kwargs):
288         recipe.ingredient_blocks.append(IngredientBlock())
289         block_index = len(recipe.ingredient_blocks)-1
290         ingredient_block_map[block_index] = block_index
291         ingredient_map[block_index] = {}
292         return 'edit'
293
294     def _action_remove_ingredient_block(self, recipe, action, block_index,
295                                         ingredient_block_map, **kwargs):
296         block_index = ingredient_block_map[int(block_index)]
297         recipe.ingredient_blocks.pop(block_index)
298         for k,v in ingredient_block_map.items():
299             if v >= block_index:
300                 ingredient_block_map[k] = v-1
301         return 'edit'
302
303     def _action_add_ingredient(self, recipe, action, block_index,
304                                ingredient_block_map,
305                                ingredient_map, **kwargs):
306         block_index = ingredient_block_map[int(block_index)]
307         recipe.ingredient_blocks[block_index].append(Ingredient(
308                 amount=Amount()))
309         index = len(recipe.ingredient_blocks[block_index])-1
310         ingredient_map[block_index][index] = index
311         return 'edit'
312
313     def _action_remove_ingredient(self, recipe, action, block_index, index,
314                                   ingredient_block_map,
315                                   ingredient_map, **kwargs):
316         block_index = ingredient_block_map[int(block_index)]
317         index = ingredient_map[block_index][int(index)]
318         recipe.ingredient_blocks[block_index].pop(index)
319         for k,v in ingredient_map[block_index].items():
320             if v >= index:
321                 ingredient_map[block_index][k] = v-1
322         return 'edit'
323
324     def _recipe_form(self, recipe):
325         lines = [
326             '<form action="" method="post">',
327             '<table>',
328             ]
329         for attr in ['name', 'yield_', 'author', 'source', 'url']:
330             lines.extend(self._text_form(
331                     self._recipe_attr_to_name(attr),
332                     self._recipe_attr_to_user(attr),
333                     getattr(recipe, attr)))
334         for i,ib in enumerate(recipe.ingredient_blocks):
335             lines.extend(self._ingredient_block_form(ib, i))
336         lines.extend(self._submit_form('add ingredient block'))
337         lines.extend(self._textarea_form(
338                 self._recipe_attr_to_name('directions'),
339                 self._recipe_attr_to_user('directions'),
340                 '\n\n'.join(recipe.directions or Directions())))
341         lines.extend(self._submit_form('submit'))
342         lines.extend([
343                 '</table>',
344                 '</form>',
345                 ])
346         return lines
347
348     def _ingredient_block_form(self, ingredient_block, index):
349         lines = self._submit_form(
350             'remove ingredient block %d' % index,
351             note='Ingredient block %d' % index)
352         for name,user,value in [
353             ('ingredient block %d position' % index,
354              'Position (%d)' % index,
355              None),
356             ('ingredient block %d name' % index,
357              'Name',
358              ingredient_block.name),
359             ]:
360             lines.extend(self._text_form(name, user, value))
361         for i,ingredient in enumerate(ingredient_block):
362             lines.extend(self._ingredient_form(ingredient, index, i))
363         lines.extend(self._submit_form('add ingredient %d' % index))
364         return lines
365
366     def _ingredient_form(self, ingredient, block_index, index):
367         lines = self._submit_form(
368             'remove ingredient %d %d' % (block_index, index),
369             note='Ingredient %d %d' % (block_index, index))
370         for name,user,value in [
371             ('ingredient %d %d position' % (block_index, index),
372              'Position (%d)' % index,
373              ''),
374             ('ingredient %d %d value' % (block_index, index),
375              'Value',
376              ingredient.amount.value),
377             ('ingredient %d %d units' % (block_index, index),
378              'Units',
379              ingredient.amount.units),
380             ('ingredient %d %d name' % (block_index, index),
381              'Name',
382              ingredient.name),
383             ('ingredient %d %d note' % (block_index, index),
384              'Note',
385              ingredient.note),
386             ]:
387             lines.extend(self._text_form(name, user, value))
388         # TODO: Amount: range_, alternatives
389         return lines
390
391     def _submit_form(self, value, note=''):
392         return [
393             '  <tr>',
394             '    <td>%s</td>' % note,
395             '    <td><input type="submit" name="action" value="%s"/></td>'
396             % value,
397             '  </tr>',
398             ]
399
400     def _text_form(self, name, user, value):
401         return [
402             '  <tr>',
403             '    <td><label for="%s">%s</label></td>' % (name, user),
404             '    <td><input type="text" name="%s" size="60" value="%s"/></td>'
405             % (name, value or ''),
406             '  </tr>',
407             ]
408
409     def _textarea_form(self, name, user, value):
410         return [
411             '  <tr>',
412             '    <td><label for="%s">%s</label></td>' % (name, user),
413             '    <td><textarea name="%s" rows="20" cols="60"/>' % name,
414             value,
415             '        </textarea></td>'
416             '  </tr>',
417             ]
418
419     def _recipe_attr_to_name(self, attr):
420         if attr == 'yield_':
421             return 'yield'
422         elif attr == 'name':
423             return 'new_name'
424         return attr
425
426     def _recipe_attr_to_user(self, attr):
427         if attr == 'yield_':
428             attr = 'yield'
429         return attr.capitalize()
430
431
432 def test():
433     import doctest
434     doctest.testmod()