76dded630ff4d3dafd4c90b9fdc9c66fbf63d51a
[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.startswith('edit'):
136             self._update_recipe(name, recipe)
137             if action == 'edit and redirect':  # done editing this recipe
138                 raise cherrypy.HTTPRedirect(
139                     u'recipe?name=%s' % recipe.clean_name(ascii=True),
140                     status=302)
141             elif recipe.name != name:
142                 raise cherrypy.HTTPRedirect(
143                     u'edit?name=%s' % recipe.clean_name(ascii=True),
144                     status=302)
145         template = self.env.get_template('edit-recipe.html')
146         return template.render(cookbook=self.cookbook,
147                                recipe=recipe,
148                                form='\n'.join(self._recipe_form(recipe)))
149
150     def _update_recipe(self, name, recipe):
151         if len(recipe.ingredient_blocks) == 0:
152             recipe.ingredient_blocks = None
153         if name not in self.cookbook.index:  # new recipe
154             print 'new'
155             self.cookbook.append(recipe)
156             self.cookbook.make_index()
157         elif name != recipe.name:  # renamed recipe
158             print 'rename'
159             os.remove(recipe.path)
160             recipe.path = None
161             self.cookbook.make_index()
162         with open(recipe.path, 'w') as f:
163             recipe.save(f)
164
165     def _normalize_edit_params(self, name, **kwargs):
166         action = 'view form'
167         if name in self.cookbook.index:
168             recipe = self.cookbook.index[name]
169             name = recipe.name  # get the canonical name for this recipe
170         else:
171             # Use uuid4() to generate a random name if name is None
172             recipe = Recipe(name=(name or str(uuid.uuid4())))
173             recipe.path = None
174         # non-ingredient text updates
175         for attr in ['name','directions','yield_','author','source','url']:
176             n = self._recipe_attr_to_name(attr)
177             if n not in kwargs:
178                 continue
179             v = kwargs.get(n).strip()
180             if v == '':
181                 v = None
182             elif attr == 'directions':
183                 d = Directions()
184                 v = v.replace('\r\n', '\n').replace('\r', '\n')
185                 for p in v.split('\n\n'):
186                     p = p.strip()
187                     if len(p) > 0:
188                         d.append(p)
189                 v = d
190             if v != getattr(recipe, attr):
191                 setattr(recipe, attr, v)
192                 action = 'edit'
193         if recipe.ingredient_blocks == None:
194             recipe.ingredient_blocks = []
195         # protect against index-changing operations
196         ingredient_block_map = dict(
197             [(i,i) for i in range(len(recipe.ingredient_blocks))])
198         ingredient_map = dict(
199             [(i, dict([(j,j) for j in range(len(ib))]))
200              for i,ib in enumerate(recipe.ingredient_blocks)])
201         # ingredient text updates
202         for key,value in kwargs.items():
203             for k in ['ingredient_block', 'ingredient']:
204                 regexp = getattr(self, '_action_%s_regexp' % k)
205                 m = regexp.match(key)
206                 if m != None:
207                     handler = getattr(self, '_action_%s' % k)
208                     action = handler(recipe, action, value, *m.groups(),
209                                      ingredient_block_map=ingredient_block_map,
210                                      ingredient_map=ingredient_map)
211                     break
212         # button updates
213         action = kwargs.get('action', action)
214         if action == 'submit':
215             action = 'edit and redirect'
216         for a in ['add_ingredient_block', 'remove_ingredient_block',
217                   'add_ingredient', 'remove_ingredient']:
218             regexp = getattr(self, '_action_%s_regexp' % a)
219             m = regexp.match(action)
220             if m != None:
221                 handler = getattr(self, '_action_%s' % a)
222                 action = handler(recipe, action, *m.groups(),
223                                  ingredient_block_map=ingredient_block_map,
224                                  ingredient_map=ingredient_map)
225                 break
226         return (name, recipe, action)
227
228     def _action_ingredient_block(self, recipe, action, value,
229                                  block_index, block_action,
230                                  ingredient_block_map, **kwargs):
231         block_index = ingredient_block_map[int(block_index)]
232         ib = recipe.ingredient_blocks[block_index]
233         if value == '':
234             value = None        
235         if block_action == 'position' and value not in [None, '']:
236             value = int(value)
237             if value != block_index:
238                 ibs = recipe.ingredient_blocks
239                 ibs.insert(value, ibs.pop(block_index))
240                 for i in range(min(block_index, value),
241                                max(block_index, value)+1):
242                     ingredient_block_map[i] = i+1
243                 ingredient_block_map[block_index] = value
244                 return 'edit'
245         elif block_action == 'name':
246             if value != ib.name:
247                 ib.name = value
248                 return 'edit'
249         return action
250
251     def _action_ingredient(self, recipe, action, value,
252                            block_index, index, ingredient_action,
253                            ingredient_block_map, ingredient_map, **kwargs):
254         block_index = ingredient_block_map[int(block_index)]
255         index = ingredient_map[block_index][int(index)]
256         ingredient = recipe.ingredient_blocks[block_index][index]
257         if value == '':
258             value = None
259         if ingredient_action == 'position' and value != None:
260             value = int(value)
261             if value != block_index:
262                 ibs = recipe.ingredient_blocks
263                 ibs.insert(value, ibs.pop(block_index))
264                 for i in range(min(block_index, value),
265                                max(block_index, value)+1):
266                     ingredient_block_map[i] = i+1
267                 ingredient_block_map[block_index] = value
268                 return 'edit'
269         elif ingredient_action in ['name', 'note']:
270             if value != getattr(ingredient, ingredient_action):
271                 setattr(ingredient, ingredient_action, value)
272                 return 'edit'
273         elif ingredient_action in ['value', 'units']:
274             if value != getattr(ingredient.amount, ingredient_action):
275                 setattr(ingredient.amount, ingredient_action, value)
276                 return 'edit'
277         return action
278
279     def _action_add_ingredient_block(self, recipe, action,
280                                      ingredient_block_map,
281                                      ingredient_map, **kwargs):
282         recipe.ingredient_blocks.append(IngredientBlock())
283         block_index = len(recipe.ingredient_blocks)-1
284         ingredient_block_map[block_index] = block_index
285         ingredient_map[block_index] = {}
286         return 'edit'
287
288     def _action_remove_ingredient_block(self, recipe, action, block_index,
289                                         ingredient_block_map, **kwargs):
290         block_index = ingredient_block_map[int(block_index)]
291         recipe.ingredient_blocks.pop(block_index)
292         for k,v in ingredient_block_map.items():
293             if v >= block_index:
294                 ingredient_block_map[k] = v-1
295         return 'edit'
296
297     def _action_add_ingredient(self, recipe, action, block_index,
298                                ingredient_block_map,
299                                ingredient_map, **kwargs):
300         block_index = ingredient_block_map[int(block_index)]
301         recipe.ingredient_blocks[block_index].append(Ingredient(
302                 amount=Amount()))
303         index = len(recipe.ingredient_blocks[block_index])-1
304         ingredient_map[block_index][index] = index
305         return 'edit'
306
307     def _action_remove_ingredient(self, recipe, action, block_index, index,
308                                   ingredient_block_map,
309                                   ingredient_map, **kwargs):
310         block_index = ingredient_block_map[int(block_index)]
311         index = ingredient_map[block_index][int(index)]
312         recipe.ingredient_blocks[block_index].pop(index)
313         for k,v in ingredient_map[block_index].items():
314             if v >= index:
315                 ingredient_map[block_index][k] = v-1
316         return 'edit'
317
318     def _recipe_form(self, recipe):
319         lines = [
320             '<form action="" method="post">',
321             '<table>',
322             ]
323         for attr in ['name', 'yield_', 'author', 'source', 'url']:
324             lines.extend(self._text_form(
325                     self._recipe_attr_to_name(attr),
326                     self._recipe_attr_to_user(attr),
327                     getattr(recipe, attr)))
328         for i,ib in enumerate(recipe.ingredient_blocks):
329             lines.extend(self._ingredient_block_form(ib, i))
330         lines.extend(self._submit_form('add ingredient block'))
331         lines.extend(self._textarea_form(
332                 self._recipe_attr_to_name('directions'),
333                 self._recipe_attr_to_user('directions'),
334                 '\n\n'.join(recipe.directions or Directions())))
335         lines.extend(self._submit_form('submit'))
336         lines.extend([
337                 '</table>',
338                 '</form>',
339                 ])
340         return lines
341
342     def _ingredient_block_form(self, ingredient_block, index):
343         lines = self._submit_form(
344             'remove ingredient block %d' % index,
345             note='Ingredient block %d' % index)
346         for name,user,value in [
347             ('ingredient block %d position' % index,
348              'Position (%d)' % index,
349              None),
350             ('ingredient block %d name' % index,
351              'Name',
352              ingredient_block.name),
353             ]:
354             lines.extend(self._text_form(name, user, value))
355         for i,ingredient in enumerate(ingredient_block):
356             lines.extend(self._ingredient_form(ingredient, index, i))
357         lines.extend(self._submit_form('add ingredient %d' % index))
358         return lines
359
360     def _ingredient_form(self, ingredient, block_index, index):
361         lines = self._submit_form(
362             'remove ingredient %d %d' % (block_index, index),
363             note='Ingredient %d %d' % (block_index, index))
364         for name,user,value in [
365             ('ingredient %d %d position' % (block_index, index),
366              'Position (%d)' % index,
367              ''),
368             ('ingredient %d %d value' % (block_index, index),
369              'Value',
370              ingredient.amount.value),
371             ('ingredient %d %d units' % (block_index, index),
372              'Units',
373              ingredient.amount.units),
374             ('ingredient %d %d name' % (block_index, index),
375              'Name',
376              ingredient.name),
377             ('ingredient %d %d note' % (block_index, index),
378              'Note',
379              ingredient.note),
380             ]:
381             lines.extend(self._text_form(name, user, value))
382         # TODO: Amount: range_, alternatives
383         return lines
384
385     def _submit_form(self, value, note=''):
386         return [
387             '  <tr>',
388             '    <td>%s</td>' % note,
389             '    <td><input type="submit" name="action" value="%s"/></td>'
390             % value,
391             '  </tr>',
392             ]
393
394     def _text_form(self, name, user, value):
395         return [
396             '  <tr>',
397             '    <td><label for="%s">%s</label></td>' % (name, user),
398             '    <td><input type="text" name="%s" size="60" value="%s"/></td>'
399             % (name, value or ''),
400             '  </tr>',
401             ]
402
403     def _textarea_form(self, name, user, value):
404         return [
405             '  <tr>',
406             '    <td><label for="%s">%s</label></td>' % (name, user),
407             '    <td><textarea name="%s" rows="20" cols="60"/>' % name,
408             value,
409             '        </textarea></td>'
410             '  </tr>',
411             ]
412
413     def _recipe_attr_to_name(self, attr):
414         if attr == 'yield_':
415             return 'yield'
416         elif attr == 'name':
417             return 'new_name'
418         return attr
419
420     def _recipe_attr_to_user(self, attr):
421         if attr == 'yield_':
422             attr = 'yield'
423         return attr.capitalize()
424
425
426 def test():
427     import doctest
428     doctest.testmod()