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 roundup import password, date, hyperdb
35 from roundup.i18n import _
37 # MessageSendError is imported for backwards compatibility
38 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
39 nice_sender_header
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, issueid, 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, issueid, 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 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))
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)
262 if authid:
263 seen_message[authid] = 1
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)
270 # now deal with bcc people.
271 for userid in bcc:
272 if good_recipient(userid):
273 add_recipient(userid, bcc_sendto)
275 if oldvalues:
276 note = self.generateChangeNote(issueid, oldvalues)
277 else:
278 note = self.generateCreateNote(issueid)
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)
288 # backwards compatibility - don't remove
289 sendmessage = nosymessage
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
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')
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)
317 # compose title
318 cn = self.classname
319 title = self.get(issueid, 'title') or '%s message copy'%cn
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', '')
331 if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
332 authaddr = " <%s>" % formataddr( ('',authaddr) )
333 elif authaddr:
334 authaddr = ""
336 # make the message body
337 m = ['']
339 # put in roundup's signature
340 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
341 m.append(self.email_signature(issueid, msgid))
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('')
355 # add the content
356 if msgid is not None:
357 m.append(messages.get(msgid, 'content', ''))
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())
374 # add the change note
375 if note:
376 m.append(note)
378 # put in roundup's signature
379 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
380 m.append(self.email_signature(issueid, msgid))
382 # figure the encoding
383 charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
385 # construct the content and convert to unicode object
386 body = unicode('\n'.join(m), 'utf-8').encode(charset)
388 # make sure the To line is always the same (for testing mostly)
389 sendto.sort()
391 # make sure we have a from address
392 if from_address is None:
393 from_address = self.db.config.TRACKER_EMAIL
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
400 subject = '[%s%s] %s'%(cn, issueid, title)
401 author = (authname + from_tag, from_address)
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]
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)
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)
423 message = mailer.get_standard_message(sendto, subject, author,
424 multipart=message_files)
426 # set reply-to to the tracker
427 message['Reply-To'] = tracker_name
429 # message ids
430 if messageid:
431 message['Message-Id'] = messageid
432 if inreplyto:
433 message['In-Reply-To'] = inreplyto
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)
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
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)
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)
508 else:
509 message.set_payload(body)
510 encode_quopri(message)
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
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
533 # ensure the email address is properly quoted
534 email = formataddr((self.db.config.TRACKER_NAME,
535 self.db.config.TRACKER_EMAIL))
537 line = '_' * max(len(web)+2, len(email))
538 return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
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)
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)
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))
590 cn = self.classname
591 cl = self.db.classes[cn]
592 changed = {}
593 props = cl.getprops(protected=0)
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
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)
680 def indentChangeNoteValue(self, text):
681 lines = text.rstrip('\n').split('\n')
682 lines = [ ' '+line for line in lines ]
683 return '\n'.join(lines)
685 # vim: set filetype=python sts=4 sw=4 et si :