diff --git a/src/onegov/directory/models/directory_entry.py b/src/onegov/directory/models/directory_entry.py index acb6ecdc7a..9ffd4fb653 100644 --- a/src/onegov/directory/models/directory_entry.py +++ b/src/onegov/directory/models/directory_entry.py @@ -1,8 +1,10 @@ +from datetime import datetime + from onegov.core.orm import Base from onegov.core.orm.mixins import ContentMixin from onegov.core.orm.mixins import TimestampMixin from onegov.core.orm.mixins import UTCPublicationMixin -from onegov.core.orm.types import UUID +from onegov.core.orm.types import UUID, UTCDateTime from onegov.file import AssociatedFiles from onegov.gis import CoordinatesMixin from onegov.search import SearchableContent @@ -75,6 +77,10 @@ def es_public(self) -> bool: #: Describes the entry briefly lead: 'Column[str | None]' = Column(Text, nullable=True) + #: Marks the entry if publication notifications have been sent + notification_sent: 'Column[datetime | None]' = Column(UTCDateTime, + default=None) + #: All keywords defined for this entry (indexed) _keywords: 'Column[dict[str, str] | None]' = Column( # type:ignore MutableDict.as_mutable(HSTORE), diff --git a/src/onegov/directory/upgrade.py b/src/onegov/directory/upgrade.py index a28a3606a1..b2f33ad5a6 100644 --- a/src/onegov/directory/upgrade.py +++ b/src/onegov/directory/upgrade.py @@ -67,3 +67,29 @@ def make_directory_models_polymorphic_type_non_nullable( """) context.operations.alter_column(table, 'type', nullable=False) + +@upgrade_task('Directory entries add notification_sent column 1') +def add_notification_sent_column(context: UpgradeContext) -> None: + if not context.has_column('directory_entries', 'notification_sent'): + context.operations.add_column( + 'directory_entries', + Column( + 'notification_sent', + Boolean, + nullable=True, + default=False + ) + ) + + # update existing entries to have notification_sent=False + context.operations.execute(f""" + UPDATE directory_entries SET notification_sent = FALSE + WHERE notification_sent IS NULL; + """) + context.session.flush() + + # alter notification_sent non-nullable + context.operations.alter_column( + 'directory_entries', 'notification_sent', nullable=False) + + # TODO requires migration for past entries in cli command \ No newline at end of file diff --git a/src/onegov/org/cronjobs.py b/src/onegov/org/cronjobs.py index 25e63e4c8b..50ffa1c631 100644 --- a/src/onegov/org/cronjobs.py +++ b/src/onegov/org/cronjobs.py @@ -8,6 +8,7 @@ from onegov.core.orm import find_models from onegov.core.orm.mixins.publication import UTCPublicationMixin from onegov.core.templates import render_template +from onegov.directory import DirectoryCollection from onegov.event import Occurrence, Event from onegov.file import FileCollection from onegov.form import FormSubmission, parse_form @@ -17,10 +18,14 @@ from onegov.org.layout import DefaultMailLayout from onegov.org.models import ( ResourceRecipient, ResourceRecipientCollection, TANAccess, News) +from onegov.org.models.directory import ExtendedDirectoryEntryCollection, \ + ExtendedDirectory from onegov.org.models.extensions import ( GeneralFileLinkExtension, DeletableContentExtension) from onegov.org.models.ticket import ReservationHandler from onegov.org.views.allocation import handle_rules_cronjob +from onegov.org.views.directory import \ + send_email_notification_for_directory_entry from onegov.org.views.newsletter import send_newsletter from onegov.org.views.ticket import delete_tickets_and_related_data from onegov.reservation import Reservation, Resource, ResourceCollection @@ -69,6 +74,7 @@ def hourly_maintenance_tasks(request: 'OrgRequest') -> None: publish_files(request) reindex_published_models(request) send_scheduled_newsletter(request) + send_email_notification_for_recent_directory_entry_publications(request) delete_old_tans(request) delete_old_tan_accesses(request) @@ -84,6 +90,34 @@ def send_scheduled_newsletter(request: 'OrgRequest') -> None: newsletter.scheduled = None +def send_email_notification_for_recent_directory_entry_publications( + request: 'OrgRequest' +) -> None: + """ + Sends notifications to users about recently published `DirectoryEntry`. + + """ + + now = utcnow() + + for directory in DirectoryCollection(request.session).query().filter( + ExtendedDirectory.enable_update_notifications == True): + + directory_entries = ExtendedDirectoryEntryCollection( + directory).query().filter( + and_( + now >= ExtendedDirectoryEntry.publication_start, # test for published? + ExtendedDirectoryEntry.notification_sent == None, + ) + ) + for entry in directory_entries: + assert entry.published + + send_email_notification_for_directory_entry( + directory, entry, request) + entry.notification_sent = now + + def publish_files(request: 'OrgRequest') -> None: FileCollection(request.session).publish_files() diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index 38c8636c3d..43bb3651d5 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -574,6 +574,56 @@ def as_dict(entry: Any) -> dict[str, Any]: return tuple(as_dict(e) for e in entries) +def send_email_notification_for_directory_entry( + directory, + entry, + request +) -> None: + title = request.translate(_( + '${org}: New Entry in "${directory}"', + mapping={'org': request.app.org.title, + 'directory': directory.title}, + )) + entry_link = request.link(entry) + recipients = EntryRecipientCollection(request.session).query( + ).filter_by(directory_id=directory.id).filter_by( + confirmed=True).all() + + def email_iter() -> 'Iterator[EmailJsonDict]': + for recipient in recipients: + unsubscribe = request.link( + recipient.subscription, 'unsubscribe') + content = render_template( + 'mail_new_directory_entry.pt', + request, + { + 'layout': DefaultMailLayout(object(), request), + 'title': title, + 'directory': directory, + 'entry_title': entry.title, + 'entry_link': entry_link, + 'unsubscribe': unsubscribe + }, + ) + plaintext = html_to_text(content) + print('*** tschupre sending notification to', recipient.address) + yield request.app.prepare_email( + receivers=(recipient.address,), + subject=title, + content=content, + plaintext=plaintext, + category='transactional', + attachments=(), + headers={ + 'List-Unsubscribe': f'<{unsubscribe}>', + 'List-Unsubscribe-Post': ( + 'List-Unsubscribe=One-Click') + } + ) + + request.app.send_transactional_email_batch(email_iter()) + + @OrgApp.form( model=ExtendedDirectoryEntryCollection, permission=Private, @@ -601,59 +651,12 @@ def handle_new_directory_entry( transaction.abort() return request.redirect(request.link(self)) - # FIXME: if this entry is not yet published we will need to send - # a notification using some kind of cronjob, but we need - # to take care to only send it once, so we probably need - # to add a marker to entries to indicate that notifications - # have already been sent. if self.directory.enable_update_notifications and entry.access in ( 'public', 'mtan' ) and entry.published: - title = request.translate(_( - '${org}: New Entry in "${directory}"', - mapping={'org': request.app.org.title, - 'directory': self.directory.title}, - )) - - entry_link = request.link(entry) - - recipients = EntryRecipientCollection(request.session).query( - ).filter_by(directory_id=self.directory.id).filter_by( - confirmed=True).all() - - def email_iter() -> 'Iterator[EmailJsonDict]': - for recipient in recipients: - unsubscribe = request.link( - recipient.subscription, 'unsubscribe') - content = render_template( - 'mail_new_directory_entry.pt', - request, - { - 'layout': DefaultMailLayout(object(), request), - 'title': title, - 'directory': self.directory, - 'entry_title': entry.title, - 'entry_link': entry_link, - 'unsubscribe': unsubscribe - }, - ) - plaintext = html_to_text(content) - yield request.app.prepare_email( - receivers=(recipient.address,), - subject=title, - content=content, - plaintext=plaintext, - category='transactional', - attachments=(), - headers={ - 'List-Unsubscribe': f'<{unsubscribe}>', - 'List-Unsubscribe-Post': ( - 'List-Unsubscribe=One-Click') - } - ) - - request.app.send_transactional_email_batch(email_iter()) + send_email_notification_for_directory_entry( + self.directory, entry, request) request.success(_('Added a new directory entry')) return request.redirect(request.link(entry)) diff --git a/tests/onegov/org/test_cronjobs.py b/tests/onegov/org/test_cronjobs.py index 3c1f833d0d..ff84769bc1 100644 --- a/tests/onegov/org/test_cronjobs.py +++ b/tests/onegov/org/test_cronjobs.py @@ -10,6 +10,7 @@ from onegov.directory import (DirectoryEntryCollection, DirectoryConfiguration, DirectoryCollection) +from onegov.directory.collections.directory import EntryRecipientCollection from onegov.event import EventCollection, OccurrenceCollection from onegov.event.utils import as_rdates from onegov.form import FormSubmissionCollection @@ -1068,7 +1069,6 @@ def test_delete_content_marked_deletable__directory_entries(org_app, handlers): grundeigentumer_in='Berta Bertinio', publication_start=datetime(2024, 4, 1, tzinfo=tz), publication_end=datetime(2024, 4, 10, tzinfo=tz), - # delete_when_expired=True, )) event.delete_when_expired = True @@ -1283,3 +1283,106 @@ def count_occurrences(): client.get(get_cronjob_url(job)) assert count_events() == 0 assert count_occurrences() == 0 + + +def test_send_email_notification_for_recent_directory_entry_publications( + org_app, + handlers +): + register_echo_handler(handlers) + register_directory_handler(handlers) + + client = Client(org_app) + job = get_cronjob_by_name(org_app, 'hourly_maintenance_tasks') + job.app = org_app + tz = ensure_timezone('Europe/Zurich') + + assert len(os.listdir(client.app.maildir)) == 0 + + transaction.begin() + + directories = DirectoryCollection(org_app.session(), type='extended') + planauflage = directories.add( + title='Öffentliche Planauflage', + structure=""" + Gesuchsteller/in *= ___ + Grundeigentümer/in *= ___ + """, + configuration=DirectoryConfiguration( + title="[Gesuchsteller/in]", + order=('Gesuchsteller/in'), + ), + enable_update_notifications=True, + ) + + planauflage.add(values=dict( + gesuchsteller_in='Carmine Carminio', + grundeigentumer_in='Doris Dorinio', + publication_start=datetime(2025, 1, 6, 2, 0, tzinfo=tz), + publication_end=datetime(2025, 1, 30, 2, 0, tzinfo=tz), + )) + + planauflage.add(values=dict( + gesuchsteller_in='Emil Emilio', + grundeigentumer_in='Franco Francinio', + publication_start=datetime(2025, 1, 8, 2, 0, tzinfo=tz), + publication_end=datetime(2025, 1, 31, 2, 0, tzinfo=tz), + )) + + EntryRecipientCollection(org_app.session()).add( + directory_id=planauflage.id, + address='john@doe.ch', + confirmed=True + ) + + transaction.commit() + close_all_sessions() + + def planauflagen(): + return (DirectoryCollection(org_app.session(), type='extended') + .by_name('offentliche-planauflage')) + + def count_recipients(): + return (EntryRecipientCollection(org_app.session()).query() + .filter_by(directory_id=planauflagen().id) + .filter_by(confirmed=True).count()) + + assert count_recipients() == 1 + john = EntryRecipientCollection(org_app.session()).query().first() + + with freeze_time(datetime(2025, 1, 1, 4, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + entry_1 = planauflagen().entries[0] + entry_2 = planauflagen().entries[1] + assert entry_1.notification_sent is None + assert entry_2.notification_sent is None + assert len(os.listdir(client.app.maildir)) == 0 + + with freeze_time(datetime(2025, 1, 6, 4, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + entry_1 = planauflagen().entries[0] + entry_2 = planauflagen().entries[1] + assert entry_1.notification_sent + assert entry_2.notification_sent is None + + assert len(os.listdir(client.app.maildir)) == 1 + message = client.get_email(0) + assert message['To'] == john.address + assert planauflagen().title in message['Subject'] + assert entry_1.name in message['TextBody'] + + with freeze_time(datetime(2025, 1, 8, 4, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + entry_1 = planauflagen().entries[0] + entry_2 = planauflagen().entries[1] + assert entry_1.notification_sent + assert entry_2.notification_sent + + assert len(os.listdir(client.app.maildir)) == 2 + message = client.get_email(1) + assert message['To'] == john.address + assert planauflagen().title in message['Subject'] + assert entry_2.name in message['TextBody']