Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.venv
*__pycache__*
Binary file added solution/.coverage
Binary file not shown.
8 changes: 8 additions & 0 deletions solution/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
test:
poetry run pytest

coverage:
poetry run pytest --cov=src tests/

format:
poetry run black .
48 changes: 48 additions & 0 deletions solution/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# DI US Shopping - Checkout System

## Prerequisites
- Install [Python](https://www.python.org/downloads/) (make sure you install a version that is compatible with this project, this can be found in the [pyproject file](pyproject.toml))
- Install [Poetry](https://python-poetry.org/docs/) to manage your virtual environment.
- Install [Make](https://www.gnu.org/software/make/) to take advantage of shortcuts for running tests.

## Installing Requirements
- Create your virtual environment by running the following command in a terminal:
```
python -m venv .venv
```
- Activate the virtual environment:
```
source .venv/bin/activate
```
- Install the requirements using poetry
```
poetry install
```
- Check requirements have installed successfully by running:
```
poetry show
```
All installed requirements will be returned in blue. If any are not installed they will appear as red. Try running `poetry install` again if this happens, or installing the missing packages individually.

## Running the Example script
From the command line, move to the `/solution` folder:
```
cd solution
```

Then run:
```
poetry run python example.py
```

Follow the instructions to enter the skus of items that need scanning and hit enter to print the total.

## Running Tests
- To run all tests, from the `solution` folder, run:
```
make test
```
- To run all tests and receive a coverage report, run:
```
make coverage
```
23 changes: 23 additions & 0 deletions solution/data/catalogue.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
ipd:
sku: ipd
name: Super iPad
price: 549.99
currency: dollar
mbp:
sku: mbp
name: MacBook Pro
price: 1399.99
currency: dollar
atv:
sku: atv
name: Apple TV
price: 109.50
currency: dollar
vga:
sku: vga
name: VGA adapter
price: 30.00
currency: dollar
bad-item:
sku: bad-item
name: Bad Item For Testing
16 changes: 16 additions & 0 deletions solution/data/specials.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FreeItemDeals:
- name: FreeVGAWithMacBook
sku: mbp
item_sku: vga
purchase_number_required: 1
n_free_items: 1
BulkDiscountDeals:
- name: iPadBulkDiscount
sku: ipd
purchase_number_required: 4
new_item_price: 499.99
NForMDeals:
- name: Apple3for2
sku: atv
purchase_number_required: 3
number_to_pay_for: 2
51 changes: 51 additions & 0 deletions solution/example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import json
import logging

import yaml

from src.pricing_rules import BulkDiscountDeal, NForMDeal, FreeItemDeal
from src.checkout import Checkout

logging.basicConfig(level=logging.INFO)

if __name__ == "__main__":
# load pricing rules
with open("data/specials.yaml") as f:
deals = yaml.safe_load(f)

free_item_deals = deals["FreeItemDeals"]
bulk_discount_deals = deals["BulkDiscountDeals"]
n_for_m_deals = deals["NForMDeals"]

pricing_rules = list()

for deal in free_item_deals:
pricing_rules.append(FreeItemDeal(**deal))

for deal in bulk_discount_deals:
pricing_rules.append(BulkDiscountDeal(**deal))

for deal in n_for_m_deals:
pricing_rules.append(NForMDeal(**deal))

# construct checkout object
with open("data/catalogue.yaml") as f:
catalogue = yaml.safe_load(f)

co = Checkout(pricing_rules=pricing_rules, catalogue=catalogue)

# scan things
items_to_scan = input(
"Enter the skus of the items you would like to be scanned, separated by a comma: "
)
items_to_scan = items_to_scan.replace(" ", "")
items_to_scan = items_to_scan.split(",")
for sku in items_to_scan:
co.scan(sku)
print(f"Total: ${co.total()}")

view_receipt = input(
"Do you want to see an itemized receipt with discounts applied? (y/n) "
)
if view_receipt.lower() == "y":
print(json.dumps([i.model_dump() for i in co.receipt()], indent=2))
512 changes: 512 additions & 0 deletions solution/poetry.lock

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions solution/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[tool.poetry]
name = "dius-shopping"
version = "0.1.0"
description = "Solution to the shopping coding challenge created by DIUS for DataRock."
authors = ["Richa <richa_lad@outlook.com>"]
readme = "README.md"
package-mode = false

[tool.poetry.dependencies]
python = "^3.10"
pydantic = "^2.9.2"
pyyaml = "^6.0.2"
black = "^24.8.0"
pytest = "^8.3.3"
pytest-cov = "^5.0.0"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
Empty file added solution/src/__init__.py
Empty file.
53 changes: 53 additions & 0 deletions solution/src/checkout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
from typing import List, Dict
from src.exceptions import ItemNotInCatalogue
from src.models import PricingRule, Item
import logging

logger = logging.getLogger(__name__)


class Checkout:

def __init__(self, pricing_rules: List[PricingRule], catalogue: Dict[str, Item]):
"""Creates Checkout object.

Args:
pricing_rules (List[PricingRule]): A list of rules that specify logic relating to offers.
catalogue (Dict[str, Item]): All items available, indexed by their unique sku.
"""
self.pricing_rules = pricing_rules
self.scanned_items = list()
self.catalogue = catalogue

def scan(self, item_sku: str) -> None:
"""Adds an item to the list of items being purchased

Args:
item_sku (str): Unique identifier of the item.
"""
item_attrs = self.catalogue.get(item_sku)
if item_attrs is None:
raise ItemNotInCatalogue(
f"Item with sku {item_sku} not found in catalogue."
)
item = Item(**item_attrs)
self.scanned_items.append(item)

def total(self) -> float:
"""Calculates the total sum of all scanned items, with any discounts applied.

Returns:
float: The total cost of the scanned items.
"""
self.items_with_deals = self.scanned_items.copy()
for rule in self.pricing_rules:
self.items_with_deals = rule.apply(self.items_with_deals)
return sum(item.price for item in self.items_with_deals)

def receipt(self) -> List[Item]:
"""Returns the list of all scanned items, with any discounts applied.

Returns:
List[Item]: _description_
"""
return self.items_with_deals
2 changes: 2 additions & 0 deletions solution/src/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
class ItemNotInCatalogue(Exception):
pass
33 changes: 33 additions & 0 deletions solution/src/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from typing import Any, List, Protocol
from pydantic import BaseModel


class Item(BaseModel):
sku: str
name: str
price: float
currency: str

def update(self, attr_to_update: str, new_value: Any):
"""Returns a new version of the item with the relevant field updated.

Args:
attr_to_update (str): The field to update
new_value (Any): The updated value of the field.

Returns:
Item: A new version of the Item.
"""
current = self.model_dump()
current[attr_to_update] = new_value

new = Item(**current)

return new


class PricingRule(Protocol):

def __init__(self, *args, **kwargs) -> None: ...

def apply(self, items: List[Item]) -> List[Item]: ...
Loading