from collections import Sequence
from smtplib import SMTPException
import random
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core import mail
from django.db.models import Q
from django.utils.six import iteritems, iterkeys, string_types
from django.utils.six.moves import range
from celery.task import task
from .compat import is_authenticated
from .models import Watch, WatchFilter, EmailUser, multi_raw
from .utils import collate, hash_to_unsigned
[docs]class ActivationRequestFailed(Exception):
"""Raised when activation request fails, e.g. if email could not be sent"""
def __init__(self, msgs):
self.msgs = msgs
def _unique_by_email(users_and_watches):
"""Given a sequence of (User/EmailUser, [Watch, ...]) pairs
clustered by email address (which is never ''), yield from each
cluster a single pair like this::
(User/EmailUser, [Watch, Watch, ...]).
The User/Email is that of...
(1) the first incoming pair where the User has an email and is not
anonymous, or, if there isn't such a user...
(2) the first pair.
The list of Watches consists of all those found in the cluster.
Compares email addresses case-insensitively.
"""
def ensure_user_has_email(user, cluster_email):
"""Make sure the user in the user-watch pair has an email address.
The caller guarantees us an email from either the user or the watch. If
the passed-in user has no email, we return an EmailUser instead having
the email address from the watch.
"""
# Some of these cases shouldn't happen, but we're tolerant.
if not getattr(user, 'email', ''):
user = EmailUser(cluster_email)
return user
# TODO: Do this instead with clever SQL that somehow returns just the
# best row for each email.
cluster_email = '' # email of current cluster
favorite_user = None # best user in cluster so far
watches = [] # all watches in cluster
for u, w in users_and_watches:
# w always has at least 1 Watch. All the emails are the same.
row_email = u.email or w[0].email
if cluster_email.lower() != row_email.lower():
# Starting a new cluster.
if cluster_email != '':
# Ship the favorites from the previous cluster:
yield (ensure_user_has_email(favorite_user, cluster_email),
watches)
favorite_user, watches = u, []
cluster_email = row_email
elif ((not favorite_user.email or not is_authenticated(u)) and
u.email and is_authenticated(u)):
favorite_user = u
watches.extend(w)
if favorite_user is not None:
yield ensure_user_has_email(favorite_user, cluster_email), watches
[docs]class Event(object):
"""Abstract base class for events
An :class:`Event` represents, simply, something that occurs. A
:class:`~tidings.models.Watch` is a record of someone's interest in a
certain type of :class:`Event`, distinguished by ``Event.event_type``.
Fire an Event (``SomeEvent.fire()``) from the code that causes the
interesting event to occur. Fire it any time the event *might* have
occurred. The Event will determine whether conditions are right to actually
send notifications; don't succumb to the temptation to do these tests
outside the Event, because you'll end up repeating yourself if the event is
ever fired from more than one place.
:class:`Event` subclasses can optionally represent a more limited scope of
interest by populating the ``Watch.content_type`` field and/or adding
related :class:`~tidings.models.WatchFilter` rows holding name/value pairs,
the meaning of which is up to each individual subclass. NULL values are
considered wildcards.
:class:`Event` subclass instances must be pickleable so they can be
shuttled off to celery tasks.
"""
# event_type = 'hamster modified' # key for the event_type column
content_type = None # or, for example, Hamster
#: Possible filter keys, for validation only. For example:
#: ``set(['color', 'flavor'])``
filters = set()
[docs] def fire(self, exclude=None, delay=True):
"""Notify everyone watching the event.
We are explicit about sending notifications; we don't just key off
creation signals, because the receiver of a ``post_save`` signal has no
idea what just changed, so it doesn't know which notifications to send.
Also, we could easily send mail accidentally: for instance, during
tests. If we want implicit event firing, we can always register a
signal handler that calls :meth:`fire()`.
:arg exclude: If a saved user is passed in, that user will not be
notified, though anonymous notifications having the same email
address may still be sent. A sequence of users may also be passed in.
:arg delay: If True (default), the event is handled asynchronously with
Celery. This requires the pickle task serializer, which is no longer
the default starting in Celery 4.0. If False, the event is processed
immediately.
"""
if delay:
# Tasks don't receive the `self` arg implicitly.
self._fire_task.apply_async(
args=(self,),
kwargs={'exclude': exclude},
serializer='pickle')
else:
self._fire_task(self, exclude=exclude)
@task
def _fire_task(self, exclude=None):
"""Build and send the emails as a celery task."""
connection = mail.get_connection(fail_silently=True)
# Warning: fail_silently swallows errors thrown by the generators, too.
connection.open()
for m in self._mails(self._users_watching(exclude=exclude)):
connection.send_messages([m])
[docs] @classmethod
def _validate_filters(cls, filters):
"""Raise a TypeError if ``filters`` contains any keys inappropriate to
this event class."""
for k in iterkeys(filters):
if k not in cls.filters:
# Mirror "unexpected keyword argument" message:
raise TypeError("%s got an unsupported filter type '%s'" %
(cls.__name__, k))
[docs] def _users_watching_by_filter(self, object_id=None, exclude=None,
**filters):
"""Return an iterable of (``User``/:class:`~tidings.models.EmailUser`,
[:class:`~tidings.models.Watch` objects]) tuples watching the event.
Of multiple Users/EmailUsers having the same email address, only one is
returned. Users are favored over EmailUsers so we are sure to be able
to, for example, include a link to a user profile in the mail.
The list of :class:`~tidings.models.Watch` objects includes both
those tied to the given User (if there is a registered user)
and to any anonymous Watch having the same email address. This
allows you to include all relevant unsubscribe URLs in a mail,
for example. It also lets you make decisions in the
:meth:`~tidings.events.EventUnion._mails()` method of
:class:`~tidings.events.EventUnion` based on the kinds of
watches found.
"Watching the event" means having a Watch whose ``event_type`` is
``self.event_type``, whose ``content_type`` is ``self.content_type`` or
``NULL``, whose ``object_id`` is ``object_id`` or ``NULL``, and whose
WatchFilter rows match as follows: each name/value pair given in
``filters`` must be matched by a related WatchFilter, or there must be
no related WatchFilter having that name. If you find yourself wanting
the lack of a particularly named WatchFilter to scuttle the match, use
a different event_type instead.
:arg exclude: If a saved user is passed in as this argument, that user
will never be returned, though anonymous watches having the same
email address may. A sequence of users may also be passed in.
"""
# I don't think we can use the ORM here, as there's no way to get a
# second condition (name=whatever) into a left join. However, if we
# were willing to have 2 subqueries run for every watch row--select
# {are there any filters with name=x?} and select {is there a filter
# with name=x and value=y?}--we could do it with extra(). Then we could
# have EventUnion simply | the QuerySets together, which would avoid
# having to merge in Python.
if exclude is None:
exclude = []
elif not isinstance(exclude, Sequence):
exclude = [exclude]
def filter_conditions():
"""Return joins, WHERE conditions, and params to bind to them in
order to check a notification against all the given filters."""
# Not a one-liner. You're welcome. :-)
self._validate_filters(filters)
joins, wheres, join_params, where_params = [], [], [], []
for n, (k, v) in enumerate(iteritems(filters)):
joins.append(
'LEFT JOIN tidings_watchfilter f{n} '
'ON f{n}.watch_id=w.id '
'AND f{n}.name=%s'.format(n=n))
join_params.append(k)
wheres.append('(f{n}.value=%s '
'OR f{n}.value IS NULL)'.format(n=n))
where_params.append(hash_to_unsigned(v))
return joins, wheres, join_params + where_params
# Apply watchfilter constraints:
joins, wheres, params = filter_conditions()
# Start off with event_type, which is always a constraint. These go in
# the `wheres` list to guarantee that the AND after the {wheres}
# substitution in the query is okay.
wheres.append('w.event_type=%s')
params.append(self.event_type)
# Constrain on other 1-to-1 attributes:
if self.content_type:
wheres.append('(w.content_type_id IS NULL '
'OR w.content_type_id=%s)')
params.append(ContentType.objects.get_for_model(
self.content_type).id)
if object_id:
wheres.append('(w.object_id IS NULL OR w.object_id=%s)')
params.append(object_id)
if exclude:
# Don't try excluding unsaved Users:1
if not all(e.id for e in exclude):
raise ValueError("Can't exclude an unsaved User.")
wheres.append('(u.id IS NULL OR u.id NOT IN (%s))' %
', '.join('%s' for e in exclude))
params.extend(e.id for e in exclude)
def get_fields(model):
if hasattr(model._meta, '_fields'):
# For django versions < 1.6
return model._meta._fields()
else:
# For django versions >= 1.6
return model._meta.fields
User = get_user_model()
model_to_fields = dict((m, [f.get_attname() for f in get_fields(m)])
for m in [User, Watch])
query_fields = [
'u.{0}'.format(field) for field in model_to_fields[User]]
query_fields.extend([
'w.{0}'.format(field) for field in model_to_fields[Watch]])
query = (
'SELECT {fields} '
'FROM tidings_watch w '
'LEFT JOIN {user_table} u ON u.id=w.user_id {joins} '
'WHERE {wheres} '
'AND (length(w.email)>0 OR length(u.email)>0) '
'AND w.is_active '
'ORDER BY u.email DESC, w.email DESC').format(
fields=', '.join(query_fields),
joins=' '.join(joins),
wheres=' AND '.join(wheres),
user_table=User._meta.db_table)
# IIRC, the DESC ordering was something to do with the placement of
# NULLs. Track this down and explain it.
# Put watch in a list just for consistency. Once the pairs go through
# _unique_by_email, watches will be in a list, and EventUnion uses the
# same function to union already-list-enclosed pairs from individual
# events.
return _unique_by_email((u, [w]) for u, w in
multi_raw(query, params, [User, Watch],
model_to_fields))
[docs] @classmethod
def _watches_belonging_to_user(cls, user_or_email, object_id=None,
**filters):
"""Return a QuerySet of watches having the given user or email, having
(only) the given filters, and having the event_type and content_type
attrs of the class.
Matched Watches may be either confirmed and unconfirmed. They may
include duplicates if the get-then-create race condition in
:meth:`notify()` allowed them to be created.
If you pass an email, it will be matched against only the email
addresses of anonymous watches. At the moment, the only integration
point planned between anonymous and registered watches is the claiming
of anonymous watches of the same email address on user registration
confirmation.
If you pass the AnonymousUser, this will return an empty QuerySet.
"""
# If we have trouble distinguishing subsets and such, we could store a
# number_of_filters on the Watch.
cls._validate_filters(filters)
if isinstance(user_or_email, string_types):
user_condition = Q(email=user_or_email)
elif is_authenticated(user_or_email):
user_condition = Q(user=user_or_email)
else:
return Watch.objects.none()
# Filter by stuff in the Watch row:
watches = getattr(Watch, 'uncached', Watch.objects).filter(
user_condition,
Q(content_type=ContentType.objects.get_for_model(
cls.content_type)) if cls.content_type else Q(),
Q(object_id=object_id) if object_id else Q(),
event_type=cls.event_type).extra(
where=['(SELECT count(*) FROM tidings_watchfilter WHERE '
'tidings_watchfilter.watch_id='
'tidings_watch.id)=%s'],
params=[len(filters)])
# Optimization: If the subselect ends up being slow, store the number
# of filters in each Watch row or try a GROUP BY.
# Apply 1-to-many filters:
for k, v in iteritems(filters):
watches = watches.filter(filters__name=k,
filters__value=hash_to_unsigned(v))
return watches
[docs] @classmethod
# Funny arg name to reserve use of nice ones for filters
def is_notifying(cls, user_or_email_, object_id=None, **filters):
"""Return whether the user/email is watching this event (either
active or inactive watches), conditional on meeting the criteria in
``filters``.
Count only watches that match the given filters exactly--not ones which
match merely a superset of them. This lets callers distinguish between
watches which overlap in scope. Equivalently, this lets callers check
whether :meth:`notify()` has been called with these arguments.
Implementations in subclasses may take different arguments--for
example, to assume certain filters--though most will probably just use
this. However, subclasses should clearly document what filters they
supports and the meaning of each.
Passing this an ``AnonymousUser`` always returns ``False``. This means
you can always pass it ``request.user`` in a view and get a sensible
response.
"""
return cls._watches_belonging_to_user(user_or_email_,
object_id=object_id,
**filters).exists()
[docs] @classmethod
def notify(cls, user_or_email_, object_id=None, **filters):
"""Start notifying the given user or email address when this event
occurs and meets the criteria given in ``filters``.
Return the created (or the existing matching) Watch so you can call
:meth:`~tidings.models.Watch.activate()` on it if you're so inclined.
Implementations in subclasses may take different arguments; see the
docstring of :meth:`is_notifying()`.
Send an activation email if an anonymous watch is created and
:data:`~django.conf.settings.TIDINGS_CONFIRM_ANONYMOUS_WATCHES` is
``True``. If the activation request fails, raise a
ActivationRequestFailed exception.
Calling :meth:`notify()` twice for an anonymous user will send the
email each time.
"""
# A test-for-existence-then-create race condition exists here, but it
# doesn't matter: de-duplication on fire() and deletion of all matches
# on stop_notifying() nullify its effects.
try:
# Pick 1 if >1 are returned:
watch = cls._watches_belonging_to_user(
user_or_email_,
object_id=object_id,
**filters)[0:1].get()
except Watch.DoesNotExist:
create_kwargs = {}
if cls.content_type:
create_kwargs['content_type'] = \
ContentType.objects.get_for_model(cls.content_type)
create_kwargs['email' if isinstance(user_or_email_, string_types)
else 'user'] = user_or_email_
# Letters that can't be mistaken for other letters or numbers in
# most fonts, in case people try to type these:
distinguishable_letters = \
'abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRTUVWXYZ'
secret = ''.join(random.choice(distinguishable_letters)
for x in range(10))
# Registered users don't need to confirm, but anonymous users do.
is_active = ('user' in create_kwargs or
not settings.TIDINGS_CONFIRM_ANONYMOUS_WATCHES)
if object_id:
create_kwargs['object_id'] = object_id
watch = Watch.objects.create(
secret=secret,
is_active=is_active,
event_type=cls.event_type,
**create_kwargs)
for k, v in iteritems(filters):
WatchFilter.objects.create(watch=watch, name=k,
value=hash_to_unsigned(v))
# Send email for inactive watches.
if not watch.is_active:
email = watch.user.email if watch.user else watch.email
message = cls._activation_email(watch, email)
try:
message.send()
except SMTPException as e:
watch.delete()
raise ActivationRequestFailed(e.recipients)
return watch
[docs] @classmethod
def stop_notifying(cls, user_or_email_, **filters):
"""Delete all watches matching the exact user/email and filters.
Delete both active and inactive watches. If duplicate watches
exist due to the get-then-create race condition, delete them all.
Implementations in subclasses may take different arguments; see the
docstring of :meth:`is_notifying()`.
"""
cls._watches_belonging_to_user(user_or_email_, **filters).delete()
# TODO: If GenericForeignKeys don't give us cascading deletes, make a
# stop_notifying_all(**filters) or something. It should delete any watch of
# the class's event_type and content_type and having filters matching each
# of **filters. Even if there are additional filters on a watch, that watch
# should still be deleted so we can delete, for example, any watch that
# references a certain Question instance. To do that, factor such that you
# can effectively call _watches_belonging_to_user() without it calling
# extra().
# Subclasses should implement the following:
[docs] def _mails(self, users_and_watches):
"""Return an iterable yielding an EmailMessage to send to each user.
:arg users_and_watches: an iterable of (User or EmailUser, [Watches])
pairs where the first element is the user to send to and the second
is a list of watches (usually just one) that indicated the
user's interest in this event
:meth:`~tidings.utils.emails_with_users_and_watches()` can come in
handy for generating mails from Django templates.
"""
# Did this instead of mail() because a common case might be sending the
# same mail to many users. mail() would make it difficult to avoid
# redoing the templating every time.
raise NotImplementedError
[docs] def _users_watching(self, **kwargs):
"""Return an iterable of Users and EmailUsers watching this event
and the Watches that map them to it.
Each yielded item is a tuple: (User or EmailUser, [list of Watches]).
Default implementation returns users watching this object's event_type
and, if defined, content_type.
"""
return self._users_watching_by_filter(**kwargs)
[docs] @classmethod
def _activation_email(cls, watch, email):
"""Return an EmailMessage to send to anonymous watchers.
They are expected to follow the activation URL sent in the email to
activate their watch, so you should include at least that.
"""
# TODO: basic implementation.
return mail.EmailMessage('TODO', 'Activate!',
settings.TIDINGS_FROM_ADDRESS,
[email])
[docs] @classmethod
def _activation_url(cls, watch):
"""Return a URL pointing to a view which :meth:`activates
<tidings.models.Watch.activate()>` a watch.
TODO: provide generic implementation of this before liberating.
Generic implementation could involve a setting to the default
``reverse()`` path, e.g. ``'tidings.activate_watch'``.
"""
raise NotImplementedError
[docs] @classmethod
def description_of_watch(cls, watch):
"""Return a description of the Watch which can be used in emails.
For example, "changes to English articles"
"""
raise NotImplementedError
[docs]class EventUnion(Event):
"""Fireable conglomeration of multiple events
Use this when you want to send a single mail to each person watching any of
several events. For example, this sends only 1 mail to a given user, even
if he was being notified of all 3 events::
EventUnion(SomeEvent(), OtherEvent(), ThirdEvent()).fire()
"""
# Calls some private methods on events, but this and Event are good
# friends.
[docs] def __init__(self, *events):
""":arg events: the events of which to take the union"""
super(EventUnion, self).__init__()
self.events = events
[docs] def _mails(self, users_and_watches):
"""Default implementation calls the
:meth:`~tidings.events.Event._mails()` of my first event but may
pass it any of my events as ``self``.
Use this default implementation when the content of each event's mail
template is essentially the same, e.g. "This new post was made.
Enjoy.". When the receipt of a second mail from the second event would
add no value, this is a fine choice. If the second event's email would
add value, you should probably fire both events independently and let
both mails be delivered. Or, if you would like to send a single mail
with a custom template for a batch of events, just subclass
:class:`EventUnion` and override this method.
"""
return self.events[0]._mails(users_and_watches)
def _users_watching(self, **kwargs):
# Get a sorted iterable of user-watches pairs:
def email_key(pair):
user, watch = pair
return user.email.lower()
users_and_watches = collate(
*[e._users_watching(**kwargs) for e in self.events],
key=email_key,
reverse=True)
# Pick the best User out of each cluster of identical email addresses:
return _unique_by_email(users_and_watches)
[docs]class InstanceEvent(Event):
"""Abstract superclass for watching a specific instance of a Model.
Subclasses must specify an ``event_type`` and should specify a
``content_type``.
"""
[docs] def __init__(self, instance, *args, **kwargs):
"""Initialize an InstanceEvent
:arg instance: the instance someone would have to be watching in
order to be notified when this event is fired.
"""
super(InstanceEvent, self).__init__(*args, **kwargs)
self.instance = instance
[docs] @classmethod
def notify(cls, user_or_email, instance):
"""Create, save, and return a watch which fires when something
happens to ``instance``."""
return super(InstanceEvent, cls).notify(user_or_email,
object_id=instance.pk)
[docs] @classmethod
def stop_notifying(cls, user_or_email, instance):
"""Delete the watch created by notify."""
super(InstanceEvent, cls).stop_notifying(user_or_email,
object_id=instance.pk)
[docs] @classmethod
def is_notifying(cls, user_or_email, instance):
"""Check if the watch created by notify exists."""
return super(InstanceEvent, cls).is_notifying(user_or_email,
object_id=instance.pk)
[docs] def _users_watching(self, **kwargs):
"""Return users watching this instance."""
return self._users_watching_by_filter(object_id=self.instance.pk,
**kwargs)