Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/onegov/org/cronjobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -542,10 +542,11 @@ def send_monthly_ticket_statistics(request: OrgRequest) -> None:
)


@OrgApp.cronjob(hour=6, minute=5, timezone='Europe/Zurich')
@OrgApp.cronjob(hour='*', minute='*/5', timezone='Europe/Zurich')
def send_daily_resource_usage_overview(request: OrgRequest) -> None:
today = to_timezone(utcnow(), 'Europe/Zurich')
weekday = WEEKDAYS[today.weekday()]
current_time = f'{today.hour:02d}:{today.minute:02d}'

# get all recipients which require an e-mail today
recipients_q = (
Expand All @@ -561,11 +562,13 @@ def send_daily_resource_usage_overview(request: OrgRequest) -> None:

# If the key 'daily_reservations' doesn't exist, the recipient was
# created before anything else was an option, therefore it must be true
# Legacy recipients without 'daily_reservations_times' default to 06:00.
recipients = [
(address, content['resources'])
for address, content in recipients_q
if content.get('daily_reservations', True)
and weekday in content['send_on']
and current_time in content.get('daily_reservations_times', ['06:00'])
]

if not recipients:
Expand Down
49 changes: 46 additions & 3 deletions src/onegov/org/forms/resource_recipient.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from __future__ import annotations

import re

from onegov.form import Form
from onegov.form.fields import MultiCheckboxField
from onegov.form.fields import MultiCheckboxField, TagsField
from onegov.org import _
from onegov.reservation import Resource, ResourceCollection
from wtforms.fields import EmailField
Expand All @@ -15,6 +17,8 @@
from onegov.org.request import OrgRequest


_TIME_RE = re.compile(r'^([01]\d|2[0-3]):([0-5]\d)$')

WEEKDAYS = (
('MO', _('Mo')),
('TU', _('Tu')),
Expand Down Expand Up @@ -56,8 +60,8 @@ class ResourceRecipientForm(Form):
label=_('Daily Reservations'),
fieldset=_('Notifications *'),
description=_("On each day selected below, a notification with the "
"day's reservations will be sent to the recipient above "
"at 06:00."),
"day's reservations will be sent to the recipient "
"above."),
)

customer_messages = BooleanField(
Expand Down Expand Up @@ -93,13 +97,49 @@ class ResourceRecipientForm(Form):
render_kw={'prefix_label': False, 'class_': 'oneline-checkboxes'}
)

daily_reservations_times = TagsField(
label=_('Delivery Times'),
fieldset='Tage und Ressourcen',
description=_('e.g. 07:00 or 07:00, 14:30'),
depends_on=('daily_reservations', 'y'),
)

resources = MultiCheckboxField(
label=_('Resources'),
fieldset='Tage und Ressourcen',
validators=[InputRequired()],
choices=None
)

def ensure_valid_delivery_times(self) -> bool | None:
if not self.daily_reservations.data:
return None

times = self.daily_reservations_times.data
if isinstance(times, str):
times = [t.strip() for t in times.split(',') if t.strip()]
if not times:
self.daily_reservations_times.errors.append( # type: ignore[attr-defined]
_('Please enter at least one delivery time.')
)
return False

for t in times:
m = _TIME_RE.match(t)
if not m:
self.daily_reservations_times.errors.append( # type: ignore[attr-defined]
_('Invalid time "${time}". Use HH:MM format (e.g. 06:00).',
mapping={'time': t})
)
return False
if int(m.group(2)) % 5 != 0:
self.daily_reservations_times.errors.append( # type: ignore[attr-defined]
_('Minutes must be a multiple of 5 (e.g. 06:00, 06:30).')
)
return False

return None

def ensure_at_least_one_notification(self) -> bool | None:
if not (
self.new_reservations.data
Expand All @@ -113,6 +153,9 @@ def ensure_at_least_one_notification(self) -> bool | None:
return None

def on_request(self) -> None:
if not self.request.POST and not self.daily_reservations_times.data:
self.daily_reservations_times.data = ['06:00'] # legacy default

default_group = self.request.translate(_('General'))

self.resources.choices = [
Expand Down
22 changes: 19 additions & 3 deletions src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po
Original file line number Diff line number Diff line change
Expand Up @@ -1975,11 +1975,27 @@ msgstr "Tägliche Reservationen"

msgid ""
"On each day selected below, a notification with the day's reservations will "
"be sent to the recipient above at 06:00."
"be sent to the recipient above."
msgstr ""
"An jedem unten ausgewählten Tag wird eine Benachrichtigung mit den "
"Reservierungen des Tages um 06:00 Uhr an den oben genannten Empfänger "
"gesendet."
"Reservierungen des Tages an den oben genannten Empfänger gesendet."

msgid "Delivery Times"
msgstr "Zustellungszeiten"

msgid "e.g. 07:00 or 07:00, 14:30"
msgstr "z.B. 07:00 oder 07:00, 14:30"

msgid "Please enter at least one delivery time."
msgstr "Bitte mindestens eine Zustellungszeit eingeben."

msgid ""
"Invalid time \"${time}\". Use HH:MM format (e.g. 06:00)."
msgstr ""
"Ungültige Zeit \"${time}\". Bitte im Format HH:MM eingeben (z.B. 06:00)."

msgid "Minutes must be a multiple of 5 (e.g. 06:00, 06:30)."
msgstr "Minuten müssen ein Vielfaches von 5 sein (z.B. 06:00, 06:30)."

msgid "Customer Messages"
msgstr "Kunden Nachrichten"
Expand Down
21 changes: 19 additions & 2 deletions src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po
Original file line number Diff line number Diff line change
Expand Up @@ -1979,10 +1979,27 @@ msgstr "Réservations quotidiennes"

msgid ""
"On each day selected below, a notification with the day's reservations will "
"be sent to the recipient above at 06:00."
"be sent to the recipient above."
msgstr ""
"Pour chaque jour sélectionné ci-dessous, une notification avec les "
"réservations du jour sera envoyée au destinataire ci-dessus à 6h00.."
"réservations du jour sera envoyée au destinataire ci-dessus."

msgid "Delivery Times"
msgstr "Heures d'envoi"

msgid "e.g. 07:00 or 07:00, 14:30"
msgstr "p.ex. 07:00 ou 07:00, 14:30"

msgid "Please enter at least one delivery time."
msgstr "Veuillez saisir au moins une heure d'envoi."

msgid ""
"Invalid time \"${time}\". Use HH:MM format (e.g. 06:00)."
msgstr ""
"Heure invalide \"${time}\". Utiliser le format HH:MM (p.ex. 06:00)."

msgid "Minutes must be a multiple of 5 (e.g. 06:00, 06:30)."
msgstr "Les minutes doivent être un multiple de 5 (p.ex. 06:00, 06:30)."

msgid "Customer Messages"
msgstr "Messages des clients"
Expand Down
23 changes: 20 additions & 3 deletions src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po
Original file line number Diff line number Diff line change
Expand Up @@ -1979,10 +1979,27 @@ msgstr "Prenotazioni giornaliere"

msgid ""
"On each day selected below, a notification with the day's reservations will "
"be sent to the recipient above at 06:00."
"be sent to the recipient above."
msgstr ""
"Alle ore 6:00 di ogni giorno selezionato qui sotto, al suddetto destinatario "
"verrà inviata una notifica con le prenotazioni del giorno."
"Per ogni giorno selezionato qui sotto, al suddetto destinatario verrà "
"inviata una notifica con le prenotazioni del giorno."

msgid "Delivery Times"
msgstr "Orari di invio"

msgid "e.g. 07:00 or 07:00, 14:30"
msgstr "es. 07:00 o 07:00, 14:30"

msgid "Please enter at least one delivery time."
msgstr "Inserire almeno un orario di invio."

msgid ""
"Invalid time \"${time}\". Use HH:MM format (e.g. 06:00)."
msgstr ""
"Orario non valido \"${time}\". Usare il formato HH:MM (es. 06:00)."

msgid "Minutes must be a multiple of 5 (e.g. 06:00, 06:30)."
msgstr "I minuti devono essere un multiplo di 5 (es. 06:00, 06:30)."

msgid "Customer Messages"
msgstr "Messaggi dei clienti"
Expand Down
2 changes: 2 additions & 0 deletions src/onegov/org/models/recipient.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ class ResourceRecipient(GenericRecipient):
__mapper_args__ = {'polymorphic_identity': 'resource'}

daily_reservations: dict_property[bool | None] = content_property()
daily_reservations_times: dict_property[list[str] | None] = (
content_property())
new_reservations: dict_property[bool | None] = content_property()
customer_messages: dict_property[bool | None] = content_property()
internal_notes: dict_property[bool | None] = content_property()
Expand Down
4 changes: 4 additions & 0 deletions src/onegov/org/views/resource_recipient.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,11 @@ def handle_new_resource_recipient(
medium='email',
address=form.address.data,
daily_reservations=form.daily_reservations.data,
daily_reservations_times=form.daily_reservations_times.data,
new_reservations=form.new_reservations.data,
customer_messages=form.customer_messages.data,
internal_notes=form.internal_notes.data,
rejected_reservations=form.rejected_reservations.data,
send_on=form.send_on.data,
resources=form.resources.data,
)
Expand Down Expand Up @@ -136,6 +138,8 @@ def handle_edit_resource_recipient(
)
elif not request.POST:
form.process(obj=self)
if not form.daily_reservations_times.data:
form.daily_reservations_times.data = ['06:00']

title = _('Edit Recipient')

Expand Down
88 changes: 80 additions & 8 deletions tests/onegov/org/test_cronjobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ def test_daily_reservation_overview(client: Client[TestOrgApp]) -> None:
medium='email',
address='gym@example.org',
daily_reservations=True,
daily_reservations_times=['06:00'],
send_on=['FR'],
resources=[
gymnasium.id.hex
Expand All @@ -553,6 +554,7 @@ def test_daily_reservation_overview(client: Client[TestOrgApp]) -> None:
medium='email',
address='day@example.org',
daily_reservations=True,
daily_reservations_times=['06:00'],
send_on=['FR'],
resources=[
dailypass.id.hex
Expand All @@ -563,6 +565,7 @@ def test_daily_reservation_overview(client: Client[TestOrgApp]) -> None:
medium='email',
address='both@example.org',
daily_reservations=True,
daily_reservations_times=['06:00'],
send_on=['SA'],
resources=[
dailypass.id.hex,
Expand All @@ -577,22 +580,25 @@ def test_daily_reservation_overview(client: Client[TestOrgApp]) -> None:
job.app = client.app

url = get_cronjob_url(job)
tz = ensure_timezone('Europe/Zurich')
# Naive UTC (avoids pytz LMT bug); tick=True prevents email filename
# collisions. January Zurich = CET (UTC+1), so 06:00 CET = 05:00 UTC.
fri_0600 = datetime(2017, 1, 6, 5, 0) # Friday 06:00 Zurich
sat_0600 = datetime(2017, 1, 7, 5, 0) # Saturday 06:00 Zurich

# do not send an e-mail outside the selected days
for day in [2, 3, 4, 5, 8]:
with freeze_time(datetime(2017, 1, day, tzinfo=tz), tick=True):
with freeze_time(datetime(2017, 1, day, 5, 0), tick=True):
client.get(url)

assert len(os.listdir(client.app.maildir)) == 0

# only send e-mails to the users with the right selection
with freeze_time(datetime(2017, 1, 6, tzinfo=tz), tick=True):
with freeze_time(fri_0600, tick=True):
client.get(url)

assert len(os.listdir(client.app.maildir)) == 2

with freeze_time(datetime(2017, 1, 7, tzinfo=tz), tick=True):
with freeze_time(sat_0600, tick=True):
client.get(url)

assert len(os.listdir(client.app.maildir)) == 3
Expand All @@ -601,7 +607,7 @@ def test_daily_reservation_overview(client: Client[TestOrgApp]) -> None:
# e-mail will not contain any information info
client.flush_email_queue()

with freeze_time(datetime(2017, 1, 6, tzinfo=tz), tick=True):
with freeze_time(fri_0600, tick=True):
client.get(url)

mails = [client.get_email(i) for i in range(2)]
Expand Down Expand Up @@ -636,10 +642,10 @@ def test_daily_reservation_overview(client: Client[TestOrgApp]) -> None:

transaction.commit()

with freeze_time(datetime(2017, 1, 6, tzinfo=tz), tick=True):
with freeze_time(fri_0600, tick=True):
client.get(url)

with freeze_time(datetime(2017, 1, 7, tzinfo=tz), tick=True):
with freeze_time(sat_0600, tick=True):
client.get(url)

# NOTE: These seem to not always get sent in the same order...
Expand All @@ -665,7 +671,7 @@ def test_daily_reservation_overview(client: Client[TestOrgApp]) -> None:

transaction.commit()

with freeze_time(datetime(2017, 1, 6, tzinfo=tz), tick=True):
with freeze_time(fri_0600, tick=True):
client.get(url)

# NOTE: These seem to not always get sent in the same order...
Expand All @@ -683,6 +689,72 @@ def test_daily_reservation_overview(client: Client[TestOrgApp]) -> None:
assert 'day-reservation' in text


def test_daily_reservation_overview_delivery_times(
client: Client[TestOrgApp],
) -> None:
"""Emails are sent only at configured HH:MM times; multiple times work."""
resources = ResourceCollection(client.app.libres_context)
room = resources.add('Room', 'Europe/Zurich', type='room')

recipients = ResourceRecipientCollection(client.app.session())
# recipient with two delivery times: 06:00 and 14:30 Zurich CET
recipients.add(
name='Multi',
medium='email',
address='multi@example.org',
daily_reservations=True,
daily_reservations_times=['06:00', '14:30'],
send_on=['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'],
resources=[room.id.hex]
)
# legacy recipient without daily_reservations_times (defaults to 06:00)
recipients.add(
name='Legacy',
medium='email',
address='legacy@example.org',
daily_reservations=True,
send_on=['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'],
resources=[room.id.hex]
)

transaction.commit()

job = get_cronjob_by_name(client.app, 'daily_resource_usage')
assert job is not None
job.app = client.app
url = get_cronjob_url(job)

# All times are naive UTC. January Zurich = CET (UTC+1), so -1h.
t_0700_utc = datetime(2017, 1, 6, 6, 0) # 07:00 Zurich — wrong time
t_0600_utc = datetime(2017, 1, 6, 5, 0) # 06:00 Zurich
t_1430_utc = datetime(2017, 1, 6, 13, 30) # 14:30 Zurich
t_1431_utc = datetime(2017, 1, 6, 13, 31) # 14:31 Zurich — between buckets

# wrong time: no emails
with freeze_time(t_0700_utc, tick=True):
client.get(url)
assert len(os.listdir(client.app.maildir)) == 0

# 06:00: multi-time recipient AND legacy recipient both fire
with freeze_time(t_0600_utc, tick=True):
client.get(url)
assert len(os.listdir(client.app.maildir)) == 2
client.flush_email_queue()

# 14:30: only the multi-time recipient fires
with freeze_time(t_1430_utc, tick=True):
client.get(url)
assert len(os.listdir(client.app.maildir)) == 1
assert client.get_email(0)['To'] == 'multi@example.org'
client.flush_email_queue()

# 14:31: nothing fires (cronjob runs every 5 min, 14:31 is not a bucket)
with freeze_time(t_1431_utc, tick=True):
client.get(url)
assert len(os.listdir(client.app.maildir)) == 0



@pytest.mark.parametrize('secret_content_allowed', [False, True])
def test_send_scheduled_newsletters(
client: Client[TestOrgApp],
Expand Down
Loading
Loading