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 :