Files
TimeTracker/tests/test_models/test_per_diem.py
Dries Peeters b353184a4f feat: implement advanced expense management with templates and navigation
Implement complete Advanced Expense Management feature set with UI templates,
database schema fixes, and reorganized navigation structure.

Features:
- Expense Categories: Full CRUD with budget tracking and visualization
- Mileage Tracking: Vehicle mileage entries with approval workflow
- Per Diem Management: Daily allowance claims with location-based rates
- Receipt OCR: Infrastructure for receipt scanning (utilities ready)

Database:
- Migration 037: Create expense_categories, mileage, per_diem_rates, per_diems tables
- Migration 038: Fix schema column name mismatches (trip_purpose→purpose, etc.)
- Add missing columns (description, odometer, rates, reimbursement tracking)
- Fix circular foreign key dependencies

Templates (11 new files):
- expense_categories/: list, form, view
- mileage/: list, form, view
- per_diem/: list, form, view, rates_list, rate_form

Navigation:
- Move Mileage and Per Diem to Expenses sub-pages (header buttons)
- Move Expense Categories to Admin menu only
- Remove expense management items from Finance menu

Fixes:
- Fix NoneType comparison error in expense categories utilization
- Handle None values safely in budget progress bars
- Resolve database column name mismatches

UI/UX:
- Responsive design with Tailwind CSS and dark mode support
- Real-time calculations for mileage amounts
- Color-coded budget utilization progress bars
- Status badges for approval workflow states
- Advanced filtering on all list views

Default data:
- 7 expense categories (Travel, Meals, Accommodation, etc.)
- 4 per diem rates (US, GB, DE, FR)
2025-10-31 06:21:35 +01:00

339 lines
9.2 KiB
Python

"""
Tests for PerDiem and PerDiemRate models
"""
import pytest
from datetime import date, datetime, time
from decimal import Decimal
from app import db
from app.models import PerDiem, PerDiemRate, User
@pytest.fixture
def user(client):
"""Create a test user"""
user = User(username='testuser', email='test@example.com')
user.set_password('password123')
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def rate(client):
"""Create a test per diem rate"""
rate = PerDiemRate(
country='Germany',
city='Berlin',
full_day_rate=28.00,
half_day_rate=14.00,
breakfast_rate=5.60,
lunch_rate=11.20,
dinner_rate=11.20,
incidental_rate=3.00,
currency_code='EUR',
effective_from=date(2024, 1, 1)
)
db.session.add(rate)
db.session.commit()
return rate
def test_create_per_diem_rate(client):
"""Test creating a per diem rate"""
rate = PerDiemRate(
country='France',
city='Paris',
full_day_rate=45.00,
half_day_rate=22.50,
effective_from=date(2024, 1, 1)
)
db.session.add(rate)
db.session.commit()
assert rate.id is not None
assert rate.country == 'France'
assert rate.city == 'Paris'
assert rate.full_day_rate == Decimal('45.00')
assert rate.half_day_rate == Decimal('22.50')
assert rate.is_active is True
def test_get_rate_for_location(client, rate):
"""Test getting rate for a specific location"""
found_rate = PerDiemRate.get_rate_for_location('Germany', 'Berlin', date.today())
assert found_rate is not None
assert found_rate.id == rate.id
assert found_rate.city == 'Berlin'
def test_get_rate_falls_back_to_country(client):
"""Test that rate search falls back to country rate if city not found"""
# Create country-level rate
country_rate = PerDiemRate(
country='Netherlands',
city=None, # Country-level rate
full_day_rate=35.00,
half_day_rate=17.50,
effective_from=date(2024, 1, 1)
)
db.session.add(country_rate)
db.session.commit()
# Search for a city that doesn't have a rate
found_rate = PerDiemRate.get_rate_for_location('Netherlands', 'Amsterdam', date.today())
assert found_rate is not None
assert found_rate.id == country_rate.id
assert found_rate.city is None
def test_create_per_diem_claim(client, user, rate):
"""Test creating a per diem claim"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Conference',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 23),
country='Germany',
city='Berlin',
full_day_rate=rate.full_day_rate,
half_day_rate=rate.half_day_rate,
full_days=3,
half_days=1,
breakfast_deduction=rate.breakfast_rate,
currency_code='EUR'
)
db.session.add(per_diem)
db.session.commit()
assert per_diem.id is not None
assert per_diem.trip_purpose == 'Conference'
assert per_diem.full_days == 3
assert per_diem.half_days == 1
assert per_diem.total_days == 3.5
assert per_diem.status == 'pending'
def test_per_diem_calculation(client, user, rate):
"""Test per diem amount calculation"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Business trip',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
city='Berlin',
full_day_rate=28,
half_day_rate=14,
full_days=2,
half_days=1,
breakfast_provided=0,
breakfast_deduction=0,
lunch_deduction=0,
dinner_deduction=0
)
db.session.add(per_diem)
db.session.commit()
# Calculation: (2 * 28) + (1 * 14) = 56 + 14 = 70
assert per_diem.calculated_amount == Decimal('70')
def test_per_diem_with_meal_deductions(client, user, rate):
"""Test per diem with provided meals"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Conference with meals',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
city='Berlin',
full_day_rate=28,
half_day_rate=14,
full_days=3,
half_days=0,
breakfast_provided=2,
lunch_provided=3,
dinner_provided=2,
breakfast_deduction=5.60,
lunch_deduction=11.20,
dinner_deduction=11.20
)
db.session.add(per_diem)
db.session.commit()
# Calculation: (3 * 28) - (2 * 5.60) - (3 * 11.20) - (2 * 11.20)
# = 84 - 11.20 - 33.60 - 22.40 = 16.80
assert per_diem.calculated_amount == Decimal('16.80')
def test_calculate_days_from_dates_single_day(client):
"""Test calculating days for a single day trip"""
result = PerDiem.calculate_days_from_dates(
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 20),
departure_time=time(8, 0),
return_time=time(18, 0) # 10 hours
)
assert result['full_days'] == 1
assert result['half_days'] == 0
def test_calculate_days_from_dates_multi_day(client):
"""Test calculating days for multi-day trip"""
result = PerDiem.calculate_days_from_dates(
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 23),
departure_time=time(8, 0), # Before noon = full day
return_time=time(14, 0) # After noon = full day
)
# Day 1: departure before 12:00 = full day
# Day 2-3: middle days = 2 full days
# Day 4: return after 12:00 = full day
# Total: 4 full days
assert result['full_days'] == 4
assert result['half_days'] == 0
def test_calculate_days_with_half_days(client):
"""Test calculating days with half days"""
result = PerDiem.calculate_days_from_dates(
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
departure_time=time(14, 0), # After noon = half day
return_time=time(10, 0) # Before noon = half day
)
# Day 1: departure after 12:00 = half day
# Day 2: middle day = full day
# Day 3: return before 12:00 = half day
# Total: 1 full day, 2 half days
assert result['full_days'] == 1
assert result['half_days'] == 2
def test_per_diem_approval(client, user):
"""Test per diem approval workflow"""
admin = User(username='admin', email='admin@example.com', role='admin')
admin.set_password('admin123')
db.session.add(admin)
db.session.commit()
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Business trip',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
full_day_rate=28,
half_day_rate=14,
full_days=2,
half_days=1
)
db.session.add(per_diem)
db.session.commit()
# Approve
per_diem.approve(admin.id, notes='Approved')
db.session.commit()
assert per_diem.status == 'approved'
assert per_diem.approved_by == admin.id
assert per_diem.approved_at is not None
def test_per_diem_to_dict(client, user, rate):
"""Test converting per diem to dictionary"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Test trip',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
city='Berlin',
full_day_rate=28,
half_day_rate=14,
full_days=2,
half_days=1
)
db.session.add(per_diem)
db.session.commit()
data = per_diem.to_dict()
assert data['id'] == per_diem.id
assert data['user_id'] == user.id
assert data['trip_purpose'] == 'Test trip'
assert data['country'] == 'Germany'
assert data['city'] == 'Berlin'
assert data['full_days'] == 2
assert data['half_days'] == 1
assert data['total_days'] == 2.5
def test_per_diem_recalculate(client, user):
"""Test recalculating per diem amount"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Trip',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 22),
country='Germany',
full_day_rate=28,
half_day_rate=14,
full_days=2,
half_days=0
)
db.session.add(per_diem)
db.session.commit()
initial_amount = per_diem.calculated_amount
assert initial_amount == Decimal('56')
# Change days
per_diem.full_days = 3
new_amount = per_diem.recalculate_amount()
assert new_amount == Decimal('84')
assert per_diem.calculated_amount == Decimal('84')
def test_per_diem_create_expense(client, user):
"""Test creating expense from per diem claim"""
per_diem = PerDiem(
user_id=user.id,
trip_purpose='Conference',
start_date=date(2025, 10, 20),
end_date=date(2025, 10, 23),
country='Germany',
city='Berlin',
full_day_rate=28,
half_day_rate=14,
full_days=3,
half_days=1
)
db.session.add(per_diem)
db.session.commit()
# Create expense
expense = per_diem.create_expense()
assert expense is not None
assert expense.user_id == user.id
assert expense.category == 'meals'
assert expense.amount == per_diem.calculated_amount
assert 'Berlin, Germany' in expense.title