Code

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