Add initial support for email bounce management.

This commit is contained in:
Lance Edgar 2015-07-21 19:19:01 -05:00
parent cfd5e5ae50
commit f523146a4b
5 changed files with 329 additions and 0 deletions

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -0,0 +1,14 @@
## -*- coding: utf-8 -*-
<%inherit file="/crud.mako" />
<%def name="context_menu_items()">
<% bounce = form.fieldset.model %>
<li>${h.link_to("Back to Email Bounces", url('emailbounces'))}</li>
% if not bounce.processed and request.has_perm('emailbounces.process'):
<li>${h.link_to("Mark this Email Bounce as Processed", url('emailbounce.process', uuid=bounce.uuid))}</li>
% elif bounce.processed and request.has_perm('emailbounces.unprocess'):
<li>${h.link_to("Mark this Email Bounce as UN-processed", url('emailbounce.unprocess', uuid=bounce.uuid))}</li>
% endif
</%def>
${parent.body()}

View file

@ -0,0 +1,6 @@
## -*- coding: utf-8 -*-
<%inherit file="/grid.mako" />
<%def name="title()">Email Bounces</%def>
${parent.body()}

View file

@ -55,6 +55,7 @@ def includeme(config):
config.include('tailbone.views.auth') config.include('tailbone.views.auth')
config.include('tailbone.views.batches') config.include('tailbone.views.batches')
config.include('tailbone.views.bouncer')
config.include('tailbone.views.brands') config.include('tailbone.views.brands')
config.include('tailbone.views.categories') config.include('tailbone.views.categories')
config.include('tailbone.views.customergroups') config.include('tailbone.views.customergroups')

245
tailbone/views/bouncer.py Normal file
View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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('<ul>')
for link in value:
html += literal('<li>{0}:&nbsp; <a href="{1}" target="_blank">{2}</a></li>'.format(
link.type, link.url, link.title))
html += literal('</ul>')
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')