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