diff --git a/development.ini b/development.ini index 03a509f..db5d2bd 100644 --- a/development.ini +++ b/development.ini @@ -31,6 +31,16 @@ mail.host = localhost mail.port = 2525 mail.default_sender = ordr@example.com +# custom jinja2 filters: +jinja2.filters = + resource_url = pyramid_jinja2.filters:resource_url_filter + as_date = ordr3.views:jinja_date + as_time = ordr3.views:jinja_time + as_datetime = ordr3.views:jinja_datetime + view_comment = ordr3.views:jinja_view_comment + extract_links = ordr3.views:jinja_extract_links + nl2br = ordr3.views:jinja_nl2br + # By default, the toolbar only appears for clients from IP addresses # '127.0.0.1' and '::1'. # debugtoolbar.hosts = 127.0.0.1 ::1 diff --git a/ordr3/__init__.py b/ordr3/__init__.py index c923877..7e92133 100644 --- a/ordr3/__init__.py +++ b/ordr3/__init__.py @@ -27,6 +27,8 @@ def main(global_config, **settings): config.set_root_factory(resources.Root) + config.include("pyramid_jinja2") + config.include(".adapters") config.include(".events") config.include(".resources") @@ -35,8 +37,6 @@ def main(global_config, **settings): config.include(".schemas") config.include(".views") - config.include("pyramid_jinja2") - config.scan() return config.make_wsgi_app() diff --git a/ordr3/resources.py b/ordr3/resources.py index 1f0ad93..0fa60e0 100644 --- a/ordr3/resources.py +++ b/ordr3/resources.py @@ -2,7 +2,7 @@ import abc -from pyramid.security import Allow, Everyone, Authenticated +from pyramid.security import DENY_ALL, Allow, Everyone, Authenticated class BaseResource(abc.ABC): @@ -37,6 +37,7 @@ class User(BaseResource): acl = [ (Allow, "role:admin", "view"), (Allow, "role:admin", "edit"), + DENY_ALL, ] if not self.model.is_active: acl.append((Allow, "role:admin", "delete")) @@ -51,7 +52,7 @@ class User(BaseResource): class UserList(BaseResource): def __acl__(self): """ access controll list """ - return [(Allow, "role:admin", "view")] + return [(Allow, "role:admin", "view"), DENY_ALL] def __getitem__(self, key): """ returns child resources """ @@ -76,6 +77,7 @@ class Order(BaseResource): else: acl.append((Allow, f"user:{self.model.created_by}", "edit")) acl.append((Allow, f"user:{self.model.created_by}", "delete")) + acl.append(DENY_ALL) return acl @classmethod @@ -92,6 +94,7 @@ class OrderList(BaseResource): (Allow, "role:user", "add"), (Allow, "role:purchaser", "batch-edit"), (Allow, "role:purchaser", "batch-delete"), + DENY_ALL, ] def __getitem__(self, key): diff --git a/ordr3/services.py b/ordr3/services.py index 7fe51ab..e49d3bf 100644 --- a/ordr3/services.py +++ b/ordr3/services.py @@ -58,11 +58,15 @@ def _find_consumables(repo, repeat=3, days=365 * 2): def create_log_entry(order, status, user): old_status = order.status - log_entry = models.LogEntry(order.id, status, user.username) - order.add_to_log(log_entry) - - # is this noteworthy? - return (old_status != status) and (order.created_by != user.username) + if old_status != status: + # only change to a new status + log_entry = models.LogEntry(order.id, status, user.username) + order.add_to_log(log_entry) + # is this noteworthy? + return order.created_by != user.username + + # it's not noteworthy + return False def verify_credentials(repo, pass_ctx, username, password): diff --git a/ordr3/templates/orders/batch_delete.jinja2 b/ordr3/templates/orders/batch_delete.jinja2 index ed68f22..56979dd 100644 --- a/ordr3/templates/orders/batch_delete.jinja2 +++ b/ordr3/templates/orders/batch_delete.jinja2 @@ -58,11 +58,11 @@ {% endfor %} -

This action is permanent and cannot be undone!

+

Deleting the orders permanent and cannot be undone!

- +

diff --git a/ordr3/templates/orders/delete.jinja2 b/ordr3/templates/orders/delete.jinja2 index 6eee562..ff7f699 100644 --- a/ordr3/templates/orders/delete.jinja2 +++ b/ordr3/templates/orders/delete.jinja2 @@ -18,16 +18,16 @@

Package Size
{{ context.model.package_size }}
Amount, Price
-
{{ context.model.amount }} * {{ context.model.unit_price }} {{ context.model.currency }}
+
{{ context.model.amount }} * {{ "%.2f"|format(context.model.unit_price) }} {{ context.model.currency }}
Ordered By
{{ context.model.created_by }}
Ordered On
{{ context.model.created_on.strftime("%Y-%m-%d %H:%I") }}
Status
{{ macros.status_label(context.model.status) }}
- + -

This action is permanent and cannot be undone!

+

Deleting this order is permanent and cannot be undone!

diff --git a/ordr3/templates/orders/list_content.jinja2 b/ordr3/templates/orders/list_content.jinja2 index a2877c9..9161b6d 100644 --- a/ordr3/templates/orders/list_content.jinja2 +++ b/ordr3/templates/orders/list_content.jinja2 @@ -5,8 +5,8 @@ {% endif %} - {{ order.model.created_on.strftime("%Y-%m-%d") }} -
{{ order.model.created_on.strftime("%H:%I:%S") }}
+ {{ order.model.created_on|as_date }} +
{{ order.model.created_on|as_time }}
{{ order.model.cas_description }} @@ -34,7 +34,7 @@ {% if request.has_permission("edit", order) %} - {{ macros.icon("pencil")}} + {{ macros.icon("pencil")}} {% elif request.has_permission("view", order) %} {{ macros.icon("eye")}} {% endif %} diff --git a/ordr3/templates/orders/view.jinja2 b/ordr3/templates/orders/view.jinja2 new file mode 100644 index 0000000..e1f6886 --- /dev/null +++ b/ordr3/templates/orders/view.jinja2 @@ -0,0 +1,55 @@ +{% extends "ordr3:templates/layout_full.jinja2" %} + +{% block subtitle %} View Order {{context.model.cas_description}} {% endblock subtitle %} + +{% block content %} + +
+

Details for Order {{ context.model.cas_description }}

+
+
Cas / Description
+
{{ context.model.cas_description }}
+
Vendor
+
{{ context.model.vendor }}
+
Catalog Nr.
+
{{ context.model.catalog_nr }}
+
Package Size
+
{{ context.model.package_size }}
+
Amount, Price
+
{{ context.model.amount }} * {{ "%.2f"|format(context.model.unit_price) }} {{ context.model.currency }}
+
Price, total
+
{{ "%.2f"|format(context.model.total_price) }} {{ context.model.currency }}
+
Ordered By
+
{{ context.model.created_by }}
+
Ordered On
+
{{ context.model.created_on.strftime("%Y-%m-%d %H:%I") }}
+
Account
+
{{ context.model.account|default("-", True) }}
+
Comment
+
{{ context.model.comment|default("-", True) }}
+ +
Status
+
{{ macros.status_label(context.model.status) }}
+
+ +

Deleting this order is permanent and cannot be undone!

+ +

+

+ + +
+

+

+ + + +

+ +
+ +
+ +{% endblock content %} + + diff --git a/ordr3/views/__init__.py b/ordr3/views/__init__.py index 4f52126..7eabd22 100644 --- a/ordr3/views/__init__.py +++ b/ordr3/views/__init__.py @@ -3,7 +3,10 @@ some view helpers are defined here """ -from collections import UserDict +import re +from collections import UserDict, namedtuple + +RE_SIMPLE_URL = re.compile("((?:www|http)\\S+)") def get_offset(request): @@ -21,6 +24,39 @@ class DefaultQueryParams(UserDict): return {k: v for k, v in copied.items() if v is not None} +def jinja_date(some_date): + return some_date.strftime("%Y-%m-%d") + + +def jinja_time(some_date): + return some_date.strftime("%H:%I") + + +def jinja_datetime(some_date): + return some_date.strftime("%Y-%m-%d %H:%I") + + +def _as_html_link(url, css_class): + return f'{url}' + + +def jinja_extract_links(text, css_class=""): + links = RE_SIMPLE_URL.findall(text) + return [_as_html_link(url, css_class) for url in links] + + +def jinja_view_comment(text, css_class): + links = RE_SIMPLE_URL.findall(text) + for url in links: + html = _as_html_link(url, css_class) + text = text.replace(url, html) + return text + + +def jinja_nl2br(text, replacement="
"): + return replacement.join(text.splitlines()) + + def includeme(config): """ adding request helpers diff --git a/ordr3/views/orders.py b/ordr3/views/orders.py index 061a808..ef2c94d 100644 --- a/ordr3/views/orders.py +++ b/ordr3/views/orders.py @@ -239,6 +239,17 @@ def batch_edit_confirm(context, request): return HTTPFound(request.resource_path(context)) +@view_config( + context="ordr3:resources.Order", + permission="view", + name="view", + request_method="GET", + renderer="ordr3:templates/orders/view.jinja2", +) +def view_order(context, request): + return {"csrf_token": get_csrf_token(request)} + + @view_config( context="ordr3:resources.Order", permission="delete", diff --git a/ordr3/views/root.py b/ordr3/views/root.py index e51e0da..47fa1de 100644 --- a/ordr3/views/root.py +++ b/ordr3/views/root.py @@ -1,10 +1,16 @@ """ static and login pages """ -from pyramid.view import view_config +from pyramid.view import ( + view_config, + notfound_view_config, + forbidden_view_config, +) from pyramid.httpexceptions import HTTPFound +# @forbidden_view_config() +# @notfound_view_config() @view_config( context="ordr3:resources.Root", permission="view", ) diff --git a/tests/test_models.py b/tests/test_models.py index 402298d..655a5fe 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -64,6 +64,25 @@ def test_orderitem_add_to_log_non_empty_log(): assert order.status == log_entry_2.status +@pytest.mark.parametrize( + "status,expected", + [ + ("OPEN", False), + ("APPROVAL", True), + ("ORDERED", True), + ("COMPLETED", True), + ("HOLD", False), + ], +) +def test_orderitem_in_process(status, expected): + from ordr3.models import OrderItem, OrderStatus + + order = OrderItem(*list("ABCDEFGHIJK")) + order.status = OrderStatus[status] + + assert order.in_process == expected + + def test_LogEntry_init(): from ordr3.models import LogEntry diff --git a/tests/test_repo.py b/tests/test_repo.py index eb0e3dc..64bb1d6 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -79,6 +79,26 @@ def test_sql_repo_add_order(session, example_orders): assert order == example_orders[0] +def test_sql_repo_delete_order(session, example_orders): + from ordr3.repo import SqlAlchemyRepository + from ordr3.services import create_log_entry + from ordr3.models import LogEntry, OrderStatus, User + + repo = SqlAlchemyRepository(session) + repo.add_order(example_orders[0]) + repo.add_order(example_orders[1]) + user = User(*list("ABCDEFG")) + create_log_entry(example_orders[0], OrderStatus.APPROVAL, user) + create_log_entry(example_orders[1], OrderStatus.APPROVAL, user) + session.flush() + + repo.delete_order(example_orders[0]) + session.flush() + + assert repo.list_orders() == [example_orders[1]] + assert session.query(LogEntry).all() == example_orders[1].log + + def test_sql_repo_get_order(session, example_orders): from ordr3.repo import SqlAlchemyRepository @@ -126,6 +146,19 @@ def test_sql_repo_add_user(session, example_users): assert user == example_users[0] +def test_sql_repo_delte_user(session, example_users): + from ordr3.repo import SqlAlchemyRepository + + repo = SqlAlchemyRepository(session) + repo.add_user(example_users[0]) + repo.add_user(example_users[1]) + session.flush() + + repo.delete_user(example_users[0]) + + assert repo.list_users() == [example_users[1]] + + def test_sql_repo_get_user(session, example_users): from ordr3.repo import SqlAlchemyRepository @@ -204,6 +237,19 @@ def test_sql_repo_list_users(session, example_users): assert repo.list_users() == [earlier, later] +def test_sql_repo_count_new_users(session, example_users): + from ordr3.repo import SqlAlchemyRepository + from ordr3.models import UserRole + + repo = SqlAlchemyRepository(session) + example_users[0].role = UserRole.NEW + repo.add_user(example_users[0]) + repo.add_user(example_users[1]) + session.flush() + + assert repo.count_new_users() == 1 + + def test_sql_search_vendor(session, example_users): from ordr3.repo import SqlAlchemyRepository from ordr3.models import Vendor diff --git a/tests/test_services.py b/tests/test_services.py index e779558..cda6a46 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -149,24 +149,54 @@ def test_service_find_consumables(prefilled_repo): assert [o.id for o in result] == [3, 0] -def test_create_log_entry(prefilled_repo): +def test_create_log_entry_noteworthy(prefilled_repo): + from ordr3.services import create_log_entry + from ordr3.models import OrderStatus, User + + order = prefilled_repo.get_order(1) + user_1 = User(*list("ABCDEFG")) + user_2 = User(*list("AXCDEFG")) + + # first log entry, order.created_by is automatically set + create_log_entry(order, OrderStatus.OPEN, user_1) + # second log entry + result = create_log_entry(order, OrderStatus.APPROVAL, user_2) + + assert result is True + assert len(order.log) == 2 + first_entry, second_entry = order.log + assert first_entry.order_id == order.id + assert first_entry.status == OrderStatus.OPEN + assert first_entry.by == "B" + assert isinstance(first_entry.date, datetime) + assert order.created_by == first_entry.by + assert order.created_on == first_entry.date + assert order.status == second_entry.status + + +def test_create_log_entry_not_noteworthy_same_user(prefilled_repo): + from ordr3.services import create_log_entry + from ordr3.models import OrderStatus, User + + order = prefilled_repo.get_order(1) + user = User(*list("ABCDEFG")) + order.created_by = user.username + + result = create_log_entry(order, OrderStatus.APPROVAL, user) + + assert result is False + + +def test_create_log_entry_not_noteworthy_same_status(prefilled_repo): from ordr3.services import create_log_entry from ordr3.models import OrderStatus, User order = prefilled_repo.get_order(1) user = User(*list("ABCDEFG")) - create_log_entry(order, OrderStatus.APPROVAL, user) - - assert len(order.log) == 1 - log_entry = order.log[0] - assert log_entry.order_id == order.id - assert log_entry.status == OrderStatus.APPROVAL - assert log_entry.by == "B" - assert isinstance(log_entry.date, datetime) - assert order.status == log_entry.status - assert order.created_by == log_entry.by - assert order.created_on == log_entry.date + result = create_log_entry(order, OrderStatus.ORDERED, user) + + assert result is False @pytest.mark.parametrize(