from __future__ import annotations
from decimal import Decimal
from functools import cached_property
from onegov.activity import Activity, Attendee, Booking, Occasion
from onegov.feriennet import _
from onegov.feriennet import FeriennetApp
from onegov.feriennet.collections import BillingCollection, MatchCollection
from onegov.feriennet.exports.unlucky import UnluckyExport
from onegov.feriennet.layout import DefaultLayout
from onegov.org.boardlets import TicketBoardlet, EditedTopicsBoardlet, \
EditedNewsBoardlet, PlausibleStats, PlausibleTopPages
from onegov.org.models import Boardlet, BoardletFact
from sqlalchemy import func
from typing import Literal, TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterator
from onegov.activity.models import PeriodMeta
from onegov.activity.models.booking import BookingState
from onegov.feriennet.collections.match import OccasionState
from onegov.feriennet.request import FeriennetRequest
from sqlalchemy.orm import Query, Session
[docs]
class FeriennetBoardlet(Boardlet):
[docs]
request: FeriennetRequest
@cached_property
[docs]
def session(self) -> Session:
return self.request.session
@cached_property
[docs]
def period(self) -> PeriodMeta | None:
return self.request.app.active_period
@cached_property
[docs]
def layout(self) -> DefaultLayout:
return DefaultLayout(None, self.request)
@property
[docs]
def state(self) -> Literal['success', 'warning', 'failure']:
if not self.period:
return 'failure'
if not self.period.confirmed:
return 'warning'
return 'success'
@FeriennetApp.boardlet(name='ticket', order=(1, 1), icon='fa-ticket-alt')
[docs]
class DisabledTicketBoardlet(TicketBoardlet):
@property
[docs]
def is_available(self) -> bool:
return False
@FeriennetApp.boardlet(name='pages', order=(1, 2), icon='fa-edit')
[docs]
class DisabledEditedPagesBoardlet(EditedTopicsBoardlet):
@property
[docs]
def is_available(self) -> bool:
return False
@FeriennetApp.boardlet(name='news', order=(1, 3), icon='fa-edit')
[docs]
class DisabledEditedNewsBoardlet(EditedNewsBoardlet):
@property
[docs]
def is_available(self) -> bool:
return False
@FeriennetApp.boardlet(name='web stats', order=(2, 1))
[docs]
class DisabledPlausibleStats(PlausibleStats):
@property
[docs]
def is_available(self) -> bool:
return False
@FeriennetApp.boardlet(name='most popular pages', order=(2, 2))
[docs]
class DisabledPlausibleTopPages(PlausibleTopPages):
@property
[docs]
def is_available(self) -> bool:
return False
@FeriennetApp.boardlet(name='period', order=(1, 1))
[docs]
class PeriodBoardlet(FeriennetBoardlet):
@property
[docs]
def title(self) -> str:
return self.period and self.period.title or _('No active period')
@property
[docs]
def state(self) -> Literal['success', 'failure']:
if not self.period:
return 'failure'
return 'success'
@property
[docs]
def facts(self) -> Iterator[BoardletFact]:
if not self.period:
return
def icon(checked: bool) -> str:
return 'fa-check-square-o' if checked else 'fa-square-o'
yield BoardletFact(
text=_('Prebooking: ${dates}', mapping={
'dates': self.layout.format_date_range(
self.period.prebooking_start,
self.period.prebooking_end,
)
}),
icon=icon(self.period.confirmed)
)
yield BoardletFact(
text=_('Booking: ${dates}', mapping={
'dates': self.layout.format_date_range(
self.period.booking_start,
self.period.booking_end,
)
}),
icon=icon(self.period.is_booking_in_past)
)
yield BoardletFact(
text=_('Execution: ${dates}', mapping={
'dates': self.layout.format_date_range(
self.period.execution_start,
self.period.execution_end,
)
}),
icon=icon(self.period.is_execution_in_past)
)
@FeriennetApp.boardlet(name='activities', order=(1, 2))
[docs]
class ActivitiesBoardlet(FeriennetBoardlet):
@cached_property
[docs]
def occasions(self) -> Query[Occasion]:
assert self.period is not None
return self.session.query(Occasion).filter_by(
period_id=self.period.id).join(
Activity, Occasion.activity_id == Activity.id).filter_by(
state='accepted')
@cached_property
[docs]
def occasions_count(self) -> int:
if not self.period:
return 0
return self.occasions.with_entities(func.count(Occasion.id)).scalar()
@cached_property
[docs]
def activities_count(self) -> int:
if not self.period:
return 0
return self.session.query(func.count(Activity.id)).filter(
Activity.id.in_(
self.session.query(Occasion.activity_id)
.filter_by(period_id=self.period.id)
.subquery()
)
).filter_by(state='accepted').scalar()
[docs]
def occasion_states(self) -> dict[OccasionState, int]:
occasion_states: dict[OccasionState, int] = {
'overfull': 0,
'full': 0,
'operable': 0,
'unoperable': 0,
'empty': 0,
'cancelled': 0,
}
if self.period is None:
return occasion_states
# FIXME: We should try to do the filtering, grouping, counting
# in SQL, we just have to restructure things a bit so
# we can modify the query or use it as a subquery
collection = MatchCollection(self.session, self.period)
accepted_occasions = [a.id for a in self.occasions]
occasions = [
o.state
for o in collection.occasions
if o.occasion_id in accepted_occasions
]
states = set(occasions)
for s in states:
if s is None:
continue
occasion_states[s] = occasions.count(s)
return occasion_states
@property
[docs]
def title(self) -> str:
return _('${count} Activities', mapping={
'count': self.activities_count
})
@property
[docs]
def state(self) -> Literal['success', 'warning', 'failure']:
if not self.period:
return 'failure'
return self.activities_count and 'success' or 'warning'
@property
[docs]
def facts(self) -> Iterator[BoardletFact]:
if not self.period:
return
yield BoardletFact(
text=_('${count} Activities', mapping={
'count': self.activities_count
}),
icon='fa-square'
)
yield BoardletFact(
text=_('${count} Occasions', mapping={
'count': self.occasions_count
}),
icon='fa-chevron-circle-down'
)
states = self.occasion_states()
yield BoardletFact(
text=_('${count} overfull', mapping={
'count': states['overfull'],
}),
icon='fa-exclamation-circle',
css_class='' if states['overfull'] else 'zero'
)
yield BoardletFact(
text=_('${count} full', mapping={
'count': states['full'],
}),
icon='fa-circle',
css_class='' if states['full'] else 'zero'
)
yield BoardletFact(
text=_('${count} operable', mapping={
'count': states['operable'],
}),
icon='fa-check-circle',
css_class='' if states['operable'] else 'zero'
)
yield BoardletFact(
text=_('${count} unoperable', mapping={
'count': states['unoperable'],
}),
icon='fa-stop-circle',
css_class='' if states['unoperable'] else 'zero'
)
yield BoardletFact(
text=_('${count} empty', mapping={
'count': states['empty'],
}),
icon='fa-circle-o',
css_class='' if states['empty'] else 'zero'
)
yield BoardletFact(
text=_('${count} cancelled', mapping={
'count': states['cancelled'],
}),
icon='fa-times-circle',
css_class='' if states['cancelled'] else 'zero'
)
@FeriennetApp.boardlet(name='bookings', order=(1, 3))
[docs]
class BookingsBoardlet(FeriennetBoardlet):
@cached_property
[docs]
def counts(self) -> dict[BookingState | Literal['total'], int]:
counts: dict[BookingState | Literal['total'], int] = {
'accepted': 0,
'blocked': 0,
'cancelled': 0,
'denied': 0,
'total': 0,
}
if not self.period:
return counts
query = (
self.session.query(Booking.state, func.count(Booking.id))
.filter_by(period_id=self.period.id)
.group_by(Booking.state)
)
for state, count in query:
counts['total'] += count
if state in counts:
counts[state] = count
return counts
@cached_property
[docs]
def attendees_count(self) -> int:
if not self.period:
return 0
# NOTE: This works because Booking.attendee_id is not nullable
return self.session.query(
func.count(Booking.attendee_id.distinct())
).filter_by(period_id=self.period.id).scalar()
@property
[docs]
def title(self) -> str:
if not self.period or not self.period.confirmed:
return _('${count} Wishes', mapping={
'count': self.counts['total']
})
else:
return _('${count} Bookings', mapping={
'count': self.counts['total']
})
@property
[docs]
def state(self) -> Literal['success', 'warning', 'failure']:
if not self.period:
return 'failure'
return self.counts['total'] and 'success' or 'warning'
@property
[docs]
def facts(self) -> Iterator[BoardletFact]:
if not self.period:
return
if not self.period.confirmed:
yield BoardletFact(
text=_('${count} Wishes', mapping={
'count': self.counts['total']
}),
icon='fa-square',
)
yield BoardletFact(
text=_('${count} Wishes per Attendee', mapping={
'count': self.attendees_count and (
round(self.counts['total'] / self.attendees_count, 1)
) or 0
}),
icon='fa-line-chart',
)
else:
yield BoardletFact(
text=_('${count} Bookings', mapping={
'count': self.counts['total']
}),
icon='fa-square',
)
yield BoardletFact(
text=_('${count} accepted', mapping={
'count': self.counts['accepted']
}),
icon='fa-check-square',
css_class='' if self.counts['accepted'] else 'zero'
)
yield BoardletFact(
text=_('${count} not carried out or cancelled', mapping={
'count': self.counts['cancelled']
}),
icon='fa-minus-square',
css_class='' if self.counts['cancelled'] else 'zero'
)
yield BoardletFact(
text=_('${count} denied', mapping={
'count': self.counts['denied']
}),
icon='fa-minus-square',
css_class='' if self.counts['denied'] else 'zero'
)
yield BoardletFact(
text=_('${count} blocked', mapping={
'count': self.counts['blocked']
}),
icon='fa-minus-square',
css_class='' if self.counts['blocked'] else 'zero'
)
yield BoardletFact(
text=_('${count} Bookings per Attendee', mapping={
'count': self.attendees_count and round(
self.counts['accepted'] / self.attendees_count, 1
) or 0
}),
icon='fa-line-chart',
)
@FeriennetApp.boardlet(name='attendees', order=(1, 4))
[docs]
class AttendeesBoardlet(FeriennetBoardlet):
@cached_property
[docs]
def attendee_counts(self) -> dict[str, int]:
counts = {
'total': 0,
'girls': 0,
'boys': 0,
'without_booking': 0
}
if not self.period:
return counts
query = self.session.query(
Attendee.gender,
func.count(Attendee.id),
).filter(Attendee.id.in_(
self.session.query(Booking.attendee_id)
.filter_by(period_id=self.period.id)
.subquery()
)).group_by(Attendee.gender)
for gender, count in query:
counts['total'] += count
if gender == 'male':
counts['boys'] = count
elif gender == 'female':
counts['girls'] = count
accepted_attendees = self.session.query(
func.count(Booking.attendee_id.distinct())
).filter(
Booking.state == 'accepted',
Booking.period_id == self.period.id
).scalar()
counts['without_booking'] = counts['total'] - accepted_attendees
return counts
@property
[docs]
def title(self) -> str:
return _('${count} Attendees', mapping={
'count': self.attendee_counts['total']
})
@property
[docs]
def state(self) -> Literal['success', 'warning', 'failure']:
if not self.period:
return 'failure'
return self.attendee_counts['total'] and 'success' or 'warning'
@property
[docs]
def facts(self) -> Iterator[BoardletFact]:
if not self.period:
return
yield BoardletFact(
text=_('${count} Girls', mapping={
'count': self.attendee_counts['girls']
}),
icon='fa-female',
css_class='' if self.attendee_counts['girls'] else 'zero'
)
yield BoardletFact(
text=_('${count} Boys', mapping={
'count': self.attendee_counts['boys']
}),
icon='fa-male',
css_class='' if self.attendee_counts['boys'] else 'zero'
)
yield BoardletFact(
text=_('${count} of which without accepted bookings',
mapping={
'count': self.attendee_counts['without_booking']
}),
icon='fa-minus',
css_class='' if self.attendee_counts['without_booking'] else 'zero'
)
@FeriennetApp.boardlet(name='matching', order=(1, 5))
[docs]
class MatchingBoardlet(FeriennetBoardlet):
@cached_property
[docs]
def happiness(self) -> float:
if not self.period or not self.period.confirmed:
return 0
raw = MatchCollection(self.session, self.period).happiness
return round(raw * 100, 2)
@cached_property
[docs]
def unlucky_count(self) -> int:
if not self.period:
return 0
return UnluckyExport().query(self.session, self.period).count()
@property
[docs]
def title(self) -> str:
return _('${amount}% Happiness', mapping={
'amount': self.happiness
})
@property
[docs]
def state(self) -> Literal['success', 'warning', 'failure']:
if not self.period:
return 'failure'
return self.happiness > 75 and 'success' or 'warning'
@property
[docs]
def facts(self) -> Iterator[BoardletFact]:
if not self.period:
return
yield BoardletFact(
text=_('${amount}% Happiness', mapping={
'amount': self.happiness
}),
icon='fa-smile-o',
)
yield BoardletFact(
text=_('${count} Attendees Without Occasion', mapping={
'count': self.unlucky_count
}),
icon='fa-frown-o',
css_class='' if self.unlucky_count else 'zero'
)
@FeriennetApp.boardlet(name='billing', order=(1, 6))
[docs]
class BillingPortlet(FeriennetBoardlet):
@cached_property
[docs]
def amounts(self) -> dict[str, Decimal]:
if not self.period:
return {
'total': Decimal(0),
'outstanding': Decimal(0),
'paid': Decimal(0),
}
billing = BillingCollection(self.request, self.period)
result = {
'total': billing.total,
'outstanding': billing.outstanding,
}
result['paid'] = result['total'] - result['outstanding']
return result
@property
[docs]
def title(self) -> str:
return _('${amount} CHF outstanding', mapping={
'amount': self.layout.format_number(self.amounts['outstanding'])
})
@property
[docs]
def state(self) -> Literal['success', 'warning', 'failure']:
if not self.period:
return 'failure'
return self.amounts['outstanding'] and 'warning' or 'success'
@property
[docs]
def facts(self) -> Iterator[BoardletFact]:
if not self.period:
return
yield BoardletFact(
text=_('${amount} CHF total', mapping={
'amount': self.layout.format_number(self.amounts['total'])
}),
icon='fa-circle',
css_class='' if self.amounts['total'] else 'zero'
)
yield BoardletFact(
text=_('${amount} CHF paid', mapping={
'amount': self.layout.format_number(self.amounts['paid'])
}),
icon='fa-plus-circle',
css_class='' if self.amounts['paid'] else 'zero'
)
yield BoardletFact(
text=_('${amount} CHF outstanding', mapping={
'amount': self.layout.format_number(
self.amounts['outstanding']
)
}),
icon='fa-minus-circle',
css_class='' if self.amounts['outstanding'] else 'zero'
)