diff --git a/Makefile b/Makefile index a8b0d4e..eb4224f 100644 --- a/Makefile +++ b/Makefile @@ -56,10 +56,10 @@ lint: ## reformat with black and check style with flake8 black ordr3 tests flake8 ordr3 tests -test: ## run tests quickly with the default Python - pytest tests -x --disable-warnings -k "not app" +test: lint ## run tests quickly with the default Python + pytest tests -x --disable-warnings -m "not app" -coverage: ## full test suite, check code coverage and open coverage report +coverage: lint ## full test suite, check code coverage and open coverage report pytest tests --cov=ordr3 coverage html $(BROWSER) htmlcov/index.html @@ -80,5 +80,5 @@ repo: devenv ## complete project setup with development environment and git repo git init . git add . git commit -m "import of project template" - + .venv/bin/pre-commit install --install-hooks diff --git a/ordr3/adapters.py b/ordr3/adapters.py new file mode 100644 index 0000000..7e06185 --- /dev/null +++ b/ordr3/adapters.py @@ -0,0 +1,89 @@ +""" sqlalchemy metadata configuration """ + +from datetime import datetime + +from sqlalchemy import ( + Enum, + Text, + Float, + Table, + Column, + Integer, + DateTime, + ForeignKey, +) +from sqlalchemy.orm import mapper, relationship +from sqlalchemy.schema import MetaData + +from . import models + +# Recommended naming convention used by Alembic, as various different database +# providers will autogenerate vastly different names making migrations more +# difficult. See: http://alembic.zzzcomputing.com/en/latest/naming.html +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + +metadata = MetaData(naming_convention=NAMING_CONVENTION) + +order_table = Table( + "orders", + metadata, + Column("id", Integer, primary_key=True), + Column("cas_description", Text, nullable=False), + Column("catalog_nr", Text, nullable=False), + Column("vendor", Text, nullable=False), + Column("category", Enum(models.OrderCategory), nullable=False), + Column("package_size", Text, nullable=False), + Column("unit_price", Float, nullable=False), + Column("currency", Text, nullable=False, default="EUR"), + Column("amount", Integer, nullable=False), + Column("account", Text, nullable=False, default=""), + Column("comment", Text, nullable=False, default=""), + # redundant properties, could be determined from orders log + Column("created_on", DateTime, nullable=False, default=datetime.utcnow), + Column("created_by", Text, nullable=True, index=True), + Column("status", Enum(models.OrderStatus), nullable=True), +) + + +log_table = Table( + "logs", + metadata, + Column("order_id", Integer, ForeignKey("orders.id"), primary_key=True), + Column("status", Enum(models.OrderStatus), primary_key=True), + Column("date", DateTime, primary_key=True, default=datetime.utcnow), + Column("by", Text, nullable=False), + Column("user_id", Integer, nullable=False, index=True), +) + + +user_table = Table( + "users", + metadata, + Column("id", Integer, primary_key=True), + Column("username", Text, nullable=False, index=True), + Column("first_name", Text, nullable=False), + Column("last_name", Text, nullable=False), + Column("email", Text, nullable=False, index=True), + Column("password", Text, nullable=False), + Column("role", Enum(models.UserRole)), +) + + +def start_mappers(): + mapper( + models.OrderItem, + order_table, + properties={ + "log": relationship( + models.LogItem, backref="order", order_by=log_table.c.date + ) + }, + ) + mapper(models.LogItem, log_table) + mapper(models.User, user_table) diff --git a/ordr3/models.py b/ordr3/models.py index c835805..ea8a4ea 100644 --- a/ordr3/models.py +++ b/ordr3/models.py @@ -19,7 +19,27 @@ class OrderCategory(enum.Enum): BIOLAB = 4 -class OrderItem: +@enum.unique +class UserRole(enum.Enum): + NEW = 1 + USER = 2 + PURCHASER = 3 + ADMIN = 4 + INACTIVE = 5 + + +class Model: + def __hash__(self): + items = sorted(self.__dict__.items()) + no_log = ((k, v) for k, v in items if not k == "log") + content = ((k, v) for k, v in no_log if not k.startswith("_")) + return hash(tuple(content)) + + def __eq__(self, other): + return hash(self) == hash(other) + + +class OrderItem(Model): """ an ordered item """ # properties @@ -35,9 +55,6 @@ class OrderItem: account = None comment = None - # logging status changes of the order - status_log = None - # redundant properties, could be determined from orders log created_on = None created_by = None @@ -56,6 +73,9 @@ class OrderItem: currency="", account="", comment="", + created_on=None, + created_by=None, + status=None, ): self.id = id self.cas_description = cas_description @@ -68,6 +88,11 @@ class OrderItem: self.currency = currency self.account = account self.comment = comment + self.created_on = created_on + self.created_by = created_by + self.status = status + + self.log = [] def __repr__(self): return f"" @@ -78,27 +103,28 @@ class OrderItem: def add_to_log(self, log_item): """ adds a log item to the status log """ - if not self.status_log: - self.status_log = [] + if len(self.log) == 0: self.created_by = log_item.by self.created_on = log_item.date - self.status_log.append(log_item) self.status = log_item.status + self.log.append(log_item) -class LogItem: +class LogItem(Model): """ an entry in the order log """ order_id = None status = None - date = None by = None + user_id = None + date = None - def __init__(self, order, status, by, date=None): - self.order_id = order.id + def __init__(self, order_id, status, by, user_id, date=None): + self.order_id = order_id self.status = status self.by = by - self.date = date or datetime.now() + self.user_id = user_id + self.date = date or datetime.utcnow() class ProposedConsumable: @@ -107,3 +133,47 @@ class ProposedConsumable: def __init__(self, order): self.order = order self.times = 0 + + +class User(Model): + + id = None + username = None + first_name = None + last_name = None + email = None + password = None + role = None + + def __init__( + self, id, username, first_name, last_name, email, password, role + ): + self.id = id + self.username = username + self.first_name = first_name + self.last_name = last_name + self.email = email + self.password = password + self.role = role + + @property + def principal(self): + return f"user:{self.id}" + + @property + def principals(self): + tmp = [self.principal] + if self.role in {UserRole.PURCHASER, UserRole.ADMIN}: + tmp.append("role:user") + if self.role == UserRole.ADMIN: + tmp.append("role:purchaser") + tmp.append("role:" + self.role.name.lower()) + return tmp + + def __hash__(self): + items = sorted(self.__dict__.items()) + content = ((k, v) for k, v in items if not k.startswith("_")) + return hash(tuple(content)) + + def __repr__(self): + return f"" diff --git a/ordr3/repo.py b/ordr3/repo.py index a4baeb4..1b2a8d3 100644 --- a/ordr3/repo.py +++ b/ordr3/repo.py @@ -1,7 +1,6 @@ """ Classes for acessing a datastore """ import abc -from datetime import datetime from . import models @@ -9,10 +8,8 @@ from . import models class AbstractOrderRepository(abc.ABC): """ Abstract base class for a datastore """ - consumable_stati = { - models.OrderStatus.ORDERED, - models.OrderStatus.COMPLETED, - } + def __init__(self, session): + self.session = session @abc.abstractmethod def add(self, order): @@ -23,41 +20,25 @@ class AbstractOrderRepository(abc.ABC): raise NotImplementedError @abc.abstractmethod - def find_consumables(self, repeat=3, days=365 * 2): + def list(self): raise NotImplementedError -class FakeOrderRepository(AbstractOrderRepository): - """ Repository implementation for testing """ - - def __init__(self): - self._orders = [] +class SqlAlchemyRepository(AbstractOrderRepository): + """ Repository implementation for SQLAlchemy """ def add(self, order): - """ add an order to the datastore """ - self._orders.append(order) + self.session.add(order) + self.session.flush() def get(self, reference): - """ retrieve an order from the datastore """ - return next(o for o in self._orders if o.id == reference) - - def find_consumables(self, repeat=3, days=365 * 2): - """ search for orders that are requested often """ - unsorted = self._find_consumables(repeat=3, days=365 * 2) - return sorted(unsorted, key=lambda x: x.cas_description) - - def _find_consumables(self, repeat=3, days=365 * 2): - """ helper function for find_consumables() implementation """ - now = datetime.now() - by_date = (o for o in self._orders if (now - o.created_on).days < days) - relevant = (o for o in by_date if o.status in self.consumable_stati) - counter = {} - for order in sorted( - relevant, reverse=True, key=lambda x: x.created_on - ): - item = counter.setdefault( - order.catalog_nr, models.ProposedConsumable(order) - ) - item.times = item.times + 1 - if item.times == repeat: - yield item.order + return ( + self.session.query(models.OrderItem).filter_by(id=reference).one() + ) + + def list(self): + return ( + self.session.query(models.OrderItem) + .order_by(models.OrderItem.created_on.desc()) + .all() + ) diff --git a/ordr3/services.py b/ordr3/services.py new file mode 100644 index 0000000..40a6716 --- /dev/null +++ b/ordr3/services.py @@ -0,0 +1,34 @@ +from datetime import datetime + +from . import models + +CONSUMABLE_STATI = { + models.OrderStatus.ORDERED, + models.OrderStatus.COMPLETED, +} + + +def find_consumables(repo, repeat=3, days=365 * 2): + """ search for orders that are requested often """ + unsorted = _find_consumables(repo, repeat, days) + return sorted(unsorted, key=lambda x: x.cas_description) + + +def _find_consumables(repo, repeat=3, days=365 * 2): + """ helper function for find_consumables() implementation """ + now = datetime.now() + by_date = (o for o in repo.list() if (now - o.created_on).days < days) + relevant = (o for o in by_date if o.status in CONSUMABLE_STATI) + counter = {} + for order in relevant: + item = counter.setdefault( + order.catalog_nr, models.ProposedConsumable(order) + ) + item.times = item.times + 1 + if item.times == repeat: + yield item.order + + +def create_log_entry(order, status, user): + log_entry = models.LogItem(order.id, status, user.username, user.id) + order.add_to_log(log_entry) diff --git a/pyproject.toml b/pyproject.toml index 69e2339..a730320 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ requires = [ "pyramid >= 1.10", "passlib[argon2] >= 1.7.2", + "sqlalchemy >= 1.3.15", ] requires-python = ">=3.7" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..64ccc65 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,19 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, clear_mappers + +from ordr3.adapters import metadata, start_mappers + + +@pytest.fixture +def in_memory_db(): + engine = create_engine("sqlite:///:memory:") + metadata.create_all(engine) + return engine + + +@pytest.fixture +def session(in_memory_db): + start_mappers() + yield sessionmaker(bind=in_memory_db)() + clear_mappers() diff --git a/tests/test_adapters.py b/tests/test_adapters.py new file mode 100644 index 0000000..7535a0b --- /dev/null +++ b/tests/test_adapters.py @@ -0,0 +1,151 @@ +from datetime import datetime + +import pytest + + +@pytest.fixture +def order_sql(): + return """ + INSERT INTO orders ( + id, cas_description, catalog_nr, vendor, category, package_size, + unit_price, currency, amount, account, comment, created_on, + created_by, status + ) VALUES ( + 1, "Ethanol", "1-23", "VWR", "SOLVENT", "5 l", + 20, "EUR", 2, "DFG", "urgent", "2020-02-04 15:14:13.000000", + "me", "OPEN" + ), ( + 2, "Gloves", "12-3", "Carl Roth", "DISPOSABLE", "100 St.", + 40, "USD", 5, "BMBF", "no comment", "2020-02-03 15:14:13.000000", + "you", "APPROVAL" + ) + """ + + +def test_order_mapper(session, order_sql): + from ordr3.models import OrderItem, OrderCategory, OrderStatus + + session.execute(order_sql) + expected = [ + OrderItem( + 1, + "Ethanol", + "1-23", + "VWR", + OrderCategory.SOLVENT, + "5 l", + 20.0, + 2, + "EUR", + "DFG", + "urgent", + datetime(2020, 2, 4, 15, 14, 13), + "me", + OrderStatus.OPEN, + ), + OrderItem( + 2, + "Gloves", + "12-3", + "Carl Roth", + OrderCategory.DISPOSABLE, + "100 St.", + 40.0, + 5, + "USD", + "BMBF", + "no comment", + datetime(2020, 2, 3, 15, 14, 13), + "you", + OrderStatus.APPROVAL, + ), + ] + + assert session.query(OrderItem).all() == expected + + +def test_log_mapper(session, order_sql): + from ordr3.models import LogItem, OrderItem, OrderStatus + + session.execute(order_sql) + session.execute( + """ + INSERT INTO logs + (order_id, status, by, user_id, date) + VALUES + (1, "OPEN", "ME", 1, "2020-02-05 15:14:13.000000"), + (1, "COMPLETED", "YOU", 2, "2020-02-07 15:14:13.000000"), + (1, "ORDERED", "ME", 1, "2020-02-06 15:14:13.000000") + """ + ) + expected = [ + LogItem( + 1, OrderStatus.OPEN, "ME", 1, datetime(2020, 2, 5, 15, 14, 13), + ), + LogItem( + 1, + OrderStatus.COMPLETED, + "YOU", + 2, + datetime(2020, 2, 7, 15, 14, 13), + ), + LogItem( + 1, OrderStatus.ORDERED, "ME", 1, datetime(2020, 2, 6, 15, 14, 13) + ), + ] + + assert session.query(LogItem).all() == expected + + order = session.query(OrderItem).first() + assert order.log == sorted(expected, key=lambda x: x.date) + + logitem = session.query(LogItem).first() + assert logitem.order.id == order.id + + +def test_user_mapper(session): + from ordr3.models import User, UserRole + + session.execute( + """ + INSERT INTO users + (id, username, first_name, last_name, email, password, role) + VALUES + (1, "Me", "Jane", "Doe", "jane.doe", "1234", "USER"), + (2, "You", "Jim", "Smith", "jim.smith", "abcd", "ADMIN") + """ + ) + + expected = [ + User(1, "Me", "Jane", "Doe", "jane.doe", "1234", UserRole.USER), + User(2, "You", "Jim", "Smith", "jim.smith", "abcd", UserRole.ADMIN,), + ] + + assert session.query(User).all() == expected + + +def test_adapter_behaviour(session): + from ordr3.models import OrderItem, OrderCategory, OrderStatus, LogItem + + order = OrderItem( + None, + "Ethanol", + "1-23", + "VWR", + OrderCategory.SOLVENT, + "5 l", + 20.0, + 2, + "EUR", + ) + session.add(order) + + log_entry = LogItem(order.id, OrderStatus.OPEN, "ME", 123) + order.add_to_log(log_entry) + + from_db = session.query(OrderItem).first() + + assert len(from_db.log) == 1 + assert from_db.created_by == "ME" + assert from_db.created_on == from_db.log[0].date + assert from_db.status == OrderStatus.OPEN diff --git a/tests/test_models.py b/tests/test_models.py index 2064d14..2d5be74 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,7 +1,10 @@ +import pytest + + def test_orderitem_init(): from ordr3.models import OrderItem - order = OrderItem(*list("ABCDEFGHIJK")) + order = OrderItem(*list("ABCDEFGHIJKLMN")) assert order.id == "A" assert order.cas_description == "B" @@ -14,6 +17,17 @@ def test_orderitem_init(): assert order.currency == "I" assert order.account == "J" assert order.comment == "K" + assert order.created_on == "L" + assert order.created_by == "M" + assert order.status == "N" + + +def test_orderitem_repr(): + from ordr3.models import OrderItem + + order = OrderItem(*list("ABCDEFGHIJK")) + + assert repr(order) == "" def test_orderitem_total_price(): @@ -28,7 +42,7 @@ def test_orderitem_add_to_log_empty_log(): from ordr3.models import OrderItem, LogItem order = OrderItem(*list("ABCDEFGHIJK")) - log_item = LogItem(order, "critical", "me", "yesterday") + log_item = LogItem(order, "critical", "me", "myid", "yesterday") order.add_to_log(log_item) assert order.created_on == log_item.date @@ -40,8 +54,8 @@ def test_orderitem_add_to_log_non_empty_log(): from ordr3.models import OrderItem, LogItem order = OrderItem(*list("ABCDEFGHIJK")) - log_item_1 = LogItem(order, "critical", "me", "yesterday") - log_item_2 = LogItem(order, "normal", "you", "today") + log_item_1 = LogItem(order, "critical", "me", "myid", "yesterday") + log_item_2 = LogItem(order, "normal", "you", "yourid", "today") order.add_to_log(log_item_1) order.add_to_log(log_item_2) @@ -50,26 +64,61 @@ def test_orderitem_add_to_log_non_empty_log(): assert order.status == log_item_2.status -def test_logitem_init_with_date(): - from ordr3.models import OrderItem, LogItem +def test_logitem_init(): + from ordr3.models import LogItem - order = OrderItem(*list("ABCDEFGHIJK")) - log_item = LogItem(order, "critical", "me", "yesterday") + log_item = LogItem(1, "critical", "me", "myid", "yesterday") - assert log_item.order_id == order.id + assert log_item.order_id == 1 assert log_item.status == "critical" assert log_item.by == "me" + assert log_item.user_id == "myid" assert log_item.date == "yesterday" -def test_logitem_init_with_out_date(): - from ordr3.models import OrderItem, LogItem - from datetime import datetime +def test_user_init(): + from ordr3.models import User - order = OrderItem(*list("ABCDEFGHIJK")) - log_item = LogItem(order, "critical", "me") + user = User(*list("ABCDEFG")) - assert log_item.order_id == order.id - assert log_item.status == "critical" - assert log_item.by == "me" - assert isinstance(log_item.date, datetime) + assert user.id == "A" + assert user.username == "B" + assert user.first_name == "C" + assert user.last_name == "D" + assert user.email == "E" + assert user.password == "F" + assert user.role == "G" + + +def test_user_principal(): + from ordr3.models import User + + user = User(*list("ABCDEFG")) + + assert user.principal == "user:A" + + +@pytest.mark.parametrize( + "role_str,roles", + [ + ("NEW", ["role:new"]), + ("USER", ["role:user"]), + ("PURCHASER", ["role:user", "role:purchaser"]), + ("ADMIN", ["role:user", "role:purchaser", "role:admin"]), + ("INACTIVE", ["role:inactive"]), + ], +) +def test_user_principals(role_str, roles): + from ordr3.models import User, UserRole + + user = User(*list("ABCDEF"), UserRole[role_str]) + + assert user.principals == ["user:A"] + roles + + +def test_user_repr(): + from ordr3.models import User + + user = User(*list("ABCDEFG")) + + assert repr(user) == "" diff --git a/tests/test_repo.py b/tests/test_repo.py index a3041b0..093a132 100644 --- a/tests/test_repo.py +++ b/tests/test_repo.py @@ -1,75 +1,78 @@ import pytest -@pytest.fixture -def prefilled_repo(): - from datetime import datetime, timedelta - from itertools import count - from ordr3.models import OrderItem, OrderStatus - from ordr3.repo import FakeOrderRepository - - i = count() - catalog = { - 1: "Ethanol", - 2: "Aceton", - 3: "NaOH", - 4: "Coffee", - 5: "Water", - } - - def _create_order(item, date, status): - order = OrderItem(next(i), catalog[item], item, "", "", "", "", "") - order.created_on = date - order.status = status - return order - - month = timedelta(days=30) - today = datetime.now() - - repo = FakeOrderRepository() - # should be consumables - repo.add(_create_order(1, today - month * 1, OrderStatus.ORDERED)) - repo.add(_create_order(1, today - month * 2, OrderStatus.ORDERED)) - repo.add(_create_order(1, today - month * 3, OrderStatus.COMPLETED)) - repo.add(_create_order(2, today - month * 1, OrderStatus.ORDERED)) - repo.add(_create_order(2, today - month * 2, OrderStatus.ORDERED)) - repo.add(_create_order(2, today - month * 3, OrderStatus.COMPLETED)) - # no consumable, only two repeats - repo.add(_create_order(3, today - month * 1, OrderStatus.ORDERED)) - repo.add(_create_order(3, today - month * 2, OrderStatus.ORDERED)) - # no consumable, only two repeats in the last two years - repo.add(_create_order(4, today - month * 1, OrderStatus.ORDERED)) - repo.add(_create_order(4, today - month * 2, OrderStatus.ORDERED)) - repo.add(_create_order(4, today - month * 50, OrderStatus.ORDERED)) - # no consumable, one order on hold - repo.add(_create_order(5, today - month * 1, OrderStatus.ORDERED)) - repo.add(_create_order(5, today - month * 2, OrderStatus.ORDERED)) - repo.add(_create_order(5, today - month * 3, OrderStatus.HOLD)) - - return repo - - -def test_fakerepo_add(): - from ordr3.repo import FakeOrderRepository - - repo = FakeOrderRepository() - repo.add("Something") - - assert len(repo._orders) == 1 - assert repo._orders[0] == "Something" - - -def test_fakerepo_get(prefilled_repo): - reference = 8 - - result = prefilled_repo.get(reference) - - assert result.id == reference - - -def test_fakerepo_find_consumables(prefilled_repo): - - result = prefilled_repo.find_consumables() - - assert len(result) == 2 - assert result == [prefilled_repo._orders[3], prefilled_repo._orders[0]] +@pytest.fixture() +def example_orders(): + from datetime import datetime + from ordr3.models import OrderItem, OrderCategory, OrderStatus + + return [ + OrderItem( + 1, + "Ethanol", + "1-23", + "VWR", + OrderCategory.SOLVENT, + "5 l", + 20.0, + 2, + "EUR", + "DFG", + "urgent", + datetime(2020, 2, 3, 15, 14, 13), + "me", + OrderStatus.OPEN, + ), + OrderItem( + 2, + "Gloves", + "12-3", + "Carl Roth", + OrderCategory.DISPOSABLE, + "100 St.", + 40.0, + 5, + "USD", + "BMBF", + "no comment", + datetime(2020, 2, 4, 15, 14, 13), + "you", + OrderStatus.APPROVAL, + ), + ] + + +def test_sql_repo_add(session, example_orders): + from ordr3.repo import SqlAlchemyRepository + from ordr3.models import OrderItem + + repo = SqlAlchemyRepository(session) + repo.add(example_orders[0]) + session.flush() + + order = session.query(OrderItem).first() + + assert order == example_orders[0] + + +def test_sql_repo_get(session, example_orders): + from ordr3.repo import SqlAlchemyRepository + + repo = SqlAlchemyRepository(session) + repo.add(example_orders[0]) + repo.add(example_orders[1]) + session.flush() + + assert example_orders[1] == repo.get(2) + + +def test_sql_repo_list(session, example_orders): + from ordr3.repo import SqlAlchemyRepository + + earlier, later = example_orders + repo = SqlAlchemyRepository(session) + repo.add(earlier) + repo.add(later) + session.flush() + + assert repo.list() == [later, earlier] diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..e8c81c0 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,99 @@ +from datetime import datetime, timedelta + +import pytest + +from ordr3.repo import AbstractOrderRepository + + +class FakeOrderRepository(AbstractOrderRepository): + """ Repository implementation for testing """ + + def __init__(self, session): + self._orders = set() + + def add(self, order): + """ add an order to the datastore """ + self._orders.add(order) + + def get(self, reference): + """ retrieve an order from the datastore """ + return next(o for o in self._orders if o.id == reference) + + def list(self): + return sorted(self._orders, reverse=True, key=lambda x: x.created_on) + + +@pytest.fixture +def prefilled_repo(): + from itertools import count + from ordr3.models import OrderItem, OrderStatus + + i = count() + catalog = { + 1: "Ethanol", + 2: "Aceton", + 3: "NaOH", + 4: "Coffee", + 5: "Water", + } + + def _create_order(item, date, status): + order = OrderItem(next(i), catalog[item], item, "", "", "", "", "") + order.created_on = date + order.status = status + return order + + month = timedelta(days=30) + today = datetime.now() + + repo = FakeOrderRepository(session=None) + # should be consumables + repo.add(_create_order(1, today - month * 1, OrderStatus.ORDERED)) + repo.add(_create_order(1, today - month * 2, OrderStatus.ORDERED)) + repo.add(_create_order(1, today - month * 3, OrderStatus.COMPLETED)) + repo.add(_create_order(2, today - month * 1, OrderStatus.ORDERED)) + repo.add(_create_order(2, today - month * 2, OrderStatus.ORDERED)) + repo.add(_create_order(2, today - month * 3, OrderStatus.COMPLETED)) + # no consumable, only two repeats + repo.add(_create_order(3, today - month * 1, OrderStatus.ORDERED)) + repo.add(_create_order(3, today - month * 2, OrderStatus.ORDERED)) + # no consumable, only two repeats in the last two years + repo.add(_create_order(4, today - month * 1, OrderStatus.ORDERED)) + repo.add(_create_order(4, today - month * 2, OrderStatus.ORDERED)) + repo.add(_create_order(4, today - month * 50, OrderStatus.ORDERED)) + # no consumable, one order on hold + repo.add(_create_order(5, today - month * 1, OrderStatus.ORDERED)) + repo.add(_create_order(5, today - month * 2, OrderStatus.ORDERED)) + repo.add(_create_order(5, today - month * 3, OrderStatus.HOLD)) + + return repo + + +def test_service_find_consumables(prefilled_repo): + from ordr3.services import find_consumables + + result = find_consumables(prefilled_repo) + + assert len(result) == 2 + assert [o.id for o in result] == [3, 0] + + +def test_create_log_entry(prefilled_repo): + from ordr3.services import create_log_entry + from ordr3.models import OrderStatus, User + + order = prefilled_repo.get(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 log_entry.user_id == "A" + 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