Category overhaul #20

Merged
mathias2784 merged 18 commits from category-overhaul into master 2026-10-27 18:08:10 +00:00
5 changed files with 207 additions and 95 deletions
Showing only changes of commit d4ebab96d6 - Show all commits
+8 -6
View File
@@ -7,15 +7,17 @@
uv sync uv sync
# Run migrations # Run migrations
alembic upgrade head uv run alembic upgrade head
# Run all tests # Run all tests
pytest uv run pytest
# Run a single test file # Run a single test file
pytest tests/test_model_b4wexport.py -v uv run pytest tests/test_model_b4wexport.py -v
``` ```
Use `uv run` for project commands so they use the project-managed Python environment and dependencies.
## Project Structure ## Project Structure
- **`src/`**: Application code - **`src/`**: Application code
@@ -53,10 +55,10 @@ with get_session() as session:
```bash ```bash
# Tests automatically run migrations via conftest fixture # Tests automatically run migrations via conftest fixture
pytest tests/ -v --tb=short uv run pytest tests/ -v --tb=short
# To test without migrations (in-memory SQLite), use DATABASE_URL="sqlite://" # To test without migrations (in-memory SQLite), use DATABASE_URL="sqlite://"
export DATABASE_URL="sqlite://" pytest tests/test_db.py DATABASE_URL="sqlite://" uv run pytest tests/test_db.py
``` ```
### Environment Variables ### Environment Variables
@@ -129,7 +131,7 @@ item = B4wExportFactory.build( # build() for partial, create() for full
## Testing Tips ## Testing Tips
- Run single test: `pytest tests/test_model_b4wexport.py -v` - Run single test: `uv run pytest tests/test_model_b4wexport.py -v`
- View migrations: `ls alembic/versions/` - View migrations: `ls alembic/versions/`
- Test data location: `/scripts/dummy_data/` - Test data location: `/scripts/dummy_data/`
-3
View File
@@ -57,9 +57,6 @@ dev = [
"ruff>=0.16.7", "ruff>=0.16.7",
] ]
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=term-missing"
[tool.coverage.run] [tool.coverage.run]
branch = true branch = true
source = ["src"] source = ["src"]
+34 -85
View File
@@ -75,53 +75,28 @@ def set_category_budget(session: Session, category_budget: CategoryBudget):
start1: date | None, end1: date | None, start2: date | None, end2: date | None start1: date | None, end1: date | None, start2: date | None, end2: date | None
) -> bool: ) -> bool:
"""Check if two date ranges overlap.""" """Check if two date ranges overlap."""
# Both completely unbounded - always overlap range1_unbounded = start1 is None and end1 is None
if start1 is None and end1 is None and start2 is None and end2 is None: range2_unbounded = start2 is None and end2 is None
# A fully unbounded budget acts as a default alongside bounded budgets.
if range1_unbounded and range2_unbounded:
return True return True
if range1_unbounded or range2_unbounded:
# At least one range has both bounds None (truly unbounded)
# It only overlaps with another truly unbounded range, not with bounded ranges
if start1 is None and end1 is None or start2 is None and end2 is None:
return False return False
# Both ranges are bounded - standard overlap check # None on one side of a partially bounded range means infinity. Date
if end1 is None or end2 is None: # Defensive; mypy needs guards # endpoints are inclusive, so equal endpoints also overlap.
return False starts_before_end1 = end1 is None or start2 is None or start2 <= end1
# At this point, start1/start2/type narrowing ensures they're not None either starts_before_end2 = end2 is None or start1 is None or start1 <= end2
_start1, _start2 = start1, start2 # type: ignore[misc] return starts_before_end1 and starts_before_end2
return _start2 < end1 and _start1 < end2 # type: ignore[operator]
# Helper to find overlapping sub-range # Helper to find overlapping sub-range
def _overlap_range( def _overlap_range(
start1: date | None, end1: date | None, start2: date | None, end2: date | None start1: date | None, end1: date | None, start2: date | None, end2: date | None
) -> tuple[date | None, date | None]: ) -> tuple[date | None, date | None]:
"""Return the overlapping sub-range of two ranges.""" """Return the overlapping sub-range of two ranges."""
# Handle completely unbounded cases first
if start1 is None and end1 is None:
return (
(start2, end2)
if any(x is not None for x in (start2, end2))
else (None, None)
)
if start2 is None and end2 is None:
return (
(start1, end1)
if any(x is not None for x in (start1, end1))
else (None, None)
)
# Both ranges are bounded or partially unbounded (after handling fully unbounded)
o_start = max((s for s in (start1, start2) if s is not None), default=None) o_start = max((s for s in (start1, start2) if s is not None), default=None)
o_end = min((e for e in (end1, end2) if e is not None), default=None) o_end = min((e for e in (end1, end2) if e is not None), default=None)
# If either was filtered out, there's no overlap
if o_start is None or o_end is None:
return (None, None)
if o_start > o_end:
return (None, None)
return (o_start, o_end) return (o_start, o_end)
existing_budgets = ( existing_budgets = (
@@ -130,54 +105,33 @@ def set_category_budget(session: Session, category_budget: CategoryBudget):
def _remove_and_split_budget(budget: CategoryBudgetDb): def _remove_and_split_budget(budget: CategoryBudgetDb):
"""Remove the overlapping parts of an existing budget and add back non-overlapping parts.""" """Remove the overlapping parts of an existing budget and add back non-overlapping parts."""
# Check for overlap with new range
if not _ranges_overlap(new_start, new_end, budget.valid_from, budget.valid_to):
row = _add_row(
amount_eur=budget.amount_eur,
valid_from=budget.valid_from,
valid_to=budget.valid_to,
)
session.add(row) # Add the unchanged budget back
return [row] # No overlap, keep the whole existing budget
# Calculate overlap boundaries
overlap_start, overlap_end = _overlap_range( overlap_start, overlap_end = _overlap_range(
new_start, new_end, budget.valid_from, budget.valid_to new_start, new_end, budget.valid_from, budget.valid_to
) )
if overlap_start is None or overlap_end is None: # Keep the old budget's portion before the new range, if any.
# Full unbounded overlap - replace entirely with new budget if overlap_start is not None and (
row = _add_row(new_amount, new_start, new_end) budget.valid_from is None or budget.valid_from < overlap_start
session.add(row) ):
return [row] session.add(
_add_row(
# Split the budget into non-overlapping parts and add the new budget budget.amount_eur,
parts = [] budget.valid_from,
overlap_start - ONE_DAY,
# Part 1: Before the overlap (if any) )
if budget.valid_from is not None and budget.valid_from < overlap_start:
row = _add_row(
budget.amount_eur, budget.valid_from, overlap_start - ONE_DAY
) )
parts.append(row)
# Part 2: After the overlap (if any) # Keep the old budget's portion after the new range, if any.
if budget.valid_to is not None and overlap_end < budget.valid_to: if overlap_end is not None and (
row = _add_row(budget.amount_eur, overlap_end + ONE_DAY, budget.valid_to) budget.valid_to is None or overlap_end < budget.valid_to
parts.append(row) ):
session.add(
# Add the new budget _add_row(
row = _add_row(new_amount, new_start, new_end) budget.amount_eur,
parts.append(row) overlap_end + ONE_DAY,
budget.valid_to,
# Add all parts to session )
for row in parts: )
session.add(row)
return parts
# Track if new budget was already added (during overlap handling)
new_budget_added = False
# Process each existing budget # Process each existing budget
for existing_budget in existing_budgets: for existing_budget in existing_budgets:
@@ -188,12 +142,7 @@ def set_category_budget(session: Session, category_budget: CategoryBudget):
# Remove and split the overlapping budget # Remove and split the overlapping budget
session.delete(existing_budget) session.delete(existing_budget)
# Add back non-overlapping parts plus the new budget
_remove_and_split_budget(existing_budget) _remove_and_split_budget(existing_budget)
new_budget_added = True # Mark that we already added the new budget
# Always add the new budget unless it was already added during overlap handling # Add the requested budget once, even if it replaced multiple existing rows.
if not new_budget_added: session.add(_add_row(new_amount, new_start, new_end))
row = _add_row(new_amount, new_start, new_end)
session.add(row)
+3 -1
View File
@@ -16,7 +16,9 @@ CATEGORY_PATHS = tuple(
for line in ( for line in (
Path(__file__).resolve().parents[1] Path(__file__).resolve().parents[1]
/ "scripts/initial_load/categories/categories.txt" / "scripts/initial_load/categories/categories.txt"
).read_text(encoding="utf-8").splitlines() )
.read_text(encoding="utf-8")
.splitlines()
if line.strip() if line.strip()
) )
+162
View File
@@ -414,6 +414,168 @@ class TestSetCategoryBudgetDateEdgeCases:
) )
assert len(budgets) == 2 assert len(budgets) == 2
def test_shared_endpoint_is_replaced(self, db_session: Session):
"""Inclusive ranges that share a date must not overlap after replacement."""
cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit()
set_category_budget(
db_session,
CategoryBudget(
category=cat,
amount_eur=100.00,
valid_from=date(2026, 10, 1),
valid_to=date(2026, 10, 15),
),
)
set_category_budget(
db_session,
CategoryBudget(
category=cat,
amount_eur=200.00,
valid_from=date(2026, 10, 15),
valid_to=date(2026, 10, 31),
),
)
budgets = (
db_session.query(CategoryBudgetDb)
.filter_by(category_id=cat_db.id)
.order_by(CategoryBudgetDb.valid_from)
.all()
)
assert [(b.amount_eur, b.valid_from, b.valid_to) for b in budgets] == [
(100.00, date(2026, 10, 1), date(2026, 10, 14)),
(200.00, date(2026, 10, 15), date(2026, 10, 31)),
]
class TestSetCategoryBudgetRegressionCases:
def test_new_range_overlapping_multiple_budgets_is_added_once(
self, db_session: Session
):
cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit()
for start, end, amount in (
(date(2026, 10, 1), date(2026, 10, 31), 100.00),
(date(2026, 12, 1), date(2026, 12, 31), 200.00),
):
set_category_budget(
db_session,
CategoryBudget(
category=cat,
amount_eur=amount,
valid_from=start,
valid_to=end,
),
)
set_category_budget(
db_session,
CategoryBudget(
category=cat,
amount_eur=300.00,
valid_from=date(2026, 10, 1),
valid_to=date(2026, 12, 31),
),
)
budgets = (
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id).all()
)
assert len(budgets) == 1
assert budgets[0].amount_eur == 300.00
assert budgets[0].valid_from == date(2026, 10, 1)
assert budgets[0].valid_to == date(2026, 12, 31)
@pytest.mark.parametrize(
("existing_start", "existing_end", "new_start", "new_end", "expected"),
[
(
date(2026, 10, 1),
date(2026, 12, 31),
None,
date(2026, 11, 15),
[
(100.00, date(2026, 11, 16), date(2026, 12, 31)),
(200.00, None, date(2026, 11, 15)),
],
),
(
date(2026, 10, 1),
date(2026, 12, 31),
date(2026, 11, 15),
None,
[
(100.00, date(2026, 10, 1), date(2026, 11, 14)),
(200.00, date(2026, 11, 15), None),
],
),
(
None,
date(2026, 12, 31),
date(2026, 10, 1),
date(2026, 10, 31),
[
(100.00, None, date(2026, 9, 30)),
(200.00, date(2026, 10, 1), date(2026, 10, 31)),
(100.00, date(2026, 11, 1), date(2026, 12, 31)),
],
),
(
date(2026, 10, 1),
None,
date(2026, 11, 1),
date(2026, 11, 30),
[
(100.00, date(2026, 10, 1), date(2026, 10, 31)),
(200.00, date(2026, 11, 1), date(2026, 11, 30)),
(100.00, date(2026, 12, 1), None),
],
),
],
)
def test_partially_unbounded_ranges_are_split_correctly(
self,
db_session: Session,
existing_start: date | None,
existing_end: date | None,
new_start: date | None,
new_end: date | None,
expected: list[tuple[float, date | None, date | None]],
):
cat = CategoryFactory.build()
cat_db = upsert_category(db_session, cat, do_commit=False)
db_session.commit()
set_category_budget(
db_session,
CategoryBudget(
category=cat,
amount_eur=100.00,
valid_from=existing_start,
valid_to=existing_end,
),
)
set_category_budget(
db_session,
CategoryBudget(
category=cat,
amount_eur=200.00,
valid_from=new_start,
valid_to=new_end,
),
)
budgets = (
db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id).all()
)
actual = {(float(b.amount_eur), b.valid_from, b.valid_to) for b in budgets}
assert actual == set(expected)
class TestFindCategoryDb: class TestFindCategoryDb:
"""Test find_category_db function.""" """Test find_category_db function."""