1
0
Fork 0

feat: add basic user feedback email mechanism

this definitely needs some more work.  using pyramid_mailer for
testing although not ready to declare that dependency.  for now this
is "broken" without it being installed.
This commit is contained in:
Lance Edgar 2024-08-25 20:25:14 -05:00
parent 8669ca2283
commit 4934ed1d93
6 changed files with 461 additions and 2 deletions

View file

@ -420,7 +420,153 @@
<%def name="render_theme_picker()"></%def> <%def name="render_theme_picker()"></%def>
<%def name="render_feedback_button()"></%def> <%def name="render_feedback_button()">
% if request.has_perm('common.feedback'):
<wutta-feedback-form action="${url('feedback')}" />
% endif
</%def>
<%def name="render_vue_template_feedback()">
<script type="text/x-template" id="wutta-feedback-template">
<div>
<b-button type="is-primary"
@click="showFeedback()"
icon-pack="fas"
icon-left="comment">
Feedback
</b-button>
<b-modal has-modal-card
:active.sync="showDialog">
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">User Feedback</p>
</header>
<section class="modal-card-body">
<p class="block">
Feedback regarding this website may be submitted below.
</p>
<b-field label="User Name"
:type="userName && userName.trim() ? null : 'is-danger'">
<b-input v-model.trim="userName"
% if request.user:
disabled
% endif
/>
</b-field>
<b-field label="Referring URL">
<b-input v-model="referrer"
disabled="true" />
</b-field>
<b-field label="Message"
:type="message && message.trim() ? null : 'is-danger'">
<b-input type="textarea"
v-model.trim="message"
ref="textarea" />
</b-field>
</section>
<footer class="modal-card-foot">
<b-button @click="showDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
icon-pack="fas"
icon-left="paper-plane"
@click="sendFeedback()"
:disabled="submitDisabled">
{{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
</b-button>
</footer>
</div>
</b-modal>
</div>
</script>
</%def>
<%def name="render_vue_script_feedback()">
<script>
const WuttaFeedbackForm = {
template: '#wutta-feedback-template',
mixins: [WuttaRequestMixin],
props: {
action: String,
},
computed: {
submitDisabled() {
if (this.sendingFeedback) {
return true
}
if (!this.userName || !this.userName.trim()) {
return true
}
if (!this.message || !this.message.trim()) {
return true
}
return false
},
},
methods: {
showFeedback() {
// nb. update referrer to include anchor hash if any
this.referrer = location.href
this.showDialog = true
this.$nextTick(function() {
this.$refs.textarea.focus()
})
},
sendFeedback() {
this.sendingFeedback = true
const params = {
referrer: this.referrer,
user_uuid: this.userUUID,
user_name: this.userName,
message: this.message.trim(),
}
this.wuttaPOST(this.action, params, response => {
this.$buefy.toast.open({
message: "Message sent! Thank you for your feedback.",
type: 'is-info',
duration: 4000, // 4 seconds
})
this.showDialog = false
// clear out message, in case they need to send another
this.message = ""
this.sendingFeedback = false
}, response => { // failure
this.sendingFeedback = false
})
},
}
}
const WuttaFeedbackFormData = {
referrer: null,
userUUID: ${json.dumps(request.user.uuid if request.user else None)|n},
userName: ${json.dumps(str(request.user) if request.user else None)|n},
showDialog: false,
sendingFeedback: false,
message: '',
}
</script>
</%def>
<%def name="render_vue_script_whole_page()"> <%def name="render_vue_script_whole_page()">
<script> <script>
@ -578,18 +724,33 @@
############################## ##############################
<%def name="render_vue_templates()"> <%def name="render_vue_templates()">
## nb. must make wutta components first; they are stable so
## intermediate pages do not need to modify them. and some pages
## may need the request mixin to be defined.
${make_wutta_components()}
${self.render_vue_template_whole_page()} ${self.render_vue_template_whole_page()}
${self.render_vue_script_whole_page()} ${self.render_vue_script_whole_page()}
% if request.has_perm('common.feedback'):
${self.render_vue_template_feedback()}
${self.render_vue_script_feedback()}
% endif
</%def> </%def>
<%def name="modify_vue_vars()"></%def> <%def name="modify_vue_vars()"></%def>
<%def name="make_vue_components()"> <%def name="make_vue_components()">
${make_wutta_components()}
<script> <script>
WholePage.data = function() { return WholePageData } WholePage.data = function() { return WholePageData }
Vue.component('whole-page', WholePage) Vue.component('whole-page', WholePage)
</script> </script>
% if request.has_perm('common.feedback'):
<script>
WuttaFeedbackForm.data = function() { return WuttaFeedbackFormData }
Vue.component('wutta-feedback-form', WuttaFeedbackForm)
</script>
% endif
</%def> </%def>
<%def name="make_vue_app()"> <%def name="make_vue_app()">

View file

@ -0,0 +1,40 @@
## -*- coding: utf-8 -*-
<html>
<head>
<style type="text/css">
label {
display: block;
font-weight: bold;
margin-top: 1em;
}
p {
margin: 1em 0 1em 1.5em;
}
p.msg {
white-space: pre-wrap;
}
</style>
</head>
<body>
<h1>User feedback from website</h1>
<label>User Name</label>
<p>
% if user:
<a href="${user_url}">${user}</a>
% else:
${user_name}
% endif
</p>
<label>Referring URL</label>
<p><a href="${referrer}">${referrer}</a></p>
<label>Client IP</label>
<p>${client_ip}</p>
<label>Message</label>
<p class="msg">${message}</p>
</body>
</html>

View file

@ -0,0 +1,23 @@
## -*- coding: utf-8; -*-
# User feedback from website
**User Name**
% if user:
${user}
% else:
${user_name}
% endif
**Referring URL**
${referrer}
**Client IP**
${client_ip}
**Message**
${message}

View file

@ -1,10 +1,87 @@
<%def name="make_wutta_components()"> <%def name="make_wutta_components()">
${self.make_wutta_request_mixin()}
${self.make_wutta_button_component()} ${self.make_wutta_button_component()}
${self.make_wutta_filter_component()} ${self.make_wutta_filter_component()}
${self.make_wutta_filter_value_component()} ${self.make_wutta_filter_value_component()}
</%def> </%def>
<%def name="make_wutta_request_mixin()">
<script>
const WuttaRequestMixin = {
methods: {
wuttaGET(url, params, success, failure) {
this.$http.get(url, {params: params}).then(response => {
if (response.data.error) {
this.$buefy.toast.open({
message: `Request failed: ${'$'}{response.data.error}`,
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
} else {
success(response)
}
}, response => {
this.$buefy.toast.open({
message: "Request failed: (unknown server error)",
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
})
},
wuttaPOST(action, params, success, failure) {
const csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
const headers = {'X-CSRF-TOKEN': csrftoken}
this.$http.post(action, params, {headers: headers}).then(response => {
if (response.data.error) {
this.$buefy.toast.open({
message: "Submit failed: " + (response.data.error ||
"(unknown error)"),
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
} else {
success(response)
}
}, response => {
this.$buefy.toast.open({
message: "Submit failed! (unknown server error)",
type: 'is-danger',
duration: 4000, // 4 seconds
})
if (failure) {
failure(response)
}
})
},
},
}
</script>
</%def>
<%def name="make_wutta_button_component()"> <%def name="make_wutta_button_component()">
<script type="text/x-template" id="wutta-button-template"> <script type="text/x-template" id="wutta-button-template">
<b-button :type="type" <b-button :type="type"

View file

@ -24,13 +24,19 @@
Common Views Common Views
""" """
import logging
import colander import colander
from pyramid.renderers import render
from wuttaweb.views import View from wuttaweb.views import View
from wuttaweb.forms import widgets from wuttaweb.forms import widgets
from wuttaweb.db import Session from wuttaweb.db import Session
log = logging.getLogger(__name__)
class CommonView(View): class CommonView(View):
""" """
Common views shared by all apps. Common views shared by all apps.
@ -78,6 +84,80 @@ class CommonView(View):
""" """
return {'index_title': self.app.get_title()} return {'index_title': self.app.get_title()}
def feedback(self):
""" """
model = self.app.model
session = Session()
# validate form
schema = self.feedback_make_schema()
form = self.make_form(schema=schema)
if not form.validate():
# TODO: native Form class should better expose error(s)
dform = form.get_deform()
return {'error': str(dform.error)}
# build email template context
context = dict(form.validated)
if context['user_uuid']:
context['user'] = session.get(model.User, context['user_uuid'])
context['user_url'] = self.request.route_url('users.view', uuid=context['user_uuid'])
context['client_ip'] = self.request.client_addr
# send email
try:
self.feedback_send(context)
except Exception as error:
log.warning("failed to send feedback email", exc_info=True)
return {'error': str(error) or error.__class__.__name__}
return {'ok': True}
def feedback_make_schema(self):
""" """
schema = colander.Schema()
schema.add(colander.SchemaNode(colander.String(),
name='referrer'))
schema.add(colander.SchemaNode(colander.String(),
name='user_uuid',
missing=None))
schema.add(colander.SchemaNode(colander.String(),
name='user_name'))
schema.add(colander.SchemaNode(colander.String(),
name='message'))
return schema
def feedback_send(self, context): # pragma: no cover
""" """
# TODO: this is definitely a stopgap bit of logic, until we
# have a more robust way to handle email via wuttjamaican etc.
from pyramid_mailer.mailer import Mailer
from pyramid_mailer.message import Message
From = self.config.require(f'{self.config.appname}.email.default.sender')
To = self.config.require(f'{self.config.appname}.email.feedback.to')
Subject = self.config.get(f'{self.config.appname}.email.feedback.subject',
default="User Feedback")
text_body = render('/temporary/feedback.txt.mako', context, request=self.request)
html_body = render('/temporary/feedback.html.mako', context, request=self.request)
msg = Message(subject=Subject,
sender=From,
recipients=[To],
body=text_body,
html=html_body)
mailer = Mailer()
mailer.send_immediately(msg)
def setup(self, session=None): def setup(self, session=None):
""" """
View for first-time app setup, to create admin user. View for first-time app setup, to create admin user.
@ -203,6 +283,8 @@ class CommonView(View):
@classmethod @classmethod
def _defaults(cls, config): def _defaults(cls, config):
config.add_wutta_permission_group('common', "(common)", overwrite=False)
# home page # home page
config.add_route('home', '/') config.add_route('home', '/')
config.add_view(cls, attr='home', config.add_view(cls, attr='home',
@ -219,6 +301,16 @@ class CommonView(View):
append_slash=True, append_slash=True,
renderer='/notfound.mako') renderer='/notfound.mako')
# feedback
config.add_route('feedback', '/feedback',
request_method='POST')
config.add_view(cls, attr='feedback',
route_name='feedback',
permission='common.feedback',
renderer='json')
config.add_wutta_permission('common', 'common.feedback',
"Send user feedback about the app")
# setup # setup
config.add_route('setup', '/setup') config.add_route('setup', '/setup')
config.add_view(cls, attr='setup', config.add_view(cls, attr='setup',

View file

@ -1,5 +1,9 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest.mock import patch
import colander
from wuttaweb.views import common as mod from wuttaweb.views import common as mod
from tests.util import WebTestCase from tests.util import WebTestCase
@ -51,6 +55,68 @@ class TestCommonView(WebTestCase):
context = view.home(session=self.session) context = view.home(session=self.session)
self.assertEqual(context['index_title'], self.app.get_title()) self.assertEqual(context['index_title'], self.app.get_title())
def test_feedback_make_schema(self):
view = self.make_view()
schema = view.feedback_make_schema()
self.assertIsInstance(schema, colander.Schema)
self.assertIn('message', schema)
def test_feedback(self):
self.pyramid_config.add_route('users.view', '/users/{uuid}')
model = self.app.model
user = model.User(username='barney')
self.session.add(user)
self.session.commit()
view = self.make_view()
with patch.object(view, 'feedback_send') as feedback_send:
# basic send, no user
self.request.client_addr = '127.0.0.1'
self.request.method = 'POST'
self.request.POST = {
'referrer': '/foo',
'user_name': "Barney Rubble",
'message': "hello world",
}
context = view.feedback()
self.assertEqual(context, {'ok': True})
feedback_send.assert_called_once()
# reset
feedback_send.reset_mock()
# basic send, with user
self.request.user = user
self.request.POST['user_uuid'] = user.uuid
with patch.object(mod, 'Session', return_value=self.session):
context = view.feedback()
self.assertEqual(context, {'ok': True})
feedback_send.assert_called_once()
# reset
self.request.user = None
feedback_send.reset_mock()
# invalid form data
self.request.POST = {'message': 'hello world'}
context = view.feedback()
self.assertEqual(list(context), ['error'])
self.assertIn('Required', context['error'])
feedback_send.assert_not_called()
# error on send
self.request.POST = {
'referrer': '/foo',
'user_name': "Barney Rubble",
'message': "hello world",
}
feedback_send.side_effect = RuntimeError
context = view.feedback()
feedback_send.assert_called_once()
self.assertEqual(list(context), ['error'])
self.assertIn('RuntimeError', context['error'])
def test_setup(self): def test_setup(self):
self.pyramid_config.add_route('home', '/') self.pyramid_config.add_route('home', '/')
self.pyramid_config.add_route('login', '/login') self.pyramid_config.add_route('login', '/login')