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 :