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,
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 show_links_when_closed(self) -> bool:
"""Allow showing handler links even when ticket is closed.
This allows users to download PDFs and QR bills after the
time report has been accepted and the ticket closed.
"""
return True
@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:
if assignment_type_key in TIME_REPORT_INTERPRETING_TYPES:
assignment_type_translated = request.translate(
TIME_REPORT_INTERPRETING_TYPES[assignment_type_key]
)
# Be backwards compatible:
elif assignment_type_key in INTERPRETING_TYPES:
assignment_type_translated = request.translate(
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