2
from gourmet.gdebug import debug, TimeAction
3
import re, pickle, string, os.path, string
4
from gettext import gettext as _
5
import gourmet.gglobals
6
from gourmet import Undo, keymanager, convert, shopping
7
from gourmet.defaults import lang as defaults
9
# This is our base class for rdatabase. All functions needed by
10
# Gourmet to access the database should be defined here and
11
# implemented by subclasses. This was designed around metakit, so
12
# Gourmet requires an object-attribute style syntax for accessing
13
# database information. For the time being, this is built in
14
# throughout Gourmet. Non-metakit backends, such as sql, have to
15
# implement this syntax themselves (which is fine by me because that
16
# means I have abstracted ways to handle e.g. SQLite in the future). See
17
# PythonicSQL.py and other files for the glue between SQL and the object/attribute
18
# style required by Gourmet.
22
"""RecData is our base class for handling database connections.
24
Subclasses implement specific backends, such as metakit, sqlite, etc."""
25
# constants for determining how to get amounts when there are ranges.
30
RECIPE_TABLE_DESC = ('recipe',
33
('instructions',"text"),
34
('modifications',"text"),
37
('description',"text"),
40
('preptime',"char(50)"),
41
('cooktime',"char(50)"),
42
('servings',"char(50)"),
48
INGREDIENTS_TABLE_DESC=('ingredients',
53
('rangeamount','float'),
55
('ingkey','char(200)'),
57
('shopoptional','int'), #Integer so we can distinguish unset from False
58
('inggroup','char(200)'),
63
SHOPCATS_TABLE_DESC = ('shopcats',
64
[('shopkey','char(50)'),
65
('category','char(200)'),
69
SHOPCATSORDER_TABLE_DESC = ('shopcatsorder',
70
[('category','char(50)'),
75
PANTRY_TABLE_DESC = ('pantry',
77
('pantry','char(10)')],
80
CATEGORIES_TABLE_DESC = ("categories",
83
('description','text')], 'id' #key
85
DENSITY_TABLE_DESC = ("density",
86
[('dkey','char(150)'),
87
('value','char(150)')],'dkey' #key
89
CROSSUNITDICT_TABLE_DESC = ("crossunitdict",
90
[('cukey','char(150)'),
91
('value','char(150)')],'cukey' #key
93
UNITDICT_TABLE_DESC = ("unitdict",
94
[('ukey','char(150)'),
95
('value','char(150)')],'ukey' #key
97
CONVTABLE_TABLE_DESC = ("convtable",
98
[('ckey','char(150)'),
99
('value','char(150)')],'ckey' #key
103
timer = TimeAction('initialize_connection + setup_tables',0)
104
self.initialize_connection()
107
self.top_id = {'r': 1}
108
# hooks run after adding, modifying or deleting a recipe.
109
# Each hook is handed the recipe, except for delete_hooks,
110
# which is handed the ID (since the recipe has been deleted)
112
self.modify_hooks = []
113
self.delete_hooks = []
114
self.add_ing_hooks = []
116
def initialize_connection (self):
117
"""Initialize our database connection."""
118
raise NotImplementedError
121
"""Save our database (if we have a separate 'save' concept)"""
124
def setup_tables (self):
125
"""Setup all of our tables by calling setup_table for each one.
127
Subclasses should do any necessary adjustments/tweaking before calling
129
self.recipe_table = self.setup_table(*self.RECIPE_TABLE_DESC)
130
self.ingredients_table = self.setup_table(*self.INGREDIENTS_TABLE_DESC)
131
self.ingredients_table_not_deleted = self.ingredients_table.select(deleted=False)
132
self.ingredients_table_deleted = self.ingredients_table.select(deleted=True)
133
self.shopcats_table = self.setup_table(*self.SHOPCATS_TABLE_DESC)
134
self.shopcatsorder_table = self.setup_table(*self.SHOPCATSORDER_TABLE_DESC)
135
self.pantry_table = self.setup_table(*self.PANTRY_TABLE_DESC)
136
self.metaview = self.setup_table(*self.CATEGORIES_TABLE_DESC)
138
self.density_table = self.setup_table(*self.DENSITY_TABLE_DESC)
139
self.convtable_table = self.setup_table(*self.CONVTABLE_TABLE_DESC)
140
self.crossunitdict_table = self.setup_table(*self.CROSSUNITDICT_TABLE_DESC)
141
self.unitdict_table = self.setup_table(*self.UNITDICT_TABLE_DESC)
143
def setup_table (self, name, data, key):
144
"""Create and return an object representing a table/view of our database.
146
Name is the name of our table.
147
Data is a list of tuples of column names and data types.
148
Key is the column of the table that should be indexed.
150
raise NotImplementedError
152
def run_hooks (self, hooks, *args):
154
debug('running hook %s with args %s'%(h,args),3)
157
def get_dict_for_obj (self, obj, keys):
164
def undoable_modify_rec (self, rec, dic, history=[], get_current_rec_method=None,
165
select_change_method=None):
166
"""Modify our recipe and remember how to undo our modification using history."""
167
orig_dic = self.get_dict_for_obj(rec,dic.keys())
168
reundo_name = "Re_apply"
169
reapply_name = "Re_apply "
170
reundo_name += string.join(["%s <i>%s</i>"%(k,v) for k,v in orig_dic.items()])
171
reapply_name += string.join(["%s <i>%s</i>"%(k,v) for k,v in dic.items()])
172
redo,reundo=None,None
173
if get_current_rec_method:
175
r=get_current_rec_method()
176
odic = self.get_dict_for_obj(r,dic.keys())
177
return ([r,dic],[r,odic])
179
r = get_current_rec_method()
180
odic = self.get_dict_for_obj(r,orig_dic.keys())
181
return ([r,orig_dic],[r,odic])
183
def action (*args,**kwargs):
184
"""Our actual action allows for selecting changes after modifying"""
185
self.modify_rec(*args,**kwargs)
186
if select_change_method:
187
select_change_method(*args,**kwargs)
189
obj = Undo.UndoableObject(action,action,history,
190
action_args=[rec,dic],undo_action_args=[rec,orig_dic],
191
get_reapply_action_args=redo,
192
get_reundo_action_args=reundo,
193
reapply_name=reapply_name,
194
reundo_name=reundo_name,)
197
def modify_rec (self, rec, dict):
198
"""Modify recipe 'rec' based on a dictionary of properties and new values."""
199
raise NotImplementedError
201
def search (self, table, colname, text, exact=0, use_regexp=True):
203
"""Search colname of table for text, optionally using regular
204
expressions and/or requiring an exact match."""
206
raise NotImplementedError
208
def get_default_values (self, colname):
210
return defaults.fields[colname]
214
def get_unique_values (self, colname,table=None):
215
if not table: table=self.recipe_table
217
if defaults.fields.has_key(colname):
218
for v in defaults.fields[colname]:
220
def add_to_dic (row):
221
a=getattr(row,colname)
222
if type(a)==type(""):
223
for i in a.split(","):
227
table.filter(add_to_dic)
230
def get_ings (self, rec):
231
"""Handed rec, return a list of ingredients.
233
rec should be an ID or an object with an attribute ID)"""
234
if hasattr(rec,'id'):
238
return self.ingredients_table.select(id=id,deleted=False)
240
def order_ings (self, ingredients_table):
241
"""Handed a view of ingredients, we return an alist:
242
[['group'|None ['ingredient1', 'ingredient2', ...]], ... ]
247
for i in ingredients_table:
249
if not hasattr(i,'inggroup'):
253
if not hasattr(i,'position'):
256
if groups.has_key(group):
257
groups[group].append(i)
258
# the position of the group is the smallest position of its members
259
# in other words, positions pay no attention to groups really.
260
if i.position < group_order[group]: group_order[group]=i.position
263
group_order[group]=i.position
264
# now we just have to sort an i-listify
265
def sort_groups (x,y):
266
if group_order[x[0]] > group_order[y[0]]: return 1
267
elif group_order[x[0]] == group_order[y[0]]: return 0
270
alist.sort(sort_groups)
272
if x.position > y.position: return 1
273
elif x.position == y.position: return 0
279
def ingview_to_lst (self, view):
280
"""Handed a view of ingredient data, we output a useful list.
281
The data we hand out consists of a list of tuples. Each tuple contains
282
amt, unit, key, alternative?"""
284
ret.append([self.get_amount(i), i.unit, i.ingkey,])
287
def ing_shopper (self, view):
288
return mkShopper(self.ingview_to_lst(view))
290
def get_amount (self, ing, mult=1):
291
"""Given an ingredient object, return the amount for it.
293
Amount may be a tuple if the amount is a range, a float if
294
there is a single amount, or None"""
295
amt=getattr(ing,'amount')
296
ramt = getattr(ing,'rangeamount')
298
if amt: amt = amt * mult
299
if ramt: ramt = ramt * mult
305
def get_amount_and_unit (self, ing, mult=1, conv=None,fractions=convert.FRACTIONS_ALL):
306
"""Return a tuple of strings representing our amount and unit.
308
If we are handed a converter interface, we will adjust the
309
units to make them readable.
311
amt=self.get_amount(ing,mult)
314
if type(amt)==tuple: amt,ramount = amt
316
amt,unit = conv.adjust_unit(amt,unit)
317
if ramount and unit != ing.unit:
318
# if we're changing units... convert the upper range too
319
ramount = ramount * conv.converter(ing.unit, unit)
320
if ramount: amt = (amt,ramount)
321
return (self._format_amount_string_from_amount(amt,fractions=fractions),unit)
323
def get_amount_as_string (self,
326
fractions=convert.FRACTIONS_ALL
328
"""Return a string representing our amount.
329
If we have a multiplier, multiply the amount before returning it.
331
amt = self.get_amount(ing,mult)
332
return self._format_amount_string_from_amount(amt, fractions=fractions)
334
def _format_amount_string_from_amount (self, amt, fractions=convert.FRACTIONS_ALL):
335
"""Format our amount string given an amount tuple.
337
If you're thinking of using this function from outside, you
338
should probably just use a convenience function like
339
get_amount_as_string or get_amount_and_unit
342
return "%s-%s"%(convert.float_to_frac(amt[0],fractions=fractions).strip(),
343
convert.float_to_frac(amt[1],fractions=fractions).strip())
344
elif type(amt)==float:
345
return convert.float_to_frac(amt,fractions=fractions)
348
def get_amount_as_float (self, ing, mode=1): #1 == self.AMT_MODE_AVERAGE
349
"""Return a float representing our amount.
351
If we have a range for amount, this function will ignore the range and simply
352
return a number. 'mode' specifies how we deal with the mode:
353
self.AMT_MODE_AVERAGE means we average the mode (our default behavior)
354
self.AMT_MODE_LOW means we use the low number.
355
self.AMT_MODE_HIGH means we take the high number.
357
amt = self.get_amount(ing)
358
if type(amt) in [float, type(None)]:
361
# otherwise we do our magic
363
amt.sort() # make sure these are in order
365
if mode==self.AMT_MODE_AVERAGE: return (low+high)/2.0
366
elif mode==self.AMT_MODE_LOW: return low
367
elif mode==self.AMT_MODE_HIGH: return high # mode==self.AMT_MODE_HIGH
369
raise ValueError("%s is an invalid value for mode"%mode)
371
def get_referenced_rec (self, ing):
372
"""Get recipe referenced by ingredient object."""
373
if hasattr(ing,'refid') and ing.refid:
374
rec = self.get_rec(ing.refid)
376
# otherwise, our reference is no use! Something's been
377
# foobared. Unfortunately, this does happen, so rather than
378
# screwing our user, let's try to look based on title/item
379
# name (the name of the ingredient *should* be the title of
380
# the recipe, though the user could change this)
381
if hasattr(ing,'item'):
382
recs=self.search(self.recipe_table,'title',ing.item,exact=True,use_regexp=False)
384
self.modify_ing(ing,{'idref':recs[0].id})
387
debug("""Warning: there is more than one recipe titled"%(title)s"
388
and our id reference to %(idref)s failed to match any
389
recipes. We are going to assume recipe ID %(id)s is
390
the correct one."""%{'title':ing.item,
396
def get_rec (self, id, recipe_table=None):
397
"""Handed an ID, return a recipe object."""
399
print 'handing get_rec an recipe_table is deprecated'
400
print 'Ignoring recipe_table handed to get_rec'
401
recipe_table=self.recipe_table
402
s = recipe_table.select(id=id)
404
return recipe_table.select(id=id)[0]
408
def add_rec (self, rdict):
409
"""Add a recipe based on a dictionary of properties and values."""
411
t = TimeAction('rdatabase.add_rec - checking keys',3)
412
if not rdict.has_key('deleted'):
414
if not rdict.has_key('id'):
415
rdict['id']=self.new_id()
418
debug('Adding recipe %s'%rdict, 4)
419
t = TimeAction('rdatabase.add_rec - recipe_table.append(rdict)',3)
420
self.recipe_table.append(rdict)
422
debug('Running add hooks %s'%self.add_hooks,2)
423
if self.add_hooks: self.run_hooks(self.add_hooks,self.recipe_table[-1])
424
return self.recipe_table[-1]
426
debug("There was a problem adding recipe%s"%rdict,-1)
429
def delete_rec (self, rec):
430
"""Delete recipe object rec from our database."""
431
raise NotImplementedError
433
def undoable_delete_recs (self, recs, history, make_visible=None):
434
"""Delete recipes by setting their 'deleted' flag to True and add to UNDO history."""
436
for rec in recs: rec.deleted = True
437
if make_visible: make_visible(recs)
439
for rec in recs: rec.deleted = False
440
if make_visible: make_visible(recs)
441
obj = Undo.UndoableObject(do_delete,undo_delete,history)
445
"""Create and return a new, empty recipe"""
446
blankdict = {'id':self.new_id(),
447
'title':_('New Recipe'),
450
return self.add_rec(blankdict)
452
def new_id (self, base="r"):
453
"""Return a new unique ID. Possibly, we can have a base"""
454
if self.top_id.has_key(base):
455
start = self.top_id[base]
459
while self.recipe_table.find(id=self.format_id(n, base)) > -1 or self.ingredients_table.find(id=self.format_id(n, base)) > -1:
460
# if the ID exists, we keep incrementing
461
# until we find a unique ID
463
# every time we're called, we increment out record.
464
# This way, if party A asks for an ID and still hasn't
465
# committed a recipe by the time party B asks for an ID,
466
# they'll still get unique IDs.
468
return self.format_id(n, base)
470
def format_id (self, n, base="r"):
473
def add_ing (self, ingdict):
474
"""Add ingredient to ingredients_table based on ingdict and return
475
ingredient object. Ingdict contains:
479
key: keyed descriptor
480
alternative: not yet implemented (alternative)
482
optional: True|False (boolean)
483
position: INTEGER [position in list]
484
refid: id of reference recipe. If ref is provided, everything
485
else is irrelevant except for amount.
488
debug('adding to ingredients_table %s'%ingdict,3)
489
timer = TimeAction('rdatabase.add_ing 2',5)
490
if ingdict.has_key('amount') and not ingdict['amount']: del ingdict['amount']
491
self.ingredients_table.append(ingdict)
493
debug('running ing hooks %s'%self.add_ing_hooks,3)
494
timer = TimeAction('rdatabase.add_ing 3',5)
495
if self.add_ing_hooks: self.run_hooks(self.add_ing_hooks, self.ingredients_table[-1])
497
debug('done with ing hooks',3)
498
return self.ingredients_table[-1]
500
def undoable_modify_ing (self, ing, dic, history, make_visible=None):
501
"""modify ingredient object ing based on a dictionary of properties and new values.
503
history is our undo history to be handed to Undo.UndoableObject
504
make_visible is a function that will make our change (or the undo or our change) visible.
506
orig_dic = self.get_dict_for_obj(ing,dic.keys())
508
debug('undoable_modify_ing modifying %s'%dic,2)
509
self.modify_ing(ing,dic)
510
if make_visible: make_visible(ing,dic)
512
debug('undoable_modify_ing unmodifying %s'%orig_dic,2)
513
self.modify_ing(ing,orig_dic)
514
if make_visible: make_visible(ing,orig_dic)
515
obj = Undo.UndoableObject(do_action,undo_action,history)
518
def modify_ing (self, ing, ingdict):
519
"""modify ing based on dictionary of properties and new values."""
520
#self.delete_ing(ing)
521
#return self.add_ing(ingdict)
522
for k,v in ingdict.items():
527
debug("Warning: ing has no attribute %s (attempted to set value to %s" %(k,v),0)
530
def replace_ings (self, ingdicts):
531
"""Add a new ingredients and remove old ingredient list."""
532
## we assume (hope!) all ingdicts are for the same ID
534
debug("Deleting ingredients for recipe with ID %s"%id,1)
535
ings = self.get_ings(id)
537
debug("Deleting ingredient: %s"%i.ingredient,5)
539
for ingd in ingdicts:
542
def undoable_delete_ings (self, ings, history, make_visible=None):
543
"""Delete ingredients in list ings and add to our undo history."""
550
for i in ings: i.deleted=False
551
if make_visible: make_visible(ings)
552
obj = Undo.UndoableObject(do_delete,undo_delete,history)
555
def delete_ing (self, ing):
556
"""Delete ingredient permanently."""
557
raise NotImplementedError
559
class RecipeManager (RecData):
562
debug('recipeManager.__init__()',3)
563
RecData.__init__(self)
564
self.km = keymanager.KeyManager(rm=self)
566
def key_search (self, ing):
567
"""Handed a string, we search for keys that could match
569
result=self.km.look_for_key(ing)
570
if type(result)==type(""):
572
elif type(result)==type([]):
573
# look_for contains an alist of sorts... we just want the first
574
# item of every cell.
575
if len(result)>0 and result[0][1]>0.8:
576
return map(lambda a: a[0],result)
578
## otherwise, we make a mad attempt to guess!
579
k=self.km.generate_key(ing)
581
l.extend(map(lambda a: a[0],result))
586
def ingredient_parser (self, s, conv=None, get_key=True):
587
"""Handed a string, we hand back a dictionary (sans recipe ID)"""
588
debug('ingredient_parser handed: %s'%s,0)
589
s = unicode(s) # convert to unicode so our ING MATCHER works properly
590
s=s.strip("\n\t #*+-")
591
debug('ingredient_parser handed: "%s"'%s,1)
592
m=convert.ING_MATCHER.match(s)
594
debug('ingredient parser successfully parsed %s'%s,1)
597
a,u,i=(g[convert.ING_MATCHER_AMT_GROUP],
598
g[convert.ING_MATCHER_UNIT_GROUP],
599
g[convert.ING_MATCHER_ITEM_GROUP])
601
asplit = convert.RANGE_MATCHER.split(a)
603
d['amount']=convert.frac_to_float(asplit[0].strip())
604
d['rangeamount']=convert.frac_to_float(asplit[1].strip())
606
d['amount']=convert.frac_to_float(a.strip())
608
if conv and conv.unit_dict.has_key(u.strip()):
609
d['unit']=conv.unit_dict[u.strip()]
613
optmatch = re.search('\s+\(?[Oo]ptional\)?',i)
616
i = i[0:optmatch.start()] + i[optmatch.end():]
618
if get_key: d['ingkey']=self.km.get_key(i.strip())
619
debug('ingredient_parser returning: %s'%d,0)
622
debug("Unable to parse %s"%s,0)
625
def ing_search (self, ing, keyed=None, recipe_table=None, use_regexp=True, exact=False):
626
"""Search for an ingredient."""
627
raise NotImplementedError
629
def ings_search (self, ings, keyed=None, recipe_table=None, use_regexp=True, exact=False):
630
"""Search for multiple ingredients."""
631
raise NotImplementedError
633
def clear_remembered_optional_ings (self, recipe=None):
634
"""Clear our memories of optional ingredient defaults.
636
If handed a recipe, we clear only for the recipe we've been
639
Otherwise, we clear *all* recipes.
642
vw = self.get_ings(recipe)
644
vw = self.ingredients_table
646
vw1 = vw.select(shopoptional=1)
647
vw2 = vw.select(shopoptional=2)
649
for i in v: self.modify_ing(i,{'shopoptional':0})
651
class mkConverter(convert.converter):
652
def __init__ (self, db):
654
convert.converter.__init__(self)
655
## still need to finish this class and then
656
## replace calls to convert.converter with
657
## calls to rmetakit.mkConverter
659
def create_conv_table (self):
660
self.conv_table = dbDic('ckey','value',self.db.convtable_table, self.db,
662
for k,v in defaults.CONVERTER_TABLE.items():
663
if not self.conv_table.has_key(k):
666
def create_density_table (self):
667
self.density_table = dbDic('dkey','value',
668
self.db.density_table,self.db)
669
for k,v in defaults.DENSITY_TABLE.items():
670
if not self.density_table.has_key(k):
671
self.density_table[k]=v
673
def create_cross_unit_table (self):
674
self.cross_unit_table=dbDic('cukey','value',self.db.crossunitdict_table,self.db)
675
for k,v in defaults.CROSS_UNIT_TABLE:
676
if not self.cross_unit_table.has_key(k):
677
self.cross_unit_table[k]=v
679
def create_unit_dict (self):
680
self.units = defaults.UNITS
681
self.unit_dict=dbDic('ukey','value',self.db.unitdict_table,self.db)
682
for itm in self.units:
685
self.unit_dict[key] = key
687
self.unit_dict[v] = key
690
def __init__ (self, keyprop, valprop, view, db, pickle_key=False):
691
"""Create a dictionary interface to a database table."""
692
self.pickle_key = pickle_key
699
def has_key (self, k):
701
self.just_got = {k:self.__getitem__(k)}
710
def __setitem__ (self, k, v):
713
row = self.vw.select(**{self.kp:k})
715
setattr(row[0],self.vp,pickle.dumps(v))
717
self.vw.append({self.kp:k,self.vp:pickle.dumps(v)})
721
def __getitem__ (self, k):
722
if self.just_got.has_key(k): return self.just_got[k]
725
t=TimeAction('dbdict getting from db',5)
726
v = getattr(self.vw.select(**{self.kp:k})[0],self.vp)
730
return pickle.loads(v)
732
print "Problem unpickling ",v
741
str += "%s"%pickle.loads(getattr(i,self.kp))
743
str += getattr(i,self.kp)
745
str += "%s"%pickle.loads(getattr(i,self.vp))
753
ret.append(getattr(i,self.kp))
759
val = getattr(i,self.vp)
760
if val: val = pickle.loads(val)
767
key = getattr(i,self.kp)
768
val = getattr(i,self.vp)
769
if key and self.pickle_key:
771
key = pickle.loads(key)
773
print 'Problem unpickling key ',key
777
val = pickle.loads(val)
779
print 'Problem unpickling value ',val, ' for key ',key
780
print """Fearlessly, stupidly pushing forward!
781
(This may help us with corrupt data, but this
782
shouldn't be a normal part of our business).
784
ret.append((key,val))