Commit 2bdec9b3 authored by Luko van der Maas's avatar Luko van der Maas
Browse files

Merge branch 'feature/scheduled-notifications' into 'master'

Scheduled notifications for events

See merge request !831
parents 8bc4bcbd 70b089d7
......@@ -54,8 +54,10 @@ RUN if [ "$install_dev_requirements" -eq 1 ]; then \
# Create entry points
COPY resources/entrypoint.sh /usr/local/bin/entrypoint.sh
COPY resources/entrypoint_production.sh /usr/local/bin/entrypoint_production.sh
COPY resources/entrypoint_celery.sh /usr/local/bin/entrypoint_celery.sh
RUN chmod +x /usr/local/bin/entrypoint.sh && \
chmod +x /usr/local/bin/entrypoint_production.sh
chmod +x /usr/local/bin/entrypoint_production.sh && \
chmod +x /usr/local/bin/entrypoint_celery.sh
# copy app source
COPY website /usr/src/app/website/
......
......@@ -6,6 +6,11 @@ services:
- /var/lib/postgresql/
environment: &postgresvars
POSTGRES_DB: thalia
redis:
image: redis:4.0.9
command: redis-server --appendonly yes
volumes:
- redis:/data
web:
image: registry.gitlab.com/thaliawww/concrexit
build: .
......@@ -14,13 +19,29 @@ services:
- 8000:8000
depends_on:
- postgres
- celery
volumes:
- ./website:/usr/src/app/website/
- concrexit:/concrexit/
environment:
environment: &webvars
<<: *postgresvars
DJANGO_DEBUG: 'True'
DJANGO_POSTGRES_HOST: postgres
CELERY_REDIS_HOST: redis
celery:
image: registry.gitlab.com/thaliawww/concrexit
build: .
entrypoint: /usr/local/bin/entrypoint_celery.sh
volumes:
- ./website:/usr/src/app/website/
- concrexit:/concrexit/
depends_on:
- redis
environment:
<<: *webvars
volumes:
concrexit:
driver: local
redis:
driver: local
......@@ -40,6 +40,14 @@ pushnotifications\.models module
:undoc-members:
:show-inheritance:
pushnotifications\.tasks module
-------------------------------
.. automodule:: pushnotifications.tasks
:members:
:undoc-members:
:show-inheritance:
pushnotifications\.urls module
------------------------------
......
......@@ -26,6 +26,14 @@ thaliawebsite\.admin module
:undoc-members:
:show-inheritance:
thaliawebsite\.celery module
----------------------------
.. automodule:: thaliawebsite.celery
:members:
:undoc-members:
:show-inheritance:
thaliawebsite\.forms module
---------------------------
......
......@@ -33,6 +33,14 @@ utils\.snippets module
:undoc-members:
:show-inheritance:
utils\.tasks module
-------------------
.. automodule:: utils.tasks
:members:
:undoc-members:
:show-inheritance:
utils\.threading module
-----------------------
......
......@@ -13,3 +13,6 @@ django-libsass>=0.7,<1
django-cors-headers>=2.1.0,<2.2
python-magic>=0.4.13,<0.5
pyfcm>=1.4.2,<1.5
celery>=4.0<4.2
redis>=2.10<2.11
django-celery-results>=1.0.1<1.1
#!/bin/bash
set -e
# Wait for Redis server to start
sleep 10
# Could do the following if redis-tools installed:
# X="`redis-cli -h \"$CELERY_BROKER_HOST\" ping`"
# echo ${X}
#
# while [ "${X}" != "PONG" ]; do
# >&2 echo "Redis is unavailable: Sleeping"
# echo "${X}"
# sleep 5
# done
# >&2 echo "Redis is up"
cd /usr/src/app/website/
>&2 echo "Starting celery worker"
celery worker -A thaliawebsite
# Generated by Django 2.0.2 on 2018-06-13 18:46
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('pushnotifications', '0008_scheduledmessage'),
('events', '0025_auto_20180112_1214'),
]
operations = [
migrations.AddField(
model_name='event',
name='registration_reminder',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registration_event', to='pushnotifications.ScheduledMessage'),
),
migrations.AddField(
model_name='event',
name='start_reminder',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='start_event', to='pushnotifications.ScheduledMessage'),
),
]
......@@ -11,6 +11,8 @@ from django.utils.translation import ugettext_lazy as _
from django.utils.text import format_lazy
from tinymce.models import HTMLField
from members.models import Member
from pushnotifications.models import ScheduledMessage, Category
from utils.translation import ModelTranslateMeta, MultilingualField
......@@ -137,6 +139,13 @@ class Event(models.Model, metaclass=ModelTranslateMeta):
published = models.BooleanField(_("published"), default=False)
registration_reminder = models.ForeignKey(
ScheduledMessage, on_delete=models.deletion.SET_NULL,
related_name='registration_event', blank=True, null=True)
start_reminder = models.ForeignKey(
ScheduledMessage, on_delete=models.deletion.SET_NULL,
related_name='start_event', blank=True, null=True)
@property
def after_cancel_deadline(self):
return self.cancel_deadline and self.cancel_deadline <= timezone.now()
......@@ -270,6 +279,47 @@ class Event(models.Model, metaclass=ModelTranslateMeta):
def get_absolute_url(self):
return reverse('events:event', args=[str(self.pk)])
def save(self, *args, **kwargs):
if self.registration_required:
registration_reminder = ScheduledMessage()
if self.registration_reminder is not None:
registration_reminder = self.registration_reminder
registration_reminder.title_en = 'Event registration'
registration_reminder.title_nl = 'Evenement registratie'
registration_reminder.body_en = ('Registration for \'{}\' '
'starts in 1 hour'
.format(self.title_en))
registration_reminder.body_nl = ('Registratie voor \'{}\' '
'start in 1 uur'
.format(self.title_nl))
registration_reminder.category = Category.objects.get(key='event')
registration_reminder.time = (self.registration_start -
timezone.timedelta(hours=1))
registration_reminder.save()
self.registration_reminder = registration_reminder
self.registration_reminder.users.set(Member.active_members.all())
start_reminder = ScheduledMessage()
if self.start_reminder is not None:
start_reminder = self.start_reminder
start_reminder.title_en = 'Event'
start_reminder.title_nl = 'Evenement'
start_reminder.body_en = ('\'{}\' starts in '
'1 hour'.format(self.title_en))
start_reminder.body_nl = ('\'{}\' begint over '
'1 uur'.format(self.title_nl))
start_reminder.category = Category.objects.get(key='event')
start_reminder.time = (self.start - timezone.timedelta(hours=1))
start_reminder.save()
self.start_reminder = start_reminder
if self.registration_required:
self.start_reminder.users.set(self.participants.values_list(
'member', flat=True))
else:
self.start_reminder.users.set(Member.active_members.all())
super().save(*args, **kwargs)
def __str__(self):
return '{}: {}'.format(
self.title,
......@@ -395,6 +445,16 @@ class Registration(models.Model):
def validate_unique(self, exclude=None):
super().validate_unique(exclude)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
if self.event.start_reminder and self.date_cancelled:
self.event.start_reminder.users.remove(self.member)
elif (self.event.start_reminder and self.member is not None and
not self.event.start_reminder.users
.filter(pk=self.member.pk).exists()):
self.event.start_reminder.users.add(self.member)
def __str__(self):
if self.member:
return '{}: {}'.format(self.member.get_full_name(), self.event)
......
......@@ -52,3 +52,28 @@ class MessageAdmin(TranslatedModelAdmin):
obj = Message.objects.filter(id=object_id)[0]
return super(MessageAdmin, self).change_view(
request, object_id, form_url, {'message': obj})
@admin.register(models.ScheduledMessage)
class ScheduledMessageAdmin(TranslatedModelAdmin):
list_display = ('title', 'body', 'time', 'category', 'sent', 'success',
'failure')
date_hierarchy = 'time'
filter_horizontal = ('users',)
list_filter = ('sent', 'category')
def get_fields(self, request, obj=None):
if obj and obj.sent:
return ('users', 'title_nl', 'title_en', 'body_nl', 'body_en',
'category', 'success', 'failure', 'time', 'task_id')
return ('users', 'title_nl', 'title_en', 'body_nl', 'body_en',
'category', 'time', 'task_id')
def get_readonly_fields(self, request, obj=None):
if obj and obj.sent:
return ('users', 'title_nl', 'title_en', 'body_nl', 'body_en',
'category', 'success', 'failure', 'time', 'task_id')
return 'task_id',
def has_add_permission(self, request):
return False
# Generated by Django 2.0.2 on 2018-06-13 18:46
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('pushnotifications', '0007_auto_20180307_2148'),
]
operations = [
migrations.CreateModel(
name='ScheduledMessage',
fields=[
('message_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='pushnotifications.Message')),
('task_id', models.CharField(blank=True, max_length=50, null=True)),
('time', models.DateTimeField()),
],
bases=('pushnotifications.message',),
),
]
......@@ -2,14 +2,19 @@ from __future__ import unicode_literals
from django.conf import settings
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import override
from django.utils.translation import ugettext_lazy as _
from pyfcm import FCMNotification
from utils.tasks import revoke_task, schedule_task
from utils.translation import MultilingualField, ModelTranslateMeta
from .tasks import send_message
from thaliawebsite import celery_app
class Category(models.Model, metaclass=ModelTranslateMeta):
"""Describes a Message category"""
key = models.CharField(max_length=16, primary_key=True)
name = MultilingualField(
......@@ -27,6 +32,8 @@ def default_receive_category():
class Device(models.Model):
"""Describes a device"""
DEVICE_TYPES = (
('ios', 'iOS'),
('android', 'Android')
......@@ -61,7 +68,19 @@ class Device(models.Model):
unique_together = ('registration_id', 'user',)
class MessageManager(models.Manager):
"""Returns manual messages only"""
def get_queryset(self):
return (super().get_queryset()
.filter(scheduledmessage__task_id=None))
class Message(models.Model, metaclass=ModelTranslateMeta):
"""Describes a push notification"""
objects = MessageManager()
GENERAL = 'general'
PIZZA = 'pizza'
EVENT = 'event'
......@@ -173,3 +192,44 @@ class Message(models.Model, metaclass=ModelTranslateMeta):
return result_list
return None
class ScheduledMessageManager(models.Manager):
"""Returns scheduled messages only"""
def get_queryset(self):
return super().get_queryset()
class ScheduledMessage(Message, metaclass=ModelTranslateMeta):
"""Describes a scheduled push notification"""
objects = ScheduledMessageManager()
task_id = models.CharField(max_length=50, blank=True, null=True)
time = models.DateTimeField()
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._time = self.time
def schedule(self):
"""Schedules a Celery task to send this message"""
return schedule_task(send_message, args=(self.pk,), eta=self.time)
def save(self, *args, **kwargs):
"""Custom save method which also schedules the task"""
if not (self._time == self.time):
if self.task_id:
# Revoke that task in case its time has changed
revoke_task(self.task_id)
super().save(*args, **kwargs)
self.task_id = self.schedule()
super().save(*args, **kwargs)
def delete(self, using=None, keep_parents=False):
if self.task_id:
celery_app.control.revoke(self.task_id)
return super().delete(using, keep_parents)
from __future__ import absolute_import
from celery import shared_task
from django.apps import apps
@shared_task
def send_message(message_id):
"""Send a push notification"""
print('Sending push notification {}'.format(message_id))
ScheduledMessage = apps.get_model('pushnotifications', 'ScheduledMessage')
try:
message = ScheduledMessage.objects.get(pk=message_id)
except ScheduledMessage.DoesNotExist:
print('Cannot find ScheduledMessage')
return
message.send()
from __future__ import absolute_import, unicode_literals
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ['celery_app']
from __future__ import absolute_import, unicode_literals
import os
from celery import Celery
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'thaliawebsite.settings')
app = Celery('thaliawebsite')
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object('django.conf:settings', namespace='CELERY')
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()
......@@ -125,6 +125,12 @@ if os.environ.get('DJANGO_EMAIL_HOST'):
EMAIL_USE_SSL = os.environ.get('DJANGO_EMAIL_USE_SSL', False) == 'True'
EMAIL_TIMEOUT = 10
# Celery settings
CELERY_BROKER_URL = 'redis://{}:6379/0'.format(
os.environ.get('CELERY_REDIS_HOST')
)
CELERY_ENABLED = True
# Secure headers
X_FRAME_OPTIONS = 'DENY'
SECURE_CONTENT_TYPE_NOSNIFF = True
......
......@@ -54,6 +54,7 @@ INSTALLED_APPS = [
'rest_framework.authtoken',
'compressor',
'corsheaders',
'django_celery_results',
# Our apps
# Directly link to the app config when applicable as recommended
# by the docs: https://docs.djangoproject.com/en/2.0/ref/applications/
......@@ -240,6 +241,14 @@ COMPRESS_CSS_FILTERS = ['compressor.filters.css_default.CssAbsoluteFilter',
# Precompiler settings
STATIC_PRECOMPILER_LIST_FILES = True
# Celery settings
CELERY_BROKER_URL = 'redis://localhost:6379/0'
# Checkout caveats for the timeout config below:
# http://docs.celeryproject.org/en/latest/getting-started/brokers/redis.html#id1
CELERY_BROKER_TRANSPORT_OPTIONS = {'visibility_timeout': 15778800}
CELERY_RESULT_BACKEND = 'django-db'
CELERY_ENABLED = False
# Membership prices
MEMBERSHIP_PRICES = {
'year': 7.5,
......
......@@ -41,3 +41,6 @@ PASSWORD_HASHERS = (
'django.middleware.csrf.CsrfViewMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
)]
# Celery not needed for testing
CELERY_ENABLED = False
from django.utils import timezone
from thaliawebsite import celery_app
from django.conf import settings
def schedule_task(task, args=(), eta=timezone.now()):
if settings.CELERY_ENABLED:
result = task.apply_async(args, eta=eta)
return result.id
return None
def revoke_task(task_id):
if settings.CELERY_ENABLED:
celery_app.control.revoke(task_id)
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment