Source code for translator_directory.forms.time_report
from __future__ import annotations
from sedate import to_timezone
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from onegov.form import Form
from onegov.form.fields import ChosenSelectField, TimeField
from onegov.translator_directory import _
from onegov.translator_directory.constants import (
HOURLY_RATE_CERTIFIED,
HOURLY_RATE_UNCERTIFIED,
TIME_REPORT_INTERPRETING_TYPES
)
from wtforms.fields import BooleanField
from wtforms.fields import DateField
from wtforms.fields import StringField
from wtforms.fields import TextAreaField
from wtforms.validators import InputRequired
from wtforms.validators import Optional
from wtforms.validators import ValidationError
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from onegov.translator_directory.models.time_report import (
TranslatorTimeReport,
)
from onegov.translator_directory.models.translator import Translator
from onegov.translator_directory.request import TranslatorAppRequest
[docs]
class TranslatorTimeReportForm(Form):
"""Form for creating/editing translator time reports."""
[docs]
assignment_type = ChosenSelectField(
label=_('Type of translation/interpreting'),
choices=[],
default='on-site',
)
[docs]
start_date = DateField(
label=_('Start date'),
validators=[InputRequired()],
default=date.today,
)
[docs]
end_date = DateField(
label=_('End date'),
validators=[InputRequired()],
default=date.today,
)
[docs]
break_time = TimeField(
label=_('Break time'),
validators=[Optional()],
default=time(0, 0),
)
[docs]
case_number = StringField(
label=_('Case number (Police)'),
validators=[InputRequired()],
description=_('Geschäftsnummer Police for linking if needed'),
)
[docs]
is_urgent = BooleanField(
label=_('Exceptionally urgent'),
description=_('25% surcharge'),
default=False,
)
[docs]
def validate_end_time(self, field: TimeField) -> None:
if not all(
[
self.start_date.data,
self.start_time.data,
self.end_date.data,
field.data,
]
):
return
assert self.start_date.data is not None
assert self.start_time.data is not None
assert self.end_date.data is not None
assert field.data is not None
start_dt = datetime.combine(self.start_date.data, self.start_time.data)
end_dt = datetime.combine(self.end_date.data, field.data)
if end_dt <= start_dt:
raise ValidationError(_('End time must be after start time'))
[docs]
def on_request(self) -> None:
self.assignment_type.choices = [
(key, self.request.translate(value))
for key, value in TIME_REPORT_INTERPRETING_TYPES.items()
]
[docs]
def get_hourly_rate(self, translator: Translator) -> Decimal:
"""Determine hourly rate based on translator certification."""
if translator.admission == 'certified':
return HOURLY_RATE_CERTIFIED
return HOURLY_RATE_UNCERTIFIED
[docs]
def get_duration_hours(self) -> Decimal:
"""Calculate duration in hours from start/end times, rounded.
Calculates raw time between start and end (ignoring breaks for now).
"""
if not all(
[
self.start_date.data,
self.start_time.data,
self.end_date.data,
self.end_time.data,
]
):
return Decimal('0')
assert self.start_date.data is not None
assert self.start_time.data is not None
assert self.end_date.data is not None
assert self.end_time.data is not None
start_dt = datetime.combine(self.start_date.data, self.start_time.data)
end_dt = datetime.combine(self.end_date.data, self.end_time.data)
duration = end_dt - start_dt
hours = Decimal(duration.total_seconds()) / Decimal(3600)
# Ensure hours is not negative
if hours < 0:
hours = Decimal('0')
# Round to nearest 0.5 hour
hours_times_two = hours * Decimal('2')
rounded = hours_times_two.to_integral_value(rounding='ROUND_CEILING')
return rounded / Decimal('2')
[docs]
def calculate_night_hours(self) -> Decimal:
"""Calculate actual hours worked during night (20:00-06:00)."""
if not all(
[
self.start_date.data,
self.start_time.data,
self.end_date.data,
self.end_time.data,
]
):
return Decimal('0')
assert self.start_date.data is not None
assert self.start_time.data is not None
assert self.end_date.data is not None
assert self.end_time.data is not None
start_dt = datetime.combine(self.start_date.data, self.start_time.data)
end_dt = datetime.combine(self.end_date.data, self.end_time.data)
# Calculate night hours by iterating through time range
night_seconds = 0
current = start_dt
while current < end_dt:
# Determine if current time is in night period (20:00-06:00)
current_time = current.time()
is_night = current_time >= time(20, 0) or current_time < time(6, 0)
if is_night:
# Find the end of current night period
if current_time >= time(20, 0):
# Night goes until 06:00 next day
period_end = datetime.combine(
current.date() + timedelta(days=1), time(6, 0)
)
else:
# We're in early morning (before 06:00)
period_end = datetime.combine(current.date(), time(6, 0))
# Calculate overlap
segment_end = min(end_dt, period_end)
night_seconds += int((segment_end - current).total_seconds())
current = segment_end
else:
# We're in day period (06:00-20:00), skip to next night
next_night = datetime.combine(current.date(), time(20, 0))
if next_night <= current:
next_night = datetime.combine(
current.date() + timedelta(days=1), time(20, 0)
)
current = min(end_dt, next_night)
# Convert to hours
night_hours = Decimal(night_seconds) / Decimal(3600)
# Round to nearest 0.5 hour (same as total duration rounding)
night_hours_times_two = night_hours * Decimal('2')
rounded = night_hours_times_two.to_integral_value(
rounding='ROUND_CEILING'
)
return rounded / Decimal('2')
[docs]
def calculate_weekend_holiday_hours(self) -> Decimal:
"""Calculate actual hours worked during weekends or public holidays.
Counts hours that fall on:
- Saturday or Sunday (any time)
- Public holidays (any time)
Returns hours as Decimal, rounded to nearest 0.5 hour.
"""
if not all(
[
self.start_date.data,
self.start_time.data,
self.end_date.data,
self.end_time.data,
]
):
return Decimal('0')
assert self.start_date.data is not None
assert self.start_time.data is not None
assert self.end_date.data is not None
assert self.end_time.data is not None
start_dt = datetime.combine(self.start_date.data, self.start_time.data)
end_dt = datetime.combine(self.end_date.data, self.end_time.data)
# Get holidays if available
holidays: set[date] = set()
if self.request and self.request.app.org.holidays:
holiday_list = self.request.app.org.holidays.between(
self.start_date.data, self.end_date.data
)
holidays = {dt for dt, _ in holiday_list}
# Calculate weekend/holiday hours by iterating through time range
weekend_holiday_seconds = 0
current = start_dt
# Process day by day
while current < end_dt:
current_date = current.date()
is_weekend = current_date.weekday() >= 5 # Sat or Sun
is_holiday = current_date in holidays
if is_weekend or is_holiday:
# Find end of current day
day_end = datetime.combine(
current_date + timedelta(days=1), time(0, 0)
)
segment_end = min(end_dt, day_end)
weekend_holiday_seconds += int(
(segment_end - current).total_seconds()
)
current = segment_end
else:
# Skip to next day
next_day = datetime.combine(
current_date + timedelta(days=1), time(0, 0)
)
current = min(end_dt, next_day)
# Convert to hours
weekend_holiday_hours = Decimal(weekend_holiday_seconds) / Decimal(
3600
)
# Round to nearest 0.5 hour
weekend_holiday_hours_times_two = weekend_holiday_hours * Decimal('2')
rounded = weekend_holiday_hours_times_two.to_integral_value(
rounding='ROUND_CEILING'
)
return rounded / Decimal('2')
[docs]
def populate_obj( # type: ignore[override]
self, obj: TranslatorTimeReport # type: ignore[override]
) -> None:
"""Populate the model from form, converting hours to minutes."""
duration_hours = self.get_duration_hours()
obj.duration = int(float(duration_hours) * 60)
[docs]
def process( # type: ignore[override]
self, formdata: object = None, obj: object = None, **kwargs: object
) -> None:
"""Process form data for editing existing time reports."""
super().process(formdata, obj, **kwargs) # type: ignore[arg-type]
if formdata is None and obj is not None:
if hasattr(obj, 'start') and obj.start:
# Convert UTC to Europe/Zurich timezone before
# extracting date/time
local_start = to_timezone(obj.start, 'Europe/Zurich')
self.start_date.data = local_start.date()
self.start_time.data = local_start.time()
elif hasattr(obj, 'assignment_date'):
self.start_date.data = obj.assignment_date
if hasattr(obj, 'end') and obj.end:
# Convert UTC to Europe/Zurich timezone before
# extracting date/time
local_end = to_timezone(obj.end, 'Europe/Zurich')
self.end_date.data = local_end.date()
self.end_time.data = local_end.time()
elif hasattr(obj, 'assignment_date'):
self.end_date.data = obj.assignment_date
if hasattr(obj, 'break_time'):
break_minutes = getattr(obj, 'break_time', 0)
if break_minutes:
hours = break_minutes // 60
minutes = break_minutes % 60
self.break_time.data = time(hours, minutes)
if hasattr(obj, 'surcharge_types'):
surcharge_types = getattr(obj, 'surcharge_types', None)
if surcharge_types:
self.is_urgent.data = 'urgent' in surcharge_types
[docs]
def get_surcharge_types(self) -> list[str]:
"""Get list of active surcharge types from form based on actual
hours."""
types: list[str] = []
if self.calculate_night_hours() > 0:
types.append('night_work')
if self.calculate_weekend_holiday_hours() > 0:
types.append('weekend_holiday')
if self.is_urgent.data:
types.append('urgent')
return types
[docs]
def get_travel_compensation(self, translator: Translator) -> Decimal:
"""Calculate travel compensation based on round trip distance.
The drive_distance is multiplied by 2 to account for the round trip
(Wegentschädigung * 2).
Returns 0 for telephonic and written assignments.
"""
if self.assignment_type.data in ('telephonic', 'schriftlich'):
return Decimal('0')
if not translator.drive_distance:
return Decimal('0')
distance = float(translator.drive_distance) * 2
if distance <= 25:
return Decimal('20')
elif distance <= 50:
return Decimal('50')
elif distance <= 100:
return Decimal('100')
else:
return Decimal('150')
[docs]
def update_model(self, model: TranslatorTimeReport) -> None:
"""Update the time report model with form data."""
from sedate import replace_timezone
assert self.start_date.data is not None
assert self.start_time.data is not None
assert self.end_date.data is not None
assert self.end_time.data is not None
model.assignment_type = self.assignment_type.data or None
duration_hours = self.get_duration_hours()
model.duration = int(float(duration_hours) * 60)
# Store break time in minutes
if self.break_time.data:
break_minutes = (
self.break_time.data.hour * 60 + self.break_time.data.minute
)
model.break_time = break_minutes
else:
model.break_time = 0
# Calculate and store night hours (in minutes)
night_hours = self.calculate_night_hours()
model.night_minutes = int(float(night_hours) * 60)
# Calculate and store weekend/holiday hours (in minutes)
weekend_holiday_hours = self.calculate_weekend_holiday_hours()
model.weekend_holiday_minutes = int(float(weekend_holiday_hours) * 60)
model.case_number = self.case_number.data or None
model.assignment_date = self.start_date.data
start_dt = datetime.combine(self.start_date.data, self.start_time.data)
end_dt = datetime.combine(self.end_date.data, self.end_time.data)
model.start = replace_timezone(start_dt, 'Europe/Zurich')
model.end = replace_timezone(end_dt, 'Europe/Zurich')
model.notes = self.notes.data or None
hourly_rate = self.get_hourly_rate(model.translator)
model.hourly_rate = hourly_rate
surcharge_types = self.get_surcharge_types()
model.surcharge_types = surcharge_types if surcharge_types else None
travel_comp = self.get_travel_compensation(model.translator)
model.travel_compensation = travel_comp
# Use centralized calculation from model
breakdown = model.calculate_compensation_breakdown()
model.total_compensation = breakdown['total']