diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d204e0260..d00e73dde1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: exclude: .pre-commit-config.yaml - id: pt_structure - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.1 + rev: v0.9.2 hooks: - id: ruff args: [ "--fix" ] diff --git a/src/onegov/org/cronjobs.py b/src/onegov/org/cronjobs.py index 7ea7bb3754..7be3f897d3 100644 --- a/src/onegov/org/cronjobs.py +++ b/src/onegov/org/cronjobs.py @@ -23,6 +23,8 @@ 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,7 +71,7 @@ @OrgApp.cronjob(hour='*', minute=0, timezone='UTC') def hourly_maintenance_tasks(request: OrgRequest) -> None: publish_files(request) - reindex_published_models(request) + handle_publication_models(request) send_scheduled_newsletter(request) delete_old_tans(request) delete_old_tan_accesses(request) @@ -90,13 +92,16 @@ def publish_files(request: OrgRequest) -> None: FileCollection(request.session).publish_files() -def reindex_published_models(request: OrgRequest) -> None: +def handle_publication_models(request: OrgRequest) -> None: """ Reindexes all recently published/unpublished objects in the elasticsearch database. For pages it also updates the propagated access to any associated files. + + For directory entries it also sends out e-mail notifications if + published within the last hour. """ if not hasattr(request.app, 'es_client'): @@ -110,10 +115,11 @@ def publication_models( cls, UTCPublicationMixin) ) - objects = [] + objects = set() session = request.app.session() now = utcnow() - then = now - timedelta(hours=1) + then = request.app.org.meta.get('hourly_maintenance_tasks_last_run', + now - timedelta(hours=1)) for base in request.app.session_manager.bases: for model in publication_models(base): query = session.query(model).filter( @@ -128,7 +134,7 @@ def publication_models( ) ) ) - objects.extend(query.all()) + objects.update(query.all()) for obj in objects: if isinstance(obj, GeneralFileLinkExtension): @@ -138,6 +144,13 @@ def publication_models( if isinstance(obj, Searchable): request.app.es_orm_events.index(request.app.schema, obj) + if (isinstance(obj, ExtendedDirectoryEntry) and obj.published and + obj.directory.enable_update_notifications): + send_email_notification_for_directory_entry( + obj.directory, obj, request) + + request.app.org.meta['hourly_maintenance_tasks_last_run'] = now + def delete_old_tans(request: OrgRequest) -> None: """ diff --git a/src/onegov/org/models/organisation.py b/src/onegov/org/models/organisation.py index 0985ec65fd..7ab0568723 100644 --- a/src/onegov/org/models/organisation.py +++ b/src/onegov/org/models/organisation.py @@ -8,7 +8,7 @@ from onegov.core.orm.abstract import associated from onegov.core.orm.mixins import ( dict_markup_property, dict_property, meta_property, TimestampMixin) -from onegov.core.orm.types import JSON, UUID +from onegov.core.orm.types import JSON, UUID, UTCDateTime from onegov.core.utils import linkify, paragraphify from onegov.file.models.file import File from onegov.form import flatten_fieldsets, parse_formcode @@ -241,6 +241,10 @@ class Organisation(Base, TimestampMixin): ogd_publisher_id: dict_property[str | None] = meta_property() ogd_publisher_name: dict_property[str | None] = meta_property() + # cron jobs + hourly_maintenance_tasks_last_run: ( + dict_property)[UTCDateTime | None] = (meta_property(default=None)) + @property def mtan_access_window(self) -> timedelta: seconds = self.mtan_access_window_seconds diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index f19eb47c50..d420da820c 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -576,6 +576,55 @@ 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: ExtendedDirectory, + entry: ExtendedDirectoryEntry, + request: OrgRequest +) -> 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) + 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, @@ -603,59 +652,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..3dbf5280e8 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,162 @@ 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( + es_org_app, + handlers +): + org_app = es_org_app + 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'), + searchable=['title'], + ), + 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, 6, 1, tzinfo=tz), + publication_end=datetime(2025, 1, 31, 2, 0, tzinfo=tz), + )) + + sport_clubs = directories.add( + title='Sport Clubs', + structure=""" + Name *= ___ + Category *= ___ + """, + configuration=DirectoryConfiguration( + title="[Name]", + order=('Name'), + searchable=['title'] + ), + enable_update_notifications=False, + ) + sport_clubs.add(values=dict( + name='Wanderfreunde', + category='Hiking', + publication_start=datetime(2025, 2, 1, 2, 0, tzinfo=tz), + publication_end=datetime(2025, 2, 22, 2, 0, tzinfo=tz), + )) + sport_clubs.add(values=dict( + name='Pokerfreunde', + category='Games', + publication_start=datetime(2025, 2, 1, 2, 0, tzinfo=tz), + publication_end=datetime(2025, 2, 2, 2, 0, tzinfo=tz), + )) + + EntryRecipientCollection(org_app.session()).add( + directory_id=planauflage.id, + address='john@doe.ch', + confirmed=True + ) + EntryRecipientCollection(org_app.session()).add( + directory_id=sport_clubs.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 sport_clubs(): + return (DirectoryCollection(org_app.session(), type='extended') + .by_name('sport-clubs')) + + 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() + + assert org_app.org.meta.get('hourly_maintenance_tasks_last_run') is None + + with freeze_time(datetime(2025, 1, 1, 4, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + assert len(os.listdir(client.app.maildir)) == 0 + assert org_app.org.meta.get('hourly_maintenance_tasks_last_run') + + with freeze_time(datetime(2025, 1, 6, 4, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + entry_1 = planauflagen().entries[0] + + 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, 10, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + entry_2 = planauflagen().entries[1] + + 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'] + + # before enabling notifications for sport clubs after publication + with freeze_time(datetime(2025, 2, 1, 6, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + assert len(os.listdir(client.app.maildir)) == 2 # no additional mail + + # enable notifications for sport clubs + sport_clubs().enable_update_notifications = True + transaction.commit() + + with freeze_time(datetime(2025, 2, 1, 1, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + # no additional mail, because the entry is not published yet + assert len(os.listdir(client.app.maildir)) == 2 + + with freeze_time(datetime(2025, 2, 3, 10, 0, tzinfo=tz)): + client.get(get_cronjob_url(job)) + + entry_2 = sport_clubs().entries[1] + + # only for still published sports club entry 'Wanderfreunde' + assert len(os.listdir(client.app.maildir)) == 3 + message = client.get_email(2) + assert message['To'] == john.address + assert sport_clubs().title in message['Subject'] + assert entry_2.name in message['TextBody']