from email.headerregistry import Address
from itertools import chain
from onegov.core.custom import json
from onegov.core.html import html_to_text
from onegov.core.orm import Base
from onegov.core.orm.mixins import TimestampMixin
from onegov.core.orm.types import UTCDateTime
from onegov.core.orm.types import UUID
from onegov.core.templates import render_template
from onegov.core.utils import PostThread
from onegov.election_day import _
from onegov.election_day.models.election import Election
from onegov.election_day.models.election_compound import ElectionCompound
from onegov.election_day.models.subscriber import EmailSubscriber
from onegov.election_day.models.subscriber import SmsSubscriber
from onegov.election_day.models.vote import Vote
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import func
from sqlalchemy import or_
from sqlalchemy import Text
from sqlalchemy.orm import relationship
from uuid import uuid4
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import uuid
from collections.abc import Iterator
from collections.abc import Sequence
from datetime import datetime
from onegov.core.types import EmailJsonDict
from onegov.election_day.request import ElectionDayRequest
from translationstring import TranslationString
[docs]
class Notification(Base, TimestampMixin):
""" Stores triggered notifications. """
[docs]
__tablename__ = 'notifications'
#: the type of the item, this can be used to create custom polymorphic
#: subclasses of this class. See
#: `<https://docs.sqlalchemy.org/en/improve_toc/\
#: orm/extensions/declarative/inheritance.html>`_.
[docs]
type: 'Column[str]' = Column(
Text,
nullable=False,
default=lambda: 'generic'
)
[docs]
__mapper_args__ = {
'polymorphic_on': type,
'polymorphic_identity': 'generic'
}
#: Identifies the notification
[docs]
id: 'Column[uuid.UUID]' = Column(
UUID, # type:ignore[arg-type]
primary_key=True,
default=uuid4
)
#: The last update of the corresponding election/vote
[docs]
last_modified: 'Column[datetime | None]' = Column(
UTCDateTime,
nullable=True
)
#: The corresponding election id
[docs]
election_id: 'Column[str | None]' = Column(
Text,
ForeignKey(Election.id, onupdate='CASCADE', ondelete='CASCADE'),
nullable=True
)
#: The corresponding election
[docs]
election: 'relationship[Election | None]' = relationship(
'Election',
back_populates='notifications'
)
#: The corresponding election compound id
[docs]
election_compound_id: 'Column[str | None]' = Column(
Text,
ForeignKey(
ElectionCompound.id, onupdate='CASCADE', ondelete='CASCADE'
),
nullable=True
)
#: The corresponding election compound
[docs]
election_compound: 'relationship[ElectionCompound | None]' = relationship(
'ElectionCompound',
back_populates='notifications'
)
#: The corresponding vote id
[docs]
vote_id: 'Column[str | None]' = Column(
Text,
ForeignKey(Vote.id, onupdate='CASCADE', ondelete='CASCADE'),
nullable=True
)
#: The corresponding vote
[docs]
vote: 'relationship[Vote | None]' = relationship(
'Vote',
back_populates='notifications'
)
[docs]
def update_from_model(
self,
model: Election | ElectionCompound | Vote
) -> None:
""" Copy """
self.last_modified = model.last_modified
if isinstance(model, Election):
self.election_id = model.id
elif isinstance(model, ElectionCompound):
self.election_compound_id = model.id
elif isinstance(model, Vote):
self.vote_id = model.id
[docs]
def trigger(
self,
request: 'ElectionDayRequest',
model: Election | ElectionCompound | Vote
) -> None:
""" Trigger the custom actions. """
raise NotImplementedError
[docs]
class WebhookNotification(Notification):
[docs]
__mapper_args__ = {'polymorphic_identity': 'webhooks'}
[docs]
def trigger(
self,
request: 'ElectionDayRequest',
model: Election | ElectionCompound | Vote
) -> None:
""" Posts the summary of the given vote or election to the webhook
URL defined for this principal.
This only works for external URLs. If posting to the server itself is
needed, use a process instead of the thread:
process = Process(target=send_post_request, args=(urls, data))
process.start()
"""
from onegov.election_day.utils import get_summary
self.update_from_model(model)
webhooks = request.app.principal.webhooks
if webhooks:
summary = get_summary(model, request)
data = json.dumps(summary).encode('utf-8')
for url, headers in webhooks.items():
headers = headers or {}
headers['Content-Type'] = 'application/json; charset=utf-8'
headers['Content-Length'] = str(len(data))
PostThread(
url,
data,
tuple((key, value) for key, value in headers.items())
).start()
[docs]
class EmailNotification(Notification):
[docs]
__mapper_args__ = {'polymorphic_identity': 'email'}
[docs]
def set_locale(
self,
request: 'ElectionDayRequest',
locale: str | None = None
) -> None:
""" Changes the locale of the request.
(Re)stores the intial locale if no locale is given.
"""
if not locale:
locale = request.__dict__.setdefault('_old_locale', request.locale)
request.locale = locale
if 'translator' in request.__dict__:
del request.__dict__['translator']
[docs]
def send_emails(
self,
request: 'ElectionDayRequest',
elections: 'Sequence[Election]',
election_compounds: 'Sequence[ElectionCompound]',
votes: 'Sequence[Vote]',
subject: str | None = None
) -> None:
""" Sends the results of the vote or election to all subscribers.
Adds unsubscribe headers (RFC 2369, RFC 8058).
"""
from onegov.election_day.layouts import MailLayout # circular
from onegov.election_day.utils import segment_models
if not elections and not election_compounds and not votes:
return
self.set_locale(request)
groups = segment_models(elections, election_compounds, votes)
reply_to = Address(
display_name=request.app.principal.name or '',
addr_spec=request.app.principal.reply_to
or request.app.mail['marketing']['sender'] # type:ignore[index]
)
# We use a generator function to submit the email batch since that
# is significantly more memory efficient for large batches.
def email_iter() -> 'Iterator[EmailJsonDict]':
for locale in request.app.locales:
for group in groups:
query = request.session.query(EmailSubscriber.address)
query = query.filter(
EmailSubscriber.active.is_(True),
EmailSubscriber.locale == locale,
group.filter
)
addresses = {address for address, in query}
if not addresses:
continue
self.set_locale(request, locale)
layout = MailLayout(self, request)
if subject:
subject_ = request.translate(subject)
else:
items: Iterator[Election | ElectionCompound | Vote]
items = chain(
group.election_compounds,
group.elections,
group.votes
)
subject_ = layout.subject(next(items))
content = render_template(
'mail_results.pt',
request,
{
'title': subject_,
'elections': group.elections,
'election_compounds': group.election_compounds,
'votes': group.votes,
'layout': layout
}
)
plaintext = html_to_text(content)
for address in addresses:
token = request.new_url_safe_token({
'address': address
})
optout_custom = f'{layout.optout_link}?opaque={token}'
yield request.app.prepare_email(
subject=subject_,
receivers=(address, ),
reply_to=reply_to,
content=content.replace(
layout.optout_link,
optout_custom
),
plaintext=plaintext.replace(
layout.optout_link,
optout_custom
),
headers={
'List-Unsubscribe': f'<{optout_custom}>',
'List-Unsubscribe-Post':
'List-Unsubscribe=One-Click'
}
)
request.app.send_marketing_email_batch(email_iter())
self.set_locale(request)
[docs]
def trigger(
self,
request: 'ElectionDayRequest',
model: Election | ElectionCompound | Vote
) -> None:
""" Sends the results of the vote, election or election compound to
all subscribers.
Adds unsubscribe headers (RFC 2369, RFC 8058).
"""
self.update_from_model(model)
self.send_emails(
request,
elections=[model] if isinstance(model, Election) else [],
election_compounds=(
[model] if isinstance(model, ElectionCompound) else []
),
votes=[model] if isinstance(model, Vote) else []
)
[docs]
class SmsNotification(Notification):
[docs]
__mapper_args__ = {'polymorphic_identity': 'sms'}
[docs]
def send_sms(
self,
request: 'ElectionDayRequest',
elections: 'Sequence[Election]',
election_compounds: 'Sequence[ElectionCompound]',
votes: 'Sequence[Vote]',
content: 'TranslationString',
url: str | None = None
) -> None:
""" Sends the given text to all subscribers. """
from onegov.election_day.utils import segment_models
groups = segment_models(elections, election_compounds, votes)
query = request.session.query(
SmsSubscriber.locale,
func.array_agg(SmsSubscriber.address),
)
query = query.filter(
SmsSubscriber.active.is_(True),
or_(*(group.filter for group in groups))
)
query = query.group_by(SmsSubscriber.locale)
query = query.order_by(SmsSubscriber.locale)
for locale, addresses in query:
translator = request.app.translations.get(locale)
translated = translator.gettext(content) if translator else content
if url is not None and len(translated) + len(url) <= 154:
# If the given url fits into a single SMS, then prefer it
# over the generic one it's bound to by default
content = content % {'url': url}
translated = content.interpolate(translated)
request.app.send_sms(tuple(set(addresses)), translated)
[docs]
def trigger(
self,
request: 'ElectionDayRequest',
model: Election | ElectionCompound | Vote
) -> None:
""" Posts a link to the vote or election to all subscribers.
This is done by writing files to a directory similary to maildir,
sending the SMS is done using an external command, probably called
by a cronjob.
"""
self.update_from_model(model)
self.send_sms(
request,
elections=[model] if isinstance(model, Election) else [],
election_compounds=(
[model] if isinstance(model, ElectionCompound) else []
),
votes=[model] if isinstance(model, Vote) else [],
content=_(
'New results are available on ${url}',
mapping={'url': request.app.principal.sms_notification}
),
# NOTE: A SiteLocale link would be nicer UX, but that will make
# the URL significantly longer, so we take what we can get
url=request.link(model)
)