diff --git a/portail_citoyen_announces/admin.py b/portail_citoyen_announces/admin.py new file mode 100644 index 0000000..dd345ae --- /dev/null +++ b/portail_citoyen_announces/admin.py @@ -0,0 +1,78 @@ +import os + +from django.contrib import admin +from django.contrib.sites.models import get_current_site + +import models +import transports +import app_settings +from django.utils.translation import ugettext_lazy as _ + + +class SendingAction(object): + __name__ = 'sending action' + + def __init__(self, mode): + self.mode = mode + + @property + def short_description(self): + transport = transports.get_transport(self.mode) + display_name = getattr(transport, 'display_name', transport.identifier) + return _('Send by {0}').format(display_name) + + def __call__(self, modeladmin, request, queryset): + pid = os.fork() + if pid != 0: + return + transport = transports.get_transport(self.mode) + for announce in queryset.select_related(): + transport.send(announce) + + +def transport_actions(): + return [SendingAction(mode) for mode in app_settings.transport_modes] + + +class SentInlineAdmin(admin.TabularInline): + model = models.Sent + extra = 0 + can_delete = False + fields = [ 'time', 'transport', 'result' ] + readonly_fields = [ 'time', 'transport', 'result' ] + + def has_add_permission(self, request): + return False + + +class AnnounceAdmin(admin.ModelAdmin): + actions = transport_actions() + list_display = ['title', 'category', 'extract', 'publication_time', + 'expiration_time', 'hidden', 'sent' ] + inlines = ( SentInlineAdmin, ) + + def extract(self, instance): + return instance.text[:60] + extract.short_description = _('extract') + + def sent(self, instance): + return u', '.join(instance.sent_set.values_list('transport', flat=True).distinct()) + sent.short_description = _('sent') + + +class CategoryAdmin(admin.ModelAdmin): + list_display = [ 'name', 'identifier' ] + + def get_queryset(self, request): + qs = super(CategoryAdmin, self).get_queryset(request) + qs.filter(site=get_current_site(request)) + return qs + +class SubscriptionAdmin(admin.ModelAdmin): + list_filter = [ 'category' ] + list_display = [ 'category', 'transport', 'identifier', 'user', 'created' ] + + +admin.site.register(models.Announce, AnnounceAdmin) +admin.site.register(models.Category, CategoryAdmin) +admin.site.register(models.Subscription, SubscriptionAdmin) diff --git a/portail_citoyen_announces/app_settings.py b/portail_citoyen_announces/app_settings.py new file mode 100644 index 0000000..fb3871b --- /dev/null +++ b/portail_citoyen_announces/app_settings.py @@ -0,0 +1,10 @@ +from django.conf import settings + +default_transport_modes = { + 'email': 'portail_citoyen_announces.transports.EmailTransport', +} + +transport_modes = getattr(settings, 'ANNOUNCES_TRANSPORTS', + default_transport_modes) + +default_from = getattr(settings, 'ANNOUNCES_DEFAULT_FROM_EMAIL', None) diff --git a/portail_citoyen_announces/forms.py b/portail_citoyen_announces/forms.py new file mode 100644 index 0000000..acae039 --- /dev/null +++ b/portail_citoyen_announces/forms.py @@ -0,0 +1,47 @@ +from collections import defaultdict + + +from django import forms + + +import models +import transports + + +class SubscriptionForm(forms.Form): + def __init__(self, *args, **kwargs): + self.user = kwargs.pop('user') + self.site = kwargs.pop('site') + self.categories = models.Category.objects.filter(site=self.site) + self.subscriptions = models.Subscription.objects.filter(user=self.user) + sub_by_category = defaultdict(lambda: []) + for sub in self.subscriptions: + sub_by_category[sub].append(sub.transport) + super(SubscriptionForm, self).__init__(*args, **kwargs) + choices = transports.get_transport_choices() + for category in self.categories: + field = forms.MultipleChoiceField( + label=category.name, choices=choices, + widget=forms.CheckboxSelectMultiple, + initial=sub_by_category[category]) + self.fields['category_%s' % category.identifier] = field + + def save(self): + cleaned_data = self.cleaned_data() + wanted_subscriptions = set() + for category in self.categories: + field_id = 'category_%s' % category.identifier + transports = cleaned_data.get(field_id, []) + for transport in transports: + wanted_subscriptions.add((category, transport)) + # delete dead subscriptions + for subscription in self.subscriptions: + pair = (subscription.category, subscription.transport) + if pair not in wanted_subscriptions: + subscription.delete() + # create or update new ones + for category, transport in wanted_subscriptions: + models.SubscriptionForm.objects.get_or_create( + user=self.user, + category=category, + transport=transport) diff --git a/portail_citoyen_announces/managers.py b/portail_citoyen_announces/managers.py new file mode 100644 index 0000000..b5a9529 --- /dev/null +++ b/portail_citoyen_announces/managers.py @@ -0,0 +1,26 @@ +from model_utils.managers import PassThroughManager +from django.db.models.query import QuerySet +from django.db.models import Q +try: + from django.utils.timezone import now as now +except ImportError: + import datetime + now = datetime.now + +class AnnounceQueryset(QuerySet): + def published(self): + qs = self.filter(hidden=False) + qs = qs.filter( + (Q(publication_time__lte=now) | Q(publication_time__isnull=True)) & + (Q(expiration_time__gte=now) | Q(expiration_time__isnull=True))) + return qs + + def unpublished(self): + qs = self.exclude(hidden=False) + qs = qs.exclude( + (Q(publication_time__lte=now) | Q(publication_time__isnull=True)) & + (Q(expiration_time__gte=now) | Q(expiration_time__isnull=True))) + return qs + + +AnnounceManager = PassThroughManager.for_queryset_class(QuerySet) diff --git a/portail_citoyen_announces/models.py b/portail_citoyen_announces/models.py index 71a8362..1141eb7 100644 --- a/portail_citoyen_announces/models.py +++ b/portail_citoyen_announces/models.py @@ -1,3 +1,98 @@ from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings +from django.core.validators import RegexValidator -# Create your models here. + +from model_utils.models import TimeStampedModel + + +import managers +import transports + + +class Category(TimeStampedModel): + site = models.ForeignKey('sites.Site', verbose_name=_('site')) + name = models.CharField(_('name'), max_length=64) + identifier = models.CharField(_('identifier'), + unique = True, + max_length=64, + validators=[RegexValidator(r'^[a-z0-9_]+$')], + help_text=_('the identifier is used to find ' + 'templates for sending announces; it can ' + 'only contain lowercase letter, digits or underscores')) + + def __unicode__(self): + return self.name + + class Meta: + verbose_name = _('category') + ordering = ('name',) + unique_together = (('site', 'name'), ('site', 'identifier')) + + +class Announce(models.Model): + objects = managers.AnnounceManager() + + title = models.CharField(_('title'), max_length=256, + help_text=_('maximum 256 characters')) + text = models.TextField(_('text')) + + hidden = models.BooleanField(_('hidden'), blank=True, default=False) + + publication_time = models.DateTimeField(_('publication time'), blank=True, + null=True) + expiration_time = models.DateTimeField(_('expiration time'), blank=True, + null=True) + creation_time = models.DateTimeField(_('creation time'), auto_now_add=True) + modification_time = models.DateTimeField(_('modification time'), auto_now=True) + + category = models.ForeignKey('Category', verbose_name=_('category'), blank=True) + + def __unicode__(self): + return u'{title} ({id}) at {modification_time}'.format(title=self.title, + id=self.id, modification_time=self.modification_time) + + class Meta: + verbose_name = _('announce') + ordering = ('-modification_time',) + + +class Sent(models.Model): + announce = models.ForeignKey('Announce', verbose_name=_('announce')) + # subscription = models.ForeignKey('Subscription', verbose_name=_('subscription')) + transport = models.CharField(_('transport'), max_length=32, + choices=transports.get_transport_choices(), blank=False) + time = models.DateTimeField(_('sent time'), auto_now_add=True) + result = models.TextField(_('result'), blank=True) + + def __unicode__(self): + return u'announce {id} sent via {transport} at {time}'.format( + id=self.announce.id, transport=self.transport, time=self.time) + + class Meta: + verbose_name = _('sent') + ordering = ('-time',) + + +class Subscription(TimeStampedModel): + user = models.ForeignKey(settings.AUTH_USER_MODEL, verbose_name=_('user'), + blank=True, null=True) + category = models.ForeignKey('Category', verbose_name=_('category')) + identifier = models.CharField(_('identifier'), max_length=128, blank=False, + help_text=_('ex.: email, mobile phone number, jabber id')) + transport = models.CharField(_('transport'), + max_length=32, choices=transports.get_transport_choices(), + blank=False) + + def __unicode__(self): + return u'subscription of {user} to category {category} with ' \ + 'transport {transport} and identifier {identifier}'.format( + user=self.user, + category=self.category, + transport=self.transport, + identifier=self.identifier) + + class Meta: + verbose_name = _('subscription') + ordering = ('-created',) diff --git a/portail_citoyen_announces/templates/portail_citoyen_announces/body.txt b/portail_citoyen_announces/templates/portail_citoyen_announces/body.txt new file mode 100644 index 0000000..2d98664 --- /dev/null +++ b/portail_citoyen_announces/templates/portail_citoyen_announces/body.txt @@ -0,0 +1 @@ +{{ announce.text }} diff --git a/portail_citoyen_announces/templates/portail_citoyen_announces/subject.txt b/portail_citoyen_announces/templates/portail_citoyen_announces/subject.txt new file mode 100644 index 0000000..861cf37 --- /dev/null +++ b/portail_citoyen_announces/templates/portail_citoyen_announces/subject.txt @@ -0,0 +1 @@ +{{ announce.title }} diff --git a/portail_citoyen_announces/templates/portail_citoyen_announces/subscription_edit.html b/portail_citoyen_announces/templates/portail_citoyen_announces/subscription_edit.html new file mode 100644 index 0000000..121b7d7 --- /dev/null +++ b/portail_citoyen_announces/templates/portail_citoyen_announces/subscription_edit.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
+ {{ form }} + +
+{% endblock %} diff --git a/portail_citoyen_announces/transports.py b/portail_citoyen_announces/transports.py new file mode 100644 index 0000000..838823e --- /dev/null +++ b/portail_citoyen_announces/transports.py @@ -0,0 +1,113 @@ +import logging +import smtplib + + +from django.utils.importlib import import_module +from django.core.mail import EmailMessage +from django.template.loader import select_template +from django.template import Context +from django.utils.translation import ugettext_lazy as _ + + +import app_settings +import models + +logger = logging.getLogger() + + +__transport_choices = None + +def get_transport_choices(): + global __transport_choices + if __transport_choices is None: + __transport_choices = [] + for mode in app_settings.transport_modes: + transport = get_transport(mode) + identifier = transport.identifier + display_name = getattr(transport, 'display_name', identifier) + __transport_choices.append((identifier, display_name)) + return __transport_choices + + +def get_transport(mode): + if mode not in app_settings.transport_modes: + return None + module_path, class_name = app_settings.transport_modes[mode].rsplit('.', 1) + try: + module = import_module(module_path) + return getattr(module, class_name)() + except (ImportError, AttributeError): + logger.error('unable to load transport class %s.%s', + module_path, class_name) + return None + + +def get_template_list(template_list, category): + '''Customize a template list given an announce category''' + for template in template_list: + yield template.format(category=category.identifier) + + +def get_template(template_list, category): + template_list = get_template_list(template_list, category) + return select_template(template_list) + + +class EmailTransport(object): + subject_template_list = [ + 'portail_citoyen_announces/email/subject_{category}.txt', + 'portail_citoyen_announces/email/subject.txt', + 'portail_citoyen_announces/subject_{category}.txt', + 'portail_citoyen_announces/subject.txt', + ] + + body_template_list = [ + 'portail_citoyen_announces/email/body_{category}.txt', + 'portail_citoyen_announces/email/body.txt', + 'portail_citoyen_announces/body_{category}.txt', + 'portail_citoyen_announces/body.txt', + ] + identifier = 'email' + display_name = _('email') + + def get_subscriptions(self, category): + return models.Subscription.objects.filter(category=category, + transport=self.identifier) + + def get_emails(self, category): + qs = self.get_subscriptions(category) + return qs.values_list('identifier', flat=True).distinct() + + def send(self, announce): + category = announce.category + site = category.site + subject_template = get_template(self.subject_template_list, category) + body_template = get_template(self.body_template_list, category) + ctx = Context({ 'announce': announce, 'site': site, 'category': category }) + subject = subject_template.render(ctx).replace('\r', '').replace('\n', '') + body = body_template.render(ctx) + emails = self.get_emails(category) + logger.info(u'sending announce %(announce)s through %(mode)s to %(count)s emails', + dict(announce=announce, mode=self.identifier, count=len(emails))) + try: + message = EmailMessage(subject=subject, + body=body, + from_email=app_settings.default_from, + bcc=emails) + message.send() + except smtplib.SMTPException, e: + msg = u'unable to send announce "%s" on site "%s": %s' % (announce, + site, e) + logger.error(msg) + except Exception, e: + msg = u'unable to send announce "%s" on site "%s": %s' % (announce, + site, e) + logger.exception(msg) + else: + logger.info('announce %(announce)s sent succesfully', + dict(announce=announce)) + msg = u'ok' + models.Sent.objects.create( + announce=announce, + transport=self.identifier, + result=msg) diff --git a/portail_citoyen_announces/views.py b/portail_citoyen_announces/views.py index 60f00ef..7e3075c 100644 --- a/portail_citoyen_announces/views.py +++ b/portail_citoyen_announces/views.py @@ -1 +1,17 @@ -# Create your views here. +from django.views.generic.edit import FormView + + +import forms + + +class SubscriptionView(FormView): + template_name = 'portail_citoyen_announces/subscription_edit.html' + form_class = forms.SubscriptionForm + success_url = '..' + + def form_valid(self, form): + form.save() + return super(SubscriptionView, self).form_valid(self, form) + + +subscription_view = SubscriptionView.as_view()