Code

svn repository setup
[roundup.git] / roundup / roundupdb.py
1 from __future__ import nested_scopes
2 #
3 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
4 # This module is free software, and you may redistribute it and/or modify
5 # under the same terms as Python, so long as this copyright message and
6 # disclaimer are retained in their original form.
7 #
8 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
9 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
10 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
11 # POSSIBILITY OF SUCH DAMAGE.
12 #
13 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
14 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
15 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
16 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
17 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
18 #
19 # $Id: roundupdb.py,v 1.139 2008-08-07 06:31:16 richard Exp $
21 """Extending hyperdb with types specific to issue-tracking.
22 """
23 __docformat__ = 'restructuredtext'
25 import re, os, smtplib, socket, time, random
26 import cStringIO, base64, quopri, mimetypes
27 import os.path
29 from rfc2822 import encode_header
31 from roundup import password, date, hyperdb
32 from roundup.i18n import _
34 # MessageSendError is imported for backwards compatibility
35 from roundup.mailer import Mailer, straddr, MessageSendError
37 class Database:
39     # remember the journal uid for the current journaltag so that:
40     # a. we don't have to look it up every time we need it, and
41     # b. if the journaltag disappears during a transaction, we don't barf
42     #    (eg. the current user edits their username)
43     journal_uid = None
44     def getuid(self):
45         """Return the id of the "user" node associated with the user
46         that owns this connection to the hyperdatabase."""
47         if self.journaltag is None:
48             return None
49         elif self.journaltag == 'admin':
50             # admin user may not exist, but always has ID 1
51             return '1'
52         else:
53             if (self.journal_uid is None or self.journal_uid[0] !=
54                     self.journaltag):
55                 uid = self.user.lookup(self.journaltag)
56                 self.journal_uid = (self.journaltag, uid)
57             return self.journal_uid[1]
59     def setCurrentUser(self, username):
60         """Set the user that is responsible for current database
61         activities.
62         """
63         self.journaltag = username
65     def isCurrentUser(self, username):
66         """Check if a given username equals the already active user.
67         """
68         return self.journaltag == username
70     def getUserTimezone(self):
71         """Return user timezone defined in 'timezone' property of user class.
72         If no such property exists return 0
73         """
74         userid = self.getuid()
75         timezone = None
76         try:
77             tz = self.user.get(userid, 'timezone')
78             date.get_timezone(tz)
79             timezone = tz
80         except KeyError:
81             pass
82         # If there is no class 'user' or current user doesn't have timezone
83         # property or that property is not set assume he/she lives in
84         # the timezone set in the tracker config.
85         if timezone is None:
86             timezone = self.config['TIMEZONE']
87         return timezone
89     def confirm_registration(self, otk):
90         props = self.getOTKManager().getall(otk)
91         for propname, proptype in self.user.getprops().items():
92             value = props.get(propname, None)
93             if value is None:
94                 pass
95             elif isinstance(proptype, hyperdb.Date):
96                 props[propname] = date.Date(value)
97             elif isinstance(proptype, hyperdb.Interval):
98                 props[propname] = date.Interval(value)
99             elif isinstance(proptype, hyperdb.Password):
100                 props[propname] = password.Password()
101                 props[propname].unpack(value)
103         # tag new user creation with 'admin'
104         self.journaltag = 'admin'
106         # create the new user
107         cl = self.user
109         props['roles'] = self.config.NEW_WEB_USER_ROLES
110         userid = cl.create(**props)
111         # clear the props from the otk database
112         self.getOTKManager().destroy(otk)
113         self.commit()
115         return userid
118 class DetectorError(RuntimeError):
119     """ Raised by detectors that want to indicate that something's amiss
120     """
121     pass
123 # deviation from spec - was called IssueClass
124 class IssueClass:
125     """This class is intended to be mixed-in with a hyperdb backend
126     implementation. The backend should provide a mechanism that
127     enforces the title, messages, files, nosy and superseder
128     properties:
130     - title = hyperdb.String(indexme='yes')
131     - messages = hyperdb.Multilink("msg")
132     - files = hyperdb.Multilink("file")
133     - nosy = hyperdb.Multilink("user")
134     - superseder = hyperdb.Multilink(classname)
135     """
137     # The tuple below does not affect the class definition.
138     # It just lists all names of all issue properties
139     # marked for message extraction tool.
140     #
141     # XXX is there better way to get property names into message catalog??
142     #
143     # Note that this list also includes properties
144     # defined in the classic template:
145     # assignedto, keyword, priority, status.
146     (
147         ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
148         ''"assignedto", ''"keyword", ''"priority", ''"status",
149         # following properties are common for all hyperdb classes
150         # they are listed here to keep things in one place
151         ''"actor", ''"activity", ''"creator", ''"creation",
152     )
154     # New methods:
155     def addmessage(self, nodeid, summary, text):
156         """Add a message to an issue's mail spool.
158         A new "msg" node is constructed using the current date, the user that
159         owns the database connection as the author, and the specified summary
160         text.
162         The "files" and "recipients" fields are left empty.
164         The given text is saved as the body of the message and the node is
165         appended to the "messages" field of the specified issue.
166         """
168     def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
169             from_address=None, cc=[], bcc=[]):
170         """Send a message to the members of an issue's nosy list.
172         The message is sent only to users on the nosy list who are not
173         already on the "recipients" list for the message.
175         These users are then added to the message's "recipients" list.
177         If 'msgid' is None, the message gets sent only to the nosy
178         list, and it's called a 'System Message'.
180         The "cc" argument indicates additional recipients to send the
181         message to that may not be specified in the message's recipients
182         list.
184         The "bcc" argument also indicates additional recipients to send the
185         message to that may not be specified in the message's recipients
186         list. These recipients will not be included in the To: or Cc:
187         address lists.
188         """
189         if msgid:
190             authid = self.db.msg.get(msgid, 'author')
191             recipients = self.db.msg.get(msgid, 'recipients', [])
192         else:
193             # "system message"
194             authid = None
195             recipients = []
197         sendto = []
198         bcc_sendto = []
199         seen_message = {}
200         for recipient in recipients:
201             seen_message[recipient] = 1
203         def add_recipient(userid, to):
204             # make sure they have an address
205             address = self.db.user.get(userid, 'address')
206             if address:
207                 to.append(address)
208                 recipients.append(userid)
210         def good_recipient(userid):
211             # Make sure we don't send mail to either the anonymous
212             # user or a user who has already seen the message.
213             return (userid and
214                     (self.db.user.get(userid, 'username') != 'anonymous') and
215                     not seen_message.has_key(userid))
217         # possibly send the message to the author, as long as they aren't
218         # anonymous
219         if (good_recipient(authid) and
220             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
221              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
222             add_recipient(authid, sendto)
224         if authid:
225             seen_message[authid] = 1
227         # now deal with the nosy and cc people who weren't recipients.
228         for userid in cc + self.get(nodeid, whichnosy):
229             if good_recipient(userid):
230                 add_recipient(userid, sendto)
232         # now deal with bcc people.
233         for userid in bcc:
234             if good_recipient(userid):
235                 add_recipient(userid, bcc_sendto)
237         if oldvalues:
238             note = self.generateChangeNote(nodeid, oldvalues)
239         else:
240             note = self.generateCreateNote(nodeid)
242         # If we have new recipients, update the message's recipients
243         # and send the mail.
244         if sendto or bcc_sendto:
245             if msgid is not None:
246                 self.db.msg.set(msgid, recipients=recipients)
247             self.send_message(nodeid, msgid, note, sendto, from_address,
248                 bcc_sendto)
250     # backwards compatibility - don't remove
251     sendmessage = nosymessage
253     def send_message(self, nodeid, msgid, note, sendto, from_address=None,
254             bcc_sendto=[]):
255         '''Actually send the nominated message from this node to the sendto
256            recipients, with the note appended.
257         '''
258         users = self.db.user
259         messages = self.db.msg
260         files = self.db.file
262         if msgid is None:
263             inreplyto = None
264             messageid = None
265         else:
266             inreplyto = messages.get(msgid, 'inreplyto')
267             messageid = messages.get(msgid, 'messageid')
269         # make up a messageid if there isn't one (web edit)
270         if not messageid:
271             # this is an old message that didn't get a messageid, so
272             # create one
273             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
274                                            self.classname, nodeid,
275                                            self.db.config.MAIL_DOMAIN)
276             if msgid is not None:
277                 messages.set(msgid, messageid=messageid)
279         # compose title
280         cn = self.classname
281         title = self.get(nodeid, 'title') or '%s message copy'%cn
283         # figure author information
284         if msgid:
285             authid = messages.get(msgid, 'author')
286         else:
287             authid = self.db.getuid()
288         authname = users.get(authid, 'realname')
289         if not authname:
290             authname = users.get(authid, 'username', '')
291         authaddr = users.get(authid, 'address', '')
293         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
294             authaddr = " <%s>" % straddr( ('',authaddr) )
295         elif authaddr:
296             authaddr = ""
298         # make the message body
299         m = ['']
301         # put in roundup's signature
302         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
303             m.append(self.email_signature(nodeid, msgid))
305         # add author information
306         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
307             if msgid and len(self.get(nodeid, 'messages')) == 1:
308                 m.append(_("New submission from %(authname)s%(authaddr)s:")
309                     % locals())
310             elif msgid:
311                 m.append(_("%(authname)s%(authaddr)s added the comment:")
312                     % locals())
313             else:
314                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
315             m.append('')
317         # add the content
318         if msgid is not None:
319             m.append(messages.get(msgid, 'content', ''))
321         # get the files for this message
322         message_files = []
323         if msgid :
324             for fileid in messages.get(msgid, 'files') :
325                 # check the attachment size
326                 filename = self.db.filename('file', fileid, None)
327                 filesize = os.path.getsize(filename)
328                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
329                     message_files.append(fileid)
330                 else:
331                     base = self.db.config.TRACKER_WEB
332                     link = "".join((base, files.classname, fileid))
333                     filename = files.get(fileid, 'name')
334                     m.append(_("File '%(filename)s' not attached - "
335                         "you can download it from %(link)s.") % locals())
337         # add the change note
338         if note:
339             m.append(note)
341         # put in roundup's signature
342         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
343             m.append(self.email_signature(nodeid, msgid))
345         # encode the content as quoted-printable
346         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
347         m = '\n'.join(m)
348         if charset != 'utf-8':
349             m = unicode(m, 'utf-8').encode(charset)
350         content = cStringIO.StringIO(m)
351         content_encoded = cStringIO.StringIO()
352         quopri.encode(content, content_encoded, 0)
353         content_encoded = content_encoded.getvalue()
355         # make sure the To line is always the same (for testing mostly)
356         sendto.sort()
358         # make sure we have a from address
359         if from_address is None:
360             from_address = self.db.config.TRACKER_EMAIL
362         # additional bit for after the From: "name"
363         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
364         if from_tag:
365             from_tag = ' ' + from_tag
367         subject = '[%s%s] %s'%(cn, nodeid, title)
368         author = (authname + from_tag, from_address)
370         # send an individual message per recipient?
371         if self.db.config.NOSY_EMAIL_SENDING != 'single':
372             sendto = [[address] for address in sendto]
373         else:
374             sendto = [sendto]
376         # now send one or more messages
377         # TODO: I believe we have to create a new message each time as we
378         # can't fiddle the recipients in the message ... worth testing
379         # and/or fixing some day
380         first = True
381         for sendto in sendto:
382             # create the message
383             mailer = Mailer(self.db.config)
384             message, writer = mailer.get_standard_message(sendto, subject,
385                 author)
387             # set reply-to to the tracker
388             tracker_name = self.db.config.TRACKER_NAME
389             if charset != 'utf-8':
390                 tracker = unicode(tracker_name, 'utf-8').encode(charset)
391             tracker_name = encode_header(tracker_name, charset)
392             writer.addheader('Reply-To', straddr((tracker_name, from_address)))
394             # message ids
395             if messageid:
396                 writer.addheader('Message-Id', messageid)
397             if inreplyto:
398                 writer.addheader('In-Reply-To', inreplyto)
400             # Generate a header for each link or multilink to
401             # a class that has a name attribute
402             for propname, prop in self.getprops().items():
403                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
404                     continue
405                 cl = self.db.getclass(prop.classname)
406                 if not 'name' in cl.getprops():
407                     continue
408                 if isinstance(prop, hyperdb.Link):
409                     value = self.get(nodeid, propname)
410                     if value is None:
411                         continue
412                     values = [value]
413                 else:
414                     values = self.get(nodeid, propname)
415                     if not values:
416                         continue
417                 values = [cl.get(v, 'name') for v in values]
418                 values = ', '.join(values)
419                 writer.addheader("X-Roundup-%s-%s" % (self.classname, propname),
420                                  values)
421             if not inreplyto:
422                 # Default the reply to the first message
423                 msgs = self.get(nodeid, 'messages')
424                 # Assume messages are sorted by increasing message number here
425                 # If the issue is just being created, and the submitter didn't
426                 # provide a message, then msgs will be empty.
427                 if msgs and msgs[0] != nodeid:
428                     inreplyto = messages.get(msgs[0], 'messageid')
429                     if inreplyto:
430                         writer.addheader('In-Reply-To', inreplyto)
432             # attach files
433             if message_files:
434                 part = writer.startmultipartbody('mixed')
435                 part = writer.nextpart()
436                 part.addheader('Content-Transfer-Encoding', 'quoted-printable')
437                 body = part.startbody('text/plain; charset=%s'%charset)
438                 body.write(content_encoded)
439                 for fileid in message_files:
440                     name = files.get(fileid, 'name')
441                     mime_type = files.get(fileid, 'type')
442                     content = files.get(fileid, 'content')
443                     part = writer.nextpart()
444                     if mime_type == 'text/plain':
445                         part.addheader('Content-Disposition',
446                             'attachment;\n filename="%s"'%name)
447                         try:
448                             content.decode('ascii')
449                         except UnicodeError:
450                             # the content cannot be 7bit-encoded.
451                             # use quoted printable
452                             part.addheader('Content-Transfer-Encoding',
453                                 'quoted-printable')
454                             body = part.startbody('text/plain')
455                             body.write(quopri.encodestring(content))
456                         else:
457                             part.addheader('Content-Transfer-Encoding', '7bit')
458                             body = part.startbody('text/plain')
459                             body.write(content)
460                     else:
461                         # some other type, so encode it
462                         if not mime_type:
463                             # this should have been done when the file was saved
464                             mime_type = mimetypes.guess_type(name)[0]
465                         if mime_type is None:
466                             mime_type = 'application/octet-stream'
467                         part.addheader('Content-Disposition',
468                             'attachment;\n filename="%s"'%name)
469                         part.addheader('Content-Transfer-Encoding', 'base64')
470                         body = part.startbody(mime_type)
471                         body.write(base64.encodestring(content))
472                 writer.lastpart()
473             else:
474                 writer.addheader('Content-Transfer-Encoding',
475                     'quoted-printable')
476                 body = writer.startbody('text/plain; charset=%s'%charset)
477                 body.write(content_encoded)
479             if first:
480                 mailer.smtp_send(sendto + bcc_sendto, message)
481             else:
482                 mailer.smtp_send(sendto, message)
483             first = False
485     def email_signature(self, nodeid, msgid):
486         ''' Add a signature to the e-mail with some useful information
487         '''
488         # simplistic check to see if the url is valid,
489         # then append a trailing slash if it is missing
490         base = self.db.config.TRACKER_WEB
491         if (not isinstance(base , type('')) or
492             not (base.startswith('http://') or base.startswith('https://'))):
493             web = "Configuration Error: TRACKER_WEB isn't a " \
494                 "fully-qualified URL"
495         else:
496             if not base.endswith('/'):
497                 base = base + '/'
498             web = base + self.classname + nodeid
500         # ensure the email address is properly quoted
501         email = straddr((self.db.config.TRACKER_NAME,
502             self.db.config.TRACKER_EMAIL))
504         line = '_' * max(len(web)+2, len(email))
505         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
508     def generateCreateNote(self, nodeid):
509         """Generate a create note that lists initial property values
510         """
511         cn = self.classname
512         cl = self.db.classes[cn]
513         props = cl.getprops(protected=0)
515         # list the values
516         m = []
517         prop_items = props.items()
518         prop_items.sort()
519         for propname, prop in prop_items:
520             value = cl.get(nodeid, propname, None)
521             # skip boring entries
522             if not value:
523                 continue
524             if isinstance(prop, hyperdb.Link):
525                 link = self.db.classes[prop.classname]
526                 if value:
527                     key = link.labelprop(default_to_id=1)
528                     if key:
529                         value = link.get(value, key)
530                 else:
531                     value = ''
532             elif isinstance(prop, hyperdb.Multilink):
533                 if value is None: value = []
534                 l = []
535                 link = self.db.classes[prop.classname]
536                 key = link.labelprop(default_to_id=1)
537                 if key:
538                     value = [link.get(entry, key) for entry in value]
539                 value.sort()
540                 value = ', '.join(value)
541             else:
542                 value = str(value)
543                 if '\n' in value:
544                     value = '\n'+self.indentChangeNoteValue(value)
545             m.append('%s: %s'%(propname, value))
546         m.insert(0, '----------')
547         m.insert(0, '')
548         return '\n'.join(m)
550     def generateChangeNote(self, nodeid, oldvalues):
551         """Generate a change note that lists property changes
552         """
553         if not isinstance(oldvalues, type({})):
554             raise TypeError("'oldvalues' must be dict-like, not %s."%
555                 type(oldvalues))
557         cn = self.classname
558         cl = self.db.classes[cn]
559         changed = {}
560         props = cl.getprops(protected=0)
562         # determine what changed
563         for key in oldvalues.keys():
564             if key in ['files','messages']:
565                 continue
566             if key in ('actor', 'activity', 'creator', 'creation'):
567                 continue
568             # not all keys from oldvalues might be available in database
569             # this happens when property was deleted
570             try:
571                 new_value = cl.get(nodeid, key)
572             except KeyError:
573                 continue
574             # the old value might be non existent
575             # this happens when property was added
576             try:
577                 old_value = oldvalues[key]
578                 if type(new_value) is type([]):
579                     new_value.sort()
580                     old_value.sort()
581                 if new_value != old_value:
582                     changed[key] = old_value
583             except:
584                 changed[key] = new_value
586         # list the changes
587         m = []
588         changed_items = changed.items()
589         changed_items.sort()
590         for propname, oldvalue in changed_items:
591             prop = props[propname]
592             value = cl.get(nodeid, propname, None)
593             if isinstance(prop, hyperdb.Link):
594                 link = self.db.classes[prop.classname]
595                 key = link.labelprop(default_to_id=1)
596                 if key:
597                     if value:
598                         value = link.get(value, key)
599                     else:
600                         value = ''
601                     if oldvalue:
602                         oldvalue = link.get(oldvalue, key)
603                     else:
604                         oldvalue = ''
605                 change = '%s -> %s'%(oldvalue, value)
606             elif isinstance(prop, hyperdb.Multilink):
607                 change = ''
608                 if value is None: value = []
609                 if oldvalue is None: oldvalue = []
610                 l = []
611                 link = self.db.classes[prop.classname]
612                 key = link.labelprop(default_to_id=1)
613                 # check for additions
614                 for entry in value:
615                     if entry in oldvalue: continue
616                     if key:
617                         l.append(link.get(entry, key))
618                     else:
619                         l.append(entry)
620                 if l:
621                     l.sort()
622                     change = '+%s'%(', '.join(l))
623                     l = []
624                 # check for removals
625                 for entry in oldvalue:
626                     if entry in value: continue
627                     if key:
628                         l.append(link.get(entry, key))
629                     else:
630                         l.append(entry)
631                 if l:
632                     l.sort()
633                     change += ' -%s'%(', '.join(l))
634             else:
635                 change = '%s -> %s'%(oldvalue, value)
636                 if '\n' in change:
637                     value = self.indentChangeNoteValue(str(value))
638                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
639                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
640                         "new": value, "old": oldvalue}
641             m.append('%s: %s'%(propname, change))
642         if m:
643             m.insert(0, '----------')
644             m.insert(0, '')
645         return '\n'.join(m)
647     def indentChangeNoteValue(self, text):
648         lines = text.rstrip('\n').split('\n')
649         lines = [ '  '+line for line in lines ]
650         return '\n'.join(lines)
652 # vim: set filetype=python sts=4 sw=4 et si :