Add basic "mobile index" master view, plus support for demo mode

This commit is contained in:
Lance Edgar 2017-03-19 11:21:00 -05:00
parent 9808bb3a91
commit 581a21bd9d
16 changed files with 301 additions and 16 deletions

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,6 +24,9 @@
Grids and Friends
"""
from __future__ import unicode_literals, absolute_import
from . import filters
from .core import Grid, GridColumn, GridAction
from .alchemy import AlchemyGrid
from .mobile import MobileGrid

View file

@ -0,0 +1,71 @@
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 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/>.
#
################################################################################
"""
Mobile Grids
"""
from __future__ import unicode_literals, absolute_import
from webhelpers.html import tags
class Grid(object):
"""
Base class for all grids
"""
configured = False
def __init__(self, key, data=None, **kwargs):
"""
Grid constructor
"""
self.key = key
self.data = data
for k, v in kwargs.items():
setattr(self, k, v)
def __iter__(self):
"""
This grid supports iteration, over its data
"""
return iter(self.data)
def configure(self, include=None):
"""
Configure the grid. This must define which columns to display and in
which order, etc.
"""
self.configured = True
def view_url(self, obj, mobile=False):
route = '{}{}.view'.format('mobile.' if mobile else '', self.route_prefix)
return self.request.route_url(route, uuid=obj.uuid)
class MobileGrid(Grid):
"""
Base class for all mobile grids
"""
def render_object(self, obj):
return tags.link_to(obj, self.view_url(obj, mobile=True))

View file

@ -32,3 +32,9 @@ div.field-wrapper div.field input[type="password"] {
div.buttons input {
margin: auto 5px;
}
/* this is for "login as chuck" tip in demo mode */
.tips {
margin-top: 2em;
text-align: center;
}

View file

@ -38,3 +38,9 @@
${self.logo()}
${self.login_form()}
% if request.rattail_config.demo():
<p class="tips">
Login with <strong>chuck / admin</strong> for full demo access
</p>
% endif

View file

@ -0,0 +1,16 @@
## -*- coding: utf-8; -*-
## ##############################################################################
##
## Default master 'index' template for mobile. Features a somewhat abbreviated
## data table and (hopefully) exposes a way to filter and sort the data, etc.
##
## ##############################################################################
<%inherit file="/mobile/base.mako" />
<%def name="title()">${model_title_plural}</%def>
<ul data-role="listview">
% for obj in grid:
<li>${grid.render_object(obj)}</li>
% endfor
</ul>

View file

@ -0,0 +1,12 @@
## -*- coding: utf-8; -*-
## ##############################################################################
##
## Default master 'view' template for mobile. Features a basic field list, and
## links to edit/delete the object when appropriate.
##
## ##############################################################################
<%inherit file="/mobile/base.mako" />
<%def name="title()">${model_title}: ${instance_title}</%def>
<p>TODO: display fieldset for object</p>

View file

@ -162,6 +162,10 @@ class AuthenticationView(View):
if not self.request.user:
return self.redirect(self.request.route_url('home'))
if self.rattail_config.demo() and self.request.user.username == 'chuck':
self.request.session.flash("Cannot change password for 'chuck' in demo mode", 'error')
return self.redirect(self.request.get_referrer())
form = Form(self.request, schema=ChangePassword, state=self.request.user)
if form.validate():
set_user_password(self.request.user, form.data['new_password'])

View file

@ -401,7 +401,7 @@ class BatchMasterView(MasterView):
del batch.data_rows[:]
super(BatchMasterView, self).delete_instance(batch)
def get_fallback_templates(self, template):
def get_fallback_templates(self, template, mobile=False):
return [
'/newbatch/{}.mako'.format(template),
'/master/{}.mako'.format(template),

View file

@ -45,6 +45,7 @@ class CustomersView(MasterView):
Master view for the Customer class.
"""
model_class = model.Customer
supports_mobile = True
def configure_grid(self, g):
@ -81,6 +82,10 @@ class CustomersView(MasterView):
],
readonly=True)
def get_mobile_data(self, session=None):
# TODO: hacky!
return self.get_data(session=session).order_by(model.Customer.name)
def get_instance(self):
try:
instance = super(CustomersView, self).get_instance()

View file

@ -140,6 +140,16 @@ class ProfilesView(MasterView):
def get_instance_title(self, email):
return email['_email'].get_complete_subject(render=False)
def editable_instance(self, profile):
if self.rattail_config.demo():
return profile['key'] != 'user_feedback'
return True
def deletable_instance(self, profile):
if self.rattail_config.demo():
return profile['key'] != 'user_feedback'
return True
def make_form(self, email, **kwargs):
"""
Make a simple form for use with CRUD views.

View file

@ -111,6 +111,16 @@ class EmployeesView(MasterView):
q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
return q
def editable_instance(self, employee):
if self.rattail_config.demo():
return not bool(employee.user and employee.username == 'chuck')
return True
def deletable_instance(self, employee):
if self.rattail_config.demo():
return not bool(employee.user and employee.username == 'chuck')
return True
def _preconfigure_fieldset(self, fs):
fs.append(forms.AssociationProxyField('first_name'))
fs.append(forms.AssociationProxyField('last_name'))

View file

@ -38,7 +38,7 @@ from webhelpers.html import HTML, tags
from tailbone import forms, newgrids as grids
from tailbone.views import View
from tailbone.newgrids import filters, AlchemyGrid, GridAction
from tailbone.newgrids import filters, AlchemyGrid, GridAction, MobileGrid
class MasterView(View):
@ -57,6 +57,8 @@ class MasterView(View):
bulk_deletable = False
mergeable = False
supports_mobile = False
listing = False
creating = False
viewing = False
@ -122,6 +124,83 @@ class MasterView(View):
return self.render_to_response('index', {'grid': grid})
def mobile_index(self):
"""
Mobile "home" page for the data model
"""
self.listing = True
grid = self.make_mobile_grid()
return self.render_to_response('index', {'grid': grid}, mobile=True)
def make_mobile_grid(self, **kwargs):
factory = self.get_mobile_grid_factory()
key = self.get_mobile_grid_key()
data = self.get_mobile_data(session=kwargs.get('session'))
kwargs = self.make_mobile_grid_kwargs(**kwargs)
kwargs.setdefault('key', key)
kwargs.setdefault('request', self.request)
kwargs.setdefault('data', data)
kwargs.setdefault('model_class', self.get_model_class(error=False))
grid = factory(**kwargs)
self.preconfigure_mobile_grid(grid)
self.configure_mobile_grid(grid)
return grid
@classmethod
def get_mobile_grid_factory(cls):
"""
Must return a callable to be used when creating new mobile grid
instances. Instead of overriding this, you can set
:attr:`mobile_grid_factory`. Default factory is :class:`MobileGrid`.
"""
return getattr(cls, 'mobile_grid_factory', MobileGrid)
@classmethod
def get_mobile_grid_key(cls):
"""
Must return a unique "config key" for the mobile grid, for sort/filter
purposes etc. (It need only be unique among *mobile* grids.) Instead
of overriding this, you can set :attr:`mobile_grid_key`. Default is
the value returned by :meth:`get_route_prefix()`.
"""
if hasattr(cls, 'mobile_grid_key'):
return cls.mobile_grid_key
return cls.get_route_prefix()
def get_mobile_data(self, session=None):
"""
Must return the "raw" / full data set for the mobile grid. This data
should *not* yet be sorted or filtered in any way; that happens later.
Default is the value returned by :meth:`get_data()`, in which case all
records visible in the traditional view, are visible in mobile too.
"""
return self.get_data(session=session)
def make_mobile_grid_kwargs(self, **kwargs):
"""
Must return a dictionary of kwargs to be passed to the factory when
creating new mobile grid instances.
"""
defaults = {
'route_prefix': self.get_route_prefix(),
}
defaults.update(kwargs)
return defaults
def preconfigure_mobile_grid(self, grid):
"""
Optionally perform pre-configuration for the mobile grid, to establish
some sane defaults etc.
"""
def configure_mobile_grid(self, grid):
"""
Configure the mobile grid. The primary objective here is to define
which columns to show and in which order etc. Along the way you're
free to customize any column(s) you like, as needed.
"""
grid.configure()
def create(self):
"""
View for creating a new model record.
@ -185,6 +264,23 @@ class MasterView(View):
tools=self.make_row_grid_tools(instance))
return self.render_to_response('view', context)
def mobile_view(self):
"""
Mobile view for displaying a single object's details
"""
self.viewing = True
instance = self.get_instance()
# form = self.make_form(instance)
context = {
'instance': instance,
'instance_title': self.get_instance_title(instance),
# 'instance_editable': self.editable_instance(instance),
# 'instance_deletable': self.deletable_instance(instance),
# 'form': form,
}
return self.render_to_response('view', context, mobile=True)
def make_default_row_grid_tools(self, obj):
if self.rows_creatable:
link = tags.link_to("Create a new {}".format(self.get_row_model_title()),
@ -614,7 +710,7 @@ class MasterView(View):
return self.request.route_url('{0}.{1}'.format(self.get_route_prefix(), action),
**self.get_action_route_kwargs(instance))
def render_to_response(self, template, data):
def render_to_response(self, template, data, mobile=False):
"""
Return a response with the given template rendered with the given data.
Note that ``template`` must only be a "key" (e.g. 'index' or 'view').
@ -650,14 +746,17 @@ class MasterView(View):
context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context))
# First try the template path most specific to the view.
if mobile:
mako_path = '/mobile{}/{}.mako'.format(self.get_template_prefix(), template)
else:
mako_path = '{}/{}.mako'.format(self.get_template_prefix(), template)
try:
return render_to_response('{}/{}.mako'.format(self.get_template_prefix(), template),
context, request=self.request)
return render_to_response(mako_path, context, request=self.request)
except IOError:
# Failing that, try one or more fallback templates.
for fallback in self.get_fallback_templates(template):
for fallback in self.get_fallback_templates(template, mobile=mobile):
try:
return render_to_response(fallback, context, request=self.request)
except IOError:
@ -704,7 +803,9 @@ class MasterView(View):
return render('{}/{}.mako'.format(self.get_template_prefix(), template),
context, request=self.request)
def get_fallback_templates(self, template):
def get_fallback_templates(self, template, mobile=False):
if mobile:
return ['/mobile/master/{}.mako'.format(template)]
return ['/master/{}.mako'.format(template)]
def template_kwargs(self, **kwargs):
@ -1297,11 +1398,15 @@ class MasterView(View):
# list/search
if cls.listable:
config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix),
"List / search {}".format(model_title_plural))
config.add_route(route_prefix, '{}/'.format(url_prefix))
config.add_view(cls, attr='index', route_name=route_prefix,
permission='{}.list'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix),
"List / search {}".format(model_title_plural))
if cls.supports_mobile:
config.add_route('mobile.{}'.format(route_prefix), '/mobile{}/'.format(url_prefix))
config.add_view(cls, attr='mobile_index', route_name='mobile.{}'.format(route_prefix),
permission='{}.list'.format(permission_prefix))
# create
if cls.creatable:
@ -1344,6 +1449,10 @@ class MasterView(View):
config.add_route('{}.view'.format(route_prefix), '{}/{{{}}}'.format(url_prefix, model_key))
config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix),
permission='{}.view'.format(permission_prefix))
if cls.supports_mobile:
config.add_route('mobile.{}.view'.format(route_prefix), '/mobile{}/{{{}}}'.format(url_prefix, model_key))
config.add_view(cls, attr='mobile_view', route_name='mobile.{}.view'.format(route_prefix),
permission='{}.view'.format(permission_prefix))
# edit
if cls.editable:

View file

@ -107,6 +107,16 @@ class PeopleView(MasterView):
return instance.person
raise HTTPNotFound
def editable_instance(self, person):
if self.rattail_config.demo():
return not bool(person.user and person.user.username == 'chuck')
return True
def deletable_instance(self, person):
if self.rattail_config.demo():
return not bool(person.user and person.user.username == 'chuck')
return True
def _preconfigure_fieldset(self, fs):
fs.display_name.set(label="Full Name")
fs.phone.set(label="Phone Number", readonly=True)

View file

@ -38,10 +38,10 @@ class PrincipalMasterView(MasterView):
Master view base class for security principal models, i.e. User and Role.
"""
def get_fallback_templates(self, template):
def get_fallback_templates(self, template, mobile=False):
return [
'/principal/{}.mako'.format(template),
] + super(PrincipalMasterView, self).get_fallback_templates(template)
] + super(PrincipalMasterView, self).get_fallback_templates(template, mobile=mobile)
def find_by_perm(self):
"""

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar
# Copyright © 2010-2017 Lance Edgar
#
# This file is part of Rattail.
#
@ -24,7 +24,9 @@
Settings Views
"""
from __future__ import unicode_literals
from __future__ import unicode_literals, absolute_import
import re
from rattail.db import model
@ -36,6 +38,7 @@ class SettingsView(MasterView):
Master view for the settings model.
"""
model_class = model.Setting
feedback = re.compile(r'^rattail\.mail\.user_feedback\..*')
def configure_grid(self, g):
g.filters['name'].default_active = True
@ -57,6 +60,16 @@ class SettingsView(MasterView):
if self.editing:
fs.name.set(readonly=True)
def editable_instance(self, setting):
if self.rattail_config.demo():
return not bool(self.feedback.match(setting.name))
return True
def deletable_instance(self, setting):
if self.rattail_config.demo():
return not bool(self.feedback.match(setting.name))
return True
def includeme(config):
SettingsView.defaults(config)

View file

@ -201,6 +201,16 @@ class UsersView(PrincipalMasterView):
del fs.password
del fs.confirm_password
def editable_instance(self, user):
if self.rattail_config.demo():
return user.username != 'chuck'
return True
def deletable_instance(self, user):
if self.rattail_config.demo():
return user.username != 'chuck'
return True
def find_principals_with_permission(self, session, permission):
# TODO: this should search Permission table instead, and work backward to User?
all_users = session.query(model.User)\