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