diff --git a/tailbone/forms/renderers/bouncer.py b/tailbone/forms/renderers/bouncer.py new file mode 100644 index 00000000..e9522668 --- /dev/null +++ b/tailbone/forms/renderers/bouncer.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +Batch Field Renderers +""" + +from __future__ import unicode_literals + +import os +import stat +import random + +from formalchemy.ext import fsblob + + +class BounceMessageFieldRenderer(fsblob.FileFieldRenderer): + """ + Custom file field renderer for email bounce messages. In readonly mode, + shows the filename and size. + """ + + @classmethod + def new(cls, request, handler): + name = 'Configured%s_%s' % (cls.__name__, unicode(random.random())[2:]) + return type(str(name), (cls,), dict(request=request, handler=handler)) + + @property + def storage_path(self): + return self.handler.root_msgdir + + def get_size(self): + size = super(BounceMessageFieldRenderer, self).get_size() + if size: + return size + bounce = self.field.parent.model + path = os.path.join(self.handler.msgpath(bounce)) + if os.path.isfile(path): + return os.stat(path)[stat.ST_SIZE] + return 0 + + def get_url(self, filename): + bounce = self.field.parent.model + return self.request.route_url('emailbounce.download', uuid=bounce.uuid) diff --git a/tailbone/templates/emailbounces/crud.mako b/tailbone/templates/emailbounces/crud.mako new file mode 100644 index 00000000..666c28d9 --- /dev/null +++ b/tailbone/templates/emailbounces/crud.mako @@ -0,0 +1,14 @@ +## -*- coding: utf-8 -*- +<%inherit file="/crud.mako" /> + +<%def name="context_menu_items()"> + <% bounce = form.fieldset.model %> +
  • ${h.link_to("Back to Email Bounces", url('emailbounces'))}
  • + % if not bounce.processed and request.has_perm('emailbounces.process'): +
  • ${h.link_to("Mark this Email Bounce as Processed", url('emailbounce.process', uuid=bounce.uuid))}
  • + % elif bounce.processed and request.has_perm('emailbounces.unprocess'): +
  • ${h.link_to("Mark this Email Bounce as UN-processed", url('emailbounce.unprocess', uuid=bounce.uuid))}
  • + % endif + + +${parent.body()} diff --git a/tailbone/templates/emailbounces/index.mako b/tailbone/templates/emailbounces/index.mako new file mode 100644 index 00000000..01f5beb7 --- /dev/null +++ b/tailbone/templates/emailbounces/index.mako @@ -0,0 +1,6 @@ +## -*- coding: utf-8 -*- +<%inherit file="/grid.mako" /> + +<%def name="title()">Email Bounces + +${parent.body()} diff --git a/tailbone/views/__init__.py b/tailbone/views/__init__.py index 6d0b0c80..fe0bd02c 100644 --- a/tailbone/views/__init__.py +++ b/tailbone/views/__init__.py @@ -55,6 +55,7 @@ def includeme(config): config.include('tailbone.views.auth') config.include('tailbone.views.batches') + config.include('tailbone.views.bouncer') config.include('tailbone.views.brands') config.include('tailbone.views.categories') config.include('tailbone.views.customergroups') diff --git a/tailbone/views/bouncer.py b/tailbone/views/bouncer.py new file mode 100644 index 00000000..1eb743ab --- /dev/null +++ b/tailbone/views/bouncer.py @@ -0,0 +1,245 @@ +# -*- coding: utf-8 -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2015 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU Affero General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for +# more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Rattail. If not, see . +# +################################################################################ +""" +Views for Email Bounces +""" + +from __future__ import unicode_literals + +import os +import datetime + +from rattail.db import model +from rattail.bouncer import get_handler + +import formalchemy +from pyramid.response import FileResponse +from pyramid.httpexceptions import HTTPFound, HTTPNotFound +from webhelpers.html import literal + +from tailbone.db import Session +from tailbone.views import SearchableAlchemyGridView, CrudView +from tailbone.forms import renderers +from tailbone.forms.renderers.bouncer import BounceMessageFieldRenderer +from tailbone.grids.search import BooleanSearchFilter + + +class EmailBouncesGrid(SearchableAlchemyGridView): + """ + Main grid view for email bounces. + """ + mapped_class = model.EmailBounce + config_prefix = 'emailbounces' + + def join_map(self): + return { + 'processed_by': lambda q: q.outerjoin(model.User), + } + + def filter_map(self): + + def processed_is(q, v): + if v == 'True': + return q.filter(model.EmailBounce.processed != None) + else: + return q.filter(model.EmailBounce.processed == None) + + def processed_nt(q, v): + if v == 'True': + return q.filter(model.EmailBounce.processed == None) + else: + return q.filter(model.EmailBounce.processed != None) + + return self.make_filter_map( + ilike=['config_key', 'bounce_recipient_address', 'intended_recipient_address'], + processed={'is': processed_is, 'nt': processed_nt}, + processed_by=self.filter_ilike(model.User.username)) + + def filter_config(self): + return self.make_filter_config( + include_filter_config_key=True, + filter_type_config_key='lk', + filter_label_config_key="Source", + filter_factory_processed=BooleanSearchFilter, + filter_type_processed='is', + processed=False, + include_filter_processed=True, + filter_label_bounce_recipient_address="Bounced To", + filter_label_intended_recipient_address="Intended For") + + def sort_config(self): + return self.make_sort_config(sort='bounced', dir='desc') + + def sort_map(self): + return self.make_sort_map( + 'config_key', 'bounced', 'bounce_recipient_address', 'intended_recipient_address', + processed_by=self.sorter(model.User.username)) + + def grid(self): + g = self.make_grid() + g.bounced.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config)) + g.configure( + include=[ + g.config_key.label("Source"), + g.bounced, + g.bounce_recipient_address.label("Bounced To"), + g.intended_recipient_address.label("Intended For"), + g.processed_by, + ], + readonly=True) + if self.request.has_perm('emailbounces.view'): + g.viewable = True + g.view_route_name = 'emailbounce' + if self.request.has_perm('emailbounces.delete'): + g.deletable = True + g.delete_route_name = 'emailbounce.delete' + return g + + +class LinksFieldRenderer(formalchemy.FieldRenderer): + + def render_readonly(self, **kwargs): + value = self.raw_value + if not value: + return 'n/a' + html = literal('') + return html + + +class EmailBounceCrud(CrudView): + """ + Main CRUD view for email bounces. + """ + mapped_class = model.EmailBounce + home_route = 'emailbounces' + pretty_name = "Email Bounce" + + def get_handler(self, bounce): + return get_handler(self.rattail_config, bounce.config_key) + + def fieldset(self, bounce): + assert isinstance(bounce, model.EmailBounce) + handler = self.get_handler(bounce) + fs = self.make_fieldset(bounce) + fs.bounced.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config)) + fs.processed.set(renderer=renderers.DateTimeFieldRenderer(self.rattail_config)) + fs.append(formalchemy.Field('message', + value=handler.msgpath(bounce), + renderer=BounceMessageFieldRenderer.new(self.request, handler))) + fs.append(formalchemy.Field('links', + value=list(handler.make_links(Session(), bounce.intended_recipient_address)), + renderer=LinksFieldRenderer)) + fs.configure( + include=[ + fs.config_key.label("Source"), + fs.message, + fs.bounced, + fs.intended_recipient_address.label("Intended For"), + fs.bounce_recipient_address.label("Bounced To"), + fs.links, + fs.processed, + fs.processed_by, + ], + readonly=True) + return fs + + def template_kwargs(self, form): + kwargs = super(EmailBounceCrud, self).template_kwargs(form) + bounce = form.fieldset.model + kwargs['handler'] = self.get_handler(bounce) + return kwargs + + def process(self): + """ + View for marking a bounce as processed. + """ + bounce = self.get_model_from_request() + if not bounce: + return HTTPNotFound() + bounce.processed = datetime.datetime.utcnow() + bounce.processed_by = self.request.user + self.request.session.flash("Email bounce has been marked processed.") + return HTTPFound(location=self.request.route_url('emailbounces')) + + def unprocess(self): + """ + View for marking a bounce as *unprocessed*. + """ + bounce = self.get_model_from_request() + if not bounce: + return HTTPNotFound() + bounce.processed = None + bounce.processed_by = None + self.request.session.flash("Email bounce has been marked UN-processed.") + return HTTPFound(location=self.request.route_url('emailbounces')) + + def download(self): + """ + View for downloading the message file associated with a bounce. + """ + bounce = self.get_model_from_request() + if not bounce: + return HTTPNotFound() + handler = self.get_handler(bounce) + path = handler.msgpath(bounce) + response = FileResponse(path, request=self.request) + response.headers[b'Content-Length'] = str(os.path.getsize(path)) + response.headers[b'Content-Disposition'] = b'attachment; filename="bounce.eml"' + return response + + +def add_routes(config): + config.add_route('emailbounces', '/emailbounces/') + config.add_route('emailbounce', '/emailbounces/{uuid}') + config.add_route('emailbounce.process', '/emailbounces/{uuid}/process') + config.add_route('emailbounce.unprocess', '/emailbounces/{uuid}/unprocess') + config.add_route('emailbounce.delete', '/emailbounces/{uuid}/delete') + config.add_route('emailbounce.download', '/emailbounces/{uuid}/download') + + +def includeme(config): + add_routes(config) + + config.add_view(EmailBouncesGrid, route_name='emailbounces', + renderer='/emailbounces/index.mako', + permission='emailbounces.list') + + config.add_view(EmailBounceCrud, attr='read', route_name='emailbounce', + renderer='/emailbounces/crud.mako', + permission='emailbounces.view') + + config.add_view(EmailBounceCrud, attr='process', route_name='emailbounce.process', + permission='emailbounces.process') + + config.add_view(EmailBounceCrud, attr='unprocess', route_name='emailbounce.unprocess', + permission='emailbounces.unprocess') + + config.add_view(EmailBounceCrud, attr='download', route_name='emailbounce.download', + permission='emailbounces.download') + + config.add_view(EmailBounceCrud, attr='delete', route_name='emailbounce.delete', + permission='emailbounces.delete')