Code

Add config-option "nosy" to messages_to_author setting in [nosy] section
[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) or
289              (self.db.config.MESSAGES_TO_AUTHOR == 'nosy' and authid in
290              self.get(issueid, whichnosy)))):
291             add_recipient(authid, sendto)
293         if authid:
294             seen_message[authid] = 1
296         # now deal with the nosy and cc people who weren't recipients.
297         for userid in cc + self.get(issueid, whichnosy):
298             if good_recipient(userid):
299                 add_recipient(userid, sendto)
300         if encrypt and not pgproles:
301             sendto['crypt'].extend (cc_emails)
302         else:
303             sendto['plain'].extend (cc_emails)
305         # now deal with bcc people.
306         for userid in bcc:
307             if good_recipient(userid):
308                 add_recipient(userid, bcc_sendto)
309         if encrypt and not pgproles:
310             bcc_sendto['crypt'].extend (bcc_emails)
311         else:
312             bcc_sendto['plain'].extend (bcc_emails)
314         if oldvalues:
315             note = self.generateChangeNote(issueid, oldvalues)
316         else:
317             note = self.generateCreateNote(issueid)
319         # If we have new recipients, update the message's recipients
320         # and send the mail.
321         if sendto['plain'] or sendto['crypt']:
322             # update msgid and recipients only if non-bcc have changed
323             if msgid is not None:
324                 self.db.msg.set(msgid, recipients=recipients)
325         if sendto['plain'] or bcc_sendto['plain']:
326             self.send_message(issueid, msgid, note, sendto['plain'],
327                 from_address, bcc_sendto['plain'])
328         if sendto['crypt'] or bcc_sendto['crypt']:
329             self.send_message(issueid, msgid, note, sendto['crypt'],
330                 from_address, bcc_sendto['crypt'], crypt=True)
332     # backwards compatibility - don't remove
333     sendmessage = nosymessage
335     def encrypt_to(self, message, sendto):
336         """ Encrypt given message to sendto receivers.
337             Returns a new RFC 3156 conforming message.
338         """
339         plain = pyme.core.Data(message.as_string())
340         cipher = pyme.core.Data()
341         ctx = pyme.core.Context()
342         ctx.set_armor(1)
343         keys = []
344         for adr in sendto:
345             ctx.op_keylist_start(adr, 0)
346             # only first key per email
347             k = ctx.op_keylist_next()
348             if k is not None:
349                 keys.append(k)
350             else:
351                 msg = _('No key for "%(adr)s" in keyring')%locals()
352                 raise MessageSendError, msg
353             ctx.op_keylist_end()
354         ctx.op_encrypt(keys, 1, plain, cipher)
355         cipher.seek(0,0)
356         msg = MIMEMultipart('encrypted', boundary=None, _subparts=None,
357             protocol="application/pgp-encrypted")
358         part = MIMEBase('application', 'pgp-encrypted')
359         part.set_payload("Version: 1\r\n")
360         msg.attach(part)
361         part = MIMEBase('application', 'octet-stream')
362         part.set_payload(cipher.read())
363         msg.attach(part)
364         return msg
366     def send_message(self, issueid, msgid, note, sendto, from_address=None,
367             bcc_sendto=[], crypt=False):
368         '''Actually send the nominated message from this issue to the sendto
369            recipients, with the note appended.
370         '''
371         users = self.db.user
372         messages = self.db.msg
373         files = self.db.file
375         if msgid is None:
376             inreplyto = None
377             messageid = None
378         else:
379             inreplyto = messages.get(msgid, 'inreplyto')
380             messageid = messages.get(msgid, 'messageid')
382         # make up a messageid if there isn't one (web edit)
383         if not messageid:
384             # this is an old message that didn't get a messageid, so
385             # create one
386             messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
387                                            self.classname, issueid,
388                                            self.db.config.MAIL_DOMAIN)
389             if msgid is not None:
390                 messages.set(msgid, messageid=messageid)
392         # compose title
393         cn = self.classname
394         title = self.get(issueid, 'title') or '%s message copy'%cn
396         # figure author information
397         if msgid:
398             authid = messages.get(msgid, 'author')
399         else:
400             authid = self.db.getuid()
401         authname = users.get(authid, 'realname')
402         if not authname:
403             authname = users.get(authid, 'username', '')
404         authaddr = users.get(authid, 'address', '')
406         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
407             authaddr = " <%s>" % formataddr( ('',authaddr) )
408         elif authaddr:
409             authaddr = ""
411         # make the message body
412         m = ['']
414         # put in roundup's signature
415         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
416             m.append(self.email_signature(issueid, msgid))
418         # add author information
419         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
420             if msgid and len(self.get(issueid, 'messages')) == 1:
421                 m.append(_("New submission from %(authname)s%(authaddr)s:")
422                     % locals())
423             elif msgid:
424                 m.append(_("%(authname)s%(authaddr)s added the comment:")
425                     % locals())
426             else:
427                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
428             m.append('')
430         # add the content
431         if msgid is not None:
432             m.append(messages.get(msgid, 'content', ''))
434         # get the files for this message
435         message_files = []
436         if msgid :
437             for fileid in messages.get(msgid, 'files') :
438                 # check the attachment size
439                 filesize = self.db.filesize('file', fileid, None)
440                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
441                     message_files.append(fileid)
442                 else:
443                     base = self.db.config.TRACKER_WEB
444                     link = "".join((base, files.classname, fileid))
445                     filename = files.get(fileid, 'name')
446                     m.append(_("File '%(filename)s' not attached - "
447                         "you can download it from %(link)s.") % locals())
449         # add the change note
450         if note:
451             m.append(note)
453         # put in roundup's signature
454         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
455             m.append(self.email_signature(issueid, msgid))
457         # figure the encoding
458         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
460         # construct the content and convert to unicode object
461         body = unicode('\n'.join(m), 'utf-8').encode(charset)
463         # make sure the To line is always the same (for testing mostly)
464         sendto.sort()
466         # make sure we have a from address
467         if from_address is None:
468             from_address = self.db.config.TRACKER_EMAIL
470         # additional bit for after the From: "name"
471         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
472         if from_tag:
473             from_tag = ' ' + from_tag
475         subject = '[%s%s] %s'%(cn, issueid, title)
476         author = (authname + from_tag, from_address)
478         # send an individual message per recipient?
479         if self.db.config.NOSY_EMAIL_SENDING != 'single':
480             sendto = [[address] for address in sendto]
481         else:
482             sendto = [sendto]
484         # tracker sender info
485         tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
486         tracker_name = nice_sender_header(tracker_name, from_address,
487             charset)
489         # now send one or more messages
490         # TODO: I believe we have to create a new message each time as we
491         # can't fiddle the recipients in the message ... worth testing
492         # and/or fixing some day
493         first = True
494         for sendto in sendto:
495             # create the message
496             mailer = Mailer(self.db.config)
498             message = mailer.get_standard_message(multipart=message_files)
500             # set reply-to to the tracker
501             message['Reply-To'] = tracker_name
503             # message ids
504             if messageid:
505                 message['Message-Id'] = messageid
506             if inreplyto:
507                 message['In-Reply-To'] = inreplyto
509             # Generate a header for each link or multilink to
510             # a class that has a name attribute
511             for propname, prop in self.getprops().items():
512                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
513                     continue
514                 cl = self.db.getclass(prop.classname)
515                 if not 'name' in cl.getprops():
516                     continue
517                 if isinstance(prop, hyperdb.Link):
518                     value = self.get(issueid, propname)
519                     if value is None:
520                         continue
521                     values = [value]
522                 else:
523                     values = self.get(issueid, propname)
524                     if not values:
525                         continue
526                 values = [cl.get(v, 'name') for v in values]
527                 values = ', '.join(values)
528                 header = "X-Roundup-%s-%s"%(self.classname, propname)
529                 try:
530                     message[header] = values.encode('ascii')
531                 except UnicodeError:
532                     message[header] = Header(values, charset)
534             if not inreplyto:
535                 # Default the reply to the first message
536                 msgs = self.get(issueid, 'messages')
537                 # Assume messages are sorted by increasing message number here
538                 # If the issue is just being created, and the submitter didn't
539                 # provide a message, then msgs will be empty.
540                 if msgs and msgs[0] != msgid:
541                     inreplyto = messages.get(msgs[0], 'messageid')
542                     if inreplyto:
543                         message['In-Reply-To'] = inreplyto
545             # attach files
546             if message_files:
547                 # first up the text as a part
548                 part = MIMEText(body)
549                 part.set_charset(charset)
550                 encode_quopri(part)
551                 message.attach(part)
553                 for fileid in message_files:
554                     name = files.get(fileid, 'name')
555                     mime_type = files.get(fileid, 'type')
556                     content = files.get(fileid, 'content')
557                     if mime_type == 'text/plain':
558                         try:
559                             content.decode('ascii')
560                         except UnicodeError:
561                             # the content cannot be 7bit-encoded.
562                             # use quoted printable
563                             # XXX stuffed if we know the charset though :(
564                             part = MIMEText(content)
565                             encode_quopri(part)
566                         else:
567                             part = MIMEText(content)
568                             part['Content-Transfer-Encoding'] = '7bit'
569                     elif mime_type == 'message/rfc822':
570                         main, sub = mime_type.split('/')
571                         p = FeedParser()
572                         p.feed(content)
573                         part = MIMEBase(main, sub)
574                         part.set_payload([p.close()])
575                     else:
576                         # some other type, so encode it
577                         if not mime_type:
578                             # this should have been done when the file was saved
579                             mime_type = mimetypes.guess_type(name)[0]
580                         if mime_type is None:
581                             mime_type = 'application/octet-stream'
582                         main, sub = mime_type.split('/')
583                         part = MIMEBase(main, sub)
584                         part.set_payload(content)
585                         Encoders.encode_base64(part)
586                     cd = 'Content-Disposition'
587                     part[cd] = 'attachment;\n filename="%s"'%name
588                     message.attach(part)
590             else:
591                 message.set_payload(body)
592                 encode_quopri(message)
594             if crypt:
595                 send_msg = self.encrypt_to (message, sendto)
596             else:
597                 send_msg = message
598             mailer.set_message_attributes(send_msg, sendto, subject, author)
599             send_msg ['Message-Id'] = message ['Message-Id']
600             send_msg ['Reply-To'] = message ['Reply-To']
601             if message.get ('In-Reply-To'):
602                 send_msg ['In-Reply-To'] = message ['In-Reply-To']
603             mailer.smtp_send(sendto, send_msg.as_string())
604             if first:
605                 if crypt:
606                     # send individual bcc mails, otherwise receivers can
607                     # deduce bcc recipients from keys in message
608                     for bcc in bcc_sendto:
609                         send_msg = self.encrypt_to (message, [bcc])
610                         send_msg ['Message-Id'] = message ['Message-Id']
611                         send_msg ['Reply-To'] = message ['Reply-To']
612                         if message.get ('In-Reply-To'):
613                             send_msg ['In-Reply-To'] = message ['In-Reply-To']
614                         mailer.smtp_send([bcc], send_msg.as_string())
615                 elif bcc_sendto:
616                     mailer.smtp_send(bcc_sendto, send_msg.as_string())
617             first = False
619     def email_signature(self, issueid, msgid):
620         ''' Add a signature to the e-mail with some useful information
621         '''
622         # simplistic check to see if the url is valid,
623         # then append a trailing slash if it is missing
624         base = self.db.config.TRACKER_WEB
625         if (not isinstance(base , type('')) or
626             not (base.startswith('http://') or base.startswith('https://'))):
627             web = "Configuration Error: TRACKER_WEB isn't a " \
628                 "fully-qualified URL"
629         else:
630             if not base.endswith('/'):
631                 base = base + '/'
632             web = base + self.classname + issueid
634         # ensure the email address is properly quoted
635         email = formataddr((self.db.config.TRACKER_NAME,
636             self.db.config.TRACKER_EMAIL))
638         line = '_' * max(len(web)+2, len(email))
639         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
642     def generateCreateNote(self, issueid):
643         """Generate a create note that lists initial property values
644         """
645         cn = self.classname
646         cl = self.db.classes[cn]
647         props = cl.getprops(protected=0)
649         # list the values
650         m = []
651         prop_items = props.items()
652         prop_items.sort()
653         for propname, prop in prop_items:
654             value = cl.get(issueid, propname, None)
655             # skip boring entries
656             if not value:
657                 continue
658             if isinstance(prop, hyperdb.Link):
659                 link = self.db.classes[prop.classname]
660                 if value:
661                     key = link.labelprop(default_to_id=1)
662                     if key:
663                         value = link.get(value, key)
664                 else:
665                     value = ''
666             elif isinstance(prop, hyperdb.Multilink):
667                 if value is None: value = []
668                 l = []
669                 link = self.db.classes[prop.classname]
670                 key = link.labelprop(default_to_id=1)
671                 if key:
672                     value = [link.get(entry, key) for entry in value]
673                 value.sort()
674                 value = ', '.join(value)
675             else:
676                 value = str(value)
677                 if '\n' in value:
678                     value = '\n'+self.indentChangeNoteValue(value)
679             m.append('%s: %s'%(propname, value))
680         m.insert(0, '----------')
681         m.insert(0, '')
682         return '\n'.join(m)
684     def generateChangeNote(self, issueid, oldvalues):
685         """Generate a change note that lists property changes
686         """
687         if not isinstance(oldvalues, type({})):
688             raise TypeError("'oldvalues' must be dict-like, not %s."%
689                 type(oldvalues))
691         cn = self.classname
692         cl = self.db.classes[cn]
693         changed = {}
694         props = cl.getprops(protected=0)
696         # determine what changed
697         for key in oldvalues.keys():
698             if key in ['files','messages']:
699                 continue
700             if key in ('actor', 'activity', 'creator', 'creation'):
701                 continue
702             # not all keys from oldvalues might be available in database
703             # this happens when property was deleted
704             try:
705                 new_value = cl.get(issueid, key)
706             except KeyError:
707                 continue
708             # the old value might be non existent
709             # this happens when property was added
710             try:
711                 old_value = oldvalues[key]
712                 if type(new_value) is type([]):
713                     new_value.sort()
714                     old_value.sort()
715                 if new_value != old_value:
716                     changed[key] = old_value
717             except:
718                 changed[key] = new_value
720         # list the changes
721         m = []
722         changed_items = changed.items()
723         changed_items.sort()
724         for propname, oldvalue in changed_items:
725             prop = props[propname]
726             value = cl.get(issueid, propname, None)
727             if isinstance(prop, hyperdb.Link):
728                 link = self.db.classes[prop.classname]
729                 key = link.labelprop(default_to_id=1)
730                 if key:
731                     if value:
732                         value = link.get(value, key)
733                     else:
734                         value = ''
735                     if oldvalue:
736                         oldvalue = link.get(oldvalue, key)
737                     else:
738                         oldvalue = ''
739                 change = '%s -> %s'%(oldvalue, value)
740             elif isinstance(prop, hyperdb.Multilink):
741                 change = ''
742                 if value is None: value = []
743                 if oldvalue is None: oldvalue = []
744                 l = []
745                 link = self.db.classes[prop.classname]
746                 key = link.labelprop(default_to_id=1)
747                 # check for additions
748                 for entry in value:
749                     if entry in oldvalue: continue
750                     if key:
751                         l.append(link.get(entry, key))
752                     else:
753                         l.append(entry)
754                 if l:
755                     l.sort()
756                     change = '+%s'%(', '.join(l))
757                     l = []
758                 # check for removals
759                 for entry in oldvalue:
760                     if entry in value: continue
761                     if key:
762                         l.append(link.get(entry, key))
763                     else:
764                         l.append(entry)
765                 if l:
766                     l.sort()
767                     change += ' -%s'%(', '.join(l))
768             else:
769                 change = '%s -> %s'%(oldvalue, value)
770                 if '\n' in change:
771                     value = self.indentChangeNoteValue(str(value))
772                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
773                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
774                         "new": value, "old": oldvalue}
775             m.append('%s: %s'%(propname, change))
776         if m:
777             m.insert(0, '----------')
778             m.insert(0, '')
779         return '\n'.join(m)
781     def indentChangeNoteValue(self, text):
782         lines = text.rstrip('\n').split('\n')
783         lines = [ '  '+line for line in lines ]
784         return '\n'.join(lines)
786 # vim: set filetype=python sts=4 sw=4 et si :