Code

Fixed debug output in page footer; added expiry date to the login cookie
[roundup.git] / roundup / roundupdb.py
1 #
2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
3 # This module is free software, and you may redistribute it and/or modify
4 # under the same terms as Python, so long as this copyright message and
5 # disclaimer are retained in their original form.
6 #
7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
10 # POSSIBILITY OF SUCH DAMAGE.
11 #
12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
17
18 # $Id: roundupdb.py,v 1.16 2001-10-30 00:54:45 richard Exp $
20 import re, os, smtplib, socket
22 import hyperdb, date
24 class DesignatorError(ValueError):
25     pass
26 def splitDesignator(designator, dre=re.compile(r'([^\d]+)(\d+)')):
27     ''' Take a foo123 and return ('foo', 123)
28     '''
29     m = dre.match(designator)
30     if m is None:
31         raise DesignatorError, '"%s" not a node designator'%designator
32     return m.group(1), m.group(2)
34 class Database:
35     def getuid(self):
36         """Return the id of the "user" node associated with the user
37         that owns this connection to the hyperdatabase."""
38         return self.user.lookup(self.journaltag)
40     def uidFromAddress(self, address, create=1):
41         ''' address is from the rfc822 module, and therefore is (name, addr)
43             user is created if they don't exist in the db already
44         '''
45         (realname, address) = address
46         users = self.user.stringFind(address=address)
47         for dummy in range(2):
48             if len(users) > 1:
49                 # make sure we don't match the anonymous or admin user
50                 for user in users:
51                     if user == '1': continue
52                     if self.user.get(user, 'username') == 'anonymous': continue
53                     # first valid match will do
54                     return user
55                 # well, I guess we have no choice
56                 return user[0]
57             elif users:
58                 return users[0]
59             # try to match the username to the address (for local
60             # submissions where the address is empty)
61             users = self.user.stringFind(username=address)
63         # couldn't match address or username, so create a new user
64         return self.user.create(username=address, address=address,
65             realname=realname)
67 _marker = []
68 # XXX: added the 'creator' faked attribute
69 class Class(hyperdb.Class):
70     # Overridden methods:
71     def __init__(self, db, classname, **properties):
72         if (properties.has_key('creation') or properties.has_key('activity')
73                 or properties.has_key('creator')):
74             raise ValueError, '"creation", "activity" and "creator" are reserved'
75         hyperdb.Class.__init__(self, db, classname, **properties)
76         self.auditors = {'create': [], 'set': [], 'retire': []}
77         self.reactors = {'create': [], 'set': [], 'retire': []}
79     def create(self, **propvalues):
80         """These operations trigger detectors and can be vetoed.  Attempts
81         to modify the "creation" or "activity" properties cause a KeyError.
82         """
83         if propvalues.has_key('creation') or propvalues.has_key('activity'):
84             raise KeyError, '"creation" and "activity" are reserved'
85         for audit in self.auditors['create']:
86             audit(self.db, self, None, propvalues)
87         nodeid = hyperdb.Class.create(self, **propvalues)
88         for react in self.reactors['create']:
89             react(self.db, self, nodeid, None)
90         return nodeid
92     def set(self, nodeid, **propvalues):
93         """These operations trigger detectors and can be vetoed.  Attempts
94         to modify the "creation" or "activity" properties cause a KeyError.
95         """
96         if propvalues.has_key('creation') or propvalues.has_key('activity'):
97             raise KeyError, '"creation" and "activity" are reserved'
98         for audit in self.auditors['set']:
99             audit(self.db, self, nodeid, propvalues)
100         oldvalues = self.db.getnode(self.classname, nodeid)
101         hyperdb.Class.set(self, nodeid, **propvalues)
102         for react in self.reactors['set']:
103             react(self.db, self, nodeid, oldvalues)
105     def retire(self, nodeid):
106         """These operations trigger detectors and can be vetoed.  Attempts
107         to modify the "creation" or "activity" properties cause a KeyError.
108         """
109         for audit in self.auditors['retire']:
110             audit(self.db, self, nodeid, None)
111         hyperdb.Class.retire(self, nodeid)
112         for react in self.reactors['retire']:
113             react(self.db, self, nodeid, None)
115     def get(self, nodeid, propname, default=_marker):
116         """Attempts to get the "creation" or "activity" properties should
117         do the right thing.
118         """
119         if propname == 'creation':
120             journal = self.db.getjournal(self.classname, nodeid)
121             if journal:
122                 return self.db.getjournal(self.classname, nodeid)[0][1]
123             else:
124                 # on the strange chance that there's no journal
125                 return date.Date()
126         if propname == 'activity':
127             journal = self.db.getjournal(self.classname, nodeid)
128             if journal:
129                 return self.db.getjournal(self.classname, nodeid)[-1][1]
130             else:
131                 # on the strange chance that there's no journal
132                 return date.Date()
133         if propname == 'creator':
134             journal = self.db.getjournal(self.classname, nodeid)
135             if journal:
136                 name = self.db.getjournal(self.classname, nodeid)[0][2]
137             else:
138                 return None
139             return self.db.user.lookup(name)
140         if default is not _marker:
141             return hyperdb.Class.get(self, nodeid, propname, default)
142         else:
143             return hyperdb.Class.get(self, nodeid, propname)
145     def getprops(self, protected=1):
146         """In addition to the actual properties on the node, these
147         methods provide the "creation" and "activity" properties. If the
148         "protected" flag is true, we include protected properties - those
149         which may not be modified.
150         """
151         d = hyperdb.Class.getprops(self, protected=protected).copy()
152         if protected:
153             d['creation'] = hyperdb.Date()
154             d['activity'] = hyperdb.Date()
155             d['creator'] = hyperdb.Link("user")
156         return d
158     #
159     # Detector interface
160     #
161     def audit(self, event, detector):
162         """Register a detector
163         """
164         self.auditors[event].append(detector)
166     def react(self, event, detector):
167         """Register a detector
168         """
169         self.reactors[event].append(detector)
172 class FileClass(Class):
173     def create(self, **propvalues):
174         ''' snaffle the file propvalue and store in a file
175         '''
176         content = propvalues['content']
177         del propvalues['content']
178         newid = Class.create(self, **propvalues)
179         self.setcontent(self.classname, newid, content)
180         return newid
182     def filename(self, classname, nodeid):
183         # TODO: split into multiple files directories
184         return os.path.join(self.db.dir, 'files', '%s%s'%(classname, nodeid))
186     def setcontent(self, classname, nodeid, content):
187         ''' set the content file for this file
188         '''
189         open(self.filename(classname, nodeid), 'wb').write(content)
191     def getcontent(self, classname, nodeid):
192         ''' get the content file for this file
193         '''
194         return open(self.filename(classname, nodeid), 'rb').read()
196     def get(self, nodeid, propname, default=_marker):
197         ''' trap the content propname and get it from the file
198         '''
199         if propname == 'content':
200             return self.getcontent(self.classname, nodeid)
201         if default is not _marker:
202             return Class.get(self, nodeid, propname, default)
203         else:
204             return Class.get(self, nodeid, propname)
206     def getprops(self, protected=1):
207         ''' In addition to the actual properties on the node, these methods
208             provide the "content" property. If the "protected" flag is true,
209             we include protected properties - those which may not be
210             modified.
211         '''
212         d = Class.getprops(self, protected=protected).copy()
213         if protected:
214             d['content'] = hyperdb.String()
215         return d
217 # XXX deviation from spec - was called ItemClass
218 class IssueClass(Class):
219     # configuration
220     MESSAGES_TO_AUTHOR = 'no'
222     # Overridden methods:
224     def __init__(self, db, classname, **properties):
225         """The newly-created class automatically includes the "messages",
226         "files", "nosy", and "superseder" properties.  If the 'properties'
227         dictionary attempts to specify any of these properties or a
228         "creation" or "activity" property, a ValueError is raised."""
229         if not properties.has_key('title'):
230             properties['title'] = hyperdb.String()
231         if not properties.has_key('messages'):
232             properties['messages'] = hyperdb.Multilink("msg")
233         if not properties.has_key('files'):
234             properties['files'] = hyperdb.Multilink("file")
235         if not properties.has_key('nosy'):
236             properties['nosy'] = hyperdb.Multilink("user")
237         if not properties.has_key('superseder'):
238             properties['superseder'] = hyperdb.Multilink(classname)
239         Class.__init__(self, db, classname, **properties)
241     # New methods:
243     def addmessage(self, nodeid, summary, text):
244         """Add a message to an issue's mail spool.
246         A new "msg" node is constructed using the current date, the user that
247         owns the database connection as the author, and the specified summary
248         text.
250         The "files" and "recipients" fields are left empty.
252         The given text is saved as the body of the message and the node is
253         appended to the "messages" field of the specified issue.
254         """
256     def sendmessage(self, nodeid, msgid):
257         """Send a message to the members of an issue's nosy list.
259         The message is sent only to users on the nosy list who are not
260         already on the "recipients" list for the message.
261         
262         These users are then added to the message's "recipients" list.
263         """
264         # figure the recipient ids
265         recipients = self.db.msg.get(msgid, 'recipients')
266         r = {}
267         for recipid in recipients:
268             r[recipid] = 1
270         # figure the author's id, and indicate they've received the message
271         authid = self.db.msg.get(msgid, 'author')
272         r[authid] = 1
274         sendto = []
275         # ... but duplicate the message to the author as long as it's not
276         # the anonymous user
277         if (self.MESSAGES_TO_AUTHOR == 'yes' and
278                 self.db.user.get(authid, 'username') != 'anonymous'):
279             sendto.append(authid)
281         # now figure the nosy people who weren't recipients
282         nosy = self.get(nodeid, 'nosy')
283         for nosyid in nosy:
284             # Don't send nosy mail to the anonymous user (that user
285             # shouldn't appear in the nosy list, but just in case they
286             # do...)
287             if self.db.user.get(nosyid, 'username') == 'anonymous': continue
288             if not r.has_key(nosyid):
289                 sendto.append(nosyid)
290                 recipients.append(nosyid)
292         if sendto:
293             # update the message's recipients list
294             self.db.msg.set(msgid, recipients=recipients)
296             # send an email to the people who missed out
297             sendto = [self.db.user.get(i, 'address') for i in recipients]
298             cn = self.classname
299             title = self.get(nodeid, 'title') or '%s message copy'%cn
300             # figure author information
301             authname = self.db.user.get(authid, 'realname')
302             if not authname:
303                 authname = self.db.user.get(authid, 'username')
304             authaddr = self.db.user.get(authid, 'address')
305             if authaddr:
306                 authaddr = '<%s> '%authaddr
307             else:
308                 authaddr = ''
309             # TODO attachments
310             m = ['Subject: [%s%s] %s'%(cn, nodeid, title)]
311             m.append('To: %s'%', '.join(sendto))
312             m.append('From: %s'%self.ISSUE_TRACKER_EMAIL)
313             m.append('Reply-To: %s'%self.ISSUE_TRACKER_EMAIL)
314             m.append('')
315             # add author information
316             m.append("%s %sadded the comment:"%(authname, authaddr))
317             m.append('')
318             # add the content
319             m.append(self.db.msg.get(msgid, 'content'))
320             # "list information" footer
321             m.append(self.email_footer(nodeid, msgid))
322             try:
323                 smtp = smtplib.SMTP(self.MAILHOST)
324                 smtp.sendmail(self.ISSUE_TRACKER_EMAIL, sendto, '\n'.join(m))
325             except socket.error, value:
326                 return "Couldn't send confirmation email: mailhost %s"%value
327             except smtplib.SMTPException, value:
328                 return "Couldn't send confirmation email: %s"%value
330     def email_footer(self, nodeid, msgid):
331         ''' Add a footer to the e-mail with some useful information
332         '''
333         web = self.ISSUE_TRACKER_WEB
334         return '''%s
335 Roundup issue tracker
336 %s
337 %s
338 '''%('_'*len(web), self.ISSUE_TRACKER_EMAIL, web)
341 # $Log: not supported by cvs2svn $
342 # Revision 1.15  2001/10/23 01:00:18  richard
343 # Re-enabled login and registration access after lopping them off via
344 # disabling access for anonymous users.
345 # Major re-org of the htmltemplate code, cleaning it up significantly. Fixed
346 # a couple of bugs while I was there. Probably introduced a couple, but
347 # things seem to work OK at the moment.
349 # Revision 1.14  2001/10/21 07:26:35  richard
350 # feature #473127: Filenames. I modified the file.index and htmltemplate
351 #  source so that the filename is used in the link and the creation
352 #  information is displayed.
354 # Revision 1.13  2001/10/21 00:45:15  richard
355 # Added author identification to e-mail messages from roundup.
357 # Revision 1.12  2001/10/04 02:16:15  richard
358 # Forgot to pass the protected flag down *sigh*.
360 # Revision 1.11  2001/10/04 02:12:42  richard
361 # Added nicer command-line item adding: passing no arguments will enter an
362 # interactive more which asks for each property in turn. While I was at it, I
363 # fixed an implementation problem WRT the spec - I wasn't raising a
364 # ValueError if the key property was missing from a create(). Also added a
365 # protected=boolean argument to getprops() so we can list only the mutable
366 # properties (defaults to yes, which lists the immutables).
368 # Revision 1.10  2001/08/07 00:24:42  richard
369 # stupid typo
371 # Revision 1.9  2001/08/07 00:15:51  richard
372 # Added the copyright/license notice to (nearly) all files at request of
373 # Bizar Software.
375 # Revision 1.8  2001/08/02 06:38:17  richard
376 # Roundupdb now appends "mailing list" information to its messages which
377 # include the e-mail address and web interface address. Templates may
378 # override this in their db classes to include specific information (support
379 # instructions, etc).
381 # Revision 1.7  2001/07/30 02:38:31  richard
382 # get() now has a default arg - for migration only.
384 # Revision 1.6  2001/07/30 00:05:54  richard
385 # Fixed IssueClass so that superseders links to its classname rather than
386 # hard-coded to "issue".
388 # Revision 1.5  2001/07/29 07:01:39  richard
389 # Added vim command to all source so that we don't get no steenkin' tabs :)
391 # Revision 1.4  2001/07/29 04:05:37  richard
392 # Added the fabricated property "id".
394 # Revision 1.3  2001/07/23 07:14:41  richard
395 # Moved the database backends off into backends.
397 # Revision 1.2  2001/07/22 12:09:32  richard
398 # Final commit of Grande Splite
400 # Revision 1.1  2001/07/22 11:58:35  richard
401 # More Grande Splite
404 # vim: set filetype=python ts=4 sw=4 et si