Browse Source

working on orders

funding-tag
Holger Frey 5 years ago
parent
commit
5a73e1689b
  1. 6
      ordr3/models.py
  2. 37
      ordr3/resources.py
  3. 9
      ordr3/scripts/migrate_db.py
  4. 51
      ordr3/static/script.js
  5. 40
      ordr3/static/style.css
  6. 35
      ordr3/templates/layout_full.jinja2
  7. 15
      ordr3/templates/macros.jinja2
  8. 87
      ordr3/templates/orders/list.jinja2
  9. 53
      ordr3/templates/orders/list_content.jinja2
  10. 2
      ordr3/templates/users/list_content.jinja2
  11. 17
      ordr3/views/__init__.py
  12. 83
      ordr3/views/orders.py
  13. 18
      ordr3/views/users.py
  14. BIN
      ordr3_backup.sqlite
  15. 4
      tests/test_models.py

6
ordr3/models.py

@ -106,6 +106,10 @@ class OrderItem(Model): @@ -106,6 +106,10 @@ class OrderItem(Model):
def total_price(self):
return self.unit_price * self.amount
@property
def in_process(self):
return self.status not in (OrderStatus.OPEN, OrderStatus.HOLD)
def add_to_log(self, log_entry):
""" adds a log item to the status log """
if len(self.log) == 0:
@ -178,7 +182,7 @@ class User(Model): @@ -178,7 +182,7 @@ class User(Model):
@property
def principal(self):
return f"user:{self.id}"
return f"user:{self.username}"
@property
def principals(self):

37
ordr3/resources.py

@ -62,13 +62,48 @@ class UserList(BaseResource): @@ -62,13 +62,48 @@ class UserList(BaseResource):
raise KeyError from e
class Order(BaseResource):
def __acl__(self):
""" access controll list """
acl = [
(Allow, "role:user", "view"),
(Allow, "role:purchaser", "edit"),
(Allow, "role:purchaser", "delete"),
]
if self.model.in_process:
acl.append((Allow, "role:purchaser", "reorder")),
acl.append((Allow, "role:user", "reorder")),
else:
acl.append((Allow, f"user:{self.model.created_by}", "edit"))
acl.append((Allow, f"user:{self.model.created_by}", "delete"))
return acl
@classmethod
def from_model(cls, model, parent):
""" initializes a resource from an SQLalchemy object """
return cls(model.id, parent, model)
class OrderList(BaseResource):
def __acl__(self):
""" access controll list """
return [
(Allow, "role:user", "view"),
(Allow, "role:user", "add"),
(Allow, "role:purchaser", "edit-multiple"),
]
class Root(BaseResource):
""" Root resource """
__name__ = None
__parent__ = None
nodes = {"users": UserList}
nodes = {
"users": UserList,
"orders": OrderList,
}
def __init__(self, request):
self.request = request

9
ordr3/scripts/migrate_db.py

@ -299,6 +299,15 @@ def migrate_orders(cursor, repo): @@ -299,6 +299,15 @@ def migrate_orders(cursor, repo):
)
order.add_to_log(log_entry)
if order.created_on < datetime(2018, 1, 1):
log_entry = models.LogEntry(
order.id,
models.OrderStatus.COMPLETED,
"OrderSystem",
datetime.utcnow(),
)
order.add_to_log(log_entry)
def parse_args(argv):
parser = argparse.ArgumentParser()

51
ordr3/static/script.js

@ -44,7 +44,21 @@ $(function() { @@ -44,7 +44,21 @@ $(function() {
}
});
$(".o3-reg-username-source").on("keyup", function(event){
$(".o3-details").on("click", function(event){
// handle detail visibility
var target = $(event.delegateTarget)
if (target.hasClass("o3-show-details")) {
target.removeClass("o3-show-details");
target.addClass("o3-hide-details");
$(".o3-data-table").addClass("o3-data-hide-details")
} else {
target.addClass("o3-show-details");
target.removeClass("o3-hide-details");
$(".o3-data-table").removeClass("o3-data-hide-details")
}
});
$(".o3-reg-username-source").on("keyup", function(event) {
// automatic username creation
var first_name = $(".o3-reg-firstname").val();
var last_name = $(".o3-reg-lastname").val();
@ -52,18 +66,49 @@ $(function() { @@ -52,18 +66,49 @@ $(function() {
$(".o3-reg-username").val(user_name);
});
$("#o3-selectall").on("click", function(event) {
// select all checkboxes
var target = $(event.delegateTarget);
var is_checked = target.is(':checked');
$(".o3-multiple-selection").prop('checked', is_checked);
if (is_checked) {
$(".o3-on-select").fadeIn(100);
} else {
$(".o3-on-select").fadeOut(100);
}
});
$(".o3-data-table").on("click", function(event) {
// set visibility of edit multiples
if ($(".o3-data-table .o3-multiple-selection:checked").length > 0) {
$(".o3-on-select").fadeIn(100);
} else {
$(".o3-on-select").fadeOut(100);
}
});
$(".o3-on-select .btn").on("click", function(event) {
var target = $(event.delegateTarget);
var action = target.attr("data-action");
var form = $("#o3-multiple-form");
form.attr("action", action);
form.submit();
});
$(".o3-copy").on("click", function(event) {
var target = $(event.delegateTarget)
// copy to clipboard
var target = $(event.delegateTarget);
var $temp = $("<input>");
$("body").append($temp);
$temp.val($(target).html()).select();
document.execCommand("copy");
$temp.remove();
$(target).fadeOut( 100 ).delay( 100 ).fadeIn( 100 );
//$(target).fadeOut( 100 ).delay( 100 ).fadeIn( 100 );
$(target).fadeTo(100, 0).delay(100).fadeTo(100, 1);
});
$(".o3-confirmation").on("click", function(event) {
// delete confirmation dialog
var target = $(event.delegateTarget);
var checkbox = $(target).find(".custom-control-input");
var button = $(".o3-confirm-button");

40
ordr3/static/style.css

@ -39,14 +39,18 @@ @@ -39,14 +39,18 @@
margin-top:2em;
}
.o3-on-select {
display:none;
}
.o3-details.o3-show-details .bi-eye-slash,
.o3-details.o3-hide-details .bi-eye {
display:none;
display:inline;
}
.o3-details.o3-hide-details .bi-eye-slash,
.o3-details.o3-show-details .bi-eye {
display:inline;
display:none;
}
.o3-users-link:hover .badge.badge-light{
@ -109,7 +113,7 @@ @@ -109,7 +113,7 @@
background-color: #f8f9fa!important;
}
.o3-content .table td {
.o3-content .table td, .o3-content .table th {
padding:.5rem;
}
@ -127,11 +131,23 @@ td.o3-actions a:hover { @@ -127,11 +131,23 @@ td.o3-actions a:hover {
cursor:pointer;
}
.o3-data-table th {
border-top:none;
}
.o3-data-table .form-control {
margin-bottom:.2rem;
margin-right:.2rem;
}
.o3-data-table.o3-data-hide-details .text-secondary {
display:none;
}
.o3-data-table .badge {
text-transform: uppercase;
}
.infinite-more-link td {
text-align:center;
@ -150,18 +166,26 @@ td.o3-actions a:hover { @@ -150,18 +166,26 @@ td.o3-actions a:hover {
color: #fff;
border-color: #dc3545;
background-color:#dc3545;
}
}
.o3-delete-warning .custom-control-input:focus ~ .custom-control-label::before {
box-shadow:0 0 0 .2rem rgba(0, 123, 255, .25)
}
}
.o3-delete-warning .custom-control-input:focus:not(:checked) ~ .custom-control-label::before {
border-color:#80bdff
}
}
.o3-delete-warning .custom-control-input:not(:disabled):active ~ .custom-control-label::before {
color: #fff;
background-color: #b3d7ff;
border-color:#b3d7ff
}
}
.o3-dont-wrap {
word-break: keep-all;
white-space: nowrap;
}

35
ordr3/templates/layout_full.jinja2

@ -30,28 +30,29 @@ @@ -30,28 +30,29 @@
{{ macros.icon("plus-circle") }}
</a>
</li>
<li class="nav-item btn-group b3-on-select mr-3">
<a href="{{request.resource_url(request.root, 'orders', 'add') }}" class="btn btn-outline-light pt-1" title="Edit selected orders">
{{ macros.icon("pencil-square") }}
</a>
<a href="{{request.resource_url(request.root, 'orders', 'add') }}" class="btn btn-outline-light pt-1" title="Delete selected orders">
{{ macros.icon("trash") }}
</a>
<li class="nav-item o3-on-select mr-3">
<div class="btn-group">
<button class="btn btn-outline-light pt-1" title="Edit selected orders" data-action="{{ request.resource_url(context, "edit-multiple") }}">
{{ macros.icon("pencil-square") }}
</button>
<button class="btn btn-outline-light pt-1" title="Delete selected orders" data-action="{{ request.resource_url(context, "delete-multiple") }}">
{{ macros.icon("trash") }}
</button>
</div>
</li>
<li class="nav-item mr-3">
<a href="{{request.resource_url(request.root, 'orders', 'add') }}" class="btn btn-outline-light pt-1 o3-details o3-show-details" title="View order details">
<button class="btn btn-outline-light pt-1 o3-details o3-hide-details" title="Show or hide order details">
{{ macros.icon("eye") }}
{{ macros.icon("eye-slash") }}
</a>
</button>
</li>
{% endif %}
{% if is_user_list %}
<li class="nav-item mr-3">
<a href="{{request.resource_url(request.root) }}" class="btn btn-outline-light pt-1" title="Show orders">
{{ macros.icon("bag") }}
</a>
</li>
{% elif request.user.role.name == "ADMIN" %}
<li class="nav-item mr-3">
<a href="{{request.resource_url(request.root) }}" class="btn btn-outline-light pt-1" title="Show orders">
{{ macros.icon("bag") }}
</a>
</li>
{% if request.user.role.name == "ADMIN" %}
<li class="nav-item mr-3">
<a href="{{request.resource_url(request.root, 'users') }}" class="btn btn-outline-light pt-1 o3-users-link" title="User Management{% if new_user_badge >0 %}, {{ new_user_badge }} new users{% endif %}">
{{ macros.icon("people") }}
@ -67,7 +68,7 @@ @@ -67,7 +68,7 @@
<ul class="navbar-nav">
<li class="nav-item mr-3">
<form class="form-inline mt-1" method="GET" action="{{request.resource_url(request.root, 'orders') }}">
<input class="form-control form-control-sm" name="search" type="search" placeholder="Search Orders" aria-label="Search">
<input class="form-control form-control-sm" name="search" type="search" value="{% if query_defaults and query_defaults['search'] %}{{ query_defaults['search']|default('') }}{% endif %}" placeholder="Search Orders" aria-label="Search">
</form>
</li>
<li class="nav-item dropdown">

15
ordr3/templates/macros.jinja2

@ -20,3 +20,18 @@ @@ -20,3 +20,18 @@
{% endfor %}
</div>
{%- endmacro %}
{% macro status_label(status) -%}
{% if status.name == "OPEN" %}
<span class="badge badge-danger">open</span>
{% elif status.name == "APPROVAL" %}
<span class="badge badge-primary">approval</span>
{% elif status.name == "ORDERED" %}
<span class="badge badge-warning">ordered</span>
{% elif status.name == "COMPLETED" %}
<span class="badge badge-success">completed</span>
{% elif status.name == "HOLD" %}
<span class="badge badge-secondary">hold</span>
{% endif %}
{%- endmacro %}

87
ordr3/templates/orders/list.jinja2

@ -0,0 +1,87 @@ @@ -0,0 +1,87 @@
{% extends "ordr3:templates/layout_full.jinja2" %}
{% block subtitle %} Manage Users {% endblock subtitle %}
{% block sidebar %}
<nav class="nav nav-pills flex-column">
<div class="nav-link disabled text-small" tabindex="-1" aria-disabled="true">All Orders</div>
<a class="nav-link {% if query_defaults['status'] == 'all' and not query_defaults['user'] %}active{% endif %}" href="{{ request.resource_url(context, query=query_defaults(status=None, user=None, search=None)) }}">All</a>
{% for status in stati %}
<a class="nav-link {% if query_defaults['status'] == status.name.lower() and not query_defaults['user'] %}active{% endif %}" href="{{ request.resource_url(context, query=query_defaults(status=status.name.lower(), user=None, search=None)) }}">{{status.name.lower()}}</a>
{% endfor %}
</nav>
<nav class="nav nav-pills flex-column mt-3">
<div class="nav-link disabled text-small" tabindex="-1" aria-disabled="true">My Orders</div>
<a class="nav-link {% if query_defaults['status'] == 'all' and query_defaults['user'] == request.user.username %}active{% endif %}" href="{{ request.resource_url(context, query=query_defaults(status=None, user=request.user.username, search=None)) }}">All</a>
{% for status in stati %}
<a class="nav-link {% if query_defaults['status'] == status.name.lower() and query_defaults['user'] == request.user.username %}active{% endif %}" href="{{ request.resource_url(context, query=query_defaults(status=status.name.lower(), user=request.user.username, search=None)) }}">{{status.name.lower()}}</a>
{% endfor %}
</nav>
{% if request.has_permission("edit-multiple", context) %}
<nav class="nav nav-pills flex-column mt-3">
<div class="nav-link disabled text-small" tabindex="-1" aria-disabled="true">Specials</div>
<a class="nav-link {% if query_defaults['user'] == '-purchaser-' %}active{% endif %}" href="{{ request.resource_url(context, query=query_defaults(status=None, user='-purchaser-', search=None)) }}">Edited by me</a>
</nav>
{% endif %}
{% endblock sidebar %}
{% block content %}
<div class="col-10">
<form id="o3-multiple-form" method="POST">
<table class="table table-hover o3-data-table o3-data-hide-details">
<thead>
<tr>
{% if request.has_permission("edit-multiple", context) %}
<th scope="col"><input type="checkbox" id="o3-selectall"></th>
{% endif %}
<th scope="col">
Date
<div class="text-secondary small">Time</div>
</th>
<th scope="col">
CAS / Description
<div class="text-secondary small">Catalog Nr.</div>
</th>
<th scope="col">
Vendor
<div class="text-secondary small">Account</div>
</th>
<th scope="col" class="o3-dont-wrap text-right">
Total Price
<div class="text-secondary small">Unit Price</div>
</th>
<th scope="col">
Status
<div class="text-secondary small">&nbsp;</div>
</th>
<th scope="col">
Ordered by
<div class="text-secondary small">&nbsp;</div>
</th>
<th scope="col">
Actions
<div class="text-secondary small">&nbsp;</div>
</th>
</tr>
</thead>
<tbody class="infinite-container">
{% include 'ordr3:templates/orders/list_content.jinja2' %}
</tbody>
</table>
</form>
{% if not orders %}
<p class="bg-light text-center pt-2 pb-2">No data available</p>
{% endif %}
</div>
<input type="checkbox" class="form-control">
{% endblock content %}

53
ordr3/templates/orders/list_content.jinja2

@ -0,0 +1,53 @@ @@ -0,0 +1,53 @@
{% import 'ordr3:templates/macros.jinja2' as macros with context %}
{% for order in orders %}
<tr class="infinite-item">
{% if request.has_permission("edit-multiple", context) %}
<td><input type="checkbox" name="selection" value="{{order.model.id}}" class="o3-multiple-selection"></td>
{% endif %}
<td class="o3-dont-wrap">
{{ order.model.created_on.strftime("%Y-%m-%d") }}
<div class="text-secondary small">{{ order.model.created_on.strftime("%H:%I:%S") }}</div>
</td>
<td>
<a class="o3-copy" title="copy to clipboard">{{ order.model.cas_description }}</a>
<div class="text-secondary small"><a class="o3-copy" title="copy to clipboard">{{ order.model.catalog_nr }}</a></div>
</td>
<td>
<a class="o3-copy" title="copy to clipboard">{{ order.model.vendor }}</a>
<div class="text-secondary small"><a class="o3-copy" title="copy to clipboard">{{ order.model.account }}</a></div>
</td>
<td class="text-right">
<span class="o3-dont-wrap"><a class="o3-copy" title="copy to clipboard">{{ "%.2f"|format(order.model.total_price)|replace(".", ",") }}</a> {{ order.model.currency }}</span>
<div class="text-secondary small o3-dont-wrap"><a class="o3-copy" title="copy to clipboard">{{ order.model.amount }}</a> * <a class="o3-copy" title="copy to clipboard">{{ "%.2f"|format(order.model.unit_price)|replace(".", ",") }}</a> {{ order.model.currency }}</div>
</td>
<td>
{{ macros.status_label(order.model.status) }}
<div class="text-secondary small">&nbsp;</div>
</td>
<td>
<a href="{{ request.resource_url(context, query={'user':order.model.created_by})}}" title="Show orders for this user">{{ order.model.created_by}}</a>
<div class="text-secondary small">&nbsp;</div>
</td>
<td class="o3-actions">
{% if request.has_permission("edit", order) %}
<a href="{{ request.resource_url(order, 'edit') }}" title="Edit Order">{{ macros.icon("pencil")}}</a>
{% elif request.has_permission("view", order) %}
<a href="{{ request.resource_url(order) }}" title="View Order">{{ macros.icon("eye")}}</a>
{% endif %}
{% if request.has_permission("delete", order) %}
<a href="{{ request.resource_url(order, 'delete') }}" title="Delete Order">{{ macros.icon("trash")}}</a>
{% endif %}
<div class="text-secondary small">&nbsp;</div>
</td>
</tr>
{% endfor %}
{% if next_offset %}
<tr class="infinite-more-link" href="{{ request.resource_url(context, query=query_defaults(o=next_offset)) }}">
<td colspan="6">
<button class="btn btn-outline-primary btn-small">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Loading...
</button>
</td>
</tr>
{% endif %}

2
ordr3/templates/users/list_content.jinja2

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
<td><a href="{{request.resource_url(request.root, 'orders', query={'user':user.model.username})}}" title="show orders">{{ user.model.username }}</a></td>
<td>{{ user.model.first_name }}</td>
<td>{{ user.model.last_name }}</td>
<td><a class="o3-copy" title="copy to clipboard">{{ user.model.email }}</a></td>
<td><a class="o3-copy text-dark" title="copy to clipboard">{{ user.model.email }}</a></td>
<td>{{ user.model.role.name.capitalize() }}</td>
<td class="o3-actions">
{% if request.has_permission("edit", user) %}

17
ordr3/views/__init__.py

@ -3,6 +3,23 @@ @@ -3,6 +3,23 @@
some view helpers are defined here
"""
from collections import UserDict
def get_offset(request):
offset_param = request.GET.get("o", 0)
try:
return int(offset_param)
except ValueError:
return 0
class DefaultQueryParams(UserDict):
def __call__(self, **kwargs):
copied = dict(**self)
copied.update(kwargs)
return {k: v for k, v in copied.items() if v is not None}
def includeme(config):
""" adding request helpers

83
ordr3/views/orders.py

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
# import deform
from sqlalchemy import or_ # , func
# from pyramid.csrf import get_csrf_token
from pyramid.view import view_config
from . import DefaultQueryParams, get_offset
from .. import models, resources # , events, services
# from pyramid.httpexceptions import HTTPFound
# from ..schemas import account
def get_status(request):
status_param = request.GET.get("status", "")
try:
return models.OrderStatus[status_param.upper()]
except KeyError:
return None
@view_config(
context="ordr3:resources.OrderList",
permission="view",
request_method="GET",
renderer="ordr3:templates/orders/list.jinja2",
)
@view_config(
context="ordr3:resources.OrderList",
permission="view",
request_method="GET",
xhr=True,
renderer="ordr3:templates/orders/list_content.jinja2",
)
def list(context, request):
limit = 25
offset = get_offset(request)
status = get_status(request)
username = request.GET.get("user", None)
search = request.GET.get("search", None)
query = request.repo.session.query(models.OrderItem).order_by(
models.OrderItem.created_on.desc()
)
if status:
query = query.filter(models.OrderItem.status == status)
if username == "-purchaser-":
query = (
query.distinct()
.join(models.OrderItem.log)
.filter(models.LogEntry.by == request.user.username)
)
elif username:
query = query.filter(models.OrderItem.created_by == username)
if search:
term = "%{}%".format(search)
query = query.filter(
or_(
models.OrderItem.cas_description.ilike(term),
models.OrderItem.vendor.ilike(term),
models.OrderItem.catalog_nr.ilike(term),
models.OrderItem.account.ilike(term),
models.OrderItem.created_by.ilike(term),
)
)
orders = query[offset : offset + limit] # noqa: E203
next_offset = None if limit != len(orders) else (offset + limit)
order_resources = [resources.Order.from_model(u, context) for u in orders]
filter_status = "all" if status is None else status.name.lower()
query_defaults = DefaultQueryParams(
status=filter_status, user=username, search=search
)
return {
"is_order_list": True,
"next_offset": next_offset,
"orders": order_resources,
"stati": models.OrderStatus,
"query_defaults": query_defaults,
}

18
ordr3/views/users.py

@ -4,11 +4,12 @@ from pyramid.csrf import get_csrf_token @@ -4,11 +4,12 @@ from pyramid.csrf import get_csrf_token
from pyramid.view import view_config
from pyramid.httpexceptions import HTTPFound
from . import get_offset
from .. import events, models, services, resources
from ..schemas import account
def _get_role(request):
def get_role(request):
role_param = request.GET.get("role", "")
try:
return models.UserRole[role_param.upper()]
@ -16,14 +17,6 @@ def _get_role(request): @@ -16,14 +17,6 @@ def _get_role(request):
return None
def _get_offset(request):
offset_param = request.GET.get("o", 0)
try:
return int(offset_param)
except ValueError:
return 0
@view_config(
context="ordr3:resources.UserList",
permission="view",
@ -38,9 +31,9 @@ def _get_offset(request): @@ -38,9 +31,9 @@ def _get_offset(request):
renderer="ordr3:templates/users/list_content.jinja2",
)
def list(context, request):
role = _get_role(request)
offset = _get_offset(request)
limit = 12
role = get_role(request)
offset = get_offset(request)
limit = 25
query = request.repo.session.query(models.User)
if role:
@ -57,7 +50,6 @@ def list(context, request): @@ -57,7 +50,6 @@ def list(context, request):
"roles": models.UserRole,
"users": user_resources,
"next_offset": next_offset,
"is_user_list": True,
}

BIN
ordr3_backup.sqlite

Binary file not shown.

4
tests/test_models.py

@ -112,7 +112,7 @@ def test_user_principal(): @@ -112,7 +112,7 @@ def test_user_principal():
user = User(*list("ABCDEFG"))
assert user.principal == "user:A"
assert user.principal == "user:B"
@pytest.mark.parametrize(
@ -130,7 +130,7 @@ def test_user_principals(role_str, roles): @@ -130,7 +130,7 @@ def test_user_principals(role_str, roles):
user = User(*list("ABCDEF"), UserRole[role_str])
assert user.principals == ["user:A"] + roles
assert user.principals == ["user:B"] + roles
@pytest.mark.parametrize(

Loading…
Cancel
Save