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 # Rattail -- Retail Software Framework
# Copyright © 2010-2015 Lance Edgar # Copyright © 2010-2017 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -24,6 +24,9 @@
Grids and Friends Grids and Friends
""" """
from __future__ import unicode_literals, absolute_import
from . import filters from . import filters
from .core import Grid, GridColumn, GridAction from .core import Grid, GridColumn, GridAction
from .alchemy import AlchemyGrid 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 { div.buttons input {
margin: auto 5px; 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.logo()}
${self.login_form()} ${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: if not self.request.user:
return self.redirect(self.request.route_url('home')) 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) form = Form(self.request, schema=ChangePassword, state=self.request.user)
if form.validate(): if form.validate():
set_user_password(self.request.user, form.data['new_password']) set_user_password(self.request.user, form.data['new_password'])

View file

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

View file

@ -45,6 +45,7 @@ class CustomersView(MasterView):
Master view for the Customer class. Master view for the Customer class.
""" """
model_class = model.Customer model_class = model.Customer
supports_mobile = True
def configure_grid(self, g): def configure_grid(self, g):
@ -81,6 +82,10 @@ class CustomersView(MasterView):
], ],
readonly=True) 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): def get_instance(self):
try: try:
instance = super(CustomersView, self).get_instance() instance = super(CustomersView, self).get_instance()

View file

@ -140,6 +140,16 @@ class ProfilesView(MasterView):
def get_instance_title(self, email): def get_instance_title(self, email):
return email['_email'].get_complete_subject(render=False) 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): def make_form(self, email, **kwargs):
""" """
Make a simple form for use with CRUD views. 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) q = q.filter(model.Employee.status == self.enum.EMPLOYEE_STATUS_CURRENT)
return q 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): def _preconfigure_fieldset(self, fs):
fs.append(forms.AssociationProxyField('first_name')) fs.append(forms.AssociationProxyField('first_name'))
fs.append(forms.AssociationProxyField('last_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 import forms, newgrids as grids
from tailbone.views import View from tailbone.views import View
from tailbone.newgrids import filters, AlchemyGrid, GridAction from tailbone.newgrids import filters, AlchemyGrid, GridAction, MobileGrid
class MasterView(View): class MasterView(View):
@ -57,6 +57,8 @@ class MasterView(View):
bulk_deletable = False bulk_deletable = False
mergeable = False mergeable = False
supports_mobile = False
listing = False listing = False
creating = False creating = False
viewing = False viewing = False
@ -122,6 +124,83 @@ class MasterView(View):
return self.render_to_response('index', {'grid': grid}) 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): def create(self):
""" """
View for creating a new model record. View for creating a new model record.
@ -185,6 +264,23 @@ class MasterView(View):
tools=self.make_row_grid_tools(instance)) tools=self.make_row_grid_tools(instance))
return self.render_to_response('view', context) 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): def make_default_row_grid_tools(self, obj):
if self.rows_creatable: if self.rows_creatable:
link = tags.link_to("Create a new {}".format(self.get_row_model_title()), 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), return self.request.route_url('{0}.{1}'.format(self.get_route_prefix(), action),
**self.get_action_route_kwargs(instance)) **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. 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'). 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)) context.update(getattr(self, 'template_kwargs_{}'.format(template))(**context))
# First try the template path most specific to the view. # 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: try:
return render_to_response('{}/{}.mako'.format(self.get_template_prefix(), template), return render_to_response(mako_path, context, request=self.request)
context, request=self.request)
except IOError: except IOError:
# Failing that, try one or more fallback templates. # 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: try:
return render_to_response(fallback, context, request=self.request) return render_to_response(fallback, context, request=self.request)
except IOError: except IOError:
@ -704,7 +803,9 @@ class MasterView(View):
return render('{}/{}.mako'.format(self.get_template_prefix(), template), return render('{}/{}.mako'.format(self.get_template_prefix(), template),
context, request=self.request) 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)] return ['/master/{}.mako'.format(template)]
def template_kwargs(self, **kwargs): def template_kwargs(self, **kwargs):
@ -1297,11 +1398,15 @@ class MasterView(View):
# list/search # list/search
if cls.listable: 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_route(route_prefix, '{}/'.format(url_prefix))
config.add_view(cls, attr='index', route_name=route_prefix, config.add_view(cls, attr='index', route_name=route_prefix,
permission='{}.list'.format(permission_prefix)) permission='{}.list'.format(permission_prefix))
config.add_tailbone_permission(permission_prefix, '{}.list'.format(permission_prefix), if cls.supports_mobile:
"List / search {}".format(model_title_plural)) 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 # create
if cls.creatable: if cls.creatable:
@ -1344,6 +1449,10 @@ class MasterView(View):
config.add_route('{}.view'.format(route_prefix), '{}/{{{}}}'.format(url_prefix, model_key)) config.add_route('{}.view'.format(route_prefix), '{}/{{{}}}'.format(url_prefix, model_key))
config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix), config.add_view(cls, attr='view', route_name='{}.view'.format(route_prefix),
permission='{}.view'.format(permission_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 # edit
if cls.editable: if cls.editable:

View file

@ -107,6 +107,16 @@ class PeopleView(MasterView):
return instance.person return instance.person
raise HTTPNotFound 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): def _preconfigure_fieldset(self, fs):
fs.display_name.set(label="Full Name") fs.display_name.set(label="Full Name")
fs.phone.set(label="Phone Number", readonly=True) 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. 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 [ return [
'/principal/{}.mako'.format(template), '/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): def find_by_perm(self):
""" """

View file

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

View file

@ -201,6 +201,16 @@ class UsersView(PrincipalMasterView):
del fs.password del fs.password
del fs.confirm_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): def find_principals_with_permission(self, session, permission):
# TODO: this should search Permission table instead, and work backward to User? # TODO: this should search Permission table instead, and work backward to User?
all_users = session.query(model.User)\ all_users = session.query(model.User)\