Code

- new mailgw config item unpack_rfc822 that unpacks message attachments
[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 #
20 """Extending hyperdb with types specific to issue-tracking.
21 """
22 __docformat__ = 'restructuredtext'
24 import re, os, smtplib, socket, time, random
25 import cStringIO, base64, mimetypes
26 import os.path
27 import logging
28 from email import Encoders
29 from email.Utils import formataddr
30 from email.Header import Header
31 from email.MIMEText import MIMEText
32 from email.MIMEBase import MIMEBase
33 from email.parser   import FeedParser
35 from roundup import password, date, hyperdb
36 from roundup.i18n import _
38 # MessageSendError is imported for backwards compatibility
39 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
40     nice_sender_header
42 class Database:
44     # remember the journal uid for the current journaltag so that:
45     # a. we don't have to look it up every time we need it, and
46     # b. if the journaltag disappears during a transaction, we don't barf
47     #    (eg. the current user edits their username)
48     journal_uid = None
49     def getuid(self):
50         """Return the id of the "user" node associated with the user
51         that owns this connection to the hyperdatabase."""
52         if self.journaltag is None:
53             return None
54         elif self.journaltag == 'admin':
55             # admin user may not exist, but always has ID 1
56             return '1'
57         else:
58             if (self.journal_uid is None or self.journal_uid[0] !=
59                     self.journaltag):
60                 uid = self.user.lookup(self.journaltag)
61                 self.journal_uid = (self.journaltag, uid)
62             return self.journal_uid[1]
64     def setCurrentUser(self, username):
65         """Set the user that is responsible for current database
66         activities.
67         """
68         self.journaltag = username
70     def isCurrentUser(self, username):
71         """Check if a given username equals the already active user.
72         """
73         return self.journaltag == username
75     def getUserTimezone(self):
76         """Return user timezone defined in 'timezone' property of user class.
77         If no such property exists return 0
78         """
79         userid = self.getuid()
80         timezone = None
81         try:
82             tz = self.user.get(userid, 'timezone')
83             date.get_timezone(tz)
84             timezone = tz
85         except KeyError:
86             pass
87         # If there is no class 'user' or current user doesn't have timezone
88         # property or that property is not set assume he/she lives in
89         # the timezone set in the tracker config.
90         if timezone is None:
91             timezone = self.config['TIMEZONE']
92         return timezone
94     def confirm_registration(self, otk):
95         props = self.getOTKManager().getall(otk)
96         for propname, proptype in self.user.getprops().items():
97             value = props.get(propname, None)
98             if value is None:
99                 pass
100             elif isinstance(proptype, hyperdb.Date):
101                 props[propname] = date.Date(value)
102             elif isinstance(proptype, hyperdb.Interval):
103                 props[propname] = date.Interval(value)
104             elif isinstance(proptype, hyperdb.Password):
105                 props[propname] = password.Password()
106                 props[propname].unpack(value)
108         # tag new user creation with 'admin'
109         self.journaltag = 'admin'
111         # create the new user
112         cl = self.user
114         props['roles'] = self.config.NEW_WEB_USER_ROLES
115         userid = cl.create(**props)
116         # clear the props from the otk database
117         self.getOTKManager().destroy(otk)
118         self.commit()
120         return userid
123     def log_debug(self, msg, *args, **kwargs):
124         """Log a message with level DEBUG."""
126         logger = self.get_logger()
127         logger.debug(msg, *args, **kwargs)
129     def log_info(self, msg, *args, **kwargs):
130         """Log a message with level INFO."""
132         logger = self.get_logger()
133         logger.info(msg, *args, **kwargs)
135     def get_logger(self):
136         """Return the logger for this database."""
138         # Because getting a logger requires acquiring a lock, we want
139         # to do it only once.
140         if not hasattr(self, '__logger'):
141             self.__logger = logging.getLogger('roundup.hyperdb')
143         return self.__logger
146 class DetectorError(RuntimeError):
147     """ Raised by detectors that want to indicate that something's amiss
148     """
149     pass
151 # deviation from spec - was called IssueClass
152 class IssueClass:
153     """This class is intended to be mixed-in with a hyperdb backend
154     implementation. The backend should provide a mechanism that
155     enforces the title, messages, files, nosy and superseder
156     properties:
158     - title = hyperdb.String(indexme='yes')
159     - messages = hyperdb.Multilink("msg")
160     - files = hyperdb.Multilink("file")
161     - nosy = hyperdb.Multilink("user")
162     - superseder = hyperdb.Multilink(classname)
163     """
165     # The tuple below does not affect the class definition.
166     # It just lists all names of all issue properties
167     # marked for message extraction tool.
168     #
169     # XXX is there better way to get property names into message catalog??
170     #
171     # Note that this list also includes properties
172     # defined in the classic template:
173     # assignedto, keyword, priority, status.
174     (
175         ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
176         ''"assignedto", ''"keyword", ''"priority", ''"status",
177         # following properties are common for all hyperdb classes
178         # they are listed here to keep things in one place
179         ''"actor", ''"activity", ''"creator", ''"creation",
180     )
182     # New methods:
183     def addmessage(self, issueid, summary, text):
184         """Add a message to an issue's mail spool.
186         A new "msg" node is constructed using the current date, the user that
187         owns the database connection as the author, and the specified summary
188         text.
190         The "files" and "recipients" fields are left empty.
192         The given text is saved as the body of the message and the node is
193         appended to the "messages" field of the specified issue.
194         """
196     def nosymessage(self, issueid, msgid, oldvalues, whichnosy='nosy',
197             from_address=None, cc=[], bcc=[]):
198         """Send a message to the members of an issue's nosy list.
200         The message is sent only to users on the nosy list who are not
201         already on the "recipients" list for the message.
203         These users are then added to the message's "recipients" list.
205         If 'msgid' is None, the message gets sent only to the nosy
206         list, and it's called a 'System Message'.
208         The "cc" argument indicates additional recipients to send the
209         message to that may not be specified in the message's recipients
210         list.
212         The "bcc" argument also indicates additional recipients to send the
213         message to that may not be specified in the message's recipients
214         list. These recipients will not be included in the To: or Cc:
215         address lists.
216         """
217         if msgid:
218             authid = self.db.msg.get(msgid, 'author')
219             recipients = self.db.msg.get(msgid, 'recipients', [])
220         else:
221             # "system message"
222             authid = None
223             recipients = []
225         sendto = []
226         bcc_sendto = []
227         seen_message = {}
228         for recipient in recipients:
229             seen_message[recipient] = 1
231         def add_recipient(userid, to):
232             """ make sure they have an address """
233             address = self.db.user.get(userid, 'address')
234             if address:
235                 to.append(address)
236                 recipients.append(userid)
238         def good_recipient(userid):
239             """ Make sure we don't send mail to either the anonymous
240                 user or a user who has already seen the message.
241                 Also check permissions on the message if not a system
242                 message: A user must have view permission on content and
243                 files to be on the receiver list. We do *not* check the 
244                 author etc. for now.
245             """
246             allowed = True
247             if msgid:
248                 for prop in 'content', 'files':
249                     if prop in self.db.msg.properties:
250                         allowed = allowed and self.db.security.hasPermission(
251                             'View', userid, 'msg', prop, msgid)
252             return (userid and
253                     (self.db.user.get(userid, 'username') != 'anonymous') and
254                     allowed and not seen_message.has_key(userid))
256         # possibly send the message to the author, as long as they aren't
257         # anonymous
258         if (good_recipient(authid) and
259             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
260              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
261             add_recipient(authid, sendto)
263         if authid:
264             seen_message[authid] = 1
266         # now deal with the nosy and cc people who weren't recipients.
267         for userid in cc + self.get(issueid, whichnosy):
268             if good_recipient(userid):
269                 add_recipient(userid, sendto)
271         # now deal with bcc people.
272         for userid in bcc:
273             if good_recipient(userid):
274                 add_recipient(userid, bcc_sendto)
276         if oldvalues:
277             note = self.generateChangeNote(issueid, oldvalues)
278         else:
279             note = self.generateCreateNote(issueid)
281         # If we have new recipients, update the message's recipients
282         # and send the mail.
283         if sendto or bcc_sendto:
284             if msgid is not None:
285                 self.db.msg.set(msgid, recipients=recipients)
286             self.send_message(issueid, msgid, note, sendto, from_address,
287                 bcc_sendto)
289     # backwards compatibility - don't remove
290     sendmessage = nosymessage
292     def send_message(self, issueid, msgid, note, sendto, from_address=None,
293             bcc_sendto=[]):
294         '''Actually send the nominated message from this issue to the sendto
295            recipients, with the note appended.
296         '''
297         users = self.db.user
298         messages = self.db.msg
299         files = self.db.file
301         if msgid is None:
302             inreplyto = None
303             messageid = None
304         else:
305             inreplyto = messages.get(msgid, 'inreplyto')
306             messageid = messages.get(msgid, 'messageid')
308         # make up a messageid if there isn't one (web edit)
309         if not messageid:
310             # this is an old message that didn't get a messageid, so
311             # create one
312             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
313                                            self.classname, issueid,
314                                            self.db.config.MAIL_DOMAIN)
315             if msgid is not None:
316                 messages.set(msgid, messageid=messageid)
318         # compose title
319         cn = self.classname
320         title = self.get(issueid, 'title') or '%s message copy'%cn
322         # figure author information
323         if msgid:
324             authid = messages.get(msgid, 'author')
325         else:
326             authid = self.db.getuid()
327         authname = users.get(authid, 'realname')
328         if not authname:
329             authname = users.get(authid, 'username', '')
330         authaddr = users.get(authid, 'address', '')
332         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
333             authaddr = " <%s>" % formataddr( ('',authaddr) )
334         elif authaddr:
335             authaddr = ""
337         # make the message body
338         m = ['']
340         # put in roundup's signature
341         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
342             m.append(self.email_signature(issueid, msgid))
344         # add author information
345         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
346             if msgid and len(self.get(issueid, 'messages')) == 1:
347                 m.append(_("New submission from %(authname)s%(authaddr)s:")
348                     % locals())
349             elif msgid:
350                 m.append(_("%(authname)s%(authaddr)s added the comment:")
351                     % locals())
352             else:
353                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
354             m.append('')
356         # add the content
357         if msgid is not None:
358             m.append(messages.get(msgid, 'content', ''))
360         # get the files for this message
361         message_files = []
362         if msgid :
363             for fileid in messages.get(msgid, 'files') :
364                 # check the attachment size
365                 filesize = self.db.filesize('file', fileid, None)
366                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
367                     message_files.append(fileid)
368                 else:
369                     base = self.db.config.TRACKER_WEB
370                     link = "".join((base, files.classname, fileid))
371                     filename = files.get(fileid, 'name')
372                     m.append(_("File '%(filename)s' not attached - "
373                         "you can download it from %(link)s.") % locals())
375         # add the change note
376         if note:
377             m.append(note)
379         # put in roundup's signature
380         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
381             m.append(self.email_signature(issueid, msgid))
383         # figure the encoding
384         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
386         # construct the content and convert to unicode object
387         body = unicode('\n'.join(m), 'utf-8').encode(charset)
389         # make sure the To line is always the same (for testing mostly)
390         sendto.sort()
392         # make sure we have a from address
393         if from_address is None:
394             from_address = self.db.config.TRACKER_EMAIL
396         # additional bit for after the From: "name"
397         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
398         if from_tag:
399             from_tag = ' ' + from_tag
401         subject = '[%s%s] %s'%(cn, issueid, title)
402         author = (authname + from_tag, from_address)
404         # send an individual message per recipient?
405         if self.db.config.NOSY_EMAIL_SENDING != 'single':
406             sendto = [[address] for address in sendto]
407         else:
408             sendto = [sendto]
410         # tracker sender info
411         tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
412         tracker_name = nice_sender_header(tracker_name, from_address,
413             charset)
415         # now send one or more messages
416         # TODO: I believe we have to create a new message each time as we
417         # can't fiddle the recipients in the message ... worth testing
418         # and/or fixing some day
419         first = True
420         for sendto in sendto:
421             # create the message
422             mailer = Mailer(self.db.config)
424             message = mailer.get_standard_message(sendto, subject, author,
425                 multipart=message_files)
427             # set reply-to to the tracker
428             message['Reply-To'] = tracker_name
430             # message ids
431             if messageid:
432                 message['Message-Id'] = messageid
433             if inreplyto:
434                 message['In-Reply-To'] = inreplyto
436             # Generate a header for each link or multilink to
437             # a class that has a name attribute
438             for propname, prop in self.getprops().items():
439                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
440                     continue
441                 cl = self.db.getclass(prop.classname)
442                 if not 'name' in cl.getprops():
443                     continue
444                 if isinstance(prop, hyperdb.Link):
445                     value = self.get(issueid, propname)
446                     if value is None:
447                         continue
448                     values = [value]
449                 else:
450                     values = self.get(issueid, propname)
451                     if not values:
452                         continue
453                 values = [cl.get(v, 'name') for v in values]
454                 values = ', '.join(values)
455                 header = "X-Roundup-%s-%s"%(self.classname, propname)
456                 try:
457                     message[header] = values.encode('ascii')
458                 except UnicodeError:
459                     message[header] = Header(values, charset)
461             if not inreplyto:
462                 # Default the reply to the first message
463                 msgs = self.get(issueid, 'messages')
464                 # Assume messages are sorted by increasing message number here
465                 # If the issue is just being created, and the submitter didn't
466                 # provide a message, then msgs will be empty.
467                 if msgs and msgs[0] != msgid:
468                     inreplyto = messages.get(msgs[0], 'messageid')
469                     if inreplyto:
470                         message['In-Reply-To'] = inreplyto
472             # attach files
473             if message_files:
474                 # first up the text as a part
475                 part = MIMEText(body)
476                 part.set_charset(charset)
477                 encode_quopri(part)
478                 message.attach(part)
480                 for fileid in message_files:
481                     name = files.get(fileid, 'name')
482                     mime_type = files.get(fileid, 'type')
483                     content = files.get(fileid, 'content')
484                     if mime_type == 'text/plain':
485                         try:
486                             content.decode('ascii')
487                         except UnicodeError:
488                             # the content cannot be 7bit-encoded.
489                             # use quoted printable
490                             # XXX stuffed if we know the charset though :(
491                             part = MIMEText(content)
492                             encode_quopri(part)
493                         else:
494                             part = MIMEText(content)
495                             part['Content-Transfer-Encoding'] = '7bit'
496                     elif mime_type == 'message/rfc822':
497                         main, sub = mime_type.split('/')
498                         p = FeedParser()
499                         p.feed(content)
500                         part = MIMEBase(main, sub)
501                         part.set_payload([p.close()])
502                     else:
503                         # some other type, so encode it
504                         if not mime_type:
505                             # this should have been done when the file was saved
506                             mime_type = mimetypes.guess_type(name)[0]
507                         if mime_type is None:
508                             mime_type = 'application/octet-stream'
509                         main, sub = mime_type.split('/')
510                         part = MIMEBase(main, sub)
511                         part.set_payload(content)
512                         Encoders.encode_base64(part)
513                     cd = 'Content-Disposition'
514                     part[cd] = 'attachment;\n filename="%s"'%name
515                     message.attach(part)
517             else:
518                 message.set_payload(body)
519                 encode_quopri(message)
521             if first:
522                 mailer.smtp_send(sendto + bcc_sendto, message.as_string())
523             else:
524                 mailer.smtp_send(sendto, message.as_string())
525             first = False
527     def email_signature(self, issueid, msgid):
528         ''' Add a signature to the e-mail with some useful information
529         '''
530         # simplistic check to see if the url is valid,
531         # then append a trailing slash if it is missing
532         base = self.db.config.TRACKER_WEB
533         if (not isinstance(base , type('')) or
534             not (base.startswith('http://') or base.startswith('https://'))):
535             web = "Configuration Error: TRACKER_WEB isn't a " \
536                 "fully-qualified URL"
537         else:
538             if not base.endswith('/'):
539                 base = base + '/'
540             web = base + self.classname + issueid
542         # ensure the email address is properly quoted
543         email = formataddr((self.db.config.TRACKER_NAME,
544             self.db.config.TRACKER_EMAIL))
546         line = '_' * max(len(web)+2, len(email))
547         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
550     def generateCreateNote(self, issueid):
551         """Generate a create note that lists initial property values
552         """
553         cn = self.classname
554         cl = self.db.classes[cn]
555         props = cl.getprops(protected=0)
557         # list the values
558         m = []
559         prop_items = props.items()
560         prop_items.sort()
561         for propname, prop in prop_items:
562             value = cl.get(issueid, propname, None)
563             # skip boring entries
564             if not value:
565                 continue
566             if isinstance(prop, hyperdb.Link):
567                 link = self.db.classes[prop.classname]
568                 if value:
569                     key = link.labelprop(default_to_id=1)
570                     if key:
571                         value = link.get(value, key)
572                 else:
573                     value = ''
574             elif isinstance(prop, hyperdb.Multilink):
575                 if value is None: value = []
576                 l = []
577                 link = self.db.classes[prop.classname]
578                 key = link.labelprop(default_to_id=1)
579                 if key:
580                     value = [link.get(entry, key) for entry in value]
581                 value.sort()
582                 value = ', '.join(value)
583             else:
584                 value = str(value)
585                 if '\n' in value:
586                     value = '\n'+self.indentChangeNoteValue(value)
587             m.append('%s: %s'%(propname, value))
588         m.insert(0, '----------')
589         m.insert(0, '')
590         return '\n'.join(m)
592     def generateChangeNote(self, issueid, oldvalues):
593         """Generate a change note that lists property changes
594         """
595         if not isinstance(oldvalues, type({})):
596             raise TypeError("'oldvalues' must be dict-like, not %s."%
597                 type(oldvalues))
599         cn = self.classname
600         cl = self.db.classes[cn]
601         changed = {}
602         props = cl.getprops(protected=0)
604         # determine what changed
605         for key in oldvalues.keys():
606             if key in ['files','messages']:
607                 continue
608             if key in ('actor', 'activity', 'creator', 'creation'):
609                 continue
610             # not all keys from oldvalues might be available in database
611             # this happens when property was deleted
612             try:
613                 new_value = cl.get(issueid, key)
614             except KeyError:
615                 continue
616             # the old value might be non existent
617             # this happens when property was added
618             try:
619                 old_value = oldvalues[key]
620                 if type(new_value) is type([]):
621                     new_value.sort()
622                     old_value.sort()
623                 if new_value != old_value:
624                     changed[key] = old_value
625             except:
626                 changed[key] = new_value
628         # list the changes
629         m = []
630         changed_items = changed.items()
631         changed_items.sort()
632         for propname, oldvalue in changed_items:
633             prop = props[propname]
634             value = cl.get(issueid, propname, None)
635             if isinstance(prop, hyperdb.Link):
636                 link = self.db.classes[prop.classname]
637                 key = link.labelprop(default_to_id=1)
638                 if key:
639                     if value:
640                         value = link.get(value, key)
641                     else:
642                         value = ''
643                     if oldvalue:
644                         oldvalue = link.get(oldvalue, key)
645                     else:
646                         oldvalue = ''
647                 change = '%s -> %s'%(oldvalue, value)
648             elif isinstance(prop, hyperdb.Multilink):
649                 change = ''
650                 if value is None: value = []
651                 if oldvalue is None: oldvalue = []
652                 l = []
653                 link = self.db.classes[prop.classname]
654                 key = link.labelprop(default_to_id=1)
655                 # check for additions
656                 for entry in value:
657                     if entry in oldvalue: continue
658                     if key:
659                         l.append(link.get(entry, key))
660                     else:
661                         l.append(entry)
662                 if l:
663                     l.sort()
664                     change = '+%s'%(', '.join(l))
665                     l = []
666                 # check for removals
667                 for entry in oldvalue:
668                     if entry in value: continue
669                     if key:
670                         l.append(link.get(entry, key))
671                     else:
672                         l.append(entry)
673                 if l:
674                     l.sort()
675                     change += ' -%s'%(', '.join(l))
676             else:
677                 change = '%s -> %s'%(oldvalue, value)
678                 if '\n' in change:
679                     value = self.indentChangeNoteValue(str(value))
680                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
681                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
682                         "new": value, "old": oldvalue}
683             m.append('%s: %s'%(propname, change))
684         if m:
685             m.insert(0, '----------')
686             m.insert(0, '')
687         return '\n'.join(m)
689     def indentChangeNoteValue(self, text):
690         lines = text.rstrip('\n').split('\n')
691         lines = [ '  '+line for line in lines ]
692         return '\n'.join(lines)
694 # vim: set filetype=python sts=4 sw=4 et si :