Code

Sending of PGP-Encrypted mail to all users or selected users (via roles)
[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
33 from email.MIMEMultipart import MIMEMultipart
35 from anypy.email_ import FeedParser
37 from roundup import password, date, hyperdb
38 from roundup.i18n import _
39 from roundup.hyperdb import iter_roles
41 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
42     nice_sender_header
44 try:
45     import pyme, pyme.core
46 except ImportError:
47     pyme = None
50 class Database:
52     # remember the journal uid for the current journaltag so that:
53     # a. we don't have to look it up every time we need it, and
54     # b. if the journaltag disappears during a transaction, we don't barf
55     #    (eg. the current user edits their username)
56     journal_uid = None
57     def getuid(self):
58         """Return the id of the "user" node associated with the user
59         that owns this connection to the hyperdatabase."""
60         if self.journaltag is None:
61             return None
62         elif self.journaltag == 'admin':
63             # admin user may not exist, but always has ID 1
64             return '1'
65         else:
66             if (self.journal_uid is None or self.journal_uid[0] !=
67                     self.journaltag):
68                 uid = self.user.lookup(self.journaltag)
69                 self.journal_uid = (self.journaltag, uid)
70             return self.journal_uid[1]
72     def setCurrentUser(self, username):
73         """Set the user that is responsible for current database
74         activities.
75         """
76         self.journaltag = username
78     def isCurrentUser(self, username):
79         """Check if a given username equals the already active user.
80         """
81         return self.journaltag == username
83     def getUserTimezone(self):
84         """Return user timezone defined in 'timezone' property of user class.
85         If no such property exists return 0
86         """
87         userid = self.getuid()
88         timezone = None
89         try:
90             tz = self.user.get(userid, 'timezone')
91             date.get_timezone(tz)
92             timezone = tz
93         except KeyError:
94             pass
95         # If there is no class 'user' or current user doesn't have timezone
96         # property or that property is not set assume he/she lives in
97         # the timezone set in the tracker config.
98         if timezone is None:
99             timezone = self.config['TIMEZONE']
100         return timezone
102     def confirm_registration(self, otk):
103         props = self.getOTKManager().getall(otk)
104         for propname, proptype in self.user.getprops().items():
105             value = props.get(propname, None)
106             if value is None:
107                 pass
108             elif isinstance(proptype, hyperdb.Date):
109                 props[propname] = date.Date(value)
110             elif isinstance(proptype, hyperdb.Interval):
111                 props[propname] = date.Interval(value)
112             elif isinstance(proptype, hyperdb.Password):
113                 props[propname] = password.Password(encrypted=value)
115         # tag new user creation with 'admin'
116         self.journaltag = 'admin'
118         # create the new user
119         cl = self.user
121         props['roles'] = self.config.NEW_WEB_USER_ROLES
122         userid = cl.create(**props)
123         # clear the props from the otk database
124         self.getOTKManager().destroy(otk)
125         self.commit()
127         return userid
130     def log_debug(self, msg, *args, **kwargs):
131         """Log a message with level DEBUG."""
133         logger = self.get_logger()
134         logger.debug(msg, *args, **kwargs)
136     def log_info(self, msg, *args, **kwargs):
137         """Log a message with level INFO."""
139         logger = self.get_logger()
140         logger.info(msg, *args, **kwargs)
142     def get_logger(self):
143         """Return the logger for this database."""
145         # Because getting a logger requires acquiring a lock, we want
146         # to do it only once.
147         if not hasattr(self, '__logger'):
148             self.__logger = logging.getLogger('roundup.hyperdb')
150         return self.__logger
153 class DetectorError(RuntimeError):
154     """ Raised by detectors that want to indicate that something's amiss
155     """
156     pass
158 # deviation from spec - was called IssueClass
159 class IssueClass:
160     """This class is intended to be mixed-in with a hyperdb backend
161     implementation. The backend should provide a mechanism that
162     enforces the title, messages, files, nosy and superseder
163     properties:
165     - title = hyperdb.String(indexme='yes')
166     - messages = hyperdb.Multilink("msg")
167     - files = hyperdb.Multilink("file")
168     - nosy = hyperdb.Multilink("user")
169     - superseder = hyperdb.Multilink(classname)
170     """
172     # The tuple below does not affect the class definition.
173     # It just lists all names of all issue properties
174     # marked for message extraction tool.
175     #
176     # XXX is there better way to get property names into message catalog??
177     #
178     # Note that this list also includes properties
179     # defined in the classic template:
180     # assignedto, keyword, priority, status.
181     (
182         ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
183         ''"assignedto", ''"keyword", ''"priority", ''"status",
184         # following properties are common for all hyperdb classes
185         # they are listed here to keep things in one place
186         ''"actor", ''"activity", ''"creator", ''"creation",
187     )
189     # New methods:
190     def addmessage(self, issueid, summary, text):
191         """Add a message to an issue's mail spool.
193         A new "msg" node is constructed using the current date, the user that
194         owns the database connection as the author, and the specified summary
195         text.
197         The "files" and "recipients" fields are left empty.
199         The given text is saved as the body of the message and the node is
200         appended to the "messages" field of the specified issue.
201         """
203     def nosymessage(self, issueid, msgid, oldvalues, whichnosy='nosy',
204             from_address=None, cc=[], bcc=[], cc_emails = [], bcc_emails = []):
205         """Send a message to the members of an issue's nosy list.
207         The message is sent only to users on the nosy list who are not
208         already on the "recipients" list for the message.
210         These users are then added to the message's "recipients" list.
212         If 'msgid' is None, the message gets sent only to the nosy
213         list, and it's called a 'System Message'.
215         The "cc" argument indicates additional recipients to send the
216         message to that may not be specified in the message's recipients
217         list.
219         The "bcc" argument also indicates additional recipients to send the
220         message to that may not be specified in the message's recipients
221         list. These recipients will not be included in the To: or Cc:
222         address lists. Note that the list of bcc users *is* updated in
223         the recipient list of the message, so this field has to be
224         protected (using appropriate permissions), otherwise the bcc
225         will be decuceable for users who have web access to the tracker.
227         The cc_emails and bcc_emails arguments take a list of additional
228         recipient email addresses (just the mail address not roundup users)
229         this can be useful for sending to additional email addresses
230         which are no roundup users. These arguments are currently not
231         used by roundups nosyreaction but can be used by customized
232         (nosy-)reactors.
234         A note on encryption: If pgp encryption for outgoing mails is
235         turned on in the configuration and no specific pgp roles are
236         defined, we try to send encrypted mail to *all* users
237         *including* cc, bcc, cc_emails and bcc_emails and this might
238         fail if not all the keys are available in roundups keyring.
239         """
240         encrypt = self.db.config.PGP_ENABLE and self.db.config.PGP_ENCRYPT
241         pgproles = self.db.config.PGP_ROLES
242         if msgid:
243             authid = self.db.msg.get(msgid, 'author')
244             recipients = self.db.msg.get(msgid, 'recipients', [])
245         else:
246             # "system message"
247             authid = None
248             recipients = []
250         sendto = dict (plain = [], crypt = [])
251         bcc_sendto = dict (plain = [], crypt = [])
252         seen_message = {}
253         for recipient in recipients:
254             seen_message[recipient] = 1
256         def add_recipient(userid, to):
257             """ make sure they have an address """
258             address = self.db.user.get(userid, 'address')
259             if address:
260                 ciphered = encrypt and (not pgproles or
261                     self.db.user.has_role(userid, *iter_roles(pgproles)))
262                 type = ['plain', 'crypt'][ciphered]
263                 to[type].append(address)
264                 recipients.append(userid)
266         def good_recipient(userid):
267             """ Make sure we don't send mail to either the anonymous
268                 user or a user who has already seen the message.
269                 Also check permissions on the message if not a system
270                 message: A user must have view permission on content and
271                 files to be on the receiver list. We do *not* check the
272                 author etc. for now.
273             """
274             allowed = True
275             if msgid:
276                 for prop in 'content', 'files':
277                     if prop in self.db.msg.properties:
278                         allowed = allowed and self.db.security.hasPermission(
279                             'View', userid, 'msg', prop, msgid)
280             return (userid and
281                     (self.db.user.get(userid, 'username') != 'anonymous') and
282                     allowed and not seen_message.has_key(userid))
284         # possibly send the message to the author, as long as they aren't
285         # anonymous
286         if (good_recipient(authid) and
287             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
288              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
289             add_recipient(authid, sendto)
291         if authid:
292             seen_message[authid] = 1
294         # now deal with the nosy and cc people who weren't recipients.
295         for userid in cc + self.get(issueid, whichnosy):
296             if good_recipient(userid):
297                 add_recipient(userid, sendto)
298         if encrypt and not pgproles:
299             sendto['crypt'].extend (cc_emails)
300         else:
301             sendto['plain'].extend (cc_emails)
303         # now deal with bcc people.
304         for userid in bcc:
305             if good_recipient(userid):
306                 add_recipient(userid, bcc_sendto)
307         if encrypt and not pgproles:
308             bcc_sendto['crypt'].extend (bcc_emails)
309         else:
310             bcc_sendto['plain'].extend (bcc_emails)
312         if oldvalues:
313             note = self.generateChangeNote(issueid, oldvalues)
314         else:
315             note = self.generateCreateNote(issueid)
317         # If we have new recipients, update the message's recipients
318         # and send the mail.
319         if sendto['plain'] or sendto['crypt']:
320             # update msgid and recipients only if non-bcc have changed
321             if msgid is not None:
322                 self.db.msg.set(msgid, recipients=recipients)
323         if sendto['plain'] or bcc_sendto['plain']:
324             self.send_message(issueid, msgid, note, sendto['plain'],
325                 from_address, bcc_sendto['plain'])
326         if sendto['crypt'] or bcc_sendto['crypt']:
327             self.send_message(issueid, msgid, note, sendto['crypt'],
328                 from_address, bcc_sendto['crypt'], crypt=True)
330     # backwards compatibility - don't remove
331     sendmessage = nosymessage
333     def encrypt_to(self, message, sendto):
334         """ Encrypt given message to sendto receivers.
335             Returns a new RFC 3156 conforming message.
336         """
337         plain = pyme.core.Data(message.as_string())
338         cipher = pyme.core.Data()
339         ctx = pyme.core.Context()
340         ctx.set_armor(1)
341         keys = []
342         for adr in sendto:
343             ctx.op_keylist_start(adr, 0)
344             # only first key per email
345             k = ctx.op_keylist_next()
346             if k is not None:
347                 keys.append(k)
348             else:
349                 msg = _('No key for "%(adr)s" in keyring')%locals()
350                 raise MessageSendError, msg
351             ctx.op_keylist_end()
352         ctx.op_encrypt(keys, 1, plain, cipher)
353         cipher.seek(0,0)
354         msg = MIMEMultipart('encrypted', boundary=None, _subparts=None,
355             protocol="application/pgp-encrypted")
356         part = MIMEBase('application', 'pgp-encrypted')
357         part.set_payload("Version: 1\r\n")
358         msg.attach(part)
359         part = MIMEBase('application', 'octet-stream')
360         part.set_payload(cipher.read())
361         msg.attach(part)
362         return msg
364     def send_message(self, issueid, msgid, note, sendto, from_address=None,
365             bcc_sendto=[], crypt=False):
366         '''Actually send the nominated message from this issue to the sendto
367            recipients, with the note appended.
368         '''
369         users = self.db.user
370         messages = self.db.msg
371         files = self.db.file
373         if msgid is None:
374             inreplyto = None
375             messageid = None
376         else:
377             inreplyto = messages.get(msgid, 'inreplyto')
378             messageid = messages.get(msgid, 'messageid')
380         # make up a messageid if there isn't one (web edit)
381         if not messageid:
382             # this is an old message that didn't get a messageid, so
383             # create one
384             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
385                                            self.classname, issueid,
386                                            self.db.config.MAIL_DOMAIN)
387             if msgid is not None:
388                 messages.set(msgid, messageid=messageid)
390         # compose title
391         cn = self.classname
392         title = self.get(issueid, 'title') or '%s message copy'%cn
394         # figure author information
395         if msgid:
396             authid = messages.get(msgid, 'author')
397         else:
398             authid = self.db.getuid()
399         authname = users.get(authid, 'realname')
400         if not authname:
401             authname = users.get(authid, 'username', '')
402         authaddr = users.get(authid, 'address', '')
404         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
405             authaddr = " <%s>" % formataddr( ('',authaddr) )
406         elif authaddr:
407             authaddr = ""
409         # make the message body
410         m = ['']
412         # put in roundup's signature
413         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
414             m.append(self.email_signature(issueid, msgid))
416         # add author information
417         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
418             if msgid and len(self.get(issueid, 'messages')) == 1:
419                 m.append(_("New submission from %(authname)s%(authaddr)s:")
420                     % locals())
421             elif msgid:
422                 m.append(_("%(authname)s%(authaddr)s added the comment:")
423                     % locals())
424             else:
425                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
426             m.append('')
428         # add the content
429         if msgid is not None:
430             m.append(messages.get(msgid, 'content', ''))
432         # get the files for this message
433         message_files = []
434         if msgid :
435             for fileid in messages.get(msgid, 'files') :
436                 # check the attachment size
437                 filesize = self.db.filesize('file', fileid, None)
438                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
439                     message_files.append(fileid)
440                 else:
441                     base = self.db.config.TRACKER_WEB
442                     link = "".join((base, files.classname, fileid))
443                     filename = files.get(fileid, 'name')
444                     m.append(_("File '%(filename)s' not attached - "
445                         "you can download it from %(link)s.") % locals())
447         # add the change note
448         if note:
449             m.append(note)
451         # put in roundup's signature
452         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
453             m.append(self.email_signature(issueid, msgid))
455         # figure the encoding
456         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
458         # construct the content and convert to unicode object
459         body = unicode('\n'.join(m), 'utf-8').encode(charset)
461         # make sure the To line is always the same (for testing mostly)
462         sendto.sort()
464         # make sure we have a from address
465         if from_address is None:
466             from_address = self.db.config.TRACKER_EMAIL
468         # additional bit for after the From: "name"
469         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
470         if from_tag:
471             from_tag = ' ' + from_tag
473         subject = '[%s%s] %s'%(cn, issueid, title)
474         author = (authname + from_tag, from_address)
476         # send an individual message per recipient?
477         if self.db.config.NOSY_EMAIL_SENDING != 'single':
478             sendto = [[address] for address in sendto]
479         else:
480             sendto = [sendto]
482         # tracker sender info
483         tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
484         tracker_name = nice_sender_header(tracker_name, from_address,
485             charset)
487         # now send one or more messages
488         # TODO: I believe we have to create a new message each time as we
489         # can't fiddle the recipients in the message ... worth testing
490         # and/or fixing some day
491         first = True
492         for sendto in sendto:
493             # create the message
494             mailer = Mailer(self.db.config)
496             message = mailer.get_standard_message(multipart=message_files)
498             # set reply-to to the tracker
499             message['Reply-To'] = tracker_name
501             # message ids
502             if messageid:
503                 message['Message-Id'] = messageid
504             if inreplyto:
505                 message['In-Reply-To'] = inreplyto
507             # Generate a header for each link or multilink to
508             # a class that has a name attribute
509             for propname, prop in self.getprops().items():
510                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
511                     continue
512                 cl = self.db.getclass(prop.classname)
513                 if not 'name' in cl.getprops():
514                     continue
515                 if isinstance(prop, hyperdb.Link):
516                     value = self.get(issueid, propname)
517                     if value is None:
518                         continue
519                     values = [value]
520                 else:
521                     values = self.get(issueid, propname)
522                     if not values:
523                         continue
524                 values = [cl.get(v, 'name') for v in values]
525                 values = ', '.join(values)
526                 header = "X-Roundup-%s-%s"%(self.classname, propname)
527                 try:
528                     message[header] = values.encode('ascii')
529                 except UnicodeError:
530                     message[header] = Header(values, charset)
532             if not inreplyto:
533                 # Default the reply to the first message
534                 msgs = self.get(issueid, 'messages')
535                 # Assume messages are sorted by increasing message number here
536                 # If the issue is just being created, and the submitter didn't
537                 # provide a message, then msgs will be empty.
538                 if msgs and msgs[0] != msgid:
539                     inreplyto = messages.get(msgs[0], 'messageid')
540                     if inreplyto:
541                         message['In-Reply-To'] = inreplyto
543             # attach files
544             if message_files:
545                 # first up the text as a part
546                 part = MIMEText(body)
547                 part.set_charset(charset)
548                 encode_quopri(part)
549                 message.attach(part)
551                 for fileid in message_files:
552                     name = files.get(fileid, 'name')
553                     mime_type = files.get(fileid, 'type')
554                     content = files.get(fileid, 'content')
555                     if mime_type == 'text/plain':
556                         try:
557                             content.decode('ascii')
558                         except UnicodeError:
559                             # the content cannot be 7bit-encoded.
560                             # use quoted printable
561                             # XXX stuffed if we know the charset though :(
562                             part = MIMEText(content)
563                             encode_quopri(part)
564                         else:
565                             part = MIMEText(content)
566                             part['Content-Transfer-Encoding'] = '7bit'
567                     elif mime_type == 'message/rfc822':
568                         main, sub = mime_type.split('/')
569                         p = FeedParser()
570                         p.feed(content)
571                         part = MIMEBase(main, sub)
572                         part.set_payload([p.close()])
573                     else:
574                         # some other type, so encode it
575                         if not mime_type:
576                             # this should have been done when the file was saved
577                             mime_type = mimetypes.guess_type(name)[0]
578                         if mime_type is None:
579                             mime_type = 'application/octet-stream'
580                         main, sub = mime_type.split('/')
581                         part = MIMEBase(main, sub)
582                         part.set_payload(content)
583                         Encoders.encode_base64(part)
584                     cd = 'Content-Disposition'
585                     part[cd] = 'attachment;\n filename="%s"'%name
586                     message.attach(part)
588             else:
589                 message.set_payload(body)
590                 encode_quopri(message)
592             if crypt:
593                 send_msg = self.encrypt_to (message, sendto)
594             else:
595                 send_msg = message
596             mailer.set_message_attributes(send_msg, sendto, subject, author)
597             send_msg ['Message-Id'] = message ['Message-Id']
598             send_msg ['Reply-To'] = message ['Reply-To']
599             if message.get ('In-Reply-To'):
600                 send_msg ['In-Reply-To'] = message ['In-Reply-To']
601             mailer.smtp_send(sendto, send_msg.as_string())
602             if first:
603                 if crypt:
604                     # send individual bcc mails, otherwise receivers can
605                     # deduce bcc recipients from keys in message
606                     for bcc in bcc_sendto:
607                         send_msg = self.encrypt_to (message, [bcc])
608                         send_msg ['Message-Id'] = message ['Message-Id']
609                         send_msg ['Reply-To'] = message ['Reply-To']
610                         if message.get ('In-Reply-To'):
611                             send_msg ['In-Reply-To'] = message ['In-Reply-To']
612                         mailer.smtp_send([bcc], send_msg.as_string())
613                 elif bcc_sendto:
614                     mailer.smtp_send(bcc_sendto, send_msg.as_string())
615             first = False
617     def email_signature(self, issueid, msgid):
618         ''' Add a signature to the e-mail with some useful information
619         '''
620         # simplistic check to see if the url is valid,
621         # then append a trailing slash if it is missing
622         base = self.db.config.TRACKER_WEB
623         if (not isinstance(base , type('')) or
624             not (base.startswith('http://') or base.startswith('https://'))):
625             web = "Configuration Error: TRACKER_WEB isn't a " \
626                 "fully-qualified URL"
627         else:
628             if not base.endswith('/'):
629                 base = base + '/'
630             web = base + self.classname + issueid
632         # ensure the email address is properly quoted
633         email = formataddr((self.db.config.TRACKER_NAME,
634             self.db.config.TRACKER_EMAIL))
636         line = '_' * max(len(web)+2, len(email))
637         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
640     def generateCreateNote(self, issueid):
641         """Generate a create note that lists initial property values
642         """
643         cn = self.classname
644         cl = self.db.classes[cn]
645         props = cl.getprops(protected=0)
647         # list the values
648         m = []
649         prop_items = props.items()
650         prop_items.sort()
651         for propname, prop in prop_items:
652             value = cl.get(issueid, propname, None)
653             # skip boring entries
654             if not value:
655                 continue
656             if isinstance(prop, hyperdb.Link):
657                 link = self.db.classes[prop.classname]
658                 if value:
659                     key = link.labelprop(default_to_id=1)
660                     if key:
661                         value = link.get(value, key)
662                 else:
663                     value = ''
664             elif isinstance(prop, hyperdb.Multilink):
665                 if value is None: value = []
666                 l = []
667                 link = self.db.classes[prop.classname]
668                 key = link.labelprop(default_to_id=1)
669                 if key:
670                     value = [link.get(entry, key) for entry in value]
671                 value.sort()
672                 value = ', '.join(value)
673             else:
674                 value = str(value)
675                 if '\n' in value:
676                     value = '\n'+self.indentChangeNoteValue(value)
677             m.append('%s: %s'%(propname, value))
678         m.insert(0, '----------')
679         m.insert(0, '')
680         return '\n'.join(m)
682     def generateChangeNote(self, issueid, oldvalues):
683         """Generate a change note that lists property changes
684         """
685         if not isinstance(oldvalues, type({})):
686             raise TypeError("'oldvalues' must be dict-like, not %s."%
687                 type(oldvalues))
689         cn = self.classname
690         cl = self.db.classes[cn]
691         changed = {}
692         props = cl.getprops(protected=0)
694         # determine what changed
695         for key in oldvalues.keys():
696             if key in ['files','messages']:
697                 continue
698             if key in ('actor', 'activity', 'creator', 'creation'):
699                 continue
700             # not all keys from oldvalues might be available in database
701             # this happens when property was deleted
702             try:
703                 new_value = cl.get(issueid, key)
704             except KeyError:
705                 continue
706             # the old value might be non existent
707             # this happens when property was added
708             try:
709                 old_value = oldvalues[key]
710                 if type(new_value) is type([]):
711                     new_value.sort()
712                     old_value.sort()
713                 if new_value != old_value:
714                     changed[key] = old_value
715             except:
716                 changed[key] = new_value
718         # list the changes
719         m = []
720         changed_items = changed.items()
721         changed_items.sort()
722         for propname, oldvalue in changed_items:
723             prop = props[propname]
724             value = cl.get(issueid, propname, None)
725             if isinstance(prop, hyperdb.Link):
726                 link = self.db.classes[prop.classname]
727                 key = link.labelprop(default_to_id=1)
728                 if key:
729                     if value:
730                         value = link.get(value, key)
731                     else:
732                         value = ''
733                     if oldvalue:
734                         oldvalue = link.get(oldvalue, key)
735                     else:
736                         oldvalue = ''
737                 change = '%s -> %s'%(oldvalue, value)
738             elif isinstance(prop, hyperdb.Multilink):
739                 change = ''
740                 if value is None: value = []
741                 if oldvalue is None: oldvalue = []
742                 l = []
743                 link = self.db.classes[prop.classname]
744                 key = link.labelprop(default_to_id=1)
745                 # check for additions
746                 for entry in value:
747                     if entry in oldvalue: continue
748                     if key:
749                         l.append(link.get(entry, key))
750                     else:
751                         l.append(entry)
752                 if l:
753                     l.sort()
754                     change = '+%s'%(', '.join(l))
755                     l = []
756                 # check for removals
757                 for entry in oldvalue:
758                     if entry in value: continue
759                     if key:
760                         l.append(link.get(entry, key))
761                     else:
762                         l.append(entry)
763                 if l:
764                     l.sort()
765                     change += ' -%s'%(', '.join(l))
766             else:
767                 change = '%s -> %s'%(oldvalue, value)
768                 if '\n' in change:
769                     value = self.indentChangeNoteValue(str(value))
770                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
771                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
772                         "new": value, "old": oldvalue}
773             m.append('%s: %s'%(propname, change))
774         if m:
775             m.insert(0, '----------')
776             m.insert(0, '')
777         return '\n'.join(m)
779     def indentChangeNoteValue(self, text):
780         lines = text.rstrip('\n').split('\n')
781         lines = [ '  '+line for line in lines ]
782         return '\n'.join(lines)
784 # vim: set filetype=python sts=4 sw=4 et si :