fe9c8e315b947cd9b0ce4f8b2090560f1dcc4d47
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 # $Id: roundupdb.py,v 1.139 2008-08-07 06:31:16 richard Exp $
21 """Extending hyperdb with types specific to issue-tracking.
22 """
23 __docformat__ = 'restructuredtext'
25 import re, os, smtplib, socket, time, random
26 import cStringIO, base64, mimetypes
27 import os.path
28 import logging
29 from email import Encoders
30 from email.Utils import formataddr
31 from email.Header import Header
32 from email.MIMEText import MIMEText
33 from email.MIMEBase import MIMEBase
35 from roundup import password, date, hyperdb
36 from roundup.i18n import _
38 # MessageSendError is imported for backwards compatibility
39 from roundup.mailer import Mailer, MessageSendError, encode_quopri
41 class Database:
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]
63 def setCurrentUser(self, username):
64 """Set the user that is responsible for current database
65 activities.
66 """
67 self.journaltag = username
69 def isCurrentUser(self, username):
70 """Check if a given username equals the already active user.
71 """
72 return self.journaltag == username
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
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)
107 # tag new user creation with 'admin'
108 self.journaltag = 'admin'
110 # create the new user
111 cl = self.user
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()
119 return userid
122 def log_debug(self, msg, *args, **kwargs):
123 """Log a message with level DEBUG."""
125 logger = self.get_logger()
126 logger.debug(msg, *args, **kwargs)
128 def log_info(self, msg, *args, **kwargs):
129 """Log a message with level INFO."""
131 logger = self.get_logger()
132 logger.info(msg, *args, **kwargs)
134 def get_logger(self):
135 """Return the logger for this database."""
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')
142 return self.__logger
145 class DetectorError(RuntimeError):
146 """ Raised by detectors that want to indicate that something's amiss
147 """
148 pass
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:
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 """
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 )
181 # New methods:
182 def addmessage(self, nodeid, summary, text):
183 """Add a message to an issue's mail spool.
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.
189 The "files" and "recipients" fields are left empty.
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 """
195 def nosymessage(self, nodeid, msgid, oldvalues, whichnosy='nosy',
196 from_address=None, cc=[], bcc=[]):
197 """Send a message to the members of an issue's nosy list.
199 The message is sent only to users on the nosy list who are not
200 already on the "recipients" list for the message.
202 These users are then added to the message's "recipients" list.
204 If 'msgid' is None, the message gets sent only to the nosy
205 list, and it's called a 'System Message'.
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.
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 = []
224 sendto = []
225 bcc_sendto = []
226 seen_message = {}
227 for recipient in recipients:
228 seen_message[recipient] = 1
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)
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 return (userid and
241 (self.db.user.get(userid, 'username') != 'anonymous') and
242 not seen_message.has_key(userid))
244 # possibly send the message to the author, as long as they aren't
245 # anonymous
246 if (good_recipient(authid) and
247 (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
248 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
249 add_recipient(authid, sendto)
251 if authid:
252 seen_message[authid] = 1
254 # now deal with the nosy and cc people who weren't recipients.
255 for userid in cc + self.get(nodeid, whichnosy):
256 if good_recipient(userid):
257 add_recipient(userid, sendto)
259 # now deal with bcc people.
260 for userid in bcc:
261 if good_recipient(userid):
262 add_recipient(userid, bcc_sendto)
264 if oldvalues:
265 note = self.generateChangeNote(nodeid, oldvalues)
266 else:
267 note = self.generateCreateNote(nodeid)
269 # If we have new recipients, update the message's recipients
270 # and send the mail.
271 if sendto or bcc_sendto:
272 if msgid is not None:
273 self.db.msg.set(msgid, recipients=recipients)
274 self.send_message(nodeid, msgid, note, sendto, from_address,
275 bcc_sendto)
277 # backwards compatibility - don't remove
278 sendmessage = nosymessage
280 def send_message(self, nodeid, msgid, note, sendto, from_address=None,
281 bcc_sendto=[]):
282 '''Actually send the nominated message from this node to the sendto
283 recipients, with the note appended.
284 '''
285 users = self.db.user
286 messages = self.db.msg
287 files = self.db.file
289 if msgid is None:
290 inreplyto = None
291 messageid = None
292 else:
293 inreplyto = messages.get(msgid, 'inreplyto')
294 messageid = messages.get(msgid, 'messageid')
296 # make up a messageid if there isn't one (web edit)
297 if not messageid:
298 # this is an old message that didn't get a messageid, so
299 # create one
300 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
301 self.classname, nodeid,
302 self.db.config.MAIL_DOMAIN)
303 if msgid is not None:
304 messages.set(msgid, messageid=messageid)
306 # compose title
307 cn = self.classname
308 title = self.get(nodeid, 'title') or '%s message copy'%cn
310 # figure author information
311 if msgid:
312 authid = messages.get(msgid, 'author')
313 else:
314 authid = self.db.getuid()
315 authname = users.get(authid, 'realname')
316 if not authname:
317 authname = users.get(authid, 'username', '')
318 authaddr = users.get(authid, 'address', '')
320 if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
321 authaddr = " <%s>" % formataddr( ('',authaddr) )
322 elif authaddr:
323 authaddr = ""
325 # make the message body
326 m = ['']
328 # put in roundup's signature
329 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
330 m.append(self.email_signature(nodeid, msgid))
332 # add author information
333 if authid and self.db.config.MAIL_ADD_AUTHORINFO:
334 if msgid and len(self.get(nodeid, 'messages')) == 1:
335 m.append(_("New submission from %(authname)s%(authaddr)s:")
336 % locals())
337 elif msgid:
338 m.append(_("%(authname)s%(authaddr)s added the comment:")
339 % locals())
340 else:
341 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
342 m.append('')
344 # add the content
345 if msgid is not None:
346 m.append(messages.get(msgid, 'content', ''))
348 # get the files for this message
349 message_files = []
350 if msgid :
351 for fileid in messages.get(msgid, 'files') :
352 # check the attachment size
353 filename = self.db.filename('file', fileid, None)
354 filesize = os.path.getsize(filename)
355 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
356 message_files.append(fileid)
357 else:
358 base = self.db.config.TRACKER_WEB
359 link = "".join((base, files.classname, fileid))
360 filename = files.get(fileid, 'name')
361 m.append(_("File '%(filename)s' not attached - "
362 "you can download it from %(link)s.") % locals())
364 # add the change note
365 if note:
366 m.append(note)
368 # put in roundup's signature
369 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
370 m.append(self.email_signature(nodeid, msgid))
372 # figure the encoding
373 charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
375 # construct the content and convert to unicode object
376 content = unicode('\n'.join(m), 'utf-8').encode(charset)
378 # make sure the To line is always the same (for testing mostly)
379 sendto.sort()
381 # make sure we have a from address
382 if from_address is None:
383 from_address = self.db.config.TRACKER_EMAIL
385 # additional bit for after the From: "name"
386 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
387 if from_tag:
388 from_tag = ' ' + from_tag
390 subject = '[%s%s] %s'%(cn, nodeid, title)
391 author = (authname + from_tag, from_address)
393 # send an individual message per recipient?
394 if self.db.config.NOSY_EMAIL_SENDING != 'single':
395 sendto = [[address] for address in sendto]
396 else:
397 sendto = [sendto]
399 tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
400 tracker_name = formataddr((tracker_name, from_address))
401 tracker_name = Header(tracker_name, charset)
403 # now send one or more messages
404 # TODO: I believe we have to create a new message each time as we
405 # can't fiddle the recipients in the message ... worth testing
406 # and/or fixing some day
407 first = True
408 for sendto in sendto:
409 # create the message
410 mailer = Mailer(self.db.config)
412 message = mailer.get_standard_message(sendto, subject, author,
413 multipart=message_files)
415 # set reply-to to the tracker
416 message['Reply-To'] = tracker_name
418 # message ids
419 if messageid:
420 message['Message-Id'] = messageid
421 if inreplyto:
422 message['In-Reply-To'] = inreplyto
424 # Generate a header for each link or multilink to
425 # a class that has a name attribute
426 for propname, prop in self.getprops().items():
427 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
428 continue
429 cl = self.db.getclass(prop.classname)
430 if not 'name' in cl.getprops():
431 continue
432 if isinstance(prop, hyperdb.Link):
433 value = self.get(nodeid, propname)
434 if value is None:
435 continue
436 values = [value]
437 else:
438 values = self.get(nodeid, propname)
439 if not values:
440 continue
441 values = [cl.get(v, 'name') for v in values]
442 values = ', '.join(values)
443 header = "X-Roundup-%s-%s"%(self.classname, propname)
444 try:
445 message[header] = values.encode('ascii')
446 except UnicodeError:
447 message[header] = Header(values, charset)
449 if not inreplyto:
450 # Default the reply to the first message
451 msgs = self.get(nodeid, 'messages')
452 # Assume messages are sorted by increasing message number here
453 # If the issue is just being created, and the submitter didn't
454 # provide a message, then msgs will be empty.
455 if msgs and msgs[0] != nodeid:
456 inreplyto = messages.get(msgs[0], 'messageid')
457 if inreplyto:
458 message['In-Reply-To'] = inreplyto
460 # attach files
461 if message_files:
462 # first up the text as a part
463 part = MIMEText(content)
464 encode_quopri(part)
465 message.attach(part)
467 for fileid in message_files:
468 name = files.get(fileid, 'name')
469 mime_type = files.get(fileid, 'type')
470 content = files.get(fileid, 'content')
471 if mime_type == 'text/plain':
472 try:
473 content.decode('ascii')
474 except UnicodeError:
475 # the content cannot be 7bit-encoded.
476 # use quoted printable
477 # XXX stuffed if we know the charset though :(
478 part = MIMEText(content)
479 encode_quopri(part)
480 else:
481 part = MIMEText(content)
482 part['Content-Transfer-Encoding'] = '7bit'
483 else:
484 # some other type, so encode it
485 if not mime_type:
486 # this should have been done when the file was saved
487 mime_type = mimetypes.guess_type(name)[0]
488 if mime_type is None:
489 mime_type = 'application/octet-stream'
490 main, sub = mime_type.split('/')
491 part = MIMEBase(main, sub)
492 part.set_payload(content)
493 Encoders.encode_base64(part)
494 part['Content-Disposition'] = 'attachment;\n filename="%s"'%name
495 message.attach(part)
497 else:
498 message.set_payload(content)
499 encode_quopri(message)
501 if first:
502 mailer.smtp_send(sendto + bcc_sendto, str(message))
503 else:
504 mailer.smtp_send(sendto, str(message))
505 first = False
507 def email_signature(self, nodeid, msgid):
508 ''' Add a signature to the e-mail with some useful information
509 '''
510 # simplistic check to see if the url is valid,
511 # then append a trailing slash if it is missing
512 base = self.db.config.TRACKER_WEB
513 if (not isinstance(base , type('')) or
514 not (base.startswith('http://') or base.startswith('https://'))):
515 web = "Configuration Error: TRACKER_WEB isn't a " \
516 "fully-qualified URL"
517 else:
518 if not base.endswith('/'):
519 base = base + '/'
520 web = base + self.classname + nodeid
522 # ensure the email address is properly quoted
523 email = formataddr((self.db.config.TRACKER_NAME,
524 self.db.config.TRACKER_EMAIL))
526 line = '_' * max(len(web)+2, len(email))
527 return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
530 def generateCreateNote(self, nodeid):
531 """Generate a create note that lists initial property values
532 """
533 cn = self.classname
534 cl = self.db.classes[cn]
535 props = cl.getprops(protected=0)
537 # list the values
538 m = []
539 prop_items = props.items()
540 prop_items.sort()
541 for propname, prop in prop_items:
542 value = cl.get(nodeid, propname, None)
543 # skip boring entries
544 if not value:
545 continue
546 if isinstance(prop, hyperdb.Link):
547 link = self.db.classes[prop.classname]
548 if value:
549 key = link.labelprop(default_to_id=1)
550 if key:
551 value = link.get(value, key)
552 else:
553 value = ''
554 elif isinstance(prop, hyperdb.Multilink):
555 if value is None: value = []
556 l = []
557 link = self.db.classes[prop.classname]
558 key = link.labelprop(default_to_id=1)
559 if key:
560 value = [link.get(entry, key) for entry in value]
561 value.sort()
562 value = ', '.join(value)
563 else:
564 value = str(value)
565 if '\n' in value:
566 value = '\n'+self.indentChangeNoteValue(value)
567 m.append('%s: %s'%(propname, value))
568 m.insert(0, '----------')
569 m.insert(0, '')
570 return '\n'.join(m)
572 def generateChangeNote(self, nodeid, oldvalues):
573 """Generate a change note that lists property changes
574 """
575 if not isinstance(oldvalues, type({})):
576 raise TypeError("'oldvalues' must be dict-like, not %s."%
577 type(oldvalues))
579 cn = self.classname
580 cl = self.db.classes[cn]
581 changed = {}
582 props = cl.getprops(protected=0)
584 # determine what changed
585 for key in oldvalues.keys():
586 if key in ['files','messages']:
587 continue
588 if key in ('actor', 'activity', 'creator', 'creation'):
589 continue
590 # not all keys from oldvalues might be available in database
591 # this happens when property was deleted
592 try:
593 new_value = cl.get(nodeid, key)
594 except KeyError:
595 continue
596 # the old value might be non existent
597 # this happens when property was added
598 try:
599 old_value = oldvalues[key]
600 if type(new_value) is type([]):
601 new_value.sort()
602 old_value.sort()
603 if new_value != old_value:
604 changed[key] = old_value
605 except:
606 changed[key] = new_value
608 # list the changes
609 m = []
610 changed_items = changed.items()
611 changed_items.sort()
612 for propname, oldvalue in changed_items:
613 prop = props[propname]
614 value = cl.get(nodeid, propname, None)
615 if isinstance(prop, hyperdb.Link):
616 link = self.db.classes[prop.classname]
617 key = link.labelprop(default_to_id=1)
618 if key:
619 if value:
620 value = link.get(value, key)
621 else:
622 value = ''
623 if oldvalue:
624 oldvalue = link.get(oldvalue, key)
625 else:
626 oldvalue = ''
627 change = '%s -> %s'%(oldvalue, value)
628 elif isinstance(prop, hyperdb.Multilink):
629 change = ''
630 if value is None: value = []
631 if oldvalue is None: oldvalue = []
632 l = []
633 link = self.db.classes[prop.classname]
634 key = link.labelprop(default_to_id=1)
635 # check for additions
636 for entry in value:
637 if entry in oldvalue: continue
638 if key:
639 l.append(link.get(entry, key))
640 else:
641 l.append(entry)
642 if l:
643 l.sort()
644 change = '+%s'%(', '.join(l))
645 l = []
646 # check for removals
647 for entry in oldvalue:
648 if entry in value: 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 else:
657 change = '%s -> %s'%(oldvalue, value)
658 if '\n' in change:
659 value = self.indentChangeNoteValue(str(value))
660 oldvalue = self.indentChangeNoteValue(str(oldvalue))
661 change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
662 "new": value, "old": oldvalue}
663 m.append('%s: %s'%(propname, change))
664 if m:
665 m.insert(0, '----------')
666 m.insert(0, '')
667 return '\n'.join(m)
669 def indentChangeNoteValue(self, text):
670 lines = text.rstrip('\n').split('\n')
671 lines = [ ' '+line for line in lines ]
672 return '\n'.join(lines)
674 # vim: set filetype=python sts=4 sw=4 et si :