Premiere version : mise en route du suivi.
[auf_roundup.git] / roundup / .svn / text-base / roundupdb.py.svn-base
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 #
19
20 """Extending hyperdb with types specific to issue-tracking.
21 """
22 __docformat__ = 'restructuredtext'
23
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
34 from roundup import password, date, hyperdb
35 from roundup.i18n import _
36
37 # MessageSendError is imported for backwards compatibility
38 from roundup.mailer import Mailer, MessageSendError, encode_quopri, \
39     nice_sender_header
40
41 class Database:
42
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]
62
63     def setCurrentUser(self, username):
64         """Set the user that is responsible for current database
65         activities.
66         """
67         self.journaltag = username
68
69     def isCurrentUser(self, username):
70         """Check if a given username equals the already active user.
71         """
72         return self.journaltag == username
73
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
92
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)
106
107         # tag new user creation with 'admin'
108         self.journaltag = 'admin'
109
110         # create the new user
111         cl = self.user
112
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()
118
119         return userid
120
121
122     def log_debug(self, msg, *args, **kwargs):
123         """Log a message with level DEBUG."""
124
125         logger = self.get_logger()
126         logger.debug(msg, *args, **kwargs)
127
128     def log_info(self, msg, *args, **kwargs):
129         """Log a message with level INFO."""
130
131         logger = self.get_logger()
132         logger.info(msg, *args, **kwargs)
133
134     def get_logger(self):
135         """Return the logger for this database."""
136
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')
141
142         return self.__logger
143
144
145 class DetectorError(RuntimeError):
146     """ Raised by detectors that want to indicate that something's amiss
147     """
148     pass
149
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:
156
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     """
163
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     )
180
181     # New methods:
182     def addmessage(self, issueid, summary, text):
183         """Add a message to an issue's mail spool.
184
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.
188
189         The "files" and "recipients" fields are left empty.
190
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         """
194
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.
198
199         The message is sent only to users on the nosy list who are not
200         already on the "recipients" list for the message.
201
202         These users are then added to the message's "recipients" list.
203
204         If 'msgid' is None, the message gets sent only to the nosy
205         list, and it's called a 'System Message'.
206
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.
210
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 = []
223
224         sendto = []
225         bcc_sendto = []
226         seen_message = {}
227         for recipient in recipients:
228             seen_message[recipient] = 1
229
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)
236
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))
254
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)
261
262         if authid:
263             seen_message[authid] = 1
264
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)
269
270         # now deal with bcc people.
271         for userid in bcc:
272             if good_recipient(userid):
273                 add_recipient(userid, bcc_sendto)
274
275         if oldvalues:
276             note = self.generateChangeNote(issueid, oldvalues)
277         else:
278             note = self.generateCreateNote(issueid)
279
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)
287
288     # backwards compatibility - don't remove
289     sendmessage = nosymessage
290
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
299
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')
306
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)
316
317         # compose title
318         cn = self.classname
319         title = self.get(issueid, 'title') or '%s message copy'%cn
320
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', '')
330
331         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
332             authaddr = " <%s>" % formataddr( ('',authaddr) )
333         elif authaddr:
334             authaddr = ""
335
336         # make the message body
337         m = ['']
338
339         # put in roundup's signature
340         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
341             m.append(self.email_signature(issueid, msgid))
342
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('')
354
355         # add the content
356         if msgid is not None:
357             m.append(messages.get(msgid, 'content', ''))
358
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())
373
374         # add the change note
375         if note:
376             m.append(note)
377
378         # put in roundup's signature
379         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
380             m.append(self.email_signature(issueid, msgid))
381
382         # figure the encoding
383         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
384
385         # construct the content and convert to unicode object
386         body = unicode('\n'.join(m), 'utf-8').encode(charset)
387
388         # make sure the To line is always the same (for testing mostly)
389         sendto.sort()
390
391         # make sure we have a from address
392         if from_address is None:
393             from_address = self.db.config.TRACKER_EMAIL
394
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
399
400         subject = '[%s%s] %s'%(cn, issueid, title)
401         author = (authname + from_tag, from_address)
402
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]
408
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)
413
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)
422
423             message = mailer.get_standard_message(sendto, subject, author,
424                 multipart=message_files)
425
426             # set reply-to to the tracker
427             message['Reply-To'] = tracker_name
428
429             # message ids
430             if messageid:
431                 message['Message-Id'] = messageid
432             if inreplyto:
433                 message['In-Reply-To'] = inreplyto
434
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)
459
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
470
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)
477
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)
507
508             else:
509                 message.set_payload(body)
510                 encode_quopri(message)
511
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
517
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
532
533         # ensure the email address is properly quoted
534         email = formataddr((self.db.config.TRACKER_NAME,
535             self.db.config.TRACKER_EMAIL))
536
537         line = '_' * max(len(web)+2, len(email))
538         return '\n%s\n%s\n<%s>\n%s'%(line, email, web, line)
539
540
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)
547
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)
582
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))
589
590         cn = self.classname
591         cl = self.db.classes[cn]
592         changed = {}
593         props = cl.getprops(protected=0)
594
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
618
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)
679
680     def indentChangeNoteValue(self, text):
681         lines = text.rstrip('\n').split('\n')
682         lines = [ '  '+line for line in lines ]
683         return '\n'.join(lines)
684
685 # vim: set filetype=python sts=4 sw=4 et si :