# HG changeset patch # User Sylvain Thénault # Date 1250526357 -7200 # Node ID bfc8e18312901908a1ac7dc1ab3ee0a9c642d66c # Parent 0cb160fd3cdfed00e7e18e9c52be45797bd30f1c# Parent ae26a80c0635daabb827d66c076b886fef1376b5 backport stable branch diff -r 0cb160fd3cdf -r bfc8e1831290 common/mail.py --- a/common/mail.py Sun Aug 16 20:42:33 2009 +0200 +++ b/common/mail.py Mon Aug 17 18:25:57 2009 +0200 @@ -7,11 +7,20 @@ """ __docformat__ = "restructuredtext en" +from base64 import b64encode, b64decode +from itertools import repeat +from time import time from email.MIMEMultipart import MIMEMultipart from email.MIMEText import MIMEText from email.MIMEImage import MIMEImage from email.Header import Header +try: + from socket import gethostname +except ImportError: + def gethostname(): # gae + return 'XXX' +from cubicweb.view import EntityView def header(ustring): return Header(ustring.encode('UTF-8'), 'UTF-8') @@ -25,6 +34,34 @@ return addr +def construct_message_id(appid, eid, withtimestamp=True): + if withtimestamp: + addrpart = 'eid=%s×tamp=%.10f' % (eid, time()) + else: + addrpart = 'eid=%s' % eid + # we don't want any equal sign nor trailing newlines + leftpart = b64encode(addrpart, '.-').rstrip().rstrip('=') + return '<%s@%s.%s>' % (leftpart, appid, gethostname()) + + +def parse_message_id(msgid, appid): + if msgid[0] == '<': + msgid = msgid[1:] + if msgid[-1] == '>': + msgid = msgid[:-1] + try: + values, qualif = msgid.split('@') + padding = len(values) % 4 + values = b64decode(str(values + '='*padding), '.-') + values = dict(v.split('=') for v in values.split('&')) + fromappid, host = qualif.split('.', 1) + except: + return None + if appid != fromappid or host != gethostname(): + return None + return values + + def format_mail(uinfo, to_addrs, content, subject="", cc_addrs=(), msgid=None, references=(), config=None): """Sends an Email to 'e_addr' with content 'content', and subject 'subject' @@ -94,3 +131,115 @@ image = MIMEImage(data) image.add_header('Content-ID', '<%s>' % htmlId) self.attach(image) + + +class NotificationView(EntityView): + """abstract view implementing the "email" API (eg to simplify sending + notification) + """ + # XXX refactor this class to work with len(rset) > 1 + + msgid_timestamp = True + + def user_login(self): + try: + # if req is actually a session (we are on the server side), and we + # have to prevent nested internal session + return self.req.actual_session().user.login + except AttributeError: + return self.req.user.login + + def recipients(self): + finder = self.vreg['components'].select('recipients_finder', self.req, + rset=self.rset, + row=self.row or 0, + col=self.col or 0) + return finder.recipients() + + def subject(self): + entity = self.entity(self.row or 0, self.col or 0) + subject = self.req._(self.message) + etype = entity.dc_type() + eid = entity.eid + login = self.user_login() + return self.req._('%(subject)s %(etype)s #%(eid)s (%(login)s)') % locals() + + def context(self, **kwargs): + entity = self.entity(self.row or 0, self.col or 0) + for key, val in kwargs.iteritems(): + if val and isinstance(val, unicode) and val.strip(): + kwargs[key] = self.req._(val) + kwargs.update({'user': self.user_login(), + 'eid': entity.eid, + 'etype': entity.dc_type(), + 'url': entity.absolute_url(), + 'title': entity.dc_long_title(),}) + return kwargs + + def cell_call(self, row, col=0, **kwargs): + self.w(self.req._(self.content) % self.context(**kwargs)) + + def construct_message_id(self, eid): + return construct_message_id(self.config.appid, eid, self.msgid_timestamp) + + def render_emails(self, **kwargs): + """generate and send an email message for this view""" + self._kwargs = kwargs + recipients = self.recipients() + if not recipients: + self.info('skipping %s notification, no recipients', self.id) + return + if not isinstance(recipients[0], tuple): + from warnings import warn + warn('recipients should now return a list of 2-uple (email, language)', + DeprecationWarning, stacklevel=1) + lang = self.vreg.property_value('ui.language') + recipients = zip(recipients, repeat(lang)) + if self.rset is not None: + entity = self.entity(self.row or 0, self.col or 0) + # if the view is using timestamp in message ids, no way to reference + # previous email + if not self.msgid_timestamp: + refs = [self.construct_message_id(eid) + for eid in entity.notification_references(self)] + else: + refs = () + msgid = self.construct_message_id(entity.eid) + else: + refs = () + msgid = None + userdata = self.req.user_data() + origlang = self.req.lang + for emailaddr, lang in recipients: + self.req.set_language(lang) + # since the same view (eg self) may be called multiple time and we + # need a fresh stream at each iteration, reset it explicitly + self.w = None + # XXX call render before subject to set .row/.col attributes on the + # view + content = self.render(row=0, col=0, **kwargs) + subject = self.subject() + msg = format_mail(userdata, [emailaddr], content, subject, + config=self.config, msgid=msgid, references=refs) + yield [emailaddr], msg + # restore language + self.req.set_language(origlang) + + def render_and_send(self, **kwargs): + """generate and send an email message for this view""" + delayed = kwargs.pop('delay_to_commit', None) + for recipients, msg in self.render_emails(**kwargs): + if delayed is None: + self.send(recipients, msg) + elif delayed: + self.send_on_commit(recipients, msg) + else: + self.send_now(recipients, msg) + + def send_now(self, recipients, msg): + self.config.sendmails([(msg, recipients)]) + + def send_on_commit(self, recipients, msg): + raise NotImplementedError + + send = send_now diff -r 0cb160fd3cdf -r bfc8e1831290 entity.py --- a/entity.py Sun Aug 16 20:42:33 2009 +0200 +++ b/entity.py Mon Aug 17 18:25:57 2009 +0200 @@ -152,11 +152,16 @@ desttype = rschema.objects(eschema.type)[0] card = rschema.rproperty(eschema, desttype, 'cardinality')[0] if card not in '?1': + self.warning('bad relation %s specified in fetch attrs for %s', + attr, self.__class__) selection.pop() restrictions.pop() continue - if card == '?': - restrictions[-1] += '?' # left outer join if not mandatory + # XXX we need outer join in case the relation is not mandatory + # (card == '?') *or if the entity is being added*, since in + # that case the relation may still be missing. As we miss this + # later information here, systematically add it. + restrictions[-1] += '?' # XXX user.req.vreg iiiirk destcls = user.req.vreg['etypes'].etype_class(desttype) destcls._fetch_restrictions(var, varmaker, destcls.fetch_attrs, @@ -709,7 +714,7 @@ # raw edition utilities ################################################### - def set_attributes(self, **kwargs): + def set_attributes(self, _cw_unsafe=False, **kwargs): assert kwargs relations = [] for key in kwargs: @@ -718,8 +723,12 @@ self.update(kwargs) # and now update the database kwargs['x'] = self.eid - self.req.execute('SET %s WHERE X eid %%(x)s' % ','.join(relations), - kwargs, 'x') + if _cw_unsafe: + self.req.unsafe_execute( + 'SET %s WHERE X eid %%(x)s' % ','.join(relations), kwargs, 'x') + else: + self.req.execute('SET %s WHERE X eid %%(x)s' % ','.join(relations), + kwargs, 'x') def delete(self): assert self.has_eid(), self.eid @@ -820,7 +829,8 @@ def __set__(self, eobj, value): eobj[self._attrname] = value - + if hasattr(eobj, 'edited_attributes'): + eobj.edited_attributes.add(self._attrname) class Relation(object): """descriptor that controls schema relation access""" diff -r 0cb160fd3cdf -r bfc8e1831290 hooks/workflow.py --- a/hooks/workflow.py Sun Aug 16 20:42:33 2009 +0200 +++ b/hooks/workflow.py Mon Aug 17 18:25:57 2009 +0200 @@ -15,14 +15,16 @@ from cubicweb.server import hook + def previous_state(session, eid): """return the state of the entity with the given eid, usually since it's changing in the current transaction. Due to internal relation hooks, the relation may has been deleted at this point, so we have handle that """ - if session.added_in_transaction(eid): - return + # don't check eid has been added in the current transaction, we don't want + # to miss previous state of entity whose state change in the same + # transaction as it's being created pending = session.transaction_data.get('pendingrelations', ()) for eidfrom, rtype, eidto in reversed(pending): if rtype == 'in_state' and eidfrom == eid: @@ -139,7 +141,8 @@ return entity = self._cw.entity_from_eid(self.eidfrom) try: - entity.set_attributes(modification_date=datetime.now()) + entity.set_attributes(modification_date=datetime.now(), + _cw_unsafe=True) except RepositoryError, ex: # usually occurs if entity is coming from a read-only source # (eg ldap user) diff -r 0cb160fd3cdf -r bfc8e1831290 server/hookhelper.py diff -r 0cb160fd3cdf -r bfc8e1831290 server/repository.py --- a/server/repository.py Sun Aug 16 20:42:33 2009 +0200 +++ b/server/repository.py Mon Aug 17 18:25:57 2009 +0200 @@ -1011,7 +1011,7 @@ session.set_entity_cache(entity) only_inline_rels, need_fti_update = True, False relations = [] - for attr in entity.keys(): + for attr in edited_attributes: if attr == 'eid': continue rschema = eschema.subject_relation(attr) @@ -1021,8 +1021,8 @@ only_inline_rels = False else: # inlined relation - previous_value = entity.related(attr) - if previous_value: + previous_value = entity.related(attr) or None + if previous_value is not None: previous_value = previous_value[0][0] # got a result set if previous_value == entity[attr]: previous_value = None @@ -1051,7 +1051,7 @@ for attr, value, prevvalue in relations: # if the relation is already cached, update existant cache relcache = entity.relation_cached(attr, 'subject') - if prevvalue: + if prevvalue is not None: self.hm.call_hooks('after_delete_relation', session, eidfrom=entity.eid, rtype=attr, eidto=prevvalue) if relcache is not None: diff -r 0cb160fd3cdf -r bfc8e1831290 server/session.py --- a/server/session.py Sun Aug 16 20:42:33 2009 +0200 +++ b/server/session.py Mon Aug 17 18:25:57 2009 +0200 @@ -344,7 +344,7 @@ try: csession = self._threaddata.childsession except AttributeError: - if self.is_super_session: + if isinstance(self, (ChildSession, InternalSession)): csession = self else: csession = ChildSession(self) diff -r 0cb160fd3cdf -r bfc8e1831290 server/ssplanner.py --- a/server/ssplanner.py Sun Aug 16 20:42:33 2009 +0200 +++ b/server/ssplanner.py Mon Aug 17 18:25:57 2009 +0200 @@ -482,7 +482,7 @@ repo = session.repo edefs = {} # insert relations - attributes = [relation.r_type for relation in self.attribute_relations] + attributes = set([relation.r_type for relation in self.attribute_relations]) for row in self.execute_child(): for relation in self.attribute_relations: lhs, rhs = relation.get_variable_parts() diff -r 0cb160fd3cdf -r bfc8e1831290 sobjects/notification.py --- a/sobjects/notification.py Sun Aug 16 20:42:33 2009 +0200 +++ b/sobjects/notification.py Mon Aug 17 18:25:57 2009 +0200 @@ -8,21 +8,15 @@ __docformat__ = "restructuredtext en" _ = unicode -from base64 import b64encode, b64decode from itertools import repeat -from time import time -try: - from socket import gethostname -except ImportError: - def gethostname(): # gae - return 'XXX' from logilab.common.textutils import normalize_text +from logilab.common.deprecation import class_renamed, deprecated from cubicweb.selectors import yes -from cubicweb.view import EntityView, Component +from cubicweb.view import Component from cubicweb.common.mail import format_mail - +from cubicweb.common.mail import NotificationView from cubicweb.server.hookhelper import SendMailOp @@ -55,127 +49,13 @@ # abstract or deactivated notification views and mixin ######################## -class NotificationView(EntityView): - """abstract view implementing the email API - - all you have to do by default is : - * set id and accepts attributes to match desired events and entity types - * set a content attribute to define the content of the email (unless you - override call) +class NotificationView(NotificationView): + """overriden to delay actual sending of mails to a commit operation by + default """ - # XXX refactor this class to work with len(rset) > 1 - - msgid_timestamp = True - - def recipients(self): - finder = self.vreg['components'].select('recipients_finder', self.req, - rset=self.rset) - return finder.recipients() - - def subject(self): - entity = self.rset.get_entity(self.row or 0, self.col or 0) - subject = self.req._(self.message) - etype = entity.dc_type() - eid = entity.eid - login = self.user_login() - return self.req._('%(subject)s %(etype)s #%(eid)s (%(login)s)') % locals() - - def user_login(self): - # req is actually a session (we are on the server side), and we have to - # prevent nested internal session - return self.req.actual_session().user.login - - def context(self, **kwargs): - entity = self.rset.get_entity(self.row or 0, self.col or 0) - for key, val in kwargs.iteritems(): - if val and isinstance(val, unicode) and val.strip(): - kwargs[key] = self.req._(val) - kwargs.update({'user': self.user_login(), - 'eid': entity.eid, - 'etype': entity.dc_type(), - 'url': entity.absolute_url(), - 'title': entity.dc_long_title(),}) - return kwargs - - def cell_call(self, row, col=0, **kwargs): - self.w(self.req._(self.content) % self.context(**kwargs)) - - def construct_message_id(self, eid): - return construct_message_id(self.req.vreg.config.appid, eid, self.msgid_timestamp) - - def render_and_send(self, **kwargs): - """generate and send an email message for this view""" - self._kwargs = kwargs - recipients = self.recipients() - if not recipients: - self.info('skipping %s notification, no recipients', self.id) - return - if not isinstance(recipients[0], tuple): - from warnings import warn - warn('recipients should now return a list of 2-uple (email, language)', - DeprecationWarning, stacklevel=1) - lang = self.vreg.property_value('ui.language') - recipients = zip(recipients, repeat(lang)) - if self.rset is not None: - entity = self.rset.get_entity(self.row or 0, self.col or 0) - # if the view is using timestamp in message ids, no way to reference - # previous email - if not self.msgid_timestamp: - refs = [self.construct_message_id(eid) - for eid in entity.notification_references(self)] - else: - refs = () - msgid = self.construct_message_id(entity.eid) - else: - refs = () - msgid = None - userdata = self.req.user_data() - origlang = self.req.lang - for emailaddr, lang in recipients: - self.req.set_language(lang) - # since the same view (eg self) may be called multiple time and we - # need a fresh stream at each iteration, reset it explicitly - self.w = None - # XXX call render before subject to set .row/.col attributes on the - # view - content = self.render(row=0, col=0, **kwargs) - subject = self.subject() - msg = format_mail(userdata, [emailaddr], content, subject, - config=self.req.vreg.config, msgid=msgid, references=refs) - self.send([emailaddr], msg) - # restore language - self.req.set_language(origlang) - - def send(self, recipients, msg): + def send_on_commit(self, recipients, msg): SendMailOp(self.req, recipients=recipients, msg=msg) - - -def construct_message_id(appid, eid, withtimestamp=True): - if withtimestamp: - addrpart = 'eid=%s×tamp=%.10f' % (eid, time()) - else: - addrpart = 'eid=%s' % eid - # we don't want any equal sign nor trailing newlines - leftpart = b64encode(addrpart, '.-').rstrip().rstrip('=') - return '<%s@%s.%s>' % (leftpart, appid, gethostname()) - - -def parse_message_id(msgid, appid): - if msgid[0] == '<': - msgid = msgid[1:] - if msgid[-1] == '>': - msgid = msgid[:-1] - try: - values, qualif = msgid.split('@') - padding = len(values) % 4 - values = b64decode(str(values + '='*padding), '.-') - values = dict(v.split('=') for v in values.split('&')) - fromappid, host = qualif.split('.', 1) - except: - return None - if appid != fromappid or host != gethostname(): - return None - return values + send = send_on_commit class StatusChangeMixIn(object): @@ -201,6 +81,13 @@ # XXX should be based on dc_title/dc_description, no? class ContentAddedView(NotificationView): + """abstract class for notification on entity/relation + + all you have to do by default is : + * set id and __select__ attributes to match desired events and entity types + * set a content attribute to define the content of the email (unless you + override call) + """ __abstract__ = True id = 'notif_after_add_entity' msgid_timestamp = False @@ -226,7 +113,11 @@ return u'%s #%s (%s)' % (self.req.__('New %s' % entity.e_schema), entity.eid, self.user_login()) -from logilab.common.deprecation import class_renamed, class_moved + +from logilab.common.deprecation import class_renamed, class_moved, deprecated +from cubicweb.hooks.notification import RenderAndSendNotificationView +from cubicweb.common.mail import parse_message_id + NormalizedTextView = class_renamed('NormalizedTextView', ContentAddedView) -from cubicweb.hooks.notification import RenderAndSendNotificationView RenderAndSendNotificationView = class_moved(RenderAndSendNotificationView) +parse_message_id = deprecated('parse_message_id is now defined in cubicweb.common.mail') diff -r 0cb160fd3cdf -r bfc8e1831290 web/views/basecontrollers.py --- a/web/views/basecontrollers.py Sun Aug 16 20:42:33 2009 +0200 +++ b/web/views/basecontrollers.py Mon Aug 17 18:25:57 2009 +0200 @@ -310,7 +310,7 @@ vtitle = self.req.form.get('vtitle') if vtitle: stream.write(u'

%s

\n' % vtitle) - view.pagination(req, self.rset, view.w, not view.need_navigation) + view.paginate() if divid == 'pageContent': stream.write(u'
') view.render(**kwargs)