Code

91d855c9282bfae8df9fdd5f59ffa092d57b303f
[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
34 from anypy.email_ import FeedParser
36 from roundup import password, date, hyperdb
37 from roundup.i18n import _
39 # MessageSendError is imported for backwards compatibility
40 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
41     nice_sender_header
43 class Database:
45     # remember the journal uid for the current journaltag so that:
46     # a. we don't have to look it up every time we need it, and
47     # b. if the journaltag disappears during a transaction, we don't barf
48     #    (eg. the current user edits their username)
49     journal_uid = None
50     def getuid(self):
51         """Return the id of the "user" node associated with the user
52         that owns this connection to the hyperdatabase."""
53         if self.journaltag is None:
54             return None
55         elif self.journaltag == 'admin':
56             # admin user may not exist, but always has ID 1
57             return '1'
58         else:
59             if (self.journal_uid is None or self.journal_uid[0] !=
60                     self.journaltag):
61                 uid = self.user.lookup(self.journaltag)
62                 self.journal_uid = (self.journaltag, uid)
63             return self.journal_uid[1]
65     def setCurrentUser(self, username):
66         """Set the user that is responsible for current database
67         activities.
68         """
69         self.journaltag = username
71     def isCurrentUser(self, username):
72         """Check if a given username equals the already active user.
73         """
74         return self.journaltag == username
76     def getUserTimezone(self):
77         """Return user timezone defined in 'timezone' property of user class.
78         If no such property exists return 0
79         """
80         userid = self.getuid()
81         timezone = None
82         try:
83             tz = self.user.get(userid, 'timezone')
84             date.get_timezone(tz)
85             timezone = tz
86         except KeyError:
87             pass
88         # If there is no class 'user' or current user doesn't have timezone
89         # property or that property is not set assume he/she lives in
90         # the timezone set in the tracker config.
91         if timezone is None:
92             timezone = self.config['TIMEZONE']
93         return timezone
95     def confirm_registration(self, otk):
96         props = self.getOTKManager().getall(otk)
97         for propname, proptype in self.user.getprops().items():
98             value = props.get(propname, None)
99             if value is None:
100                 pass
101             elif isinstance(proptype, hyperdb.Date):
102                 props[propname] = date.Date(value)
103             elif isinstance(proptype, hyperdb.Interval):
104                 props[propname] = date.Interval(value)
105             elif isinstance(proptype, hyperdb.Password):
106                 props[propname] = password.Password(encrypted=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=[], cc_emails = [], bcc_emails = []):
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.
217         The cc_emails and bcc_emails arguments take a list of additional
218         recipient email addresses (just the mail address not roundup users)
219         this can be useful for sending to additional email addresses which are no
220         roundup users. These arguments are currently not used by roundups
221         nosyreaction but can be used by customized (nosy-)reactors.
222         """
223         if msgid:
224             authid = self.db.msg.get(msgid, 'author')
225             recipients = self.db.msg.get(msgid, 'recipients', [])
226         else:
227             # "system message"
228             authid = None
229             recipients = []
231         sendto = []
232         bcc_sendto = []
233         seen_message = {}
234         for recipient in recipients:
235             seen_message[recipient] = 1
237         def add_recipient(userid, to):
238             """ make sure they have an address """
239             address = self.db.user.get(userid, 'address')
240             if address:
241                 to.append(address)
242                 recipients.append(userid)
244         def good_recipient(userid):
245             """ Make sure we don't send mail to either the anonymous
246                 user or a user who has already seen the message.
247                 Also check permissions on the message if not a system
248                 message: A user must have view permission on content and
249                 files to be on the receiver list. We do *not* check the
250                 author etc. for now.
251             """
252             allowed = True
253             if msgid:
254                 for prop in 'content', 'files':
255                     if prop in self.db.msg.properties:
256                         allowed = allowed and self.db.security.hasPermission(
257                             'View', userid, 'msg', prop, msgid)
258             return (userid and
259                     (self.db.user.get(userid, 'username') != 'anonymous') and
260                     allowed and not seen_message.has_key(userid))
262         # possibly send the message to the author, as long as they aren't
263         # anonymous
264         if (good_recipient(authid) and
265             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
266              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
267             add_recipient(authid, sendto)
269         if authid:
270             seen_message[authid] = 1
272         # now deal with the nosy and cc people who weren't recipients.
273         for userid in cc + self.get(issueid, whichnosy):
274             if good_recipient(userid):
275                 add_recipient(userid, sendto)
276         sendto.extend (cc_emails)
278         # now deal with bcc people.
279         for userid in bcc:
280             if good_recipient(userid):
281                 add_recipient(userid, bcc_sendto)
282         bcc_sendto.extend (bcc_emails)
284         if oldvalues:
285             note = self.generateChangeNote(issueid, oldvalues)
286         else:
287             note = self.generateCreateNote(issueid)
289         # If we have new recipients, update the message's recipients
290         # and send the mail.
291         if sendto or bcc_sendto:
292             if msgid is not None:
293                 self.db.msg.set(msgid, recipients=recipients)
294             self.send_message(issueid, msgid, note, sendto, from_address,
295                 bcc_sendto)
297     # backwards compatibility - don't remove
298     sendmessage = nosymessage
300     def send_message(self, issueid, msgid, note, sendto, from_address=None,
301             bcc_sendto=[]):
302         '''Actually send the nominated message from this issue to the sendto
303            recipients, with the note appended.
304         '''
305         users = self.db.user
306         messages = self.db.msg
307         files = self.db.file
309         if msgid is None:
310             inreplyto = None
311             messageid = None
312         else:
313             inreplyto = messages.get(msgid, 'inreplyto')
314             messageid = messages.get(msgid, 'messageid')
316         # make up a messageid if there isn't one (web edit)
317         if not messageid:
318             # this is an old message that didn't get a messageid, so
319             # create one
320             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
321                                            self.classname, issueid,
322                                            self.db.config.MAIL_DOMAIN)
323             if msgid is not None:
324                 messages.set(msgid, messageid=messageid)
326         # compose title
327         cn = self.classname
328         title = self.get(issueid, 'title') or '%s message copy'%cn
330         # figure author information
331         if msgid:
332             authid = messages.get(msgid, 'author')
333         else:
334             authid = self.db.getuid()
335         authname = users.get(authid, 'realname')
336         if not authname:
337             authname = users.get(authid, 'username', '')
338         authaddr = users.get(authid, 'address', '')
340         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
341             authaddr = " <%s>" % formataddr( ('',authaddr) )
342         elif authaddr:
343             authaddr = ""
345         # make the message body
346         m = ['']
348         # put in roundup's signature
349         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
350             m.append(self.email_signature(issueid, msgid))
352         # add author information
353         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
354             if msgid and len(self.get(issueid, 'messages')) == 1:
355                 m.append(_("New submission from %(authname)s%(authaddr)s:")
356                     % locals())
357             elif msgid:
358                 m.append(_("%(authname)s%(authaddr)s added the comment:")
359                     % locals())
360             else:
361                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
362             m.append('')
364         # add the content
365         if msgid is not None:
366             m.append(messages.get(msgid, 'content', ''))
368         # get the files for this message
369         message_files = []
370         if msgid :
371             for fileid in messages.get(msgid, 'files') :
372                 # check the attachment size
373                 filesize = self.db.filesize('file', fileid, None)
374                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
375                     message_files.append(fileid)
376                 else:
377                     base = self.db.config.TRACKER_WEB
378                     link = "".join((base, files.classname, fileid))
379                     filename = files.get(fileid, 'name')
380                     m.append(_("File '%(filename)s' not attached - "
381                         "you can download it from %(link)s.") % locals())
383         # add the change note
384         if note:
385             m.append(note)
387         # put in roundup's signature
388         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
389             m.append(self.email_signature(issueid, msgid))
391         # figure the encoding
392         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
394         # construct the content and convert to unicode object
395         body = unicode('\n'.join(m), 'utf-8').encode(charset)
397         # make sure the To line is always the same (for testing mostly)
398         sendto.sort()
400         # make sure we have a from address
401         if from_address is None:
402             from_address = self.db.config.TRACKER_EMAIL
404         # additional bit for after the From: "name"
405         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
406         if from_tag:
407             from_tag = ' ' + from_tag
409         subject = '[%s%s] %s'%(cn, issueid, title)
410         author = (authname + from_tag, from_address)
412         # send an individual message per recipient?
413         if self.db.config.NOSY_EMAIL_SENDING != 'single':
414             sendto = [[address] for address in sendto]
415         else:
416             sendto = [sendto]
418         # tracker sender info
419         tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
420         tracker_name = nice_sender_header(tracker_name, from_address,
421             charset)
423         # now send one or more messages
424         # TODO: I believe we have to create a new message each time as we
425         # can't fiddle the recipients in the message ... worth testing
426         # and/or fixing some day
427         first = True
428         for sendto in sendto:
429             # create the message
430             mailer = Mailer(self.db.config)
432             message = mailer.get_standard_message(multipart=message_files)
433             mailer.set_message_attributes(message, sendto, subject, author)
435             # set reply-to to the tracker
436             message['Reply-To'] = tracker_name
438             # message ids
439             if messageid:
440                 message['Message-Id'] = messageid
441             if inreplyto:
442                 message['In-Reply-To'] = inreplyto
444             # Generate a header for each link or multilink to
445             # a class that has a name attribute
446             for propname, prop in self.getprops().items():
447                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
448                     continue
449                 cl = self.db.getclass(prop.classname)
450                 if not 'name' in cl.getprops():
451                     continue
452                 if isinstance(prop, hyperdb.Link):
453                     value = self.get(issueid, propname)
454                     if value is None:
455                         continue
456                     values = [value]
457                 else:
458                     values = self.get(issueid, propname)
459                     if not values:
460                         continue
461                 values = [cl.get(v, 'name') for v in values]
462                 values = ', '.join(values)
463                 header = "X-Roundup-%s-%s"%(self.classname, propname)
464                 try:
465                     message[header] = values.encode('ascii')
466                 except UnicodeError:
467                     message[header] = Header(values, charset)
469             if not inreplyto:
470                 # Default the reply to the first message
471                 msgs = self.get(issueid, 'messages')
472                 # Assume messages are sorted by increasing message number here
473                 # If the issue is just being created, and the submitter didn't
474                 # provide a message, then msgs will be empty.
475                 if msgs and msgs[0] != msgid:
476                     inreplyto = messages.get(msgs[0], 'messageid')
477                     if inreplyto:
478                         message['In-Reply-To'] = inreplyto
480             # attach files
481             if message_files:
482                 # first up the text as a part
483                 part = MIMEText(body)
484                 part.set_charset(charset)
485                 encode_quopri(part)
486                 message.attach(part)
488                 for fileid in message_files:
489                     name = files.get(fileid, 'name')
490                     mime_type = files.get(fileid, 'type')
491                     content = files.get(fileid, 'content')
492                     if mime_type == 'text/plain':
493                         try:
494                             content.decode('ascii')
495                         except UnicodeError:
496                             # the content cannot be 7bit-encoded.
497                             # use quoted printable
498                             # XXX stuffed if we know the charset though :(
499                             part = MIMEText(content)
500                             encode_quopri(part)
501                         else:
502                             part = MIMEText(content)
503                             part['Content-Transfer-Encoding'] = '7bit'
504                     elif mime_type == 'message/rfc822':
505                         main, sub = mime_type.split('/')
506                         p = FeedParser()
507                         p.feed(content)
508                         part = MIMEBase(main, sub)
509                         part.set_payload([p.close()])
510                     else:
511                         # some other type, so encode it
512                         if not mime_type:
513                             # this should have been done when the file was saved
514                             mime_type = mimetypes.guess_type(name)[0]
515                         if mime_type is None:
516                             mime_type = 'application/octet-stream'
517                         main, sub = mime_type.split('/')
518                         part = MIMEBase(main, sub)
519                         part.set_payload(content)
520                         Encoders.encode_base64(part)
521                     cd = 'Content-Disposition'
522                     part[cd] = 'attachment;\n filename="%s"'%name
523                     message.attach(part)
525             else:
526                 message.set_payload(body)
527                 encode_quopri(message)
529             if first:
530                 mailer.smtp_send(sendto + bcc_sendto, message.as_string())
531             else:
532                 mailer.smtp_send(sendto, message.as_string())
533             first = False
535     def email_signature(self, issueid, msgid):
536         ''' Add a signature to the e-mail with some useful information
537         '''
538         # simplistic check to see if the url is valid,
539         # then append a trailing slash if it is missing
540         base = self.db.config.TRACKER_WEB
541         if (not isinstance(base , type('')) or
542             not (base.startswith('http://') or base.startswith('https://'))):
543             web = "Configuration Error: TRACKER_WEB isn't a " \
544                 "fully-qualified URL"
545         else:
546             if not base.endswith('/'):
547                 base = base + '/'
548             web = base + self.classname + issueid
550         # ensure the email address is properly quoted
551         email = formataddr((self.db.config.TRACKER_NAME,
552             self.db.config.TRACKER_EMAIL))
554         line = '_' * max(len(web)+2, len(email))
555         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
558     def generateCreateNote(self, issueid):
559         """Generate a create note that lists initial property values
560         """
561         cn = self.classname
562         cl = self.db.classes[cn]
563         props = cl.getprops(protected=0)
565         # list the values
566         m = []
567         prop_items = props.items()
568         prop_items.sort()
569         for propname, prop in prop_items:
570             value = cl.get(issueid, propname, None)
571             # skip boring entries
572             if not value:
573                 continue
574             if isinstance(prop, hyperdb.Link):
575                 link = self.db.classes[prop.classname]
576                 if value:
577                     key = link.labelprop(default_to_id=1)
578                     if key:
579                         value = link.get(value, key)
580                 else:
581                     value = ''
582             elif isinstance(prop, hyperdb.Multilink):
583                 if value is None: value = []
584                 l = []
585                 link = self.db.classes[prop.classname]
586                 key = link.labelprop(default_to_id=1)
587                 if key:
588                     value = [link.get(entry, key) for entry in value]
589                 value.sort()
590                 value = ', '.join(value)
591             else:
592                 value = str(value)
593                 if '\n' in value:
594                     value = '\n'+self.indentChangeNoteValue(value)
595             m.append('%s: %s'%(propname, value))
596         m.insert(0, '----------')
597         m.insert(0, '')
598         return '\n'.join(m)
600     def generateChangeNote(self, issueid, oldvalues):
601         """Generate a change note that lists property changes
602         """
603         if not isinstance(oldvalues, type({})):
604             raise TypeError("'oldvalues' must be dict-like, not %s."%
605                 type(oldvalues))
607         cn = self.classname
608         cl = self.db.classes[cn]
609         changed = {}
610         props = cl.getprops(protected=0)
612         # determine what changed
613         for key in oldvalues.keys():
614             if key in ['files','messages']:
615                 continue
616             if key in ('actor', 'activity', 'creator', 'creation'):
617                 continue
618             # not all keys from oldvalues might be available in database
619             # this happens when property was deleted
620             try:
621                 new_value = cl.get(issueid, key)
622             except KeyError:
623                 continue
624             # the old value might be non existent
625             # this happens when property was added
626             try:
627                 old_value = oldvalues[key]
628                 if type(new_value) is type([]):
629                     new_value.sort()
630                     old_value.sort()
631                 if new_value != old_value:
632                     changed[key] = old_value
633             except:
634                 changed[key] = new_value
636         # list the changes
637         m = []
638         changed_items = changed.items()
639         changed_items.sort()
640         for propname, oldvalue in changed_items:
641             prop = props[propname]
642             value = cl.get(issueid, propname, None)
643             if isinstance(prop, hyperdb.Link):
644                 link = self.db.classes[prop.classname]
645                 key = link.labelprop(default_to_id=1)
646                 if key:
647                     if value:
648                         value = link.get(value, key)
649                     else:
650                         value = ''
651                     if oldvalue:
652                         oldvalue = link.get(oldvalue, key)
653                     else:
654                         oldvalue = ''
655                 change = '%s -> %s'%(oldvalue, value)
656             elif isinstance(prop, hyperdb.Multilink):
657                 change = ''
658                 if value is None: value = []
659                 if oldvalue is None: oldvalue = []
660                 l = []
661                 link = self.db.classes[prop.classname]
662                 key = link.labelprop(default_to_id=1)
663                 # check for additions
664                 for entry in value:
665                     if entry in oldvalue: continue
666                     if key:
667                         l.append(link.get(entry, key))
668                     else:
669                         l.append(entry)
670                 if l:
671                     l.sort()
672                     change = '+%s'%(', '.join(l))
673                     l = []
674                 # check for removals
675                 for entry in oldvalue:
676                     if entry in value: continue
677                     if key:
678                         l.append(link.get(entry, key))
679                     else:
680                         l.append(entry)
681                 if l:
682                     l.sort()
683                     change += ' -%s'%(', '.join(l))
684             else:
685                 change = '%s -> %s'%(oldvalue, value)
686                 if '\n' in change:
687                     value = self.indentChangeNoteValue(str(value))
688                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
689                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
690                         "new": value, "old": oldvalue}
691             m.append('%s: %s'%(propname, change))
692         if m:
693             m.insert(0, '----------')
694             m.insert(0, '')
695         return '\n'.join(m)
697     def indentChangeNoteValue(self, text):
698         lines = text.rstrip('\n').split('\n')
699         lines = [ '  '+line for line in lines ]
700         return '\n'.join(lines)
702 # vim: set filetype=python sts=4 sw=4 et si :