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