Add basic "mobile index" master view, plus support for demo mode
This commit is contained in:
		
							parent
							
								
									9808bb3a91
								
							
						
					
					
						commit
						581a21bd9d
					
				
					 16 changed files with 301 additions and 16 deletions
				
			
		|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										71
									
								
								tailbone/newgrids/mobile.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								tailbone/newgrids/mobile.py
									
										
									
									
									
										Normal 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)) | ||||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
							
								
								
									
										16
									
								
								tailbone/templates/mobile/master/index.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								tailbone/templates/mobile/master/index.mako
									
										
									
									
									
										Normal 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> | ||||
							
								
								
									
										12
									
								
								tailbone/templates/mobile/master/view.mako
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								tailbone/templates/mobile/master/view.mako
									
										
									
									
									
										Normal 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> | ||||
|  | @ -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']) | ||||
|  |  | |||
|  | @ -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), | ||||
|  |  | |||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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. | ||||
|  |  | |||
|  | @ -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')) | ||||
|  |  | |||
|  | @ -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: | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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): | ||||
|         """ | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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)\ | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Lance Edgar
						Lance Edgar