Merge lp:~kolmis/wxbanker/transaction-tagging into lp:wxbanker

Proposed by Michael Rooney
Status: Merged
Merge reported by: Michael Rooney
Merged at revision: not available
Proposed branch: lp:~kolmis/wxbanker/transaction-tagging
Merge into: lp:wxbanker
Diff against target: None lines
To merge this branch: bzr merge lp:~kolmis/wxbanker/transaction-tagging
Reviewer Review Type Date Requested Status
Michael Rooney Needs Information
Review via email: mp+7356@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Michael Rooney (mrooney) wrote :

I'll propose this for a merge since it would be nice to integrate, and then I can potentially merge the categories branch as well.

One issue I noticed off-hand is that using a list as a default argument probably isn't what you want (tags=[]) as default arguments are only evaluated once thus it will be the same list:

>>> def foo(x=[]):
... x.append(1)
... print x
...
>>> foo()
[1]
>>> foo()
[1, 1]
>>> foo()
[1, 1, 1]

So typically in this case what you do is set tags=None and at the top of the function say if tags is None: tags = []

What are your thoughts on merging this branch? I don't want an extra tags column by default as I want wxBanker to be as simple and clean as possible as surely some users won't need tags, but I'd like it to be as discoverable as possible. Perhaps have some exposed way to tag a transaction, and then only show the column for accounts which have a tagged transaction.

Revision history for this message
Michael Rooney (mrooney) :
review: Needs Information
Revision history for this message
Karel Kolman (kolmis) wrote :

Michael, there are a few issues:
- i need access to bankModel in persistenStore; i had reverted the code that cached the bankModel in a persistentStore member variable, however i didn't realize the bankModel was used to access cached Tag objects. could you spare a simple explanation on the Model - Store coupling, or explain the way multiple models/stores are used in the unit tests (that failed by caching the bankModel)

- as for the tag column hiding, the visibility toggling you're suggesting seems like a good idea to me

- i was not aware of python's default argument once-only-evaluation, thanks for pointing that out

- i promised writing a few tests for the tags, i didn't get around to it yet (i admit there are times when i don't enjoy writing tests)

Revision history for this message
Michael Rooney (mrooney) wrote :

The tests rely on getting a fresh model from the db in a few cases, to make sure something changed on the model actually ended up in the db and is reflected in a new model. See the test*IsStored tests in modeltests.

I've push up a new branch (~mrooney/wxbanker/transaction-tagging) and r248 should address this for both of us. GetModel now uses a cached model by default if one exists, but the tests pass in useCached=False to make sure they get a fresh one.

I also fixed the default argument issue in r249, so I'd recommend merging my branch and continuing from there.

Any ideas on how to expose tagging a transaction in a discoverable way? Or, is there already such a way in your branch?

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'bankobjects.py'
2--- bankobjects.py 2009-04-08 02:30:18 +0000
3+++ bankobjects.py 2009-05-15 12:07:21 +0000
4@@ -24,14 +24,30 @@
5
6
7 class BankModel(object):
8- def __init__(self, store, accountList):
9+ def __init__(self, store):
10 self.Store = store
11- self.Accounts = accountList
12+ self.Accounts = AccountList(store, store.getAccounts())
13+ self.Tags = store.getTags()
14
15 Publisher().subscribe(self.onCurrencyChanged, "user.currency_changed")
16
17 def GetBalance(self):
18 return self.Accounts.Balance
19+
20+ def GetTagById(self, id):
21+ for tag in self.Tags:
22+ if tag.ID == id:
23+ return tag
24+ return None
25+
26+ def GetTagByName(self, name):
27+ ''' returns a tag by name, creates a new one if none by that name exists '''
28+ for tag in self.Tags:
29+ if tag.Name == name:
30+ return tag
31+ newTag = self.Store.CreateTag(name)
32+ self.Tags.append(newTag)
33+ return newTag
34
35 def GetTransactions(self):
36 transactions = []
37@@ -56,7 +72,7 @@
38
39 def Search(self, searchString, account=None, matchIndex=1, matchCase=False):
40 """
41- matchIndex: 0: Amount, 1: Description, 2: Date
42+ matchIndex: 0: Amount, 1: Description, 2: Date, 3: Tags
43 I originally used strings here but passing around and then validating on translated
44 strings seems like a bad and fragile idea.
45 """
46@@ -73,9 +89,13 @@
47 matches = []
48 for trans in potentials:
49 #print unicode(trans.Description), searchString
50- potentialStr = unicode((trans.Amount, trans.Description, trans.Date)[matchIndex])
51- if re.findall(searchString, potentialStr, flags=reFlag):
52- matches.append(trans)
53+ potentialStrings = (
54+ (trans.Amount, ), (trans.Description, ), (trans.Date, ), trans.Tags or ('', )
55+ )[matchIndex]
56+ for potentialStr in potentialStrings:
57+ if re.search(searchString, unicode(potentialStr), flags=reFlag):
58+ matches.append(trans)
59+ break
60 return matches
61
62 def Save(self):
63@@ -316,6 +336,18 @@
64 self.RemoveTransactions(transactions)
65 destAccount.AddTransactions(transactions)
66 Publisher.sendMessage("batch.end")
67+
68+ def TagTransactions(self, transactions, tag):
69+ Publisher.sendMessage("batch.start")
70+ for t in transactions:
71+ t.AddTag(tag)
72+ Publisher.sendMessage("batch.end")
73+
74+ def UntagTransactions(self, transactions, tag):
75+ Publisher.sendMessage("batch.start")
76+ for t in transactions:
77+ t.RemoveTag(tag)
78+ Publisher.sendMessage("batch.end")
79
80 def onTransactionAmountChanged(self, message):
81 transaction, difference = message.data
82@@ -371,7 +403,7 @@
83 Changes to this object get sent out via pubsub,
84 typically causing the model to make the change.
85 """
86- def __init__(self, tID, parent, amount, description, date):
87+ def __init__(self, tID, parent, amount, description, date, tags=[]):
88 self.IsFrozen = True
89
90 self.ID = tID
91@@ -379,9 +411,29 @@
92 self.Date = date
93 self.Description = description
94 self.Amount = amount
95+ self.Tags = tags
96
97 self.IsFrozen = False
98
99+ def GetTags(self):
100+ return self._Tags
101+
102+ def SetTags(self, tags):
103+ self._Tags = tags
104+
105+ if not self.IsFrozen:
106+ Publisher.sendMessage("transaction.updated.tags", (self, None))
107+
108+ def AddTag(self, tag):
109+ if not tag in self.Tags:
110+ self.Tags.append(tag)
111+ Publisher.sendMessage("transaction.updated.tags", (self, None))
112+
113+ def RemoveTag(self, tag):
114+ if tag in self.Tags:
115+ self.Tags.remove(tag)
116+ Publisher.sendMessage("transaction.updated.tags", (self, None))
117+
118 def GetDate(self):
119 return self._Date
120
121@@ -480,8 +532,21 @@
122
123 Date = property(GetDate, SetDate)
124 Description = property(GetDescription, SetDescription)
125+ Tags = property(GetTags, SetTags)
126 Amount = property(GetAmount, SetAmount)
127
128+
129+class Tag(object):
130+ def __init__(self, aID, name):
131+ self.ID = aID
132+ self.Name = name
133+
134+ def __str__(self):
135+ return self.Name
136+
137+ def __cmp__(self, other):
138+ return cmp(self.ID, other.ID)
139+
140
141 if __name__ == "__main__":
142 import doctest
143
144=== modified file 'persistentstore.py'
145--- persistentstore.py 2009-04-08 02:30:18 +0000
146+++ persistentstore.py 2009-05-15 13:15:36 +0000
147@@ -44,7 +44,7 @@
148 back the changes.
149 """
150 def __init__(self, path, autoSave=True):
151- self.Version = 3
152+ self.Version = 4
153 self.Path = path
154 self.AutoSave = autoSave
155 self.Dirty = False
156@@ -70,6 +70,8 @@
157 debug.debug(self.Meta)
158
159 self.commitIfAppropriate()
160+ # better delete tags on exit ?
161+ #self.DeleteUnusedTags()
162
163 self.Subscriptions = (
164 (self.onTransactionUpdated, "transaction.updated"),
165@@ -88,9 +90,7 @@
166 if "--sync-balances" in sys.argv:
167 self.syncBalances()
168
169- accounts = self.getAccounts()
170- accountList = bankobjects.AccountList(self, accounts)
171- bankmodel = bankobjects.BankModel(self, accountList)
172+ bankmodel = bankobjects.BankModel(self)
173
174 return bankmodel
175
176@@ -134,6 +134,19 @@
177 # everything is fine. So just return True, as there we no errors that we are aware of.
178 return True
179
180+ def CreateTag(self, name):
181+ cursor = self.dbconn.cursor()
182+ cursor.execute('INSERT INTO tags (name) VALUES (?)', [name])
183+ ID = cursor.lastrowid
184+ self.commitIfAppropriate()
185+
186+ return bankobjects.Tag(ID, name)
187+
188+ def DeleteUnusedTags(self):
189+ cursor = self.dbconn.cursor()
190+ cursor.execute('DELETE FROM tags WHERE NOT EXISTS (SELECT 1 FROM transactions_tags_link l WHERE tagId = tags.id)')
191+ self.commitIfAppropriate()
192+
193 def Save(self):
194 import time; t = time.time()
195 self.dbconn.commit()
196@@ -173,9 +186,15 @@
197
198 cursor.execute('CREATE TABLE accounts (id INTEGER PRIMARY KEY, name VARCHAR(255), currency INTEGER, balance FLOAT)')
199 cursor.execute('CREATE TABLE transactions (id INTEGER PRIMARY KEY, accountId INTEGER, amount FLOAT, description VARCHAR(255), date CHAR(10))')
200+ cursor.execute('CREATE INDEX transactions_accountId_idx ON transactions(accountId)')
201+
202+ cursor.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, name VARCHAR(255))')
203+ cursor.execute('CREATE TABLE transactions_tags_link (id INTEGER PRIMARY KEY, transactionId INTEGER, tagId INTEGER)')
204+ cursor.execute('CREATE INDEX transactions_tags_transactionId_idx ON transactions_tags_link(transactionId)')
205+ cursor.execute('CREATE INDEX transactions_tags_tagId_idx ON transactions_tags_link(tagId)')
206
207 cursor.execute('CREATE TABLE meta (id INTEGER PRIMARY KEY, name VARCHAR(255), value VARCHAR(255))')
208- cursor.execute('INSERT INTO meta VALUES (null, ?, ?)', ('VERSION', '3'))
209+ cursor.execute('INSERT INTO meta VALUES (null, ?, ?)', ('VERSION', self.Version))
210
211 return connection
212
213@@ -219,12 +238,24 @@
214 # Add `total` column to the accounts table.
215 cursor.execute('ALTER TABLE accounts ADD balance FLOAT not null DEFAULT 0.0')
216 self.syncBalances()
217- # Update the meta version number.
218- cursor.execute('UPDATE meta SET value=? WHERE name=?', (3, "VERSION"))
219+ self.__updateDbVersion(3)
220+ elif fromVer == 3:
221+ # index for the accountId column in transactions
222+ cursor.execute('CREATE INDEX transactions_accountId_idx ON transactions(accountId)')
223+ # tags table; transactions - tags link table
224+ cursor.execute('CREATE TABLE tags (id INTEGER PRIMARY KEY, name VARCHAR(255))')
225+ cursor.execute('CREATE TABLE transactions_tags_link (id INTEGER PRIMARY KEY, transactionId INTEGER, tagId INTEGER)')
226+ cursor.execute('CREATE INDEX transactions_tags_transactionId_idx ON transactions_tags_link(transactionId)')
227+ cursor.execute('CREATE INDEX transactions_tags_tagId_idx ON transactions_tags_link(tagId)')
228+ self.__updateDbVersion(4)
229 else:
230 raise Exception("Cannot upgrade database from version %i"%fromVer)
231
232 self.commitIfAppropriate()
233+
234+ def __updateDbVersion(self, version):
235+ # Update the meta version number.
236+ self.dbconn.cursor().execute('UPDATE meta SET value=? WHERE name=?', (version, "VERSION"))
237
238 def syncBalances(self):
239 debug.debug("Syncing balances...")
240@@ -252,17 +283,32 @@
241 self.dbconn.cursor().execute('DELETE FROM transactions WHERE accountId=?', (account.ID,))
242 self.commitIfAppropriate()
243
244+ def getTags(self):
245+ return [self.result2tag(result) for result in self.dbconn.cursor().execute("SELECT * FROM tags").fetchall()]
246+
247+ def result2tag(self, result):
248+ ID, name = result
249+ return bankobjects.Tag(ID, name)
250+
251+ def getTagsForTransaction(self, transId):
252+ result = self.dbconn.cursor().execute(
253+ 'SELECT tagId FROM transactions_tags_link WHERE transactionId=?', (transId, )).fetchall()
254+ return [self.bankModel.GetTagById(row[0]) for row in result]
255+
256 def getTransactionsFrom(self, account):
257 transactions = bankobjects.TransactionList()
258 for result in self.dbconn.cursor().execute('SELECT * FROM transactions WHERE accountId=?', (account.ID,)).fetchall():
259 tid, pid, amount, description, date = result
260- transactions.append(bankobjects.Transaction(tid, account, amount, description, date))
261+ transactions.append(bankobjects.Transaction(tid, account, amount, description, date, self.getTagsForTransaction(tid)))
262 return transactions
263
264 def updateTransaction(self, transObj):
265 result = self.transaction2result(transObj)
266 result.append( result.pop(0) ) # Move the uid to the back as it is last in the args below.
267 self.dbconn.cursor().execute('UPDATE transactions SET amount=?, description=?, date=? WHERE id=?', result)
268+ self.dbconn.cursor().execute('DELETE FROM transactions_tags_link WHERE transactionId=?', (transObj.ID, ))
269+ for tag in transObj.Tags:
270+ self.dbconn.cursor().execute('INSERT INTO transactions_tags_link (transactionId, tagId) VALUES (?, ?)', (transObj.ID, tag.ID))
271 self.commitIfAppropriate()
272
273 def renameAccount(self, oldName, account):
274
275=== modified file 'searchctrl.py'
276--- searchctrl.py 2009-04-13 03:09:07 +0000
277+++ searchctrl.py 2009-05-14 08:11:28 +0000
278@@ -37,7 +37,7 @@
279 # The More/Less button.
280 self.moreButton = bankcontrols.MultiStateButton(self, labelDict={True: _("More options"), False: _("Less options")}, state=True)
281
282- self.matchChoices = [_("Amount"), _("Description"), _("Date")]
283+ self.matchChoices = [_("Amount"), _("Description"), _("Date"), _("Tags")]
284 self.matchBox = bankcontrols.CompactableComboBox(self, value=self.matchChoices[1], choices=self.matchChoices, style=wx.CB_READONLY)
285
286 self.caseCheck = wx.CheckBox(self, label=_("Case Sensitive"))
287@@ -107,4 +107,4 @@
288 # Give or take the appropriate amount of space.
289 self.Parent.Layout()
290 Publisher().sendMessage("SEARCH.MORETOGGLED")
291-
292\ No newline at end of file
293+
294
295=== modified file 'transactionolv.py'
296--- transactionolv.py 2009-05-06 22:24:20 +0000
297+++ transactionolv.py 2009-05-12 21:24:35 +0000
298@@ -46,6 +46,7 @@
299 self.oddRowsBackColor = wx.WHITE
300 self.cellEditMode = GroupListView.CELLEDIT_DOUBLECLICK
301 self.SetEmptyListMsg(_("No transactions entered."))
302+ self.TagSeparator = ','
303
304 # Calculate the necessary width for the date column.
305 dateStr = str(datetime.date.today())
306@@ -61,6 +62,7 @@
307 ColumnDefn(_("Description"), valueGetter="Description", isSpaceFilling=True),
308 ColumnDefn(_("Amount"), "right", valueGetter="Amount", stringConverter=self.renderFloat),
309 ColumnDefn(_("Total"), "right", valueGetter=self.getTotal, stringConverter=self.renderFloat, isEditable=False),
310+ ColumnDefn(_("Tags"), valueGetter=self.getTagsString, valueSetter=self.setTagsFromString),
311 ])
312 # Our custom hack in OLV.py:2017 will render amount floats appropriately as %.2f when editing.
313
314@@ -106,6 +108,13 @@
315 self.Freeze()
316 self.SortBy(self.SORT_COL)
317 self.Thaw()
318+
319+ def getTagsString(self, transaction):
320+ return (self.TagSeparator + ' ').join([tag.Name for tag in transaction.Tags])
321+
322+ def setTagsFromString(self, transaction, tags):
323+ tagNames = [tag.strip() for tag in tags.split(self.TagSeparator) if tag.strip()]
324+ transaction.Tags = [self.BankController.Model.GetTagByName(name) for name in tagNames]
325
326 def getTotal(self, transObj):
327 """
328@@ -192,9 +201,13 @@
329 if len(transactions) == 1:
330 removeStr = _("Remove this transaction")
331 moveStr = _("Move this transaction to account")
332+ addTagStr = _("Tag the transaction with")
333+ removeTagStr = _("Untag the transaction with")
334 else:
335 removeStr = _("Remove these %i transactions") % len(transactions)
336 moveStr = _("Move these %i transactions to account") % len(transactions)
337+ addTagStr = _("Tag these %i transactions with") % len(transactions)
338+ removeTagStr = _("Untag these %i transactions with") % len(transactions)
339
340 removeItem = wx.MenuItem(menu, -1, removeStr)
341 menu.Bind(wx.EVT_MENU, lambda e: self.onRemoveTransactions(transactions), source=removeItem)
342@@ -211,6 +224,42 @@
343 accountsMenu.Bind(wx.EVT_MENU, lambda e, account=account: self.onMoveTransactions(transactions, account), source=accountItem)
344 moveToAccountItem.SetSubMenu(accountsMenu)
345 menu.AppendItem(moveToAccountItem)
346+
347+ # Tagging
348+ tags = sorted(self.BankController.Model.Tags, key=lambda x: x.Name)
349+
350+ tagActionItem = wx.MenuItem(menu, -1, addTagStr)
351+ tagsMenu = wx.Menu()
352+ for tag in tags:
353+ tagItem = wx.MenuItem(menu, -1, tag.Name)
354+ tagsMenu.AppendItem(tagItem)
355+ tagsMenu.Bind(wx.EVT_MENU, lambda e, tag=tag: self.onTagTransactions(transactions, tag), source=tagItem)
356+
357+ tagsMenu.AppendSeparator()
358+
359+ newTagItem = wx.MenuItem(menu,-1, _("New Tag"))
360+ tagsMenu.AppendItem(newTagItem)
361+ tagsMenu.Bind(wx.EVT_MENU, lambda e: self.onTagTransactions(transactions), source=newTagItem)
362+
363+ tagActionItem.SetSubMenu(tagsMenu)
364+ menu.AppendItem(tagActionItem)
365+
366+ # get the tags currently applied to the selected transactions
367+ currentTags = set()
368+ for t in transactions:
369+ currentTags.update(t.Tags)
370+ currentTags = sorted(currentTags, key=lambda x: x.Name)
371+
372+ if len(currentTags) > 0:
373+ tagActionItem = wx.MenuItem(menu, -1, removeTagStr)
374+ tagsMenu = wx.Menu()
375+
376+ for tag in currentTags:
377+ tagItem = wx.MenuItem(menu, -1, tag.Name)
378+ tagsMenu.AppendItem(tagItem)
379+ tagsMenu.Bind(wx.EVT_MENU, lambda e, tag=tag: self.onUntagTransactions(transactions, tag), source=tagItem)
380+ tagActionItem.SetSubMenu(tagsMenu)
381+ menu.AppendItem(tagActionItem)
382
383 # Show the menu and then destroy it afterwards.
384 self.PopupMenu(menu)
385@@ -242,6 +291,20 @@
386 def onMoveTransactions(self, transactions, targetAccount):
387 """Move the transactions to the target account."""
388 self.CurrentAccount.MoveTransactions(transactions, targetAccount)
389+
390+ def onTagTransactions(self, transactions, tag=None):
391+ if tag is None:
392+ dialog = wx.TextEntryDialog(self, _("Enter new tag"), _("New Tag"))
393+ dialog.ShowModal()
394+ dialog.Destroy()
395+ tagName = dialog.GetValue().strip()
396+ if len(tagName) == 0:
397+ return
398+ tag = self.BankController.Model.GetTagByName(tagName)
399+ self.CurrentAccount.TagTransactions(transactions, tag)
400+
401+ def onUntagTransactions(self, transactions, tag):
402+ self.CurrentAccount.UntagTransactions(transactions, tag)
403
404 def frozenResize(self):
405 self.Parent.Layout()

Subscribers

People subscribed via source and target branches