Implement notifications
authorMagnus Hagander <magnus@hagander.net>
Fri, 12 Feb 2016 17:03:26 +0000 (18:03 +0100)
committerMagnus Hagander <magnus@hagander.net>
Fri, 12 Feb 2016 17:03:26 +0000 (18:03 +0100)
This makes it possible to receive notifications (via email) when a patch
has been updated.

It's possible to subscribe to notifications on a specific patch. It is also
possible to set the user profile up so that one receives notifications for
all patches where one is author, reviewer or committer on.

Notifications are, of course, only sent when other people make modifications
to the entry.

15 files changed:
pgcommitfest/commitfest/ajax.py
pgcommitfest/commitfest/management/__init__.py [new file with mode: 0644]
pgcommitfest/commitfest/management/commands/__init__.py [new file with mode: 0644]
pgcommitfest/commitfest/management/commands/send_notifications.py [new file with mode: 0644]
pgcommitfest/commitfest/migrations/0002_notifications.py [new file with mode: 0644]
pgcommitfest/commitfest/models.py
pgcommitfest/commitfest/templates/mail/patch_notify.txt [new file with mode: 0644]
pgcommitfest/commitfest/templates/patch.html
pgcommitfest/commitfest/views.py
pgcommitfest/mailqueue/util.py
pgcommitfest/urls.py
pgcommitfest/userprofile/forms.py
pgcommitfest/userprofile/migrations/0002_notifications.py [new file with mode: 0644]
pgcommitfest/userprofile/models.py
pgcommitfest/userprofile/templates/userprofileform.html

index 96ae7b02e6607d6f58c8b5bedd66ef68187cefc7..14bbe053a83f10e470be6c452a7f3be6f4f9dc46 100644 (file)
@@ -95,7 +95,7 @@ def annotateMessage(request):
                        annotation.save()
 
                        for p in thread.patches.all():
-                               PatchHistory(patch=p, by=request.user, what='Added annotation "%s" to %s' % (msg, msgid)).save()
+                               PatchHistory(patch=p, by=request.user, what='Added annotation "%s" to %s' % (msg, msgid)).save_and_notify()
                                p.set_modified()
                                p.save()
 
@@ -107,7 +107,7 @@ def deleteAnnotation(request):
        annotation = get_object_or_404(MailThreadAnnotation, pk=request.POST['id'])
 
        for p in annotation.mailthread.patches.all():
-               PatchHistory(patch=p, by=request.user, what='Deleted annotation "%s" from %s' % (annotation.annotationtext, annotation.msgid)).save()
+               PatchHistory(patch=p, by=request.user, what='Deleted annotation "%s" from %s' % (annotation.annotationtext, annotation.msgid)).save_and_notify()
                p.set_modified()
                p.save()
 
@@ -178,7 +178,7 @@ def doAttachThread(cf, patch, msgid, user):
                m.save()
                parse_and_add_attachments(r, m)
 
-       PatchHistory(patch=patch, by=user, what='Attached mail thread %s' % r[0]['msgid']).save()
+       PatchHistory(patch=patch, by=user, what='Attached mail thread %s' % r[0]['msgid']).save_and_notify()
        patch.update_lastmail()
        patch.set_modified()
        patch.save()
@@ -192,7 +192,7 @@ def detachThread(request):
        thread = get_object_or_404(MailThread, messageid=request.POST['msg'])
 
        patch.mailthread_set.remove(thread)
-       PatchHistory(patch=patch, by=request.user, what='Detached mail thread %s' % request.POST['msg']).save()
+       PatchHistory(patch=patch, by=request.user, what='Detached mail thread %s' % request.POST['msg']).save_and_notify()
        patch.update_lastmail()
        patch.set_modified()
        patch.save()
diff --git a/pgcommitfest/commitfest/management/__init__.py b/pgcommitfest/commitfest/management/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/pgcommitfest/commitfest/management/commands/__init__.py b/pgcommitfest/commitfest/management/commands/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/pgcommitfest/commitfest/management/commands/send_notifications.py b/pgcommitfest/commitfest/management/commands/send_notifications.py
new file mode 100644 (file)
index 0000000..634af58
--- /dev/null
@@ -0,0 +1,42 @@
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from django.conf import settings
+
+from StringIO import StringIO
+
+from pgcommitfest.commitfest.models import PendingNotification
+from pgcommitfest.mailqueue.util import send_template_mail
+
+class Command(BaseCommand):
+       help = "Send queued notifications"
+
+       def handle(self, *args, **options):
+               with transaction.atomic():
+                       # Django doesn't do proper group by in the ORM, so we have to
+                       # build our own.
+                       matches = {}
+                       for n in PendingNotification.objects.all().order_by('user', 'history__patch__id', 'history__id'):
+                               if not matches.has_key(n.user.id):
+                                       matches[n.user.id] = {'user': n.user, 'patches': {}}
+                               if not matches[n.user.id]['patches'].has_key(n.history.patch.id):
+                                       matches[n.user.id]['patches'][n.history.patch.id] = {'patch': n.history.patch, 'entries': []}
+                               matches[n.user.id]['patches'][n.history.patch.id]['entries'].append(n.history)
+                               n.delete()
+
+                       # Ok, now let's build emails from this
+                       for v in matches.values():
+                               user = v['user']
+                               email = user.email
+                               if user.userprofile and user.userprofile.notifyemail:
+                                       email = user.userprofile.notifyemail.email
+
+                               send_template_mail(settings.NOTIFICATION_FROM,
+                                                                  None,
+                                                                  email,
+                                                                  "PostgreSQL commitfest updates",
+                                                                  'mail/patch_notify.txt',
+                                                                  {
+                                                                          'user': user,
+                                                                          'patches': v['patches'],
+                                                                  },
+                                                                  )
diff --git a/pgcommitfest/commitfest/migrations/0002_notifications.py b/pgcommitfest/commitfest/migrations/0002_notifications.py
new file mode 100644 (file)
index 0000000..b94daed
--- /dev/null
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+        ('commitfest', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='PendingNotification',
+            fields=[
+                ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
+                ('history', models.ForeignKey(to='commitfest.PatchHistory')),
+                ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='patch',
+            name='subscribers',
+            field=models.ManyToManyField(related_name='patch_subscriber', to=settings.AUTH_USER_MODEL, blank=True),
+        ),
+    ]
index ead5eeab24a5cd521dd84e94e9a432b2bba0a0d8..04d2fb4953a4de755db73afececcc87a93bf6c15 100644 (file)
@@ -91,6 +91,9 @@ class Patch(models.Model, DiffableModel):
 
        committer = models.ForeignKey(Committer, blank=True, null=True)
 
+       # Users to be notified when something happens
+       subscribers = models.ManyToManyField(User, related_name='patch_subscriber', blank=True)
+
        # Datestamps for tracking activity
        created = models.DateTimeField(blank=False, null=False, auto_now_add=True)
        modified = models.DateTimeField(blank=False, null=False)
@@ -210,6 +213,34 @@ class PatchHistory(models.Model):
        class Meta:
                ordering = ('-date', )
 
+       def save_and_notify(self, prevcommitter=None,
+                                               prevreviewers=None, prevauthors=None):
+               # Save this model, and then trigger notifications if there are any. There are
+               # many different things that can trigger notifications, so try them all.
+               self.save()
+
+               recipients = []
+               recipients.extend(self.patch.subscribers.all())
+
+               # Current or previous committer wants all notifications
+               if self.patch.committer and self.patch.committer.user.userprofile.notify_all_committer:
+                       recipients.append(self.patch.committer.user)
+               if prevcommitter and prevcommitter.user.userprofile.notify_all_committer:
+                       recipients.append(prevcommitter.user)
+
+               # Current or previous reviewers wants all notifications
+               recipients.extend(self.patch.reviewers.filter(userprofile__notify_all_reviewer=True))
+               if prevreviewers:
+                       # prevreviewers is a list
+                       recipients.extend(User.objects.filter(id__in=[p.id for p in prevreviewers], userprofile__notify_all_reviewer=True))
+
+               # Current or previous authors wants all notifications
+               recipients.extend(self.patch.authors.filter(userprofile__notify_all_author=True))
+
+               for u in set(recipients):
+                       if u != self.by: # Don't notify for changes we make ourselves
+                               PendingNotification(history=self, user=u).save()
+
 class MailThread(models.Model):
        # This class tracks mail threads from the main postgresql.org
        # mailinglist archives. For each thread, we store *one* messageid.
@@ -270,3 +301,8 @@ class PatchStatus(models.Model):
        status = models.IntegerField(null=False, blank=False, primary_key=True)
        statusstring = models.TextField(max_length=50, null=False, blank=False)
        sortkey = models.IntegerField(null=False, blank=False, default=10)
+
+
+class PendingNotification(models.Model):
+       history = models.ForeignKey(PatchHistory, blank=False, null=False)
+       user = models.ForeignKey(User, blank=False, null=False)
diff --git a/pgcommitfest/commitfest/templates/mail/patch_notify.txt b/pgcommitfest/commitfest/templates/mail/patch_notify.txt
new file mode 100644 (file)
index 0000000..1ab838c
--- /dev/null
@@ -0,0 +1,11 @@
+The following patches that you follow, directly or indirectly,
+have received updates in the PostgreSQL commitfest app:
+
+{%for p in patches.values %}
+{{p.patch.name}}
+https://commitfest.postgresql.org/{{p.patch.patchoncommitfest_set.all.0.commitfest.id}}/{{p.patch.id}}/
+{%for h in p.entries%}
+* {{h.what}} ({{h.by}}){%endfor%}
+
+
+{%endfor%}
index 8bace189de3722e1aae526c6623730d9c4fe379e..0f2b5be1f999630b6584872db8c96b9286876ba7 100644 (file)
          </tbody>
        </table>
        </div>
+       {%if user.is_authenticated%}
+       <a href="{{is_subscribed|yesno:"unsubscribe,subscribe"}}/" class="btn btn-default">{{is_subscribed|yesno:"Unsubscribe,Subscribe"}}</a>
+       {%endif%}
       </td>
     </tr>
   </tbody>
index b4f19b2db470e5269943418b2402ca9ddfff0dff..ddd62c9b4bc1ffe7447a5fcc5cd9b20e8e50cb14 100644 (file)
@@ -209,10 +209,12 @@ def patch(request, cfid, patchid):
                        is_committer = is_this_committer = False
 
                is_reviewer = request.user in patch.reviewers.all()
+               is_subscribed = patch.subscribers.filter(id=request.user.id).exists()
        else:
                is_committer = False
                is_this_committer = False
                is_reviewer = False
+               is_subscribed = False
 
        return render_to_response('patch.html', {
                'cf': cf,
@@ -221,6 +223,7 @@ def patch(request, cfid, patchid):
                'is_committer': is_committer,
                'is_this_committer': is_this_committer,
                'is_reviewer': is_reviewer,
+               'is_subscribed': is_subscribed,
                'committers': committers,
                'attachnow': request.GET.has_key('attachthreadnow'),
                'title': patch.name,
@@ -233,6 +236,10 @@ def patchform(request, cfid, patchid):
        cf = get_object_or_404(CommitFest, pk=cfid)
        patch = get_object_or_404(Patch, pk=patchid, commitfests=cf)
 
+       prevreviewers = list(patch.reviewers.all())
+       prevauthors = list(patch.authors.all())
+       prevcommitter = patch.committer
+
        if request.method == 'POST':
                form = PatchForm(data=request.POST, instance=patch)
                if form.is_valid():
@@ -244,7 +251,7 @@ def patchform(request, cfid, patchid):
 
                        # Track all changes
                        for field, values in r.diff.items():
-                               PatchHistory(patch=patch, by=request.user, what='Changed %s to %s' % (field, values[1])).save()
+                               PatchHistory(patch=patch, by=request.user, what='Changed %s to %s' % (field, values[1])).save_and_notify(prevcommitter=prevcommitter, prevreviewers=prevreviewers, prevauthors=prevauthors)
                        r.set_modified()
                        r.save()
                        return HttpResponseRedirect('../../%s/' % r.pk)
@@ -347,7 +354,7 @@ def comment(request, cfid, patchid, what):
                        if int(form.cleaned_data['newstatus']) != poc.status:
                                poc.status = int(form.cleaned_data['newstatus'])
                                poc.save()
-                               PatchHistory(patch=poc.patch, by=request.user, what='New status: %s' % poc.statusstring).save()
+                               PatchHistory(patch=poc.patch, by=request.user, what='New status: %s' % poc.statusstring).save_and_notify()
                                txt += "\n\nThe new status of this patch is: %s\n" % poc.statusstring
 
                        msg = MIMEText(txt, _charset='utf-8')
@@ -425,7 +432,7 @@ def status(request, cfid, patchid, status):
                poc.patch.save()
                poc.save()
 
-               PatchHistory(patch=poc.patch, by=request.user, what='New status: %s' % poc.statusstring).save()
+               PatchHistory(patch=poc.patch, by=request.user, what='New status: %s' % poc.statusstring).save_and_notify()
 
        return HttpResponseRedirect('/%s/%s/' % (poc.commitfest.id, poc.patch.id))
 
@@ -489,8 +496,9 @@ def close(request, cfid, patchid, status):
                committer = get_object_or_404(Committer, user__username=request.GET['c'])
                if committer != poc.patch.committer:
                        # Committer changed!
+                       prevcommitter = poc.patch.committer
                        poc.patch.committer = committer
-                       PatchHistory(patch=poc.patch, by=request.user, what='Changed committer to %s' % committer).save()
+                       PatchHistory(patch=poc.patch, by=request.user, what='Changed committer to %s' % committer).save_and_notify(prevcommitter=prevcommitter)
                poc.status = PatchOnCommitFest.STATUS_COMMITTED
        else:
                raise Exception("Can't happen")
@@ -499,7 +507,7 @@ def close(request, cfid, patchid, status):
        poc.patch.save()
        poc.save()
 
-       PatchHistory(patch=poc.patch, by=request.user, what='Closed in commitfest %s with status: %s' % (poc.commitfest, poc.statusstring)).save()
+       PatchHistory(patch=poc.patch, by=request.user, what='Closed in commitfest %s with status: %s' % (poc.commitfest, poc.statusstring)).save_and_notify()
 
        return HttpResponseRedirect('/%s/%s/' % (poc.commitfest.id, poc.patch.id))
 
@@ -514,11 +522,11 @@ def reviewer(request, cfid, patchid, status):
        if status=='become' and not is_reviewer:
                patch.reviewers.add(request.user)
                patch.set_modified()
-               PatchHistory(patch=patch, by=request.user, what='Added %s as reviewer' % request.user.username).save()
+               PatchHistory(patch=patch, by=request.user, what='Added %s as reviewer' % request.user.username).save_and_notify()
        elif status=='remove' and is_reviewer:
                patch.reviewers.remove(request.user)
                patch.set_modified()
-               PatchHistory(patch=patch, by=request.user, what='Removed %s from reviewers' % request.user.username).save()
+               PatchHistory(patch=patch, by=request.user, what='Removed %s from reviewers' % request.user.username).save_and_notify()
        return HttpResponseRedirect('../../')
 
 @login_required
@@ -534,17 +542,33 @@ def committer(request, cfid, patchid, status):
 
        is_committer = committer == patch.committer
 
+       prevcommitter = patch.committer
        if status=='become' and not is_committer:
                patch.committer = committer
                patch.set_modified()
-               PatchHistory(patch=patch, by=request.user, what='Added %s as committer' % request.user.username).save()
+               PatchHistory(patch=patch, by=request.user, what='Added %s as committer' % request.user.username).save_and_notify(prevcommitter=prevcommitter)
        elif status=='remove' and is_committer:
                patch.committer = None
                patch.set_modified()
-               PatchHistory(patch=patch, by=request.user, what='Removed %s from committers' % request.user.username).save()
+               PatchHistory(patch=patch, by=request.user, what='Removed %s from committers' % request.user.username).save_and_notify(prevcommitter=prevcommitter)
        patch.save()
        return HttpResponseRedirect('../../')
 
+@login_required
+@transaction.atomic
+def subscribe(request, cfid, patchid, sub):
+       get_object_or_404(CommitFest, pk=cfid)
+       patch = get_object_or_404(Patch, pk=patchid)
+
+       if sub == 'un':
+               patch.subscribers.remove(request.user)
+               messages.info(request, "You have been unsubscribed from updates on this patch")
+       else:
+               patch.subscribers.add(request.user)
+               messages.info(request, "You have been subscribed to updates on this patch")
+       patch.save()
+       return HttpResponseRedirect("../")
+
 @login_required
 @transaction.atomic
 def send_email(request, cfid):
index 525f0687ae3dbd7a88894d8d6c3e69141e82318f..b0601c6da12a88e2a5aaa8fdd8e3aa9816499b9a 100644 (file)
@@ -18,7 +18,8 @@ def send_simple_mail(sender, receiver, subject, msgtxt, sending_username, attach
        msg['From'] = sender
        msg['Date'] = formatdate(localtime=True)
        msg['User-Agent'] = 'pgcommitfest'
-       msg['X-cfsender'] = sending_username
+       if sending_username:
+               msg['X-cfsender'] = sending_username
 
        msg.attach(MIMEText(msgtxt, _charset='utf-8'))
 
index 952d8feb0544e8effbc7547b4cf4934f190e3113..cdf5f6090ae4f43da100b98593cd17db11fb0c07 100644 (file)
@@ -18,6 +18,7 @@ urlpatterns = patterns('',
     url(r'^(\d+)/(\d+)/close/(reject|feedback|committed|next)/$', 'pgcommitfest.commitfest.views.close'),
     url(r'^(\d+)/(\d+)/reviewer/(become|remove)/$', 'pgcommitfest.commitfest.views.reviewer'),
     url(r'^(\d+)/(\d+)/committer/(become|remove)/$', 'pgcommitfest.commitfest.views.committer'),
+    url(r'^(\d+)/(\d+)/(un)?subscribe/$', 'pgcommitfest.commitfest.views.subscribe'),
     url(r'^(\d+)/(\d+)/(comment|review)/', 'pgcommitfest.commitfest.views.comment'),
     url(r'^(\d+)/send_email/$', 'pgcommitfest.commitfest.views.send_email'),
     url(r'^(\d+)/\d+/send_email/$', 'pgcommitfest.commitfest.views.send_email'),
index b7db7ad8d10a2a09f4ece1271b9538881f156d93..b177ef13231227ca5da734a5153534c1dfaea08b 100644 (file)
@@ -14,6 +14,8 @@ class UserProfileForm(forms.ModelForm):
 
                self.fields['selectedemail'].empty_label=self.user.email
                self.fields['selectedemail'].queryset=UserExtraEmail.objects.filter(user=self.user, confirmed=True)
+               self.fields['notifyemail'].empty_label=self.user.email
+               self.fields['notifyemail'].queryset=UserExtraEmail.objects.filter(user=self.user, confirmed=True)
 
 class MailForm(forms.Form):
        email = forms.EmailField()
diff --git a/pgcommitfest/userprofile/migrations/0002_notifications.py b/pgcommitfest/userprofile/migrations/0002_notifications.py
new file mode 100644 (file)
index 0000000..4800811
--- /dev/null
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+from django.conf import settings
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('userprofile', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='userprofile',
+            name='notify_all_author',
+            field=models.BooleanField(default=False, verbose_name=b'Notify on all where author'),
+        ),
+        migrations.AddField(
+            model_name='userprofile',
+            name='notify_all_committer',
+            field=models.BooleanField(default=False, verbose_name=b'Notify on all where committer'),
+        ),
+        migrations.AddField(
+            model_name='userprofile',
+            name='notify_all_reviewer',
+            field=models.BooleanField(default=False, verbose_name=b'Notify on all where reviewer'),
+        ),
+        migrations.AddField(
+            model_name='userprofile',
+            name='notifyemail',
+            field=models.ForeignKey(related_name='notifier', verbose_name=b'Notifications sent to', blank=True, to='userprofile.UserExtraEmail', null=True),
+        ),
+        migrations.AlterField(
+            model_name='userprofile',
+            name='user',
+            field=models.OneToOneField(to=settings.AUTH_USER_MODEL),
+        ),
+    ]
index c5a982cd8b81d45e7a0a6bf5bf3ba895d5795cfc..f5e58872eb6d742f97853a26f0577e6772aeb8d0 100644 (file)
@@ -17,9 +17,15 @@ class UserExtraEmail(models.Model):
 
 
 class UserProfile(models.Model):
-       user = models.ForeignKey(User, null=False, blank=False)
+       user = models.OneToOneField(User, null=False, blank=False)
        selectedemail = models.ForeignKey(UserExtraEmail, null=True, blank=True,
                                                                          verbose_name='Sender email')
+       notifyemail = models.ForeignKey(UserExtraEmail, null=True, blank=True,
+                                                                       verbose_name='Notifications sent to',
+                                                                       related_name='notifier')
+       notify_all_author = models.BooleanField(null=False, blank=False, default=False, verbose_name="Notify on all where author")
+       notify_all_reviewer = models.BooleanField(null=False, blank=False, default=False, verbose_name="Notify on all where reviewer")
+       notify_all_committer = models.BooleanField(null=False, blank=False, default=False, verbose_name="Notify on all where committer")
 
        def __unicode__(self):
                return unicode(self.user)
index 9e6db7fd533e4023ae378147c89680b4f9a0ec73..da8b73446c1b7dd9c04856bcf3194f0a850c8615 100644 (file)
@@ -12,8 +12,8 @@
  {%for field in form%}
  {%if not field.is_hidden%}
  <div class="form-group">
-   {{field|label_class:"control-label col-lg-1"}}
-   <div class="col-lg-11 controls">
+   {{field|label_class:"control-label col-lg-2"}}
+   <div class="col-lg-10 controls">
   {%if field.errors %}
    {%for e in field.errors%}
  <div class="alert alert-danger">{{e}}</div>