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