diff --git a/ordr2/__init__.py b/ordr2/__init__.py index f7670f7..37c13d2 100644 --- a/ordr2/__init__.py +++ b/ordr2/__init__.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - ''' Top-level package for Ordr2. ''' __author__ = 'Holger Frey' diff --git a/ordr2/events.py b/ordr2/events.py index 3609b8f..1396096 100644 --- a/ordr2/events.py +++ b/ordr2/events.py @@ -1,3 +1,5 @@ +''' custom events and event subsribers ''' + from pyramid.events import NewRequest, subscriber from pyramid.renderers import render from pyramid_mailer.message import Message @@ -5,14 +7,19 @@ from pyramid_mailer.message import Message from ordr2.views import set_display_defaults +# custom events + class UserLogIn(object): + ''' notify on user log in ''' def __init__(self, request, user): self.request = request self.user = user class UserNotification(object): + ''' base class for user notifications ''' + subject = None template = None def __init__(self, request, user, data=None): @@ -22,32 +29,40 @@ class UserNotification(object): class AccountActivation(UserNotification): + ''' user notification for account activation ''' subject='[ordr] Your account was activated' template = 'ordr2:templates/emails/activation.jinja2' class PasswordReset(UserNotification): + ''' user notification for password reset link ''' subject='[ordr] Password Reset' template = 'ordr2:templates/emails/password_reset.jinja2' class OrderStatusChange(UserNotification): + ''' user notification for order status change ''' subject='[ordr] Order Status Change' template = 'ordr2:templates/emails/order.jinja2' +# subsribers for events + @subscriber(UserLogIn) def set_display_defaults_on_log_in(event): + ''' set column display defaults at every login ''' set_display_defaults(event.request) @subscriber(NewRequest) 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: set_display_defaults(event.request) @subscriber(UserNotification) def notify_user(event): + ''' notify a user about an event ''' body = render( event.template, {'user': event.user, 'data': event.data}, diff --git a/ordr2/models/__init__.py b/ordr2/models/__init__.py index cdc4478..0225288 100644 --- a/ordr2/models/__init__.py +++ b/ordr2/models/__init__.py @@ -1,3 +1,5 @@ +''' Database models and setup ''' + from sqlalchemy import engine_from_config from sqlalchemy.orm import sessionmaker 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 # Base.metadata prior to any initialization routines 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 # all relationships can be setup @@ -14,18 +16,19 @@ configure_mappers() def get_engine(settings, prefix='sqlalchemy.'): + ''' returns a sqlalchemy engine from the application configuration ''' return engine_from_config(settings, prefix) def get_session_factory(engine): + ''' returns a database session ''' factory = sessionmaker() factory.configure(bind=engine) return factory 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 will take care of committing any changes. diff --git a/ordr2/models/meta.py b/ordr2/models/meta.py index 327b219..36114fc 100644 --- a/ordr2/models/meta.py +++ b/ordr2/models/meta.py @@ -1,3 +1,5 @@ +''' sqlalchemy metadata configuration ''' + from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.schema import MetaData diff --git a/ordr2/models/orders.py b/ordr2/models/orders.py index 9003107..1d21938 100644 --- a/ordr2/models/orders.py +++ b/ordr2/models/orders.py @@ -1,3 +1,5 @@ +''' Consumables, Categories, Orders and Order Status Database Models ''' + import bcrypt import enum import uuid @@ -99,26 +101,32 @@ class Order(Base): def _date_info(self, some_date, some_one): + ''' string representaton of date and user ''' if not some_date: + # no date, no string return '' if some_one: + # in the new system a status change also stores the purchaser return '{!s} by {!s}'.format(some_date, some_one) - else: - return '{!s}'.format(some_date) - + # historical data does not have a purchaser associated with a date + return '{!s}'.format(some_date) @property def placed(self): + ''' string representation for placed on / by ''' return self._date_info(self.created_date, self.created_by) @property def approved(self): + ''' string representation for approval on / by ''' return self._date_info(self.approval_date, self.approval_by) @property def ordered(self): + ''' string representation for ordered on / by ''' return self._date_info(self.ordered_date, self.ordered_by) @property def completed(self): + ''' string representation for completed on / by ''' return self._date_info(self.completed_date, self.completed_by) diff --git a/ordr2/models/user.py b/ordr2/models/user.py index 1f29d7b..43abfd1 100644 --- a/ordr2/models/user.py +++ b/ordr2/models/user.py @@ -1,3 +1,5 @@ +''' User Account and Roles Models ''' + import bcrypt import enum import uuid @@ -16,7 +18,8 @@ from .meta import Base class Role(enum.Enum): - ''' roles of the user ''' + ''' roles of user accounts ''' + NEW = 'new' USER = 'user' PURCHASER = 'purchaser' @@ -25,7 +28,8 @@ class Role(enum.Enum): @property def principal(self): - return 'role:' + self.value + ''' returns the principal identifier of the role ''' + return 'role:' + self.value.lower() class User(Base): @@ -54,8 +58,10 @@ class User(Base): ''' returns the principal identifiers for the user's role ''' principals = [self.role.principal] if self.role is Role.PURCHASER: + # a purchaser is also a user principals.append(Role.USER.principal) elif self.role is Role.ADMIN: + # an admin is also a purchaser and a user principals.append(Role.USER.principal) principals.append(Role.PURCHASER.principal) return principals @@ -78,6 +84,7 @@ class User(Base): return False def generate_password_token(self): + ''' generates a token for a password reset link ''' token = uuid.uuid4() self.password_reset = token.hex return token.hex diff --git a/ordr2/resources/__init__.py b/ordr2/resources/__init__.py index 601a6f5..cda9c01 100644 --- a/ordr2/resources/__init__.py +++ b/ordr2/resources/__init__.py @@ -1,3 +1,5 @@ +''' base resource and resource root factory ''' + from pyramid.security import Allow, Everyone from .account import Account, PasswordResetAccount @@ -28,6 +30,7 @@ class Root(BaseResource): self.request = request def __acl__(self): + ''' access controll list ''' return [ (Allow, Everyone, 'view') ] diff --git a/ordr2/resources/account.py b/ordr2/resources/account.py index 718d6e4..1ee27b5 100644 --- a/ordr2/resources/account.py +++ b/ordr2/resources/account.py @@ -1,3 +1,5 @@ +''' Resources for User Accounts ''' + from pyramid.security import Allow, Authenticated, Deny, DENY_ALL, Everyone from ordr2.models import User @@ -5,10 +7,11 @@ from ordr2.models import User from .base import BaseResource - class PasswordResetAccount(BaseResource): + ''' resource for passwort change representing a reset token ''' def __acl__(self): + ''' access controll list ''' return [ (Allow, Everyone, 'reset'), DENY_ALL @@ -16,14 +19,17 @@ class PasswordResetAccount(BaseResource): class PasswordReset(BaseResource): + ''' resource for passwort reset link ''' def __acl__(self): + ''' access controll list ''' return [ (Allow, Everyone, 'reset'), DENY_ALL ] def __getitem__(self, key): + ''' queries the database for a password reset token ''' key = key.strip() if key: account = self.request.dbsession.\ @@ -36,6 +42,7 @@ class PasswordReset(BaseResource): class Account(BaseResource): + ''' User Account and Settings ''' nodes = {'reset': PasswordReset} @@ -45,6 +52,7 @@ class Account(BaseResource): def __acl__(self): + ''' access controll list ''' return [ (Allow, Everyone, 'login'), (Allow, Everyone, 'logout'), diff --git a/ordr2/resources/admin.py b/ordr2/resources/admin.py index 1b79ee2..bd1b177 100644 --- a/ordr2/resources/admin.py +++ b/ordr2/resources/admin.py @@ -1,3 +1,5 @@ +''' Resources for the Admin Section ''' + from sqlalchemy import or_ 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 class UserAccount(BaseResource): + ''' Resource for a user account ''' def __acl__(self): + ''' Access Controll List ''' return [ (Allow, 'role:admin', 'view'), (Allow, 'role:admin', 'edit'), @@ -19,12 +23,15 @@ class UserAccount(BaseResource): class UserList(BaseResource, PaginationResourceMixin): + ''' Resource for a list of users ''' + sql_model_class = User child_resource_class = UserAccount default_sorting = 'user.asc' default_items_per_page = 12 def __acl__(self): + ''' Access Controll List ''' return [ (Allow, 'role:admin', 'view'), (Allow, 'role:admin', 'edit'), @@ -32,11 +39,11 @@ class UserList(BaseResource, PaginationResourceMixin): DENY_ALL ] - def prepare_filtered_query(self, dbsession, filter_params): ''' setup the base filtered query ''' query = dbsession.query(self.sql_model_class) + # filter by role role_name = filter_params.get('role', None) try: role_name = role_name.lower() @@ -46,6 +53,7 @@ class UserList(BaseResource, PaginationResourceMixin): role_name = None self.filters['role'] = role_name + # filter by search term search = filter_params.get('search', None) if search: term = '%{}%'.format(search) @@ -61,9 +69,8 @@ class UserList(BaseResource, PaginationResourceMixin): return query - def prepare_sorted_query(self, query, sorting): - ''' setup the base filtered query ''' + ''' add sorting to the base query ''' available_fields = { 'user': 'user_name', 'first': 'first_name', @@ -76,10 +83,13 @@ class UserList(BaseResource, PaginationResourceMixin): if model_field: sort_func = sorting.func(model_field) query = query.order_by(sort_func) + + # add default sorting default_sort = self.parse_sort_parameters(self.default_sorting) if sorting.field != default_sort.field: default_sort = self.parse_sort_parameters(self.default_sorting) query = self.prepare_sorted_query(query, default_sort) + return query @@ -87,7 +97,9 @@ class UserList(BaseResource, PaginationResourceMixin): # consumables resources class ConsumableResource(BaseResource): + ''' Resource for one consumable ''' def __acl__(self): + ''' Access Controll List ''' return [ (Allow, 'role:admin', 'view'), (Allow, 'role:admin', 'edit'), @@ -97,12 +109,15 @@ class ConsumableResource(BaseResource): class ConsumableList(BaseResource, PaginationResourceMixin): + ''' Resource for a list of consumables ''' + sql_model_class = Consumable child_resource_class = ConsumableResource default_sorting = 'cas.asc' default_items_per_page = 12 def __acl__(self): + ''' Access Controll List ''' return [ (Allow, 'role:admin', 'view'), (Allow, 'role:admin', 'create'), @@ -116,6 +131,7 @@ class ConsumableList(BaseResource, PaginationResourceMixin): ''' setup the base filtered query ''' query = dbsession.query(self.sql_model_class) + # filter by category category_name = filter_params.get('category', None) try: category_name = category_name.lower() @@ -125,6 +141,7 @@ class ConsumableList(BaseResource, PaginationResourceMixin): category_name = None self.filters['category'] = category_name + # filter by search term search = filter_params.get('search', None) if search: term = '%{}%'.format(search) @@ -139,9 +156,8 @@ class ConsumableList(BaseResource, PaginationResourceMixin): return query - def prepare_sorted_query(self, query, sorting): - ''' setup the base filtered query ''' + ''' add sorting to the base query ''' available_fields = { 'cas': 'cas_description', 'category': 'category', @@ -156,14 +172,18 @@ class ConsumableList(BaseResource, PaginationResourceMixin): if model_field: sort_func = sorting.func(model_field) query = query.order_by(sort_func) + + # add default sorting default_sort = self.parse_sort_parameters(self.default_sorting) if sorting.field != default_sort.field: default_sort = self.parse_sort_parameters(self.default_sorting) query = self.prepare_sorted_query(query, default_sort) + return query class Admin(BaseResource): + ''' Resource for the admin section ''' nodes = { 'users': UserList, @@ -171,4 +191,5 @@ class Admin(BaseResource): } def __acl__(self): + ''' Access Controll List ''' return [ (Allow, 'role:admin', 'view') ] diff --git a/ordr2/resources/base.py b/ordr2/resources/base.py index b25ea29..a5c7e13 100644 --- a/ordr2/resources/base.py +++ b/ordr2/resources/base.py @@ -1,11 +1,14 @@ +''' Base Resource and Mixin classes, not to be used directly ''' + from collections import namedtuple -from pyramid.security import DENY_ALL +from pyramid.security import DENY_ALL from sqlalchemy import asc, desc from sqlalchemy.inspection import inspect class BaseResource(object): + ''' Base Resource for all other resources ''' __parent__ = None __name__ = None @@ -26,10 +29,12 @@ class BaseResource(object): super().__init__() def __acl__(self): + ''' Access controll list ''' return [ DENY_ALL ] def __getitem__(self, key): + ''' returns child resources ''' klass = self.nodes.get(key, None) if klass: return klass(key, self) @@ -52,19 +57,45 @@ class BaseResource(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_window_size = 7 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) count = self._ensure_int(count, 0) items = self._ensure_int(items, self.default_items) window_size = self._ensure_int(window_size, self.default_window_size) + # set the simples values that won't change self.count = count self.items = self.items_per_page = items + # calculate number of pages pages = (count - 1) // items + 1 self.first = 1 self.last = max(self.first, pages) @@ -88,33 +119,57 @@ class Pagination(object): return default def _ensure_int(self, value, default): + ''' converts the value to integer, returns default if it fails ''' try: return int(value) except Exception: return default +# named tuple for parameters used in sorting SortParameter = namedtuple('SortParameter', 'text field direction func') 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 child_resource_class = None default_sorting = None default_items_per_page = 25 + # attributes set by processing request.GET pages = None sorting = None filters = {} + # keys for request.GET processing query_key_current_page = 'p' query_key_items_per_page = 'n' query_key_sorting = 'o' + # base sqlalchemy query object _base_query = None + def __init__(self): + ''' sets parameters from request.GET ''' # first we need to remove non-filter parameters from GET params = dict(self.request.GET) page = params.pop(self.query_key_current_page, 1) @@ -145,13 +200,16 @@ class PaginationResourceMixin(object): ''' setup the base filtered query An example: + def prepare_filtered_query(self, dbsession, filter_params): query = dbsession.query(self.sql_model_class) + by_username = filter_params.get('username', None) if by_username is not None: query = query.filter_by(username=by_username) # don't forget to remember the filter self.filters['username'] = by_username + return query ''' msg = 'Query setup must be implemented in child class' @@ -159,9 +217,10 @@ class PaginationResourceMixin(object): def prepare_sorted_query(self, query, sorting): - ''' setup the base filtered query + ''' add sorting to the base query An example: + def prepare_sorted_query(self, query, sorting): model_field = getattr(self.sql_model_class, sorting.field) sort_func = sorting.func(model_field) @@ -172,6 +231,7 @@ class PaginationResourceMixin(object): def parse_sort_parameters(self, sort_param): + ''' parses a string that might contain sorting information ''' sort_functions = { 'asc': asc, 'desc': desc} try: sort_param = sort_param.lower() @@ -185,12 +245,16 @@ class PaginationResourceMixin(object): def items(self): ''' returns the items of the current page as resources''' if not self.pages.count: - return + return [] + # calculate the offset from the paging information 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 = query.offset(offset).limit(self.pages.items_per_page) + + # return a list of resources representing the items found by the query return [ self.child_resource_class.from_sqla(item, self) for item @@ -199,6 +263,14 @@ class PaginationResourceMixin(object): 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 = { self.query_key_current_page: self.pages.current, self.query_key_items_per_page: self.pages.items, @@ -207,16 +279,23 @@ class PaginationResourceMixin(object): params.update(self.filters) params.update(args) 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} return filtered 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) return self.request.resource_url(self, query=params) def __getitem__(self, key): + ''' returns a child resource representing a sqlalchemy model ''' model = self.request.dbsession.query(self.sql_model_class).get(key) if not model: raise KeyError() diff --git a/ordr2/resources/orders.py b/ordr2/resources/orders.py index a41998d..1ae7b79 100644 --- a/ordr2/resources/orders.py +++ b/ordr2/resources/orders.py @@ -8,27 +8,38 @@ from ordr2.models import Category, Order, OrderStatus class OrderResource(BaseResource): + ''' Resource representing one order ''' + def __acl__(self): + ''' Access controll list ''' acl = [ (Allow, 'role:user', 'view'), (Allow, 'role:user', 'create'), (Allow, 'role:purchaser', 'edit'), (Allow, 'role:purchaser', 'delete'), ] + # open orders may be edited and deleted by the user that placed them if self.model.status == OrderStatus.OPEN: - acl.append( (Allow, 'user:' + str(self.model.created_by), 'edit') ) - acl.append( (Allow, 'user:' + str(self.model.created_by), 'delete') ) + acl.append( + (Allow, 'user:' + str(self.model.created_by), 'edit') + ) + acl.append( + (Allow, 'user:' + str(self.model.created_by), 'delete') + ) acl.append(DENY_ALL) return acl class OrderList(BaseResource, PaginationResourceMixin): + ''' Resource representing a list of orders ''' + sql_model_class = Order child_resource_class = OrderResource default_sorting = 'created.desc' default_items_per_page = 12 def __acl__(self): + ''' Access controll list ''' return [ (Allow, 'role:user', 'view'), (Allow, 'role:user', 'create'), @@ -37,13 +48,13 @@ class OrderList(BaseResource, PaginationResourceMixin): DENY_ALL ] - def prepare_filtered_query(self, dbsession, filter_params): ''' setup the base filtered query ''' query = dbsession.query(self.sql_model_class) + # filter by status + status_name = filter_params.get('status', None) try: - status_name = filter_params.get('status', None) status_name = status_name.lower() status = OrderStatus(status_name) query = query.filter_by(status=status) @@ -51,11 +62,13 @@ class OrderList(BaseResource, PaginationResourceMixin): status_name = None self.filters['status'] = status_name + # filter by user user_name = filter_params.get('user', None) if user_name: query = query.filter_by(created_by=user_name) self.filters['user'] = user_name + # filter by search term search = filter_params.get('search', None) if search: term = '%{}%'.format(search) @@ -72,9 +85,8 @@ class OrderList(BaseResource, PaginationResourceMixin): return query - def prepare_sorted_query(self, query, sorting): - ''' setup the base filtered query ''' + ''' add sorting to the base query ''' available_fields = { 'cas': 'cas_description', 'category': 'category', @@ -92,8 +104,11 @@ class OrderList(BaseResource, PaginationResourceMixin): if model_field: sort_func = sorting.func(model_field) query = query.order_by(sort_func) + + # add default sorting default_sort = self.parse_sort_parameters(self.default_sorting) if sorting.field != default_sort.field: default_sort = self.parse_sort_parameters(self.default_sorting) query = self.prepare_sorted_query(query, default_sort) + return query diff --git a/ordr2/schemas/__init__.py b/ordr2/schemas/__init__.py index af78273..bf8bfa4 100644 --- a/ordr2/schemas/__init__.py +++ b/ordr2/schemas/__init__.py @@ -1,16 +1,19 @@ +''' Schemas for form input and validation ''' + import colander import deform +from deform.renderer import configure_zpt_renderer + from .helpers import ( deferred_csrf_default, deferred_csrf_validator ) -from deform.renderer import configure_zpt_renderer - # Make Deform widgets aware of our widget template paths configure_zpt_renderer(['ordr2:templates/deform']) + # Base Schema class CSRFSchema(colander.Schema): @@ -25,7 +28,8 @@ class CSRFSchema(colander.Schema): @classmethod 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: url = request.resource_url(request.context, request.view_name) schema = cls().bind(request=request) @@ -35,6 +39,7 @@ class CSRFSchema(colander.Schema): class MoneyInputSchema(colander.Schema): + ''' custom schema for structured money and currency input ''' amount = colander.SchemaNode( colander.Decimal(), @@ -53,6 +58,7 @@ class MoneyInputSchema(colander.Schema): ) def __init__(self, *args, **kwargs): + ''' define the custom schema templates ''' if 'widget' not in kwargs: readonly = kwargs.pop('readonly', False) kwargs['widget'] = deform.widget.MappingWidget( diff --git a/ordr2/schemas/orders.py b/ordr2/schemas/orders.py index be22613..d465b08 100644 --- a/ordr2/schemas/orders.py +++ b/ordr2/schemas/orders.py @@ -1,3 +1,5 @@ +''' schemas for creating and editing orders and consumables''' + import colander import deform @@ -5,10 +7,12 @@ from ordr2.models import Category, OrderStatus from . import CSRFSchema, MoneyInputSchema + +# key / value pairs for select fields + CATEGORIES = [(c.name, c.value.capitalize()) for c in Category] STATI = [(s.name, s.value.capitalize()) for s in OrderStatus] -# schema for user registration class ConsumableSchema(CSRFSchema): ''' edit or add consumable ''' @@ -40,6 +44,8 @@ class ConsumableSchema(CSRFSchema): @classmethod 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) if is_new_consumable: settings = { @@ -67,6 +73,10 @@ class ConsumableSchema(CSRFSchema): class OrderInformation(colander.Schema): + ''' schema for editing order status + + parital schema, used in EditOrderSchema + ''' status = colander.SchemaNode( colander.String(), @@ -75,6 +85,10 @@ class OrderInformation(colander.Schema): class OrderItem(colander.Schema): + ''' schema for editing item information + + parital schema, used in NewOrderSchema and EditOrderSchema + ''' cas_description = colander.SchemaNode( colander.String() @@ -95,6 +109,10 @@ class OrderItem(colander.Schema): class OrderPricing(colander.Schema): + ''' schema for editing price information + + parital schema, used in NewOrderSchema and EditOrderSchema + ''' unit_price = MoneyInputSchema( readonly=False @@ -113,7 +131,10 @@ class OrderPricing(colander.Schema): class OrderOptionals(colander.Schema): + ''' schema for editing optional order information + parital schema, used in NewOrderSchema and EditOrderSchema + ''' account = colander.SchemaNode( colander.String(), missing='' @@ -126,7 +147,7 @@ class OrderOptionals(colander.Schema): class NewOrderSchema(CSRFSchema): - ''' edit or add an order ''' + ''' schema for a new order ''' item_information = OrderItem() pricing = OrderPricing() @@ -134,6 +155,7 @@ class NewOrderSchema(CSRFSchema): @classmethod def as_form(cls, request, **override): + ''' returns the schema as a form ''' settings = { 'buttons': ( deform.Button(name='save', title='Place Order'), @@ -146,7 +168,7 @@ class NewOrderSchema(CSRFSchema): class EditOrderSchema(CSRFSchema): - ''' edit or add an order ''' + ''' schema for editing an order ''' order_information = OrderInformation( widget=deform.widget.MappingWidget( @@ -159,6 +181,7 @@ class EditOrderSchema(CSRFSchema): @classmethod def as_form(cls, request, **override): + ''' returns the schema as a form ''' settings = { 'buttons': ( deform.Button(name='save', title='Edit Order'), @@ -178,6 +201,8 @@ class EditOrderSchema(CSRFSchema): } settings.update(override) 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: form['order_information']['status'].widget = \ deform.widget.TextInputWidget( diff --git a/ordr2/scripts/__init__.py b/ordr2/scripts/__init__.py index 5bb534f..e3233dc 100644 --- a/ordr2/scripts/__init__.py +++ b/ordr2/scripts/__init__.py @@ -1 +1 @@ -# package +''' command line scripts ''' diff --git a/ordr2/scripts/initializedb.py b/ordr2/scripts/initializedb.py index d9993a5..b961705 100644 --- a/ordr2/scripts/initializedb.py +++ b/ordr2/scripts/initializedb.py @@ -1,37 +1,36 @@ +''' initializes a new data base and migrates old data ''' + import os import sys import transaction - import yaml from datetime import datetime from tqdm import tqdm from urllib.parse import urlparse -from pyramid.paster import ( - get_appsettings, - setup_logging, - ) - +from pyramid.paster import get_appsettings, setup_logging from pyramid.scripts.common import parse_vars -from ..models.meta import Base -from ..models import ( +from ordr2.models.meta import Base +from ordr2.models import ( get_engine, get_session_factory, 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): + ''' display the command line help message ''' cmd = os.path.basename(argv[0]) print('usage: %s [var=value]\n' '(example: "%s development.ini")' % (cmd, cmd)) sys.exit(1) -def read_exported_file(*paths): +def read_yaml_file(*paths): + ''' read and parse a yaml file ''' path = os.path.join(*paths) with open(path, 'r') as filehandle: items = yaml.load(filehandle) @@ -39,12 +38,18 @@ def read_exported_file(*paths): 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'): return datetime.utcnow() if default is False else default return datetime.strptime(value + ' -0000', '%Y-%m-%d %H:%M:%S %z') def main(argv=sys.argv): + ''' setup the database and migrate the data ''' + # load the settings from the provided config file if len(argv) < 2: usage(argv) 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): os.remove(database_url.path) + # setup the database connection engine = get_engine(settings) Base.metadata.create_all(engine) - session_factory = get_session_factory(engine) with transaction.manager: dbsession = get_tm_session(session_factory, transaction.manager) - 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'): user = User( id=data['id'], @@ -83,8 +89,9 @@ def main(argv=sys.argv): dbsession.add(user) dbsession.flush() - cat_list = read_exported_file(base_dir, 'export_consumables.yml') - for data in tqdm(cat_list, desc='Consumables'): + # migrate consumables + con_list = read_yaml_file(base_dir, 'export_consumables.yml') + for data in tqdm(con_list, desc='Consumables'): category = Category[data['category'].upper()] date_created = parse_dt(data['date_created']) consumable = Consumable( @@ -103,8 +110,8 @@ def main(argv=sys.argv): dbsession.add(consumable) dbsession.flush() - - order_list = read_exported_file(base_dir, 'export_orders.yml') + # migrate orders + order_list = read_yaml_file(base_dir, 'export_orders.yml') for data in tqdm(order_list, desc='Orders'): work_status = data.get('work_status', 'approval') status = OrderStatus[work_status.upper()] diff --git a/ordr2/security.py b/ordr2/security.py index 88d0e97..3fe16aa 100644 --- a/ordr2/security.py +++ b/ordr2/security.py @@ -1,3 +1,5 @@ +''' User Authentication and Authorization ''' + from pyramid.authentication import AuthTktAuthenticationPolicy from pyramid.authorization import ACLAuthorizationPolicy from pyramid.security import Authenticated, Everyone @@ -10,7 +12,7 @@ class AuthenticationPolicy(AuthTktAuthenticationPolicy): def authenticated_userid(self, request): ''' returns the id of an authenticated user - + heavy lifting done in get_user() attached to request ''' user = request.user @@ -39,7 +41,10 @@ def get_user(request): 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() authn_policy = AuthenticationPolicy( settings['auth.secret'], diff --git a/ordr2/templates/tests.py b/ordr2/templates/tests.py index e353561..458fa09 100644 --- a/ordr2/templates/tests.py +++ b/ordr2/templates/tests.py @@ -1,4 +1,7 @@ +''' custom jinja2 tests and filters ''' + def are_extras_active(context, extras): + ''' checks if the filters are active in a PaginationResource ''' if extras is None: return True return all(context.filters.get(k) == v for k, v in extras.items()) diff --git a/ordr2/views/__init__.py b/ordr2/views/__init__.py index b3684d3..114da97 100644 --- a/ordr2/views/__init__.py +++ b/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') @@ -11,7 +16,7 @@ def flash(request, channel, message, description='', dismissable=True): 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']: return display_keys = request.session['display'][section].keys() @@ -23,6 +28,7 @@ def update_column_display(request, section): def set_display_defaults(request): + ''' sets the coulumn display default ''' defaults = { 'users': { 'first': True, @@ -43,7 +49,10 @@ def set_display_defaults(request): 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') settings = config.get_settings() diff --git a/ordr2/views/account.py b/ordr2/views/account.py index 8a3c862..b3bc369 100644 --- a/ordr2/views/account.py +++ b/ordr2/views/account.py @@ -1,3 +1,5 @@ +''' Account Registration and Settings ''' + import deform from pyramid.httpexceptions import HTTPFound @@ -13,6 +15,9 @@ from ordr2.schemas.account import ( SettingsSchema ) +# below this password length a warning is displayed +MIN_PW_LENGTH = 12 + # user log in and log out @view_config( @@ -90,7 +95,6 @@ def logout(context, request): ) def registration_form(context, request): ''' display a registration form ''' - context.nav_highlight = 'register' form = RegistrationSchema.as_form(request) return {'form': form} @@ -133,7 +137,7 @@ def registration_form_processing(context, request): 'Your account {} has been created.'.format(account.user_name), dismissable=False ) - if len(appstruct['password']) < 8: + if len(appstruct['password']) < MIN_PW_LENGTH: request.flash( 'warning', 'You should really consider using a longer password.', @@ -187,7 +191,7 @@ def settings_form(context, request): renderer='ordr2:templates/account/settings.jinja2' ) def settings_form_processing(context, request): - ''' display the user settings form ''' + ''' process the user settings form ''' if 'Cancel' in request.POST: return HTTPFound(request.resource_url(request.root)) @@ -205,7 +209,7 @@ def settings_form_processing(context, request): request.user.email = appstruct['general']['email'] if 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( 'warning', 'You should really consider using a longer password.' @@ -249,6 +253,7 @@ def reset_password_form_processing(context, request): except deform.ValidationFailure as e: return {'form': form} + # form validation sucessful, change password and remove reset token context.model.set_password(appstruct['new_password']) context.model.password_reset = '' @@ -257,6 +262,12 @@ def reset_password_form_processing(context, request): 'Password reset successful', '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')) diff --git a/ordr2/views/admin.py b/ordr2/views/admin.py index 268e66f..6723fdd 100644 --- a/ordr2/views/admin.py +++ b/ordr2/views/admin.py @@ -1,3 +1,5 @@ +''' views for the admin section ''' + import deform from pyramid.httpexceptions import HTTPFound @@ -37,7 +39,7 @@ def admin_section(context, request): return {} -# user list and user editing +# user list @view_config( context='ordr2:resources.UserList', @@ -71,6 +73,7 @@ def change_column_view(context, request): request_method='POST' ) def user_search(context, request): + ''' process the search user form ''' term = request.POST.get('search', '') term = term.strip() if term: @@ -87,6 +90,7 @@ def user_search(context, request): renderer='ordr2:templates/admin/users_delete.jinja2' ) 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'] accounts = request.dbsession.\ query(User).\ @@ -107,6 +111,7 @@ def delete_multiple_accounts_form(context, request): renderer='ordr2:templates/admin/users_change_roles.jinja2' ) 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'] accounts = request.dbsession.\ query(User).\ @@ -126,6 +131,7 @@ def edit_multiple_roles_form(context, request): request_method='POST' ) def edit_multiple_roles_form_processing(context, request): + ''' form processing for editing multiple user roles ''' if 'change' in request.POST: count = 0 @@ -155,6 +161,8 @@ def edit_multiple_roles_form_processing(context, request): return HTTPFound(context.url()) +# editing one user account + @view_config( context='ordr2:resources.UserAccount', permission='edit', @@ -186,9 +194,19 @@ def user_account_form_processing(context, request): form = UserSchema.as_form(request) data = request.POST.items() + if 'delete' in request.POST: + # redirect to delete user confirmation page 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: try: appstruct = form.validate(data) @@ -217,16 +235,6 @@ def user_account_form_processing(context, request): ) 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()) @@ -238,6 +246,7 @@ def user_account_form_processing(context, request): renderer='ordr2:templates/admin/users_delete.jinja2' ) def user_delete_form(context, request): + ''' delete user confirmation page ''' return {'accounts': [context.model]} @@ -254,6 +263,7 @@ def user_delete_form(context, request): request_method='POST' ) def user_delete_form_processing(context, request): + ''' delete one or multiple users after confirmation ''' if 'delete' in request.POST: account_ids = [v for k, v in request.POST.items() if k == 'account'] accounts = request.dbsession.\ @@ -272,7 +282,7 @@ def user_delete_form_processing(context, request): return HTTPFound(request.resource_url(request.root, 'admin', 'users')) -# consumables +# consumables list @view_config( context='ordr2:resources.ConsumableList', @@ -294,6 +304,7 @@ def consumable_list(context, request): request_method='POST' ) def consumable_search(context, request): + ''' process the search consumable form ''' term = request.POST.get('search', '') term = term.strip() if term: @@ -301,6 +312,8 @@ def consumable_search(context, request): return HTTPFound(context.url()) +# adding a consumable + @view_config( context='ordr2:resources.ConsumableList', name='new', @@ -351,6 +364,8 @@ def consumable_new_form_processing(context, request): return HTTPFound(context.url()) +# edit a consumable + @view_config( context='ordr2:resources.ConsumableResource', permission='edit', @@ -407,11 +422,14 @@ def consumable_edit_form_processing(context, request): request.flash('success', msg) elif 'delete' in request.POST and context.model: + # redirect to delete consumable confirmation page return HTTPFound(request.resource_url(context, 'delete')) return HTTPFound(context.__parent__.url()) +# delete consumable + @view_config( context='ordr2:resources.ConsumableResource', name='delete', @@ -420,6 +438,7 @@ def consumable_edit_form_processing(context, request): renderer='ordr2:templates/admin/consumable_delete.jinja2' ) def consumable_delete_form(context, request): + ''' delete consumable confirmation page ''' return {'consumables': [context.model]} @@ -430,6 +449,7 @@ def consumable_delete_form(context, request): request_method='POST' ) def consumable_delete_form_processing(context, request): + ''' delete consumable after confirmation ''' if 'delete' in request.POST: c_ids = [v for k, v in request.POST.items() if k == 'consumable'] consumables = request.dbsession.\ diff --git a/ordr2/views/orders.py b/ordr2/views/orders.py index 7d3a9d6..aef6cdb 100644 --- a/ordr2/views/orders.py +++ b/ordr2/views/orders.py @@ -1,9 +1,11 @@ +''' views for creating and editing orders ''' + import deform import io import xlsxwriter -from datetime import datetime from collections import OrderedDict +from datetime import datetime from pyramid.httpexceptions import HTTPFound from pyramid.renderers import render @@ -20,6 +22,7 @@ from . import update_column_display # helper method def change_in_order_status(request, order, old): + ''' notifies a user if a noteworthy change in a order occured ''' noteworthy = False if old != OrderStatus.ORDERED and order.status == OrderStatus.ORDERED: noteworthy = True @@ -35,7 +38,7 @@ def change_in_order_status(request, order, old): request.registry.notify(event) -# oder list and multiple editing +# oder list @view_config( context='ordr2:resources.OrderList', @@ -70,7 +73,9 @@ def change_column_view(context, request): renderer='ordr2:templates/orders/edit_multiple_stati.jinja2' ) 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. output = io.BytesIO() # 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' ) def search(context, request): + ''' process the search order form ''' term = request.POST.get('search', '') term = term.strip() if term: @@ -150,6 +156,7 @@ def search(context, request): renderer='ordr2:templates/orders/edit_multiple_stati.jinja2' ) 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'] orders = request.dbsession.\ query(Order).\ @@ -169,6 +176,7 @@ def edit_multiple_stati_form(context, request): request_method='POST' ) def edit_multiple_stati_form_processing(context, request): + ''' change the stati of multiple orders ''' if 'change' in request.POST: count = 0 @@ -205,6 +213,7 @@ def edit_multiple_stati_form_processing(context, request): renderer='ordr2:templates/orders/delete.jinja2' ) 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'] orders = request.dbsession.\ query(Order).\ @@ -229,6 +238,7 @@ def delete_multiple_orders_form(context, request): request_method='POST' ) def order_delete_form_processing(context, request): + ''' delete one or multiple orders after confirmation ''' if 'delete' in request.POST: order_ids = [v for k, v in request.POST.items() if k == 'order'] orders = request.dbsession.\ @@ -247,7 +257,7 @@ def order_delete_form_processing(context, request): return HTTPFound(request.resource_url(request.root, 'orders')) -# single order processing +# Single Order views @view_config( context='ordr2:resources.OrderResource', @@ -256,8 +266,10 @@ def order_delete_form_processing(context, request): renderer='ordr2:templates/orders/view.jinja2' ) def order_view(context, request): + ''' show the order information ''' return {} + @view_config( context='ordr2:resources.OrderResource', name='delete', @@ -266,6 +278,7 @@ def order_view(context, request): renderer='ordr2:templates/orders/delete.jinja2' ) def order_delete_form(context, request): + ''' show the confirmation page for deleting one order ''' return {'orders': [context.model]} @@ -277,6 +290,7 @@ def order_delete_form(context, request): renderer='ordr2:templates/orders/edit.jinja2' ) def order_edit_form(context, request): + ''' show the edit order form ''' form = EditOrderSchema.as_form(request) order = context.model info = { @@ -323,7 +337,7 @@ def order_edit_form(context, request): renderer='ordr2:templates/orders/edit.jinja2' ) def order_edit_form_processing(context, request): - ''' process the consumable edit form ''' + ''' process the edit order form ''' form = EditOrderSchema.as_form(request) data = request.POST.items() @@ -375,9 +389,11 @@ def order_edit_form_processing(context, request): request.flash('success', msg) elif 'delete' in request.POST and context.model: + # redirect to delete order confirmation page return HTTPFound(request.resource_url(context, 'delete')) elif 'reorder' in request.POST and context.model: + # redirect to create new order form return HTTPFound( request.resource_url( context.__parent__, @@ -397,8 +413,11 @@ def order_edit_form_processing(context, request): renderer='ordr2:templates/orders/new.jinja2' ) def order_new_form(context, request): + ''' create a new order ''' 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) order_id = request.GET.get('reorder', None) prefill = None @@ -408,7 +427,9 @@ def order_new_form(context, request): elif consumable_id: prefill = request.dbsession.query(Consumable).get(consumable_id) + # prefill the form data if prefill: + # some fields depend on if the prefill was a reorder or consumable quantity = prefill.amount if order_id else 1 total_price = prefill.total_price if order_id else prefill.unit_price account = prefill.account if order_id else '' @@ -453,6 +474,7 @@ def order_new_form(context, request): renderer='ordr2:templates/orders/splash.jinja2' ) def order_splash(context, request): + ''' splash screen for a new order where consumables can be selected ''' structured = OrderedDict() for cat in Category: structured[cat] = [] @@ -475,6 +497,7 @@ def order_splash(context, request): renderer='ordr2:templates/orders/splash.jinja2' ) def order_splash_processing(context, request): + ''' process the splash screen selection ''' name = request.POST.get('search') consumable = request.dbsession.\ query(Consumable).\ @@ -501,7 +524,7 @@ def order_splash_processing(context, request): renderer='ordr2:templates/orders/new.jinja2' ) def order_new_form_processing(context, request): - ''' process the consumable edit form ''' + ''' process the new order form ''' form = NewOrderSchema.as_form(request) data = request.POST.items() @@ -543,10 +566,3 @@ def order_new_form_processing(context, request): return HTTPFound(context.url()) - - - - - - - diff --git a/ordr2/views/pages.py b/ordr2/views/pages.py index 12ffe50..4a76331 100644 --- a/ordr2/views/pages.py +++ b/ordr2/views/pages.py @@ -1,3 +1,5 @@ +''' views for static pages ''' + from pyramid.httpexceptions import HTTPFound from pyramid.view import view_config