Merge lp:~kolmis/wxbanker/transaction-tagging into lp:wxbanker
- transaction-tagging
- Merge into trunk
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 | ||||||||||||
Related bugs: |
|
||||||||||||
Related blueprints: |
Transaction Tagging
(Medium)
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Michael Rooney | Needs Information | ||
Review via email: mp+7356@code.launchpad.net |
Commit message
Description of the change
Michael Rooney (mrooney) wrote : | # |
Michael Rooney (mrooney) : | # |
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-
- 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)
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/
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
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() |
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.