"Fossies" - the Fresh Open Source Software Archive

Member "roundup-2.0.0/roundup/roundupdb.py" (30 Apr 2020, 36212 Bytes) of package /linux/www/roundup-2.0.0.tar.gz:


As a special service "Fossies" has tried to format the requested source page into HTML format using (guessed) Python source code syntax highlighting (style: standard) with prefixed line numbers. Alternatively you can here view or download the uninterpreted source code file. See also the latest Fossies "Diffs" side-by-side code changes report for "roundupdb.py": 1.6.1_vs_2.0.0.

    1 #
    2 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
    3 # This module is free software, and you may redistribute it and/or modify
    4 # under the same terms as Python, so long as this copyright message and
    5 # disclaimer are retained in their original form.
    6 #
    7 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
    8 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
    9 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
   10 # POSSIBILITY OF SUCH DAMAGE.
   11 #
   12 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
   13 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
   14 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
   15 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
   16 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
   17 #
   18 
   19 """Extending hyperdb with types specific to issue-tracking.
   20 """
   21 __docformat__ = 'restructuredtext'
   22 
   23 import time
   24 import base64, mimetypes
   25 import logging
   26 from email import encoders
   27 from email.parser import FeedParser
   28 from email.utils import formataddr
   29 from email.header import Header
   30 from email.mime.text import MIMEText
   31 from email.mime.base import MIMEBase
   32 from email.mime.multipart import MIMEMultipart
   33 
   34 from roundup import password, date, hyperdb
   35 from roundup.i18n import _
   36 from roundup.hyperdb import iter_roles
   37 
   38 from roundup.mailer import Mailer, MessageSendError, nice_sender_header
   39 
   40 from roundup.anypy.strings import b2s, s2u
   41 import roundup.anypy.random_ as random_
   42 
   43 try:
   44     import gpg, gpg.core
   45 except ImportError:
   46     gpg = None
   47 
   48 
   49 class Database(object):
   50 
   51     # remember the journal uid for the current journaltag so that:
   52     # a. we don't have to look it up every time we need it, and
   53     # b. if the journaltag disappears during a transaction, we don't barf
   54     #    (eg. the current user edits their username)
   55     journal_uid = None
   56 
   57     def getuid(self):
   58         """Return the id of the "user" node associated with the user
   59         that owns this connection to the hyperdatabase."""
   60         if self.journaltag is None:
   61             return None
   62         elif self.journaltag == 'admin':
   63             # admin user may not exist, but always has ID 1
   64             return '1'
   65         else:
   66             if (self.journal_uid is None or self.journal_uid[0] !=
   67                     self.journaltag):
   68                 uid = self.user.lookup(self.journaltag)
   69                 self.journal_uid = (self.journaltag, uid)
   70             return self.journal_uid[1]
   71 
   72     def setCurrentUser(self, username):
   73         """Set the user that is responsible for current database
   74         activities.
   75         """
   76         self.journaltag = username
   77 
   78     def isCurrentUser(self, username):
   79         """Check if a given username equals the already active user.
   80         """
   81         return self.journaltag == username
   82 
   83     def getUserTimezone(self):
   84         """Return user timezone defined in 'timezone' property of user class.
   85         If no such property exists return 0
   86         """
   87         userid = self.getuid()
   88         timezone = None
   89         try:
   90             tz = self.user.get(userid, 'timezone')
   91             date.get_timezone(tz)
   92             timezone = tz
   93         except KeyError:
   94             pass
   95         # If there is no class 'user' or current user doesn't have timezone
   96         # property or that property is not set assume he/she lives in
   97         # the timezone set in the tracker config.
   98         if timezone is None:
   99             timezone = self.config['TIMEZONE']
  100         return timezone
  101 
  102     def confirm_registration(self, otk):
  103         props = self.getOTKManager().getall(otk)
  104         for propname, proptype in self.user.getprops().items():
  105             value = props.get(propname, None)
  106             if value is None:
  107                 pass
  108             elif isinstance(proptype, hyperdb.Date):
  109                 props[propname] = date.Date(value)
  110             elif isinstance(proptype, hyperdb.Interval):
  111                 props[propname] = date.Interval(value)
  112             elif isinstance(proptype, hyperdb.Password):
  113                 props[propname] = password.Password(encrypted=value)
  114 
  115         # tag new user creation with 'admin'
  116         self.journaltag = 'admin'
  117 
  118         # create the new user
  119         cl = self.user
  120 
  121         props['roles'] = self.config.NEW_WEB_USER_ROLES
  122         try:
  123             # ASSUME:: ValueError raised during create due to key value
  124             # conflict. I an use message in exception to determine
  125             # when I should intercept the exception with a more
  126             # friendly error message. If i18n is used to translate
  127             # original exception message this will fail and translated
  128             # text (probably unfriendly) will be used.
  129             userid = cl.create(**props)
  130         except ValueError as e:
  131             username = props['username']
  132             # Try to make error message less cryptic to the user.
  133             if str(e) == 'node with key "%s" exists' % username:
  134                 raise ValueError(
  135                     _("Username '%s' already exists." % username))
  136             else:
  137                 raise
  138 
  139             # clear the props from the otk database
  140         self.getOTKManager().destroy(otk)
  141         # commit cl.create (and otk changes)
  142         self.commit()
  143 
  144         return userid
  145 
  146     def log_debug(self, msg, *args, **kwargs):
  147         """Log a message with level DEBUG."""
  148 
  149         logger = self.get_logger()
  150         logger.debug(msg, *args, **kwargs)
  151 
  152     def log_info(self, msg, *args, **kwargs):
  153         """Log a message with level INFO."""
  154 
  155         logger = self.get_logger()
  156         logger.info(msg, *args, **kwargs)
  157 
  158     def get_logger(self):
  159         """Return the logger for this database."""
  160 
  161         # Because getting a logger requires acquiring a lock, we want
  162         # to do it only once.
  163         if not hasattr(self, '__logger'):
  164             self.__logger = logging.getLogger('roundup.hyperdb')
  165 
  166         return self.__logger
  167 
  168     def clearCache(self):
  169         """ Backends may keep a cache.
  170             It must be cleared at end of commit and rollback methods.
  171             We allow to register user-defined cache-clearing routines
  172             that are called by this routine.
  173         """
  174         if getattr(self, 'cache_callbacks', None):
  175             for method, param in self.cache_callbacks:
  176                 method(param)
  177 
  178     def registerClearCacheCallback(self, method, param=None):
  179         """ Register a callback method for clearing the cache.
  180             It is called with the given param as the only parameter.
  181             Even if the parameter is not specified, the method has to
  182             accept a single parameter.
  183         """
  184         if not getattr(self, 'cache_callbacks', None):
  185             self.cache_callbacks = []
  186         self.cache_callbacks.append((method, param))
  187 
  188 
  189 class DetectorError(RuntimeError):
  190     """ Raised by detectors that want to indicate that something's amiss
  191     """
  192     pass
  193 
  194 
  195 # deviation from spec - was called IssueClass
  196 class IssueClass:
  197     """This class is intended to be mixed-in with a hyperdb backend
  198     implementation. The backend should provide a mechanism that
  199     enforces the title, messages, files, nosy and superseder
  200     properties:
  201 
  202     - title = hyperdb.String(indexme='yes')
  203     - messages = hyperdb.Multilink("msg")
  204     - files = hyperdb.Multilink("file")
  205     - nosy = hyperdb.Multilink("user")
  206     - superseder = hyperdb.Multilink(classname)
  207     """
  208 
  209     # The tuple below does not affect the class definition.
  210     # It just lists all names of all issue properties
  211     # marked for message extraction tool.
  212     #
  213     # XXX is there better way to get property names into message catalog??
  214     #
  215     # Note that this list also includes properties
  216     # defined in the classic template:
  217     # assignedto, keyword, priority, status.
  218     (
  219         ''"title", ''"messages", ''"files", ''"nosy", ''"superseder",
  220         ''"assignedto", ''"keyword", ''"priority", ''"status",
  221         # following properties are common for all hyperdb classes
  222         # they are listed here to keep things in one place
  223         ''"actor", ''"activity", ''"creator", ''"creation",
  224     )
  225 
  226     # New methods:
  227     def addmessage(self, issueid, summary, text):
  228         """Add a message to an issue's mail spool.
  229 
  230         A new "msg" node is constructed using the current date, the user that
  231         owns the database connection as the author, and the specified summary
  232         text.
  233 
  234         The "files" and "recipients" fields are left empty.
  235 
  236         The given text is saved as the body of the message and the node is
  237         appended to the "messages" field of the specified issue.
  238         """
  239 
  240     def nosymessage(self, issueid, msgid, oldvalues, whichnosy='nosy',
  241                     from_address=None, cc=[], bcc=[], cc_emails=[],
  242                     bcc_emails=[], subject=None,
  243                     note_filter=None, add_headers={}):
  244         """Send a message to the members of an issue's nosy list.
  245 
  246         The message is sent only to users on the nosy list who are not
  247         already on the "recipients" list for the message.
  248 
  249         These users are then added to the message's "recipients" list.
  250 
  251         If 'msgid' is None, the message gets sent only to the nosy
  252         list, and it's called a 'System Message'.
  253 
  254         The "subject" argument is used as subject for the message. If no
  255         subject is passed, a subject will be generated from the message.
  256         Note the subject does not include the item designator [classID]
  257         prefix that allows proper processing of reply emails. The caller
  258         needs to include that label in the subject if needed.
  259 
  260         The "cc" argument indicates additional recipients to send the
  261         message to that may not be specified in the message's recipients
  262         list.
  263 
  264         The "bcc" argument also indicates additional recipients to send the
  265         message to that may not be specified in the message's recipients
  266         list. These recipients will not be included in the To: or Cc:
  267         address lists. Note that the list of bcc users *is* updated in
  268         the recipient list of the message, so this field has to be
  269         protected (using appropriate permissions), otherwise the bcc
  270         will be deduceable for users who have web access to the tracker.
  271 
  272         The cc_emails and bcc_emails arguments take a list of additional
  273         recipient email addresses (just the mail address not roundup users)
  274         this can be useful for sending to additional email addresses
  275         which are not roundup users. These arguments are currently not
  276         used by roundups nosyreaction but can be used by customized
  277         (nosy-)reactors.
  278 
  279         A note on encryption: If pgp encryption for outgoing mails is
  280         turned on in the configuration and no specific pgp roles are
  281         defined, we try to send encrypted mail to *all* users
  282         *including* cc, bcc, cc_emails and bcc_emails and this might
  283         fail if not all the keys are available in roundups keyring.
  284 
  285         If note_filter is specified it is a function with this
  286         prototype:
  287             note_filter(original_note, issueid, newvalues, oldvalues)
  288         If called, note_filter returns the new value for the message body.
  289 
  290         The add_headers parameter allows to set additional headers for
  291         the outgoing email.
  292         """
  293         encrypt = self.db.config.PGP_ENABLE and self.db.config.PGP_ENCRYPT
  294         pgproles = self.db.config.PGP_ROLES
  295         if msgid:
  296             authid = self.db.msg.get(msgid, 'author')
  297             recipients = self.db.msg.get(msgid, 'recipients', [])
  298         else:
  299             # "system message"
  300             authid = None
  301             recipients = []
  302 
  303         sendto = dict(plain=[], crypt=[])
  304         bcc_sendto = dict(plain=[], crypt=[])
  305         seen_message = {}
  306         for recipient in recipients:
  307             seen_message[recipient] = 1
  308 
  309         def add_recipient(userid, to):
  310             """ make sure they have an address """
  311             address = self.db.user.get(userid, 'address')
  312             if address:
  313                 ciphered = encrypt and (not pgproles or
  314                     self.db.user.has_role(userid, *iter_roles(pgproles)))
  315                 type = ['plain', 'crypt'][ciphered]
  316                 to[type].append(address)
  317                 recipients.append(userid)
  318 
  319         def good_recipient(userid):
  320             """ Make sure we don't send mail to either the anonymous
  321                 user or a user who has already seen the message.
  322                 Also check permissions on the message if not a system
  323                 message: A user must have view permission on content and
  324                 files to be on the receiver list. We do *not* check the
  325                 author etc. for now.
  326             """
  327             allowed = True
  328             if msgid:
  329                 for prop in 'content', 'files':
  330                     if prop in self.db.msg.properties:
  331                         allowed = allowed and self.db.security.hasPermission(
  332                             'View', userid, 'msg', prop, msgid)
  333             return (userid and
  334                     (self.db.user.get(userid, 'username') != 'anonymous') and
  335                     allowed and userid not in seen_message)
  336 
  337         # possibly send the message to the author, as long as they aren't
  338         # anonymous
  339         if (good_recipient(authid) and
  340             (self.db.config.MESSAGES_TO_AUTHOR == 'yes' or
  341              (self.db.config.MESSAGES_TO_AUTHOR == 'new' and not oldvalues) or
  342              (self.db.config.MESSAGES_TO_AUTHOR == 'nosy' and authid in
  343               self.get(issueid, whichnosy)))):
  344             add_recipient(authid, sendto)
  345 
  346         if authid:
  347             seen_message[authid] = 1
  348 
  349         # now deal with the nosy and cc people who weren't recipients.
  350         for userid in cc + self.get(issueid, whichnosy):
  351             if good_recipient(userid):
  352                 add_recipient(userid, sendto)
  353                 seen_message[userid] = 1
  354         if encrypt and not pgproles:
  355             sendto['crypt'].extend(cc_emails)
  356         else:
  357             sendto['plain'].extend(cc_emails)
  358 
  359         # now deal with bcc people.
  360         for userid in bcc:
  361             if good_recipient(userid):
  362                 add_recipient(userid, bcc_sendto)
  363                 seen_message[userid] = 1
  364         if encrypt and not pgproles:
  365             bcc_sendto['crypt'].extend(bcc_emails)
  366         else:
  367             bcc_sendto['plain'].extend(bcc_emails)
  368 
  369         if oldvalues:
  370             note = self.generateChangeNote(issueid, oldvalues)
  371         else:
  372             note = self.generateCreateNote(issueid)
  373         if note_filter:
  374             cn = self.classname
  375             cl = self.db.classes[cn]
  376             note = note_filter(note, issueid, self.db, cl, oldvalues)
  377 
  378         # If we have new recipients, update the message's recipients
  379         # and send the mail.
  380         if sendto['plain'] or sendto['crypt']:
  381             # update msgid and recipients only if non-bcc have changed
  382             if msgid is not None:
  383                 self.db.msg.set(msgid, recipients=recipients)
  384         if sendto['plain'] or bcc_sendto['plain']:
  385             self.send_message(issueid, msgid, note, sendto['plain'],
  386                               from_address, bcc_sendto['plain'],
  387                               subject, add_headers=add_headers)
  388         if sendto['crypt'] or bcc_sendto['crypt']:
  389             self.send_message(issueid, msgid, note, sendto['crypt'],
  390                               from_address, bcc_sendto['crypt'], subject,
  391                               crypt=True, add_headers=add_headers)
  392 
  393     # backwards compatibility - don't remove
  394     sendmessage = nosymessage
  395 
  396     def encrypt_to(self, message, sendto):
  397         """ Encrypt given message to sendto receivers.
  398             Returns a new RFC 3156 conforming message.
  399         """
  400         plain = gpg.core.Data(message.as_string())
  401         cipher = gpg.core.Data()
  402         ctx = gpg.core.Context()
  403         ctx.set_armor(1)
  404         keys = []
  405         for adr in sendto:
  406             ctx.op_keylist_start(adr, 0)
  407             # only first key per email
  408             k = ctx.op_keylist_next()
  409             if k is not None:
  410                 keys.append(k)
  411             else:
  412                 msg = _('No key for "%(adr)s" in keyring') % locals()
  413                 raise MessageSendError(msg)
  414             ctx.op_keylist_end()
  415         ctx.op_encrypt(keys, 1, plain, cipher)
  416         cipher.seek(0, 0)
  417         msg = MIMEMultipart('encrypted', boundary=None, _subparts=None,
  418                             protocol="application/pgp-encrypted")
  419         part = MIMEBase('application', 'pgp-encrypted')
  420         part.set_payload("Version: 1\r\n")
  421         msg.attach(part)
  422         part = MIMEBase('application', 'octet-stream')
  423         part.set_payload(cipher.read())
  424         msg.attach(part)
  425         return msg
  426 
  427     def send_message(self, issueid, msgid, note, sendto, from_address=None,
  428                      bcc_sendto=[], subject=None, crypt=False,
  429                      add_headers={}):
  430         '''Actually send the nominated message from this issue to the sendto
  431            recipients, with the note appended. It's possible to add
  432            headers to the message with the add_headers variable.
  433         '''
  434         users = self.db.user
  435         messages = self.db.msg
  436         files = self.db.file
  437 
  438         if msgid is None:
  439             inreplyto = None
  440             messageid = None
  441         else:
  442             inreplyto = messages.get(msgid, 'inreplyto')
  443             messageid = messages.get(msgid, 'messageid')
  444 
  445         # make up a messageid if there isn't one (web edit)
  446         if not messageid:
  447             # this is an old message that didn't get a messageid, so
  448             # create one
  449             messageid = "<%s.%s.%s%s@%s>" % (time.time(),
  450                 b2s(base64.b32encode(random_.token_bytes(10))),
  451                 self.classname, issueid, self.db.config['MAIL_DOMAIN'])
  452             if msgid is not None:
  453                 messages.set(msgid, messageid=messageid)
  454 
  455         # compose title
  456         cn = self.classname
  457         title = self.get(issueid, 'title') or '%s message copy' % cn
  458 
  459         # figure author information
  460         if msgid:
  461             authid = messages.get(msgid, 'author')
  462         else:
  463             authid = self.db.getuid()
  464         authname = users.get(authid, 'realname')
  465         if not authname:
  466             authname = users.get(authid, 'username', '')
  467         authaddr = users.get(authid, 'address', '')
  468 
  469         if authaddr and self.db.config.MAIL_ADD_AUTHOREMAIL:
  470             authaddr = " <%s>" % formataddr(('', authaddr))
  471         elif authaddr:
  472             authaddr = ""
  473 
  474         # make the message body
  475         m = ['']
  476 
  477         # put in roundup's signature
  478         if self.db.config.EMAIL_SIGNATURE_POSITION == 'top':
  479             m.append(self.email_signature(issueid, msgid))
  480 
  481         # add author information
  482         if authid and self.db.config.MAIL_ADD_AUTHORINFO:
  483             if msgid and len(self.get(issueid, 'messages')) == 1:
  484                 m.append(_("New submission from %(authname)s%(authaddr)s:")
  485                          % locals())
  486             elif msgid:
  487                 m.append(_("%(authname)s%(authaddr)s added the comment:")
  488                          % locals())
  489             else:
  490                 m.append(_("Change by %(authname)s%(authaddr)s:") % locals())
  491             m.append('')
  492 
  493         # add the content
  494         if msgid is not None:
  495             m.append(messages.get(msgid, 'content', ''))
  496 
  497         # get the files for this message
  498         message_files = []
  499         if msgid:
  500             for fileid in messages.get(msgid, 'files'):
  501                 # check the attachment size
  502                 filesize = self.db.filesize('file', fileid, None)
  503                 if filesize <= self.db.config.NOSY_MAX_ATTACHMENT_SIZE:
  504                     message_files.append(fileid)
  505                 else:
  506                     base = self.db.config.TRACKER_WEB
  507                     link = "".join((base, files.classname, fileid))
  508                     filename = files.get(fileid, 'name')
  509                     m.append(_("File '%(filename)s' not attached - "
  510                                "you can download it from %(link)s.") %
  511                              locals())
  512 
  513         # add the change note
  514         if note:
  515             m.append(note)
  516 
  517         # put in roundup's signature
  518         if self.db.config.EMAIL_SIGNATURE_POSITION == 'bottom':
  519             m.append(self.email_signature(issueid, msgid))
  520 
  521         # figure the encoding
  522         charset = getattr(self.db.config, 'EMAIL_CHARSET', 'utf-8')
  523 
  524         # construct the content and convert to unicode object
  525         body = s2u('\n'.join(m))
  526 
  527         # make sure the To line is always the same (for testing mostly)
  528         sendto.sort()
  529 
  530         # make sure we have a from address
  531         if from_address is None:
  532             from_address = self.db.config.TRACKER_EMAIL
  533 
  534         # additional bit for after the From: "name"
  535         from_tag = getattr(self.db.config, 'EMAIL_FROM_TAG', '')
  536         if from_tag:
  537             from_tag = ' ' + from_tag
  538 
  539         if subject is None:
  540             subject = '[%s%s] %s' % (cn, issueid, title)
  541 
  542         author = (authname + from_tag, from_address)
  543 
  544         # send an individual message per recipient?
  545         if self.db.config.NOSY_EMAIL_SENDING != 'single':
  546             sendto = [[address] for address in sendto]
  547         else:
  548             sendto = [sendto]
  549 
  550         # tracker sender info
  551         tracker_name = s2u(self.db.config.TRACKER_NAME)
  552         tracker_name = nice_sender_header(tracker_name, from_address,
  553                                           charset)
  554 
  555         # now send one or more messages
  556         # TODO: I believe we have to create a new message each time as we
  557         # can't fiddle the recipients in the message ... worth testing
  558         # and/or fixing some day
  559         first = True
  560         for sendto in sendto:
  561             # create the message
  562             mailer = Mailer(self.db.config)
  563 
  564             message = mailer.get_standard_message(multipart=message_files)
  565 
  566             # set reply-to as requested by config option
  567             # TRACKER_REPLYTO_ADDRESS
  568             replyto_config = self.db.config.TRACKER_REPLYTO_ADDRESS
  569             if replyto_config:
  570                 if replyto_config == "AUTHOR":
  571                     # note that authaddr at this point is already
  572                     # surrounded by < >, so get the original address
  573                     # from the db as nice_send_header adds < >
  574                     replyto_addr = nice_sender_header(authname,
  575                         users.get(authid, 'address', ''), charset)
  576                 else:
  577                     replyto_addr = replyto_config
  578             else:
  579                 replyto_addr = tracker_name
  580             message['Reply-To'] = replyto_addr
  581 
  582             # message ids
  583             if messageid:
  584                 message['Message-Id'] = messageid
  585             if inreplyto:
  586                 message['In-Reply-To'] = inreplyto
  587 
  588             # Generate a header for each link or multilink to
  589             # a class that has a name attribute
  590             for propname, prop in self.getprops().items():
  591                 if not isinstance(prop, (hyperdb.Link, hyperdb.Multilink)):
  592                     continue
  593                 cl = self.db.getclass(prop.classname)
  594                 label = None
  595                 if 'name' in cl.getprops():
  596                     label = 'name'
  597                 if prop.msg_header_property in cl.getprops():
  598                     label = prop.msg_header_property
  599                 if prop.msg_header_property == "":
  600                     # if msg_header_property is set to empty string
  601                     # suppress the header entirely. You can't use
  602                     # 'msg_header_property == None'. None is the
  603                     # default value.
  604                     label = None
  605                 if not label:
  606                     continue
  607                 if isinstance(prop, hyperdb.Link):
  608                     value = self.get(issueid, propname)
  609                     if value is None:
  610                         continue
  611                     values = [value]
  612                 else:
  613                     values = self.get(issueid, propname)
  614                     if not values:
  615                         continue
  616                 values = [cl.get(v, label) for v in values]
  617                 values = ', '.join(values)
  618                 header = "X-Roundup-%s-%s" % (self.classname, propname)
  619                 try:
  620                     values.encode('ascii')
  621                     message[header] = values
  622                 except UnicodeError:
  623                     message[header] = Header(values, charset)
  624 
  625             # Add header for main id number to make filtering
  626             # email easier than extracting from subject line.
  627             header = "X-Roundup-%s-Id" % (self.classname)
  628             values = issueid
  629             try:
  630                 values.encode('ascii')
  631                 message[header] = values
  632             except UnicodeError:
  633                 message[header] = Header(values, charset)
  634             # Generate additional headers
  635             for k in add_headers:
  636                 v = add_headers[k]
  637                 try:
  638                     v.encode('ascii')
  639                     message[k] = v
  640                 except UnicodeError:
  641                     message[k] = Header(v, charset)
  642 
  643             if not inreplyto:
  644                 # Default the reply to the first message
  645                 msgs = self.get(issueid, 'messages')
  646                 # Assume messages are sorted by increasing message number here
  647                 # If the issue is just being created, and the submitter didn't
  648                 # provide a message, then msgs will be empty.
  649                 if msgs and msgs[0] != msgid:
  650                     inreplyto = messages.get(msgs[0], 'messageid')
  651                     if inreplyto:
  652                         message['In-Reply-To'] = inreplyto
  653 
  654             # attach files
  655             if message_files:
  656                 # first up the text as a part
  657                 part = mailer.get_standard_message()
  658                 part.set_payload(body, part.get_charset())
  659                 message.attach(part)
  660 
  661                 for fileid in message_files:
  662                     name = files.get(fileid, 'name')
  663                     mime_type = (files.get(fileid, 'type') or
  664                                  mimetypes.guess_type(name)[0] or
  665                                  'application/octet-stream')
  666                     if mime_type == 'text/plain':
  667                         content = files.get(fileid, 'content')
  668                         part = MIMEText('')
  669                         del part['Content-Transfer-Encoding']
  670                         try:
  671                             enc = content.encode('ascii')
  672                             part = mailer.get_text_message('us-ascii')
  673                             part.set_payload(enc)
  674                         except UnicodeError:
  675                             # the content cannot be 7bit-encoded.
  676                             # use quoted printable
  677                             # XXX stuffed if we know the charset though :(
  678                             part = mailer.get_text_message('utf-8')
  679                             part.set_payload(content, part.get_charset())
  680                     elif mime_type == 'message/rfc822':
  681                         content = files.get(fileid, 'content')
  682                         main, sub = mime_type.split('/')
  683                         p = FeedParser()
  684                         p.feed(content)
  685                         part = MIMEBase(main, sub)
  686                         part.set_payload([p.close()])
  687                     else:
  688                         # some other type, so encode it
  689                         content = files.get(fileid, 'binary_content')
  690                         main, sub = mime_type.split('/')
  691                         part = MIMEBase(main, sub)
  692                         part.set_payload(content)
  693                         encoders.encode_base64(part)
  694                     cd = 'Content-Disposition'
  695                     part[cd] = 'attachment;\n filename="%s"' % name
  696                     message.attach(part)
  697 
  698             else:
  699                 message.set_payload(body, message.get_charset())
  700 
  701             if crypt:
  702                 send_msg = self.encrypt_to(message, sendto)
  703             else:
  704                 send_msg = message
  705             mailer.set_message_attributes(send_msg, sendto, subject, author)
  706             if crypt:
  707                 send_msg['Message-Id'] = message['Message-Id']
  708                 send_msg['Reply-To'] = message['Reply-To']
  709                 if message.get('In-Reply-To'):
  710                     send_msg['In-Reply-To'] = message['In-Reply-To']
  711 
  712             if sendto:
  713                 mailer.smtp_send(sendto, send_msg.as_string())
  714             if first:
  715                 if crypt:
  716                     # send individual bcc mails, otherwise receivers can
  717                     # deduce bcc recipients from keys in message
  718                     for bcc in bcc_sendto:
  719                         send_msg = self.encrypt_to(message, [bcc])
  720                         send_msg['Message-Id'] = message['Message-Id']
  721                         send_msg['Reply-To'] = message['Reply-To']
  722                         if message.get('In-Reply-To'):
  723                             send_msg['In-Reply-To'] = message['In-Reply-To']
  724                         mailer.smtp_send([bcc], send_msg.as_string())
  725                 elif bcc_sendto:
  726                     mailer.smtp_send(bcc_sendto, send_msg.as_string())
  727             first = False
  728 
  729     def email_signature(self, issueid, msgid):
  730         ''' Add a signature to the e-mail with some useful information
  731         '''
  732         # simplistic check to see if the url is valid,
  733         # then append a trailing slash if it is missing
  734         base = self.db.config.TRACKER_WEB
  735         if (not isinstance(base, type('')) or
  736             not (base.startswith('http://') or base.startswith('https://'))):
  737             web = "Configuration Error: TRACKER_WEB isn't a " \
  738                 "fully-qualified URL"
  739         else:
  740             if not base.endswith('/'):
  741                 base = base + '/'
  742             web = base + self.classname + issueid
  743 
  744         # ensure the email address is properly quoted
  745         email = formataddr((self.db.config.TRACKER_NAME,
  746                             self.db.config.TRACKER_EMAIL))
  747 
  748         line = '_' * max(len(web)+2, len(email))
  749         return '\n%s\n%s\n<%s>\n%s' % (line, email, web, line)
  750 
  751     def generateCreateNote(self, issueid):
  752         """Generate a create note that lists initial property values
  753         """
  754         cn = self.classname
  755         cl = self.db.classes[cn]
  756         props = cl.getprops(protected=0)
  757 
  758         # list the values
  759         m = []
  760         prop_items = sorted(props.items())
  761         for propname, prop in prop_items:
  762             # Omit quiet properties from history/changelog
  763             if prop.quiet:
  764                 continue
  765             value = cl.get(issueid, propname, None)
  766             # skip boring entries
  767             if not value:
  768                 continue
  769             if isinstance(prop, hyperdb.Link):
  770                 link = self.db.classes[prop.classname]
  771                 if value:
  772                     key = link.labelprop(default_to_id=1)
  773                     if key:
  774                         value = link.get(value, key)
  775                 else:
  776                     value = ''
  777             elif isinstance(prop, hyperdb.Multilink):
  778                 if value is None: value = []
  779                 l = []
  780                 link = self.db.classes[prop.classname]
  781                 key = link.labelprop(default_to_id=1)
  782                 if key:
  783                     value = [link.get(entry, key) for entry in value]
  784                 value.sort()
  785                 value = ', '.join(value)
  786             else:
  787                 value = str(value)
  788                 if '\n' in value:
  789                     value = '\n'+self.indentChangeNoteValue(value)
  790             m.append('%s: %s' % (propname, value))
  791         m.insert(0, '----------')
  792         m.insert(0, '')
  793         return '\n'.join(m)
  794 
  795     def generateChangeNote(self, issueid, oldvalues):
  796         """Generate a change note that lists property changes
  797         """
  798         if not isinstance(oldvalues, type({})):
  799             raise TypeError("'oldvalues' must be dict-like, not %s." %
  800                             type(oldvalues))
  801 
  802         cn = self.classname
  803         cl = self.db.classes[cn]
  804         changed = {}
  805         props = cl.getprops(protected=0)
  806 
  807         # determine what changed
  808         for key in oldvalues.keys():
  809             if key in ['files', 'messages']:
  810                 continue
  811             if key in ('actor', 'activity', 'creator', 'creation'):
  812                 continue
  813             # not all keys from oldvalues might be available in database
  814             # this happens when property was deleted
  815             try:
  816                 new_value = cl.get(issueid, key)
  817             except KeyError:
  818                 continue
  819             # the old value might be non existent
  820             # this happens when property was added
  821             try:
  822                 old_value = oldvalues[key]
  823                 if isinstance(new_value, type([])):
  824                     new_value.sort()
  825                     old_value.sort()
  826                 if new_value != old_value:
  827                     changed[key] = old_value
  828             except Exception:
  829                 changed[key] = new_value
  830 
  831         # list the changes
  832         m = []
  833         changed_items = sorted(changed.items())
  834         for propname, oldvalue in changed_items:
  835             prop = props[propname]
  836             # Omit quiet properties from history/changelog
  837             if prop.quiet:
  838                 continue
  839             value = cl.get(issueid, propname, None)
  840             if isinstance(prop, hyperdb.Link):
  841                 link = self.db.classes[prop.classname]
  842                 key = link.labelprop(default_to_id=1)
  843                 if key:
  844                     if value:
  845                         value = link.get(value, key)
  846                     else:
  847                         value = ''
  848                     if oldvalue:
  849                         oldvalue = link.get(oldvalue, key)
  850                     else:
  851                         oldvalue = ''
  852                 change = '%s -> %s' % (oldvalue, value)
  853             elif isinstance(prop, hyperdb.Multilink):
  854                 change = ''
  855                 if value is None: value = []
  856                 if oldvalue is None: oldvalue = []
  857                 l = []
  858                 link = self.db.classes[prop.classname]
  859                 key = link.labelprop(default_to_id=1)
  860                 # check for additions
  861                 for entry in value:
  862                     if entry in oldvalue: continue
  863                     if key:
  864                         l.append(link.get(entry, key))
  865                     else:
  866                         l.append(entry)
  867                 if l:
  868                     l.sort()
  869                     change = '+%s' % (', '.join(l))
  870                     l = []
  871                 # check for removals
  872                 for entry in oldvalue:
  873                     if entry in value: continue
  874                     if key:
  875                         l.append(link.get(entry, key))
  876                     else:
  877                         l.append(entry)
  878                 if l:
  879                     l.sort()
  880                     change += ' -%s' % (', '.join(l))
  881             else:
  882                 change = '%s -> %s' % (oldvalue, value)
  883                 if '\n' in change:
  884                     value = self.indentChangeNoteValue(str(value))
  885                     oldvalue = self.indentChangeNoteValue(str(oldvalue))
  886                     change = _('\nNow:\n%(new)s\nWas:\n%(old)s') % {
  887                         "new": value, "old": oldvalue}
  888             m.append('%s: %s' % (propname, change))
  889         if m:
  890             m.insert(0, '----------')
  891             m.insert(0, '')
  892         return '\n'.join(m)
  893 
  894     def indentChangeNoteValue(self, text):
  895         lines = text.rstrip('\n').split('\n')
  896         lines = ['  '+line for line in lines]
  897         return '\n'.join(lines)
  898 
  899 # vim: set filetype=python sts=4 sw=4 et si :