
11 changed files with 638 additions and 142 deletions
@ -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) |
@ -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) |
@ -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() |
@ -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 |
@ -1,75 +1,78 @@ |
|||||||
import pytest |
import pytest |
||||||
|
|
||||||
|
|
||||||
@pytest.fixture |
@pytest.fixture() |
||||||
def prefilled_repo(): |
def example_orders(): |
||||||
from datetime import datetime, timedelta |
from datetime import datetime |
||||||
from itertools import count |
from ordr3.models import OrderItem, OrderCategory, OrderStatus |
||||||
from ordr3.models import OrderItem, OrderStatus |
|
||||||
from ordr3.repo import FakeOrderRepository |
return [ |
||||||
|
OrderItem( |
||||||
i = count() |
1, |
||||||
catalog = { |
"Ethanol", |
||||||
1: "Ethanol", |
"1-23", |
||||||
2: "Aceton", |
"VWR", |
||||||
3: "NaOH", |
OrderCategory.SOLVENT, |
||||||
4: "Coffee", |
"5 l", |
||||||
5: "Water", |
20.0, |
||||||
} |
2, |
||||||
|
"EUR", |
||||||
def _create_order(item, date, status): |
"DFG", |
||||||
order = OrderItem(next(i), catalog[item], item, "", "", "", "", "") |
"urgent", |
||||||
order.created_on = date |
datetime(2020, 2, 3, 15, 14, 13), |
||||||
order.status = status |
"me", |
||||||
return order |
OrderStatus.OPEN, |
||||||
|
), |
||||||
month = timedelta(days=30) |
OrderItem( |
||||||
today = datetime.now() |
2, |
||||||
|
"Gloves", |
||||||
repo = FakeOrderRepository() |
"12-3", |
||||||
# should be consumables |
"Carl Roth", |
||||||
repo.add(_create_order(1, today - month * 1, OrderStatus.ORDERED)) |
OrderCategory.DISPOSABLE, |
||||||
repo.add(_create_order(1, today - month * 2, OrderStatus.ORDERED)) |
"100 St.", |
||||||
repo.add(_create_order(1, today - month * 3, OrderStatus.COMPLETED)) |
40.0, |
||||||
repo.add(_create_order(2, today - month * 1, OrderStatus.ORDERED)) |
5, |
||||||
repo.add(_create_order(2, today - month * 2, OrderStatus.ORDERED)) |
"USD", |
||||||
repo.add(_create_order(2, today - month * 3, OrderStatus.COMPLETED)) |
"BMBF", |
||||||
# no consumable, only two repeats |
"no comment", |
||||||
repo.add(_create_order(3, today - month * 1, OrderStatus.ORDERED)) |
datetime(2020, 2, 4, 15, 14, 13), |
||||||
repo.add(_create_order(3, today - month * 2, OrderStatus.ORDERED)) |
"you", |
||||||
# no consumable, only two repeats in the last two years |
OrderStatus.APPROVAL, |
||||||
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)) |
def test_sql_repo_add(session, example_orders): |
||||||
repo.add(_create_order(5, today - month * 2, OrderStatus.ORDERED)) |
from ordr3.repo import SqlAlchemyRepository |
||||||
repo.add(_create_order(5, today - month * 3, OrderStatus.HOLD)) |
from ordr3.models import OrderItem |
||||||
|
|
||||||
return repo |
repo = SqlAlchemyRepository(session) |
||||||
|
repo.add(example_orders[0]) |
||||||
|
session.flush() |
||||||
def test_fakerepo_add(): |
|
||||||
from ordr3.repo import FakeOrderRepository |
order = session.query(OrderItem).first() |
||||||
|
|
||||||
repo = FakeOrderRepository() |
assert order == example_orders[0] |
||||||
repo.add("Something") |
|
||||||
|
|
||||||
assert len(repo._orders) == 1 |
def test_sql_repo_get(session, example_orders): |
||||||
assert repo._orders[0] == "Something" |
from ordr3.repo import SqlAlchemyRepository |
||||||
|
|
||||||
|
repo = SqlAlchemyRepository(session) |
||||||
def test_fakerepo_get(prefilled_repo): |
repo.add(example_orders[0]) |
||||||
reference = 8 |
repo.add(example_orders[1]) |
||||||
|
session.flush() |
||||||
result = prefilled_repo.get(reference) |
|
||||||
|
assert example_orders[1] == repo.get(2) |
||||||
assert result.id == reference |
|
||||||
|
|
||||||
|
def test_sql_repo_list(session, example_orders): |
||||||
def test_fakerepo_find_consumables(prefilled_repo): |
from ordr3.repo import SqlAlchemyRepository |
||||||
|
|
||||||
result = prefilled_repo.find_consumables() |
earlier, later = example_orders |
||||||
|
repo = SqlAlchemyRepository(session) |
||||||
assert len(result) == 2 |
repo.add(earlier) |
||||||
assert result == [prefilled_repo._orders[3], prefilled_repo._orders[0]] |
repo.add(later) |
||||||
|
session.flush() |
||||||
|
|
||||||
|
assert repo.list() == [later, earlier] |
||||||
|
@ -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 |
Loading…
Reference in new issue