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] request: TranslatorAppRequest
[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] start_time = TimeField( label=_('Start time'), validators=[InputRequired()], )
[docs] end_date = DateField( label=_('End date'), validators=[InputRequired()], default=date.today, )
[docs] end_time = TimeField( label=_('End time'), validators=[InputRequired()], )
[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] notes = TextAreaField( label=_('Notes'), validators=[Optional()], render_kw={'rows': 3} )
[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']