Simplify community lists further by letting email subscriptions reuse

the existing infrastructure for accounts and emails, instead of a
having a separate confirmation step
 - Legacy-Id: 10951
This commit is contained in:
Ole Laursen 2016-03-17 12:02:45 +00:00
parent 5f4082d595
commit cdcad43fc0
16 changed files with 158 additions and 226 deletions

View file

@ -88,25 +88,24 @@ class SearchRuleForm(forms.ModelForm):
f.required = True
class SubscriptionForm(forms.Form):
notify_on = forms.ChoiceField(choices=[("all", "All changes"), ("significant", "Only significant state changes")], widget=forms.RadioSelect, initial="all")
email = forms.EmailField(label="Your email")
def __init__(self, operation, clist, *args, **kwargs):
self.operation = operation
class SubscriptionForm(forms.ModelForm):
def __init__(self, user, clist, *args, **kwargs):
self.clist = clist
self.user = user
super(SubscriptionForm, self).__init__(*args, **kwargs)
if operation == "subscribe":
self.fields["notify_on"].label = "Get notified on"
else:
self.fields["notify_on"].label = "For notifications on"
self.fields["notify_on"].widget = forms.RadioSelect(choices=self.fields["notify_on"].choices)
self.fields["email"].queryset = self.fields["email"].queryset.filter(person__user=user, active=True).order_by("-primary")
self.fields["email"].widget = forms.RadioSelect(choices=[t for t in self.fields["email"].choices if t[0]])
if self.fields["email"].queryset:
self.fields["email"].initial = self.fields["email"].queryset[0]
def clean(self):
if self.operation == "subscribe":
if EmailSubscription.objects.filter(community_list=self.clist, email=self.cleaned_data["email"], significant=self.cleaned_data["notify_on"] == "significant").exists():
raise forms.ValidationError("This email address is already subscribed.")
else:
if not EmailSubscription.objects.filter(community_list=self.clist, email=self.cleaned_data["email"], significant=self.cleaned_data["notify_on"] == "significant").exists():
raise forms.ValidationError("Couldn't find a matching subscription?")
if EmailSubscription.objects.filter(community_list=self.clist, email=self.cleaned_data["email"], notify_on=self.cleaned_data["notify_on"]).exists():
raise forms.ValidationError("You already have a subscription like this.")
class Meta:
model = EmailSubscription
fields = ("notify_on", "email")

View file

@ -105,4 +105,10 @@ class Migration(migrations.Migration):
name='searchrule',
unique_together=set([]),
),
migrations.AddField(
model_name='emailsubscription',
name='notify_on',
field=models.CharField(default=b'all', max_length=30, choices=[(b'all', b'All changes'), (b'significant', b'Only significant state changes')]),
preserve_default=True,
),
]

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
from django.db import migrations, models
def port_rules_to_typed_system(apps, schema_editor):
SearchRule = apps.get_model("community", "SearchRule")
@ -163,6 +163,37 @@ def get_rid_of_empty_lists(apps, schema_editor):
if not cl.added_docs.exists() and not cl.searchrule_set.exists() and not cl.emailsubscription_set.exists():
cl.delete()
def move_email_subscriptions_to_preregistered_email(apps, schema_editor):
EmailSubscription = apps.get_model("community", "EmailSubscription")
Email = apps.get_model("person", "Email")
Person = apps.get_model("person", "Person")
for e in EmailSubscription.objects.all():
email_obj = None
try:
email_obj = Email.objects.get(address=e.email)
except Email.DoesNotExist:
if e.community_list.user:
person = Person.objects.filter(user=e.community_list.user).first()
#print "creating", e.email, person.ascii
# we'll register it on the user, on the assumption
# that the user and the subscriber is the same person
email_obj = Email.objects.create(
address=e.email,
person=person,
)
if not email_obj:
print "deleting", e.email
e.delete()
def fill_in_notify_on(apps, schema_editor):
EmailSubscription = apps.get_model("community", "EmailSubscription")
EmailSubscription.objects.filter(significant=False, notify_on="all")
EmailSubscription.objects.filter(significant=True, notify_on="significant")
def noop(apps, schema_editor):
pass
@ -175,9 +206,21 @@ class Migration(migrations.Migration):
operations = [
migrations.RunPython(port_rules_to_typed_system, delete_extra_person_rules),
migrations.RunPython(rename_rule_type_forwards, rename_rule_type_backwards),
migrations.RunPython(move_email_subscriptions_to_preregistered_email, noop),
migrations.RunPython(get_rid_of_empty_lists, noop),
migrations.RunPython(fill_in_notify_on, noop),
migrations.RemoveField(
model_name='searchrule',
name='value',
),
migrations.AlterField(
model_name='emailsubscription',
name='email',
field=models.ForeignKey(to='person.Email'),
preserve_default=True,
),
migrations.RemoveField(
model_name='emailsubscription',
name='significant',
),
]

View file

@ -4,7 +4,7 @@ from django.db.models import signals
from ietf.doc.models import Document, DocEvent, State
from ietf.group.models import Group
from ietf.person.models import Person
from ietf.person.models import Person, Email
class CommunityList(models.Model):
user = models.ForeignKey(User, blank=True, null=True)
@ -62,11 +62,16 @@ class SearchRule(models.Model):
class EmailSubscription(models.Model):
community_list = models.ForeignKey(CommunityList)
email = models.CharField(max_length=200)
significant = models.BooleanField(default=False)
email = models.ForeignKey(Email)
NOTIFICATION_CHOICES = [
("all", "All changes"),
("significant", "Only significant state changes")
]
notify_on = models.CharField(max_length=30, choices=NOTIFICATION_CHOICES, default="all")
def __unicode__(self):
return u"%s to %s (%s changes)" % (self.email, self.community_list, "significant" if self.significant else "all")
return u"%s to %s (%s changes)" % (self.email, self.community_list, self.notify_on)
def notify_events(sender, instance, **kwargs):

View file

@ -51,8 +51,8 @@ class EmailSubscriptionResource(ModelResource):
#resource_name = 'emailsubscription'
filtering = {
"id": ALL,
"email": ALL,
"significant": ALL,
"email": ALL_WITH_RELATIONS,
"notify_on": ALL,
"community_list": ALL_WITH_RELATIONS,
}
api.community.register(EmailSubscriptionResource())

View file

@ -9,7 +9,7 @@ from ietf.community.models import CommunityList, SearchRule, EmailSubscription
from ietf.community.utils import docs_matching_community_list_rule, community_list_rules_matching_doc
from ietf.doc.models import State
from ietf.doc.utils import add_state_change_event
from ietf.person.models import Person
from ietf.person.models import Person, Email
from ietf.utils.test_data import make_test_data
from ietf.utils.test_utils import login_testing_unauthorized, TestCase
from ietf.utils.mail import outbox
@ -217,28 +217,18 @@ class CommunityListTests(TestCase):
self.assertEqual(r.status_code, 200)
self.assertTrue('<entry>' not in r.content)
def extract_confirm_url(self, confirm_email):
# dig out confirm_email link
msg = confirm_email.get_payload(decode=True)
line_start = "http"
confirm_url = None
for line in msg.split("\n"):
if line.strip().startswith(line_start):
confirm_url = line.strip()
self.assertTrue(confirm_url)
return confirm_url
def test_subscription(self):
draft = make_test_data()
url = urlreverse("community_personal_subscription", kwargs={ "operation": "subscribe", "username": "plain" })
url = urlreverse("community_personal_subscription", kwargs={ "username": "plain" })
# subscribe without list
login_testing_unauthorized(self, "plain", url)
# subscription without list
r = self.client.get(url)
self.assertEqual(r.status_code, 404)
# subscribe with list
# subscription with list
clist = CommunityList.objects.create(user=User.objects.get(username="plain"))
clist.added_docs.add(draft)
SearchRule.objects.create(
@ -250,42 +240,19 @@ class CommunityListTests(TestCase):
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
# do subscribe
mailbox_before = len(outbox)
r = self.client.post(url, { "email": "subscriber@example.com", "notify_on": "significant" })
self.assertEqual(r.status_code, 200)
self.assertEqual(len(outbox), mailbox_before + 1)
# go to confirm page
confirm_url = self.extract_confirm_url(outbox[-1])
r = self.client.get(confirm_url)
self.assertEqual(r.status_code, 200)
# confirm subscribe
r = self.client.post(confirm_url, { 'action': 'confirm' })
# subscribe
email = Email.objects.filter(person__user__username="plain").first()
r = self.client.post(url, { "email": email.pk, "notify_on": "significant", "action": "subscribe" })
self.assertEqual(r.status_code, 302)
self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email="subscriber@example.com", significant=True).count(), 1)
# unsubscribe
url = urlreverse("community_personal_subscription", kwargs={ "operation": "unsubscribe", "username": "plain" })
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
subscription = EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").first()
# do unsubscribe
mailbox_before = len(outbox)
r = self.client.post(url, { "email": "subscriber@example.com", "notify_on": "significant" })
self.assertEqual(r.status_code, 200)
self.assertEqual(len(outbox), mailbox_before + 1)
self.assertTrue(subscription)
# go to confirm page
confirm_url = self.extract_confirm_url(outbox[-1])
r = self.client.get(confirm_url)
self.assertEqual(r.status_code, 200)
# confirm unsubscribe
r = self.client.post(confirm_url, { 'action': 'confirm' })
# delete subscription
r = self.client.post(url, { "subscription_id": subscription.pk, "action": "unsubscribe" })
self.assertEqual(r.status_code, 302)
self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email="subscriber@example.com", significant=True).count(), 0)
self.assertEqual(EmailSubscription.objects.filter(community_list=clist, email=email, notify_on="significant").count(), 0)
def test_notification(self):
draft = make_test_data()
@ -299,7 +266,7 @@ class CommunityListTests(TestCase):
text="test",
)
EmailSubscription.objects.create(community_list=clist, email="subscriber@example.com", significant=True)
EmailSubscription.objects.create(community_list=clist, email=Email.objects.filter(person__user__username="plain").first(), notify_on="significant")
mailbox_before = len(outbox)
active_state = State.objects.get(type="draft", slug="active")

View file

@ -8,8 +8,7 @@ urlpatterns = patterns('ietf.community.views',
url(r'^personal/(?P<username>[^/]+)/untrackdocument/(?P<name>[^/]+)/$', 'untrack_document', name='community_personal_untrack_document'),
url(r'^personal/(?P<username>[^/]+)/csv/$', 'export_to_csv', name='community_personal_csv'),
url(r'^personal/(?P<username>[^/]+)/feed/$', 'feed', name='community_personal_feed'),
url(r'^personal/(?P<username>[^/]+)/(?P<operation>subscribe|unsubscribe)/$', 'subscription', name='community_personal_subscription'),
url(r'^personal/(?P<username>[^/]+)/(?P<operation>subscribe|unsubscribe)/confirm/(?P<auth>[^/]+)/$', 'confirm_subscription', name='community_personal_confirm_subscription'),
url(r'^personal/(?P<username>[^/]+)/subscription/$', 'subscription', name='community_personal_subscription'),
url(r'^group/(?P<acronym>[\w.@+-]+)/$', 'view_list', name='community_group_view_list'),
url(r'^group/(?P<acronym>[\w.@+-]+)/manage/$', 'manage_list', name='community_group_manage_list'),
@ -17,6 +16,5 @@ urlpatterns = patterns('ietf.community.views',
url(r'^group/(?P<acronym>[\w.@+-]+)/untrackdocument/(?P<name>[^/]+)/$', 'untrack_document', name='community_group_untrack_document'),
url(r'^group/(?P<acronym>[\w.@+-]+)/csv/$', 'export_to_csv', name='community_group_csv'),
url(r'^group/(?P<acronym>[\w.@+-]+)/feed/$', 'feed', name='community_group_feed'),
url(r'^group/(?P<acronym>[^/]+)/(?P<operation>subscribe|unsubscribe)/$', 'subscription', name='community_group_subscription'),
url(r'^group/(?P<acronym>[^/]+)/(?P<operation>subscribe|unsubscribe)/confirm/(?P<auth>[^/]+)/$', 'confirm_subscription', name='community_group_confirm_subscription'),
url(r'^group/(?P<acronym>[\w.@+-]+)/subscription/$', 'subscription', name='community_group_subscription'),
)

View file

@ -1,8 +1,5 @@
from django.db.models import Q
from django.conf import settings
from django.contrib.sites.models import Site
from django.http import Http404
import django.core.signing
from ietf.community.models import CommunityList, EmailSubscription, SearchRule
from ietf.doc.models import Document, State
@ -143,49 +140,14 @@ def notify_event_to_subscribers(event):
subscriptions = EmailSubscription.objects.filter(community_list__in=community_lists_tracking_doc(event.doc)).distinct()
if not significant:
subscriptions = subscriptions.filter(significant=False)
subscriptions = subscriptions.filter(notify_on="all")
for sub in subscriptions.select_related("community_list"):
for sub in subscriptions.select_related("community_list", "email"):
clist = sub.community_list
subject = '%s notification: Changes to %s' % (clist.long_name(), event.doc.name)
send_mail(None, sub.email, settings.DEFAULT_FROM_EMAIL, subject, 'community/notification_email.txt',
send_mail(None, sub.email.address, settings.DEFAULT_FROM_EMAIL, subject, 'community/notification_email.txt',
context = {
'event': event,
'clist': clist,
})
def confirmation_salt(operation, clist):
return ":".join(["community",
operation,
"personal" if clist.user else "group",
clist.user.username if clist.user else clist.group.acronym])
def send_subscription_confirmation_email(request, clist, operation, to_email, significant):
domain = Site.objects.get_current().domain
subject = 'Confirm list subscription: %s' % clist
from_email = settings.DEFAULT_FROM_EMAIL
auth = django.core.signing.dumps([to_email, 1 if significant else 0], salt=confirmation_salt("subscribe", clist))
send_mail(request, to_email, from_email, subject, 'community/confirm_email.txt', {
'domain': domain,
'clist': clist,
'auth': auth,
'operation': operation,
})
def verify_confirmation_data(auth, clist, operation):
try:
data = django.core.signing.loads(auth, salt=confirmation_salt(operation, clist), max_age=24 * 60 * 60)
except django.core.signing.BadSignature:
raise Http404("Invalid or expired auth")
try:
to_email, significant = data[:2]
except ValueError:
raise Http404("Invalid data")
return to_email, bool(significant)

View file

@ -14,8 +14,6 @@ from ietf.community.forms import SearchRuleTypeForm, SearchRuleForm, AddDocument
from ietf.community.utils import can_manage_community_list
from ietf.community.utils import docs_tracked_by_community_list, docs_matching_community_list_rule
from ietf.community.utils import states_of_significant_change
from ietf.community.utils import send_subscription_confirmation_email
from ietf.community.utils import verify_confirmation_data
from ietf.group.models import Group
from ietf.doc.models import DocEvent, Document
from ietf.doc.utils_search import prepare_document_table
@ -39,11 +37,14 @@ def view_list(request, username=None, acronym=None):
docs = docs_tracked_by_community_list(clist)
docs, meta = prepare_document_table(request, docs, request.GET)
subscribed = request.user.is_authenticated() and EmailSubscription.objects.filter(community_list=clist, email__person__user=request.user)
return render(request, 'community/view_list.html', {
'clist': clist,
'docs': docs,
'meta': meta,
'can_manage_list': can_manage_community_list(request.user, clist),
'subscribed': subscribed,
})
@login_required
@ -245,56 +246,34 @@ def feed(request, username=None, acronym=None):
}, content_type='text/xml')
def subscription(request, operation, username=None, acronym=None):
@login_required
def subscription(request, username=None, acronym=None):
clist = lookup_list(username, acronym)
if clist.pk is None:
raise Http404
to_email = None
if request.method == 'POST':
form = SubscriptionForm(operation, clist, request.POST)
if form.is_valid():
to_email = form.cleaned_data['email']
significant = form.cleaned_data['notify_on'] == "significant"
existing_subscriptions = EmailSubscription.objects.filter(community_list=clist, email__person__user=request.user)
send_subscription_confirmation_email(request, clist, operation, to_email, significant)
if request.method == 'POST':
action = request.POST.get("action")
if action == "subscribe":
form = SubscriptionForm(request.user, clist, request.POST)
if form.is_valid():
subscription = form.save(commit=False)
subscription.community_list = clist
subscription.save()
return HttpResponseRedirect("")
elif action == "unsubscribe":
existing_subscriptions.filter(pk=request.POST.get("subscription_id")).delete()
return HttpResponseRedirect("")
else:
form = SubscriptionForm(operation, clist)
form = SubscriptionForm(request.user, clist)
return render(request, 'community/subscription.html', {
'clist': clist,
'form': form,
'to_email': to_email,
'operation': operation,
'existing_subscriptions': existing_subscriptions,
})
def confirm_subscription(request, operation, auth, username=None, acronym=None):
clist = lookup_list(username, acronym)
if clist.pk is None:
raise Http404
to_email, significant = verify_confirmation_data(auth, clist, operation="subscribe")
if request.method == "POST" and request.POST.get("action") == "confirm":
if operation == "subscribe":
if not EmailSubscription.objects.filter(community_list=clist, email__iexact=to_email, significant=significant):
EmailSubscription.objects.create(community_list=clist, email=to_email, significant=significant)
elif operation == "unsubscribe":
EmailSubscription.objects.filter(
community_list=clist,
email__iexact=to_email,
significant=significant).delete()
if clist.group:
return redirect('community_group_view_list', acronym=clist.group.acronym)
else:
return redirect('community_personal_view_list', username=clist.user.username)
return render(request, 'community/confirm_subscription.html', {
'clist': clist,
'to_email': to_email,
'significant': significant,
'operation': operation,
})

View file

@ -10,7 +10,7 @@ urlpatterns = patterns('ietf.ietfauth.views',
url(r'^logout/$', logout),
# url(r'^loggedin/$', 'ietf_loggedin'),
# url(r'^loggedout/$', 'logged_out'),
url(r'^profile/$', 'profile'),
url(r'^profile/$', 'profile', name="account_profile"),
# (r'^login/(?P<user>[a-z0-9.@]+)/(?P<passwd>.+)$', 'url_login'),
url(r'^testemail/$', 'test_email'),
url(r'^create/$', 'create_account', name='create_account'),

View file

@ -458,3 +458,7 @@ label#list-feeds {
display: inline-block;
font-weight: normal;
}
.email-subscription button[type=submit] {
margin-left: 3em;
}

View file

@ -1,14 +0,0 @@
{% autoescape off %}
Hello,
{% filter wordwrap:73 %}In order to {% if operation == "subscribe" %}complete{% else %}cancel{% endif %} your subscription on {% if significant %}significant {% endif %}changes to {{ clist.long_name }}, please follow this link or copy it and paste it in your web browser:{% endfilter %}
https://{{ domain }}{% if clist.user %}{% url "community_personal_confirm_subscription" clist.user.username operation auth %}{% else %}{% url "community_group_confirm_subscription" operation clist.group.acronym auth %}{% endif %}
The link is valid for 24 hours.
Best regards,
The Datatracker draft tracking service
(for the IETF Secretariat)
{% endautoescape %}

View file

@ -1,19 +0,0 @@
{% extends "base.html" %}
{# Copyright The IETF Trust 2015, All Rights Reserved #}
{% load origin %}
{% block title %}Subscription to {{ clist.long_name }}{% endblock %}
{% block content %}
{% origin %}
<h1>Subscription to {{ clist.long_name }}</h1>
<p>Confirm {% if operation == "subscribe" %}subscription{% else %}cancelling subscription{% endif %} of <code>{{ to_email }}</code> to {% if significant %}significant{% endif %} changes to {{ clist.long_name }}.</p>
<form method="post">{% csrf_token %}
<p>
<a class="btn btn-default" href="{% if clist.group %}{% url "community_group_view_list" clist.group.acronym %}{% else %}{% url "community_personal_view_list" clist.user.username %}{% endif %}">Back to list</a>
<button class="btn btn-primary" type="submit" name="action" value="confirm">Confirm</button>
</p>
</form>
{% endblock %}

View file

@ -9,17 +9,36 @@
{% block content %}
{% origin %}
{% if not to_email %}
<h1>Subscription to {{ clist.long_name }}</h1>
<h1>Subscription to {{ clist.long_name }}</h1>
{% bootstrap_messages %}
{% bootstrap_messages %}
{% if operation == "subscribe" %}
<p>Get notified when changes happen to any of the tracked documents.</p>
{% else %}
<p>Unsubscribe from getting notified when changes happen to any of the tracked documents.</p>
{% endif %}
<p>Get notified when changes happen to any of the tracked documents.</p>
{% if existing_subscriptions %}
<h2>Existing subscriptions</h2>
<ul class="list-group">
{% for s in existing_subscriptions %}
<li class="list-group-item email-subscription">
<form method="post">
{% csrf_token %}
<code>{{ s.email.address }}</code> - {{ s.get_notify_on_display }}
<input type="hidden" name="subscription_id" value="{{ s.pk }}">
<button class="btn btn-danger btn-sm" type="submit" name="action" value="unsubscribe">Unsubscribe</button>
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<p><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_view_list" clist.group.acronym %}{% else %}{% url "community_personal_view_list" clist.user.username %}{% endif%}">Back to list</a></p>
<h2>Add new subscription</h2>
<p class="text-muted">The email addresses you can choose between are those registered in <a href="{% url "account_profile" %}">your profile</a>.</p>
{% if form.fields.email.queryset %}
<form method="post">
{% csrf_token %}
{% bootstrap_form form %}
@ -27,17 +46,10 @@
{% buttons %}
<a class="btn btn-default" href="{% if clist.group %}{% url "community_group_view_list" clist.group.acronym %}{% else %}{% url "community_personal_view_list" clist.user.username %}{% endif%}">Back to list</a>
<button type="submit" class="btn btn-primary">{% if operation == "subscribe" %}Subscribe{% else %}Unsubscribe{% endif %}</button>
<button type="submit" name="action" value="subscribe" class="btn btn-primary">Subscribe</button>
{% endbuttons %}
</form>
{% else %}
<h1>Sent confirmation email</h1>
<p>A message has been sent to <code>{{ to_email }}</code> with
a link for confirming {% if operation == subscribe %}the subscription{% else %}cancelling the subscription{% endif %}.</p>
<p>
<a class="btn btn-default" href="{% if clist.group %}{% url "community_group_view_list" clist.group.acronym %}{% else %}{% url "community_personal_view_list" clist.user.username %}{% endif%}">Back to list</a>
</p>
<div class="alert alert-danger">You do not have any active email addresses registered with your account. Go to <a href="{% url "account_profile" %}">your profile and add or activate one</a>.</div>
{% endif %}
{% endblock %}

View file

@ -1,12 +0,0 @@
{% autoescape off %}
Hello,
In order to complete the cancelation of your subscription to {% if significant %}significant {% endif %}changes on {{ clist.long_name }}, please follow this link or copy it and paste it in your web browser:
https://{{ domain }}{% if significant %}{% url "confirm_significant_unsubscription" clist.id to_email today auth %}{% else %}{% url "confirm_unsubscription" clist.id to_email today auth %}{% endif %}
Best regards,
The datatracker login manager service
(for the IETF Secretariat)
{% endautoescape %}

View file

@ -17,9 +17,11 @@
{% endif %}
{% if clist.pk != None %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_subscribe" clist.group.acronym %}{% else %}{% url "community_personal_subscription" clist.user.username "subscribe" %}{% endif%}">Subscribe to changes</a></li>
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_subscribe" clist.group.acronym %}{% else %}{% url "community_personal_subscription" clist.user.username "unsubscribe" %}{% endif%}">Unsubscribe</a></li>
{% if subscribed %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_subscribe" clist.group.acronym %}{% else %}{% url "community_personal_subscription" clist.user.username %}{% endif%}">Change subscription</a></li>
{% else %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_subscribe" clist.group.acronym %}{% else %}{% url "community_personal_subscription" clist.user.username %}{% endif%}">Subscribe to changes</a></li>
{% endif %}
{% endif %}
<li><a class="btn btn-default" href="{% if clist.group %}{% url "community_group_csv" clist.group.acronym %}{% else %}{% url "community_personal_csv" clist.user.username %}{% endif%}">Export as CSV</a></li>