diff --git a/ordr2/resources/__init__.py b/ordr2/resources/__init__.py index 8a3fd06..d3c0e75 100644 --- a/ordr2/resources/__init__.py +++ b/ordr2/resources/__init__.py @@ -1,7 +1,13 @@ from pyramid.security import Allow, Everyone from .account import Account, PasswordResetAccount -from .admin import Admin, UserList, UserAccount +from .admin import ( + Admin, + ConsumableList, + ConsumableResource, + UserList, + UserAccount + ) from .base import BaseResource diff --git a/ordr2/resources/admin.py b/ordr2/resources/admin.py index af2feb2..1497180 100644 --- a/ordr2/resources/admin.py +++ b/ordr2/resources/admin.py @@ -87,6 +87,7 @@ class ConsumableList(BaseResource, PaginationResourceMixin): def __acl__(self): return [ (Allow, 'role:admin', 'view'), + (Allow, 'role:admin', 'create'), (Allow, 'role:admin', 'edit'), (Allow, 'role:admin', 'delete'), DENY_ALL @@ -98,7 +99,7 @@ class ConsumableList(BaseResource, PaginationResourceMixin): query = dbsession.query(self.sql_model_class) category_name = filter_params.get('category', None) try: - category_name = cat_name.lower() + category_name = category_name.lower() category = Category(category_name) query = query.filter_by(category=category) except (AttributeError, ValueError): @@ -132,7 +133,8 @@ class ConsumableList(BaseResource, PaginationResourceMixin): class Admin(BaseResource): nodes = { - 'users': UserList + 'users': UserList, + 'consumables': ConsumableList, } def __acl__(self): diff --git a/ordr2/schemas/orders.py b/ordr2/schemas/orders.py new file mode 100644 index 0000000..c34eba4 --- /dev/null +++ b/ordr2/schemas/orders.py @@ -0,0 +1,70 @@ +import colander +import deform + +from ordr2.models import Category + +from . import CSRFSchema + +CATEGORIES = [(c.name, c.value.capitalize()) for c in Category] + +# schema for user registration + +class ConsumableSchema(CSRFSchema): + ''' edit or add consumable ''' + + cas_description = colander.SchemaNode( + colander.String() + ) + category = colander.SchemaNode( + colander.String(), + widget=deform.widget.SelectWidget(values=CATEGORIES) + ) + catalog_nr = colander.SchemaNode( + colander.String() + ) + vendor = colander.SchemaNode( + colander.String() + ) + package_size = colander.SchemaNode( + colander.String() + ) + unit_price = colander.SchemaNode( + colander.Decimal(), + widget=deform.widget.MoneyInputWidget() + ) + currency = colander.SchemaNode( + colander.String(), + default='EUR' + ) + comment = colander.SchemaNode( + colander.String(), + widget=deform.widget.TextAreaWidget(rows=5), + missing='' + ) + + @classmethod + def as_form(cls, request, **override): + is_new_consumable = override.pop('is_new_consumable', False) + if is_new_consumable: + settings = { + 'buttons': ( + deform.Button(name='save', title='Add Consumable'), + deform.Button(name='cancel', title='Cancel') + ), + 'css_class': 'form-horizontal', + } + else: + settings = { + 'buttons': ( + deform.Button(name='save', title='Save changes'), + deform.Button( + name='delete', + title='Delete Consumable', + css_class='btn-danger' + ), + deform.Button(name='cancel', title='Cancel') + ), + 'css_class': 'form-horizontal', + } + settings.update(override) + return super().as_form(request, **settings) diff --git a/ordr2/static/css/style.css b/ordr2/static/css/style.css index 909455c..95779bf 100755 --- a/ordr2/static/css/style.css +++ b/ordr2/static/css/style.css @@ -732,3 +732,4 @@ input[value="new_password:mapping"] + div { margin-bottom:10px; } margin-bottom: 20px; border-bottom: 1px solid #aaa;} div.alert a { color:inherit; text-decoration:underline; } +td.column-pkg, td.column-price { text-align:right; } diff --git a/ordr2/templates/admin/consumable_delete.jinja2 b/ordr2/templates/admin/consumable_delete.jinja2 new file mode 100755 index 0000000..98745a1 --- /dev/null +++ b/ordr2/templates/admin/consumable_delete.jinja2 @@ -0,0 +1,70 @@ +{% extends "ordr2:templates/layout.jinja2" %} +{% import 'ordr2:templates/macros.jinja2' as macros with context %} + +{% block subtitle %} Admin | Consumable | Confirm Delete {% endblock subtitle %} + +{% block content %} +
+ +
+
+
+

Delete Consumable{{ 's' if consumables|length > 1 }}

+
+
+ +
+
+ +
+

The following consumable{{ 's' if consumables|length > 1 }} will be deleted:

+
+ +
+ + + + + + + + + + + + + + + {% for consumable in consumables %} + + + + + + + + + + {% endfor %} + +
Cas / DescriptionCategoryCatalog NrVendorPackage SizeUnit PrizeCurrency
+ + {{ consumable.cas_description }} + {{ consumable.category.name|capitalize }}{{ consumable.catalog_nr }}{{ consumable.vendor }}{{ consumable.package_size }}{{ '%.2f'|format(consumable.unit_price) }}{{ consumable.currency }}
+ +
+
+ + +
+
+ +
+ +
+
+ +
+ +
+{% endblock content %} diff --git a/ordr2/templates/admin/consumable_edit.jinja2 b/ordr2/templates/admin/consumable_edit.jinja2 new file mode 100644 index 0000000..04c4258 --- /dev/null +++ b/ordr2/templates/admin/consumable_edit.jinja2 @@ -0,0 +1,24 @@ +{% extends "ordr2:templates/layout.jinja2" %} +{% import 'ordr2:templates/macros.jinja2' as macros with context %} + +{% block subtitle %} Admin | Consumable | {{ context.model.cas_description }} {% endblock subtitle %} + +{% block content %} +
+ +
+
+
+

Edit Consumable: {{ context.model.cas_description }}

+
+
+
+
+ {{ macros.flash_messages() }} + {{form.render()|safe}} +
+
+
+ +
+{% endblock content %} diff --git a/ordr2/templates/admin/consumable_list.jinja2 b/ordr2/templates/admin/consumable_list.jinja2 new file mode 100644 index 0000000..7fa1102 --- /dev/null +++ b/ordr2/templates/admin/consumable_list.jinja2 @@ -0,0 +1,72 @@ +{% extends "ordr2:templates/layout.jinja2" %} +{% import 'ordr2:templates/macros.jinja2' as macros with context %} + +{% block subtitle %} Admin | Consumables {% endblock subtitle %} + +{% block content %} +
+
+ +
+
+
+

+ Consumables +

+
+ {{ macros.filter_box('Category', 'category', categories) }} +
+ +
+
+
+ +
+
+ + {{ macros.flash_messages() }} + + {% if consumables %} + + + {{ macros.sortable_table_header('Cas / Description', 'cas') }} + {{ macros.sortable_table_header('Category', 'category') }} + {{ macros.sortable_table_header('Catalog Nr', 'catalog') }} + {{ macros.sortable_table_header('Vendor', 'vendor') }} + {{ macros.sortable_table_header('Package Size', 'pkg') }} + {{ macros.sortable_table_header('Unit Prize', 'price') }} + {{ macros.sortable_table_header('Currency', 'currency') }} + + + + + {% for consumable in consumables %} + + + + + + + + + + + {% endfor %} + +
Actions
{{ consumable.model.cas_description }}{{ consumable.model.category.name|capitalize }}{{ consumable.model.catalog_nr }}{{ consumable.model.vendor }}{{ consumable.model.package_size }}{{ '%.2f'|format(consumable.model.unit_price) }}{{ consumable.model.currency }} + edit + delete +
+ + {{ macros.pagination() }} + {% else %} +
+

Oh snap! Nothing to display!

+

Your query didn't return any data.

+
+ {% endif %} +
+
+
+
+{% endblock content %} diff --git a/ordr2/templates/admin/consumable_new.jinja2 b/ordr2/templates/admin/consumable_new.jinja2 new file mode 100644 index 0000000..f8bda98 --- /dev/null +++ b/ordr2/templates/admin/consumable_new.jinja2 @@ -0,0 +1,24 @@ +{% extends "ordr2:templates/layout.jinja2" %} +{% import 'ordr2:templates/macros.jinja2' as macros with context %} + +{% block subtitle %} Admin | Consumable | Add {% endblock subtitle %} + +{% block content %} +
+ +
+
+
+

Add Consumable

+
+
+
+
+ {{ macros.flash_messages() }} + {{form.render()|safe}} +
+
+
+ +
+{% endblock content %} diff --git a/ordr2/templates/admin/user_edit.jinja2 b/ordr2/templates/admin/user_edit.jinja2 index 98b7cf5..0cff74d 100644 --- a/ordr2/templates/admin/user_edit.jinja2 +++ b/ordr2/templates/admin/user_edit.jinja2 @@ -1,7 +1,7 @@ {% extends "ordr2:templates/layout.jinja2" %} {% import 'ordr2:templates/macros.jinja2' as macros with context %} -{% block subtitle %} Account | Admin | User | {{ context.model.user_name }} {% endblock subtitle %} +{% block subtitle %} Admin | User | {{ context.model.user_name }} {% endblock subtitle %} {% block content %}
diff --git a/ordr2/templates/admin/users_change_roles.jinja2 b/ordr2/templates/admin/users_change_roles.jinja2 index 75716e3..93b9b06 100755 --- a/ordr2/templates/admin/users_change_roles.jinja2 +++ b/ordr2/templates/admin/users_change_roles.jinja2 @@ -1,7 +1,7 @@ {% extends "ordr2:templates/layout.jinja2" %} {% import 'ordr2:templates/macros.jinja2' as macros with context %} -{% block subtitle %} Account | Admin | Users | Change Roles {% endblock subtitle %} +{% block subtitle %} Admin | Users | Change Roles {% endblock subtitle %} {% block content %}
diff --git a/ordr2/templates/admin/users_delete.jinja2 b/ordr2/templates/admin/users_delete.jinja2 index 29911f2..92df22b 100755 --- a/ordr2/templates/admin/users_delete.jinja2 +++ b/ordr2/templates/admin/users_delete.jinja2 @@ -1,7 +1,7 @@ {% extends "ordr2:templates/layout.jinja2" %} {% import 'ordr2:templates/macros.jinja2' as macros with context %} -{% block subtitle %} Account | Admin | Users | Confirm Delete {% endblock subtitle %} +{% block subtitle %} Admin | Users | Confirm Delete {% endblock subtitle %} {% block content %}
diff --git a/ordr2/templates/layout.jinja2 b/ordr2/templates/layout.jinja2 index 37145c8..e259e4f 100644 --- a/ordr2/templates/layout.jinja2 +++ b/ordr2/templates/layout.jinja2 @@ -18,7 +18,9 @@ #wrap {display:table;height:100%} - + + +
- - + diff --git a/ordr2/templates/macros.jinja2 b/ordr2/templates/macros.jinja2 index 5a2e10f..3c2b5de 100644 --- a/ordr2/templates/macros.jinja2 +++ b/ordr2/templates/macros.jinja2 @@ -47,10 +47,11 @@ {% endif %} {%- endmacro %} + {% macro sortable_table_header(title, sort_by, column_class=None) -%} {% set column_class = column_class or 'column-' + sort_by %} - {% set new_direction = 'desc' if context.sorting.direction == 'asc' else 'asc' %} + {% set new_direction = 'desc' if context.sorting.direction == 'asc' and sort_by == context.sorting.field else 'asc' %} {% set new_sort = sort_by + '.' + new_direction %} {{ title }} diff --git a/ordr2/views/admin.py b/ordr2/views/admin.py index 513901f..08cd64f 100644 --- a/ordr2/views/admin.py +++ b/ordr2/views/admin.py @@ -6,12 +6,13 @@ from pyramid.security import remember, forget from pyramid.view import view_config from ordr2.events import AccountActivation, PasswordReset -from ordr2.models import User, Role +from ordr2.models import Category, Consumable, User, Role from ordr2.schemas.account import UserSchema +from ordr2.schemas.orders import ConsumableSchema from . import update_column_display -# user log in and log out +# admin section @view_config( context='ordr2:resources.Admin', @@ -36,6 +37,8 @@ def admin_section(context, request): return {} +# user list and user editing + @view_config( context='ordr2:resources.UserList', permission='view', @@ -253,3 +256,162 @@ def user_delete_form_processing(context, request): return HTTPFound(request.resource_url(request.root, 'admin', 'users')) + +# consumables + +@view_config( + context='ordr2:resources.ConsumableList', + permission='view', + renderer='ordr2:templates/admin/consumable_list.jinja2' + ) +def consumable_list(context, request): + ''' display the consumable list ''' + consumables = context.items() + categories = [(c.value.lower(), c.value.capitalize()) for c in Category] + return {'consumables': consumables, 'categories': categories} + + +@view_config( + context='ordr2:resources.ConsumableList', + name='new', + permission='create', + request_method='GET', + renderer='ordr2:templates/admin/consumable_new.jinja2' + ) +def consumable_new_form(context, request): + ''' display the new consumable form ''' + form = ConsumableSchema.as_form(request, is_new_consumable=True) + return {'form': form} + + +@view_config( + context='ordr2:resources.ConsumableList', + name='new', + permission='create', + request_method='POST', + renderer='ordr2:templates/admin/consumable_new.jinja2' + ) +def consumable_new_form_processing(context, request): + ''' process the new consumable form ''' + + form = ConsumableSchema.as_form(request, is_new_consumable=True) + data = request.POST.items() + if 'save' in request.POST: + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + # form validation sucessful, change consumable + consumable = Consumable( + cas_description=appstruct['cas_description'], + category=Category[appstruct['category']], + vendor=appstruct['vendor'], + catalog_nr=appstruct['catalog_nr'], + package_size=appstruct['package_size'], + unit_price=appstruct['unit_price'], + currency=appstruct['currency'], + comment=appstruct['comment'] + ) + request.dbsession.add(consumable) + + msg = 'Consumable {!s} added.'.format(consumable) + request.flash('success', msg) + + return HTTPFound(context.url()) + + +@view_config( + context='ordr2:resources.ConsumableResource', + permission='edit', + request_method='GET', + renderer='ordr2:templates/admin/consumable_edit.jinja2' + ) +def consumable_edit_form(context, request): + ''' display the consumable edit form ''' + form = ConsumableSchema.as_form(request, is_new_consumable=False) + form_data = { + 'cas_description': context.model.cas_description, + 'category': context.model.category.name, + 'vendor': context.model.vendor, + 'catalog_nr': context.model.catalog_nr, + 'package_size': context.model.package_size, + 'unit_price': context.model.unit_price, + 'currency': context.model.currency, + 'comment': context.model.comment + } + form.set_appstruct(form_data) + return {'form': form} + + +@view_config( + context='ordr2:resources.ConsumableResource', + permission='edit', + request_method='POST', + renderer='ordr2:templates/admin/consumable_edit.jinja2' + ) +def consumable_edit_form_processing(context, request): + ''' process the consumable edit form ''' + + form = ConsumableSchema.as_form(request, is_new_consumable=False) + data = request.POST.items() + if 'save' in request.POST: + try: + appstruct = form.validate(data) + except deform.ValidationFailure as e: + return {'form': form} + + # form validation sucessful, change consumable + context.model.cas_description = appstruct['cas_description'] + context.model.category = Category[appstruct['category']] + context.model.vendor = appstruct['vendor'] + context.model.catalog_nr = appstruct['catalog_nr'] + context.model.package_size = appstruct['package_size'] + context.model.unit_price = appstruct['unit_price'] + context.model.currency = appstruct['currency'] + context.model.comment = appstruct['comment'] + + msg = 'Consumable {!s} updated.'.format(context.model) + request.flash('success', msg) + + elif 'delete' in request.POST and context.model: + return HTTPFound(request.resource_url(context, 'delete')) + + return HTTPFound(context.__parent__.url()) + + +@view_config( + context='ordr2:resources.ConsumableResource', + name='delete', + permission='delete', + request_method='GET', + renderer='ordr2:templates/admin/consumable_delete.jinja2' + ) +def consumable_delete_form(context, request): + return {'consumables': [context.model]} + + +@view_config( + context='ordr2:resources.ConsumableResource', + name='delete', + permission='delete', + request_method='POST' + ) +def consumable_delete_form_processing(context, request): + if 'delete' in request.POST: + c_ids = [v for k, v in request.POST.items() if k == 'consumable'] + consumables = request.dbsession.\ + query(Consumable).\ + filter(Consumable.id.in_(c_ids)).\ + all() + for consumable in consumables: + request.dbsession.delete(consumable) + + if len(consumables) == 1: + request.flash('success', 'One consumable was deleted') + elif len(consumables) > 1: + msg = '{} consumables were deleted.'.format(len(accounts)) + request.flash('success', msg) + + return HTTPFound(context.__parent__.url()) +