from __future__ import annotations
from functools import cached_property
from markupsafe import Markup, escape
from onegov.translator_directory.models.time_report import TranslatorTimeReport
from onegov.core.elements import Link, LinkGroup
from onegov.core.templates import render_macro
from onegov.core.utils import linkify
from onegov.org import _
from onegov.org.models.ticket import OrgTicketMixin
from onegov.ticket import Handler
from onegov.ticket import handlers
from onegov.ticket import Ticket
from onegov.core.elements import Intercooler
from onegov.translator_directory.collections.documents import (
TranslatorDocumentCollection)
from onegov.translator_directory.constants import (
TIME_REPORT_INTERPRETING_TYPES,
ASSIGNMENT_LOCATIONS,
FINANZSTELLE
)
from onegov.translator_directory.layout import AccreditationLayout
from onegov.translator_directory.layout import TranslatorLayout
from onegov.translator_directory.models.accreditation import Accreditation
from onegov.translator_directory.models.mutation import TranslatorMutation
from onegov.translator_directory.models.translator import Translator
from onegov.translator_directory.constants import TRANSLATOR_FA_ICON
from onegov.translator_directory.utils import get_custom_text
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from onegov.org.request import OrgRequest
from onegov.translator_directory.request import TranslatorAppRequest
[docs]
class TranslatorMutationTicket(OrgTicketMixin, Ticket):
[docs]
__mapper_args__ = {'polymorphic_identity': 'TRN'} # type:ignore
if TYPE_CHECKING:
[docs]
handler: TranslatorMutationHandler
[docs]
def reference_group(self, request: OrgRequest) -> str:
return self.title
@handlers.registered_handler('TRN')
[docs]
class TranslatorMutationHandler(Handler):
[docs]
handler_title = _('Translator')
[docs]
code_title = _('Translators')
@cached_property
[docs]
def translator(self) -> Translator | None:
return self.session.query(Translator).filter_by(
id=self.data['handler_data'].get('id')
).first()
@cached_property
[docs]
def mutation(self) -> TranslatorMutation | None:
if self.translator:
return TranslatorMutation(
self.session, self.translator.id, self.ticket.id
)
return None
@property
[docs]
def deleted(self) -> bool:
return self.translator is None
@property
[docs]
def ticket_deletable(self) -> bool:
# For now we don't support this because lots of functionality
# depends on data in translator tickets
if self.deleted:
return True
return False
@cached_property
[docs]
def email(self) -> str:
return self.data['handler_data'].get('submitter_email', '')
@cached_property
[docs]
def message(self) -> str:
return self.data['handler_data'].get('submitter_message', '')
@cached_property
[docs]
def proposed_changes(self) -> dict[str, Any]:
return self.data['handler_data'].get('proposed_changes', {})
@cached_property
[docs]
def state(self) -> str | None:
return self.data.get('state')
@cached_property
[docs]
def uploaded_files(self) -> list[Any]:
from onegov.file import File
file_ids = self.data['handler_data'].get('file_ids', [])
if not file_ids:
return []
return [
self.session.query(File).filter_by(id=fid).first()
for fid in file_ids
]
@property
[docs]
def title(self) -> str:
return self.translator.title if self.translator else '<Deleted>'
@property
[docs]
def subtitle(self) -> str:
return _('Mutation')
@cached_property
[docs]
def group(self) -> str:
return _('Translator')
[docs]
def get_summary(
self,
request: TranslatorAppRequest # type:ignore[override]
) -> Markup:
assert self.mutation is not None
assert self.translator is not None
layout = TranslatorLayout(self.translator, request)
changes = self.mutation.translated(request, self.proposed_changes)
return render_macro(
layout.macros['display_translator_mutation'],
request,
{
'translator': self.translator,
'message': linkify(self.message).replace('\n', Markup('<br>')),
'changes': changes,
'layout': layout,
'uploaded_files': self.uploaded_files,
},
)
[docs]
def get_links( # type:ignore[override]
self,
request: TranslatorAppRequest # type:ignore[override]
) -> list[Link | LinkGroup]:
if self.deleted:
return []
links: list[Link | LinkGroup] = [
Link(
text=_('Edit translator'),
url=request.return_here(
request.link(self.translator, 'edit')
),
attrs={'class': 'edit-link'}
),
Link(
_('Mail templates'),
url=request.link(
self.translator, name='mail-templates'
),
attrs={'class': 'envelope'},
)
]
if self.proposed_changes and self.state is None:
links.append(
Link(
text=_('Apply proposed changes'),
url=request.return_here(
request.link(self.mutation, 'apply')
),
attrs={'class': 'accept-link'},
)
)
return links
[docs]
class TimeReportTicket(OrgTicketMixin, Ticket):
[docs]
__mapper_args__ = {'polymorphic_identity': 'TRP'} # type:ignore
[docs]
es_type_name = 'translator_time_reports'
[docs]
def reference_group(self, request: OrgRequest) -> str:
return self.title
@handlers.registered_handler('TRP')
[docs]
class TimeReportHandler(Handler):
[docs]
handler_title = _('Time Report')
[docs]
code_title = _('Time Reports')
@cached_property
[docs]
def translator(self) -> Translator | None:
return (
self.session.query(Translator)
.filter_by(id=self.data['handler_data'].get('translator_id'))
.first()
)
@cached_property
[docs]
def time_report(self) -> TranslatorTimeReport | None:
time_report_id = self.data['handler_data'].get('time_report_id')
if not time_report_id:
return None
return (
self.session.query(TranslatorTimeReport)
.filter_by(id=time_report_id)
.first()
)
@property
[docs]
def deleted(self) -> bool:
return self.translator is None
@property
[docs]
def ticket_deletable(self) -> bool:
if self.deleted:
return True
if self.time_report and self.time_report.status == 'confirmed':
return True
return False
@cached_property
[docs]
def email(self) -> str:
return self.data['handler_data'].get('submitter_email', '')
@cached_property
[docs]
def state(self) -> str | None:
return self.data.get('state')
@property
[docs]
def title(self) -> str:
return self.translator.title if self.translator else '<Deleted>'
@property
[docs]
def subtitle(self) -> str:
return _('Time Report')
@cached_property
[docs]
def group(self) -> str:
return _('Time Report')
[docs]
def get_summary(
self, request: TranslatorAppRequest # type:ignore[override]
) -> Markup:
if not self.translator or not self.time_report:
return Markup('')
layout = TranslatorLayout(self.translator, request)
report = self.time_report
# Status badge at the top
if report.status == 'confirmed':
status_class = 'success'
status_text = request.translate(_('Confirmed'))
else:
status_class = 'warning'
status_text = request.translate(_('Pending'))
status_badge = (
f'<div class="alert-box callout {status_class}" '
f'style="margin-bottom: 1rem;">'
f'<strong>{request.translate(_("Status"))}: </strong>'
f'{status_text}'
f'</div>'
)
translator_link = (
f'<a href="{request.link(self.translator)}">'
f'{escape(self.translator.title)}</a>'
)
start_time = escape(layout.format_date(report.start, 'datetime'))
end_time = escape(layout.format_date(report.end, 'datetime'))
time_range = f'{start_time} - {end_time}'
assignment_type_key = report.assignment_type
assignment_type_translated = '-'
if assignment_type_key:
assignment_type_translated = request.translate(
TIME_REPORT_INTERPRETING_TYPES[assignment_type_key]
)
assignment_date_formatted = escape(
layout.format_date(report.assignment_date, 'date')
)
summary_parts = [
status_badge,
"<dl class='field-display'>",
f"<dt>{request.translate(_('Translator'))}</dt>",
f'<dd>{translator_link}</dd>',
f"<dt>{request.translate(_('Time'))}</dt>",
f'<dd>{time_range}</dd>',
f"<dt>{request.translate(_('Assignment Date'))}</dt>",
f'<dd>{assignment_date_formatted}</dd>',
]
# Display assignment location if available
if report.assignment_location:
location_name, address = ASSIGNMENT_LOCATIONS.get(
report.assignment_location,
(report.assignment_location, '')
)
location_display = f'{escape(location_name)}'
if address:
location_display += f'<br>{escape(address)}'
summary_parts.extend(
[
f"<dt>{request.translate(_('Assignment Location'))}</dt>",
f'<dd>{location_display}</dd>',
]
)
summary_parts.extend(
[
f"<dt>{request.translate(_('Type'))}</dt>",
f'<dd>{escape(assignment_type_translated)}</dd>',
]
)
if report.finanzstelle:
finanzstelle_name = FINANZSTELLE[report.finanzstelle].name
summary_parts.extend(
[
f"<dt>{request.translate(_('Finanzstelle'))}</dt>",
f'<dd>{escape(finanzstelle_name)}</dd>',
]
)
if report.case_number:
summary_parts.extend(
[
f"<dt>{request.translate(_('Case Number'))}</dt>",
f'<dd>{escape(report.case_number)}</dd>',
]
)
if report.notes:
notes_html = linkify(report.notes).replace('\n', Markup('<br>'))
summary_parts.extend(
[
f"<dt>{request.translate(_('Notes'))}</dt>",
f'<dd>{notes_html}</dd>',
]
)
summary_parts.extend(
[
f"<dt>{request.translate(_('Hourly Rate'))}</dt>",
f'<dd>{layout.format_currency(report.hourly_rate)}</dd>',
f"<dt>{request.translate(_('Duration'))}</dt>",
f'<dd>{report.duration_hours} h</dd>',
]
)
# Use centralized calculation from model
breakdown = report.calculate_compensation_breakdown()
# Show day/night breakdown if there are night hours
if report.night_minutes > 0:
day_hours = report.day_hours_decimal
night_hours = report.night_hours_decimal
summary_parts.extend(
[
f"<dt>{request.translate(_('Day hours'))} "
f"({layout.format_currency(report.hourly_rate)} × "
f"{day_hours} h)</dt>",
f'<dd>{layout.format_currency(breakdown["day_pay"])}</dd>',
f"<dt>{request.translate(_('Night hours 20-06'))} "
f"({layout.format_currency(report.night_hourly_rate)} × "
f"{night_hours} h, +50%)</dt>",
f'<dd>{layout.format_currency(breakdown["night_pay"])}</dd>',
]
)
else:
# No night hours - show simple base pay
summary_parts.extend(
[
f"<dt>{request.translate(_('Base pay'))} "
f"({layout.format_currency(report.hourly_rate)} × "
f"{report.duration_hours} h)</dt>",
f'<dd>{layout.format_currency(breakdown["day_pay"])}</dd>',
]
)
# Show surcharge amounts if any
if breakdown['weekend_surcharge'] > 0:
# Calculate actual weekend hours that get the surcharge (non-night)
weekend_holiday_hours = report.weekend_holiday_hours_decimal
night_hours = report.night_hours_decimal
weekend_non_night_hours = weekend_holiday_hours - min(
weekend_holiday_hours, night_hours
)
label = (
f"{request.translate(_('Weekend surcharge amount'))} "
f"({layout.format_currency(report.hourly_rate)} × "
f"{weekend_non_night_hours} h, +25%)"
)
amount = layout.format_currency(breakdown['weekend_surcharge'])
summary_parts.extend(
[
f'<dt>{label}</dt>',
f'<dd>{amount}</dd>',
]
)
if breakdown['urgent_surcharge'] > 0:
# Calculate the base amount for urgent surcharge
actual_work_pay = (
breakdown['day_pay']
+ breakdown['night_pay']
+ breakdown['weekend_surcharge']
)
rate = report.SURCHARGE_RATES['urgent']
label = (
f"{request.translate(_('Urgent surcharge'))} "
f"({layout.format_currency(actual_work_pay)} × {rate}%, "
f"+{rate}%)"
)
amount = layout.format_currency(breakdown['urgent_surcharge'])
summary_parts.extend(
[
f'<dt>{label}</dt>',
f'<dd>{amount}</dd>',
]
)
# Show break time deduction if applicable
if breakdown['break_deduction'] > 0:
break_hours = report.break_time_hours
summary_parts.extend(
[
f"<dt>{request.translate(_('Break time'))} "
f"({layout.format_currency(report.hourly_rate)} × "
f"-{break_hours} h)</dt>",
f'<dd>-{layout.format_currency(breakdown["break_deduction"])}</dd>',
]
)
summary_parts.extend(
[
f"<dt><strong>"
f"{request.translate(_('Subtotal (work compensation)'))} "
f"</strong></dt>",
f'<dd><strong>'
f'{layout.format_currency(breakdown["adjusted_subtotal"])}'
f'</strong></dd>',
]
)
travel_label = request.translate(_('Travel'))
# Build travel details showing from-to addresses
if report.assignment_location:
location_name, address = ASSIGNMENT_LOCATIONS.get(
report.assignment_location,
(report.assignment_location, '')
)
translator_address = (
f'{report.translator.address}, '
f'{report.translator.zip_code} {report.translator.city}'
)
if report.travel_distance:
travel_label = (
f"{request.translate(_('Travel'))} "
f"({request.translate(_('from'))} "
f"{escape(translator_address)} "
f"{request.translate(_('to'))} {escape(location_name)}, "
f"{report.travel_distance} km \u00d7 2)"
)
else:
travel_label = (
f"{request.translate(_('Travel'))} "
f"({request.translate(_('from'))} "
f"{escape(translator_address)} "
f"{request.translate(_('to'))} {escape(location_name)})"
)
elif report.translator.drive_distance:
translator_address = (
f'{report.translator.address}, '
f'{report.translator.zip_code} {report.translator.city}'
)
travel_label = (
f"{request.translate(_('Travel'))} "
f"({request.translate(_('from'))} "
f"{escape(translator_address)}, "
f"{report.translator.drive_distance} km \u00d7 2)"
)
summary_parts.extend(
[
f'<dt>{travel_label}</dt>',
f'<dd>{layout.format_currency(report.travel_compensation)}'
f'</dd>',
]
)
if breakdown['meal'] > 0:
meal_label = request.translate(_('Meal Allowance (6+ hours)'))
summary_parts.extend(
[
f'<dt>{meal_label}</dt>',
f'<dd>{layout.format_currency(breakdown["meal"])}</dd>',
]
)
# Build total calculation formula
calculation_parts = [
layout.format_currency(breakdown['adjusted_subtotal'])
]
if breakdown['travel'] > 0:
calculation_parts.append(
layout.format_currency(breakdown['travel'])
)
if breakdown['meal'] > 0:
calculation_parts.append(
layout.format_currency(breakdown['meal'])
)
calculation_formula = ' + '.join(calculation_parts)
summary_parts.extend(
[
f"<dt><strong>{request.translate(_('Total'))}</strong> "
f"({calculation_formula})</dt>",
f'<dd><strong>'
f'{layout.format_currency(breakdown["total"])}'
f'</strong></dd>',
'</dl>',
]
)
return Markup(''.join(summary_parts)) # nosec: B704
[docs]
def get_links( # type:ignore[override]
self, request: TranslatorAppRequest # type:ignore[override]
) -> list[Link | LinkGroup]:
if self.deleted:
return []
links: list[Link | LinkGroup] = []
time_report_links = []
if self.time_report:
if self.time_report.status == 'pending' and (
request.is_editor or request.is_admin
):
time_report_links.append(
Link(
text=_('Edit'),
url=request.return_here(
request.link(self.time_report, 'edit')
),
attrs={'class': 'edit-link'},
)
)
time_report_links.append(
Link(
text=_('Download PDF'),
url=request.link(
self.ticket, 'time-report-pdf-for-translator'
),
attrs={'class': 'pdf'},
)
)
if (
self.time_report.status == 'confirmed'
and self.translator
and self.translator.self_employed
):
time_report_links.append(
Link(
text=_('Download QR Bill'),
url=request.link(self.ticket, 'qr-bill-pdf'),
attrs={'class': 'pdf'},
)
)
time_report_links.append(
Link(
text=_('View translator'),
url=request.return_here(request.link(self.translator)),
attrs={'class': TRANSLATOR_FA_ICON},
)
)
if self.state is None:
time_report_links.append(
Link(
text=_('Accept time report'),
url=request.csrf_protected_url(
request.link(self.ticket, 'accept-time-report')
),
attrs={'class': 'accept-link'},
traits=(
Intercooler(
request_method='POST',
redirect_after=request.link(self.ticket)
)
),
)
)
if time_report_links:
links.append(
LinkGroup(
_('Time Report'),
links=time_report_links,
right_side=False
)
)
return links
[docs]
class AccreditationTicket(OrgTicketMixin, Ticket):
[docs]
__mapper_args__ = {'polymorphic_identity': 'AKK'} # type:ignore
if TYPE_CHECKING:
[docs]
handler: AccreditationHandler
[docs]
def reference_group(self, request: OrgRequest) -> str:
return self.title
@handlers.registered_handler('AKK')
[docs]
class AccreditationHandler(Handler):
[docs]
handler_title = _('Accreditation')
[docs]
code_title = _('Accreditations')
@cached_property
[docs]
def translator(self) -> Translator | None:
return self.session.query(Translator).filter_by(
id=self.data['handler_data'].get('id')
).first()
@cached_property
[docs]
def accreditation(self) -> Accreditation | None:
if self.translator is None:
return None
return Accreditation(self.session, self.translator.id, self.ticket.id)
@property
[docs]
def deleted(self) -> bool:
return self.translator is None
@property
[docs]
def ticket_deletable(self) -> bool:
# For now we don't support this because lot's of functionality
# depends on data in translator tickets
if self.deleted:
return True
return False
@cached_property
[docs]
def email(self) -> str:
return self.data['handler_data'].get('submitter_email', '')
@cached_property
[docs]
def state(self) -> str | None:
return self.data.get('state')
@property
[docs]
def title(self) -> str:
return self.translator.title if self.translator else '<Deleted>'
@property
[docs]
def subtitle(self) -> str:
return _('Request Accreditation')
@cached_property
[docs]
def group(self) -> str:
return _('Accreditation')
[docs]
def get_summary(
self,
request: TranslatorAppRequest # type:ignore[override]
) -> Markup:
layout = AccreditationLayout(self.translator, request)
locale = request.locale.split('_')[0] if request.locale else None
locale = 'de' if locale == 'de' else 'en'
agreement_text = get_custom_text(
request, f'({locale}) Custom admission course agreement')
return render_macro(
layout.macros['display_accreditation'],
request,
{
'translator': self.translator,
'ticket_data': self.data['handler_data'],
'layout': layout,
'agreement_text': agreement_text
}
)
[docs]
def get_links( # type:ignore[override]
self,
request: TranslatorAppRequest # type:ignore[override]
) -> list[Link | LinkGroup]:
if self.deleted:
return []
links: list[Link | LinkGroup] = []
advanced_links = []
if self.state is None:
links.append(
Link(
text=_('Grant admission'),
url=request.return_here(
request.link(self.accreditation, 'grant')
),
attrs={'class': 'accept-link'},
)
)
if self.translator:
advanced_links.append(
Link(
text=_('Edit translator'),
url=request.return_here(
request.link(self.translator, 'edit')
),
attrs={'class': ('edit-link', 'border')}
)
)
advanced_links.append(
Link(
text=_('Edit documents'),
url=request.return_here(
request.class_link(
TranslatorDocumentCollection,
{
'translator_id': self.translator.id,
}
)
),
attrs={'class': ('edit-link', 'border')}
)
)
links.append(
Link(
_('Mail templates'),
url=request.link(
self.translator, name='mail-templates'
),
attrs={'class': 'envelope'},
)
)
if self.state is None:
advanced_links.append(
Link(
text=_('Refuse admission'),
url=request.return_here(
request.link(self.accreditation, 'refuse')
),
attrs={'class': 'delete-link'},
)
)
if advanced_links:
links.append(
LinkGroup(
_('Advanced'),
links=advanced_links,
right_side=False
)
)
return links