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