998b29051912d85425cde44b47bf019abbf7ed12
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 return (userid and
240 (self.db.user.get(userid, 'username') != 'anonymous') and
241 not seen_message.has_key(userid))
243 # possibly send the message to the author, as long as they aren't
244 # anonymous
245 if (good_recipient(authid) and
246 (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
247 (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues))):
248 add_recipient(authid, sendto)
250 if authid:
251 seen_message[authid] = 1
253 # now deal with the nosy and cc people who weren't recipients.
254 for userid in cc + self.get(nodeid, whichnosy):
255 if good_recipient(userid):
256 add_recipient(userid, sendto)
258 # now deal with bcc people.
259 for userid in bcc:
260 if good_recipient(userid):
261 add_recipient(userid, bcc_sendto)
263 if oldvalues:
264 note = self.generateChangeNote(nodeid, oldvalues)
265 else:
266 note = self.generateCreateNote(nodeid)
268 # If we have new recipients, update the message's recipients
269 # and send the mail.
270 if sendto or bcc_sendto:
271 if msgid is not None:
272 self.db.msg.set(msgid, recipients=recipients)
273 self.send_message(nodeid, msgid, note, sendto, from_address,
274 bcc_sendto)
276 # backwards compatibility - don't remove
277 sendmessage = nosymessage
279 def send_message(self, nodeid, msgid, note, sendto, from_address=None,
280 bcc_sendto=[]):
281 '''Actually send the nominated message from this node to the sendto
282 recipients, with the note appended.
283 '''
284 users = self.db.user
285 messages = self.db.msg
286 files = self.db.file
288 if msgid is None:
289 inreplyto = None
290 messageid = None
291 else:
292 inreplyto = messages.get(msgid, 'inreplyto')
293 messageid = messages.get(msgid, 'messageid')
295 # make up a messageid if there isn't one (web edit)
296 if not messageid:
297 # this is an old message that didn't get a messageid, so
298 # create one
299 messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(),
300 self.classname, nodeid,
301 self.db.config.MAIL_DOMAIN)
302 if msgid is not None:
303 messages.set(msgid, messageid=messageid)
305 # compose title
306 cn = self.classname
307 title = self.get(nodeid, 'title') or '%s message copy'%cn
309 # figure author information
310 if msgid:
311 authid = messages.get(msgid, 'author')
312 else:
313 authid = self.db.getuid()
314 authname = users.get(authid, 'realname')
315 if not authname:
316 authname = users.get(authid, 'username', '')
317 authaddr = users.get(authid, 'address', '')
319 if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
320 authaddr = " <%s>" % formataddr( ('',authaddr) )
321 elif authaddr:
322 authaddr = ""
324 # make the message body
325 m = ['']
327 # put in roundup's signature
328 if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
329 m.append(self.email_signature(nodeid, msgid))
331 # add author information
332 if authid and self.db.config.MAIL_ADD_AUTHORINFO:
333 if msgid and len(self.get(nodeid, 'messages')) == 1:
334 m.append(_("New submission from %(authname)s%(authaddr)s:")
335 % locals())
336 elif msgid:
337 m.append(_("%(authname)s%(authaddr)s added the comment:")
338 % locals())
339 else:
340 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
341 m.append('')
343 # add the content
344 if msgid is not None:
345 m.append(messages.get(msgid, 'content', ''))
347 # get the files for this message
348 message_files = []
349 if msgid :
350 for fileid in messages.get(msgid, 'files') :
351 # check the attachment size
352 filename = self.db.filename('file', fileid, None)
353 filesize = os.path.getsize(filename)
354 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
355 message_files.append(fileid)
356 else:
357 base = self.db.config.TRACKER_WEB
358 link = "".join((base, files.classname, fileid))
359 filename = files.get(fileid, 'name')
360 m.append(_("File '%(filename)s' not attached - "
361 "you can download it from %(link)s.") % locals())
363 # add the change note
364 if note:
365 m.append(note)
367 # put in roundup's signature
368 if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
369 m.append(self.email_signature(nodeid, msgid))
371 # figure the encoding
372 charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
374 # construct the content and convert to unicode object
375 body = unicode('\n'.join(m), 'utf-8').encode(charset)
377 # make sure the To line is always the same (for testing mostly)
378 sendto.sort()
380 # make sure we have a from address
381 if from_address is None:
382 from_address = self.db.config.TRACKER_EMAIL
384 # additional bit for after the From: "name"
385 from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
386 if from_tag:
387 from_tag = ' ' + from_tag
389 subject = '[%s%s] %s'%(cn, nodeid, title)
390 author = (authname + from_tag, from_address)
392 # send an individual message per recipient?
393 if self.db.config.NOSY_EMAIL_SENDING != 'single':
394 sendto = [[address] for address in sendto]
395 else:
396 sendto = [sendto]
398 tracker_name = unicode(self.db.config.TRACKER_NAME, 'utf-8')
399 tracker_name = formataddr((tracker_name, from_address))
400 tracker_name = Header(tracker_name, charset)
402 # now send one or more messages
403 # TODO: I believe we have to create a new message each time as we
404 # can't fiddle the recipients in the message ... worth testing
405 # and/or fixing some day
406 first = True
407 for sendto in sendto:
408 # create the message
409 mailer = Mailer(self.db.config)
411 message = mailer.get_standard_message(sendto, subject, author,
412 multipart=message_files)
414 # set reply-to to the tracker
415 message['Reply-To'] = tracker_name
417 # message ids
418 if messageid:
419 message['Message-Id'] = messageid
420 if inreplyto:
421 message['In-Reply-To'] = inreplyto
423 # Generate a header for each link or multilink to
424 # a class that has a name attribute
425 for propname, prop in self.getprops().items():
426 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
427 continue
428 cl = self.db.getclass(prop.classname)
429 if not 'name' in cl.getprops():
430 continue
431 if isinstance(prop, hyperdb.Link):
432 value = self.get(nodeid, propname)
433 if value is None:
434 continue
435 values = [value]
436 else:
437 values = self.get(nodeid, propname)
438 if not values:
439 continue
440 values = [cl.get(v, 'name') for v in values]
441 values = ', '.join(values)
442 header = "X-Roundup-%s-%s"%(self.classname, propname)
443 try:
444 message[header] = values.encode('ascii')
445 except UnicodeError:
446 message[header] = Header(values, charset)
448 if not inreplyto:
449 # Default the reply to the first message
450 msgs = self.get(nodeid, 'messages')
451 # Assume messages are sorted by increasing message number here
452 # If the issue is just being created, and the submitter didn't
453 # provide a message, then msgs will be empty.
454 if msgs and msgs[0] != nodeid:
455 inreplyto = messages.get(msgs[0], 'messageid')
456 if inreplyto:
457 message['In-Reply-To'] = inreplyto
459 # attach files
460 if message_files:
461 # first up the text as a part
462 part = MIMEText(body)
463 encode_quopri(part)
464 message.attach(part)
466 for fileid in message_files:
467 name = files.get(fileid, 'name')
468 mime_type = files.get(fileid, 'type')
469 content = files.get(fileid, 'content')
470 if mime_type == 'text/plain':
471 try:
472 content.decode('ascii')
473 except UnicodeError:
474 # the content cannot be 7bit-encoded.
475 # use quoted printable
476 # XXX stuffed if we know the charset though :(
477 part = MIMEText(content)
478 encode_quopri(part)
479 else:
480 part = MIMEText(content)
481 part['Content-Transfer-Encoding'] = '7bit'
482 else:
483 # some other type, so encode it
484 if not mime_type:
485 # this should have been done when the file was saved
486 mime_type = mimetypes.guess_type(name)[0]
487 if mime_type is None:
488 mime_type = 'application/octet-stream'
489 main, sub = mime_type.split('/')
490 part = MIMEBase(main, sub)
491 part.set_payload(content)
492 Encoders.encode_base64(part)
493 part['Content-Disposition'] = 'attachment;\n filename="%s"'%name
494 message.attach(part)
496 else:
497 message.set_payload(body)
498 encode_quopri(message)
500 if first:
501 mailer.smtp_send(sendto + bcc_sendto, message.as_string())
502 else:
503 mailer.smtp_send(sendto, message.as_string())
504 first = False
506 def email_signature(self, nodeid, msgid):
507 ''' Add a signature to the e-mail with some useful information
508 '''
509 # simplistic check to see if the url is valid,
510 # then append a trailing slash if it is missing
511 base = self.db.config.TRACKER_WEB
512 if (not isinstance(base , type('')) or
513 not (base.startswith('http://') or base.startswith('https://'))):
514 web = "Configuration Error: TRACKER_WEB isn't a " \
515 "fully-qualified URL"
516 else:
517 if not base.endswith('/'):
518 base = base + '/'
519 web = base + self.classname + nodeid
521 # ensure the email address is properly quoted
522 email = formataddr((self.db.config.TRACKER_NAME,
523 self.db.config.TRACKER_EMAIL))
525 line = '_' * max(len(web)+2, len(email))
526 return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
529 def generateCreateNote(self, nodeid):
530 """Generate a create note that lists initial property values
531 """
532 cn = self.classname
533 cl = self.db.classes[cn]
534 props = cl.getprops(protected=0)
536 # list the values
537 m = []
538 prop_items = props.items()
539 prop_items.sort()
540 for propname, prop in prop_items:
541 value = cl.get(nodeid, propname, None)
542 # skip boring entries
543 if not value:
544 continue
545 if isinstance(prop, hyperdb.Link):
546 link = self.db.classes[prop.classname]
547 if value:
548 key = link.labelprop(default_to_id=1)
549 if key:
550 value = link.get(value, key)
551 else:
552 value = ''
553 elif isinstance(prop, hyperdb.Multilink):
554 if value is None: value = []
555 l = []
556 link = self.db.classes[prop.classname]
557 key = link.labelprop(default_to_id=1)
558 if key:
559 value = [link.get(entry, key) for entry in value]
560 value.sort()
561 value = ', '.join(value)
562 else:
563 value = str(value)
564 if '\n' in value:
565 value = '\n'+self.indentChangeNoteValue(value)
566 m.append('%s: %s'%(propname, value))
567 m.insert(0, '----------')
568 m.insert(0, '')
569 return '\n'.join(m)
571 def generateChangeNote(self, nodeid, oldvalues):
572 """Generate a change note that lists property changes
573 """
574 if not isinstance(oldvalues, type({})):
575 raise TypeError("'oldvalues' must be dict-like, not %s."%
576 type(oldvalues))
578 cn = self.classname
579 cl = self.db.classes[cn]
580 changed = {}
581 props = cl.getprops(protected=0)
583 # determine what changed
584 for key in oldvalues.keys():
585 if key in ['files','messages']:
586 continue
587 if key in ('actor', 'activity', 'creator', 'creation'):
588 continue
589 # not all keys from oldvalues might be available in database
590 # this happens when property was deleted
591 try:
592 new_value = cl.get(nodeid, key)
593 except KeyError:
594 continue
595 # the old value might be non existent
596 # this happens when property was added
597 try:
598 old_value = oldvalues[key]
599 if type(new_value) is type([]):
600 new_value.sort()
601 old_value.sort()
602 if new_value != old_value:
603 changed[key] = old_value
604 except:
605 changed[key] = new_value
607 # list the changes
608 m = []
609 changed_items = changed.items()
610 changed_items.sort()
611 for propname, oldvalue in changed_items:
612 prop = props[propname]
613 value = cl.get(nodeid, propname, None)
614 if isinstance(prop, hyperdb.Link):
615 link = self.db.classes[prop.classname]
616 key = link.labelprop(default_to_id=1)
617 if key:
618 if value:
619 value = link.get(value, key)
620 else:
621 value = ''
622 if oldvalue:
623 oldvalue = link.get(oldvalue, key)
624 else:
625 oldvalue = ''
626 change = '%s -> %s'%(oldvalue, value)
627 elif isinstance(prop, hyperdb.Multilink):
628 change = ''
629 if value is None: value = []
630 if oldvalue is None: oldvalue = []
631 l = []
632 link = self.db.classes[prop.classname]
633 key = link.labelprop(default_to_id=1)
634 # check for additions
635 for entry in value:
636 if entry in oldvalue: continue
637 if key:
638 l.append(link.get(entry, key))
639 else:
640 l.append(entry)
641 if l:
642 l.sort()
643 change = '+%s'%(', '.join(l))
644 l = []
645 # check for removals
646 for entry in oldvalue:
647 if entry in value: 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 else:
656 change = '%s -> %s'%(oldvalue, value)
657 if '\n' in change:
658 value = self.indentChangeNoteValue(str(value))
659 oldvalue = self.indentChangeNoteValue(str(oldvalue))
660 change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
661 "new": value, "old": oldvalue}
662 m.append('%s: %s'%(propname, change))
663 if m:
664 m.insert(0, '----------')
665 m.insert(0, '')
666 return '\n'.join(m)
668 def indentChangeNoteValue(self, text):
669 lines = text.rstrip('\n').split('\n')
670 lines = [ ' '+line for line in lines ]
671 return '\n'.join(lines)
673 # vim: set filetype=python sts=4 sw=4 et si :