Browse Source

added lots of code documentation

php2python
Holger Frey 7 years ago
parent
commit
b5da0d3411
  1. 2
      ordr2/__init__.py
  2. 15
      ordr2/events.py
  3. 9
      ordr2/models/__init__.py
  4. 2
      ordr2/models/meta.py
  5. 12
      ordr2/models/orders.py
  6. 11
      ordr2/models/user.py
  7. 3
      ordr2/resources/__init__.py
  8. 10
      ordr2/resources/account.py
  9. 31
      ordr2/resources/admin.py
  10. 85
      ordr2/resources/base.py
  11. 27
      ordr2/resources/orders.py
  12. 12
      ordr2/schemas/__init__.py
  13. 31
      ordr2/schemas/orders.py
  14. 2
      ordr2/scripts/__init__.py
  15. 41
      ordr2/scripts/initializedb.py
  16. 7
      ordr2/security.py
  17. 3
      ordr2/templates/tests.py
  18. 15
      ordr2/views/__init__.py
  19. 19
      ordr2/views/account.py
  20. 44
      ordr2/views/admin.py
  21. 42
      ordr2/views/orders.py
  22. 2
      ordr2/views/pages.py

2
ordr2/__init__.py

@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
''' Top-level package for Ordr2. ''' ''' Top-level package for Ordr2. '''
__author__ = 'Holger Frey' __author__ = 'Holger Frey'

15
ordr2/events.py

@ -1,3 +1,5 @@
''' custom events and event subsribers '''
from pyramid.events import NewRequest, subscriber from pyramid.events import NewRequest, subscriber
from pyramid.renderers import render from pyramid.renderers import render
from pyramid_mailer.message import Message from pyramid_mailer.message import Message
@ -5,14 +7,19 @@ from pyramid_mailer.message import Message
from ordr2.views import set_display_defaults from ordr2.views import set_display_defaults
# custom events
class UserLogIn(object): class UserLogIn(object):
''' notify on user log in '''
def __init__(self, request, user): def __init__(self, request, user):
self.request = request self.request = request
self.user = user self.user = user
class UserNotification(object): class UserNotification(object):
''' base class for user notifications '''
subject = None
template = None template = None
def __init__(self, request, user, data=None): def __init__(self, request, user, data=None):
@ -22,32 +29,40 @@ class UserNotification(object):
class AccountActivation(UserNotification): class AccountActivation(UserNotification):
''' user notification for account activation '''
subject='[ordr] Your account was activated' subject='[ordr] Your account was activated'
template = 'ordr2:templates/emails/activation.jinja2' template = 'ordr2:templates/emails/activation.jinja2'
class PasswordReset(UserNotification): class PasswordReset(UserNotification):
''' user notification for password reset link '''
subject='[ordr] Password Reset' subject='[ordr] Password Reset'
template = 'ordr2:templates/emails/password_reset.jinja2' template = 'ordr2:templates/emails/password_reset.jinja2'
class OrderStatusChange(UserNotification): class OrderStatusChange(UserNotification):
''' user notification for order status change '''
subject='[ordr] Order Status Change' subject='[ordr] Order Status Change'
template = 'ordr2:templates/emails/order.jinja2' template = 'ordr2:templates/emails/order.jinja2'
# subsribers for events
@subscriber(UserLogIn) @subscriber(UserLogIn)
def set_display_defaults_on_log_in(event): def set_display_defaults_on_log_in(event):
''' set column display defaults at every login '''
set_display_defaults(event.request) set_display_defaults(event.request)
@subscriber(NewRequest) @subscriber(NewRequest)
def check_display_defaults(event): def check_display_defaults(event):
''' check if column display preferences are set in sesssion '''
if event.request.user and 'display' not in event.request.session: if event.request.user and 'display' not in event.request.session:
set_display_defaults(event.request) set_display_defaults(event.request)
@subscriber(UserNotification) @subscriber(UserNotification)
def notify_user(event): def notify_user(event):
''' notify a user about an event '''
body = render( body = render(
event.template, event.template,
{'user': event.user, 'data': event.data}, {'user': event.user, 'data': event.data},

9
ordr2/models/__init__.py

@ -1,3 +1,5 @@
''' Database models and setup '''
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import configure_mappers from sqlalchemy.orm import configure_mappers
@ -6,7 +8,7 @@ import zope.sqlalchemy
# import or define all models here to ensure they are attached to the # import or define all models here to ensure they are attached to the
# Base.metadata prior to any initialization routines # Base.metadata prior to any initialization routines
from .user import User, Role # flake8: noqa from .user import User, Role # flake8: noqa
from .orders import Category, Consumable, Order, OrderStatus from .orders import Category, Consumable, Order, OrderStatus # flake8: noqa
# run configure_mappers after defining all of the models to ensure # run configure_mappers after defining all of the models to ensure
# all relationships can be setup # all relationships can be setup
@ -14,18 +16,19 @@ configure_mappers()
def get_engine(settings, prefix='sqlalchemy.'): def get_engine(settings, prefix='sqlalchemy.'):
''' returns a sqlalchemy engine from the application configuration '''
return engine_from_config(settings, prefix) return engine_from_config(settings, prefix)
def get_session_factory(engine): def get_session_factory(engine):
''' returns a database session '''
factory = sessionmaker() factory = sessionmaker()
factory.configure(bind=engine) factory.configure(bind=engine)
return factory return factory
def get_tm_session(session_factory, transaction_manager): def get_tm_session(session_factory, transaction_manager):
''' ''' Get a sqlalchemy.orm.Session instance backed by a transaction.
Get a ``sqlalchemy.orm.Session`` instance backed by a transaction.
This function will hook the session to the transaction manager which This function will hook the session to the transaction manager which
will take care of committing any changes. will take care of committing any changes.

2
ordr2/models/meta.py

@ -1,3 +1,5 @@
''' sqlalchemy metadata configuration '''
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.schema import MetaData from sqlalchemy.schema import MetaData

12
ordr2/models/orders.py

@ -1,3 +1,5 @@
''' Consumables, Categories, Orders and Order Status Database Models '''
import bcrypt import bcrypt
import enum import enum
import uuid import uuid
@ -99,26 +101,32 @@ class Order(Base):
def _date_info(self, some_date, some_one): def _date_info(self, some_date, some_one):
''' string representaton of date and user '''
if not some_date: if not some_date:
# no date, no string
return '' return ''
if some_one: if some_one:
# in the new system a status change also stores the purchaser
return '{!s} by {!s}'.format(some_date, some_one) return '{!s} by {!s}'.format(some_date, some_one)
else: # historical data does not have a purchaser associated with a date
return '{!s}'.format(some_date) return '{!s}'.format(some_date)
@property @property
def placed(self): def placed(self):
''' string representation for placed on / by '''
return self._date_info(self.created_date, self.created_by) return self._date_info(self.created_date, self.created_by)
@property @property
def approved(self): def approved(self):
''' string representation for approval on / by '''
return self._date_info(self.approval_date, self.approval_by) return self._date_info(self.approval_date, self.approval_by)
@property @property
def ordered(self): def ordered(self):
''' string representation for ordered on / by '''
return self._date_info(self.ordered_date, self.ordered_by) return self._date_info(self.ordered_date, self.ordered_by)
@property @property
def completed(self): def completed(self):
''' string representation for completed on / by '''
return self._date_info(self.completed_date, self.completed_by) return self._date_info(self.completed_date, self.completed_by)

11
ordr2/models/user.py

@ -1,3 +1,5 @@
''' User Account and Roles Models '''
import bcrypt import bcrypt
import enum import enum
import uuid import uuid
@ -16,7 +18,8 @@ from .meta import Base
class Role(enum.Enum): class Role(enum.Enum):
''' roles of the user ''' ''' roles of user accounts '''
NEW = 'new' NEW = 'new'
USER = 'user' USER = 'user'
PURCHASER = 'purchaser' PURCHASER = 'purchaser'
@ -25,7 +28,8 @@ class Role(enum.Enum):
@property @property
def principal(self): def principal(self):
return 'role:' + self.value ''' returns the principal identifier of the role '''
return 'role:' + self.value.lower()
class User(Base): class User(Base):
@ -54,8 +58,10 @@ class User(Base):
''' returns the principal identifiers for the user's role ''' ''' returns the principal identifiers for the user's role '''
principals = [self.role.principal] principals = [self.role.principal]
if self.role is Role.PURCHASER: if self.role is Role.PURCHASER:
# a purchaser is also a user
principals.append(Role.USER.principal) principals.append(Role.USER.principal)
elif self.role is Role.ADMIN: elif self.role is Role.ADMIN:
# an admin is also a purchaser and a user
principals.append(Role.USER.principal) principals.append(Role.USER.principal)
principals.append(Role.PURCHASER.principal) principals.append(Role.PURCHASER.principal)
return principals return principals
@ -78,6 +84,7 @@ class User(Base):
return False return False
def generate_password_token(self): def generate_password_token(self):
''' generates a token for a password reset link '''
token = uuid.uuid4() token = uuid.uuid4()
self.password_reset = token.hex self.password_reset = token.hex
return token.hex return token.hex

3
ordr2/resources/__init__.py

@ -1,3 +1,5 @@
''' base resource and resource root factory '''
from pyramid.security import Allow, Everyone from pyramid.security import Allow, Everyone
from .account import Account, PasswordResetAccount from .account import Account, PasswordResetAccount
@ -28,6 +30,7 @@ class Root(BaseResource):
self.request = request self.request = request
def __acl__(self): def __acl__(self):
''' access controll list '''
return [ (Allow, Everyone, 'view') ] return [ (Allow, Everyone, 'view') ]

10
ordr2/resources/account.py

@ -1,3 +1,5 @@
''' Resources for User Accounts '''
from pyramid.security import Allow, Authenticated, Deny, DENY_ALL, Everyone from pyramid.security import Allow, Authenticated, Deny, DENY_ALL, Everyone
from ordr2.models import User from ordr2.models import User
@ -5,10 +7,11 @@ from ordr2.models import User
from .base import BaseResource from .base import BaseResource
class PasswordResetAccount(BaseResource): class PasswordResetAccount(BaseResource):
''' resource for passwort change representing a reset token '''
def __acl__(self): def __acl__(self):
''' access controll list '''
return [ return [
(Allow, Everyone, 'reset'), (Allow, Everyone, 'reset'),
DENY_ALL DENY_ALL
@ -16,14 +19,17 @@ class PasswordResetAccount(BaseResource):
class PasswordReset(BaseResource): class PasswordReset(BaseResource):
''' resource for passwort reset link '''
def __acl__(self): def __acl__(self):
''' access controll list '''
return [ return [
(Allow, Everyone, 'reset'), (Allow, Everyone, 'reset'),
DENY_ALL DENY_ALL
] ]
def __getitem__(self, key): def __getitem__(self, key):
''' queries the database for a password reset token '''
key = key.strip() key = key.strip()
if key: if key:
account = self.request.dbsession.\ account = self.request.dbsession.\
@ -36,6 +42,7 @@ class PasswordReset(BaseResource):
class Account(BaseResource): class Account(BaseResource):
''' User Account and Settings '''
nodes = {'reset': PasswordReset} nodes = {'reset': PasswordReset}
@ -45,6 +52,7 @@ class Account(BaseResource):
def __acl__(self): def __acl__(self):
''' access controll list '''
return [ return [
(Allow, Everyone, 'login'), (Allow, Everyone, 'login'),
(Allow, Everyone, 'logout'), (Allow, Everyone, 'logout'),

31
ordr2/resources/admin.py

@ -1,3 +1,5 @@
''' Resources for the Admin Section '''
from sqlalchemy import or_ from sqlalchemy import or_
from pyramid.security import Allow, Authenticated, Deny, DENY_ALL, Everyone from pyramid.security import Allow, Authenticated, Deny, DENY_ALL, Everyone
@ -9,7 +11,9 @@ from ordr2.models import Category, Consumable, User, Role
# user accounr resources # user accounr resources
class UserAccount(BaseResource): class UserAccount(BaseResource):
''' Resource for a user account '''
def __acl__(self): def __acl__(self):
''' Access Controll List '''
return [ return [
(Allow, 'role:admin', 'view'), (Allow, 'role:admin', 'view'),
(Allow, 'role:admin', 'edit'), (Allow, 'role:admin', 'edit'),
@ -19,12 +23,15 @@ class UserAccount(BaseResource):
class UserList(BaseResource, PaginationResourceMixin): class UserList(BaseResource, PaginationResourceMixin):
''' Resource for a list of users '''
sql_model_class = User sql_model_class = User
child_resource_class = UserAccount child_resource_class = UserAccount
default_sorting = 'user.asc' default_sorting = 'user.asc'
default_items_per_page = 12 default_items_per_page = 12
def __acl__(self): def __acl__(self):
''' Access Controll List '''
return [ return [
(Allow, 'role:admin', 'view'), (Allow, 'role:admin', 'view'),
(Allow, 'role:admin', 'edit'), (Allow, 'role:admin', 'edit'),
@ -32,11 +39,11 @@ class UserList(BaseResource, PaginationResourceMixin):
DENY_ALL DENY_ALL
] ]
def prepare_filtered_query(self, dbsession, filter_params): def prepare_filtered_query(self, dbsession, filter_params):
''' setup the base filtered query ''' ''' setup the base filtered query '''
query = dbsession.query(self.sql_model_class) query = dbsession.query(self.sql_model_class)
# filter by role
role_name = filter_params.get('role', None) role_name = filter_params.get('role', None)
try: try:
role_name = role_name.lower() role_name = role_name.lower()
@ -46,6 +53,7 @@ class UserList(BaseResource, PaginationResourceMixin):
role_name = None role_name = None
self.filters['role'] = role_name self.filters['role'] = role_name
# filter by search term
search = filter_params.get('search', None) search = filter_params.get('search', None)
if search: if search:
term = '%{}%'.format(search) term = '%{}%'.format(search)
@ -61,9 +69,8 @@ class UserList(BaseResource, PaginationResourceMixin):
return query return query
def prepare_sorted_query(self, query, sorting): def prepare_sorted_query(self, query, sorting):
''' setup the base filtered query ''' ''' add sorting to the base query '''
available_fields = { available_fields = {
'user': 'user_name', 'user': 'user_name',
'first': 'first_name', 'first': 'first_name',
@ -76,10 +83,13 @@ class UserList(BaseResource, PaginationResourceMixin):
if model_field: if model_field:
sort_func = sorting.func(model_field) sort_func = sorting.func(model_field)
query = query.order_by(sort_func) query = query.order_by(sort_func)
# add default sorting
default_sort = self.parse_sort_parameters(self.default_sorting) default_sort = self.parse_sort_parameters(self.default_sorting)
if sorting.field != default_sort.field: if sorting.field != default_sort.field:
default_sort = self.parse_sort_parameters(self.default_sorting) default_sort = self.parse_sort_parameters(self.default_sorting)
query = self.prepare_sorted_query(query, default_sort) query = self.prepare_sorted_query(query, default_sort)
return query return query
@ -87,7 +97,9 @@ class UserList(BaseResource, PaginationResourceMixin):
# consumables resources # consumables resources
class ConsumableResource(BaseResource): class ConsumableResource(BaseResource):
''' Resource for one consumable '''
def __acl__(self): def __acl__(self):
''' Access Controll List '''
return [ return [
(Allow, 'role:admin', 'view'), (Allow, 'role:admin', 'view'),
(Allow, 'role:admin', 'edit'), (Allow, 'role:admin', 'edit'),
@ -97,12 +109,15 @@ class ConsumableResource(BaseResource):
class ConsumableList(BaseResource, PaginationResourceMixin): class ConsumableList(BaseResource, PaginationResourceMixin):
''' Resource for a list of consumables '''
sql_model_class = Consumable sql_model_class = Consumable
child_resource_class = ConsumableResource child_resource_class = ConsumableResource
default_sorting = 'cas.asc' default_sorting = 'cas.asc'
default_items_per_page = 12 default_items_per_page = 12
def __acl__(self): def __acl__(self):
''' Access Controll List '''
return [ return [
(Allow, 'role:admin', 'view'), (Allow, 'role:admin', 'view'),
(Allow, 'role:admin', 'create'), (Allow, 'role:admin', 'create'),
@ -116,6 +131,7 @@ class ConsumableList(BaseResource, PaginationResourceMixin):
''' setup the base filtered query ''' ''' setup the base filtered query '''
query = dbsession.query(self.sql_model_class) query = dbsession.query(self.sql_model_class)
# filter by category
category_name = filter_params.get('category', None) category_name = filter_params.get('category', None)
try: try:
category_name = category_name.lower() category_name = category_name.lower()
@ -125,6 +141,7 @@ class ConsumableList(BaseResource, PaginationResourceMixin):
category_name = None category_name = None
self.filters['category'] = category_name self.filters['category'] = category_name
# filter by search term
search = filter_params.get('search', None) search = filter_params.get('search', None)
if search: if search:
term = '%{}%'.format(search) term = '%{}%'.format(search)
@ -139,9 +156,8 @@ class ConsumableList(BaseResource, PaginationResourceMixin):
return query return query
def prepare_sorted_query(self, query, sorting): def prepare_sorted_query(self, query, sorting):
''' setup the base filtered query ''' ''' add sorting to the base query '''
available_fields = { available_fields = {
'cas': 'cas_description', 'cas': 'cas_description',
'category': 'category', 'category': 'category',
@ -156,14 +172,18 @@ class ConsumableList(BaseResource, PaginationResourceMixin):
if model_field: if model_field:
sort_func = sorting.func(model_field) sort_func = sorting.func(model_field)
query = query.order_by(sort_func) query = query.order_by(sort_func)
# add default sorting
default_sort = self.parse_sort_parameters(self.default_sorting) default_sort = self.parse_sort_parameters(self.default_sorting)
if sorting.field != default_sort.field: if sorting.field != default_sort.field:
default_sort = self.parse_sort_parameters(self.default_sorting) default_sort = self.parse_sort_parameters(self.default_sorting)
query = self.prepare_sorted_query(query, default_sort) query = self.prepare_sorted_query(query, default_sort)
return query return query
class Admin(BaseResource): class Admin(BaseResource):
''' Resource for the admin section '''
nodes = { nodes = {
'users': UserList, 'users': UserList,
@ -171,4 +191,5 @@ class Admin(BaseResource):
} }
def __acl__(self): def __acl__(self):
''' Access Controll List '''
return [ (Allow, 'role:admin', 'view') ] return [ (Allow, 'role:admin', 'view') ]

85
ordr2/resources/base.py

@ -1,11 +1,14 @@
''' Base Resource and Mixin classes, not to be used directly '''
from collections import namedtuple from collections import namedtuple
from pyramid.security import DENY_ALL
from pyramid.security import DENY_ALL
from sqlalchemy import asc, desc from sqlalchemy import asc, desc
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
class BaseResource(object): class BaseResource(object):
''' Base Resource for all other resources '''
__parent__ = None __parent__ = None
__name__ = None __name__ = None
@ -26,10 +29,12 @@ class BaseResource(object):
super().__init__() super().__init__()
def __acl__(self): def __acl__(self):
''' Access controll list '''
return [ DENY_ALL ] return [ DENY_ALL ]
def __getitem__(self, key): def __getitem__(self, key):
''' returns child resources '''
klass = self.nodes.get(key, None) klass = self.nodes.get(key, None)
if klass: if klass:
return klass(key, self) return klass(key, self)
@ -52,19 +57,45 @@ class BaseResource(object):
class Pagination(object): class Pagination(object):
''' calculates pagination information
Available instance attributes
count: total number of items
items: number of items displayed per page, aliased by items_per_page
first: first page number
last: last page number
current: current page number
previous: previous page number
next: next page number
window: page window, e.g:
lets assume the current page is 10 and window size is 7
self.window = [7, 8, 9, 10, 11, 12, 13]
'''
default_items = 25 default_items = 25
default_window_size = 7 default_window_size = 7
def __init__(self, current, count, items=None, window_size=None): def __init__(self, current, count, items=None, window_size=None):
''' calculates pagination information
Parameters:
current: current pages
count: total number of items
items: number of items displayed per pages
window_size: size of pagination window
'''
# ensure values are integers
current = self._ensure_int(current, 1) current = self._ensure_int(current, 1)
count = self._ensure_int(count, 0) count = self._ensure_int(count, 0)
items = self._ensure_int(items, self.default_items) items = self._ensure_int(items, self.default_items)
window_size = self._ensure_int(window_size, self.default_window_size) window_size = self._ensure_int(window_size, self.default_window_size)
# set the simples values that won't change
self.count = count self.count = count
self.items = self.items_per_page = items self.items = self.items_per_page = items
# calculate number of pages
pages = (count - 1) // items + 1 pages = (count - 1) // items + 1
self.first = 1 self.first = 1
self.last = max(self.first, pages) self.last = max(self.first, pages)
@ -88,33 +119,57 @@ class Pagination(object):
return default return default
def _ensure_int(self, value, default): def _ensure_int(self, value, default):
''' converts the value to integer, returns default if it fails '''
try: try:
return int(value) return int(value)
except Exception: except Exception:
return default return default
# named tuple for parameters used in sorting
SortParameter = namedtuple('SortParameter', 'text field direction func') SortParameter = namedtuple('SortParameter', 'text field direction func')
class PaginationResourceMixin(object): class PaginationResourceMixin(object):
''' mixin providing pagination information for simple sql models
class attributes that must be defined in child classes:
sql_model_class: sqlalchemy model class
child_resource_class: resource representing a sqlalchemy model
default_sorting: string for default soring behaviour,
e.g. 'created_on.desc'
default_items_per_page: default number of items displayed per page
available instance attributes:
pages: pagination information, see Pagination class
sorting: sorting parameter, processed from request.GET
filters: filter parameters, processed from request.GET
'''
# class attributes that must be defined in child classes
sql_model_class = None sql_model_class = None
child_resource_class = None child_resource_class = None
default_sorting = None default_sorting = None
default_items_per_page = 25 default_items_per_page = 25
# attributes set by processing request.GET
pages = None pages = None
sorting = None sorting = None
filters = {} filters = {}
# keys for request.GET processing
query_key_current_page = 'p' query_key_current_page = 'p'
query_key_items_per_page = 'n' query_key_items_per_page = 'n'
query_key_sorting = 'o' query_key_sorting = 'o'
# base sqlalchemy query object
_base_query = None _base_query = None
def __init__(self): def __init__(self):
''' sets parameters from request.GET '''
# first we need to remove non-filter parameters from GET # first we need to remove non-filter parameters from GET
params = dict(self.request.GET) params = dict(self.request.GET)
page = params.pop(self.query_key_current_page, 1) page = params.pop(self.query_key_current_page, 1)
@ -145,13 +200,16 @@ class PaginationResourceMixin(object):
''' setup the base filtered query ''' setup the base filtered query
An example: An example:
def prepare_filtered_query(self, dbsession, filter_params): def prepare_filtered_query(self, dbsession, filter_params):
query = dbsession.query(self.sql_model_class) query = dbsession.query(self.sql_model_class)
by_username = filter_params.get('username', None) by_username = filter_params.get('username', None)
if by_username is not None: if by_username is not None:
query = query.filter_by(username=by_username) query = query.filter_by(username=by_username)
# don't forget to remember the filter # don't forget to remember the filter
self.filters['username'] = by_username self.filters['username'] = by_username
return query return query
''' '''
msg = 'Query setup must be implemented in child class' msg = 'Query setup must be implemented in child class'
@ -159,9 +217,10 @@ class PaginationResourceMixin(object):
def prepare_sorted_query(self, query, sorting): def prepare_sorted_query(self, query, sorting):
''' setup the base filtered query ''' add sorting to the base query
An example: An example:
def prepare_sorted_query(self, query, sorting): def prepare_sorted_query(self, query, sorting):
model_field = getattr(self.sql_model_class, sorting.field) model_field = getattr(self.sql_model_class, sorting.field)
sort_func = sorting.func(model_field) sort_func = sorting.func(model_field)
@ -172,6 +231,7 @@ class PaginationResourceMixin(object):
def parse_sort_parameters(self, sort_param): def parse_sort_parameters(self, sort_param):
''' parses a string that might contain sorting information '''
sort_functions = { 'asc': asc, 'desc': desc} sort_functions = { 'asc': asc, 'desc': desc}
try: try:
sort_param = sort_param.lower() sort_param = sort_param.lower()
@ -185,12 +245,16 @@ class PaginationResourceMixin(object):
def items(self): def items(self):
''' returns the items of the current page as resources''' ''' returns the items of the current page as resources'''
if not self.pages.count: if not self.pages.count:
return return []
# calculate the offset from the paging information
offset = (self.pages.current - 1) * self.pages.items_per_page offset = (self.pages.current - 1) * self.pages.items_per_page
# prepare the query including sorting, offset and limit
query = self.prepare_sorted_query(self._base_query, self.sorting) query = self.prepare_sorted_query(self._base_query, self.sorting)
query = query.offset(offset).limit(self.pages.items_per_page) query = query.offset(offset).limit(self.pages.items_per_page)
# return a list of resources representing the items found by the query
return [ return [
self.child_resource_class.from_sqla(item, self) self.child_resource_class.from_sqla(item, self)
for item for item
@ -199,6 +263,14 @@ class PaginationResourceMixin(object):
def query_params(self, *args, **kwargs): def query_params(self, *args, **kwargs):
''' returns a dict with query parameters for a new request
The entries are set by the parsed request.GET parameters used to
construct the queries. If a new request comes in with the parameters
provided, it will return the same child resources in the same order.
the query parameters can be overridden by providing tuples or
keyword arguments with new values.
'''
params = { params = {
self.query_key_current_page: self.pages.current, self.query_key_current_page: self.pages.current,
self.query_key_items_per_page: self.pages.items, self.query_key_items_per_page: self.pages.items,
@ -207,16 +279,23 @@ class PaginationResourceMixin(object):
params.update(self.filters) params.update(self.filters)
params.update(args) params.update(args)
params.update(kwargs) params.update(kwargs)
# remove items that have None as a value
filtered = {k: v for k, v in params.items() if v is not None} filtered = {k: v for k, v in params.items() if v is not None}
return filtered return filtered
def url(self, *args, **kwargs): def url(self, *args, **kwargs):
''' shortcut for creating a url pointing to this resource
shortcut for:
request.resource_url(self, query=self.query_params(*args, **kwargs))
'''
params = self.query_params(*args, **kwargs) params = self.query_params(*args, **kwargs)
return self.request.resource_url(self, query=params) return self.request.resource_url(self, query=params)
def __getitem__(self, key): def __getitem__(self, key):
''' returns a child resource representing a sqlalchemy model '''
model = self.request.dbsession.query(self.sql_model_class).get(key) model = self.request.dbsession.query(self.sql_model_class).get(key)
if not model: if not model:
raise KeyError() raise KeyError()

27
ordr2/resources/orders.py

@ -8,27 +8,38 @@ from ordr2.models import Category, Order, OrderStatus
class OrderResource(BaseResource): class OrderResource(BaseResource):
''' Resource representing one order '''
def __acl__(self): def __acl__(self):
''' Access controll list '''
acl = [ acl = [
(Allow, 'role:user', 'view'), (Allow, 'role:user', 'view'),
(Allow, 'role:user', 'create'), (Allow, 'role:user', 'create'),
(Allow, 'role:purchaser', 'edit'), (Allow, 'role:purchaser', 'edit'),
(Allow, 'role:purchaser', 'delete'), (Allow, 'role:purchaser', 'delete'),
] ]
# open orders may be edited and deleted by the user that placed them
if self.model.status == OrderStatus.OPEN: if self.model.status == OrderStatus.OPEN:
acl.append( (Allow, 'user:' + str(self.model.created_by), 'edit') ) acl.append(
acl.append( (Allow, 'user:' + str(self.model.created_by), 'delete') ) (Allow, 'user:' + str(self.model.created_by), 'edit')
)
acl.append(
(Allow, 'user:' + str(self.model.created_by), 'delete')
)
acl.append(DENY_ALL) acl.append(DENY_ALL)
return acl return acl
class OrderList(BaseResource, PaginationResourceMixin): class OrderList(BaseResource, PaginationResourceMixin):
''' Resource representing a list of orders '''
sql_model_class = Order sql_model_class = Order
child_resource_class = OrderResource child_resource_class = OrderResource
default_sorting = 'created.desc' default_sorting = 'created.desc'
default_items_per_page = 12 default_items_per_page = 12
def __acl__(self): def __acl__(self):
''' Access controll list '''
return [ return [
(Allow, 'role:user', 'view'), (Allow, 'role:user', 'view'),
(Allow, 'role:user', 'create'), (Allow, 'role:user', 'create'),
@ -37,13 +48,13 @@ class OrderList(BaseResource, PaginationResourceMixin):
DENY_ALL DENY_ALL
] ]
def prepare_filtered_query(self, dbsession, filter_params): def prepare_filtered_query(self, dbsession, filter_params):
''' setup the base filtered query ''' ''' setup the base filtered query '''
query = dbsession.query(self.sql_model_class) query = dbsession.query(self.sql_model_class)
try: # filter by status
status_name = filter_params.get('status', None) status_name = filter_params.get('status', None)
try:
status_name = status_name.lower() status_name = status_name.lower()
status = OrderStatus(status_name) status = OrderStatus(status_name)
query = query.filter_by(status=status) query = query.filter_by(status=status)
@ -51,11 +62,13 @@ class OrderList(BaseResource, PaginationResourceMixin):
status_name = None status_name = None
self.filters['status'] = status_name self.filters['status'] = status_name
# filter by user
user_name = filter_params.get('user', None) user_name = filter_params.get('user', None)
if user_name: if user_name:
query = query.filter_by(created_by=user_name) query = query.filter_by(created_by=user_name)
self.filters['user'] = user_name self.filters['user'] = user_name
# filter by search term
search = filter_params.get('search', None) search = filter_params.get('search', None)
if search: if search:
term = '%{}%'.format(search) term = '%{}%'.format(search)
@ -72,9 +85,8 @@ class OrderList(BaseResource, PaginationResourceMixin):
return query return query
def prepare_sorted_query(self, query, sorting): def prepare_sorted_query(self, query, sorting):
''' setup the base filtered query ''' ''' add sorting to the base query '''
available_fields = { available_fields = {
'cas': 'cas_description', 'cas': 'cas_description',
'category': 'category', 'category': 'category',
@ -92,8 +104,11 @@ class OrderList(BaseResource, PaginationResourceMixin):
if model_field: if model_field:
sort_func = sorting.func(model_field) sort_func = sorting.func(model_field)
query = query.order_by(sort_func) query = query.order_by(sort_func)
# add default sorting
default_sort = self.parse_sort_parameters(self.default_sorting) default_sort = self.parse_sort_parameters(self.default_sorting)
if sorting.field != default_sort.field: if sorting.field != default_sort.field:
default_sort = self.parse_sort_parameters(self.default_sorting) default_sort = self.parse_sort_parameters(self.default_sorting)
query = self.prepare_sorted_query(query, default_sort) query = self.prepare_sorted_query(query, default_sort)
return query return query

12
ordr2/schemas/__init__.py

@ -1,16 +1,19 @@
''' Schemas for form input and validation '''
import colander import colander
import deform import deform
from deform.renderer import configure_zpt_renderer
from .helpers import ( from .helpers import (
deferred_csrf_default, deferred_csrf_default,
deferred_csrf_validator deferred_csrf_validator
) )
from deform.renderer import configure_zpt_renderer
# Make Deform widgets aware of our widget template paths # Make Deform widgets aware of our widget template paths
configure_zpt_renderer(['ordr2:templates/deform']) configure_zpt_renderer(['ordr2:templates/deform'])
# Base Schema # Base Schema
class CSRFSchema(colander.Schema): class CSRFSchema(colander.Schema):
@ -25,7 +28,8 @@ class CSRFSchema(colander.Schema):
@classmethod @classmethod
def as_form(cls, request, **kwargs): def as_form(cls, request, **kwargs):
url = kwargs.get('url', None) ''' returns the schema as a form '''
url = kwargs.pop('url', None)
if not url: if not url:
url = request.resource_url(request.context, request.view_name) url = request.resource_url(request.context, request.view_name)
schema = cls().bind(request=request) schema = cls().bind(request=request)
@ -35,6 +39,7 @@ class CSRFSchema(colander.Schema):
class MoneyInputSchema(colander.Schema): class MoneyInputSchema(colander.Schema):
''' custom schema for structured money and currency input '''
amount = colander.SchemaNode( amount = colander.SchemaNode(
colander.Decimal(), colander.Decimal(),
@ -53,6 +58,7 @@ class MoneyInputSchema(colander.Schema):
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
''' define the custom schema templates '''
if 'widget' not in kwargs: if 'widget' not in kwargs:
readonly = kwargs.pop('readonly', False) readonly = kwargs.pop('readonly', False)
kwargs['widget'] = deform.widget.MappingWidget( kwargs['widget'] = deform.widget.MappingWidget(

31
ordr2/schemas/orders.py

@ -1,3 +1,5 @@
''' schemas for creating and editing orders and consumables'''
import colander import colander
import deform import deform
@ -5,10 +7,12 @@ from ordr2.models import Category, OrderStatus
from . import CSRFSchema, MoneyInputSchema from . import CSRFSchema, MoneyInputSchema
# key / value pairs for select fields
CATEGORIES = [(c.name, c.value.capitalize()) for c in Category] CATEGORIES = [(c.name, c.value.capitalize()) for c in Category]
STATI = [(s.name, s.value.capitalize()) for s in OrderStatus] STATI = [(s.name, s.value.capitalize()) for s in OrderStatus]
# schema for user registration
class ConsumableSchema(CSRFSchema): class ConsumableSchema(CSRFSchema):
''' edit or add consumable ''' ''' edit or add consumable '''
@ -40,6 +44,8 @@ class ConsumableSchema(CSRFSchema):
@classmethod @classmethod
def as_form(cls, request, **override): def as_form(cls, request, **override):
''' returns the schema as a form '''
# define buttons separately for a new consumable and one to edit
is_new_consumable = override.pop('is_new_consumable', False) is_new_consumable = override.pop('is_new_consumable', False)
if is_new_consumable: if is_new_consumable:
settings = { settings = {
@ -67,6 +73,10 @@ class ConsumableSchema(CSRFSchema):
class OrderInformation(colander.Schema): class OrderInformation(colander.Schema):
''' schema for editing order status
parital schema, used in EditOrderSchema
'''
status = colander.SchemaNode( status = colander.SchemaNode(
colander.String(), colander.String(),
@ -75,6 +85,10 @@ class OrderInformation(colander.Schema):
class OrderItem(colander.Schema): class OrderItem(colander.Schema):
''' schema for editing item information
parital schema, used in NewOrderSchema and EditOrderSchema
'''
cas_description = colander.SchemaNode( cas_description = colander.SchemaNode(
colander.String() colander.String()
@ -95,6 +109,10 @@ class OrderItem(colander.Schema):
class OrderPricing(colander.Schema): class OrderPricing(colander.Schema):
''' schema for editing price information
parital schema, used in NewOrderSchema and EditOrderSchema
'''
unit_price = MoneyInputSchema( unit_price = MoneyInputSchema(
readonly=False readonly=False
@ -113,7 +131,10 @@ class OrderPricing(colander.Schema):
class OrderOptionals(colander.Schema): class OrderOptionals(colander.Schema):
''' schema for editing optional order information
parital schema, used in NewOrderSchema and EditOrderSchema
'''
account = colander.SchemaNode( account = colander.SchemaNode(
colander.String(), colander.String(),
missing='' missing=''
@ -126,7 +147,7 @@ class OrderOptionals(colander.Schema):
class NewOrderSchema(CSRFSchema): class NewOrderSchema(CSRFSchema):
''' edit or add an order ''' ''' schema for a new order '''
item_information = OrderItem() item_information = OrderItem()
pricing = OrderPricing() pricing = OrderPricing()
@ -134,6 +155,7 @@ class NewOrderSchema(CSRFSchema):
@classmethod @classmethod
def as_form(cls, request, **override): def as_form(cls, request, **override):
''' returns the schema as a form '''
settings = { settings = {
'buttons': ( 'buttons': (
deform.Button(name='save', title='Place Order'), deform.Button(name='save', title='Place Order'),
@ -146,7 +168,7 @@ class NewOrderSchema(CSRFSchema):
class EditOrderSchema(CSRFSchema): class EditOrderSchema(CSRFSchema):
''' edit or add an order ''' ''' schema for editing an order '''
order_information = OrderInformation( order_information = OrderInformation(
widget=deform.widget.MappingWidget( widget=deform.widget.MappingWidget(
@ -159,6 +181,7 @@ class EditOrderSchema(CSRFSchema):
@classmethod @classmethod
def as_form(cls, request, **override): def as_form(cls, request, **override):
''' returns the schema as a form '''
settings = { settings = {
'buttons': ( 'buttons': (
deform.Button(name='save', title='Edit Order'), deform.Button(name='save', title='Edit Order'),
@ -178,6 +201,8 @@ class EditOrderSchema(CSRFSchema):
} }
settings.update(override) settings.update(override)
form = super().as_form(request, **settings) form = super().as_form(request, **settings)
# disable the status field, if the current user is not a purchaser
if not 'role:purchaser' in request.user.role_principals: if not 'role:purchaser' in request.user.role_principals:
form['order_information']['status'].widget = \ form['order_information']['status'].widget = \
deform.widget.TextInputWidget( deform.widget.TextInputWidget(

2
ordr2/scripts/__init__.py

@ -1 +1 @@
# package ''' command line scripts '''

41
ordr2/scripts/initializedb.py

@ -1,37 +1,36 @@
''' initializes a new data base and migrates old data '''
import os import os
import sys import sys
import transaction import transaction
import yaml import yaml
from datetime import datetime from datetime import datetime
from tqdm import tqdm from tqdm import tqdm
from urllib.parse import urlparse from urllib.parse import urlparse
from pyramid.paster import ( from pyramid.paster import get_appsettings, setup_logging
get_appsettings,
setup_logging,
)
from pyramid.scripts.common import parse_vars from pyramid.scripts.common import parse_vars
from ..models.meta import Base from ordr2.models.meta import Base
from ..models import ( from ordr2.models import (
get_engine, get_engine,
get_session_factory, get_session_factory,
get_tm_session, get_tm_session,
) )
from ..models import Category, Consumable, Order, OrderStatus, User, Role from ordr2.models import Category, Consumable, Order, OrderStatus, User, Role
def usage(argv): def usage(argv):
''' display the command line help message '''
cmd = os.path.basename(argv[0]) cmd = os.path.basename(argv[0])
print('usage: %s <config_uri> [var=value]\n' print('usage: %s <config_uri> [var=value]\n'
'(example: "%s development.ini")' % (cmd, cmd)) '(example: "%s development.ini")' % (cmd, cmd))
sys.exit(1) sys.exit(1)
def read_exported_file(*paths): def read_yaml_file(*paths):
''' read and parse a yaml file '''
path = os.path.join(*paths) path = os.path.join(*paths)
with open(path, 'r') as filehandle: with open(path, 'r') as filehandle:
items = yaml.load(filehandle) items = yaml.load(filehandle)
@ -39,12 +38,18 @@ def read_exported_file(*paths):
def parse_dt(value, default=False): def parse_dt(value, default=False):
''' parse a datetime value or return a default
for a default value Null is explicitly allowed.
'''
if not value or value.startswith('0000'): if not value or value.startswith('0000'):
return datetime.utcnow() if default is False else default return datetime.utcnow() if default is False else default
return datetime.strptime(value + ' -0000', '%Y-%m-%d %H:%M:%S %z') return datetime.strptime(value + ' -0000', '%Y-%m-%d %H:%M:%S %z')
def main(argv=sys.argv): def main(argv=sys.argv):
''' setup the database and migrate the data '''
# load the settings from the provided config file
if len(argv) < 2: if len(argv) < 2:
usage(argv) usage(argv)
config_uri = argv[1] config_uri = argv[1]
@ -57,16 +62,17 @@ def main(argv=sys.argv):
if database_url.scheme == 'sqlite' and os.path.isfile(database_url.path): if database_url.scheme == 'sqlite' and os.path.isfile(database_url.path):
os.remove(database_url.path) os.remove(database_url.path)
# setup the database connection
engine = get_engine(settings) engine = get_engine(settings)
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
session_factory = get_session_factory(engine) session_factory = get_session_factory(engine)
with transaction.manager: with transaction.manager:
dbsession = get_tm_session(session_factory, transaction.manager) dbsession = get_tm_session(session_factory, transaction.manager)
base_dir = os.path.dirname(__file__) base_dir = os.path.dirname(__file__)
user_list = read_exported_file(base_dir, 'export_users.yml')
# migrate user data
user_list = read_yaml_file(base_dir, 'export_users.yml')
for data in tqdm(user_list, desc='Users'): for data in tqdm(user_list, desc='Users'):
user = User( user = User(
id=data['id'], id=data['id'],
@ -83,8 +89,9 @@ def main(argv=sys.argv):
dbsession.add(user) dbsession.add(user)
dbsession.flush() dbsession.flush()
cat_list = read_exported_file(base_dir, 'export_consumables.yml') # migrate consumables
for data in tqdm(cat_list, desc='Consumables'): con_list = read_yaml_file(base_dir, 'export_consumables.yml')
for data in tqdm(con_list, desc='Consumables'):
category = Category[data['category'].upper()] category = Category[data['category'].upper()]
date_created = parse_dt(data['date_created']) date_created = parse_dt(data['date_created'])
consumable = Consumable( consumable = Consumable(
@ -103,8 +110,8 @@ def main(argv=sys.argv):
dbsession.add(consumable) dbsession.add(consumable)
dbsession.flush() dbsession.flush()
# migrate orders
order_list = read_exported_file(base_dir, 'export_orders.yml') order_list = read_yaml_file(base_dir, 'export_orders.yml')
for data in tqdm(order_list, desc='Orders'): for data in tqdm(order_list, desc='Orders'):
work_status = data.get('work_status', 'approval') work_status = data.get('work_status', 'approval')
status = OrderStatus[work_status.upper()] status = OrderStatus[work_status.upper()]

7
ordr2/security.py

@ -1,3 +1,5 @@
''' User Authentication and Authorization '''
from pyramid.authentication import AuthTktAuthenticationPolicy from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy from pyramid.authorization import ACLAuthorizationPolicy
from pyramid.security import Authenticated, Everyone from pyramid.security import Authenticated, Everyone
@ -39,7 +41,10 @@ def get_user(request):
def includeme(config): def includeme(config):
''' initializing authentication and authorization for the Pyramid app ''' ''' initializing authentication and authorization for the Pyramid app
Activate this setup using ``config.include('ordr2.security')``.
'''
settings = config.get_settings() settings = config.get_settings()
authn_policy = AuthenticationPolicy( authn_policy = AuthenticationPolicy(
settings['auth.secret'], settings['auth.secret'],

3
ordr2/templates/tests.py

@ -1,4 +1,7 @@
''' custom jinja2 tests and filters '''
def are_extras_active(context, extras): def are_extras_active(context, extras):
''' checks if the filters are active in a PaginationResource '''
if extras is None: if extras is None:
return True return True
return all(context.filters.get(k) == v for k, v in extras.items()) return all(context.filters.get(k) == v for k, v in extras.items())

15
ordr2/views/__init__.py

@ -1,6 +1,11 @@
from collections import namedtuple ''' views package
some view helpers are defined here
'''
from collections import namedtuple
# a message for session.flash()
FlashMessage = namedtuple('FlashMessage', 'message description dismissable') FlashMessage = namedtuple('FlashMessage', 'message description dismissable')
@ -11,7 +16,7 @@ def flash(request, channel, message, description='', dismissable=True):
def update_column_display(request, section): def update_column_display(request, section):
''' update the session values for wich columns to display ''' ''' update the session values for which columns to display '''
if section not in request.session['display']: if section not in request.session['display']:
return return
display_keys = request.session['display'][section].keys() display_keys = request.session['display'][section].keys()
@ -23,6 +28,7 @@ def update_column_display(request, section):
def set_display_defaults(request): def set_display_defaults(request):
''' sets the coulumn display default '''
defaults = { defaults = {
'users': { 'users': {
'first': True, 'first': True,
@ -43,7 +49,10 @@ def set_display_defaults(request):
def includeme(config): def includeme(config):
''' adding request helpers and static views ''' ''' adding request helpers and static views
Activate this setup using ``config.include('ordr2.views')``.
'''
config.add_request_method(flash, 'flash') config.add_request_method(flash, 'flash')
settings = config.get_settings() settings = config.get_settings()

19
ordr2/views/account.py

@ -1,3 +1,5 @@
''' Account Registration and Settings '''
import deform import deform
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
@ -13,6 +15,9 @@ from ordr2.schemas.account import (
SettingsSchema SettingsSchema
) )
# below this password length a warning is displayed
MIN_PW_LENGTH = 12
# user log in and log out # user log in and log out
@view_config( @view_config(
@ -90,7 +95,6 @@ def logout(context, request):
) )
def registration_form(context, request): def registration_form(context, request):
''' display a registration form ''' ''' display a registration form '''
context.nav_highlight = 'register' context.nav_highlight = 'register'
form = RegistrationSchema.as_form(request) form = RegistrationSchema.as_form(request)
return {'form': form} return {'form': form}
@ -133,7 +137,7 @@ def registration_form_processing(context, request):
'Your account <em>{}</em> has been created.'.format(account.user_name), 'Your account <em>{}</em> has been created.'.format(account.user_name),
dismissable=False dismissable=False
) )
if len(appstruct['password']) < 8: if len(appstruct['password']) < MIN_PW_LENGTH:
request.flash( request.flash(
'warning', 'warning',
'You should really consider using a longer password.', 'You should really consider using a longer password.',
@ -187,7 +191,7 @@ def settings_form(context, request):
renderer='ordr2:templates/account/settings.jinja2' renderer='ordr2:templates/account/settings.jinja2'
) )
def settings_form_processing(context, request): def settings_form_processing(context, request):
''' display the user settings form ''' ''' process the user settings form '''
if 'Cancel' in request.POST: if 'Cancel' in request.POST:
return HTTPFound(request.resource_url(request.root)) return HTTPFound(request.resource_url(request.root))
@ -205,7 +209,7 @@ def settings_form_processing(context, request):
request.user.email = appstruct['general']['email'] request.user.email = appstruct['general']['email']
if appstruct['change_password']['new_password']: if appstruct['change_password']['new_password']:
request.user.set_password(appstruct['change_password']['new_password']) request.user.set_password(appstruct['change_password']['new_password'])
if len(appstruct['change_password']['new_password']) < 8: if len(appstruct['change_password']['new_password']) < MIN_PW_LENGTH:
request.flash( request.flash(
'warning', 'warning',
'You should really consider using a longer password.' 'You should really consider using a longer password.'
@ -249,6 +253,7 @@ def reset_password_form_processing(context, request):
except deform.ValidationFailure as e: except deform.ValidationFailure as e:
return {'form': form} return {'form': form}
# form validation sucessful, change password and remove reset token
context.model.set_password(appstruct['new_password']) context.model.set_password(appstruct['new_password'])
context.model.password_reset = '' context.model.password_reset = ''
@ -257,6 +262,12 @@ def reset_password_form_processing(context, request):
'Password reset successful', 'Password reset successful',
'Please Log In with your new password', 'Please Log In with your new password',
) )
if len(appstruct['new_password']) < MIN_PW_LENGTH:
request.flash(
'warning',
'You should really consider using a longer password.'
)
return HTTPFound(request.resource_url(request.root, 'account', 'login')) return HTTPFound(request.resource_url(request.root, 'account', 'login'))

44
ordr2/views/admin.py

@ -1,3 +1,5 @@
''' views for the admin section '''
import deform import deform
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
@ -37,7 +39,7 @@ def admin_section(context, request):
return {} return {}
# user list and user editing # user list
@view_config( @view_config(
context='ordr2:resources.UserList', context='ordr2:resources.UserList',
@ -71,6 +73,7 @@ def change_column_view(context, request):
request_method='POST' request_method='POST'
) )
def user_search(context, request): def user_search(context, request):
''' process the search user form '''
term = request.POST.get('search', '') term = request.POST.get('search', '')
term = term.strip() term = term.strip()
if term: if term:
@ -87,6 +90,7 @@ def user_search(context, request):
renderer='ordr2:templates/admin/users_delete.jinja2' renderer='ordr2:templates/admin/users_delete.jinja2'
) )
def delete_multiple_accounts_form(context, request): def delete_multiple_accounts_form(context, request):
''' show confirmation page for deleting users '''
account_ids = [v for k, v in request.POST.items() if k == 'marked'] account_ids = [v for k, v in request.POST.items() if k == 'marked']
accounts = request.dbsession.\ accounts = request.dbsession.\
query(User).\ query(User).\
@ -107,6 +111,7 @@ def delete_multiple_accounts_form(context, request):
renderer='ordr2:templates/admin/users_change_roles.jinja2' renderer='ordr2:templates/admin/users_change_roles.jinja2'
) )
def edit_multiple_roles_form(context, request): def edit_multiple_roles_form(context, request):
''' show form for editing multiple user roles '''
account_ids = [v for k, v in request.POST.items() if k == 'marked'] account_ids = [v for k, v in request.POST.items() if k == 'marked']
accounts = request.dbsession.\ accounts = request.dbsession.\
query(User).\ query(User).\
@ -126,6 +131,7 @@ def edit_multiple_roles_form(context, request):
request_method='POST' request_method='POST'
) )
def edit_multiple_roles_form_processing(context, request): def edit_multiple_roles_form_processing(context, request):
''' form processing for editing multiple user roles '''
if 'change' in request.POST: if 'change' in request.POST:
count = 0 count = 0
@ -155,6 +161,8 @@ def edit_multiple_roles_form_processing(context, request):
return HTTPFound(context.url()) return HTTPFound(context.url())
# editing one user account
@view_config( @view_config(
context='ordr2:resources.UserAccount', context='ordr2:resources.UserAccount',
permission='edit', permission='edit',
@ -186,9 +194,19 @@ def user_account_form_processing(context, request):
form = UserSchema.as_form(request) form = UserSchema.as_form(request)
data = request.POST.items() data = request.POST.items()
if 'delete' in request.POST: if 'delete' in request.POST:
# redirect to delete user confirmation page
return HTTPFound(request.resource_url(context, 'delete')) return HTTPFound(request.resource_url(context, 'delete'))
elif 'reset' in request.POST:
# create a password reset token and notify user
token = context.model.generate_password_token()
event = PasswordReset(request, context.model, token)
request.registry.notify(event)
msg = 'Password reset mail sent to {}.'.format(context.model.email)
request.flash('success', msg)
elif 'save' in request.POST: elif 'save' in request.POST:
try: try:
appstruct = form.validate(data) appstruct = form.validate(data)
@ -217,16 +235,6 @@ def user_account_form_processing(context, request):
) )
request.flash('success', msg, text) request.flash('success', msg, text)
elif 'reset' in request.POST:
token = context.model.generate_password_token()
event = PasswordReset(request, context.model, token)
request.registry.notify(event)
msg = 'Password reset mail sent to {}.'.format(context.model.email)
request.flash('success', msg)
elif 'delete' in request.POST:
return HTTPFound(context, 'delete')
return HTTPFound(context.__parent__.url()) return HTTPFound(context.__parent__.url())
@ -238,6 +246,7 @@ def user_account_form_processing(context, request):
renderer='ordr2:templates/admin/users_delete.jinja2' renderer='ordr2:templates/admin/users_delete.jinja2'
) )
def user_delete_form(context, request): def user_delete_form(context, request):
''' delete user confirmation page '''
return {'accounts': [context.model]} return {'accounts': [context.model]}
@ -254,6 +263,7 @@ def user_delete_form(context, request):
request_method='POST' request_method='POST'
) )
def user_delete_form_processing(context, request): def user_delete_form_processing(context, request):
''' delete one or multiple users after confirmation '''
if 'delete' in request.POST: if 'delete' in request.POST:
account_ids = [v for k, v in request.POST.items() if k == 'account'] account_ids = [v for k, v in request.POST.items() if k == 'account']
accounts = request.dbsession.\ accounts = request.dbsession.\
@ -272,7 +282,7 @@ def user_delete_form_processing(context, request):
return HTTPFound(request.resource_url(request.root, 'admin', 'users')) return HTTPFound(request.resource_url(request.root, 'admin', 'users'))
# consumables # consumables list
@view_config( @view_config(
context='ordr2:resources.ConsumableList', context='ordr2:resources.ConsumableList',
@ -294,6 +304,7 @@ def consumable_list(context, request):
request_method='POST' request_method='POST'
) )
def consumable_search(context, request): def consumable_search(context, request):
''' process the search consumable form '''
term = request.POST.get('search', '') term = request.POST.get('search', '')
term = term.strip() term = term.strip()
if term: if term:
@ -301,6 +312,8 @@ def consumable_search(context, request):
return HTTPFound(context.url()) return HTTPFound(context.url())
# adding a consumable
@view_config( @view_config(
context='ordr2:resources.ConsumableList', context='ordr2:resources.ConsumableList',
name='new', name='new',
@ -351,6 +364,8 @@ def consumable_new_form_processing(context, request):
return HTTPFound(context.url()) return HTTPFound(context.url())
# edit a consumable
@view_config( @view_config(
context='ordr2:resources.ConsumableResource', context='ordr2:resources.ConsumableResource',
permission='edit', permission='edit',
@ -407,11 +422,14 @@ def consumable_edit_form_processing(context, request):
request.flash('success', msg) request.flash('success', msg)
elif 'delete' in request.POST and context.model: elif 'delete' in request.POST and context.model:
# redirect to delete consumable confirmation page
return HTTPFound(request.resource_url(context, 'delete')) return HTTPFound(request.resource_url(context, 'delete'))
return HTTPFound(context.__parent__.url()) return HTTPFound(context.__parent__.url())
# delete consumable
@view_config( @view_config(
context='ordr2:resources.ConsumableResource', context='ordr2:resources.ConsumableResource',
name='delete', name='delete',
@ -420,6 +438,7 @@ def consumable_edit_form_processing(context, request):
renderer='ordr2:templates/admin/consumable_delete.jinja2' renderer='ordr2:templates/admin/consumable_delete.jinja2'
) )
def consumable_delete_form(context, request): def consumable_delete_form(context, request):
''' delete consumable confirmation page '''
return {'consumables': [context.model]} return {'consumables': [context.model]}
@ -430,6 +449,7 @@ def consumable_delete_form(context, request):
request_method='POST' request_method='POST'
) )
def consumable_delete_form_processing(context, request): def consumable_delete_form_processing(context, request):
''' delete consumable after confirmation '''
if 'delete' in request.POST: if 'delete' in request.POST:
c_ids = [v for k, v in request.POST.items() if k == 'consumable'] c_ids = [v for k, v in request.POST.items() if k == 'consumable']
consumables = request.dbsession.\ consumables = request.dbsession.\

42
ordr2/views/orders.py

@ -1,9 +1,11 @@
''' views for creating and editing orders '''
import deform import deform
import io import io
import xlsxwriter import xlsxwriter
from datetime import datetime
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from pyramid.renderers import render from pyramid.renderers import render
@ -20,6 +22,7 @@ from . import update_column_display
# helper method # helper method
def change_in_order_status(request, order, old): def change_in_order_status(request, order, old):
''' notifies a user if a noteworthy change in a order occured '''
noteworthy = False noteworthy = False
if old != OrderStatus.ORDERED and order.status == OrderStatus.ORDERED: if old != OrderStatus.ORDERED and order.status == OrderStatus.ORDERED:
noteworthy = True noteworthy = True
@ -35,7 +38,7 @@ def change_in_order_status(request, order, old):
request.registry.notify(event) request.registry.notify(event)
# oder list and multiple editing # oder list
@view_config( @view_config(
context='ordr2:resources.OrderList', context='ordr2:resources.OrderList',
@ -70,7 +73,9 @@ def change_column_view(context, request):
renderer='ordr2:templates/orders/edit_multiple_stati.jinja2' renderer='ordr2:templates/orders/edit_multiple_stati.jinja2'
) )
def download_view(context, request): def download_view(context, request):
''' see https://xlsxwriter.readthedocs.io/example_http_server3.html ''' ''' downloads the displayed order list as an excel file
see https://xlsxwriter.readthedocs.io/example_http_server3.html '''
# Create an in-memory output file for the new workbook. # Create an in-memory output file for the new workbook.
output = io.BytesIO() output = io.BytesIO()
# Even though the final file will be in memory the module uses temp # Even though the final file will be in memory the module uses temp
@ -134,6 +139,7 @@ def download_view(context, request):
request_method='POST' request_method='POST'
) )
def search(context, request): def search(context, request):
''' process the search order form '''
term = request.POST.get('search', '') term = request.POST.get('search', '')
term = term.strip() term = term.strip()
if term: if term:
@ -150,6 +156,7 @@ def search(context, request):
renderer='ordr2:templates/orders/edit_multiple_stati.jinja2' renderer='ordr2:templates/orders/edit_multiple_stati.jinja2'
) )
def edit_multiple_stati_form(context, request): def edit_multiple_stati_form(context, request):
''' form for editing the stati of multiple orders '''
order_ids = [v for k, v in request.POST.items() if k == 'marked'] order_ids = [v for k, v in request.POST.items() if k == 'marked']
orders = request.dbsession.\ orders = request.dbsession.\
query(Order).\ query(Order).\
@ -169,6 +176,7 @@ def edit_multiple_stati_form(context, request):
request_method='POST' request_method='POST'
) )
def edit_multiple_stati_form_processing(context, request): def edit_multiple_stati_form_processing(context, request):
''' change the stati of multiple orders '''
if 'change' in request.POST: if 'change' in request.POST:
count = 0 count = 0
@ -205,6 +213,7 @@ def edit_multiple_stati_form_processing(context, request):
renderer='ordr2:templates/orders/delete.jinja2' renderer='ordr2:templates/orders/delete.jinja2'
) )
def delete_multiple_orders_form(context, request): def delete_multiple_orders_form(context, request):
''' show confirmation page for deleting multiple orders '''
order_ids = [v for k, v in request.POST.items() if k == 'marked'] order_ids = [v for k, v in request.POST.items() if k == 'marked']
orders = request.dbsession.\ orders = request.dbsession.\
query(Order).\ query(Order).\
@ -229,6 +238,7 @@ def delete_multiple_orders_form(context, request):
request_method='POST' request_method='POST'
) )
def order_delete_form_processing(context, request): def order_delete_form_processing(context, request):
''' delete one or multiple orders after confirmation '''
if 'delete' in request.POST: if 'delete' in request.POST:
order_ids = [v for k, v in request.POST.items() if k == 'order'] order_ids = [v for k, v in request.POST.items() if k == 'order']
orders = request.dbsession.\ orders = request.dbsession.\
@ -247,7 +257,7 @@ def order_delete_form_processing(context, request):
return HTTPFound(request.resource_url(request.root, 'orders')) return HTTPFound(request.resource_url(request.root, 'orders'))
# single order processing # Single Order views
@view_config( @view_config(
context='ordr2:resources.OrderResource', context='ordr2:resources.OrderResource',
@ -256,8 +266,10 @@ def order_delete_form_processing(context, request):
renderer='ordr2:templates/orders/view.jinja2' renderer='ordr2:templates/orders/view.jinja2'
) )
def order_view(context, request): def order_view(context, request):
''' show the order information '''
return {} return {}
@view_config( @view_config(
context='ordr2:resources.OrderResource', context='ordr2:resources.OrderResource',
name='delete', name='delete',
@ -266,6 +278,7 @@ def order_view(context, request):
renderer='ordr2:templates/orders/delete.jinja2' renderer='ordr2:templates/orders/delete.jinja2'
) )
def order_delete_form(context, request): def order_delete_form(context, request):
''' show the confirmation page for deleting one order '''
return {'orders': [context.model]} return {'orders': [context.model]}
@ -277,6 +290,7 @@ def order_delete_form(context, request):
renderer='ordr2:templates/orders/edit.jinja2' renderer='ordr2:templates/orders/edit.jinja2'
) )
def order_edit_form(context, request): def order_edit_form(context, request):
''' show the edit order form '''
form = EditOrderSchema.as_form(request) form = EditOrderSchema.as_form(request)
order = context.model order = context.model
info = { info = {
@ -323,7 +337,7 @@ def order_edit_form(context, request):
renderer='ordr2:templates/orders/edit.jinja2' renderer='ordr2:templates/orders/edit.jinja2'
) )
def order_edit_form_processing(context, request): def order_edit_form_processing(context, request):
''' process the consumable edit form ''' ''' process the edit order form '''
form = EditOrderSchema.as_form(request) form = EditOrderSchema.as_form(request)
data = request.POST.items() data = request.POST.items()
@ -375,9 +389,11 @@ def order_edit_form_processing(context, request):
request.flash('success', msg) request.flash('success', msg)
elif 'delete' in request.POST and context.model: elif 'delete' in request.POST and context.model:
# redirect to delete order confirmation page
return HTTPFound(request.resource_url(context, 'delete')) return HTTPFound(request.resource_url(context, 'delete'))
elif 'reorder' in request.POST and context.model: elif 'reorder' in request.POST and context.model:
# redirect to create new order form
return HTTPFound( return HTTPFound(
request.resource_url( request.resource_url(
context.__parent__, context.__parent__,
@ -397,8 +413,11 @@ def order_edit_form_processing(context, request):
renderer='ordr2:templates/orders/new.jinja2' renderer='ordr2:templates/orders/new.jinja2'
) )
def order_new_form(context, request): def order_new_form(context, request):
''' create a new order '''
form = NewOrderSchema.as_form(request) form = NewOrderSchema.as_form(request)
# check if the form should be prefilled with some data,
# either from selecting a consumable or from reordering an item
consumable_id = request.GET.get('consumable', None) consumable_id = request.GET.get('consumable', None)
order_id = request.GET.get('reorder', None) order_id = request.GET.get('reorder', None)
prefill = None prefill = None
@ -408,7 +427,9 @@ def order_new_form(context, request):
elif consumable_id: elif consumable_id:
prefill = request.dbsession.query(Consumable).get(consumable_id) prefill = request.dbsession.query(Consumable).get(consumable_id)
# prefill the form data
if prefill: if prefill:
# some fields depend on if the prefill was a reorder or consumable
quantity = prefill.amount if order_id else 1 quantity = prefill.amount if order_id else 1
total_price = prefill.total_price if order_id else prefill.unit_price total_price = prefill.total_price if order_id else prefill.unit_price
account = prefill.account if order_id else '' account = prefill.account if order_id else ''
@ -453,6 +474,7 @@ def order_new_form(context, request):
renderer='ordr2:templates/orders/splash.jinja2' renderer='ordr2:templates/orders/splash.jinja2'
) )
def order_splash(context, request): def order_splash(context, request):
''' splash screen for a new order where consumables can be selected '''
structured = OrderedDict() structured = OrderedDict()
for cat in Category: for cat in Category:
structured[cat] = [] structured[cat] = []
@ -475,6 +497,7 @@ def order_splash(context, request):
renderer='ordr2:templates/orders/splash.jinja2' renderer='ordr2:templates/orders/splash.jinja2'
) )
def order_splash_processing(context, request): def order_splash_processing(context, request):
''' process the splash screen selection '''
name = request.POST.get('search') name = request.POST.get('search')
consumable = request.dbsession.\ consumable = request.dbsession.\
query(Consumable).\ query(Consumable).\
@ -501,7 +524,7 @@ def order_splash_processing(context, request):
renderer='ordr2:templates/orders/new.jinja2' renderer='ordr2:templates/orders/new.jinja2'
) )
def order_new_form_processing(context, request): def order_new_form_processing(context, request):
''' process the consumable edit form ''' ''' process the new order form '''
form = NewOrderSchema.as_form(request) form = NewOrderSchema.as_form(request)
data = request.POST.items() data = request.POST.items()
@ -543,10 +566,3 @@ def order_new_form_processing(context, request):
return HTTPFound(context.url()) return HTTPFound(context.url())

2
ordr2/views/pages.py

@ -1,3 +1,5 @@
''' views for static pages '''
from pyramid.httpexceptions import HTTPFound from pyramid.httpexceptions import HTTPFound
from pyramid.view import view_config from pyramid.view import view_config