Skip to content

Commit

Permalink
Election Day: Add notification segmentation.
Browse files Browse the repository at this point in the history
If segmented_notifications is enabled for a principal, email and SMS subscribers can subscribe either to elections and votes of a specific municipality or everything else. Multiple subscriptions are possible.

TYPE: Feature
LINK: OGC-1150
HINT: onegov-election-day --select /onegov_election_day/* migrate-subscribers
  • Loading branch information
msom authored Apr 28, 2024
1 parent 0ba48e6 commit d7d8195
Show file tree
Hide file tree
Showing 25 changed files with 1,096 additions and 421 deletions.
2 changes: 1 addition & 1 deletion src/onegov/election_day/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,7 @@ def get_common_asset() -> 'Iterator[str]':
def get_custom_asset() -> 'Iterator[str]':
# common code
yield 'common.js'
yield 'form_dependencies.js'

# D3 charts and maps
yield 'd3.chart.bar.js'
Expand All @@ -410,7 +411,6 @@ def get_backend_common_asset() -> 'Iterator[str]':
yield 'jquery.datetimepicker.css'
yield 'jquery.datetimepicker.js'
yield 'datetimepicker.js'
yield 'form_dependencies.js'
yield 'doubleclick.js'


Expand Down
20 changes: 20 additions & 0 deletions src/onegov/election_day/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from onegov.core.sms_processor import SmsQueueProcessor
from onegov.election_day.collections import ArchivedResultCollection
from onegov.election_day.models import ArchivedResult
from onegov.election_day.models import Subscriber
from onegov.election_day.utils import add_local_results
from onegov.election_day.utils.archive_generator import ArchiveGenerator
from onegov.election_day.utils.d3_renderer import D3Renderer
Expand Down Expand Up @@ -201,3 +202,22 @@ def generate(request: 'ElectionDayRequest', app: 'ElectionDayApp') -> None:
archive.update_all(request)

return generate


@cli.command('migrate-subscribers')
def migrate_subscribers() -> 'Processor':
def migrate(request: 'ElectionDayRequest', app: 'ElectionDayApp') -> None:
if not app.principal or not app.principal.segmented_notifications:
return

click.secho(f'Migrating {app.schema}', fg='yellow')

session = request.app.session()
subscribers = session.query(Subscriber).filter_by(domain=None)
count = 0
for subscriber in subscribers:
subscriber.domain = 'canton'
count += 1
click.echo(f'Migrated {count} subscribers')

return migrate
51 changes: 17 additions & 34 deletions src/onegov/election_day/collections/notifications.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from itertools import chain
from onegov.ballot import Election
from onegov.ballot import ElectionCompound
from onegov.ballot import Vote
Expand All @@ -11,6 +12,7 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Collection
from collections.abc import Iterator
from collections.abc import Sequence
from onegov.election_day.request import ElectionDayRequest
from sqlalchemy.orm import Query
Expand Down Expand Up @@ -94,27 +96,21 @@ def trigger_summarized(
"""

if not (elections or election_compounds or votes) or not options:
model_chain: 'Iterator[Election|ElectionCompound|Vote]'
model_chain = chain(elections, election_compounds, votes)
models = tuple(model_chain)

if not models or not options:
return

notification: Notification

if 'email' in options and request.app.principal.email_notification:
completed = True
for election in elections:
completed &= election.completed
notification = EmailNotification()
notification.update_from_model(election)
self.session.add(notification)
for election_compound in election_compounds:
completed &= election_compound.completed
notification = EmailNotification()
notification.update_from_model(election_compound)
self.session.add(notification)
for vote in votes:
completed &= vote.completed
for model in models:
completed &= model.completed
notification = EmailNotification()
notification.update_from_model(vote)
notification.update_from_model(model)
self.session.add(notification)

notification = EmailNotification()
Expand All @@ -128,40 +124,27 @@ def trigger_summarized(
)

if 'sms' in options and request.app.principal.sms_notification:
for election in elections:
notification = SmsNotification()
notification.update_from_model(election)
self.session.add(notification)
for election_compound in election_compounds:
notification = SmsNotification()
notification.update_from_model(election_compound)
self.session.add(notification)
for vote in votes:
for model in models:
notification = SmsNotification()
notification.update_from_model(vote)
notification.update_from_model(model)
self.session.add(notification)

notification = SmsNotification()
notification.send_sms(
request,
elections,
election_compounds,
votes,
_(
"New results are available on ${url}",
mapping={'url': request.app.principal.sms_notification}
)
)

if 'webhooks' in options and request.app.principal.webhooks:
for election in elections:
notification = WebhookNotification()
notification.trigger(request, election)
self.session.add(notification)
for election_compound in election_compounds:
notification = WebhookNotification()
notification.trigger(request, election_compound)
self.session.add(notification)
for vote in votes:
for model in models:
notification = WebhookNotification()
notification.trigger(request, vote)
notification.trigger(request, model)
self.session.add(notification)

self.session.flush()
82 changes: 67 additions & 15 deletions src/onegov/election_day/collections/subscribers.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,18 @@ def page_by_index(self, index: int) -> 'Self':
def for_active_only(self, active_only: bool) -> 'Self':
return self.__class__(self.session, 0, self.term, active_only)

def add(self, address: str, locale: str, active: bool) -> _S:
def add(
self,
address: str,
domain: str | None,
domain_segment: str | None,
locale: str,
active: bool
) -> _S:
subscriber = self.model_class(
address=address,
domain=domain,
domain_segment=domain_segment,
locale=locale,
active=active
)
Expand Down Expand Up @@ -98,16 +107,27 @@ def by_id(self, id: 'UUID') -> _S | None:
query = query.filter(self.model_class.id == id)
return query.first()

def by_address(self, address: str) -> _S | None:
def by_address(
self,
address: str,
domain: str | None,
domain_segment: str | None,
) -> _S | None:
""" Returns the (first) subscriber by its address. """

query = self.query(active_only=False)
query = query.filter(self.model_class.address == address)
query = query.filter(
self.model_class.address == address,
self.model_class.domain == domain,
self.model_class.domain_segment == domain_segment,
)
return query.first()

def initiate_subscription(
self,
address: str,
domain: str | None,
domain_segment: str | None,
request: 'ElectionDayRequest'
) -> _S:
""" Initiate the subscription process.
Expand All @@ -116,19 +136,23 @@ def initiate_subscription(
"""

subscriber = self.by_address(address)
subscriber = self.by_address(address, domain, domain_segment)
if not subscriber:
locale = request.locale
assert locale is not None
subscriber = self.add(address, locale, False)
subscriber = self.add(
address, domain, domain_segment, locale, False
)

self.handle_subscription(subscriber, request)
self.handle_subscription(subscriber, domain, domain_segment, request)

return subscriber

def handle_subscription(
self,
subscriber: _S,
domain: str | None,
domain_segment: str | None,
request: 'ElectionDayRequest'
) -> None:
""" Send the subscriber a request to confirm the subscription. """
Expand All @@ -138,11 +162,13 @@ def handle_subscription(
def initiate_unsubscription(
self,
address: str,
domain: str | None,
domain_segment: str | None,
request: 'ElectionDayRequest'
) -> None:
""" Initiate the unsubscription process. """

subscriber = self.by_address(address)
subscriber = self.by_address(address, domain, domain_segment)
if subscriber:
self.handle_unsubscription(subscriber, request)

Expand All @@ -151,8 +177,7 @@ def handle_unsubscription(
subscriber: _S,
request: 'ElectionDayRequest'
) -> None:
""" Send the subscriber a request to confirm the unsubscription.
"""
""" Send the subscriber a request to confirm the unsubscription. """

raise NotImplementedError()

Expand All @@ -162,6 +187,8 @@ def export(self) -> list[dict[str, Any]]:
return [
{
'address': subscriber.address,
'domain': subscriber.domain,
'domain_segment': subscriber.domain_segment,
'locale': subscriber.locale,
'active': subscriber.active
}
Expand All @@ -174,7 +201,11 @@ def cleanup(
mimetype: str,
delete: bool
) -> tuple[list['FileImportError'], int]:
""" Disables or deletes the subscribers in the given CSV. """
""" Disables or deletes the subscribers in the given CSV.
Ignores domain and domain segment, as this is inteded to cleanup
bounced addresses.
"""

csv, error = load_csv(file, mimetype, expected_headers=['address'])
if error:
Expand Down Expand Up @@ -206,6 +237,8 @@ def model_class(self) -> type[EmailSubscriber]:
def handle_subscription(
self,
subscriber: EmailSubscriber,
domain: str | None,
domain_segment: str | None,
request: 'ElectionDayRequest'
) -> None:
""" Send the (new) subscriber a request to confirm the subscription.
Expand All @@ -216,6 +249,8 @@ def handle_subscription(

token = request.new_url_safe_token({
'address': subscriber.address,
'domain': domain,
'domain_segment': domain_segment,
'locale': request.locale
})
optin = request.link(request.app.principal, 'optin-email')
Expand Down Expand Up @@ -252,10 +287,16 @@ def handle_subscription(
}
)

def confirm_subscription(self, address: str, locale: str) -> bool:
def confirm_subscription(
self,
address: str,
domain: str | None,
domain_segment: str | None,
locale: str,
) -> bool:
""" Confirm the subscription. """

subscriber = self.by_address(address)
subscriber = self.by_address(address, domain, domain_segment)
if subscriber:
subscriber.active = True
subscriber.locale = locale
Expand All @@ -272,7 +313,11 @@ def handle_unsubscription(

from onegov.election_day.layouts import MailLayout # circular

token = request.new_url_safe_token({'address': subscriber.address})
token = request.new_url_safe_token({
'address': subscriber.address,
'domain': subscriber.domain,
'domain_segment': subscriber.domain_segment
})
optout = request.link(request.app.principal, 'optout-email')
optout = f'{optout}?opaque={token}'

Expand Down Expand Up @@ -304,10 +349,15 @@ def handle_unsubscription(
}
)

def confirm_unsubscription(self, address: str) -> bool:
def confirm_unsubscription(
self,
address: str,
domain: str | None,
domain_segment: str | None,
) -> bool:
""" Confirm the unsubscription. """

subscriber = self.by_address(address)
subscriber = self.by_address(address, domain, domain_segment)
if subscriber:
subscriber.active = False
return True
Expand All @@ -323,6 +373,8 @@ def model_class(self) -> type[SmsSubscriber]:
def handle_subscription(
self,
subscriber: SmsSubscriber,
domain: str | None,
domain_segment: str | None,
request: 'ElectionDayRequest'
) -> None:
""" Confirm the subscription by sending an SMS (if not already
Expand Down
Loading

0 comments on commit d7d8195

Please sign in to comment.