Code

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