Skip to content
Merged
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
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,4 @@ python -m http.server --directory coverage_html 8000
```
→ open **http://localhost:8000**

![Coverage Report](./docs/test-screenshots/files_test.png)
![Coverage Report](./docs/test-screenshots/functions_test.png)
![Coverage Report](./docs/test-screenshots/classes_test.png)
![Coverage Report](./docs/test-screenshots/api-coverage-test-report.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed docs/test-screenshots/classes_test.png
Binary file not shown.
Binary file removed docs/test-screenshots/files_test.png
Binary file not shown.
Binary file removed docs/test-screenshots/functions_test.png
Binary file not shown.
29 changes: 21 additions & 8 deletions entities/migrations/0003_entity_unit_price.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,32 @@
def forwards(apps, schema_editor):
Entity = apps.get_model('entities', 'Entity')
for obj in Entity.objects.all():
# convert existing price_cents (int) to unit_price Decimal
cents = getattr(obj, 'price_cents', None)
if cents is not None:
obj.unit_price = Decimal(cents) / Decimal('100')
# convert existing price_cents (int) to unit_price Decimal (defensive)
try:
cents = getattr(obj, 'price_cents', None)
if cents is None:
continue
# handle strings/decimals/ints
d = Decimal(str(cents))
obj.unit_price = (d / Decimal('100')).quantize(Decimal('0.0001'))
obj.save(update_fields=['unit_price'])
except Exception:
# skip problematic rows but continue migration
continue


def backwards(apps, schema_editor):
Entity = apps.get_model('entities', 'Entity')
for obj in Entity.objects.all():
val = getattr(obj, 'unit_price', None)
if val is not None:
obj.price_cents = int((Decimal(val) * Decimal('100')).to_integral_value())
try:
val = getattr(obj, 'unit_price', None)
if val is None:
continue
d = Decimal(str(val))
obj.price_cents = int((d * Decimal('100')).to_integral_value())
obj.save(update_fields=['price_cents'])
except Exception:
continue


class Migration(migrations.Migration):
Expand All @@ -31,7 +43,8 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='entity',
name='unit_price',
field=models.DecimalField(decimal_places=4, default=Decimal('0.0000'), max_digits=12),
# use a serializable default (string) in migration to avoid import-time issues
field=models.DecimalField(decimal_places=4, default='0.0000', max_digits=12),
),
migrations.RunPython(forwards, backwards),
migrations.RemoveField(
Expand Down
22 changes: 18 additions & 4 deletions entities/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,25 @@ class Entity(models.Model):
def __str__(self):
return f"{self.type}: {self.name or self.pk}"

@property
def unit_price_decimal(self) -> Decimal:
"""Return the stored unit_price as Decimal."""
# ensure we always return a Decimal instance
val = self.unit_price
try:
return Decimal(val)
except Exception:
return Decimal(str(val or '0'))

@property
def unit_price_float(self) -> float:
"""Return unit price as float for convenience (not used for storage)."""
return float(self.unit_price or Decimal('0'))
"""Return the stored unit_price as float (convenience only)."""
return float(self.unit_price_decimal)

def price_cents_truncated(self) -> int:
"""Return price in cents after truncating to 2 decimal places (no rounding)."""
val = (Decimal(self.unit_price or Decimal('0'))).quantize(Decimal('0.01'), rounding=ROUND_DOWN)
"""Return price in cents after truncating to 2 decimal places (no rounding).

Example: unit_price = Decimal('1.8640') -> truncates to 1.86 -> returns 186
"""
val = self.unit_price_decimal.quantize(Decimal('0.01'), rounding=ROUND_DOWN)
return int((val * 100).to_integral_value())
38 changes: 24 additions & 14 deletions entities/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ def list_entities(
}

def apply(qs, field, value, op):
if not value:
if value is None:
return qs
# treat empty string as no-filter
if isinstance(value, str) and value.strip() == "":
return qs
if op == "notcontains":
return qs.exclude(**{f"{field}__icontains": value})
Expand All @@ -40,13 +43,17 @@ def create_entity(data: Dict[str, Any]) -> Entity:
unit = data.pop('unit_price', None)
if unit is not None:
# accept both 50.99 and '50,99' by normalizing comma to dot
unit_str = str(unit).replace(',', '.')
try:
d = Decimal(unit_str)
except (InvalidOperation, ValueError):
d = Decimal(float(unit_str))
# store with 4 decimal places to preserve measurement precision, truncate (ROUND_DOWN)
data['unit_price'] = d.quantize(Decimal('0.0001'), rounding=ROUND_DOWN)
unit_str = str(unit).replace(',', '.').strip()
# treat empty string as no-value
if unit_str == "":
unit = None
else:
try:
d = Decimal(unit_str)
except (InvalidOperation, ValueError):
d = Decimal(float(unit_str))
# store with 4 decimal places to preserve measurement precision, truncate (ROUND_DOWN)
data['unit_price'] = d.quantize(Decimal('0.0001'), rounding=ROUND_DOWN)
with transaction.atomic():
return Entity.objects.create(**data)

Expand All @@ -55,12 +62,15 @@ def update_entity(entity_id: int, data: Dict[str, Any]) -> Entity:
# support updating via `unit_price` as well
unit = data.pop('unit_price', None)
if unit is not None:
unit_str = str(unit).replace(',', '.')
try:
d = Decimal(unit_str)
except (InvalidOperation, ValueError):
d = Decimal(float(unit_str))
data['unit_price'] = d.quantize(Decimal('0.0001'), rounding=ROUND_DOWN)
unit_str = str(unit).replace(',', '.').strip()
if unit_str == "":
unit = None
else:
try:
d = Decimal(unit_str)
except (InvalidOperation, ValueError):
d = Decimal(float(unit_str))
data['unit_price'] = d.quantize(Decimal('0.0001'), rounding=ROUND_DOWN)

obj = Entity.objects.get(pk=entity_id)
for k, v in data.items():
Expand Down
83 changes: 83 additions & 0 deletions tests/test_exhaustive_branches.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import pytest
from decimal import Decimal

from entities import services
from entities.models import Entity


@pytest.mark.django_db
def test_model_helpers_direct_access():
obj = services.create_entity({"type": "test", "unit_price": "1.864"})
# access Decimal property
dec = obj.unit_price_decimal
assert isinstance(dec, Decimal)
# access float helper
f = obj.unit_price_float
assert isinstance(f, float)
# truncated cents
cents = obj.price_cents_truncated()
assert isinstance(cents, int)
assert cents == 186


@pytest.mark.django_db
def test_services_create_update_variants_cover_branches():
# integers and floats
e1 = services.create_entity({"type": "v", "unit_price": 50})
e2 = services.create_entity({"type": "v", "unit_price": 50.0})
e3 = services.create_entity({"type": "v", "unit_price": "50.50"})
e4 = services.create_entity({"type": "v", "unit_price": "50,50"})

assert e1.unit_price == Decimal("50.0000")
assert e2.unit_price == Decimal("50.0000")
assert e3.unit_price == Decimal("50.5000")
assert e4.unit_price == Decimal("50.5000")

# update with different forms
updated = services.update_entity(e1.id, {"unit_price": "10,12345"})
assert updated.unit_price == Decimal("10.1234")

# update with empty string should not change
before = e2.unit_price
updated2 = services.update_entity(e2.id, {"unit_price": ""})
assert updated2.unit_price == before

# update with None should not change
updated3 = services.update_entity(e3.id, {"unit_price": None})
assert updated3.unit_price == e3.unit_price


@pytest.mark.django_db
def test_list_entities_all_ops_exercised():
# prepare data
for e in list(services.list_entities()):
e.delete()

services.create_entity({"type": "tool", "name": "hammer", "description": "heavy tool"})
services.create_entity({"type": "toolbox", "name": "box", "description": "container"})
services.create_entity({"type": "widget", "name": "w1", "description": "blue widget"})

# contains (default)
r1 = services.list_entities(name="amm")
assert any(getattr(x, 'name', '') == 'hammer' for x in r1)

# startswith
r2 = services.list_entities(name="ham", name_op="startswith")
assert any(getattr(x, 'name', '') == 'hammer' for x in r2)

# equals on type
r3 = services.list_entities(type="tool", type_op="equals")
assert all(x.type == "tool" for x in r3)

# endswith on description
r4 = services.list_entities(description="widget", description_op="endswith")
assert any((x.description or '').endswith("widget") for x in r4)

# notcontains
r5 = services.list_entities(name="box", name_op="notcontains")
assert all((x.name is None) or ("box" not in x.name) for x in r5)

# empty string and None filters
all_none = services.list_entities(name=None)
all_empty = services.list_entities(name="")
assert all_none.count() == all_empty.count()
102 changes: 102 additions & 0 deletions tests/test_migration_and_excepts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import importlib
from decimal import Decimal

import pytest

from entities import services
from entities.models import Entity


@pytest.mark.django_db
def test_create_entity_forced_decimal_invalid_monkeypatch(monkeypatch):
# Force Decimal(...) to raise for the specific string so except branch is used
orig_D = services.Decimal

def fake_D(x):
# raise InvalidOperation when called with that exact string
if isinstance(x, str) and x == "50.99":
raise services.InvalidOperation
return orig_D(x)

monkeypatch.setattr(services, 'Decimal', fake_D)

obj = services.create_entity({"type": "x", "unit_price": "50.99"})
# despite fake raising on first call, float conversion path should succeed
assert obj.unit_price == Decimal('50.9900')


@pytest.mark.django_db
def test_update_entity_forced_decimal_invalid(monkeypatch):
orig_D = services.Decimal

def fake_D(x):
if isinstance(x, str) and x == "10.12345":
raise services.InvalidOperation
return orig_D(x)

obj = services.create_entity({"type": "u"})
monkeypatch.setattr(services, 'Decimal', fake_D)
updated = services.update_entity(obj.id, {"unit_price": "10.12345"})
assert updated.unit_price == Decimal('10.1234')


def _make_fake_objs(vals, attr_name):
class Obj:
def __init__(self, v):
setattr(self, attr_name, v)

def save(self, update_fields=None):
# pretend to save
pass

return [Obj(v) for v in vals]


def test_migration_runpython_forwards_and_backwards():
mod = importlib.import_module('entities.migrations.0003_entity_unit_price')

class FakeManager:
def __init__(self, objs):
self._objs = objs

def all(self):
return list(self._objs)

class FakeModel:
def __init__(self, objs):
self.objects = FakeManager(objs)

class Apps:
def __init__(self, model):
self._model = model

def get_model(self, app, name):
return self._model

# create fake objects with various price_cents
objs = _make_fake_objs([100, '200', None, 'bad'], 'price_cents')
fake_model = FakeModel(objs)
apps = Apps(fake_model)

# forwards should run and skip problematic rows
mod.forwards(apps, None)

# now test backwards with unit_price values
objs2 = _make_fake_objs([Decimal('1.23'), '4.56', None, 'bad'], 'unit_price')
fake_model2 = FakeModel(objs2)
apps2 = Apps(fake_model2)

mod.backwards(apps2, None)


@pytest.mark.django_db
def test_model_unit_price_decimal_variants():
e = services.create_entity({"type": "p", "unit_price": "3.1415"})
assert e.unit_price_decimal == Decimal('3.1415')

# force string stored in DB (simulate weird value)
e.unit_price = '2.5'
assert e.unit_price_decimal == Decimal('2.5')

e.unit_price = None
assert e.unit_price_decimal == Decimal('0')
Loading