Code

d3576b1732e19e56febb738db917800dec5ef269
[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 roundup import password, date, hyperdb
35 from roundup.i18n import _
37 # MessageSendError is imported for backwards compatibility
38 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
39     nice_sender_header
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('roundup.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, issueid, 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, issueid, 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                 Also check permissions on the message if not a system
241                 message: A user must have view permission on content and
242                 files to be on the receiver list. We do *not* check the 
243                 author etc. for now.
244             """
245             allowed = True
246             if msgid:
247                 for prop in 'content', 'files':
248                     if prop in self.db.msg.properties:
249                         allowed = allowed and self.db.security.hasPermission(
250                             'View', userid, 'msg', prop, msgid)
251             return (userid and
252                     (self.db.user.get(userid, 'username') != 'anonymous') and
253                     allowed and not seen_message.has_key(userid))
255         # possibly send the message to the author, as long as they aren't
256         # anonymous
257         if (good_recipient(authid) and
258             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
259              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
260             add_recipient(authid, sendto)
262         if authid:
263             seen_message[authid] = 1
265         # now deal with the nosy and cc people who weren't recipients.
266         for userid in cc + self.get(issueid, whichnosy):
267             if good_recipient(userid):
268                 add_recipient(userid, sendto)
270         # now deal with bcc people.
271         for userid in bcc:
272             if good_recipient(userid):
273                 add_recipient(userid, bcc_sendto)
275         if oldvalues:
276             note = self.generateChangeNote(issueid, oldvalues)
277         else:
278             note = self.generateCreateNote(issueid)
280         # If we have new recipients, update the message's recipients
281         # and send the mail.
282         if sendto or bcc_sendto:
283             if msgid is not None:
284                 self.db.msg.set(msgid, recipients=recipients)
285             self.send_message(issueid, msgid, note, sendto, from_address,
286                 bcc_sendto)
288     # backwards compatibility - don't remove
289     sendmessage = nosymessage
291     def send_message(self, issueid, msgid, note, sendto, from_address=None,
292             bcc_sendto=[]):
293         '''Actually send the nominated message from this issue to the sendto
294            recipients, with the note appended.
295         '''
296         users = self.db.user
297         messages = self.db.msg
298         files = self.db.file
300         if msgid is None:
301             inreplyto = None
302             messageid = None
303         else:
304             inreplyto = messages.get(msgid, 'inreplyto')
305             messageid = messages.get(msgid, 'messageid')
307         # make up a messageid if there isn't one (web edit)
308         if not messageid:
309             # this is an old message that didn't get a messageid, so
310             # create one
311             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
312                                            self.classname, issueid,
313                                            self.db.config.MAIL_DOMAIN)
314             if msgid is not None:
315                 messages.set(msgid, messageid=messageid)
317         # compose title
318         cn = self.classname
319         title = self.get(issueid, 'title') or '%s message copy'%cn
321         # figure author information
322         if msgid:
323             authid = messages.get(msgid, 'author')
324         else:
325             authid = self.db.getuid()
326         authname = users.get(authid, 'realname')
327         if not authname:
328             authname = users.get(authid, 'username', '')
329         authaddr = users.get(authid, 'address', '')
331         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
332             authaddr = " <%s>" % formataddr( ('',authaddr) )
333         elif authaddr:
334             authaddr = ""
336         # make the message body
337         m = ['']
339         # put in roundup's signature
340         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
341             m.append(self.email_signature(issueid, msgid))
343         # add author information
344         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
345             if msgid and len(self.get(issueid, 'messages')) == 1:
346                 m.append(_("New submission from %(authname)s%(authaddr)s:")
347                     % locals())
348             elif msgid:
349                 m.append(_("%(authname)s%(authaddr)s added the comment:")
350                     % locals())
351             else:
352                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
353             m.append('')
355         # add the content
356         if msgid is not None:
357             m.append(messages.get(msgid, 'content', ''))
359         # get the files for this message
360         message_files = []
361         if msgid :
362             for fileid in messages.get(msgid, 'files') :
363                 # check the attachment size
364                 filesize = self.db.filesize('file', fileid, None)
365                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
366                     message_files.append(fileid)
367                 else:
368                     base = self.db.config.TRACKER_WEB
369                     link = "".join((base, files.classname, fileid))
370                     filename = files.get(fileid, 'name')
371                     m.append(_("File '%(filename)s' not attached - "
372                         "you can download it from %(link)s.") % locals())
374         # add the change note
375         if note:
376             m.append(note)
378         # put in roundup's signature
379         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
380             m.append(self.email_signature(issueid, msgid))
382         # figure the encoding
383         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
385         # construct the content and convert to unicode object
386         body = unicode('\n'.join(m), 'utf-8').encode(charset)
388         # make sure the To line is always the same (for testing mostly)
389         sendto.sort()
391         # make sure we have a from address
392         if from_address is None:
393             from_address = self.db.config.TRACKER_EMAIL
395         # additional bit for after the From: "name"
396         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
397         if from_tag:
398             from_tag = ' ' + from_tag
400         subject = '[%s%s] %s'%(cn, issueid, title)
401         author = (authname + from_tag, from_address)
403         # send an individual message per recipient?
404         if self.db.config.NOSY_EMAIL_SENDING != 'single':
405             sendto = [[address] for address in sendto]
406         else:
407             sendto = [sendto]
409         # tracker sender info
410         tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
411         tracker_name = nice_sender_header(tracker_name, from_address,
412             charset)
414         # now send one or more messages
415         # TODO: I believe we have to create a new message each time as we
416         # can't fiddle the recipients in the message ... worth testing
417         # and/or fixing some day
418         first = True
419         for sendto in sendto:
420             # create the message
421             mailer = Mailer(self.db.config)
423             message = mailer.get_standard_message(sendto, subject, author,
424                 multipart=message_files)
426             # set reply-to to the tracker
427             message['Reply-To'] = tracker_name
429             # message ids
430             if messageid:
431                 message['Message-Id'] = messageid
432             if inreplyto:
433                 message['In-Reply-To'] = inreplyto
435             # Generate a header for each link or multilink to
436             # a class that has a name attribute
437             for propname, prop in self.getprops().items():
438                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
439                     continue
440                 cl = self.db.getclass(prop.classname)
441                 if not 'name' in cl.getprops():
442                     continue
443                 if isinstance(prop, hyperdb.Link):
444                     value = self.get(issueid, propname)
445                     if value is None:
446                         continue
447                     values = [value]
448                 else:
449                     values = self.get(issueid, propname)
450                     if not values:
451                         continue
452                 values = [cl.get(v, 'name') for v in values]
453                 values = ', '.join(values)
454                 header = "X-Roundup-%s-%s"%(self.classname, propname)
455                 try:
456                     message[header] = values.encode('ascii')
457                 except UnicodeError:
458                     message[header] = Header(values, charset)
460             if not inreplyto:
461                 # Default the reply to the first message
462                 msgs = self.get(issueid, 'messages')
463                 # Assume messages are sorted by increasing message number here
464                 # If the issue is just being created, and the submitter didn't
465                 # provide a message, then msgs will be empty.
466                 if msgs and msgs[0] != msgid:
467                     inreplyto = messages.get(msgs[0], 'messageid')
468                     if inreplyto:
469                         message['In-Reply-To'] = inreplyto
471             # attach files
472             if message_files:
473                 # first up the text as a part
474                 part = MIMEText(body)
475                 part.set_charset(charset)
476                 encode_quopri(part)
477                 message.attach(part)
479                 for fileid in message_files:
480                     name = files.get(fileid, 'name')
481                     mime_type = files.get(fileid, 'type')
482                     content = files.get(fileid, 'content')
483                     if mime_type == 'text/plain':
484                         try:
485                             content.decode('ascii')
486                         except UnicodeError:
487                             # the content cannot be 7bit-encoded.
488                             # use quoted printable
489                             # XXX stuffed if we know the charset though :(
490                             part = MIMEText(content)
491                             encode_quopri(part)
492                         else:
493                             part = MIMEText(content)
494                             part['Content-Transfer-Encoding'] = '7bit'
495                     else:
496                         # some other type, so encode it
497                         if not mime_type:
498                             # this should have been done when the file was saved
499                             mime_type = mimetypes.guess_type(name)[0]
500                         if mime_type is None:
501                             mime_type = 'application/octet-stream'
502                         main, sub = mime_type.split('/')
503                         part = MIMEBase(main, sub)
504                         part.set_payload(content)
505                         Encoders.encode_base64(part)
506                     part['Content-Disposition'] = 'attachment;\n filename="%s"'%name
507                     message.attach(part)
509             else:
510                 message.set_payload(body)
511                 encode_quopri(message)
513             if first:
514                 mailer.smtp_send(sendto + bcc_sendto, message.as_string())
515             else:
516                 mailer.smtp_send(sendto, message.as_string())
517             first = False
519     def email_signature(self, issueid, msgid):
520         ''' Add a signature to the e-mail with some useful information
521         '''
522         # simplistic check to see if the url is valid,
523         # then append a trailing slash if it is missing
524         base = self.db.config.TRACKER_WEB
525         if (not isinstance(base , type('')) or
526             not (base.startswith('http://') or base.startswith('https://'))):
527             web = "Configuration Error: TRACKER_WEB isn't a " \
528                 "fully-qualified URL"
529         else:
530             if not base.endswith('/'):
531                 base = base + '/'
532             web = base + self.classname + issueid
534         # ensure the email address is properly quoted
535         email = formataddr((self.db.config.TRACKER_NAME,
536             self.db.config.TRACKER_EMAIL))
538         line = '_' * max(len(web)+2, len(email))
539         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
542     def generateCreateNote(self, issueid):
543         """Generate a create note that lists initial property values
544         """
545         cn = self.classname
546         cl = self.db.classes[cn]
547         props = cl.getprops(protected=0)
549         # list the values
550         m = []
551         prop_items = props.items()
552         prop_items.sort()
553         for propname, prop in prop_items:
554             value = cl.get(issueid, propname, None)
555             # skip boring entries
556             if not value:
557                 continue
558             if isinstance(prop, hyperdb.Link):
559                 link = self.db.classes[prop.classname]
560                 if value:
561                     key = link.labelprop(default_to_id=1)
562                     if key:
563                         value = link.get(value, key)
564                 else:
565                     value = ''
566             elif isinstance(prop, hyperdb.Multilink):
567                 if value is None: value = []
568                 l = []
569                 link = self.db.classes[prop.classname]
570                 key = link.labelprop(default_to_id=1)
571                 if key:
572                     value = [link.get(entry, key) for entry in value]
573                 value.sort()
574                 value = ', '.join(value)
575             else:
576                 value = str(value)
577                 if '\n' in value:
578                     value = '\n'+self.indentChangeNoteValue(value)
579             m.append('%s: %s'%(propname, value))
580         m.insert(0, '----------')
581         m.insert(0, '')
582         return '\n'.join(m)
584     def generateChangeNote(self, issueid, oldvalues):
585         """Generate a change note that lists property changes
586         """
587         if not isinstance(oldvalues, type({})):
588             raise TypeError("'oldvalues' must be dict-like, not %s."%
589                 type(oldvalues))
591         cn = self.classname
592         cl = self.db.classes[cn]
593         changed = {}
594         props = cl.getprops(protected=0)
596         # determine what changed
597         for key in oldvalues.keys():
598             if key in ['files','messages']:
599                 continue
600             if key in ('actor', 'activity', 'creator', 'creation'):
601                 continue
602             # not all keys from oldvalues might be available in database
603             # this happens when property was deleted
604             try:
605                 new_value = cl.get(issueid, key)
606             except KeyError:
607                 continue
608             # the old value might be non existent
609             # this happens when property was added
610             try:
611                 old_value = oldvalues[key]
612                 if type(new_value) is type([]):
613                     new_value.sort()
614                     old_value.sort()
615                 if new_value != old_value:
616                     changed[key] = old_value
617             except:
618                 changed[key] = new_value
620         # list the changes
621         m = []
622         changed_items = changed.items()
623         changed_items.sort()
624         for propname, oldvalue in changed_items:
625             prop = props[propname]
626             value = cl.get(issueid, propname, None)
627             if isinstance(prop, hyperdb.Link):
628                 link = self.db.classes[prop.classname]
629                 key = link.labelprop(default_to_id=1)
630                 if key:
631                     if value:
632                         value = link.get(value, key)
633                     else:
634                         value = ''
635                     if oldvalue:
636                         oldvalue = link.get(oldvalue, key)
637                     else:
638                         oldvalue = ''
639                 change = '%s -> %s'%(oldvalue, value)
640             elif isinstance(prop, hyperdb.Multilink):
641                 change = ''
642                 if value is None: value = []
643                 if oldvalue is None: oldvalue = []
644                 l = []
645                 link = self.db.classes[prop.classname]
646                 key = link.labelprop(default_to_id=1)
647                 # check for additions
648                 for entry in value:
649                     if entry in oldvalue: continue
650                     if key:
651                         l.append(link.get(entry, key))
652                     else:
653                         l.append(entry)
654                 if l:
655                     l.sort()
656                     change = '+%s'%(', '.join(l))
657                     l = []
658                 # check for removals
659                 for entry in oldvalue:
660                     if entry in value: continue
661                     if key:
662                         l.append(link.get(entry, key))
663                     else:
664                         l.append(entry)
665                 if l:
666                     l.sort()
667                     change += ' -%s'%(', '.join(l))
668             else:
669                 change = '%s -> %s'%(oldvalue, value)
670                 if '\n' in change:
671                     value = self.indentChangeNoteValue(str(value))
672                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
673                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
674                         "new": value, "old": oldvalue}
675             m.append('%s: %s'%(propname, change))
676         if m:
677             m.insert(0, '----------')
678             m.insert(0, '')
679         return '\n'.join(m)
681     def indentChangeNoteValue(self, text):
682         lines = text.rstrip('\n').split('\n')
683         lines = [ '  '+line for line in lines ]
684         return '\n'.join(lines)
686 # vim: set filetype=python sts=4 sw=4 et si :