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