Code

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