Major overhaul for standalone operation.

This removes some of the `edbob` reliance, as well as borrowing some templates
and styling etc. from Dtail.
This commit is contained in:
Lance Edgar 2013-09-01 15:31:50 -07:00
parent b9f61e6a47
commit 2a50e704ef
59 changed files with 1969 additions and 39 deletions

View file

@ -62,11 +62,40 @@ requires = [
# #
# package # low high # package # low high
'edbob[db,pyramid]>=0.1a29', # 0.1a29 # Pyramid 1.3 introduced 'pcreate' command (and friends) to replace
'rattail>=0.3a40', # 0.3a40 # deprecated 'paster create' (and friends).
'pyramid>=1.3a1', # 1.3b2
'FormAlchemy', # 1.4.2
'FormEncode', # 1.2.4
'Mako', # 0.6.2
'pyramid_beaker>=0.6', # 0.6.1
'pyramid_debugtoolbar', # 1.0
'pyramid_exclog', # 0.6
'pyramid_simpleform', # 0.6.1
'pyramid_tm', # 0.3
'rattail[db]>=0.3.4', # 0.3.4
'transaction', # 1.2.0
'waitress', # 0.8.1
'WebHelpers', # 1.3
'zope.sqlalchemy', # 0.7
] ]
extras = {
'tests': [
#
# package # low high
'coverage', # 3.6
'fixture', # 1.5
'mock', # 1.0.1
'nose', # 1.3.0
],
}
setup( setup(
name = "Tailbone", name = "Tailbone",
version = __version__, version = __version__,
@ -94,10 +123,17 @@ setup(
], ],
install_requires = requires, install_requires = requires,
tests_require = requires + ['mock', 'nose', 'coverage', 'fixture'], extras_require = extras,
tests_require = ['Tailbone[tests]'],
test_suite = 'nose.collector', test_suite = 'nose.collector',
packages = find_packages(), packages = find_packages(),
include_package_data = True, include_package_data = True,
zip_safe = False, zip_safe = False,
entry_points = {
'paste.app_factory': [
'main = tailbone.app:main',
],
},
) )

View file

@ -23,13 +23,11 @@
################################################################################ ################################################################################
""" """
Rattail's Pyramid Framework Backoffice Web Application for Rattail
""" """
from ._version import __version__ from ._version import __version__
from edbob.pyramid import Session
def includeme(config): def includeme(config):
config.include('tailbone.static') config.include('tailbone.static')

81
tailbone/app.py Normal file
View file

@ -0,0 +1,81 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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/>.
#
################################################################################
"""
Application Entry Point
"""
from pyramid.config import Configurator
import os.path
import edbob
import edbob.db
from .db import Session
from zope.sqlalchemy import ZopeTransactionExtension
from pyramid.authentication import SessionAuthenticationPolicy
from .auth import TailboneAuthorizationPolicy
def main(global_config, **settings):
"""
This function returns a Pyramid WSGI application.
"""
# Use Tailbone templates by default.
settings.setdefault('mako.directories', ['tailbone:templates'])
# Make two attempts when "retryable" errors happen during transactions.
# This is intended to gracefully handle database restarts.
settings.setdefault('tm.attempts', 2)
config = Configurator(settings=settings)
# Initialize edbob, dammit.
edbob.init('rattail', os.path.abspath(settings['edbob.config']))
edbob.init_modules(['edbob.time', 'edbob.db', 'rattail.db'])
# Configure the primary database session. For now, this leverages edbob's
# initialization to define the engine connection.
assert edbob.db.engine
Session.configure(bind=edbob.db.engine)
Session.configure(extension=ZopeTransactionExtension())
# Configure user authentication / authorization.
config.set_authentication_policy(SessionAuthenticationPolicy())
config.set_authorization_policy(TailboneAuthorizationPolicy())
# Bring in some Pyramid goodies.
config.include('pyramid_beaker')
config.include('pyramid_tm')
# Bring in the rest of Tailbone.
config.include('tailbone')
# Consider PostgreSQL server restart errors to be "retryable."
config.add_tween('edbob.pyramid.tweens.sqlerror_tween_factory',
under='pyramid_tm.tm_tween_factory')
return config.make_wsgi_app()

52
tailbone/auth.py Normal file
View file

@ -0,0 +1,52 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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/>.
#
################################################################################
"""
Authentication & Authorization
"""
from zope.interface import implementer
from pyramid.interfaces import IAuthorizationPolicy
from pyramid.security import Everyone, Authenticated
from .db import Session
from rattail.db.model import User
from edbob.db.auth import has_permission
@implementer(IAuthorizationPolicy)
class TailboneAuthorizationPolicy(object):
def permits(self, context, principals, permission):
for userid in principals:
if userid not in (Everyone, Authenticated):
user = Session.query(User).get(userid)
assert user
return has_permission(user, permission)
if Everyone in principals:
return has_permission(None, permission, session=Session())
return False
def principals_allowed_by_permission(self, context, permission):
raise NotImplementedError

35
tailbone/db.py Normal file
View file

@ -0,0 +1,35 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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/>.
#
################################################################################
"""
Database Stuff
"""
from sqlalchemy.orm import sessionmaker, scoped_session
__all__ = ['Session']
Session = scoped_session(sessionmaker())

View file

@ -27,6 +27,7 @@ Common Field Renderers
""" """
from formalchemy.fields import FieldRenderer, SelectFieldRenderer from formalchemy.fields import FieldRenderer, SelectFieldRenderer
from pyramid.renderers import render
__all__ = ['AutocompleteFieldRenderer', 'EnumFieldRenderer'] __all__ = ['AutocompleteFieldRenderer', 'EnumFieldRenderer']

View file

@ -35,7 +35,7 @@ import edbob
from edbob.util import prettify from edbob.util import prettify
from .core import Grid from .core import Grid
from .. import Session from ..db import Session
from sqlalchemy.orm import object_session from sqlalchemy.orm import object_session
__all__ = ['AlchemyGrid'] __all__ = ['AlchemyGrid']

View file

@ -28,4 +28,6 @@ Static Assets
def includeme(config): def includeme(config):
# TODO: Remove edbob.
config.include('edbob.pyramid.static')
config.add_static_view('tailbone', 'tailbone:static') config.add_static_view('tailbone', 'tailbone:static')

View file

@ -0,0 +1,28 @@
/******************************
* General
******************************/
body {
font-family: Verdana, Arial, sans-serif;
font-size: 82%;
}
a {
color: #3D6E1C;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
div.flash-messages div.ui-state-highlight {
padding: .3em;
margin-bottom: 8px;
}
div.error-messages div.ui-state-error {
padding: .3em;
margin-bottom: 8px;
}

View file

@ -0,0 +1,28 @@
/******************************
* Filters
******************************/
div.filters form {
margin-bottom: 10px;
}
div.filters div.filter {
margin-bottom: 10px;
}
div.filters div.filter label {
margin-right: 8px;
}
div.filters div.filter select.filter-type {
margin-right: 8px;
}
div.filters div.filter div.value {
display: inline;
}
div.filters div.buttons * {
margin-right: 8px;
}

View file

@ -0,0 +1,121 @@
/******************************
* Form Wrapper
******************************/
div.form-wrapper {
overflow: auto;
}
/******************************
* Context Menu
******************************/
div.form-wrapper ul.context-menu {
float: right;
list-style-type: none;
margin: 0px;
text-align: right;
}
div.form-wrapper ul.context-menu li {
line-height: 2em;
}
/******************************
* Forms
******************************/
div.form,
div.fieldset-form,
div.fieldset {
float: left;
margin-top: 10px;
}
/******************************
* Fieldsets
******************************/
div.field-wrapper {
clear: both;
min-height: 30px;
overflow: auto;
padding: 5px;
}
div.field-wrapper.error {
background-color: #ddcccc;
border: 2px solid #dd6666;
}
div.field-wrapper label {
color: #000000;
display: block;
float: left;
width: 160px;
font-weight: bold;
margin-top: 2px;
white-space: nowrap;
}
div.field-wrapper div.field-error {
color: #dd6666;
font-weight: bold;
}
div.field-wrapper div.field {
display: block;
float: left;
margin-bottom: 5px;
line-height: 25px;
}
div.field-wrapper div.field input[type=text],
div.field-wrapper div.field input[type=password],
div.field-wrapper div.field select,
div.field-wrapper div.field textarea {
width: 320px;
}
label input[type="checkbox"],
label input[type="radio"] {
margin-right: 0.5em;
}
div.field ul {
margin: 0px;
padding-left: 15px;
}
/******************************
* Buttons
******************************/
div.buttons {
clear: both;
margin: 10px 0px;
}
/******************************
* Employee Login Form
******************************/
#employee-login-dialog label {
width: 120px;
}
#employee-login-dialog input[type="text"],
#employee-login-dialog input[type="password"] {
width: 80px;
}
#employee-login-dialog div.buttons {
margin: 10px 0px 0px 0px;
text-align: center;
}

View file

@ -0,0 +1,40 @@
.loadmask {
z-index: 100;
position: absolute;
top:0;
left:0;
-moz-opacity: 0.5;
opacity: .50;
filter: alpha(opacity=50);
background-color: #CCC;
width: 100%;
height: 100%;
zoom: 1;
}
.loadmask-msg {
z-index: 20001;
position: absolute;
top: 0;
left: 0;
border:1px solid #6593cf;
background: #c3daf9;
padding:2px;
}
.loadmask-msg div {
padding:5px 10px 5px 25px;
background: #fbfbfb url('../img/loading.gif') no-repeat 5px 5px;
line-height: 16px;
border:1px solid #a3bad9;
color:#222;
font:normal 11px tahoma, arial, helvetica, sans-serif;
cursor:wait;
}
.masked {
overflow: hidden !important;
}
.masked-relative {
position: relative !important;
}
.masked-hidden {
visibility: hidden !important;
}

View file

@ -0,0 +1,15 @@
/*
* jQuery UI Menubar @VERSION
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*/
.ui-menubar { list-style: none; margin: 0; padding-left: 0; }
.ui-menubar-item { float: left; }
.ui-menubar .ui-button { float: left; font-weight: normal; border-top-width: 0 !important; border-bottom-width: 0 !important; margin: 0; outline: none; }
.ui-menubar .ui-menubar-link { border-right: 1px dashed transparent; border-left: 1px dashed transparent; }
.ui-menubar .ui-menu { width: 200px; position: absolute; z-index: 9999; font-weight: normal; }

View file

@ -0,0 +1,64 @@
/******************************
* Main Layout
******************************/
html, body, #body-wrapper {
height: 100%;
}
body > #body-wrapper {
height: auto;
min-height: 100%;
}
#body-wrapper {
margin: 0px auto;
width: 1000px;
}
#header {
height: 50px;
line-height: 50px;
}
#body {
padding-top: 10px;
padding-bottom: 5em;
}
#footer {
clear: both;
margin-top: -4em;
text-align: center;
}
/******************************
* Header
******************************/
#header h1 {
float: left;
font-size: 25px;
margin: 0px;
}
#header h1.title {
font-size: 20px;
margin-left: 10px;
}
#header div.login {
float: right;
}
/******************************
* Logo
******************************/
#logo {
display: block;
margin: 40px auto;
}

396
tailbone/static/css/normalize.css vendored Normal file
View file

@ -0,0 +1,396 @@
/*! normalize.css v2.1.0 | MIT License | git.io/normalize */
/* ==========================================================================
HTML5 display definitions
========================================================================== */
/**
* Correct `block` display not defined in IE 8/9.
*/
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
nav,
section,
summary {
display: block;
}
/**
* Correct `inline-block` display not defined in IE 8/9.
*/
audio,
canvas,
video {
display: inline-block;
}
/**
* Prevent modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Address styling not present in IE 8/9.
*/
[hidden] {
display: none;
}
/* ==========================================================================
Base
========================================================================== */
/**
* 1. Set default font family to sans-serif.
* 2. Prevent iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-family: sans-serif; /* 1 */
-webkit-text-size-adjust: 100%; /* 2 */
-ms-text-size-adjust: 100%; /* 2 */
}
/**
* Remove default margin.
*/
body {
margin: 0;
}
/* ==========================================================================
Links
========================================================================== */
/**
* Address `outline` inconsistency between Chrome and other browsers.
*/
a:focus {
outline: thin dotted;
}
/**
* Improve readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/* ==========================================================================
Typography
========================================================================== */
/**
* Address variable `h1` font-size and margin within `section` and `article`
* contexts in Firefox 4+, Safari 5, and Chrome.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/**
* Address styling not present in IE 8/9, Safari 5, and Chrome.
*/
abbr[title] {
border-bottom: 1px dotted;
}
/**
* Address style set to `bolder` in Firefox 4+, Safari 5, and Chrome.
*/
b,
strong {
font-weight: bold;
}
/**
* Address styling not present in Safari 5 and Chrome.
*/
dfn {
font-style: italic;
}
/**
* Address differences between Firefox and other browsers.
*/
hr {
-moz-box-sizing: content-box;
box-sizing: content-box;
height: 0;
}
/**
* Address styling not present in IE 8/9.
*/
mark {
background: #ff0;
color: #000;
}
/**
* Correct font family set oddly in Safari 5 and Chrome.
*/
code,
kbd,
pre,
samp {
font-family: monospace, serif;
font-size: 1em;
}
/**
* Improve readability of pre-formatted text in all browsers.
*/
pre {
white-space: pre-wrap;
}
/**
* Set consistent quote types.
*/
q {
quotes: "\201C" "\201D" "\2018" "\2019";
}
/**
* Address inconsistent and variable font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` affecting `line-height` in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.5em;
}
sub {
bottom: -0.25em;
}
/* ==========================================================================
Embedded content
========================================================================== */
/**
* Remove border when inside `a` element in IE 8/9.
*/
img {
border: 0;
}
/**
* Correct overflow displayed oddly in IE 9.
*/
svg:not(:root) {
overflow: hidden;
}
/* ==========================================================================
Figures
========================================================================== */
/**
* Address margin not present in IE 8/9 and Safari 5.
*/
figure {
margin: 0;
}
/* ==========================================================================
Forms
========================================================================== */
/**
* Define consistent border, margin, and padding.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/**
* 1. Correct `color` not being inherited in IE 8/9.
* 2. Remove padding so people aren't caught out if they zero out fieldsets.
*/
legend {
border: 0; /* 1 */
padding: 0; /* 2 */
}
/**
* 1. Correct font family not being inherited in all browsers.
* 2. Correct font size not being inherited in all browsers.
* 3. Address margins set differently in Firefox 4+, Safari 5, and Chrome.
*/
button,
input,
select,
textarea {
font-family: inherit; /* 1 */
font-size: 100%; /* 2 */
margin: 0; /* 3 */
}
/**
* Address Firefox 4+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
button,
input {
line-height: normal;
}
/**
* Address inconsistent `text-transform` inheritance for `button` and `select`.
* All other form control elements do not inherit `text-transform` values.
* Correct `button` style inheritance in Chrome, Safari 5+, and IE 8+.
* Correct `select` style inheritance in Firefox 4+ and Opera.
*/
button,
select {
text-transform: none;
}
/**
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Correct inability to style clickable `input` types in iOS.
* 3. Improve usability and consistency of cursor style between image-type
* `input` and others.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button; /* 2 */
cursor: pointer; /* 3 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* 1. Address box sizing set to `content-box` in IE 8/9.
* 2. Remove excess padding in IE 8/9.
*/
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* 1. Address `appearance` set to `searchfield` in Safari 5 and Chrome.
* 2. Address `box-sizing` set to `border-box` in Safari 5 and Chrome
* (include `-moz` to future-proof).
*/
input[type="search"] {
-webkit-appearance: textfield; /* 1 */
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/**
* Remove inner padding and search cancel button in Safari 5 and Chrome
* on OS X.
*/
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Remove inner padding and border in Firefox 4+.
*/
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0;
}
/**
* 1. Remove default vertical scrollbar in IE 8/9.
* 2. Improve readability and alignment in all browsers.
*/
textarea {
overflow: auto; /* 1 */
vertical-align: top; /* 2 */
}
/* ==========================================================================
Tables
========================================================================== */
/**
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,10 @@
/**
* Copyright (c) 2009 Sergiy Kovalchuk (serg472@gmail.com)
*
* Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
* and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
*
* Following code is based on Element.mask() implementation from ExtJS framework (http://extjs.com/)
*
*/
(function(a){a.fn.mask=function(c,b){a(this).each(function(){if(b!==undefined&&b>0){var d=a(this);d.data("_mask_timeout",setTimeout(function(){a.maskElement(d,c)},b))}else{a.maskElement(a(this),c)}})};a.fn.unmask=function(){a(this).each(function(){a.unmaskElement(a(this))})};a.fn.isMasked=function(){return this.hasClass("masked")};a.maskElement=function(d,c){if(d.data("_mask_timeout")!==undefined){clearTimeout(d.data("_mask_timeout"));d.removeData("_mask_timeout")}if(d.isMasked()){a.unmaskElement(d)}if(d.css("position")=="static"){d.addClass("masked-relative")}d.addClass("masked");var e=a('<div class="loadmask"></div>');if(navigator.userAgent.toLowerCase().indexOf("msie")>-1){e.height(d.height()+parseInt(d.css("padding-top"))+parseInt(d.css("padding-bottom")));e.width(d.width()+parseInt(d.css("padding-left"))+parseInt(d.css("padding-right")))}if(navigator.userAgent.toLowerCase().indexOf("msie 6")>-1){d.find("select").addClass("masked-hidden")}d.append(e);if(c!==undefined){var b=a('<div class="loadmask-msg" style="display:none;"></div>');b.append("<div>"+c+"</div>");d.append(b);b.css("top",Math.round(d.height()/2-(b.height()-parseInt(b.css("padding-top"))-parseInt(b.css("padding-bottom")))/2)+"px");b.css("left",Math.round(d.width()/2-(b.width()-parseInt(b.css("padding-left"))-parseInt(b.css("padding-right")))/2)+"px");b.show()}};a.unmaskElement=function(b){if(b.data("_mask_timeout")!==undefined){clearTimeout(b.data("_mask_timeout"));b.removeData("_mask_timeout")}b.find(".loadmask-msg,.loadmask").remove();b.removeClass("masked");b.removeClass("masked-relative");b.find("select").removeClass("masked-hidden")}})(jQuery);

View file

@ -0,0 +1,327 @@
/*
* jQuery UI Menubar @VERSION
*
* Copyright 2011, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Menubar
*
* Depends:
* jquery.ui.core.js
* jquery.ui.widget.js
* jquery.ui.position.js
* jquery.ui.menu.js
*/
(function( $ ) {
// TODO when mixing clicking menus and keyboard navigation, focus handling is broken
// there has to be just one item that has tabindex
$.widget( "ui.menubar", {
version: "@VERSION",
options: {
autoExpand: false,
buttons: false,
items: "li",
menuElement: "ul",
menuIcon: false,
position: {
my: "left top",
at: "left bottom"
}
},
_create: function() {
var that = this;
this.menuItems = this.element.children( this.options.items );
this.items = this.menuItems.children( "button, a" );
this.menuItems
.addClass( "ui-menubar-item" )
.attr( "role", "presentation" );
// let only the first item receive focus
this.items.slice(1).attr( "tabIndex", -1 );
this.element
.addClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
.attr( "role", "menubar" );
this._focusable( this.items );
this._hoverable( this.items );
this.items.siblings( this.options.menuElement )
.menu({
position: {
within: this.options.position.within
},
select: function( event, ui ) {
ui.item.parents( "ul.ui-menu:last" ).hide();
that._close();
// TODO what is this targetting? there's probably a better way to access it
$(event.target).prev().focus();
that._trigger( "select", event, ui );
},
menus: that.options.menuElement
})
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
})
// TODO use _on
.bind( "keydown.menubar", function( event ) {
var menu = $( this );
if ( menu.is( ":hidden" ) ) {
return;
}
switch ( event.keyCode ) {
case $.ui.keyCode.LEFT:
that.previous( event );
event.preventDefault();
break;
case $.ui.keyCode.RIGHT:
that.next( event );
event.preventDefault();
break;
}
});
this.items.each(function() {
var input = $(this),
// TODO menu var is only used on two places, doesn't quite justify the .each
menu = input.next( that.options.menuElement );
// might be a non-menu button
if ( menu.length ) {
// TODO use _on
input.bind( "click.menubar focus.menubar mouseenter.menubar", function( event ) {
// ignore triggered focus event
if ( event.type === "focus" && !event.originalEvent ) {
return;
}
event.preventDefault();
// TODO can we simplify or extractthis check? especially the last two expressions
// there's a similar active[0] == menu[0] check in _open
if ( event.type === "click" && menu.is( ":visible" ) && that.active && that.active[0] === menu[0] ) {
that._close();
return;
}
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" || that.options.autoExpand ) {
if( that.options.autoExpand ) {
clearTimeout( that.closeTimer );
}
that._open( event, menu );
}
})
// TODO use _on
.bind( "keydown", function( event ) {
switch ( event.keyCode ) {
case $.ui.keyCode.SPACE:
case $.ui.keyCode.UP:
case $.ui.keyCode.DOWN:
that._open( event, $( this ).next() );
event.preventDefault();
break;
case $.ui.keyCode.LEFT:
that.previous( event );
event.preventDefault();
break;
case $.ui.keyCode.RIGHT:
that.next( event );
event.preventDefault();
break;
}
})
.attr( "aria-haspopup", "true" );
// TODO review if these options (menuIcon and buttons) are a good choice, maybe they can be merged
if ( that.options.menuIcon ) {
input.addClass( "ui-state-default" ).append( "<span class='ui-button-icon-secondary ui-icon ui-icon-triangle-1-s'></span>" );
input.removeClass( "ui-button-text-only" ).addClass( "ui-button-text-icon-secondary" );
}
} else {
// TODO use _on
input.bind( "click.menubar mouseenter.menubar", function( event ) {
if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" ) {
that._close();
}
});
}
input
.addClass( "ui-button ui-widget ui-button-text-only ui-menubar-link" )
.attr( "role", "menuitem" )
.wrapInner( "<span class='ui-button-text'></span>" );
if ( that.options.buttons ) {
input.removeClass( "ui-menubar-link" ).addClass( "ui-state-default" );
}
});
that._on( {
keydown: function( event ) {
if ( event.keyCode === $.ui.keyCode.ESCAPE && that.active && that.active.menu( "collapse", event ) !== true ) {
var active = that.active;
that.active.blur();
that._close( event );
active.prev().focus();
}
},
focusin: function( event ) {
clearTimeout( that.closeTimer );
},
focusout: function( event ) {
that.closeTimer = setTimeout( function() {
that._close( event );
}, 150);
},
"mouseleave .ui-menubar-item": function( event ) {
if ( that.options.autoExpand ) {
that.closeTimer = setTimeout( function() {
that._close( event );
}, 150);
}
},
"mouseenter .ui-menubar-item": function( event ) {
clearTimeout( that.closeTimer );
}
});
// Keep track of open submenus
this.openSubmenus = 0;
},
_destroy : function() {
this.menuItems
.removeClass( "ui-menubar-item" )
.removeAttr( "role" );
this.element
.removeClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
.removeAttr( "role" )
.unbind( ".menubar" );
this.items
.unbind( ".menubar" )
.removeClass( "ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default" )
.removeAttr( "role" )
.removeAttr( "aria-haspopup" )
// TODO unwrap?
.children( "span.ui-button-text" ).each(function( i, e ) {
var item = $( this );
item.parent().html( item.html() );
})
.end()
.children( ".ui-icon" ).remove();
this.element.find( ":ui-menu" )
.menu( "destroy" )
.show()
.removeAttr( "aria-hidden" )
.removeAttr( "aria-expanded" )
.removeAttr( "tabindex" )
.unbind( ".menubar" );
},
_close: function() {
if ( !this.active || !this.active.length ) {
return;
}
this.active
.menu( "collapseAll" )
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
});
this.active
.prev()
.removeClass( "ui-state-active" )
.removeAttr( "tabIndex" );
this.active = null;
this.open = false;
this.openSubmenus = 0;
},
_open: function( event, menu ) {
// on a single-button menubar, ignore reopening the same menu
if ( this.active && this.active[0] === menu[0] ) {
return;
}
// TODO refactor, almost the same as _close above, but don't remove tabIndex
if ( this.active ) {
this.active
.menu( "collapseAll" )
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
});
this.active
.prev()
.removeClass( "ui-state-active" );
}
// set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus)
var button = menu.prev().addClass( "ui-state-active" ).attr( "tabIndex", -1 );
this.active = menu
.show()
.position( $.extend({
of: button
}, this.options.position ) )
.removeAttr( "aria-hidden" )
.attr( "aria-expanded", "true" )
.menu("focus", event, menu.children( ".ui-menu-item" ).first() )
// TODO need a comment here why both events are triggered
.focus()
.focusin();
this.open = true;
},
next: function( event ) {
if ( this.open && this.active.data( "menu" ).active.has( ".ui-menu" ).length ) {
// Track number of open submenus and prevent moving to next menubar item
this.openSubmenus++;
return;
}
this.openSubmenus = 0;
this._move( "next", "first", event );
},
previous: function( event ) {
if ( this.open && this.openSubmenus ) {
// Track number of open submenus and prevent moving to previous menubar item
this.openSubmenus--;
return;
}
this.openSubmenus = 0;
this._move( "prev", "last", event );
},
_move: function( direction, filter, event ) {
var next,
wrapItem;
if ( this.open ) {
next = this.active.closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).first().children( ".ui-menu" ).eq( 0 );
wrapItem = this.menuItems[ filter ]().children( ".ui-menu" ).eq( 0 );
} else {
if ( event ) {
next = $( event.target ).closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).children( ".ui-menubar-link" ).eq( 0 );
wrapItem = this.menuItems[ filter ]().children( ".ui-menubar-link" ).eq( 0 );
} else {
next = wrapItem = this.menuItems.children( "a" ).eq( 0 );
}
}
if ( next.length ) {
if ( this.open ) {
this._open( event, next );
} else {
next.removeAttr( "tabIndex")[0].focus();
}
} else {
if ( this.open ) {
this._open( event, wrapItem );
} else {
wrapItem.removeAttr( "tabIndex")[0].focus();
}
}
}
});
}( jQuery ));

View file

@ -0,0 +1,260 @@
/************************************************************
*
* tailbone.js
*
************************************************************/
/*
* Initialize the disabled filters array. This is populated from within the
* /grids/search.mako template.
*/
var filters_to_disable = [];
/*
* Disables options within the "add filter" dropdown which correspond to those
* filters already being displayed. Called from /grids/search.mako template.
*/
function disable_filter_options() {
while (filters_to_disable.length) {
var filter = filters_to_disable.shift();
var option = $('#add-filter option[value="' + filter + '"]');
option.attr('disabled', 'disabled');
}
}
/*
* Convenience function to disable a form button.
*/
function disable_button(button, label) {
if (label) {
$(button).html(label + ", please wait...");
}
$(button).attr('disabled', 'disabled');
}
/*
* Load next / previous page of results to grid. This function is called on
* the click event from the pager links, via inline script code.
*/
function grid_navigate_page(link, url) {
var wrapper = $(link).parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
wrapper.mask("Loading...");
$.get(url, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
}
/*
* Fetch the UUID value associated with a table row.
*/
function get_uuid(obj) {
obj = $(obj);
if (obj.attr('uuid')) {
return obj.attr('uuid');
}
var tr = obj.parents('tr:first');
if (tr.attr('uuid')) {
return tr.attr('uuid');
}
return undefined;
}
/*
* get_dialog(id, callback)
*
* Returns a <DIV> element suitable for use as a jQuery dialog.
*
* ``id`` is used to construct a proper ID for the element and allows the
* dialog to be resused if possible.
*
* ``callback``, if specified, should be a callback function for the dialog.
* This function will be called whenever the dialog has been closed
* "successfully" (i.e. data submitted) by the user, and should accept a single
* ``data`` object which is the JSON response returned by the server.
*/
function get_dialog(id, callback) {
var dialog = $('#'+id+'-dialog');
if (! dialog.length) {
dialog = $('<div class="dialog" id="'+id+'-dialog"></div>');
}
if (callback) {
dialog.attr('callback', callback);
}
return dialog;
}
$(function() {
/*
* Initialize the menu bar.
*/
$('ul.menubar').menubar({
buttons: true,
menuIcon: true,
autoExpand: true
});
/*
* When filter labels are clicked, (un)check the associated checkbox.
*/
$('div.grid-wrapper div.filter label').on('click', function() {
var checkbox = $(this).prev('input[type="checkbox"]');
if (checkbox.prop('checked')) {
checkbox.prop('checked', false);
return false;
}
checkbox.prop('checked', true);
});
/*
* When a new filter is selected in the "add filter" dropdown, show it in
* the UI. This selects the filter's checkbox and puts focus to its input
* element. If all available filters have been displayed, the "add filter"
* dropdown will be hidden.
*/
$('#add-filter').on('change', function() {
var select = $(this);
var filters = select.parents('div.filters:first');
var filter = filters.find('#filter-' + select.val());
var checkbox = filter.find('input[type="checkbox"]:first');
var input = filter.find(':last-child');
checkbox.prop('checked', true);
filter.show();
input.select();
input.focus();
filters.find('input[type="submit"]').show();
filters.find('button[type="reset"]').show();
select.find('option:selected').attr('disabled', true);
select.val('add a filter');
if (select.find('option:enabled').length == 1) {
select.hide();
}
});
/*
* When user clicks the grid filters search button, perform the search in
* the background and reload the grid in-place.
*/
$('div.filters form').submit(function() {
var form = $(this);
var wrapper = form.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = form.serializeArray();
data.push({name: 'partial', value: true});
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
return false;
});
/*
* When user clicks the grid filters reset button, manually clear all
* filter input elements, and submit a new search.
*/
$('div.filters form button[type="reset"]').click(function() {
var form = $(this).parents('form');
form.find('div.filter').each(function() {
$(this).find('div.value input').val('');
});
form.submit();
return false;
});
$('div.grid-wrapper').on('click', 'div.grid th.sortable a', function() {
var th = $(this).parent();
var wrapper = th.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = {
sort: th.attr('field'),
dir: (th.hasClass('sorted') && th.hasClass('asc')) ? 'desc' : 'asc',
page: 1,
partial: true
};
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
return false;
});
$('#body').on('mouseenter', 'div.grid.hoverable table tbody tr', function() {
$(this).addClass('hovering');
});
$('#body').on('mouseleave', 'div.grid.hoverable table tbody tr', function() {
$(this).removeClass('hovering');
});
$('div.grid-wrapper').on('click', 'div.grid table tbody td.view', function() {
var url = $(this).attr('url');
if (url) {
location.href = url;
}
});
$('div.grid-wrapper').on('click', 'div.grid table tbody td.edit', function() {
var url = $(this).attr('url');
if (url) {
location.href = url;
}
});
$('div.grid-wrapper').on('click', 'div.grid table tbody td.delete', function() {
var url = $(this).attr('url');
if (url) {
if (confirm("Do you really wish to delete this object?")) {
location.href = url;
}
}
});
$('div.grid-wrapper').on('change', 'div.grid div.pager select#grid-page-count', function() {
var select = $(this);
var wrapper = select.parents('div.grid-wrapper');
var grid = wrapper.find('div.grid');
var data = {
per_page: select.val(),
partial: true
};
wrapper.mask("Loading...");
$.get(grid.attr('url'), data, function(data) {
wrapper.unmask();
grid.replaceWith(data);
});
});
/*
* Add "check all" functionality to tables with checkboxes.
*/
$('body').on('click', 'div.grid table thead th.checkbox input[type="checkbox"]', function() {
var table = $(this).parents('table:first');
var checked = $(this).prop('checked');
table.find('tbody tr').each(function() {
$(this).find('td.checkbox input[type="checkbox"]').prop('checked', checked);
});
});
$('body').on('click', 'div.dialog button.close', function() {
var dialog = $(this).parents('div.dialog:first');
dialog.dialog('close');
});
});

View file

@ -27,31 +27,74 @@ Event Subscribers
""" """
from pyramid import threadlocal from pyramid import threadlocal
import rattail import rattail
from . import helpers from . import helpers
from pyramid.security import authenticated_userid
from .db import Session
from rattail.db.model import User
from edbob.db.auth import has_permission
def before_render(event): def before_render(event):
""" """
Adds goodies to the global template renderer context: Adds goodies to the global template renderer context.
* ``rattail``
""" """
# Import labels module so it's available if/when needed.
import rattail.labels
# Import SIL module so it's available if/when needed.
import rattail.sil
request = event.get('request') or threadlocal.get_current_request() request = event.get('request') or threadlocal.get_current_request()
renderer_globals = event renderer_globals = event
renderer_globals['h'] = helpers renderer_globals['h'] = helpers
renderer_globals['url'] = request.route_url
renderer_globals['rattail'] = rattail renderer_globals['rattail'] = rattail
def context_found(event):
"""
Attach some goodies to the request object.
The following is attached to the request:
* The currently logged-in user instance (if any), as ``user``.
* A shortcut method for permission checking, as ``has_perm()``.
* A shortcut method for fetching the referrer, as ``get_referrer()``.
"""
request = event.request
request.user = None
uuid = authenticated_userid(request)
if uuid:
request.user = Session.query(User).get(uuid)
def has_perm(perm):
return has_permission(request.user, perm, session=Session())
request.has_perm = has_perm
def has_any_perm(perms):
for perm in perms:
if has_permission(request.user, perm, session=Session()):
return True
return False
request.has_any_perm = has_any_perm
def get_referrer(default=None):
if request.params.get('referrer'):
return request.params['referrer']
if request.session.get('referrer'):
return request.session.pop('referrer')
referrer = request.referrer
if not referrer or referrer == request.current_route_url():
if default:
referrer = default
else:
referrer = request.route_url('home')
return referrer
request.get_referrer = get_referrer
def includeme(config): def includeme(config):
config.add_subscriber('tailbone.subscribers:before_render', config.add_subscriber(before_render, 'pyramid.events.BeforeRender')
'pyramid.events.BeforeRender') config.add_subscriber(context_found, 'pyramid.events.ContextFound')

View file

@ -0,0 +1,141 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html style="direction: ltr;" xmlns="http://www.w3.org/1999/xhtml" lang="en-us">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<title>Tailbone &raquo; ${capture(self.title)}</title>
<link rel="icon" type="image/x-icon" href="${request.static_url('tailbone:static/img/rattail.ico')}" />
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery-1.9.1.min.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery-ui-1.10.0.custom.min.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.ui.menubar.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/lib/jquery.loadmask.min.js'))}
${h.javascript_link(request.static_url('tailbone:static/js/tailbone.js'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/smoothness/jquery-ui-1.10.0.custom.min.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.ui.menubar.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/jquery.loadmask.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/layout.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/filters.css'))}
${h.stylesheet_link(request.static_url('tailbone:static/css/forms.css'))}
${self.head_tags()}
</head>
<body>
<div id="body-wrapper">
<div id="header">
<h1>${h.link_to("Tailbone", url('home'))}</h1>
<h1 class="title">&raquo; ${self.title()}</h1>
<div class="login">
% if request.user:
${h.link_to(request.user.display_name, url('change_password'))}
(${h.link_to("logout", url('logout'))})
% else:
${h.link_to("login", url('login'))}
% endif
</div>
</div><!-- header -->
<ul class="menubar">
<li>
<a>Products</a>
<ul>
<li>${h.link_to("Products", url('products'))}</li>
<li>${h.link_to("Brands", url('brands'))}</li>
</ul>
</li>
<li>
<a>Customers</a>
<ul>
<li>${h.link_to("Customers", url('customers'))}</li>
<li>${h.link_to("Customer Groups", url('customer_groups'))}</li>
</ul>
</li>
<li>
<a>Employees</a>
<ul>
<li>${h.link_to("Employees", url('employees'))}</li>
</ul>
</li>
<li>
<a>Vendors</a>
<ul>
<li>${h.link_to("Vendors", url('vendors'))}</li>
</ul>
</li>
% if request.has_perm('batches.list'):
<li>
<a>Batches</a>
<ul>
<li>${h.link_to("Batches", url('batches'))}</li>
</ul>
</li>
% endif
<li>
<a>Stores</a>
<ul>
<li>${h.link_to("Stores", url('stores'))}</li>
<li>${h.link_to("Departments", url('departments'))}</li>
<li>${h.link_to("Subdepartments", url('subdepartments'))}</li>
</ul>
</li>
% if request.has_perm('users.list') or request.has_perm('roles.list'):
<li>
<a>Auth</a>
<ul>
% if request.has_perm('users.list'):
<li>${h.link_to("Users", url('users'))}</li>
% endif
% if request.has_perm('roles.list'):
<li>${h.link_to("Roles", url('roles'))}</li>
% endif
</ul>
</li>
% endif
</ul>
<div id="body">
% if request.session.peek_flash('error'):
<div class="error-messages">
% for error in request.session.pop_flash('error'):
<div class="ui-state-error ui-corner-all">
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-alert"></span>
${error}
</div>
% endfor
</div>
% endif
% if request.session.peek_flash():
<div class="flash-messages">
% for msg in request.session.pop_flash():
<div class="ui-state-highlight ui-corner-all">
<span style="float: left; margin-right: .3em;" class="ui-icon ui-icon-info"></span>
${msg|n}
</div>
% endfor
</div>
% endif
${self.body()}
</div><!-- body -->
</div><!-- body-wrapper -->
<div id="footer">
powered by ${h.link_to("Rattail", 'http://rattail.edbob.org/', target='_blank')}
</div>
</body>
</html>
<%def name="title()"></%def>
<%def name="head_tags()"></%def>

View file

@ -0,0 +1,8 @@
<%inherit file="/base.mako" />
<%def name="title()">Home</%def>
<div style="text-align: center;">
${h.image(request.static_url('tailbone:static/img/home_logo.png'), "Rattail Logo")}
<h1>Welcome to Tailbone</h1>
</div>

View file

@ -32,16 +32,39 @@ from .crud import *
from .autocomplete import * from .autocomplete import *
def home(request):
"""
Default home view.
"""
return {}
def add_routes(config):
config.add_route('home', '/')
def includeme(config): def includeme(config):
add_routes(config)
config.add_forbidden_view('edbob.pyramid.views.forbidden')
config.add_view(home, route_name='home',
renderer='/home.mako')
config.include('tailbone.views.auth')
config.include('tailbone.views.batches') config.include('tailbone.views.batches')
# config.include('tailbone.views.categories') config.include('tailbone.views.brands')
config.include('tailbone.views.categories')
config.include('tailbone.views.customergroups') config.include('tailbone.views.customergroups')
config.include('tailbone.views.customers') config.include('tailbone.views.customers')
config.include('tailbone.views.departments') config.include('tailbone.views.departments')
config.include('tailbone.views.employees') config.include('tailbone.views.employees')
config.include('tailbone.views.labels') config.include('tailbone.views.labels')
config.include('tailbone.views.people')
config.include('tailbone.views.products') config.include('tailbone.views.products')
config.include('tailbone.views.roles') config.include('tailbone.views.roles')
config.include('tailbone.views.stores') config.include('tailbone.views.stores')
config.include('tailbone.views.subdepartments') config.include('tailbone.views.subdepartments')
config.include('tailbone.views.users')
config.include('tailbone.views.vendors') config.include('tailbone.views.vendors')

152
tailbone/views/auth.py Normal file
View file

@ -0,0 +1,152 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2012 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/>.
#
################################################################################
"""
Auth Views
"""
from pyramid.httpexceptions import HTTPFound
from pyramid.security import remember, forget
import formencode
from pyramid_simpleform import Form
from ..forms.simpleform import FormRenderer
import edbob
from ..db import Session
from edbob.db.auth import authenticate_user, set_user_password
class UserLogin(formencode.Schema):
allow_extra_fields = True
filter_extra_fields = True
username = formencode.validators.NotEmpty()
password = formencode.validators.NotEmpty()
def login(request):
"""
The login view, responsible for displaying and handling the login form.
"""
referrer = request.get_referrer()
# Redirect if already logged in.
if request.user:
return HTTPFound(location=referrer)
form = Form(request, schema=UserLogin)
if form.validate():
user = authenticate_user(form.data['username'],
form.data['password'],
session=Session())
if user:
request.session.flash("%s logged in at %s" % (
user.display_name,
edbob.local_time().strftime('%I:%M %p')))
headers = remember(request, user.uuid)
return HTTPFound(location=referrer, headers=headers)
request.session.flash("Invalid username or password")
url = edbob.config.get('edbob.pyramid', 'login.logo_url',
default=request.static_url('edbob.pyramid:static/img/logo.jpg'))
kwargs = eval(edbob.config.get('edbob.pyramid', 'login.logo_kwargs',
default="dict(width=500)"))
return {'form': FormRenderer(form), 'referrer': referrer,
'logo_url': url, 'logo_kwargs': kwargs}
def logout(request):
"""
View responsible for logging out the current user.
This deletes/invalidates the current session and then redirects to the
login page.
"""
request.session.delete()
request.session.invalidate()
headers = forget(request)
referrer = request.get_referrer()
return HTTPFound(location=referrer, headers=headers)
class CurrentPasswordCorrect(formencode.validators.FancyValidator):
def _to_python(self, value, state):
user = state
if not authenticate_user(user.username, value, session=Session()):
raise formencode.Invalid("The password is incorrect.", value, state)
return value
class ChangePassword(formencode.Schema):
allow_extra_fields = True
filter_extra_fields = True
current_password = formencode.All(
formencode.validators.NotEmpty(),
CurrentPasswordCorrect())
new_password = formencode.validators.NotEmpty()
confirm_password = formencode.validators.NotEmpty()
chained_validators = [formencode.validators.FieldsMatch(
'new_password', 'confirm_password')]
def change_password(request):
"""
Allows a user to change his or her password.
"""
if not request.user:
return HTTPFound(location=request.route_url('home'))
form = Form(request, schema=ChangePassword, state=request.user)
if form.validate():
set_user_password(request.user, form.data['new_password'])
return HTTPFound(location=request.get_referrer())
return {'form': FormRenderer(form)}
def add_routes(config):
config.add_route('login', '/login')
config.add_route('logout', '/logout')
config.add_route('change_password', '/change-password')
def includeme(config):
add_routes(config)
config.add_view(login, route_name='login',
renderer='/login.mako')
config.add_view(logout, route_name='logout')
config.add_view(change_password, route_name='change_password',
renderer='/change_password.mako')

View file

@ -27,7 +27,7 @@ Autocomplete View
""" """
from .core import View from .core import View
from .. import Session from ..db import Session
__all__ = ['AutocompleteView'] __all__ = ['AutocompleteView']

View file

@ -39,7 +39,7 @@ from .. import SearchableAlchemyGridView, CrudView, View
import rattail import rattail
from rattail import batches from rattail import batches
from ... import Session from ...db import Session
from rattail.db.model import Batch from rattail.db.model import Batch
from rattail.threads import Thread from rattail.threads import Thread

View file

@ -26,7 +26,7 @@
Print Labels Batch Print Labels Batch
""" """
from .... import Session from ....db import Session
import rattail import rattail
from . import BatchParamsView from . import BatchParamsView

View file

@ -28,7 +28,7 @@ Batch Row Views
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from ... import Session from ...db import Session
from .. import SearchableAlchemyGridView, CrudView from .. import SearchableAlchemyGridView, CrudView
import rattail import rattail

View file

@ -26,6 +26,10 @@
Core View Core View
""" """
__all__ = ['View']
class View(object): class View(object):
""" """
Base for all class-based views. Base for all class-based views.

View file

@ -30,10 +30,10 @@ from pyramid.httpexceptions import HTTPFound
import formalchemy import formalchemy
from .. import Session
from edbob.pyramid.forms.formalchemy import AlchemyForm from edbob.pyramid.forms.formalchemy import AlchemyForm
from .core import View from .core import View
from edbob.util import requires_impl, prettify from edbob.util import requires_impl, prettify
from ..db import Session
__all__ = ['CrudView'] __all__ = ['CrudView']

View file

@ -28,7 +28,7 @@ CustomerGroup Views
from . import SearchableAlchemyGridView, CrudView from . import SearchableAlchemyGridView, CrudView
from .. import Session from ..db import Session
from rattail.db.model import CustomerGroup, CustomerGroupAssignment from rattail.db.model import CustomerGroup, CustomerGroupAssignment

View file

@ -34,7 +34,7 @@ from . import SearchableAlchemyGridView
from ..forms import EnumFieldRenderer from ..forms import EnumFieldRenderer
import rattail import rattail
from .. import Session from ..db import Session
from rattail.db.model import ( from rattail.db.model import (
Customer, CustomerPerson, CustomerGroupAssignment, Customer, CustomerPerson, CustomerGroupAssignment,
CustomerEmailAddress, CustomerPhoneNumber) CustomerEmailAddress, CustomerPhoneNumber)

View file

@ -30,7 +30,7 @@ from webhelpers import paginate
from .core import GridView from .core import GridView
from ... import grids from ... import grids
from ... import Session from ...db import Session
__all__ = ['AlchemyGridView', 'SortableAlchemyGridView', __all__ = ['AlchemyGridView', 'SortableAlchemyGridView',

View file

@ -32,7 +32,7 @@ import formalchemy
from webhelpers.html import HTML from webhelpers.html import HTML
from .. import Session from ..db import Session
from . import SearchableAlchemyGridView, CrudView from . import SearchableAlchemyGridView, CrudView
from ..grids.search import BooleanSearchFilter from ..grids.search import BooleanSearchFilter
from edbob.pyramid.forms import StrippingFieldRenderer from edbob.pyramid.forms import StrippingFieldRenderer

View file

@ -30,7 +30,7 @@ from sqlalchemy import and_
from . import SearchableAlchemyGridView, CrudView, AutocompleteView from . import SearchableAlchemyGridView, CrudView, AutocompleteView
from .. import Session from ..db import Session
from rattail.db.model import (Person, PersonEmailAddress, PersonPhoneNumber, from rattail.db.model import (Person, PersonEmailAddress, PersonPhoneNumber,
VendorContact) VendorContact)

View file

@ -48,7 +48,7 @@ from rattail.db.model import (
Brand, Vendor, Department, Subdepartment, LabelProfile) Brand, Vendor, Department, Subdepartment, LabelProfile)
from rattail.gpc import GPC from rattail.gpc import GPC
from .. import Session from ..db import Session
from ..forms import AutocompleteFieldRenderer, GPCFieldRenderer, PriceFieldRenderer from ..forms import AutocompleteFieldRenderer, GPCFieldRenderer, PriceFieldRenderer
from . import CrudView from . import CrudView

View file

@ -30,7 +30,7 @@ from .core import View
from mako.template import Template from mako.template import Template
from pyramid.response import Response from pyramid.response import Response
from .. import Session from ..db import Session
from rattail.db.model import Vendor, Department, Product, ProductCost from rattail.db.model import Vendor, Department, Product, ProductCost
import re import re

View file

@ -34,7 +34,7 @@ from webhelpers.html.builder import HTML
from edbob.db import auth from edbob.db import auth
from .. import Session from ..db import Session
from . import SearchableAlchemyGridView, CrudView from . import SearchableAlchemyGridView, CrudView
from rattail.db.model import Role from rattail.db.model import Role

View file

@ -26,13 +26,19 @@
User Views User Views
""" """
import formalchemy from formalchemy import Field
from formalchemy.fields import SelectFieldRenderer
from edbob.pyramid.views import users from edbob.pyramid.views import users
from . import SearchableAlchemyGridView, CrudView from . import SearchableAlchemyGridView, CrudView
from ..forms import PersonFieldRenderer from ..forms import PersonFieldRenderer
from rattail.db.model import User, Person from ..db import Session
from rattail.db.model import User, Person, Role
from edbob.db.auth import guest_role
from webhelpers.html import tags
from webhelpers.html import HTML
class UsersGrid(SearchableAlchemyGridView): class UsersGrid(SearchableAlchemyGridView):
@ -84,6 +90,49 @@ class UsersGrid(SearchableAlchemyGridView):
return g return g
class RolesField(Field):
def __init__(self, name, **kwargs):
kwargs.setdefault('value', self.get_value)
kwargs.setdefault('options', self.get_options())
kwargs.setdefault('multiple', True)
super(RolesField, self).__init__(name, **kwargs)
def get_value(self, user):
return [x.uuid for x in user.roles]
def get_options(self):
return Session.query(Role.name, Role.uuid)\
.filter(Role.uuid != guest_role(Session()).uuid)\
.order_by(Role.name)\
.all()
def sync(self):
if not self.is_readonly():
user = self.model
roles = Session.query(Role)
data = self.renderer.deserialize()
user.roles = [roles.get(x) for x in data]
def RolesFieldRenderer(request):
class RolesFieldRenderer(SelectFieldRenderer):
def render_readonly(self, **kwargs):
roles = Session.query(Role)
html = ''
for uuid in self.value:
role = roles.get(uuid)
link = tags.link_to(
role.name, request.route_url('role.read', uuid=role.uuid))
html += HTML.tag('li', c=link)
html = HTML.tag('ul', c=html)
return html
return RolesFieldRenderer
class UserCrud(CrudView): class UserCrud(CrudView):
mapped_class = User mapped_class = User
@ -98,10 +147,10 @@ class UserCrud(CrudView):
self.request.route_url('people.autocomplete'))) self.request.route_url('people.autocomplete')))
fs.append(users.PasswordField('password')) fs.append(users.PasswordField('password'))
fs.append(formalchemy.Field( fs.append(Field('confirm_password',
'confirm_password', renderer=users.PasswordFieldRenderer)) renderer=users.PasswordFieldRenderer))
fs.append(users.RolesField( fs.append(RolesField(
'roles', renderer=users.RolesFieldRenderer(self.request))) 'roles', renderer=RolesFieldRenderer(self.request)))
fs.configure( fs.configure(
include=[ include=[